feat(rhema-code): add the colors setting with four palettes
The stored `colors` key names a palette — rhema (the default, Jadeite), aquamarine, black-and-white, or dracula — and the Appearance page of /settings cycles it live, like the theme and icon toggles beside it. Each palette carries a dark and a light appearance under the existing `theme` key; dracula's light side is derived, the official palette ships only dark. The appearance decision moves off token comparison: Theme now answers is_dark() from the ground/text luminance, so palettes beyond the original two get the right per-profile fallback tones.
This commit is contained in:
commit
81da26b955
159 files changed
+50628
No files matched your search
@@ -0,0 +1,112 @@
|
|||||||
|
name: android
|
||||||
|
|
||||||
|
# The phone client's pipeline, separate from the rust/chart/docker/web
|
||||||
|
# gates: it needs its own toolchain (JDK + the Android SDK), and the
|
||||||
|
# release job is the only CI job holding a secret. Both artifacts feed
|
||||||
|
# the update story: the debug one must install over any local build
|
||||||
|
# (committed debug key), the release one anchors the future edge
|
||||||
|
# channel (secret key, never in the source).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
debug:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://gitea.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up JDK 21
|
||||||
|
run: |
|
||||||
|
sudo apt-get update && sudo apt-get install -y openjdk-21-jdk-headless
|
||||||
|
echo "JAVA_HOME=$(dirname "$(dirname "$(readlink -f "$(which java)")")")" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Set up the Android SDK
|
||||||
|
run: |
|
||||||
|
sudo apt-get update && sudo apt-get install -y wget unzip
|
||||||
|
mkdir -p "$HOME/android-sdk/cmdline-tools"
|
||||||
|
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \
|
||||||
|
-O /tmp/cmdline-tools.zip
|
||||||
|
unzip -q /tmp/cmdline-tools.zip -d "$HOME/android-sdk/cmdline-tools"
|
||||||
|
mv "$HOME/android-sdk/cmdline-tools/cmdline-tools" \
|
||||||
|
"$HOME/android-sdk/cmdline-tools/latest"
|
||||||
|
yes | "$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null || true
|
||||||
|
echo "ANDROID_HOME=$HOME/android-sdk" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build and lint
|
||||||
|
run: |
|
||||||
|
cd android
|
||||||
|
./gradlew :app:assembleDebug :app:lintDebug
|
||||||
|
|
||||||
|
# The artifact must install over any previous debug build, so the
|
||||||
|
# identity is part of the contract: pin the committed keystore's
|
||||||
|
# certificate, not merely "some debug key".
|
||||||
|
- name: Verify the APK signature
|
||||||
|
run: |
|
||||||
|
APKSIGNER=$(find "$ANDROID_HOME/build-tools" -name apksigner | sort | tail -1)
|
||||||
|
"$APKSIGNER" verify --print-certs android/app/build/outputs/apk/debug/app-debug.apk \
|
||||||
|
| grep -q 13e65a1e4fb828318077784c029e8f2664dba5ef8feece01b950b6bc2b015b64
|
||||||
|
|
||||||
|
- name: Upload the debug APK
|
||||||
|
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: rhema-android-debug
|
||||||
|
path: android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
release:
|
||||||
|
# The signed release artifact is the future edge channel's payload,
|
||||||
|
# so it only exists where the secrets exist: Gitea withholds
|
||||||
|
# secrets from fork pull_request runs, and an unsigned artifact
|
||||||
|
# would be a build check, not a deliverable.
|
||||||
|
if: gitea.event_name != 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://gitea.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up JDK 21
|
||||||
|
run: |
|
||||||
|
sudo apt-get update && sudo apt-get install -y openjdk-21-jdk-headless
|
||||||
|
echo "JAVA_HOME=$(dirname "$(dirname "$(readlink -f "$(which java)")")")" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Set up the Android SDK
|
||||||
|
run: |
|
||||||
|
sudo apt-get update && sudo apt-get install -y wget unzip
|
||||||
|
mkdir -p "$HOME/android-sdk/cmdline-tools"
|
||||||
|
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \
|
||||||
|
-O /tmp/cmdline-tools.zip
|
||||||
|
unzip -q /tmp/cmdline-tools.zip -d "$HOME/android-sdk/cmdline-tools"
|
||||||
|
mv "$HOME/android-sdk/cmdline-tools/cmdline-tools" \
|
||||||
|
"$HOME/android-sdk/cmdline-tools/latest"
|
||||||
|
yes | "$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null || true
|
||||||
|
echo "ANDROID_HOME=$HOME/android-sdk" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Decode the release keystore
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.ANDROID_RELEASE_KEYSTORE }}" | base64 -d > /tmp/release.keystore
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
cd android
|
||||||
|
./gradlew :app:assembleRelease
|
||||||
|
env:
|
||||||
|
ANDROID_RELEASE_KEYSTORE: /tmp/release.keystore
|
||||||
|
ANDROID_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }}
|
||||||
|
|
||||||
|
# Same contract as the debug artifact, tighter stakes: this key
|
||||||
|
# can update any install that trusts the channel.
|
||||||
|
- name: Verify the APK signature
|
||||||
|
run: |
|
||||||
|
APKSIGNER=$(find "$ANDROID_HOME/build-tools" -name apksigner | sort | tail -1)
|
||||||
|
"$APKSIGNER" verify --print-certs android/app/build/outputs/apk/release/app-release.apk \
|
||||||
|
| grep -q 8bd94fd4c2fe69557ccde6dd2e5c786c5288420674044ffe2c3fc2c55eb8f4f7
|
||||||
|
|
||||||
|
- name: Upload the release APK
|
||||||
|
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: rhema-android-release
|
||||||
|
path: android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Valentin Haudiquet
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
/local.properties
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
# Rhema Android
|
||||||
|
|
||||||
|
The phone client of the claw (the resident agent daemon): Kotlin + Jetpack
|
||||||
|
Compose, single module, deliberately small. It chats with the agent, reports
|
||||||
|
phone status as events, shows the agent's notifications as system
|
||||||
|
notifications, executes the agent's phone commands (DND, alerts), and
|
||||||
|
manages the device fleet the claw keeps.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Settings**: a nav of submenus mirroring the web UI's settings
|
||||||
|
sidebar — the "Phone" group (Status reporting, Devices) and the "Claw"
|
||||||
|
group (Model providers, Connection), one page per entry behind a back
|
||||||
|
arrow. First launch is the exception: it lands straight on the
|
||||||
|
connection page until a base URL is saved and validated with
|
||||||
|
`GET /healthz`. Stores the claw base URL and bearer token
|
||||||
|
(plus a generated, persistent device ID) in app-private DataStore. The
|
||||||
|
**Devices** page is the **Phones** card, **Status reporting** and
|
||||||
|
**Model providers** their own pages (both described below): the
|
||||||
|
backend instances (`GET /v1/providers`), each one's live model
|
||||||
|
listing, and the active pair staged as a draft behind an explicit save
|
||||||
|
(`PUT /v1/providers/active`; it applies from the claw's next turn).
|
||||||
|
Trade-off: the token is stored in plain DataStore rather than
|
||||||
|
EncryptedSharedPreferences; `androidx.security:security-crypto` is an
|
||||||
|
alpha-quality, unmaintained artifact, and the OS app sandbox already covers
|
||||||
|
the realistic threat (other apps). Backups that could exfiltrate the file
|
||||||
|
are disabled in the manifest (`allowBackup=false`,
|
||||||
|
`dataExtractionRules`, `fullBackupContent`).
|
||||||
|
- **Chat**: session list (`GET /v1/sessions`, interactive sessions first,
|
||||||
|
the phone's own `phone:*` status keys hidden)
|
||||||
|
and one chat screen per session. The applied events persist in a
|
||||||
|
per-session cache (DataStore, newest 500 with the full count as the
|
||||||
|
cursor), so a screen opens on its cached transcript at once and the
|
||||||
|
stream connects with `?since=<count>` to receive only what is new
|
||||||
|
instead of replaying the whole history; the cache flushes at every
|
||||||
|
`turn_done` and when the screen closes. While a chat screen is open the
|
||||||
|
app holds one SSE connection (`GET /v1/sessions/{key}/events`, OkHttp
|
||||||
|
streaming GET with the auth header, hand-parsed frames: blank-line
|
||||||
|
delimited `data:` lines, `:` comment keep-alives skipped, unknown event
|
||||||
|
types skipped). The reader's own messages render as right-anchored
|
||||||
|
bubbles (elevated surface, accent bar on the trailing edge); the
|
||||||
|
agent's turns keep the avatar on the left. The
|
||||||
|
composer POSTs `{"text"}`; the claw's documented dance is preserved:
|
||||||
|
**subscribe first, then POST**, the reply arrives on the stream. Thinking
|
||||||
|
deltas render dim and collapsible, tool rows show name/summary with error
|
||||||
|
coloring (bash-like tools render as shell lines instead: dim `$` prefix,
|
||||||
|
the command line, and an exit-code badge), pasted payloads embedded in a
|
||||||
|
message as paired `U+FFFC` marks render as compact `▣N 5L` chips, and
|
||||||
|
`attachment` events render as cards: file name, size, caption, an inline
|
||||||
|
preview for small (≤ 2 MB) images, and a download-and-open row (bytes
|
||||||
|
from `GET /v1/attachments/{id}` land in the app cache and open through a
|
||||||
|
FileProvider `ACTION_VIEW`, the only way another app can read a private
|
||||||
|
cache file). The agent's `consent_request` questions (a secret, or an
|
||||||
|
approval of a phone control action) render as cards answered in place —
|
||||||
|
`POST`/`DELETE /v1/consents/{id}` — and `consent_resolved` settles them
|
||||||
|
(a reconnect's replay rebuilds the settled states). The streaming reply
|
||||||
|
is finalized by `turn_done`. The chat renders the claw's presentation identity
|
||||||
|
(`GET /v1/agent`, cached in DataStore, refreshed per app open and on the session
|
||||||
|
list's refresh): the avatar image when one is set (`GET /v1/agent/avatar`), else
|
||||||
|
the initials, the name's first letter, or R; the agent's name labels every turn,
|
||||||
|
falling back to "rhema". Replies
|
||||||
|
render
|
||||||
|
as markdown (`com.mikepenz:multiplatform-markdown-renderer`, GFM flavour:
|
||||||
|
raw HTML stays literal, so model output never mounts as markup; the
|
||||||
|
streaming cursor rides the source text). The transcript is a reversed
|
||||||
|
LazyColumn: the newest turn anchors to the bottom edge, so streamed
|
||||||
|
blocks stay on screen and a reader scrolled into history is not yanked
|
||||||
|
back.
|
||||||
|
- **Phone status source**: a WorkManager periodic worker (15 min, the OS
|
||||||
|
floor, `NetworkType.CONNECTED`) reads every sensor into one snapshot and
|
||||||
|
POSTs **only transitions** vs the last successfully-posted snapshot
|
||||||
|
(persisted in DataStore) to `POST /v1/ingest`. Each sensor has its own
|
||||||
|
transition rule, tuned to what would otherwise spam:
|
||||||
|
|
||||||
|
| Topic | Posts when | Needs |
|
||||||
|
|---|---|---|
|
||||||
|
| `battery` | charging flip, or decile bucket change (`level/10`) | nothing |
|
||||||
|
| `network` | `wifi`/`metered`/`offline` changes | nothing |
|
||||||
|
| `dnd` | on/off changes | nothing |
|
||||||
|
| `location` | moved 300 m or more since the last posted fix | location permission |
|
||||||
|
| `app` | the foreground package changes | usage access grant |
|
||||||
|
| `device` | the identity fingerprint changes (basically once, and on app or OS updates) | nothing |
|
||||||
|
| `wifi` | the connected SSID changes | location permission |
|
||||||
|
| `alarm` | the next alarm moves | nothing |
|
||||||
|
| `heartbeat` | always, when a run produced no other post | nothing |
|
||||||
|
|
||||||
|
A sensor that is ungranted or unreadable produces no posts for its
|
||||||
|
topic, never an error. Rich readings carry their own honesty: a
|
||||||
|
location payload includes accuracy and fix age, the app payload the
|
||||||
|
package, its label and how long it has been current. The location read
|
||||||
|
requests a fresh fix (fused on API 31+, else gps then network) with a
|
||||||
|
20 s ceiling and falls back to the newest last-known fix under 30 min;
|
||||||
|
a plain last-known read would answer with whatever another app left
|
||||||
|
cached, which on a stationary phone is where the phone was, not where
|
||||||
|
it is. The heartbeat exists for the fleet's last-seen time: the claw
|
||||||
|
stamps a device "seen" on whatever post lands, so a run with no
|
||||||
|
transitions posts one anyway and the row stays fresh at the worker's
|
||||||
|
cadence instead of the last flip.
|
||||||
|
|
||||||
|
Every report is an observation on the claw: it lands in the phone
|
||||||
|
tables (the ingest answers `{"ingested": "observed"}`) and a `phone`
|
||||||
|
tool status question reads it. No report dispatches a turn, so a
|
||||||
|
commute or a session of app-hopping is never a model call per move,
|
||||||
|
and a battery flip never becomes a push notification.
|
||||||
|
|
||||||
|
Charging flips additionally kick a one-shot worker from a process-lifetime
|
||||||
|
broadcast receiver (`ACTION_POWER_CONNECTED|DISCONNECTED`,
|
||||||
|
`ACTION_BATTERY_CHANGED`). Manifest registration for those actions is
|
||||||
|
refused by the OS (implicit broadcasts, API 26+), so the receiver is
|
||||||
|
registered dynamically in the Application and stays dumb: it only
|
||||||
|
enqueues work.
|
||||||
|
- **Phone fleet**: the Settings screen's **Phones** card is the
|
||||||
|
management surface over the claw's registry (`GET /v1/phones`), not a
|
||||||
|
private view: every device the claw has heard from, its self-reported
|
||||||
|
label (the `device` topic's manufacturer and model), its last-seen
|
||||||
|
time, and a one-line state. This install's row is marked; every other
|
||||||
|
row carries **Forget**, which `DELETE`s the device's records on the
|
||||||
|
claw after a confirm dialog. A device id lives for one install, so a
|
||||||
|
reinstall or a cleared app data leaves the old identity behind as a
|
||||||
|
stale row with an old last-seen time; that row is what Forget is for.
|
||||||
|
A forgotten phone that still reports re-registers on its next run.
|
||||||
|
The **Status reporting** card names what this phone cannot send until
|
||||||
|
a grant is flipped (location permission, usage access, DND access)
|
||||||
|
and walks to the system toggles; the rows re-read on every ON_RESUME.
|
||||||
|
- **Phone commands**: a third periodic worker polls
|
||||||
|
`GET /v1/phone/commands?device=<id>` — the agent's consented control
|
||||||
|
queue — every 15 minutes like the other workers (that interval is the
|
||||||
|
control latency; opening the app kicks a one-shot KEEP-coalesced run, so
|
||||||
|
the queue is fresh while the phone is in hand). Commands execute in
|
||||||
|
order, each answered with `POST /v1/phone/commands/{id}/result`, which
|
||||||
|
doubles as the ack: a failed post leaves the command queued and the next
|
||||||
|
run repeats it. `dnd_on`/`dnd_off` set the interruption filter; they
|
||||||
|
need the special **Do Not Disturb access** grant (Settings → Special app
|
||||||
|
access — a settings toggle, not a permission), and without it the worker
|
||||||
|
reports that back and toggles nothing. `alert` posts a heads-up,
|
||||||
|
sound-on notification on the high-importance `rhema-alert` channel with
|
||||||
|
the args' `title`/`body` (falling back to "claw" / the action name).
|
||||||
|
Unknown actions are reported, never guessed.
|
||||||
|
- **Notifications**: a second periodic worker polls
|
||||||
|
`GET /v1/notifications?device=<id>&since=<cursor>` (cursor in DataStore),
|
||||||
|
posts each as a system notification on channel `rhema` (tap opens the app;
|
||||||
|
the API carries no session key, so it lands on the session list), then
|
||||||
|
`POST`s an ack per id. Acks are best-effort: the cursor means "seen", not
|
||||||
|
"acked", so a failed ack does not re-notify.
|
||||||
|
- **Permissions**: `INTERNET`, `POST_NOTIFICATIONS` (runtime request on
|
||||||
|
API 33+, re-asked per cold start; the OS throttles denials),
|
||||||
|
`RECEIVE_BOOT_COMPLETED` (boot receiver re-runs the idempotent KEEP
|
||||||
|
schedule; WorkManager also re-enqueues its own work),
|
||||||
|
`ACCESS_COARSE_LOCATION`/`ACCESS_FINE_LOCATION` (runtime request per
|
||||||
|
cold start like notifications; a refusal only silences the `location`
|
||||||
|
and `wifi` topics). Two grants are settings toggles, not permissions:
|
||||||
|
usage access (`PACKAGE_USAGE_STATS` is declared only so the toggle can
|
||||||
|
grant it) for the `app` topic, and Do Not Disturb access
|
||||||
|
(`ACCESS_NOTIFICATION_POLICY`, declared only so the app appears on the
|
||||||
|
"Do Not Disturb access" screen's list — an undeclared app opens that
|
||||||
|
screen and finds nothing to flip) for the dnd commands. The Status
|
||||||
|
reporting page in Settings shows all three.
|
||||||
|
|
||||||
|
## API contract (the claw's `/v1`)
|
||||||
|
|
||||||
|
All under one base URL; `Authorization: Bearer <token>` on `/v1`, never on
|
||||||
|
`/healthz`.
|
||||||
|
|
||||||
|
| Endpoint | Used for |
|
||||||
|
|---|---|
|
||||||
|
| `GET /healthz` | settings gate (no auth) |
|
||||||
|
| `GET /v1/providers` | backend instances + active pair |
|
||||||
|
| `GET /v1/providers/{id}/models` | an instance's live model listing |
|
||||||
|
| `PUT /v1/providers/active` | switch the backend instance, persisted |
|
||||||
|
| `GET /v1/sessions` | session list |
|
||||||
|
| `POST /v1/sessions/{key}/messages` | `{"text"}` → 202; reply via stream |
|
||||||
|
| `GET /v1/sessions/{key}/events` | SSE, replay-then-live: `user_message`, `thinking_delta`, `text_delta`, `tool_start`, `tool_result`, `turn_done`, `attachment`, `consent_request`, `consent_resolved`. `?since=N` skips the replay's oldest N events — the transcript cache's cursor |
|
||||||
|
| `GET /v1/attachments/{id}` | an attachment event's bytes (bearer) |
|
||||||
|
| `GET /v1/consents` | pending consent questions, oldest first |
|
||||||
|
| `POST /v1/consents/{id}` | answer one: `{"secret"}` or `{"grant"}` |
|
||||||
|
| `DELETE /v1/consents/{id}` | deny / cancel a question |
|
||||||
|
| `GET /v1/phones` | the device fleet, labels and topics included |
|
||||||
|
| `DELETE /v1/phones/{device}` | retire a device's records |
|
||||||
|
| `GET /v1/phone/commands?device=` | the control queue for this device |
|
||||||
|
| `POST /v1/phone/commands/{id}/result` | execution result; doubles as the ack |
|
||||||
|
| `POST /v1/ingest` | `{"source","key","payload"}` |
|
||||||
|
| `GET /v1/notifications?device=&since=` | ascending notification list |
|
||||||
|
| `POST /v1/notifications/{id}/ack` | best-effort ack |
|
||||||
|
|
||||||
|
### Ingest payloads emitted
|
||||||
|
|
||||||
|
`source` is always `"phone"`, `key` is `phone:<deviceId>:<topic>`.
|
||||||
|
Every report is an observation on the claw: it feeds the status the
|
||||||
|
`phone` tool reads, and no turn is run (the ingest answers
|
||||||
|
`{"ingested": "observed"}`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"topic": "battery", "value": "83", "charging": true}
|
||||||
|
{"topic": "battery", "value": "17", "charging": false}
|
||||||
|
{"topic": "network", "value": "wifi"} // or "metered" | "offline"
|
||||||
|
{"topic": "dnd", "value": "on"} // or "off"
|
||||||
|
{"topic": "location", "lat": 48.85, "lon": 2.35, "accuracy_m": 12.0, "provider": "fused", "age_s": 45}
|
||||||
|
{"topic": "app", "value": "com.google.android.maps", "label": "Maps", "age_s": 900}
|
||||||
|
{"topic": "device", "value": "Google Pixel 9", "manufacturer": "Google", "model": "Pixel 9", "android": "16", "sdk": 36, "app_version": "0.1.0"}
|
||||||
|
{"topic": "wifi", "value": "home"}
|
||||||
|
{"topic": "alarm", "value": 1760000000000, "in_min": 542}
|
||||||
|
{"topic": "heartbeat", "ts": 1760000000000}
|
||||||
|
```
|
||||||
|
|
||||||
|
`battery.value` is the raw level; the transition rule is charging-flip or
|
||||||
|
decile bucket change. `location` posts on a 300 m move from the last posted
|
||||||
|
fix. `app` excludes this app itself. `wifi` needs the location grant
|
||||||
|
(Android treats an SSID read as location) and is skipped when the system
|
||||||
|
answers with a placeholder. Unreadable or ungranted sensors produce no
|
||||||
|
posts for that topic.
|
||||||
|
|
||||||
|
## Trade-offs
|
||||||
|
|
||||||
|
- **Notification latency**: no push infrastructure; background
|
||||||
|
notifications are as fresh as the WorkManager interval (15 min). The chat
|
||||||
|
screen is always live via SSE.
|
||||||
|
- **Transcript cache**: the applied event log persists per session (the
|
||||||
|
newest 500 events plus the full count), so a screen opens on its cached
|
||||||
|
transcript and the stream resumes past it. The cache is a cursor over
|
||||||
|
the claw's record, not a copy of it: a claw database wiped behind the
|
||||||
|
phone's back leaves the cache pointing past a history that no longer
|
||||||
|
exists, and that session replays nothing until it catches up.
|
||||||
|
- **First launch**: nothing works until Settings validates `/healthz`.
|
||||||
|
|
||||||
|
## One-consumer principle
|
||||||
|
|
||||||
|
The claw's `/v1` is the contract. The app has no private endpoints and no
|
||||||
|
special privileges; every need goes through the same public API any other
|
||||||
|
client sees. If a feature seems to need a claw change, that change belongs
|
||||||
|
in the claw's public API first.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Requirements: JDK 17+ (Gradle 9.3.1 runs up to the current JDK; the system
|
||||||
|
default is fine), Android SDK with platform android-36 (set `ANDROID_HOME`
|
||||||
|
or `local.properties` `sdk.dir`).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ANDROID_HOME=~/Android/Sdk ./gradlew :app:assembleDebug # debug APK
|
||||||
|
ANDROID_HOME=~/Android/Sdk ./gradlew :app:lintDebug # lint
|
||||||
|
ANDROID_HOME=~/Android/Sdk ./gradlew :app:assembleRelease # release APK
|
||||||
|
```
|
||||||
|
|
||||||
|
APK lands at `app/build/outputs/apk/debug/app-debug.apk`. Install and run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
adb install app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Debug builds are signed with the committed `debug.keystore` (the standard
|
||||||
|
Android Debug identity: store/key password `android`, alias
|
||||||
|
`androiddebugkey`), so any build installs over any other: a CI artifact
|
||||||
|
updates a locally-built install and vice versa. The key is deliberately
|
||||||
|
not a secret — debug keys guard nothing — a stable signature across
|
||||||
|
machines is the whole point. The build never touches
|
||||||
|
`~/.android/debug.keystore`, so other projects keep their own key.
|
||||||
|
|
||||||
|
Release builds are the opposite custody model: the key anchors the
|
||||||
|
future edge channel — whatever it signs can silently update an install
|
||||||
|
that trusts the channel — so it never travels with the source. CI holds
|
||||||
|
it as two repository secrets (`ANDROID_RELEASE_KEYSTORE`, the base64
|
||||||
|
keystore, and `ANDROID_RELEASE_KEYSTORE_PASSWORD`; alias
|
||||||
|
`rhema-release`), and the android workflow pins both certificates by
|
||||||
|
SHA-256 fingerprint before uploading an artifact. Locally, blank
|
||||||
|
environment leaves `assembleRelease` unsigned: a build check, never a
|
||||||
|
deliverable.
|
||||||
|
|
||||||
|
Pinned toolchain: Gradle 9.3.1 (wrapper), AGP 8.13.2, Kotlin 2.2.21
|
||||||
|
(`org.jetbrains.kotlin.plugin.compose` + `plugin.serialization`), Compose
|
||||||
|
BOM 2025.12.01, minSdk 26 / target & compileSdk 36.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
app/src/main/java/rs/rhema/phone/
|
||||||
|
MainActivity.kt nav state + POST_NOTIFICATIONS runtime request
|
||||||
|
RhemaApp.kt Application: container, channel, schedule, receiver
|
||||||
|
di/AppContainer.kt manual DI (OkHttp, Json, repos, api, notifier)
|
||||||
|
data/ DataStore-backed settings + status repositories
|
||||||
|
sensors/ the status readers (location, foreground app, wifi, alarm, identity)
|
||||||
|
net/ ClawApi (one object per endpoint, sealed result type)
|
||||||
|
chat/ChatController transcript state + SSE loop for the open screen
|
||||||
|
ui/ Theme, shared design components, SettingsScreen,
|
||||||
|
SessionsScreen, ChatScreen
|
||||||
|
work/ PhoneStatusWorker, NotificationsWorker,
|
||||||
|
PhoneCommandWorker, receivers, scheduling
|
||||||
|
notif/Notifier.kt channel + system notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cleartext and certificates
|
||||||
|
|
||||||
|
The claw is self-hosted, so its address is whatever the operator points
|
||||||
|
at: a LAN name (`kosmos.lan`), a bare IP, a Tailscale name, where a
|
||||||
|
publicly-trusted certificate is rare. The app therefore permits
|
||||||
|
cleartext HTTP app-wide and trusts user-installed CAs (mkcert installs
|
||||||
|
into the user store, so a mkcert-secured claw works over HTTPS too).
|
||||||
|
Only the operator's configured claw is ever contacted, and the bearer
|
||||||
|
token is the auth; prefer HTTPS wherever the deployment has a real
|
||||||
|
certificate.
|
||||||
|
|
||||||
|
## Road notes
|
||||||
|
|
||||||
|
- Theming is Jadeite dark, always, the same tokens as the TUI and the
|
||||||
|
web UI (`crates/rhema-code/src/palette.rs` DARK). No light scheme, no
|
||||||
|
dynamic color: the palette is the brand. The screens mirror the web
|
||||||
|
claw's redesign: cards with hairline-divided rows, state dots, badges,
|
||||||
|
toned callouts, avatar-row transcripts with accent-bar tool cards, and
|
||||||
|
the round accent send button; the shared pieces live in
|
||||||
|
`ui/Components.kt`, the Compose counterparts of the web's components.
|
||||||
|
- Future onboarding: scanning a QR code from the claw (base URL +
|
||||||
|
token, rendered by the web UI or printed in the terminal) instead of
|
||||||
|
typing both by hand. Deliberately deferred; the Settings screen is
|
||||||
|
the manual path today.
|
||||||
|
- Distribution: the CI release artifact (`rhema-android-release`) is
|
||||||
|
the future edge channel's payload — publishing it somewhere public
|
||||||
|
(a Gitea generic package, an always-latest link) is deferred; the
|
||||||
|
secret release key is already the channel's trust anchor, so the
|
||||||
|
artifact is worth building and pinning now.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "rs.rhema.phone"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "rs.rhema.phone"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
// Debug builds must install over each other, so the signing key
|
||||||
|
// may not depend on the machine that ran the build: CI artifacts
|
||||||
|
// share the identity of every local build via the committed
|
||||||
|
// android/debug.keystore. A debug key guards nothing — this is
|
||||||
|
// the conventional Android Debug identity, password "android" —
|
||||||
|
// while a stable signature is what makes the artifacts updatable.
|
||||||
|
getByName("debug") {
|
||||||
|
storeFile = rootProject.file("debug.keystore")
|
||||||
|
storePassword = "android"
|
||||||
|
keyAlias = "androiddebugkey"
|
||||||
|
keyPassword = "android"
|
||||||
|
}
|
||||||
|
|
||||||
|
// The release key anchors the future edge channel: whatever is
|
||||||
|
// signed with it can silently update an install that trusts the
|
||||||
|
// channel, so it must not travel with the source. It exists only
|
||||||
|
// as a CI secret (see .gitea/workflows/android.yml); blank
|
||||||
|
// environment — local builds, fork PRs — leaves release unsigned.
|
||||||
|
create("release") {
|
||||||
|
storeFile = System.getenv("ANDROID_RELEASE_KEYSTORE")
|
||||||
|
?.takeIf { it.isNotBlank() }?.let { rootProject.file(it) }
|
||||||
|
storePassword = System.getenv("ANDROID_RELEASE_KEYSTORE_PASSWORD")
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
keyAlias = "rhema-release"
|
||||||
|
keyPassword = storePassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
// Signed only where the release credentials exist; the
|
||||||
|
// unsigned artifact is a build check, never a deliverable.
|
||||||
|
if (!System.getenv("ANDROID_RELEASE_KEYSTORE")
|
||||||
|
.isNullOrBlank()
|
||||||
|
) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(platform("androidx.compose:compose-bom:2025.12.01"))
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.foundation:foundation")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("androidx.compose.material:material-icons-core")
|
||||||
|
implementation("androidx.activity:activity-compose:1.12.4")
|
||||||
|
implementation("androidx.core:core-ktx:1.17.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
|
||||||
|
implementation("androidx.datastore:datastore-preferences:1.1.7")
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.12.0")
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||||
|
// 0.42+ declares minCompileSdk 37 in its AAR metadata; the project's
|
||||||
|
// AGP tops out at 36. Revisit when AGP moves.
|
||||||
|
implementation("com.mikepenz:multiplatform-markdown-renderer:0.41.0")
|
||||||
|
implementation("com.mikepenz:multiplatform-markdown-renderer-m3:0.41.0")
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- Network, posting the agent's notifications, and re-kicking the
|
||||||
|
workers after a reboot. Location is the status source's optional
|
||||||
|
extra: a runtime permission, and without it the location topic
|
||||||
|
simply never reports. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<!-- DND control is a special-app grant, and this declaration is what
|
||||||
|
puts the app on the "Do Not Disturb access" screen's list: an
|
||||||
|
undeclared app opens that screen and finds nothing to flip. -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
|
||||||
|
<!-- App usage is not a runtime permission but a special-app access
|
||||||
|
(Settings → Apps → Special app access → Usage access); declaring
|
||||||
|
it is what makes the toggle able to grant it to this app. -->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.PACKAGE_USAGE_STATS"
|
||||||
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".RhemaApp"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@drawable/ic_rhema"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:theme="@android:style/Theme.Material.NoActionBar">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- WorkManager already re-enqueues its own periodic work after reboot;
|
||||||
|
this receiver is the explicit boot kick. It only calls the same
|
||||||
|
idempotent (KEEP) schedule call used at app start. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".work.BootReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Attachments saved into the cache open in other apps only through
|
||||||
|
this provider's content:// mapping; a raw cache path is
|
||||||
|
unreadable to them. -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package rs.rhema.phone
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.SystemBarStyle
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import rs.rhema.phone.di.AppContainer
|
||||||
|
import rs.rhema.phone.ui.ChatScreen
|
||||||
|
import rs.rhema.phone.ui.JadeiteGround
|
||||||
|
import rs.rhema.phone.ui.RhemaTheme
|
||||||
|
import rs.rhema.phone.ui.SessionsScreen
|
||||||
|
import rs.rhema.phone.ui.SettingsScreen
|
||||||
|
import rs.rhema.phone.work.enqueuePhoneCommandsNow
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val notificationPermission =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||||
|
|
||||||
|
private val locationPermissions =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { }
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
// Edge-to-edge with the design's dark ground behind the system
|
||||||
|
// bars; the theme draws under them, the root pads them out.
|
||||||
|
enableEdgeToEdge(SystemBarStyle.dark(JadeiteGround.toArgb()))
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
// POST_NOTIFICATIONS is a runtime permission on API 33+. Asking every
|
||||||
|
// cold start is deliberate; the OS throttles repeated denials itself.
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) {
|
||||||
|
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
// Location is the status source's optional sensor: the request rides
|
||||||
|
// every cold start like the notifications one, and a refusal costs
|
||||||
|
// only the location topic — everything else reports as before. The
|
||||||
|
// Settings screen's Status reporting card names the missing grants
|
||||||
|
// and walks to the system toggles.
|
||||||
|
locationPermissions.launch(
|
||||||
|
arrayOf(
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val container = (application as RhemaApp).container
|
||||||
|
setContent {
|
||||||
|
RhemaTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
) {
|
||||||
|
AppRoot(container)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The control queue (phone commands) is freshest when the human is
|
||||||
|
// holding the phone; KEEP coalesces rapid resume bursts.
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
enqueuePhoneCommandsNow(applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppRoot(container: AppContainer) {
|
||||||
|
// null means "still loading stored settings", not "unconfigured".
|
||||||
|
val configured by container.settings.configured.collectAsState(initial = null)
|
||||||
|
var chatSession by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
BackHandler(enabled = chatSession != null || showSettings) {
|
||||||
|
chatSession = null
|
||||||
|
showSettings = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// The agent's identity (name, avatar) is refreshed once per app open
|
||||||
|
// and cached in DataStore; the sessions screen's refresh re-fetches it.
|
||||||
|
LaunchedEffect(configured) {
|
||||||
|
if (configured == true) container.identity.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
configured == null -> Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
|
||||||
|
// Unconfigured: first launch. SettingsScreen calls onConnected (a no-op
|
||||||
|
// here); flipping `configured` in DataStore recomposes this root.
|
||||||
|
configured == false -> SettingsScreen(container)
|
||||||
|
showSettings -> SettingsScreen(container) { showSettings = false }
|
||||||
|
chatSession != null -> {
|
||||||
|
val key = chatSession
|
||||||
|
if (key != null) ChatScreen(container, key) { chatSession = null }
|
||||||
|
}
|
||||||
|
else -> SessionsScreen(
|
||||||
|
container,
|
||||||
|
onOpenSession = { chatSession = it },
|
||||||
|
onOpenSettings = { showSettings = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package rs.rhema.phone
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import rs.rhema.phone.di.AppContainer
|
||||||
|
import rs.rhema.phone.work.PowerEventReceiver
|
||||||
|
|
||||||
|
class RhemaApp : Application() {
|
||||||
|
|
||||||
|
lateinit var container: AppContainer
|
||||||
|
private set
|
||||||
|
|
||||||
|
// Held in a field so the registered receiver cannot be collected while
|
||||||
|
// the process lives.
|
||||||
|
private var powerReceiver: PowerEventReceiver? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
container = AppContainer(this)
|
||||||
|
container.notifier.ensureChannel()
|
||||||
|
container.schedulePeriodicWork()
|
||||||
|
|
||||||
|
// ACTION_BATTERY_CHANGED and the power actions are implicit broadcasts:
|
||||||
|
// manifest registration for them is refused, so this receiver lives for
|
||||||
|
// the process lifetime instead. It stays dumb: it only enqueues work.
|
||||||
|
val receiver = PowerEventReceiver()
|
||||||
|
powerReceiver = receiver
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
this,
|
||||||
|
receiver,
|
||||||
|
IntentFilter().apply {
|
||||||
|
addAction(Intent.ACTION_POWER_CONNECTED)
|
||||||
|
addAction(Intent.ACTION_POWER_DISCONNECTED)
|
||||||
|
addAction(Intent.ACTION_BATTERY_CHANGED)
|
||||||
|
},
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
package rs.rhema.phone.chat
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import rs.rhema.phone.data.CachedSession
|
||||||
|
import rs.rhema.phone.data.SESSION_WINDOW
|
||||||
|
import rs.rhema.phone.data.SessionCache
|
||||||
|
import rs.rhema.phone.net.ClawApi
|
||||||
|
import rs.rhema.phone.net.ClawEvent
|
||||||
|
import rs.rhema.phone.net.ClawResult
|
||||||
|
import rs.rhema.phone.net.SseFrame
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
|
||||||
|
// Holds the live transcript of one open chat screen. The transcript is
|
||||||
|
// seeded from the session cache before the first frame and kept in the
|
||||||
|
// cache as events stream, so opening a session paints it at once and the
|
||||||
|
// claw is asked only for the events past the cache (the stream's `since`
|
||||||
|
// cursor). Rotation re-seeds from the cache; process death no longer
|
||||||
|
// costs the transcript.
|
||||||
|
class ChatController(
|
||||||
|
private val api: ClawApi,
|
||||||
|
private val cache: SessionCache,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
val sessionKey: String,
|
||||||
|
) {
|
||||||
|
sealed interface Item {
|
||||||
|
val id: Long
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UserMessage(override val id: Long, val text: String) : Item
|
||||||
|
data class Thinking(override val id: Long, val text: String) : Item
|
||||||
|
data class ToolCall(
|
||||||
|
override val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val summary: String?,
|
||||||
|
val isError: Boolean,
|
||||||
|
val running: Boolean,
|
||||||
|
// Command tools: the shell line and the exit code, both null
|
||||||
|
// until the tool_result lands (and null forever on the others).
|
||||||
|
val command: String? = null,
|
||||||
|
val exitCode: Long? = null,
|
||||||
|
) : Item
|
||||||
|
data class Reply(override val id: Long, val text: String, val streaming: Boolean) : Item
|
||||||
|
|
||||||
|
// A file the agent produced; the bytes stay at the claw until this
|
||||||
|
// screen fetches them by attachmentId.
|
||||||
|
data class Attachment(
|
||||||
|
override val id: Long,
|
||||||
|
val attachmentId: String,
|
||||||
|
val name: String,
|
||||||
|
val media: String,
|
||||||
|
val size: Long,
|
||||||
|
val caption: String?,
|
||||||
|
) : Item
|
||||||
|
|
||||||
|
// The agent's question to the human, answered in place. granted is
|
||||||
|
// null while open; consent_resolved (or a local answer) flips it.
|
||||||
|
// Replay rebuilds the whole ladder, so nothing persists here.
|
||||||
|
data class Consent(
|
||||||
|
override val id: Long,
|
||||||
|
val consentId: String,
|
||||||
|
val kind: String,
|
||||||
|
val summary: String,
|
||||||
|
val detail: String,
|
||||||
|
val granted: Boolean? = null,
|
||||||
|
) : Item
|
||||||
|
|
||||||
|
// The turn's end tag ("Handled", "Failed", …) from turn_done; the
|
||||||
|
// reply carries the text, this carries the outcome.
|
||||||
|
data class Outcome(override val id: Long, val outcome: String) : Item
|
||||||
|
|
||||||
|
sealed interface Conn {
|
||||||
|
data object Connecting : Conn
|
||||||
|
data object Live : Conn
|
||||||
|
data object Reconnecting : Conn
|
||||||
|
data class Failed(val code: Int?, val message: String) : Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
var items by mutableStateOf<List<Item>>(emptyList())
|
||||||
|
private set
|
||||||
|
var connection by mutableStateOf<Conn>(Conn.Connecting)
|
||||||
|
private set
|
||||||
|
var sending by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var sendError by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var nextId = 0L
|
||||||
|
private fun newId() = nextId++
|
||||||
|
|
||||||
|
// The applied event log: the cache's tail plus what this connection
|
||||||
|
// delivered, and [appliedTotal] its count from the session's first
|
||||||
|
// event — the cursor every (re)connection opens with.
|
||||||
|
private var applied: MutableList<ClawEvent> = mutableListOf()
|
||||||
|
private var appliedTotal = 0L
|
||||||
|
|
||||||
|
private var streamJob: Job? = null
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (streamJob != null) return
|
||||||
|
streamJob = scope.launch {
|
||||||
|
// Seed before the first frame: the screen shows the cached
|
||||||
|
// transcript at once, and the stream opens past it.
|
||||||
|
val cached = cache.get(sessionKey)
|
||||||
|
appliedTotal = cached.total
|
||||||
|
applied = cached.events.toMutableList()
|
||||||
|
items = applyEvents(emptyList(), applied)
|
||||||
|
try {
|
||||||
|
while (isActive) {
|
||||||
|
api.eventStream(sessionKey, appliedTotal).collect { frame ->
|
||||||
|
when (frame) {
|
||||||
|
// The replay was cut at the cursor, so nothing
|
||||||
|
// already on screen arrives twice; the feed just
|
||||||
|
// continues from where the cache left off.
|
||||||
|
SseFrame.Opened -> connection = Conn.Live
|
||||||
|
is SseFrame.Event -> record(frame.event)
|
||||||
|
SseFrame.Closed -> Unit
|
||||||
|
is SseFrame.Failed -> connection = Conn.Failed(frame.code, frame.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A wrong token does not fix itself by retrying.
|
||||||
|
val conn = connection
|
||||||
|
if (conn is Conn.Failed && conn.code == 401) break
|
||||||
|
connection = Conn.Reconnecting
|
||||||
|
delay(RECONNECT_DELAY_MS)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// The screen's own cancellation lands here too: flush the
|
||||||
|
// cache so the next open seeds from the true tail.
|
||||||
|
withContext(NonCancellable) { persist() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appends one delivered event to the log and the transcript. The
|
||||||
|
// turn's end persists the cache — once per turn, not per delta.
|
||||||
|
private suspend fun record(event: ClawEvent) {
|
||||||
|
applied.add(event)
|
||||||
|
appliedTotal += 1
|
||||||
|
items = applyEvent(items, event)
|
||||||
|
if (event is ClawEvent.TurnDone) persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun persist() {
|
||||||
|
val window = applied.takeLast(SESSION_WINDOW)
|
||||||
|
cache.put(sessionKey, CachedSession(total = appliedTotal, events = window))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
streamJob?.cancel()
|
||||||
|
streamJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stream is already collecting by the time the user can press send,
|
||||||
|
// so this preserves the claw's subscribe-then-post order. A failed POST
|
||||||
|
// restores the draft in the UI. The turn's user_message event is the
|
||||||
|
// transcript's own record of the question — replayed on a reload, and
|
||||||
|
// echoed live right about when this returns — so the local row is only
|
||||||
|
// appended when the echo has not already landed.
|
||||||
|
suspend fun send(text: String): Boolean {
|
||||||
|
sending = true
|
||||||
|
sendError = null
|
||||||
|
val result = api.postMessage(sessionKey, text)
|
||||||
|
sending = false
|
||||||
|
return when (result) {
|
||||||
|
is ClawResult.Ok -> {
|
||||||
|
val last = items.lastOrNull()
|
||||||
|
if (!(last is UserMessage && last.text == text)) {
|
||||||
|
items = items + UserMessage(newId(), text)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
is ClawResult.HttpError -> {
|
||||||
|
sendError = "HTTP ${result.code}" +
|
||||||
|
(result.body?.take(200)?.let { " — $it" } ?: "")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
is ClawResult.Network -> {
|
||||||
|
sendError = result.message
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folds recorded events into a transcript, the cache seed's path:
|
||||||
|
// the same reducer the live stream applies, run from the cache's tail.
|
||||||
|
private fun applyEvents(items: List<Item>, events: List<ClawEvent>): List<Item> =
|
||||||
|
events.fold(items) { acc, event -> applyEvent(acc, event) }
|
||||||
|
|
||||||
|
private fun applyEvent(items: List<Item>, event: ClawEvent): List<Item> {
|
||||||
|
var list = items
|
||||||
|
when (event) {
|
||||||
|
is ClawEvent.UserMessage -> {
|
||||||
|
// The recorded question — replayed on reload, echoed live
|
||||||
|
// for a message this surface just sent. An identical last
|
||||||
|
// row is that echo's stand-in; skip it.
|
||||||
|
val last = list.lastOrNull()
|
||||||
|
if (!(last is UserMessage && last.text == event.text)) {
|
||||||
|
list = list + UserMessage(newId(), event.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ClawEvent.ThinkingDelta -> {
|
||||||
|
val last = list.lastOrNull()
|
||||||
|
list = if (last is Thinking) {
|
||||||
|
list.dropLast(1) + last.copy(text = last.text + event.text)
|
||||||
|
} else {
|
||||||
|
list + Thinking(newId(), event.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ClawEvent.TextDelta -> {
|
||||||
|
// The turn has ONE reply: deltas append to the last open
|
||||||
|
// reply even after tool rows landed between them, so the
|
||||||
|
// agent's text reads continuously around its tool cards.
|
||||||
|
val index = list.indexOfLast { it is Reply && it.streaming }
|
||||||
|
list = if (index >= 0) {
|
||||||
|
list.toMutableList().also { mutable ->
|
||||||
|
val reply = mutable[index] as Reply
|
||||||
|
mutable[index] = reply.copy(text = reply.text + event.text)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
list + Reply(newId(), event.text, streaming = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ClawEvent.ToolStart ->
|
||||||
|
list = list + ToolCall(newId(), event.name, summary = null, isError = false, running = true)
|
||||||
|
is ClawEvent.ToolResult -> {
|
||||||
|
// tool_result has no index; match the last open row of the same tool.
|
||||||
|
val index = list.indexOfLast { it is ToolCall && it.name == event.name && it.running }
|
||||||
|
list = if (index >= 0) {
|
||||||
|
list.toMutableList().also { mutable ->
|
||||||
|
val tool = mutable[index] as ToolCall
|
||||||
|
mutable[index] = tool.copy(
|
||||||
|
summary = event.summary,
|
||||||
|
isError = event.isError,
|
||||||
|
running = false,
|
||||||
|
command = event.command,
|
||||||
|
exitCode = event.code,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
list + ToolCall(
|
||||||
|
newId(),
|
||||||
|
event.name,
|
||||||
|
event.summary,
|
||||||
|
event.isError,
|
||||||
|
running = false,
|
||||||
|
command = event.command,
|
||||||
|
exitCode = event.code,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ClawEvent.TurnDone -> {
|
||||||
|
// Thinking is scratch: it disappears once the turn settles, and
|
||||||
|
// the pinned reply replaces whatever streamed in. Every reply
|
||||||
|
// stops streaming; the last one carries the final text.
|
||||||
|
val kept = list.filterNot { it is Thinking }
|
||||||
|
val replyIndex = kept.indexOfLast { it is Reply }
|
||||||
|
list = kept.mapIndexed { i, item ->
|
||||||
|
if (item !is Reply) {
|
||||||
|
item
|
||||||
|
} else if (i == replyIndex && !event.reply.isNullOrEmpty()) {
|
||||||
|
item.copy(text = event.reply, streaming = false)
|
||||||
|
} else {
|
||||||
|
item.copy(streaming = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event.outcome?.let { outcome -> list = list + Outcome(newId(), outcome) }
|
||||||
|
}
|
||||||
|
is ClawEvent.Attachment ->
|
||||||
|
list = list + Attachment(newId(), event.id, event.name, event.media, event.size, event.caption)
|
||||||
|
is ClawEvent.ConsentRequest ->
|
||||||
|
list = list + Consent(newId(), event.id, event.kind, event.summary, event.detail)
|
||||||
|
is ClawEvent.ConsentResolved -> list = flipConsent(list, event.id, event.granted)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answers a consent question. The claw's consent_resolved event is the
|
||||||
|
// transcript's authority and flips the card again (idempotent); the
|
||||||
|
// local flip covers the gap before the event lands. The raw result
|
||||||
|
// rides back so the card can show what failed.
|
||||||
|
suspend fun answerSecret(consentId: String, secret: String): ClawResult<Unit> =
|
||||||
|
settle(consentId, granted = true) { api.revealSecret(consentId, secret) }
|
||||||
|
|
||||||
|
suspend fun grantPhone(consentId: String, grant: Boolean): ClawResult<Unit> =
|
||||||
|
settle(consentId, granted = grant) { api.grantPhone(consentId, grant) }
|
||||||
|
|
||||||
|
suspend fun denyConsent(consentId: String): ClawResult<Unit> =
|
||||||
|
settle(consentId, granted = false) { api.denyConsent(consentId) }
|
||||||
|
|
||||||
|
private suspend fun settle(
|
||||||
|
consentId: String,
|
||||||
|
granted: Boolean,
|
||||||
|
call: suspend () -> ClawResult<Unit>,
|
||||||
|
): ClawResult<Unit> {
|
||||||
|
val result = call()
|
||||||
|
if (result is ClawResult.Ok) items = flipConsent(items, consentId, granted)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flipConsent(items: List<Item>, consentId: String, granted: Boolean): List<Item> {
|
||||||
|
val index = items.indexOfLast { it is Consent && it.consentId == consentId }
|
||||||
|
if (index < 0) return items
|
||||||
|
return items.toMutableList().also { list ->
|
||||||
|
val consent = list[index] as Consent
|
||||||
|
list[index] = consent.copy(granted = granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val RECONNECT_DELAY_MS = 2_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package rs.rhema.phone.data
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.byteArrayPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import rs.rhema.phone.net.AgentIdentity
|
||||||
|
import rs.rhema.phone.net.ClawApi
|
||||||
|
import rs.rhema.phone.net.ClawResult
|
||||||
|
|
||||||
|
// The agent's face and name as the phone last saw them: the identity
|
||||||
|
// document plus the avatar bytes. Cached in DataStore so the last known
|
||||||
|
// identity renders before (and without) a network round trip — the chat
|
||||||
|
// screen must never fall back to a hardcoded "rhema" just because one
|
||||||
|
// refresh failed.
|
||||||
|
class AgentIdentityCache(
|
||||||
|
val identity: AgentIdentity? = null,
|
||||||
|
val avatar: ByteArray? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class AgentIdentityRepository(
|
||||||
|
private val store: DataStore<Preferences>,
|
||||||
|
private val api: ClawApi,
|
||||||
|
private val json: Json,
|
||||||
|
) {
|
||||||
|
private object Keys {
|
||||||
|
val identityJson = stringPreferencesKey("agent_identity_json")
|
||||||
|
val avatarBytes = byteArrayPreferencesKey("agent_avatar_bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val refreshMutex = Mutex()
|
||||||
|
|
||||||
|
val cached: Flow<AgentIdentityCache> = store.data.map { prefs ->
|
||||||
|
val identity = prefs[Keys.identityJson]?.let { raw ->
|
||||||
|
runCatching { json.decodeFromString<AgentIdentity>(raw) }.getOrNull()
|
||||||
|
}
|
||||||
|
AgentIdentityCache(identity, prefs[Keys.avatarBytes])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches the identity and, when one is set, the avatar bytes. Every
|
||||||
|
// failure keeps the cache as it was: this is presentation state, not
|
||||||
|
// a result the UI owes the user an error for.
|
||||||
|
suspend fun refresh() = refreshMutex.withLock {
|
||||||
|
val identity = when (val result = api.agent()) {
|
||||||
|
is ClawResult.Ok -> result.value
|
||||||
|
else -> return@withLock
|
||||||
|
}
|
||||||
|
val avatar = if (identity.avatar) {
|
||||||
|
when (val result = api.agentAvatar()) {
|
||||||
|
is ClawResult.Ok -> result.value.takeIf { it.isNotEmpty() }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val encoded = json.encodeToString(identity)
|
||||||
|
store.edit { prefs ->
|
||||||
|
if (prefs[Keys.identityJson] != encoded) prefs[Keys.identityJson] = encoded
|
||||||
|
val currentBytes = prefs[Keys.avatarBytes]
|
||||||
|
when {
|
||||||
|
// Rewrite only on change: the avatar can weigh megabytes and
|
||||||
|
// every refresh would otherwise rewrite it into the store.
|
||||||
|
identity.avatar && avatar != null ->
|
||||||
|
if (currentBytes?.contentEquals(avatar) != true) {
|
||||||
|
prefs[Keys.avatarBytes] = avatar
|
||||||
|
}
|
||||||
|
identity.avatar == false && currentBytes != null ->
|
||||||
|
prefs.remove(Keys.avatarBytes)
|
||||||
|
// The identity says an avatar exists but the fetch failed:
|
||||||
|
// the cached bytes stay until a fetch succeeds.
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package rs.rhema.phone.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
|
||||||
|
// One DataStore file holds everything the app persists; the two repositories
|
||||||
|
// in this package are views over it.
|
||||||
|
val Context.rhemaStore: DataStore<Preferences> by preferencesDataStore(name = "rhema")
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package rs.rhema.phone.data
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import rs.rhema.phone.net.ClawEvent
|
||||||
|
|
||||||
|
// One session's cached transcript tail: the events this device has
|
||||||
|
// applied, persisted as they stream. Opening a session seeds the screen
|
||||||
|
// from here instead of watching the claw's whole replay again, and the
|
||||||
|
// stream opens with `total` as its `since` cursor so the claw sends only
|
||||||
|
// the events past the cache.
|
||||||
|
@Serializable
|
||||||
|
data class CachedSession(
|
||||||
|
// The session's full event count — the `since` cursor. Can exceed
|
||||||
|
// [events]: the list keeps only the newest [SESSION_WINDOW] events,
|
||||||
|
// the count counts from the session's first.
|
||||||
|
val total: Long = 0,
|
||||||
|
val events: List<ClawEvent> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// How many events the cache keeps per session. A resident session runs
|
||||||
|
// for weeks; a bounded window keeps the DataStore write flat, and a tail
|
||||||
|
// is enough to paint the screen — the claw still holds the whole record.
|
||||||
|
const val SESSION_WINDOW = 500
|
||||||
|
|
||||||
|
class SessionCache(private val store: DataStore<Preferences>, private val json: Json) {
|
||||||
|
|
||||||
|
private fun key(sessionKey: String) = stringPreferencesKey("session_cache:$sessionKey")
|
||||||
|
|
||||||
|
/** The cached tail, or an empty one for a session never opened here. */
|
||||||
|
suspend fun get(sessionKey: String): CachedSession =
|
||||||
|
store.data.first()[key(sessionKey)]?.let { raw ->
|
||||||
|
runCatching { json.decodeFromString<CachedSession>(raw) }.getOrNull()
|
||||||
|
} ?: CachedSession()
|
||||||
|
|
||||||
|
suspend fun put(sessionKey: String, cache: CachedSession) {
|
||||||
|
store.edit { it[key(sessionKey)] = json.encodeToString(cache) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package rs.rhema.phone.data
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
// The bearer token lives in plain app-private DataStore, not
|
||||||
|
// EncryptedSharedPreferences: androidx.security:security-crypto is an
|
||||||
|
// alpha-quality, effectively unmaintained artifact, and the keystore ceremony
|
||||||
|
// it wraps buys little against the actual threat (another app) since the OS
|
||||||
|
// sandbox already isolates the file. Trade-off: root or a backup extraction
|
||||||
|
// can read it; backup is disabled in the manifest.
|
||||||
|
class SettingsRepository(private val store: DataStore<Preferences>) {
|
||||||
|
|
||||||
|
data class Credentials(val baseUrl: String, val token: String) {
|
||||||
|
val configured: Boolean get() = baseUrl.isNotBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
private object Keys {
|
||||||
|
val baseUrl = stringPreferencesKey("claw_base_url")
|
||||||
|
val token = stringPreferencesKey("claw_token")
|
||||||
|
val deviceId = stringPreferencesKey("device_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
val credentials: Flow<Credentials> =
|
||||||
|
store.data.map { Credentials(it[Keys.baseUrl].orEmpty(), it[Keys.token].orEmpty()) }
|
||||||
|
|
||||||
|
// True once a base URL is stored. The token is needed for /v1 but not for
|
||||||
|
// /healthz, so only the URL gates the first-launch settings screen.
|
||||||
|
val configured: Flow<Boolean> = credentials.map { it.configured }
|
||||||
|
|
||||||
|
// UI-facing id stream; workers use the suspend deviceId() below.
|
||||||
|
val deviceIdFlow: Flow<String> = store.data.map { it[Keys.deviceId].orEmpty() }
|
||||||
|
|
||||||
|
suspend fun current(): Credentials = credentials.first()
|
||||||
|
|
||||||
|
suspend fun setCredentials(rawBaseUrl: String, token: String) {
|
||||||
|
// Normalize the base: trim, drop trailing slashes, and drop a
|
||||||
|
// trailing `/v1` — the client appends the API paths itself, and a
|
||||||
|
// pasted `…/v1` would double-prefix every call into the UI's
|
||||||
|
// catch-all. Also downgrade the host spelling of a schemeless
|
||||||
|
// paste by prepending http, the self-hosted default.
|
||||||
|
val trimmed = rawBaseUrl.trim().trimEnd('/')
|
||||||
|
val base = if (trimmed.endsWith("/v1")) trimmed.removeSuffix("/v1") else trimmed
|
||||||
|
val baseUrl = when {
|
||||||
|
base.startsWith("http://") || base.startsWith("https://") -> base
|
||||||
|
base.isNotEmpty() -> "http://$base"
|
||||||
|
else -> base
|
||||||
|
}
|
||||||
|
store.edit {
|
||||||
|
it[Keys.baseUrl] = baseUrl
|
||||||
|
it[Keys.token] = token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generated once, then immutable for the life of the install; ingest keys
|
||||||
|
// and the notification cursor are namespaced by it.
|
||||||
|
suspend fun deviceId(): String {
|
||||||
|
store.data.first()[Keys.deviceId]?.takeIf { it.isNotBlank() }?.let { return it }
|
||||||
|
store.edit { prefs ->
|
||||||
|
if (prefs[Keys.deviceId].isNullOrBlank()) {
|
||||||
|
prefs[Keys.deviceId] = UUID.randomUUID().toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val id = store.data.first()[Keys.deviceId]
|
||||||
|
check(!id.isNullOrBlank())
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package rs.rhema.phone.data
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
// What the last successful phone-status run reported. A null field means the
|
||||||
|
// sensor was unreadable or ungranted on this device; null fields never produce
|
||||||
|
// ingest posts. New fields default to null, so a snapshot persisted by an
|
||||||
|
// older build decodes (and re-posts each new topic once, which is what a
|
||||||
|
// first report should do).
|
||||||
|
@Serializable
|
||||||
|
data class PhoneSnapshot(
|
||||||
|
val charging: Boolean? = null,
|
||||||
|
val batteryLevel: Int? = null,
|
||||||
|
val network: String? = null,
|
||||||
|
val dnd: String? = null,
|
||||||
|
// The last posted location, kept for the move-distance rule.
|
||||||
|
val locationLat: Double? = null,
|
||||||
|
val locationLon: Double? = null,
|
||||||
|
// The last posted foreground app package.
|
||||||
|
val app: String? = null,
|
||||||
|
// The last posted device-identity fingerprint.
|
||||||
|
val device: String? = null,
|
||||||
|
val wifi: String? = null,
|
||||||
|
// The next alarm's trigger time, epoch millis.
|
||||||
|
val alarm: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class StatusRepository(private val store: DataStore<Preferences>, private val json: Json) {
|
||||||
|
|
||||||
|
private object Keys {
|
||||||
|
val snapshot = stringPreferencesKey("last_phone_snapshot")
|
||||||
|
val notificationCursor = stringPreferencesKey("last_notification_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun lastSnapshot(): PhoneSnapshot? =
|
||||||
|
store.data.first()[Keys.snapshot]?.let { raw ->
|
||||||
|
runCatching { json.decodeFromString<PhoneSnapshot>(raw) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only called after every ingest post of a run succeeded, so a failed run
|
||||||
|
// retries the whole transition set next time instead of swallowing it.
|
||||||
|
suspend fun setSnapshot(snapshot: PhoneSnapshot) {
|
||||||
|
store.edit { it[Keys.snapshot] = json.encodeToString(snapshot) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor into the claw's notification list: the last id this device showed.
|
||||||
|
val notificationCursor: Flow<String> =
|
||||||
|
store.data.map { it[Keys.notificationCursor].orEmpty() }
|
||||||
|
|
||||||
|
suspend fun setNotificationCursor(id: String) {
|
||||||
|
store.edit { it[Keys.notificationCursor] = id }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package rs.rhema.phone.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import rs.rhema.phone.data.AgentIdentityRepository
|
||||||
|
import rs.rhema.phone.data.SessionCache
|
||||||
|
import rs.rhema.phone.data.SettingsRepository
|
||||||
|
import rs.rhema.phone.data.StatusRepository
|
||||||
|
import rs.rhema.phone.data.rhemaStore
|
||||||
|
import rs.rhema.phone.net.ClawApi
|
||||||
|
import rs.rhema.phone.notif.Notifier
|
||||||
|
import rs.rhema.phone.work.schedulePeriodicWork
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
|
||||||
|
// Manual DI: one object graph, built once in Application.onCreate, passed
|
||||||
|
// down by hand. No framework on purpose.
|
||||||
|
class AppContainer(context: Context) {
|
||||||
|
private val appContext: Context = context.applicationContext
|
||||||
|
|
||||||
|
val json: Json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
val settings = SettingsRepository(appContext.rhemaStore)
|
||||||
|
val status = StatusRepository(appContext.rhemaStore, json)
|
||||||
|
val sessionCache = SessionCache(appContext.rhemaStore, json)
|
||||||
|
|
||||||
|
// readTimeout is zeroed only for the SSE client; regular calls keep a
|
||||||
|
// timeout so a wedged claw cannot hang a POST forever.
|
||||||
|
private val http: OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val api = ClawApi(settings, http, json)
|
||||||
|
|
||||||
|
val identity = AgentIdentityRepository(appContext.rhemaStore, api, json)
|
||||||
|
|
||||||
|
val notifier = Notifier(appContext)
|
||||||
|
|
||||||
|
fun schedulePeriodicWork() = schedulePeriodicWork(appContext)
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
package rs.rhema.phone.net
|
||||||
|
|
||||||
|
import rs.rhema.phone.data.SettingsRepository
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.buffer
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
sealed interface ClawResult<out T> {
|
||||||
|
data class Ok<T>(val value: T) : ClawResult<T>
|
||||||
|
data class HttpError(val code: Int, val body: String?) : ClawResult<Nothing>
|
||||||
|
data class Network(val message: String) : ClawResult<Nothing>
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface SseFrame {
|
||||||
|
data object Opened : SseFrame
|
||||||
|
data class Event(val event: ClawEvent) : SseFrame
|
||||||
|
|
||||||
|
// code != null carries an HTTP status; 401 means the token is wrong.
|
||||||
|
data class Failed(val code: Int?, val message: String) : SseFrame
|
||||||
|
data object Closed : SseFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
// One object per claw endpoint. Paths mirror the claw's /v1 contract exactly;
|
||||||
|
// the bearer token rides on every /v1 call and never on /healthz.
|
||||||
|
class ClawApi(
|
||||||
|
private val settings: SettingsRepository,
|
||||||
|
baseClient: OkHttpClient,
|
||||||
|
private val json: Json,
|
||||||
|
) {
|
||||||
|
private val client = baseClient
|
||||||
|
|
||||||
|
// The event stream must not time out while idle; keep-alive comments are
|
||||||
|
// the only liveness signal. Regular calls keep the 30s default from baseClient.
|
||||||
|
private val streamingClient = baseClient.newBuilder()
|
||||||
|
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val jsonBody = "application/json; charset=utf-8".toMediaType()
|
||||||
|
|
||||||
|
// /healthz is the only unauthenticated endpoint; it is the settings gate.
|
||||||
|
suspend fun health(): ClawResult<Unit> {
|
||||||
|
val base = baseOrNull() ?: return ClawResult.Network("no base URL configured")
|
||||||
|
return execute(Request.Builder().url("$base/healthz").build()) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sessions(): ClawResult<List<SessionInfo>> {
|
||||||
|
val builder = v1("sessions") ?: return ClawResult.Network("no base URL configured")
|
||||||
|
return execute(builder.get().build()) { response ->
|
||||||
|
json.decodeFromString<List<SessionInfo>>(response.body?.string() ?: "[]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /v1/providers: every backend instance and the active pair.
|
||||||
|
suspend fun providers(): ClawResult<ProvidersState> {
|
||||||
|
val builder = v1("providers") ?: return ClawResult.Network("no base URL configured")
|
||||||
|
return execute(builder.get().build()) { response ->
|
||||||
|
json.decodeFromString<ProvidersState>(response.body?.string() ?: "{}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /v1/providers/{id}/models: the instance's live listing. An id
|
||||||
|
// without one (unknown, or the mock) is a 404 — an ordinary result,
|
||||||
|
// not a failure: the instance still switches on its default model.
|
||||||
|
suspend fun providerModels(id: String): ClawResult<ModelListing> {
|
||||||
|
val builder = v1("providers", id, "models")
|
||||||
|
?: return ClawResult.Network("no base URL configured")
|
||||||
|
return execute(builder.get().build()) { response ->
|
||||||
|
json.decodeFromString<ModelListing>(response.body?.string() ?: "{}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /v1/providers/active: switch the backend instance (by id),
|
||||||
|
// persisted claw-side;
|
||||||
|
// it applies from the next turn. The answer carries the new pair only.
|
||||||
|
suspend fun setActiveProvider(provider: String, model: String?): ClawResult<ActiveBackend> {
|
||||||
|
val builder = v1("providers", "active")
|
||||||
|
?: return ClawResult.Network("no base URL configured")
|
||||||
|
val body = json.encodeToString(ActiveRequest(provider, model)).toRequestBody(jsonBody)
|
||||||
|
return execute(builder.put(body).build()) { response ->
|
||||||
|
json.decodeFromString<ActiveBackend>(response.body?.string() ?: "{}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The claw's documented dance: subscribe to the event stream first, then
|
||||||
|
// POST; the reply arrives on the stream, not in the POST response (202).
|
||||||
|
suspend fun postMessage(sessionKey: String, text: String): ClawResult<Unit> {
|
||||||
|
val builder = v1("sessions", sessionKey, "messages")
|
||||||
|
?: return ClawResult.Network("no base URL configured")
|
||||||
|
val body = json.encodeToString(MessageRequest(text)).toRequestBody(jsonBody)
|
||||||
|
return execute(builder.post(body).build()) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun ingest(source: String, key: String, payload: JsonObject): ClawResult<Unit> {
|
||||||
|
val builder = v1("ingest") ?: return ClawResult.Network("no base URL configured")
|
||||||
|
val body = json.encodeToString(IngestRequest(source, key, payload)).toRequestBody(jsonBody)
|
||||||
|
return execute(builder.post(body).build()) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /v1/phones: the device fleet, for the settings screen's
|
||||||
|
// management card.
|
||||||
|
suspend fun phones(): ClawResult<PhonesPage> {
|
||||||
|
val builder = v1("phones") ?: return ClawResult.Network("no base URL configured")
|
||||||
|
return execute(builder.get().build()) { response ->
|
||||||
|
json.decodeFromString<PhonesPage>(response.body?.string() ?: "{}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /v1/phones/{device}: retire a device's records on the claw.
|
||||||
|
// The claw answers {"forgotten": bool} — false for an unknown name,
|
||||||
|
// which only means another surface got there first.
|
||||||
|
suspend fun forgetPhone(device: String): ClawResult<Unit> {
|
||||||
|
val builder = v1("phones", device) ?: return ClawResult.Network("no base URL configured")
|
||||||
|
return execute(builder.delete().build()) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun notifications(deviceId: String, since: String): ClawResult<List<RhemaNotification>> {
|
||||||
|
val base = baseOrNull() ?: return ClawResult.Network("no base URL configured")
|
||||||
|
val url = base.toHttpUrlOrNull()?.newBuilder()
|
||||||
|
?.addPathSegments("v1/notifications")
|
||||||
|
?.addQueryParameter("device", deviceId)
|
||||||
|
?.addQueryParameter("since", since)
|
||||||
|
?.build()
|
||||||
|
?: return ClawResult.Network("bad base URL")
|
||||||
|
val request = Request.Builder().url(url).header("Authorization", bearer()).build()
|
||||||
|
return execute(request) { response ->
|
||||||
|
json.decodeFromString<NotificationPage>(response.body?.string() ?: "{}").notifications
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun ackNotification(id: String): ClawResult<Unit> {
|
||||||
|
val builder = v1("notifications", id, "ack")
|
||||||
|
?: return ClawResult.Network("no base URL configured")
|
||||||
|
return execute(builder.post(EMPTY_BODY).build()) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /v1/attachments/{id} and GET /v1/agent/avatar: raw bytes, not
|
||||||
|
// JSON. The generic execute() rejects any 2xx body that does not open
|
||||||
|
// like JSON, so these endpoints share one plain read with the same
|
||||||
|
// error vocabulary.
|
||||||
|
suspend fun attachment(id: String): ClawResult<ByteArray> {
|
||||||
|
val builder = v1("attachments", id)
|
||||||
|
?: return ClawResult.Network("no base URL configured")
|
||||||
|
return rawGet(builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The avatar behind GET /v1/agent; the identity document's `avatar`
|
||||||
|
// flag says whether the fetch can succeed.
|
||||||
|
suspend fun agentAvatar(): ClawResult<ByteArray> {
|
||||||
|
val builder = v1("agent", "avatar")
|
||||||
|
?: return ClawResult.Network("no base URL configured")
|
||||||
|
return rawGet(builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun rawGet(builder: Request.Builder): ClawResult<ByteArray> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
client.newCall(builder.get().build()).execute().use { response ->
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
ClawResult.Ok(response.body?.bytes() ?: ByteArray(0))
|
||||||
|
} else {
|
||||||
|
ClawResult.HttpError(response.code, response.body?.string()?.take(500))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
ClawResult.Network(e.message ?: "network error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /v1/agent: the agent's presentation identity — the name, mark,
|
||||||
|
// initials, and whether the avatar image is set.
|
||||||
|
suspend fun agent(): ClawResult<AgentIdentity> {
|
||||||
|
val builder = v1("agent") ?: return ClawResult.Network("no base URL configured")
|
||||||
|
return execute(builder.get().build()) { response ->
|
||||||
|
json.decodeFromString<AgentIdentity>(response.body?.string() ?: "{}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /v1/consents: the pending questions, oldest first.
|
||||||
|
suspend fun consents(): ClawResult<List<ConsentInfo>> {
|
||||||
|
val builder = v1("consents") ?: return ClawResult.Network("no base URL configured")
|
||||||
|
return execute(builder.get().build()) { response ->
|
||||||
|
json.decodeFromString<ConsentsPage>(response.body?.string() ?: "{}").consents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /v1/consents/{id}: the kind decides the body — the secret
|
||||||
|
// string for kind=secret, the grant flag for kind=phone. A 409 means
|
||||||
|
// the question was already answered (here or on another surface).
|
||||||
|
suspend fun revealSecret(id: String, secret: String): ClawResult<Unit> {
|
||||||
|
val builder = v1("consents", id) ?: return ClawResult.Network("no base URL configured")
|
||||||
|
val body = json.encodeToString(SecretConsentAnswer(secret)).toRequestBody(jsonBody)
|
||||||
|
return execute(builder.post(body).build()) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun grantPhone(id: String, grant: Boolean): ClawResult<Unit> {
|
||||||
|
val builder = v1("consents", id) ?: return ClawResult.Network("no base URL configured")
|
||||||
|
val body = json.encodeToString(PhoneConsentAnswer(grant)).toRequestBody(jsonBody)
|
||||||
|
return execute(builder.post(body).build()) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /v1/consents/{id}: deny, or cancel a question left open.
|
||||||
|
suspend fun denyConsent(id: String): ClawResult<Unit> {
|
||||||
|
val builder = v1("consents", id) ?: return ClawResult.Network("no base URL configured")
|
||||||
|
return execute(builder.delete().build()) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /v1/phone/commands?device=: the control queue for this device.
|
||||||
|
// Built by hand like notifications(): v1() bakes the URL into the
|
||||||
|
// builder, leaving no room for query parameters.
|
||||||
|
suspend fun phoneCommands(deviceId: String): ClawResult<List<PhoneCommand>> {
|
||||||
|
val base = baseOrNull() ?: return ClawResult.Network("no base URL configured")
|
||||||
|
val url = base.toHttpUrlOrNull()?.newBuilder()
|
||||||
|
?.addPathSegments("v1/phone/commands")
|
||||||
|
?.addQueryParameter("device", deviceId)
|
||||||
|
?.build()
|
||||||
|
?: return ClawResult.Network("bad base URL")
|
||||||
|
val request = Request.Builder().url(url).header("Authorization", bearer()).build()
|
||||||
|
return execute(request) { response ->
|
||||||
|
json.decodeFromString<CommandPage>(response.body?.string() ?: "{}").commands
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /v1/phone/commands/{id}/result: the ack. Until it lands the
|
||||||
|
// claw keeps the command queued and the next run repeats it.
|
||||||
|
suspend fun postPhoneCommandResult(id: String, ok: Boolean, detail: String?): ClawResult<Unit> {
|
||||||
|
val builder = v1("phone", "commands", id, "result")
|
||||||
|
?: return ClawResult.Network("no base URL configured")
|
||||||
|
val body = json.encodeToString(CommandResult(ok, detail)).toRequestBody(jsonBody)
|
||||||
|
return execute(builder.post(body).build()) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /v1/sessions/{key}/events?since=N: the claw replays the
|
||||||
|
// session's record minus its oldest N events, then goes live. `since`
|
||||||
|
// is the transcript cache's cursor — how many events this device has
|
||||||
|
// already applied — so a reopening screen receives only what is new
|
||||||
|
// instead of the whole history. Built by hand like notifications():
|
||||||
|
// v1() bakes the URL into the builder, leaving no room for a query.
|
||||||
|
fun eventStream(sessionKey: String, since: Long): Flow<SseFrame> = callbackFlow {
|
||||||
|
val url = baseOrNull()?.toHttpUrlOrNull()?.newBuilder()
|
||||||
|
?.addPathSegments("v1/sessions")
|
||||||
|
?.addPathSegment(sessionKey)
|
||||||
|
?.addPathSegment("events")
|
||||||
|
?.apply { if (since > 0) addQueryParameter("since", since.toString()) }
|
||||||
|
?.build()
|
||||||
|
if (url == null) {
|
||||||
|
trySend(SseFrame.Failed(null, "no base URL configured"))
|
||||||
|
close()
|
||||||
|
return@callbackFlow
|
||||||
|
}
|
||||||
|
val call = streamingClient.newCall(
|
||||||
|
Request.Builder().url(url).header("Authorization", bearer()).build(),
|
||||||
|
)
|
||||||
|
// The chat screen cancels its collector on dispose; call.cancel() is
|
||||||
|
// what unblocks the blocking read inside onResponse below.
|
||||||
|
call.enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
trySend(SseFrame.Failed(null, e.message ?: "connection failed"))
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
response.use { resp ->
|
||||||
|
if (resp.code == 401) {
|
||||||
|
trySend(SseFrame.Failed(401, "unauthorized"))
|
||||||
|
close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!resp.isSuccessful) {
|
||||||
|
trySend(SseFrame.Failed(resp.code, "HTTP ${resp.code}"))
|
||||||
|
close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trySend(SseFrame.Opened)
|
||||||
|
val source = resp.body?.source()
|
||||||
|
if (source == null) {
|
||||||
|
trySend(SseFrame.Failed(resp.code, "empty stream body"))
|
||||||
|
close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// SSE framing per contract: blank-line-delimited data lines,
|
||||||
|
// ":" comment lines are keep-alives. No event:/id: fields.
|
||||||
|
val data = StringBuilder()
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
val line = source.readUtf8Line() ?: break
|
||||||
|
when {
|
||||||
|
line.startsWith(":") -> Unit
|
||||||
|
line.isEmpty() -> {
|
||||||
|
if (data.isNotEmpty()) {
|
||||||
|
val payload = data.toString().trim()
|
||||||
|
data.setLength(0)
|
||||||
|
runCatching { json.decodeFromString<ClawEvent>(payload) }
|
||||||
|
.getOrNull()
|
||||||
|
?.let { trySend(SseFrame.Event(it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line.startsWith("data:") -> {
|
||||||
|
data.append(line.removePrefix("data:").trimStart())
|
||||||
|
data.append('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trySend(SseFrame.Closed)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// call.cancel() from awaitClose lands here: normal close.
|
||||||
|
trySend(SseFrame.Closed)
|
||||||
|
}
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
awaitClose { call.cancel() }
|
||||||
|
}.buffer(Channel.UNLIMITED)
|
||||||
|
|
||||||
|
private suspend fun baseOrNull(): String? =
|
||||||
|
settings.current().baseUrl.trim().trimEnd('/').takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
// The claw's middleware strips the `Bearer ` prefix; the raw token
|
||||||
|
// alone does not authorize.
|
||||||
|
private suspend fun bearer(): String = "Bearer " + settings.current().token
|
||||||
|
|
||||||
|
// Builds GET-ready /v1/{...} request with auth; null when unconfigured.
|
||||||
|
private suspend fun v1(vararg segments: String): Request.Builder? {
|
||||||
|
// The `v1` prefix lives here and nowhere else: the callers name
|
||||||
|
// only their own segments. Without it every call lands in the
|
||||||
|
// web UI's catch-all and reads as a 2xx HTML page.
|
||||||
|
val base = baseOrNull() ?: return null
|
||||||
|
val url = base.toHttpUrlOrNull()?.newBuilder() ?: return null
|
||||||
|
url.addPathSegment("v1")
|
||||||
|
for (segment in segments) url.addPathSegment(segment)
|
||||||
|
return Request.Builder().url(url.build()).header("Authorization", bearer())
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> execute(request: Request, parse: (Response) -> T): ClawResult<T> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
// Peek first: a 2xx body that is not the expected
|
||||||
|
// JSON (an HTML page from a wrong route, a captive
|
||||||
|
// portal) is an error result naming the body, never
|
||||||
|
// a crash — and the snippet says who actually
|
||||||
|
// answered.
|
||||||
|
val peeked = response.peekBody(256).string().trimStart()
|
||||||
|
if (!peeked.startsWith("{") && !peeked.startsWith("[")) {
|
||||||
|
ClawResult.HttpError(
|
||||||
|
response.code,
|
||||||
|
"unexpected 2xx body: " + peeked.take(150),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
ClawResult.Ok(parse(response))
|
||||||
|
} catch (e: SerializationException) {
|
||||||
|
// Valid JSON in the wrong shape fails here
|
||||||
|
// too; the serializer's message names the
|
||||||
|
// field, so it rides along with the body.
|
||||||
|
ClawResult.HttpError(
|
||||||
|
response.code,
|
||||||
|
"unexpected response body: " +
|
||||||
|
(e.message ?: "unparseable JSON") +
|
||||||
|
" — body: " + peeked.take(150),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ClawResult.HttpError(response.code, response.body?.string()?.take(500))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
ClawResult.Network(e.message ?: "network error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val EMPTY_BODY = ByteArray(0).toRequestBody(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package rs.rhema.phone.net
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
// GET /v1/sessions
|
||||||
|
@Serializable
|
||||||
|
data class SessionInfo(
|
||||||
|
val key: String,
|
||||||
|
val state: String? = null,
|
||||||
|
val revision: Long? = null,
|
||||||
|
@SerialName("inbox_depth") val inboxDepth: Int? = null,
|
||||||
|
// The claw serializes the outcome with serde's default externally
|
||||||
|
// tagged enum form: unit variants as bare strings ("Handled",
|
||||||
|
// "Superseded"), Failed as {"Failed":"reason"} — or null when the
|
||||||
|
// key never released. Kept as raw JSON; the UI shows the variant
|
||||||
|
// name.
|
||||||
|
@SerialName("last_outcome") val lastOutcome: JsonElement? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /v1/sessions/{key}/messages
|
||||||
|
@Serializable
|
||||||
|
data class MessageRequest(val text: String)
|
||||||
|
|
||||||
|
// GET /v1/providers — the backend instances and the active pair. Each
|
||||||
|
// row is one instance, named by its id: the kind unless a login named
|
||||||
|
// it, so two instances of one kind coexist under two ids.
|
||||||
|
@Serializable
|
||||||
|
data class ProviderInfo(
|
||||||
|
val id: String,
|
||||||
|
val kind: String,
|
||||||
|
@SerialName("default_model") val defaultModel: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ActivePair(val provider: String, val model: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProvidersState(val active: ActivePair, val providers: List<ProviderInfo>)
|
||||||
|
|
||||||
|
// GET /v1/providers/{id}/models — an instance's live listing; 404 when none.
|
||||||
|
@Serializable
|
||||||
|
data class ModelCard(
|
||||||
|
val id: String,
|
||||||
|
@SerialName("display_name") val displayName: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ModelListing(val models: List<ModelCard> = emptyList())
|
||||||
|
|
||||||
|
// PUT /v1/providers/active — answers the new pair only (provider is the
|
||||||
|
// instance id); the list is not echoed and the caller carries it forward.
|
||||||
|
@Serializable
|
||||||
|
data class ActiveRequest(val provider: String, val model: String? = null)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ActiveBackend(val active: ActivePair)
|
||||||
|
|
||||||
|
// POST /v1/ingest
|
||||||
|
@Serializable
|
||||||
|
data class IngestRequest(val source: String, val key: String, val payload: JsonObject)
|
||||||
|
|
||||||
|
// GET /v1/phones — the fleet: every device that ever reported, its
|
||||||
|
// self-reported label, both clocks, and its latest observation per
|
||||||
|
// topic. This device's row is the one whose `device` equals the local
|
||||||
|
// device id.
|
||||||
|
@Serializable
|
||||||
|
data class PhonesPage(val phones: List<PhoneInfo> = emptyList())
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PhoneInfo(
|
||||||
|
val device: String,
|
||||||
|
val label: String? = null,
|
||||||
|
@SerialName("first_seen") val firstSeen: Long = 0,
|
||||||
|
@SerialName("last_seen") val lastSeen: Long = 0,
|
||||||
|
// Topic name to its latest payload (battery, location, app, …),
|
||||||
|
// verbatim from the ingest posts. Unknown topics land here too, so
|
||||||
|
// an older build still lists a newer claw's data.
|
||||||
|
val topics: Map<String, JsonObject> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /v1/notifications — the claw wraps the list in an object.
|
||||||
|
@Serializable
|
||||||
|
data class NotificationPage(val notifications: List<RhemaNotification> = emptyList())
|
||||||
|
|
||||||
|
// One queued notification: ids and timestamps are numeric (SQLite row id
|
||||||
|
// and epoch millis), the ack path and the `since` cursor carry the id.
|
||||||
|
@Serializable
|
||||||
|
data class RhemaNotification(
|
||||||
|
val id: Long,
|
||||||
|
val device: String? = null,
|
||||||
|
val title: String,
|
||||||
|
val body: String,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Frames of GET /v1/sessions/{key}/events; "type" is the discriminator.
|
||||||
|
// The stream opens with the session's recorded turns replayed before the
|
||||||
|
// live feed, so a screen (re)builds its transcript from the replay.
|
||||||
|
// Unknown types fail the per-frame parse and are skipped, so the API can grow
|
||||||
|
// without breaking older clients.
|
||||||
|
@Serializable
|
||||||
|
sealed interface ClawEvent {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("user_message")
|
||||||
|
data class UserMessage(val text: String = "") : ClawEvent
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("thinking_delta")
|
||||||
|
data class ThinkingDelta(val text: String = "") : ClawEvent
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("text_delta")
|
||||||
|
data class TextDelta(val text: String = "") : ClawEvent
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("tool_start")
|
||||||
|
data class ToolStart(val name: String = "") : ClawEvent
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("tool_result")
|
||||||
|
data class ToolResult(
|
||||||
|
val name: String = "",
|
||||||
|
@SerialName("is_error") val isError: Boolean = false,
|
||||||
|
val summary: String? = null,
|
||||||
|
// Command tools answer with a process exit code and the shell
|
||||||
|
// line; both ride only on the bash-like tools.
|
||||||
|
val code: Long? = null,
|
||||||
|
val command: String? = null,
|
||||||
|
) : ClawEvent
|
||||||
|
|
||||||
|
// The reply is null when the turn ended without producing text —
|
||||||
|
// a failed turn, mostly. Nullable on purpose: a dropped frame would
|
||||||
|
// hide exactly the turns a viewer needs the outcome tag for.
|
||||||
|
@Serializable
|
||||||
|
@SerialName("turn_done")
|
||||||
|
data class TurnDone(val reply: String? = null, val outcome: String? = null) : ClawEvent
|
||||||
|
|
||||||
|
// A file the agent produced. The bytes are not on the stream; they
|
||||||
|
// live at GET /v1/attachments/{id} behind the same bearer token.
|
||||||
|
@Serializable
|
||||||
|
@SerialName("attachment")
|
||||||
|
data class Attachment(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val media: String = "file",
|
||||||
|
val size: Long = 0,
|
||||||
|
val caption: String? = null,
|
||||||
|
) : ClawEvent
|
||||||
|
|
||||||
|
// The agent asking the human: kind=secret wants a secret string,
|
||||||
|
// kind=phone is approve/deny of a phone control action.
|
||||||
|
@Serializable
|
||||||
|
@SerialName("consent_request")
|
||||||
|
data class ConsentRequest(
|
||||||
|
val id: String,
|
||||||
|
val kind: String,
|
||||||
|
val summary: String,
|
||||||
|
val detail: String,
|
||||||
|
@SerialName("created_at") val createdAt: Long = 0,
|
||||||
|
) : ClawEvent
|
||||||
|
|
||||||
|
// A question settled — by this surface or another one; the card
|
||||||
|
// flips on it whichever way the answer went.
|
||||||
|
@Serializable
|
||||||
|
@SerialName("consent_resolved")
|
||||||
|
data class ConsentResolved(val id: String, val granted: Boolean) : ClawEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /v1/consents — the pending questions, oldest first.
|
||||||
|
@Serializable
|
||||||
|
data class ConsentsPage(val consents: List<ConsentInfo> = emptyList())
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ConsentInfo(
|
||||||
|
val id: String,
|
||||||
|
val kind: String,
|
||||||
|
val summary: String,
|
||||||
|
val detail: String,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /v1/consents/{id} — the kind decides the body.
|
||||||
|
@Serializable
|
||||||
|
data class SecretConsentAnswer(val secret: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PhoneConsentAnswer(val grant: Boolean)
|
||||||
|
|
||||||
|
// GET /v1/phone/commands — the control queue for this device, oldest
|
||||||
|
// first. The result POST doubles as the ack: until it lands the claw
|
||||||
|
// keeps the command queued and the worker repeats it.
|
||||||
|
@Serializable
|
||||||
|
data class CommandPage(val commands: List<PhoneCommand> = emptyList())
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PhoneCommand(
|
||||||
|
val id: String,
|
||||||
|
val action: String,
|
||||||
|
val args: JsonObject = JsonObject(emptyMap()),
|
||||||
|
@SerialName("created_at") val createdAt: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /v1/phone/commands/{id}/result
|
||||||
|
@Serializable
|
||||||
|
data class CommandResult(val ok: Boolean, val detail: String? = null)
|
||||||
|
|
||||||
|
// GET /v1/agent — the agent's presentation identity, shared with the web
|
||||||
|
// UI. Unset text fields are absent on the wire (the claw skips nulls), so
|
||||||
|
// every field defaults; the images report presence only, their bytes live
|
||||||
|
// at GET /v1/agent/{kind}.
|
||||||
|
@Serializable
|
||||||
|
data class AgentIdentity(
|
||||||
|
val name: String? = null,
|
||||||
|
val mark: String? = null,
|
||||||
|
val initials: String? = null,
|
||||||
|
val tagline: String? = null,
|
||||||
|
val signoff: String? = null,
|
||||||
|
val avatar: Boolean = false,
|
||||||
|
val logo: Boolean = false,
|
||||||
|
)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package rs.rhema.phone.notif
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import rs.rhema.phone.MainActivity
|
||||||
|
import rs.rhema.phone.R
|
||||||
|
|
||||||
|
class Notifier(private val context: Context) {
|
||||||
|
|
||||||
|
fun fallbackTitle(): String = context.getString(R.string.notif_default_title)
|
||||||
|
|
||||||
|
fun ensureChannel() =
|
||||||
|
ensureChannel(CHANNEL_ID, R.string.channel_name, NotificationManager.IMPORTANCE_DEFAULT)
|
||||||
|
|
||||||
|
// The claw's notification payload carries no session key, so the tap opens
|
||||||
|
// the app root (session list) rather than a specific chat. id = hash of the
|
||||||
|
// claw id: stable per notification, so re-shows collapse instead of stack.
|
||||||
|
fun post(notificationId: Int, title: String, body: String) {
|
||||||
|
notifyOn(CHANNEL_ID, notificationId, title, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The alert path (claw phone commands): heads-up and sound on, against
|
||||||
|
// the poll channel's quiet delivery. Answers false when the runtime
|
||||||
|
// notification grant is missing, so the caller reports that back
|
||||||
|
// instead of pretending delivery.
|
||||||
|
fun postAlert(notificationId: Int, title: String, body: String): Boolean =
|
||||||
|
notifyOn(ALERT_CHANNEL_ID, notificationId, title, body)
|
||||||
|
|
||||||
|
// False when POST_NOTIFICATIONS is ungranted (API 33+): nothing can be
|
||||||
|
// posted without it, and callers still advance their own state (see
|
||||||
|
// the workers).
|
||||||
|
private fun notifyOn(channel: String, notificationId: Int, title: String, body: String): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT >= 33 &&
|
||||||
|
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (channel == ALERT_CHANNEL_ID) {
|
||||||
|
ensureChannel(
|
||||||
|
ALERT_CHANNEL_ID,
|
||||||
|
R.string.alert_channel_name,
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ensureChannel()
|
||||||
|
}
|
||||||
|
val tap = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
Intent(context, MainActivity::class.java)
|
||||||
|
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
val notification = NotificationCompat.Builder(context, channel)
|
||||||
|
.setSmallIcon(R.drawable.ic_rhema)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(body)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||||
|
.setContentIntent(tap)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
context.getSystemService(NotificationManager::class.java)
|
||||||
|
?.notify(notificationId, notification)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureChannel(id: String, nameRes: Int, importance: Int) {
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
manager.createNotificationChannel(NotificationChannel(id, context.getString(nameRes), importance))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL_ID = "rhema"
|
||||||
|
|
||||||
|
// The alert channel exists for immediacy: high importance posts
|
||||||
|
// heads-up with sound, where the default channel stays quiet.
|
||||||
|
const val ALERT_CHANNEL_ID = "rhema-alert"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package rs.rhema.phone.sensors
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.AppOpsManager
|
||||||
|
import android.app.usage.UsageEvents
|
||||||
|
import android.app.usage.UsageStatsManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Process
|
||||||
|
import android.location.Location
|
||||||
|
import android.location.LocationManager
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.location.LocationManagerCompat
|
||||||
|
import androidx.core.os.CancellationSignal
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
|
/** One location observation: where, how precisely, and how old. */
|
||||||
|
data class LocationFix(
|
||||||
|
val lat: Double,
|
||||||
|
val lon: Double,
|
||||||
|
val accuracyM: Double?,
|
||||||
|
val provider: String?,
|
||||||
|
val ageS: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The app the phone last brought to the foreground, and how long ago. */
|
||||||
|
data class ForegroundApp(
|
||||||
|
val pkg: String,
|
||||||
|
val label: String?,
|
||||||
|
val ageS: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The status sensors the phone reports, one function each. Every reader
|
||||||
|
* is nullable by contract: a sensor this phone cannot or may not answer
|
||||||
|
* (a permission the human has not granted, a provider switched off,
|
||||||
|
* nothing to report) produces no value, and no value never produces an
|
||||||
|
* ingest post. The status worker calls these on its run; the settings
|
||||||
|
* screen calls the two grant checks to explain what is switched off.
|
||||||
|
*/
|
||||||
|
object PhoneSensors {
|
||||||
|
|
||||||
|
/** The age beyond which a location fix is too stale to report. */
|
||||||
|
private const val MAX_FIX_AGE_MS = 30 * 60 * 1000L
|
||||||
|
|
||||||
|
/** How long a fresh-fix request may take before the fallback wins. */
|
||||||
|
private const val FIX_TIMEOUT_MS = 20_000L
|
||||||
|
|
||||||
|
/** The look-back window for "the app currently in the foreground". */
|
||||||
|
private const val USAGE_LOOKBACK_MS = 24 * 60 * 60 * 1000L
|
||||||
|
|
||||||
|
// The foreground transition's event type: ACTIVITY_RESUMED renamed
|
||||||
|
// MOVE_TO_FOREGROUND at API 29, same value, so the inlined constant
|
||||||
|
// reads correctly on every supported level.
|
||||||
|
@Suppress("InlinedApi")
|
||||||
|
private val FOREGROUND_EVENT = UsageEvents.Event.ACTIVITY_RESUMED
|
||||||
|
|
||||||
|
/** Location (fine or coarse) is a runtime permission: granted here, or no fixes. */
|
||||||
|
fun hasLocation(context: Context): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/** Usage access is a special-app grant (a settings toggle, like DND access). */
|
||||||
|
fun hasUsageAccess(context: Context): Boolean {
|
||||||
|
val appOps = context.getSystemService(AppOpsManager::class.java) ?: return false
|
||||||
|
val mode = @Suppress("DEPRECATION") appOps.checkOpNoThrow(
|
||||||
|
AppOpsManager.OPSTR_GET_USAGE_STATS,
|
||||||
|
Process.myUid(),
|
||||||
|
context.packageName,
|
||||||
|
)
|
||||||
|
return mode == AppOpsManager.MODE_ALLOWED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fresh fix from the best enabled provider, falling back to
|
||||||
|
* [lastKnownFix] when no provider can answer inside [FIX_TIMEOUT_MS].
|
||||||
|
* The snapshot pass prefers this over a last-known read: last-known
|
||||||
|
* is whatever another app happened to leave behind, which on a
|
||||||
|
* stationary phone goes stale within minutes and reports where the
|
||||||
|
* phone was, not where it is.
|
||||||
|
*/
|
||||||
|
suspend fun currentFix(context: Context): LocationFix? {
|
||||||
|
if (!hasLocation(context)) return null
|
||||||
|
val manager = context.getSystemService(LocationManager::class.java) ?: return null
|
||||||
|
// Fused exists from API 31 and is the one provider that answers
|
||||||
|
// indoors and out; below that, gps then network.
|
||||||
|
val provider = listOf(
|
||||||
|
if (Build.VERSION.SDK_INT >= 31) LocationManager.FUSED_PROVIDER else null,
|
||||||
|
LocationManager.GPS_PROVIDER,
|
||||||
|
LocationManager.NETWORK_PROVIDER,
|
||||||
|
).filterNotNull().firstOrNull { candidate ->
|
||||||
|
runCatching { manager.isProviderEnabled(candidate) }.getOrDefault(false)
|
||||||
|
} ?: return lastKnownFix(context)
|
||||||
|
|
||||||
|
val signal = CancellationSignal()
|
||||||
|
val location = withTimeoutOrNull(FIX_TIMEOUT_MS) {
|
||||||
|
suspendCancellableCoroutine<Location?> { continuation ->
|
||||||
|
val settled = AtomicBoolean(false)
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
settled.set(true)
|
||||||
|
signal.cancel()
|
||||||
|
}
|
||||||
|
val callback = androidx.core.util.Consumer<Location> { location ->
|
||||||
|
// The timeout abandons this coroutine but not the
|
||||||
|
// callback: a late answer must not resume it twice.
|
||||||
|
// A null answer is the provider's "no fix", which
|
||||||
|
// falls through to the last-known fallback.
|
||||||
|
if (settled.compareAndSet(false, true)) {
|
||||||
|
continuation.resume(location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
LocationManagerCompat.getCurrentLocation(
|
||||||
|
manager,
|
||||||
|
provider,
|
||||||
|
signal,
|
||||||
|
ContextCompat.getMainExecutor(context),
|
||||||
|
callback,
|
||||||
|
)
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
// The grant can be revoked between the check at the
|
||||||
|
// top of currentFix and this request; that read then
|
||||||
|
// belongs to the last-known fallback, not a crash.
|
||||||
|
if (settled.compareAndSet(false, true)) {
|
||||||
|
continuation.resume(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return location?.let { fix ->
|
||||||
|
LocationFix(
|
||||||
|
lat = fix.latitude,
|
||||||
|
lon = fix.longitude,
|
||||||
|
accuracyM = fix.accuracy.takeIf { it > 0f }?.toDouble(),
|
||||||
|
provider = fix.provider,
|
||||||
|
ageS = (System.currentTimeMillis() - fix.time) / 1000,
|
||||||
|
)
|
||||||
|
} ?: lastKnownFix(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recent fix any enabled provider still holds, or null
|
||||||
|
* when location is off or every fix is older than [MAX_FIX_AGE_MS].
|
||||||
|
* The fallback read behind [currentFix], and the answer when no
|
||||||
|
* provider can produce a fresh fix in time.
|
||||||
|
*/
|
||||||
|
fun lastKnownFix(context: Context): LocationFix? {
|
||||||
|
if (!hasLocation(context)) return null
|
||||||
|
val manager = context.getSystemService(LocationManager::class.java) ?: return null
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val fix = try {
|
||||||
|
manager.getProviders(true)
|
||||||
|
.mapNotNull { provider -> manager.getLastKnownLocation(provider) }
|
||||||
|
.filter { now - it.time <= MAX_FIX_AGE_MS }
|
||||||
|
.maxByOrNull { it.time }
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
// The grant can be revoked between the check above and this read.
|
||||||
|
null
|
||||||
|
} ?: return null
|
||||||
|
return LocationFix(
|
||||||
|
lat = fix.latitude,
|
||||||
|
lon = fix.longitude,
|
||||||
|
accuracyM = fix.accuracy.takeIf { it > 0f }?.toDouble(),
|
||||||
|
provider = fix.provider,
|
||||||
|
ageS = (now - fix.time) / 1000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app last brought to the foreground, from the usage stream.
|
||||||
|
* Needs the usage-access grant; without it this is null and the
|
||||||
|
* app topic stays silent. This app's own activity is skipped: the
|
||||||
|
* status run itself must not make Rhema the answer.
|
||||||
|
*/
|
||||||
|
fun foregroundApp(context: Context): ForegroundApp? {
|
||||||
|
if (!hasUsageAccess(context)) return null
|
||||||
|
val usage = context.getSystemService(UsageStatsManager::class.java) ?: return null
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val events = usage.queryEvents(now - USAGE_LOOKBACK_MS, now)
|
||||||
|
val event = UsageEvents.Event()
|
||||||
|
var pkg: String? = null
|
||||||
|
var lastResume = 0L
|
||||||
|
while (events.hasNextEvent()) {
|
||||||
|
events.getNextEvent(event)
|
||||||
|
if (event.eventType == FOREGROUND_EVENT && event.packageName != context.packageName) {
|
||||||
|
pkg = event.packageName
|
||||||
|
lastResume = event.timeStamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pkg ?: return null
|
||||||
|
val label = runCatching {
|
||||||
|
val pm = context.packageManager
|
||||||
|
pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString()
|
||||||
|
}.getOrNull()
|
||||||
|
return ForegroundApp(pkg, label, (now - lastResume) / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The connected wifi network's name, when the system will tell. */
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
fun wifiSsid(context: Context): String? {
|
||||||
|
// Reading an SSID is treated as location by the OS: without the
|
||||||
|
// grant the answer is a placeholder, so do not post it.
|
||||||
|
if (!hasLocation(context)) return null
|
||||||
|
val wifi = context.getSystemService(WifiManager::class.java) ?: return null
|
||||||
|
val info = wifi.connectionInfo ?: return null
|
||||||
|
return info.ssid
|
||||||
|
?.removeSurrounding("\"")
|
||||||
|
?.takeIf { ssid ->
|
||||||
|
ssid.isNotBlank() && !ssid.equals("<unknown ssid>", ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user's next alarm as set in the clock app, epoch millis. */
|
||||||
|
fun nextAlarm(context: Context): Long? =
|
||||||
|
context
|
||||||
|
.getSystemService(AlarmManager::class.java)
|
||||||
|
?.nextAlarmClock
|
||||||
|
?.triggerTime
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
|
||||||
|
/** The device's identity fingerprint: changes on upgrade or rename. */
|
||||||
|
fun deviceFingerprint(context: Context): String {
|
||||||
|
val version = runCatching { appVersion(context) }.getOrNull().orEmpty()
|
||||||
|
return listOf(Build.MANUFACTURER, Build.MODEL, Build.VERSION.RELEASE, Build.VERSION.SDK_INT, version)
|
||||||
|
.joinToString("|")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `<manufacturer> <model>`, the label the claw's fleet rows lead with. */
|
||||||
|
fun deviceName(): String = "${Build.MANUFACTURER} ${Build.MODEL}".trim()
|
||||||
|
|
||||||
|
/** This install's own version name, e.g. "0.1.0". */
|
||||||
|
fun appVersion(context: Context): String {
|
||||||
|
val pm = context.packageManager
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val info = pm.getPackageInfo(context.packageName, 0)
|
||||||
|
return info.versionName ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metres between a fix and a previously posted one, 0 when either is absent. */
|
||||||
|
fun metresBetween(fromLat: Double?, fromLon: Double?, to: LocationFix): Float? {
|
||||||
|
if (fromLat == null || fromLon == null) return null
|
||||||
|
val results = FloatArray(2)
|
||||||
|
Location.distanceBetween(fromLat, fromLon, to.lat, to.lon, results)
|
||||||
|
return results[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,923 @@
|
|||||||
|
package rs.rhema.phone.ui
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import rs.rhema.phone.R
|
||||||
|
import rs.rhema.phone.chat.ChatController
|
||||||
|
import rs.rhema.phone.data.AgentIdentityCache
|
||||||
|
import rs.rhema.phone.di.AppContainer
|
||||||
|
import rs.rhema.phone.net.ClawResult
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
// The transcript screen, the design's chat panel as a phone screen:
|
||||||
|
// avatar rows per turn (the tool cards with their one-sided accent
|
||||||
|
// border, dim collapsible thinking, the outcome tag), and a composer
|
||||||
|
// with the round accent send button. The claw owns the conversation —
|
||||||
|
// this renders what the event stream delivered, nothing more.
|
||||||
|
@Composable
|
||||||
|
fun ChatScreen(container: AppContainer, sessionKey: String, onBack: () -> Unit) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val controller = remember(sessionKey) { ChatController(container.api, container.sessionCache, scope, sessionKey) }
|
||||||
|
// The SSE connection lives exactly as long as this screen is composed.
|
||||||
|
DisposableEffect(sessionKey) {
|
||||||
|
controller.start()
|
||||||
|
onDispose { controller.stop() }
|
||||||
|
}
|
||||||
|
BackHandler { onBack() }
|
||||||
|
|
||||||
|
var draft by rememberSaveable { mutableStateOf("") }
|
||||||
|
|
||||||
|
// The claw's presentation identity: the avatar picture (when one is
|
||||||
|
// set), the initials glyph otherwise, and the agent's name on every
|
||||||
|
// turn — the same face the web UI shows.
|
||||||
|
val face = rememberAgentFace(container)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
ChatHeader(sessionKey, controller.connection, face, onBack)
|
||||||
|
HorizontalDivider(color = JadeiteBorder)
|
||||||
|
|
||||||
|
// Reversed layout: index 0 is the newest turn, pinned to the
|
||||||
|
// bottom edge. A streaming reply grows against that anchor, so
|
||||||
|
// the newest text stays on screen without scroll bookkeeping,
|
||||||
|
// and a reader scrolled into history is never yanked back.
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
reverseLayout = true,
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
|
) {
|
||||||
|
items(turnGroups(controller.items).asReversed(), key = { it.key }) { group ->
|
||||||
|
TurnGroupRow(group, container, controller, face)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.sendError?.let { err ->
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.chat_send_failed, err),
|
||||||
|
color = JadeiteError,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = draft,
|
||||||
|
onValueChange = { draft = it },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
placeholder = { Text(stringResource(R.string.chat_message_hint)) },
|
||||||
|
maxLines = 4,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = JadeiteAccent,
|
||||||
|
unfocusedBorderColor = JadeiteBorder,
|
||||||
|
focusedContainerColor = JadeiteSunken,
|
||||||
|
unfocusedContainerColor = JadeiteSunken,
|
||||||
|
cursorColor = JadeiteAccentText,
|
||||||
|
focusedTextColor = JadeiteText,
|
||||||
|
unfocusedTextColor = JadeiteText,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
SendButton(
|
||||||
|
enabled = !controller.sending && draft.isNotBlank(),
|
||||||
|
) {
|
||||||
|
val text = draft
|
||||||
|
draft = ""
|
||||||
|
scope.launch {
|
||||||
|
if (!controller.send(text)) draft = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChatHeader(
|
||||||
|
sessionKey: String,
|
||||||
|
conn: ChatController.Conn,
|
||||||
|
face: AgentFace,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.chat_back),
|
||||||
|
tint = JadeiteText2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AppAvatar(face.glyph, accent = face.image == null, size = 34.dp, image = face.image)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
sessionKey.removePrefix("interactive:"),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
StatusDot(tone = connTone(conn))
|
||||||
|
Text(
|
||||||
|
connText(conn),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (connTone(conn) == DotTone.Error) JadeiteError else JadeiteText3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connTone(conn: ChatController.Conn): DotTone = when (conn) {
|
||||||
|
ChatController.Conn.Connecting -> DotTone.Warn
|
||||||
|
ChatController.Conn.Live -> DotTone.Ok
|
||||||
|
ChatController.Conn.Reconnecting -> DotTone.Error
|
||||||
|
is ChatController.Conn.Failed -> DotTone.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun connText(conn: ChatController.Conn): String = when (conn) {
|
||||||
|
ChatController.Conn.Connecting -> stringResource(R.string.chat_status_connecting)
|
||||||
|
ChatController.Conn.Live -> stringResource(R.string.chat_status_live)
|
||||||
|
ChatController.Conn.Reconnecting -> stringResource(R.string.chat_status_reconnecting)
|
||||||
|
is ChatController.Conn.Failed ->
|
||||||
|
if (conn.code == 401) {
|
||||||
|
stringResource(R.string.chat_status_unauthorized)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.chat_status_failed, conn.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SendButton(enabled: Boolean, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = if (enabled) JadeiteAccent else JadeiteElevated,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clickable(enabled = enabled, onClick = onClick),
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.Send,
|
||||||
|
contentDescription = stringResource(R.string.chat_send),
|
||||||
|
tint = if (enabled) JadeiteOnAccent else JadeiteText3,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One turn as rendered: a user message, or the agent's stack of
|
||||||
|
// thinking / tool cards / reply / outcome under one avatar. The flat
|
||||||
|
// item list folds here — consecutive agent items belong to one turn.
|
||||||
|
private sealed interface TurnGroup {
|
||||||
|
val key: String
|
||||||
|
|
||||||
|
data class User(val id: Long, val text: String) : TurnGroup {
|
||||||
|
override val key: String = "u$id"
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Agent(val items: MutableList<ChatController.Item>) : TurnGroup {
|
||||||
|
override val key: String = "a${items.first().id}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun turnGroups(items: List<ChatController.Item>): List<TurnGroup> {
|
||||||
|
val groups = mutableListOf<TurnGroup>()
|
||||||
|
for (item in items) {
|
||||||
|
when (item) {
|
||||||
|
is ChatController.UserMessage -> groups += TurnGroup.User(item.id, item.text)
|
||||||
|
else -> {
|
||||||
|
val last = groups.lastOrNull()
|
||||||
|
if (last is TurnGroup.Agent) last.items += item else groups += TurnGroup.Agent(mutableListOf(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TurnGroupRow(
|
||||||
|
group: TurnGroup,
|
||||||
|
container: AppContainer,
|
||||||
|
controller: ChatController,
|
||||||
|
face: AgentFace,
|
||||||
|
) {
|
||||||
|
when (group) {
|
||||||
|
is TurnGroup.User -> Row(
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
// The reader's own words: a right-anchored bubble, elevated
|
||||||
|
// one step and marked on its trailing edge — no label or
|
||||||
|
// avatar, the side says whose it is.
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f, fill = false)
|
||||||
|
.height(IntrinsicSize.Min)
|
||||||
|
.background(JadeiteElevated, MaterialTheme.shapes.small),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 10.dp, vertical = 8.dp)
|
||||||
|
.weight(1f, fill = false),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
// Embedded pastes render as chips, not payloads; the
|
||||||
|
// text around them stays verbatim.
|
||||||
|
var chip = 0
|
||||||
|
pasteParts(group.text).forEach { part ->
|
||||||
|
when (part) {
|
||||||
|
is PastePart.Text -> Text(part.text, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
is PastePart.Paste -> {
|
||||||
|
chip += 1
|
||||||
|
PasteChip(chipLabel(part.payload, chip))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AccentBar(JadeiteAccent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is TurnGroup.Agent -> Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
AppAvatar(face.glyph, accent = face.image == null, image = face.image)
|
||||||
|
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
face.name ?: stringResource(R.string.chat_agent),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (group.items.any { it is ChatController.Reply && it.streaming }) {
|
||||||
|
JadeiteAccentText
|
||||||
|
} else {
|
||||||
|
JadeiteText2
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// The design stacks a turn as thinking, tool cards, then the
|
||||||
|
// reply and its outcome — stream order interleaves them, the
|
||||||
|
// rendering does not.
|
||||||
|
group.items.sortedBy { turnPriority(it) }.forEach { item ->
|
||||||
|
when (item) {
|
||||||
|
is ChatController.Thinking -> ThinkingBlock(item)
|
||||||
|
is ChatController.ToolCall ->
|
||||||
|
if (isShellTool(item.name)) ShellRow(item) else ToolCard(item)
|
||||||
|
is ChatController.Attachment -> AttachmentCard(item, container)
|
||||||
|
is ChatController.Consent -> ConsentCard(item, controller)
|
||||||
|
is ChatController.Reply -> ReplyBlock(item)
|
||||||
|
is ChatController.Outcome -> OutcomeTag(item.outcome)
|
||||||
|
is ChatController.UserMessage -> Unit // never grouped here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render order inside one agent turn: thinking, tools, attachments,
|
||||||
|
// questions, reply, outcome.
|
||||||
|
private fun turnPriority(item: ChatController.Item): Int = when (item) {
|
||||||
|
is ChatController.Thinking -> 0
|
||||||
|
is ChatController.ToolCall -> 1
|
||||||
|
is ChatController.Attachment -> 2
|
||||||
|
is ChatController.Consent -> 3
|
||||||
|
is ChatController.Reply -> 4
|
||||||
|
is ChatController.Outcome -> 5
|
||||||
|
is ChatController.UserMessage -> 6
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThinkingBlock(item: ChatController.Thinking) {
|
||||||
|
// Dim and collapsible: it is the agent's scratch, not its answer.
|
||||||
|
var expanded by remember(item.id) { mutableStateOf(false) }
|
||||||
|
val text = if (expanded || item.text.length <= 120) item.text else "${item.text.take(120)}…"
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { expanded = !expanded }
|
||||||
|
.background(JadeiteSurface, MaterialTheme.shapes.extraSmall)
|
||||||
|
.padding(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"thinking",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToolCard(item: ChatController.ToolCall) {
|
||||||
|
val tint = when {
|
||||||
|
item.running -> JadeiteText3
|
||||||
|
item.isError -> JadeiteError
|
||||||
|
else -> JadeiteOk
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 32.dp)
|
||||||
|
.height(IntrinsicSize.Min)
|
||||||
|
.background(JadeiteSurface, MaterialTheme.shapes.extraSmall),
|
||||||
|
) {
|
||||||
|
AccentBar(tint)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||||
|
.weight(1f),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (item.running) "…" else if (item.isError) "✗" else "✓",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = tint,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
item.name,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontFamily = JadeiteMono,
|
||||||
|
color = JadeiteText,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
item.summary?.takeIf { it.isNotBlank() }?.let { summary ->
|
||||||
|
Text(
|
||||||
|
summary,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = JadeiteMono,
|
||||||
|
color = JadeiteText3,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Command rows render as shell lines, not glyph rows (the desktop's
|
||||||
|
* shell_line); run_command is the bash tool's other name. */
|
||||||
|
private fun isShellTool(name: String): Boolean = name == "bash" || name == "run_command"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ShellRow(item: ChatController.ToolCall) {
|
||||||
|
val tint = when {
|
||||||
|
item.running -> JadeiteText3
|
||||||
|
item.exitCode == 0L && !item.isError -> JadeiteOk
|
||||||
|
else -> JadeiteError
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 32.dp)
|
||||||
|
.height(IntrinsicSize.Min)
|
||||||
|
.background(JadeiteSurface, MaterialTheme.shapes.extraSmall),
|
||||||
|
) {
|
||||||
|
AccentBar(tint)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||||
|
.weight(1f),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
// A shell line reads as the command it ran: dim $ prefix, the
|
||||||
|
// command, the exit code at its end.
|
||||||
|
Text(
|
||||||
|
"$",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontFamily = JadeiteMono,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
item.command ?: "…",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontFamily = JadeiteMono,
|
||||||
|
color = JadeiteText,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
when {
|
||||||
|
item.running -> Unit
|
||||||
|
item.exitCode != null -> Text(
|
||||||
|
"[${item.exitCode}]",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontFamily = JadeiteMono,
|
||||||
|
color = if (item.exitCode == 0L) JadeiteOk else JadeiteError,
|
||||||
|
)
|
||||||
|
item.isError -> Text(
|
||||||
|
"✗",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = JadeiteError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReplyBlock(item: ChatController.Reply) {
|
||||||
|
AgentMarkdown(
|
||||||
|
text = item.text,
|
||||||
|
streaming = item.streaming,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OutcomeTag(outcome: String) {
|
||||||
|
Text(
|
||||||
|
outcome,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontFamily = JadeiteMono,
|
||||||
|
color = if (outcome.equals("Failed", ignoreCase = true)) JadeiteError else JadeiteText3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PasteChip(label: String) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontFamily = JadeiteMono,
|
||||||
|
color = JadeiteText3,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(JadeiteSunken, MaterialTheme.shapes.extraSmall)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- embedded pastes --------------------------------------------------
|
||||||
|
// The wire form a long paste travels in: MARK, payload, MARK — one
|
||||||
|
// logical unit inside otherwise plain text (rhema-core's paste module
|
||||||
|
// is the definition; these are its client-side shape). A mark with no
|
||||||
|
// partner is literal text.
|
||||||
|
|
||||||
|
private const val PASTE_MARK = '\uFFFC'
|
||||||
|
|
||||||
|
/** One scanned piece of embedded-form text. */
|
||||||
|
private sealed interface PastePart {
|
||||||
|
data class Text(val text: String) : PastePart
|
||||||
|
data class Paste(val payload: String) : PastePart
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits embedded-form text into its parts, left to right. A mark with
|
||||||
|
* no partner is literal text; malformed input never throws. */
|
||||||
|
private fun pasteParts(text: String): List<PastePart> {
|
||||||
|
val parts = mutableListOf<PastePart>()
|
||||||
|
var rest = text
|
||||||
|
while (true) {
|
||||||
|
val start = rest.indexOf(PASTE_MARK)
|
||||||
|
if (start < 0) break
|
||||||
|
val after = rest.substring(start + 1)
|
||||||
|
val end = after.indexOf(PASTE_MARK)
|
||||||
|
if (end < 0) {
|
||||||
|
parts += PastePart.Text(rest.substring(0, start + 1))
|
||||||
|
rest = after
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (start > 0) parts += PastePart.Text(rest.substring(0, start))
|
||||||
|
parts += PastePart.Paste(after.substring(0, end))
|
||||||
|
rest = after.substring(end + 1)
|
||||||
|
}
|
||||||
|
if (rest.isNotEmpty()) parts += PastePart.Text(rest)
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rust `str::lines`: \n-split, and a trailing newline ends the last
|
||||||
|
* line instead of opening an empty one. */
|
||||||
|
private fun lineCount(text: String): Int {
|
||||||
|
if (text.isEmpty()) return 0
|
||||||
|
val lines = text.split('\n')
|
||||||
|
return if (lines.last().isEmpty()) lines.size - 1 else lines.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A chip's label: `▣1 5L` — the anchor's one-based id and the
|
||||||
|
* payload's line count. */
|
||||||
|
private fun chipLabel(paste: String, chip: Int): String = "▣$chip ${lineCount(paste)}L"
|
||||||
|
|
||||||
|
// --- attachments ------------------------------------------------------
|
||||||
|
|
||||||
|
// Only small images preview inline: there is no image loader in the
|
||||||
|
// app, so the preview is a BitmapFactory decode of the fetched bytes.
|
||||||
|
private const val INLINE_IMAGE_MAX_BYTES = 2L * 1024 * 1024
|
||||||
|
|
||||||
|
private sealed interface AttachState {
|
||||||
|
data object Idle : AttachState
|
||||||
|
data object Loading : AttachState
|
||||||
|
data class Ready(val uri: Uri, val preview: ImageBitmap?, val note: String? = null) : AttachState
|
||||||
|
data class Failed(val message: String) : AttachState
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AttachmentCard(item: ChatController.Attachment, container: AppContainer) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
// Templates resolve in composition, not at callback time: a lookup
|
||||||
|
// through LocalContext.current freezes the locale of the first
|
||||||
|
// composition.
|
||||||
|
val failedTemplate = stringResource(R.string.chat_attachment_failed)
|
||||||
|
val openFailed = stringResource(R.string.chat_attachment_open_failed)
|
||||||
|
var state by remember(item.attachmentId) { mutableStateOf<AttachState>(AttachState.Idle) }
|
||||||
|
|
||||||
|
// One fetch: bytes → cache file → shareable uri; small images also
|
||||||
|
// decode to the inline preview. open hands the uri to ACTION_VIEW.
|
||||||
|
suspend fun download(open: Boolean) {
|
||||||
|
state = AttachState.Loading
|
||||||
|
when (val result = container.api.attachment(item.attachmentId)) {
|
||||||
|
is ClawResult.Ok -> {
|
||||||
|
val prepared = withContext(Dispatchers.IO) {
|
||||||
|
val uri = saveAttachment(context, item.attachmentId, item.name, result.value)
|
||||||
|
when {
|
||||||
|
uri == null -> AttachState.Failed(
|
||||||
|
failedTemplate.format("save failed"),
|
||||||
|
)
|
||||||
|
item.media == "image" && result.value.size <= INLINE_IMAGE_MAX_BYTES -> {
|
||||||
|
val preview = BitmapFactory.decodeByteArray(
|
||||||
|
result.value,
|
||||||
|
0,
|
||||||
|
result.value.size,
|
||||||
|
)?.asImageBitmap()
|
||||||
|
AttachState.Ready(uri, preview)
|
||||||
|
}
|
||||||
|
else -> AttachState.Ready(uri, preview = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state = prepared
|
||||||
|
if (prepared is AttachState.Ready && open && !openAttachment(context, prepared.uri)) {
|
||||||
|
state = prepared.copy(note = openFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ClawResult.HttpError ->
|
||||||
|
state = AttachState.Failed(failedTemplate.format("HTTP ${result.code}"))
|
||||||
|
is ClawResult.Network ->
|
||||||
|
state = AttachState.Failed(failedTemplate.format(result.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small images fetch themselves on first composition; everything else
|
||||||
|
// waits for the download row.
|
||||||
|
LaunchedEffect(item.attachmentId) {
|
||||||
|
if (item.media == "image" && item.size in 1L..INLINE_IMAGE_MAX_BYTES && state == AttachState.Idle) {
|
||||||
|
download(open = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val current = state
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 32.dp)
|
||||||
|
.height(IntrinsicSize.Min)
|
||||||
|
.background(JadeiteSurface, MaterialTheme.shapes.extraSmall),
|
||||||
|
) {
|
||||||
|
AccentBar(
|
||||||
|
when (current) {
|
||||||
|
AttachState.Loading -> JadeiteText3
|
||||||
|
is AttachState.Failed -> JadeiteError
|
||||||
|
else -> JadeiteOk
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||||
|
.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
item.name,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontFamily = JadeiteMono,
|
||||||
|
color = JadeiteText,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
formatBytes(item.size),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontFamily = JadeiteMono,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item.caption?.takeIf { it.isNotBlank() }?.let { caption ->
|
||||||
|
Text(caption, style = MaterialTheme.typography.bodySmall, color = JadeiteText2)
|
||||||
|
}
|
||||||
|
if (current is AttachState.Ready) {
|
||||||
|
current.preview?.let { preview ->
|
||||||
|
Image(
|
||||||
|
bitmap = preview,
|
||||||
|
contentDescription = item.name,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 240.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
current.note?.let { note ->
|
||||||
|
Text(note, style = MaterialTheme.typography.bodySmall, color = JadeiteError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current is AttachState.Failed) {
|
||||||
|
Text(current.message, style = MaterialTheme.typography.bodySmall, color = JadeiteError)
|
||||||
|
}
|
||||||
|
if (current is AttachState.Loading) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.chat_attachment_downloading),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
when (current) {
|
||||||
|
is AttachState.Ready -> stringResource(R.string.chat_attachment_open)
|
||||||
|
else -> stringResource(R.string.chat_attachment_download)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = JadeiteAccentText,
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
when (val s = state) {
|
||||||
|
is AttachState.Ready -> {
|
||||||
|
if (!openAttachment(context, s.uri)) {
|
||||||
|
state = s.copy(note = openFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> scope.launch { download(open = true) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves the bytes under cache/attachments/ and answers the shareable
|
||||||
|
* content:// uri; other apps can read the file only through the
|
||||||
|
* FileProvider mapping, so the name (its extension drives the mime)
|
||||||
|
* must survive the save. Null when the write failed. */
|
||||||
|
private fun saveAttachment(context: Context, id: String, name: String, bytes: ByteArray): Uri? {
|
||||||
|
val dir = File(context.cacheDir, "attachments")
|
||||||
|
if (!dir.exists() && !dir.mkdirs()) return null
|
||||||
|
val safe = name.substringAfterLast('/').substringAfterLast('\\').trim().ifBlank { "attachment" }
|
||||||
|
return try {
|
||||||
|
val file = File(dir, "$id-$safe")
|
||||||
|
file.writeBytes(bytes)
|
||||||
|
FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hands a saved attachment to another app. False when nothing on the
|
||||||
|
* phone opens it — the card says so, and the file stays for a retry. */
|
||||||
|
private fun openAttachment(context: Context, uri: Uri): Boolean = try {
|
||||||
|
val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Intent.ACTION_VIEW)
|
||||||
|
.setDataAndType(uri, mime)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||||
|
)
|
||||||
|
true
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** B/KB/MB, integer — the mono token beside the file name. */
|
||||||
|
private fun formatBytes(size: Long): String = when {
|
||||||
|
size < 1024 -> "$size B"
|
||||||
|
size < 1024 * 1024 -> "${size / 1024} KB"
|
||||||
|
else -> "${size / (1024 * 1024)} MB"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConsentCard(item: ChatController.Consent, controller: ChatController) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var secret by remember(item.id) { mutableStateOf("") }
|
||||||
|
var busy by remember(item.id) { mutableStateOf(false) }
|
||||||
|
var failure by remember(item.id) { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
// An open question glows warn; a settled card keeps only its tone.
|
||||||
|
val tint = when (item.granted) {
|
||||||
|
null -> JadeiteWarn
|
||||||
|
true -> JadeiteOk
|
||||||
|
false -> JadeiteText3
|
||||||
|
}
|
||||||
|
|
||||||
|
fun answer(call: suspend () -> ClawResult<Unit>) {
|
||||||
|
busy = true
|
||||||
|
scope.launch {
|
||||||
|
failure = when (val result = call()) {
|
||||||
|
is ClawResult.Ok -> null
|
||||||
|
is ClawResult.HttpError -> "HTTP ${result.code}" + (result.body?.let { " — $it" } ?: "")
|
||||||
|
is ClawResult.Network -> result.message
|
||||||
|
}
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(IntrinsicSize.Min)
|
||||||
|
.background(JadeiteSurface, MaterialTheme.shapes.extraSmall),
|
||||||
|
) {
|
||||||
|
AccentBar(tint)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||||
|
.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
AppBadge(item.kind, tint = tint)
|
||||||
|
Text(
|
||||||
|
item.summary,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(item.detail, style = MaterialTheme.typography.bodySmall, color = JadeiteText2)
|
||||||
|
when (item.granted) {
|
||||||
|
true -> Text(
|
||||||
|
"✓ " + stringResource(R.string.consent_granted),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = JadeiteOk,
|
||||||
|
)
|
||||||
|
false -> Text(
|
||||||
|
"✗ " + stringResource(R.string.consent_denied),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
null -> {
|
||||||
|
if (item.kind == "secret") {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = secret,
|
||||||
|
onValueChange = { secret = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = { Text(stringResource(R.string.consent_secret_hint)) },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !busy,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = JadeiteAccent,
|
||||||
|
unfocusedBorderColor = JadeiteBorder,
|
||||||
|
focusedContainerColor = JadeiteSunken,
|
||||||
|
unfocusedContainerColor = JadeiteSunken,
|
||||||
|
cursorColor = JadeiteAccentText,
|
||||||
|
focusedTextColor = JadeiteText,
|
||||||
|
unfocusedTextColor = JadeiteText,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (item.kind == "secret") {
|
||||||
|
Button(
|
||||||
|
enabled = !busy && secret.isNotBlank(),
|
||||||
|
onClick = { answer { controller.answerSecret(item.consentId, secret) } },
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.consent_submit))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
enabled = !busy,
|
||||||
|
onClick = { answer { controller.grantPhone(item.consentId, true) } },
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.consent_approve))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
enabled = !busy,
|
||||||
|
onClick = { answer { controller.denyConsent(item.consentId) } },
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.consent_deny))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failure?.let { fail ->
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.consent_answer_failed, fail),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = JadeiteError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The agent's face as this screen renders it: the display name (null
|
||||||
|
// keeps the string resource's fallback), the initials glyph, and the
|
||||||
|
// decoded avatar image. The glyph precedence is the web's: the claw's
|
||||||
|
// `initials`, else the name's first letter, else "R".
|
||||||
|
private data class AgentFace(val name: String?, val glyph: String, val image: ImageBitmap?)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberAgentFace(container: AppContainer): AgentFace {
|
||||||
|
val cache by container.identity.cached.collectAsState(AgentIdentityCache())
|
||||||
|
var image by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||||
|
// Decoding off the main thread: an uploaded avatar can weigh the
|
||||||
|
// claw's full 2 MB image cap.
|
||||||
|
LaunchedEffect(cache.avatar) {
|
||||||
|
image = cache.avatar?.let { bytes ->
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val identity = cache.identity
|
||||||
|
val name = identity?.name?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
val glyph = identity?.initials?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?: name?.take(1)
|
||||||
|
?: "R"
|
||||||
|
return AgentFace(name, glyph, image)
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
package rs.rhema.phone.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
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.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
// The design language's shared pieces, mirroring the web components:
|
||||||
|
// one card voice, one label voice, one badge/dot/callout vocabulary.
|
||||||
|
// Screens compose these; nothing here knows about the claw API.
|
||||||
|
|
||||||
|
/** The uppercase letterspaced section label — the repeated typographic voice. */
|
||||||
|
@Composable
|
||||||
|
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
modifier = modifier,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = JadeiteText3,
|
||||||
|
letterSpacing = MaterialTheme.typography.labelMedium.letterSpacing * 1.4f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class DotTone { Ok, Warn, Error, Idle }
|
||||||
|
|
||||||
|
/** One honest state dot: ok, warn, error, or idle gray. */
|
||||||
|
@Composable
|
||||||
|
fun StatusDot(tone: DotTone, modifier: Modifier = Modifier) {
|
||||||
|
val color = when (tone) {
|
||||||
|
DotTone.Ok -> JadeiteOk
|
||||||
|
DotTone.Warn -> JadeiteWarn
|
||||||
|
DotTone.Error -> JadeiteError
|
||||||
|
DotTone.Idle -> JadeiteText3
|
||||||
|
}
|
||||||
|
Box(modifier = modifier.size(8.dp).clip(RoundedCornerShape(50)).background(color))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pill: one small status or tag word, outlined in its tone. */
|
||||||
|
@Composable
|
||||||
|
fun AppBadge(text: String, modifier: Modifier = Modifier, tint: Color = JadeiteText3) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
color = Color.Transparent,
|
||||||
|
border = BorderStroke(1.dp, tint),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 1.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontFamily = JadeiteMono,
|
||||||
|
color = tint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The avatar square: the agent's picture when the claw has one, the
|
||||||
|
* accent mark (or a neutral one) otherwise — the same precedence as the
|
||||||
|
* web's `AgentAvatar`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppAvatar(
|
||||||
|
mark: String,
|
||||||
|
accent: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
size: Dp = 32.dp,
|
||||||
|
image: ImageBitmap? = null,
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(size / 4)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(shape)
|
||||||
|
.then(
|
||||||
|
if (image == null) {
|
||||||
|
Modifier
|
||||||
|
.background(if (accent) JadeiteAccent else JadeiteElevated)
|
||||||
|
.border(
|
||||||
|
if (accent) 0.dp else 1.dp,
|
||||||
|
if (accent) Color.Transparent else JadeiteBorderStrong,
|
||||||
|
shape,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (image != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = image,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.matchParentSize(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
mark,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (accent) JadeiteOnAccent else JadeiteText2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page header: title, optional subtitle, trailing actions. */
|
||||||
|
@Composable
|
||||||
|
fun PageHeader(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
subtitle: String? = null,
|
||||||
|
actions: (@Composable RowScope.() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||||
|
subtitle?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall, color = JadeiteText3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions?.invoke(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one card: a titled container whose body rows separate by hairline.
|
||||||
|
* Rows are the caller's job ([CardRow]); pass `first = true` to the
|
||||||
|
* card's first row so it draws no divider.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RhemaCard(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
meta: String? = null,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = JadeiteSurface,
|
||||||
|
border = BorderStroke(1.dp, JadeiteBorder),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
meta?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider(color = JadeiteBorder)
|
||||||
|
Column(content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row inside a [RhemaCard]: name and explanation on the left, control
|
||||||
|
* (or value) on the right. Hairline separation is drawn here except on
|
||||||
|
* the card's first row.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CardRow(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
description: String? = null,
|
||||||
|
first: Boolean = false,
|
||||||
|
control: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
if (!first) HorizontalDivider(color = JadeiteBorder)
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = if (description == null) Alignment.CenterVertically else Alignment.Top,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||||
|
description?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall, color = JadeiteText3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
control?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tones of [AppCallout]: the inline note vocabulary. */
|
||||||
|
enum class CalloutTone { Info, Ok, Warn, Error }
|
||||||
|
|
||||||
|
/** The toned inline callout — the "changes not applied" voice. */
|
||||||
|
@Composable
|
||||||
|
fun AppCallout(
|
||||||
|
tone: CalloutTone,
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
title: String? = null,
|
||||||
|
) {
|
||||||
|
val tint = when (tone) {
|
||||||
|
CalloutTone.Info -> JadeiteInfo
|
||||||
|
CalloutTone.Ok -> JadeiteOk
|
||||||
|
CalloutTone.Warn -> JadeiteWarn
|
||||||
|
CalloutTone.Error -> JadeiteError
|
||||||
|
}
|
||||||
|
val mark = when (tone) {
|
||||||
|
CalloutTone.Info -> "i"
|
||||||
|
CalloutTone.Ok -> "✓"
|
||||||
|
CalloutTone.Warn -> "↺"
|
||||||
|
CalloutTone.Error -> "✗"
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = tint.copy(alpha = 0.08f),
|
||||||
|
border = BorderStroke(1.dp, JadeiteBorder),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
mark,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = tint,
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
title?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
Text(text, style = MaterialTheme.typography.bodySmall, color = JadeiteText2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The design's select: a sunken field that opens a menu. Options are
|
||||||
|
* (value, label) pairs; an empty-string value is a legitimate option
|
||||||
|
* ("(provider default)"), which is why selection is by value.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RhemaSelect(
|
||||||
|
selected: String?,
|
||||||
|
options: List<Pair<String, String>>,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
val label = options.firstOrNull { it.first == selected }?.second ?: selected ?: ""
|
||||||
|
Box(modifier) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = JadeiteSunken,
|
||||||
|
border = BorderStroke(1.dp, JadeiteBorder),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable(enabled = enabled) { open = true },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = if (selected == null) JadeiteText3 else JadeiteText,
|
||||||
|
)
|
||||||
|
Text("▾", style = MaterialTheme.typography.bodySmall, color = JadeiteText3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = open,
|
||||||
|
onDismissRequest = { open = false },
|
||||||
|
containerColor = JadeiteElevated,
|
||||||
|
) {
|
||||||
|
options.forEach { (value, optionLabel) ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
optionLabel,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontFamily = if (value.isEmpty()) null else JadeiteMono,
|
||||||
|
color = if (value == selected) JadeiteAccentText else JadeiteText2,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onSelect(value)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tool-card accent bar: the design's one-sided border. The parent row must size itself (IntrinsicSize.Min) for the bar to span it. */
|
||||||
|
@Composable
|
||||||
|
fun AccentBar(color: Color, modifier: Modifier = Modifier) {
|
||||||
|
Box(modifier = modifier.width(3.dp).fillMaxHeight().background(color))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A quiet icon action: transparent, dim, brightening on press. */
|
||||||
|
@Composable
|
||||||
|
fun GhostIcon(
|
||||||
|
icon: ImageVector,
|
||||||
|
contentDescription: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onClick, enabled = enabled, modifier = modifier) {
|
||||||
|
Icon(icon, contentDescription = contentDescription, tint = JadeiteText3)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package rs.rhema.phone.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.mikepenz.markdown.m3.Markdown
|
||||||
|
import com.mikepenz.markdown.m3.markdownColor
|
||||||
|
import com.mikepenz.markdown.m3.markdownTypography
|
||||||
|
import com.mikepenz.markdown.model.markdownDimens
|
||||||
|
|
||||||
|
// Agent replies are markdown; this renders them the way the web UI's
|
||||||
|
// AppMarkdown does. The parser runs the GFM flavour: raw HTML in the
|
||||||
|
// source stays literal, so model output cannot mount markup here.
|
||||||
|
// Code and links pin Jadeite tokens; everything else follows the theme.
|
||||||
|
// Headings step down from the Material display sizes: a chat reply is
|
||||||
|
// not a document (the web styles ride the same scale).
|
||||||
|
@Composable
|
||||||
|
internal fun AgentMarkdown(text: String, modifier: Modifier = Modifier, streaming: Boolean = false) {
|
||||||
|
// The cursor rides the source itself: the parser treats it as text at
|
||||||
|
// the stream edge, so it lands where the reply currently ends.
|
||||||
|
val base = MaterialTheme.typography.bodyLarge
|
||||||
|
Markdown(
|
||||||
|
if (streaming) text + "▊" else text,
|
||||||
|
colors = markdownColor(
|
||||||
|
codeBackground = JadeiteSunken,
|
||||||
|
inlineCodeBackground = JadeiteSunken,
|
||||||
|
dividerColor = JadeiteBorder,
|
||||||
|
tableBackground = JadeiteSurface,
|
||||||
|
),
|
||||||
|
typography = markdownTypography(
|
||||||
|
h1 = base.copy(fontWeight = FontWeight.Bold),
|
||||||
|
h2 = base.copy(fontSize = 15.sp, fontWeight = FontWeight.Bold),
|
||||||
|
h3 = base.copy(fontSize = 14.sp, fontWeight = FontWeight.Bold),
|
||||||
|
h4 = base.copy(fontSize = 13.sp, fontWeight = FontWeight.Bold),
|
||||||
|
h5 = base.copy(fontSize = 13.sp, fontWeight = FontWeight.Bold),
|
||||||
|
h6 = base.copy(fontSize = 13.sp, fontWeight = FontWeight.Bold),
|
||||||
|
code = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontFamily = JadeiteMono,
|
||||||
|
color = JadeiteText,
|
||||||
|
),
|
||||||
|
inlineCode = base.copy(fontFamily = JadeiteMono, color = JadeiteText),
|
||||||
|
textLink = TextLinkStyles(
|
||||||
|
style = SpanStyle(
|
||||||
|
color = JadeiteAccentText,
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// The default 160.dp per cell makes a three-column table wider
|
||||||
|
// than the phone screen; at 100.dp it fits and only wider tables
|
||||||
|
// turn into their horizontal-scroll variant.
|
||||||
|
dimens = markdownDimens(tableCellWidth = 100.dp, tableCellPadding = 8.dp),
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package rs.rhema.phone.ui
|
||||||
|
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import rs.rhema.phone.R
|
||||||
|
import rs.rhema.phone.di.AppContainer
|
||||||
|
import rs.rhema.phone.net.ClawResult
|
||||||
|
import rs.rhema.phone.net.PhoneInfo
|
||||||
|
import rs.rhema.phone.sensors.PhoneSensors
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fleet management card: every device the claw has heard from,
|
||||||
|
* this one marked, every other forgettable. The claw's
|
||||||
|
* `GET`/`DELETE /v1/phones` are the whole story — the app holds no
|
||||||
|
* private view of the fleet, so what this shows is what any client sees.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PhonesCard(container: AppContainer) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var phones by remember { mutableStateOf<List<PhoneInfo>?>(null) }
|
||||||
|
var loadError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var forgetCandidate by remember { mutableStateOf<PhoneInfo?>(null) }
|
||||||
|
var forgotName by remember { mutableStateOf<String?>(null) }
|
||||||
|
val deviceId by container.settings.deviceIdFlow.collectAsState(initial = "")
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
scope.launch {
|
||||||
|
when (val result = container.api.phones()) {
|
||||||
|
is ClawResult.Ok -> {
|
||||||
|
phones = result.value.phones
|
||||||
|
loadError = null
|
||||||
|
}
|
||||||
|
is ClawResult.HttpError ->
|
||||||
|
loadError = "HTTP ${result.code}" + (result.body?.let { " — $it" } ?: "")
|
||||||
|
is ClawResult.Network -> loadError = result.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { load() }
|
||||||
|
|
||||||
|
RhemaCard(title = stringResource(R.string.card_phones), meta = stringResource(R.string.phones_hint)) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
val currentError = loadError
|
||||||
|
val list = phones
|
||||||
|
when {
|
||||||
|
currentError != null -> AppCallout(
|
||||||
|
tone = CalloutTone.Error,
|
||||||
|
text = stringResource(R.string.providers_error, currentError),
|
||||||
|
)
|
||||||
|
list == null -> Text(
|
||||||
|
stringResource(R.string.phones_loading),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
list.isEmpty() -> Text(
|
||||||
|
stringResource(R.string.phones_empty),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
forgotName?.let { name ->
|
||||||
|
AppCallout(tone = CalloutTone.Ok, text = stringResource(R.string.phones_forgot_fmt, name))
|
||||||
|
}
|
||||||
|
list.forEachIndexed { index, phone ->
|
||||||
|
val mine = phone.device == deviceId
|
||||||
|
CardRow(
|
||||||
|
title = phone.label ?: shortDeviceId(phone.device),
|
||||||
|
description = fleetRowDescription(phone, mine),
|
||||||
|
first = index == 0 && forgotName == null,
|
||||||
|
) {
|
||||||
|
if (mine) {
|
||||||
|
AppBadge(stringResource(R.string.phones_this_device))
|
||||||
|
} else {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { forgetCandidate = phone },
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.phones_forget))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
forgetCandidate?.let { candidate ->
|
||||||
|
val name = candidate.label ?: shortDeviceId(candidate.device)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { forgetCandidate = null },
|
||||||
|
title = { Text(stringResource(R.string.phones_forget_title, name)) },
|
||||||
|
text = { Text(stringResource(R.string.phones_forget_text)) },
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
forgetCandidate = null
|
||||||
|
scope.launch {
|
||||||
|
when (val result = container.api.forgetPhone(candidate.device)) {
|
||||||
|
is ClawResult.Ok -> {
|
||||||
|
forgotName = name
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
is ClawResult.HttpError ->
|
||||||
|
loadError = "HTTP ${result.code}" + (result.body?.let { " — $it" } ?: "")
|
||||||
|
is ClawResult.Network -> loadError = result.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.phones_forget)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { forgetCandidate = null }) {
|
||||||
|
Text(stringResource(R.string.providers_discard))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this phone reports and what it cannot until the human flips a
|
||||||
|
* grant: location (a runtime permission) and the two special-app
|
||||||
|
* toggles Android requires for usage stats and DND control. The state
|
||||||
|
* re-reads on every ON_RESUME, so coming back from the settings app
|
||||||
|
* flips the row without a restart.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ReportingCard() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
var resumeKey by remember { mutableIntStateOf(0) }
|
||||||
|
DisposableEffect(lifecycleOwner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) resumeKey++
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val locationOn = remember(resumeKey) { PhoneSensors.hasLocation(context) }
|
||||||
|
val usageOn = remember(resumeKey) { PhoneSensors.hasUsageAccess(context) }
|
||||||
|
val dndOn = remember(resumeKey) {
|
||||||
|
context.getSystemService(NotificationManager::class.java)
|
||||||
|
?.isNotificationPolicyAccessGranted == true
|
||||||
|
}
|
||||||
|
|
||||||
|
RhemaCard(
|
||||||
|
title = stringResource(R.string.card_reporting),
|
||||||
|
meta = stringResource(R.string.reporting_hint),
|
||||||
|
) {
|
||||||
|
CardRow(
|
||||||
|
title = stringResource(R.string.reporting_location_title),
|
||||||
|
description = stringResource(R.string.reporting_location_desc),
|
||||||
|
first = true,
|
||||||
|
) {
|
||||||
|
GrantControl(locationOn) { openAppSettings(context) }
|
||||||
|
}
|
||||||
|
CardRow(
|
||||||
|
title = stringResource(R.string.reporting_usage_title),
|
||||||
|
description = stringResource(R.string.reporting_usage_desc),
|
||||||
|
) {
|
||||||
|
GrantControl(usageOn) { openUsageAccessSettings(context) }
|
||||||
|
}
|
||||||
|
CardRow(
|
||||||
|
title = stringResource(R.string.reporting_dnd_title),
|
||||||
|
description = stringResource(R.string.reporting_dnd_desc),
|
||||||
|
) {
|
||||||
|
GrantControl(dndOn) {
|
||||||
|
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On reads as a quiet badge; off as a button into the system settings. */
|
||||||
|
@Composable
|
||||||
|
private fun GrantControl(on: Boolean, onAllow: () -> Unit) {
|
||||||
|
if (on) {
|
||||||
|
AppBadge(stringResource(R.string.reporting_on), tint = JadeiteOk)
|
||||||
|
} else {
|
||||||
|
OutlinedButton(onClick = onAllow) {
|
||||||
|
Text(stringResource(R.string.reporting_allow))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openAppSettings(context: Context) {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(
|
||||||
|
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||||
|
Uri.fromParts("package", context.packageName, null),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openUsageAccessSettings(context: Context) {
|
||||||
|
context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fleet row's one-line state: battery, network, current app, and how
|
||||||
|
* long ago any of it landed.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun fleetRowDescription(phone: PhoneInfo, mine: Boolean): String {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
|
||||||
|
(phone.topics["battery"]?.field("value"))?.contentOrNull?.let { level ->
|
||||||
|
val charging = phone.topics["battery"]?.field("charging")?.booleanOrNull == true
|
||||||
|
parts += if (charging) "$level% charging" else "$level%"
|
||||||
|
}
|
||||||
|
phone.topics["network"]?.field("value")?.contentOrNull?.let { parts += it }
|
||||||
|
phone.topics["app"]?.field("label")?.contentOrNull?.let { parts += it }
|
||||||
|
?: phone.topics["app"]?.field("value")?.contentOrNull
|
||||||
|
// No label on the payload: the package's last segment is the
|
||||||
|
// closest readable name ("maps" out of com.google.android.maps).
|
||||||
|
?.let { pkg -> pkg.substringAfterLast('.').ifEmpty { pkg } }
|
||||||
|
?.let { parts += it }
|
||||||
|
|
||||||
|
val seen = when {
|
||||||
|
phone.lastSeen <= 0L -> stringResource(R.string.phones_never_reported)
|
||||||
|
System.currentTimeMillis() - phone.lastSeen < 60_000 ->
|
||||||
|
stringResource(R.string.phones_seen_just_now)
|
||||||
|
else -> stringResource(R.string.phones_seen_fmt, relativeTime(phone.lastSeen))
|
||||||
|
}
|
||||||
|
if (mine && parts.isEmpty()) parts += stringResource(R.string.phones_never_reported)
|
||||||
|
|
||||||
|
val summary = parts.joinToString(" · ")
|
||||||
|
return if (summary.isEmpty()) seen else "$summary · $seen"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.field(name: String): JsonPrimitive? = this[name] as? JsonPrimitive
|
||||||
|
|
||||||
|
/** A UUID is noise; its first block is enough to tell rows apart. */
|
||||||
|
private fun shortDeviceId(device: String): String = device.take(8)
|
||||||
|
|
||||||
|
private fun relativeTime(thenMillis: Long): String {
|
||||||
|
val minutes = ((System.currentTimeMillis() - thenMillis) / 60000L).coerceAtLeast(0)
|
||||||
|
return when {
|
||||||
|
minutes < 60 -> "${minutes}m"
|
||||||
|
minutes < 60 * 24 -> "${minutes / 60}h"
|
||||||
|
else -> "${minutes / (60 * 24)}d"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package rs.rhema.phone.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import rs.rhema.phone.R
|
||||||
|
import rs.rhema.phone.di.AppContainer
|
||||||
|
import rs.rhema.phone.net.ClawResult
|
||||||
|
import rs.rhema.phone.net.SessionInfo
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
// The session inventory, the design's sidebar as a screen: grouped
|
||||||
|
// interactive-first rows with an honest state dot and the inbox depth.
|
||||||
|
// Displayed names drop the `interactive:` prefix; the full key is what
|
||||||
|
// gets opened.
|
||||||
|
@Composable
|
||||||
|
fun SessionsScreen(
|
||||||
|
container: AppContainer,
|
||||||
|
onOpenSession: (String) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var sessions by remember { mutableStateOf<List<SessionInfo>?>(null) }
|
||||||
|
var error by remember { mutableStateOf<UiMessage?>(null) }
|
||||||
|
var loading by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val refresh: () -> Unit = {
|
||||||
|
scope.launch {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
// The agent may have renamed or re-faced itself on another
|
||||||
|
// surface; this button is the manual path for the cached identity.
|
||||||
|
container.identity.refresh()
|
||||||
|
when (val result = container.api.sessions()) {
|
||||||
|
is ClawResult.Ok -> sessions = result.value
|
||||||
|
is ClawResult.HttpError -> error = UiMessage.Http(result.code, result.body)
|
||||||
|
is ClawResult.Network -> error = UiMessage.Network(result.message)
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { refresh() }
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
PageHeader(
|
||||||
|
title = stringResource(R.string.sessions_title),
|
||||||
|
actions = {
|
||||||
|
GhostIcon(Icons.Filled.Settings, stringResource(R.string.sessions_open_settings), onClick = onOpenSettings)
|
||||||
|
GhostIcon(Icons.Filled.Refresh, stringResource(R.string.sessions_refresh), enabled = !loading, onClick = refresh)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val currentError = error
|
||||||
|
val visible = sessions?.let(::displayable)
|
||||||
|
when {
|
||||||
|
currentError != null -> Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
AppCallout(
|
||||||
|
tone = CalloutTone.Error,
|
||||||
|
text = when (currentError) {
|
||||||
|
is UiMessage.Http -> stringResource(R.string.sessions_http_error, currentError.code) +
|
||||||
|
(currentError.detail?.let { " — $it" } ?: "")
|
||||||
|
is UiMessage.Network -> stringResource(R.string.sessions_network_error, currentError.message)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (currentError is UiMessage.Http && currentError.code == 401) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.sessions_unauthorized_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Button(onClick = onOpenSettings) {
|
||||||
|
Text(stringResource(R.string.sessions_open_settings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loading && sessions == null -> Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
visible != null && visible.isEmpty() -> Text(
|
||||||
|
stringResource(R.string.sessions_empty),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
visible != null -> SessionsList(
|
||||||
|
sessions = visible,
|
||||||
|
onOpenSession = onOpenSession,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class SessionGroup(val labelKey: String, val rows: List<SessionInfo>)
|
||||||
|
|
||||||
|
private const val GROUP_INTERACTIVE = "interactive"
|
||||||
|
private const val GROUP_OTHER = "other"
|
||||||
|
|
||||||
|
// `phone:<device>:<topic>` keys are this device's own status reports,
|
||||||
|
// not conversations — listed they read as broken empty sessions, so
|
||||||
|
// they never reach a group.
|
||||||
|
private fun displayable(sessions: List<SessionInfo>): List<SessionInfo> =
|
||||||
|
sessions.filterNot { it.key.startsWith("phone:") }
|
||||||
|
|
||||||
|
// Interactive keys are the chat surface and lead; other dispatch keys
|
||||||
|
// read after. Key order is stable within a group.
|
||||||
|
private fun groupSessions(sessions: List<SessionInfo>): List<SessionGroup> {
|
||||||
|
val byKey = compareBy<SessionInfo> { it.key }
|
||||||
|
val visible = displayable(sessions)
|
||||||
|
val interactive = visible.filter { it.key.startsWith("interactive:") }.sortedWith(byKey)
|
||||||
|
val others = visible.filterNot { it.key.startsWith("interactive:") }.sortedWith(byKey)
|
||||||
|
val groups = mutableListOf<SessionGroup>()
|
||||||
|
if (interactive.isNotEmpty()) {
|
||||||
|
groups += SessionGroup(GROUP_INTERACTIVE, interactive)
|
||||||
|
}
|
||||||
|
if (others.isNotEmpty()) {
|
||||||
|
groups += SessionGroup(GROUP_OTHER, others)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SessionsList(sessions: List<SessionInfo>, onOpenSession: (String) -> Unit) {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
groupSessions(sessions).forEach { group ->
|
||||||
|
item(key = group.labelKey) {
|
||||||
|
SectionLabel(
|
||||||
|
stringResource(
|
||||||
|
if (group.labelKey == GROUP_INTERACTIVE) {
|
||||||
|
R.string.sessions_group_interactive
|
||||||
|
} else {
|
||||||
|
R.string.sessions_group_other
|
||||||
|
},
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(group.rows, key = { it.key }) { session ->
|
||||||
|
SessionRow(session) { onOpenSession(session.key) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SessionRow(session: SessionInfo, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
StatusDot(tone = stateTone(session.state))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
session.key.removePrefix("interactive:"),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(
|
||||||
|
session.state,
|
||||||
|
session.revision?.let { "rev $it" },
|
||||||
|
).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = JadeiteMono,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val depth = session.inboxDepth
|
||||||
|
if (depth != null && depth > 0) {
|
||||||
|
AppBadge(depth.toString(), tint = JadeiteInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leased keys are alive, reacquiring ones are troubled, the rest idle. */
|
||||||
|
private fun stateTone(state: String?): DotTone = when (state) {
|
||||||
|
"leased" -> DotTone.Ok
|
||||||
|
"reacquire" -> DotTone.Warn
|
||||||
|
else -> DotTone.Idle
|
||||||
|
}
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
package rs.rhema.phone.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import rs.rhema.phone.R
|
||||||
|
import rs.rhema.phone.di.AppContainer
|
||||||
|
import rs.rhema.phone.net.ActivePair
|
||||||
|
import rs.rhema.phone.net.ClawResult
|
||||||
|
import rs.rhema.phone.net.ModelCard
|
||||||
|
import rs.rhema.phone.net.ProvidersState
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
// Settings in the design language, as a nav of submenus mirroring the
|
||||||
|
// web UI's settings sidebar: labeled groups ("Phone", "Claw") of one
|
||||||
|
// page each — status reporting, devices, model providers, connection.
|
||||||
|
// First launch is the exception: it lands straight on the connection
|
||||||
|
// page and stays there until GET /healthz succeeds.
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(container: AppContainer, onConnected: () -> Unit = {}) {
|
||||||
|
val configured by container.settings.configured.collectAsState(initial = null)
|
||||||
|
var section by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
// Back out of a submenu before leaving settings entirely; the
|
||||||
|
// caller's BackHandler (enabled only when this one is not) takes
|
||||||
|
// the rest.
|
||||||
|
BackHandler(enabled = section != null && configured == true) { section = null }
|
||||||
|
|
||||||
|
when {
|
||||||
|
configured == false -> ConnectionPage(container, onConnected = onConnected, onBack = null)
|
||||||
|
section == null -> SettingsNav { section = it }
|
||||||
|
else -> when (section) {
|
||||||
|
SECTION_REPORTING -> SettingsPage(
|
||||||
|
title = stringResource(R.string.settings_entry_reporting),
|
||||||
|
onBack = { section = null },
|
||||||
|
) { ReportingCard() }
|
||||||
|
SECTION_DEVICES -> SettingsPage(
|
||||||
|
title = stringResource(R.string.settings_entry_devices),
|
||||||
|
onBack = { section = null },
|
||||||
|
) { PhonesCard(container) }
|
||||||
|
SECTION_PROVIDERS -> SettingsPage(
|
||||||
|
title = stringResource(R.string.settings_entry_providers),
|
||||||
|
onBack = { section = null },
|
||||||
|
) { ProvidersCard(container) }
|
||||||
|
else -> ConnectionPage(container, onConnected = onConnected, onBack = { section = null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The nav entries. Ids are stable spellings (saveable state survives
|
||||||
|
// rotation through them).
|
||||||
|
private const val SECTION_REPORTING = "reporting"
|
||||||
|
private const val SECTION_DEVICES = "devices"
|
||||||
|
private const val SECTION_PROVIDERS = "providers"
|
||||||
|
private const val SECTION_CONNECTION = "connection"
|
||||||
|
|
||||||
|
private data class SettingsEntry(val id: String, val labelRes: Int, val descRes: Int)
|
||||||
|
private data class SettingsSection(val labelRes: Int, val entries: List<SettingsEntry>)
|
||||||
|
|
||||||
|
private val SECTIONS = listOf(
|
||||||
|
SettingsSection(
|
||||||
|
R.string.settings_group_phone,
|
||||||
|
listOf(
|
||||||
|
SettingsEntry(
|
||||||
|
SECTION_REPORTING,
|
||||||
|
R.string.settings_entry_reporting,
|
||||||
|
R.string.settings_entry_reporting_desc,
|
||||||
|
),
|
||||||
|
SettingsEntry(
|
||||||
|
SECTION_DEVICES,
|
||||||
|
R.string.settings_entry_devices,
|
||||||
|
R.string.settings_entry_devices_desc,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SettingsSection(
|
||||||
|
R.string.settings_group_claw,
|
||||||
|
listOf(
|
||||||
|
SettingsEntry(
|
||||||
|
SECTION_PROVIDERS,
|
||||||
|
R.string.settings_entry_providers,
|
||||||
|
R.string.settings_entry_providers_desc,
|
||||||
|
),
|
||||||
|
SettingsEntry(
|
||||||
|
SECTION_CONNECTION,
|
||||||
|
R.string.settings_entry_connection,
|
||||||
|
R.string.settings_entry_connection_desc,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsNav(onSelect: (String) -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
PageHeader(title = stringResource(R.string.settings_page_title))
|
||||||
|
SECTIONS.forEach { section ->
|
||||||
|
SectionLabel(
|
||||||
|
stringResource(section.labelRes),
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
section.entries.forEach { entry ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelect(entry.id) }
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
stringResource(entry.labelRes),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(entry.descRes),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text("›", style = MaterialTheme.typography.titleMedium, color = JadeiteText3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A submenu: a back arrow and title over the page's card. */
|
||||||
|
@Composable
|
||||||
|
private fun SettingsPage(title: String, onBack: () -> Unit, content: @Composable () -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.settings_back),
|
||||||
|
tint = JadeiteText2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 16.dp)) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The connection page: first launch lands here alone (onBack = null
|
||||||
|
* and the header is the plain Settings title); from the nav it is a
|
||||||
|
* submenu like the rest. */
|
||||||
|
@Composable
|
||||||
|
private fun ConnectionPage(container: AppContainer, onConnected: () -> Unit, onBack: (() -> Unit)?) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var baseUrl by rememberSaveable { mutableStateOf("") }
|
||||||
|
var token by rememberSaveable { mutableStateOf("") }
|
||||||
|
var busy by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var status by remember { mutableStateOf<UiMessage?>(null) }
|
||||||
|
val deviceId by container.settings.deviceIdFlow.collectAsState(initial = "…")
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val saved = container.settings.current()
|
||||||
|
if (saved.baseUrl.isNotBlank()) baseUrl = saved.baseUrl
|
||||||
|
if (saved.token.isNotBlank()) token = saved.token
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
if (onBack == null) {
|
||||||
|
PageHeader(title = stringResource(R.string.settings_page_title))
|
||||||
|
} else {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.settings_back),
|
||||||
|
tint = JadeiteText2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.settings_entry_connection),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
|
ConnectionCard(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
onBaseUrl = { baseUrl = it },
|
||||||
|
token = token,
|
||||||
|
onToken = { token = it },
|
||||||
|
busy = busy,
|
||||||
|
onConnect = {
|
||||||
|
busy = true
|
||||||
|
status = null
|
||||||
|
scope.launch {
|
||||||
|
container.settings.setCredentials(baseUrl.trim(), token.trim())
|
||||||
|
when (val result = container.api.health()) {
|
||||||
|
is ClawResult.Ok -> onConnected()
|
||||||
|
is ClawResult.HttpError -> status = UiMessage.Http(result.code, result.body)
|
||||||
|
is ClawResult.Network -> status = UiMessage.Network(result.message)
|
||||||
|
}
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status = status,
|
||||||
|
deviceId = deviceId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConnectionCard(
|
||||||
|
baseUrl: String,
|
||||||
|
onBaseUrl: (String) -> Unit,
|
||||||
|
token: String,
|
||||||
|
onToken: (String) -> Unit,
|
||||||
|
busy: Boolean,
|
||||||
|
onConnect: () -> Unit,
|
||||||
|
status: UiMessage?,
|
||||||
|
deviceId: String,
|
||||||
|
) {
|
||||||
|
RhemaCard(title = stringResource(R.string.card_connection)) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = baseUrl,
|
||||||
|
onValueChange = onBaseUrl,
|
||||||
|
label = { Text(stringResource(R.string.field_base_url)) },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !busy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
colors = sunkenFieldColors(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = token,
|
||||||
|
onValueChange = onToken,
|
||||||
|
label = { Text(stringResource(R.string.field_token)) },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !busy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
colors = sunkenFieldColors(),
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(enabled = !busy && baseUrl.isNotBlank(), onClick = onConnect) {
|
||||||
|
Text(stringResource(R.string.save_and_connect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val currentStatus = status
|
||||||
|
if (currentStatus != null) {
|
||||||
|
val text: String = when (currentStatus) {
|
||||||
|
is UiMessage.Http -> stringResource(R.string.settings_health_http, currentStatus.code) +
|
||||||
|
(currentErrorDetail(currentStatus) ?: "")
|
||||||
|
is UiMessage.Network -> stringResource(R.string.settings_health_network, currentStatus.message)
|
||||||
|
}
|
||||||
|
AppCallout(tone = CalloutTone.Error, text = text)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.device_id_fmt, deviceId),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = JadeiteMono,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.settings_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentErrorDetail(status: UiMessage.Http): String? =
|
||||||
|
status.detail?.let { " — $it" }
|
||||||
|
|
||||||
|
/** The Model providers card: the staged pair, its listing, and Save/Discard. */
|
||||||
|
@Composable
|
||||||
|
private fun ProvidersCard(container: AppContainer) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var state by remember { mutableStateOf<ProvidersState?>(null) }
|
||||||
|
var loadError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var listing by remember { mutableStateOf<ListingState>(ListingState.Idle) }
|
||||||
|
var draftProvider by remember { mutableStateOf<String?>(null) }
|
||||||
|
var draftModel by remember { mutableStateOf(DEFAULT_MODEL) }
|
||||||
|
var saving by remember { mutableStateOf(false) }
|
||||||
|
var savedPair by remember { mutableStateOf<ActivePair?>(null) }
|
||||||
|
var saveError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
fun defaultOf(id: String?): String =
|
||||||
|
state?.providers?.firstOrNull { it.id == id }?.defaultModel ?: ""
|
||||||
|
|
||||||
|
fun loadModels(id: String) {
|
||||||
|
listing = ListingState.Loading
|
||||||
|
scope.launch {
|
||||||
|
when (val result = container.api.providerModels(id)) {
|
||||||
|
is ClawResult.Ok -> listing = ListingState.Available(result.value.models)
|
||||||
|
is ClawResult.HttpError ->
|
||||||
|
// A 404 is the documented "no live listing", not a
|
||||||
|
// failure: the instance still switches on its default model.
|
||||||
|
listing = ListingState.Unavailable("HTTP ${result.code}")
|
||||||
|
is ClawResult.Network -> listing = ListingState.Unavailable(result.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
when (val result = container.api.providers()) {
|
||||||
|
is ClawResult.Ok -> {
|
||||||
|
state = result.value
|
||||||
|
val active = result.value.active
|
||||||
|
draftProvider = active.provider
|
||||||
|
draftModel = if (active.model == defaultOf(active.provider)) DEFAULT_MODEL else active.model
|
||||||
|
loadModels(active.provider)
|
||||||
|
}
|
||||||
|
is ClawResult.HttpError -> loadError = "HTTP ${result.code}" + (result.body?.let { " — $it" } ?: "")
|
||||||
|
is ClawResult.Network -> loadError = result.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val active = state?.active
|
||||||
|
val dirty = active != null && draftProvider != null && (
|
||||||
|
draftProvider != active.provider ||
|
||||||
|
(draftModel.ifBlank { defaultOf(draftProvider) }) != active.model
|
||||||
|
)
|
||||||
|
|
||||||
|
RhemaCard(
|
||||||
|
title = stringResource(R.string.card_providers),
|
||||||
|
meta = stringResource(R.string.providers_hint),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
val currentLoadError = loadError
|
||||||
|
val currentState = state
|
||||||
|
when {
|
||||||
|
currentLoadError != null -> AppCallout(
|
||||||
|
tone = CalloutTone.Error,
|
||||||
|
text = stringResource(R.string.providers_error, currentLoadError),
|
||||||
|
)
|
||||||
|
currentState == null -> Text(
|
||||||
|
stringResource(R.string.providers_loading),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.providers_field_provider),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
RhemaSelect(
|
||||||
|
selected = draftProvider,
|
||||||
|
options = currentState.providers.map { it.id to it.id },
|
||||||
|
enabled = !saving,
|
||||||
|
onSelect = { id ->
|
||||||
|
draftProvider = id
|
||||||
|
draftModel = DEFAULT_MODEL
|
||||||
|
loadModels(id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.providers_field_model),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
RhemaSelect(
|
||||||
|
selected = draftModel,
|
||||||
|
options = when (val l = listing) {
|
||||||
|
is ListingState.Available ->
|
||||||
|
listOf(DEFAULT_MODEL to stringResource(R.string.providers_default_option)) +
|
||||||
|
l.models.map { it.id to it.id }
|
||||||
|
else -> listOf(DEFAULT_MODEL to stringResource(R.string.providers_default_option))
|
||||||
|
},
|
||||||
|
enabled = !saving,
|
||||||
|
onSelect = { draftModel = it },
|
||||||
|
)
|
||||||
|
if (listing is ListingState.Unavailable) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.providers_none),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = JadeiteText3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirty) {
|
||||||
|
AppCallout(
|
||||||
|
tone = CalloutTone.Warn,
|
||||||
|
title = stringResource(R.string.providers_staged),
|
||||||
|
text = stringResource(R.string.providers_staged_detail),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
savedPair?.let { pair ->
|
||||||
|
AppCallout(
|
||||||
|
tone = CalloutTone.Ok,
|
||||||
|
text = stringResource(R.string.providers_saved, pair.provider, pair.model),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
saveError?.let { err ->
|
||||||
|
AppCallout(
|
||||||
|
tone = CalloutTone.Error,
|
||||||
|
text = stringResource(R.string.providers_error, err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(
|
||||||
|
enabled = dirty && !saving,
|
||||||
|
onClick = {
|
||||||
|
val activeNow = state?.active ?: return@OutlinedButton
|
||||||
|
draftProvider = activeNow.provider
|
||||||
|
draftModel =
|
||||||
|
if (activeNow.model == defaultOf(activeNow.provider)) DEFAULT_MODEL else activeNow.model
|
||||||
|
savedPair = null
|
||||||
|
saveError = null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.providers_discard))
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
enabled = dirty && !saving && draftProvider != null,
|
||||||
|
onClick = {
|
||||||
|
val provider = draftProvider ?: return@Button
|
||||||
|
saving = true
|
||||||
|
savedPair = null
|
||||||
|
saveError = null
|
||||||
|
scope.launch {
|
||||||
|
when (
|
||||||
|
val result = container.api.setActiveProvider(
|
||||||
|
provider,
|
||||||
|
draftModel.ifBlank { null },
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
is ClawResult.Ok -> {
|
||||||
|
// The route answers the new pair only;
|
||||||
|
// carry the provider list forward.
|
||||||
|
val current = state
|
||||||
|
state = if (current != null) {
|
||||||
|
current.copy(active = result.value.active)
|
||||||
|
} else {
|
||||||
|
ProvidersState(result.value.active, emptyList())
|
||||||
|
}
|
||||||
|
savedPair = result.value.active
|
||||||
|
draftProvider = result.value.active.provider
|
||||||
|
draftModel = if (
|
||||||
|
result.value.active.model == defaultOf(result.value.active.provider)
|
||||||
|
) {
|
||||||
|
DEFAULT_MODEL
|
||||||
|
} else {
|
||||||
|
result.value.active.model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ClawResult.HttpError ->
|
||||||
|
saveError = "HTTP ${result.code}" + (result.body?.let { " — $it" } ?: "")
|
||||||
|
is ClawResult.Network -> saveError = result.message
|
||||||
|
}
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.providers_save))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed interface ListingState {
|
||||||
|
data object Idle : ListingState
|
||||||
|
data object Loading : ListingState
|
||||||
|
data class Available(val models: List<ModelCard>) : ListingState
|
||||||
|
data class Unavailable(val reason: String) : ListingState
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val DEFAULT_MODEL = ""
|
||||||
|
|
||||||
|
/** The design's input voice: sunken ground, hairline border, accent focus. */
|
||||||
|
@Composable
|
||||||
|
private fun sunkenFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = JadeiteAccent,
|
||||||
|
unfocusedBorderColor = JadeiteBorder,
|
||||||
|
focusedContainerColor = JadeiteSunken,
|
||||||
|
unfocusedContainerColor = JadeiteSunken,
|
||||||
|
cursorColor = JadeiteAccentText,
|
||||||
|
focusedTextColor = JadeiteText,
|
||||||
|
unfocusedTextColor = JadeiteText,
|
||||||
|
)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package rs.rhema.phone.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Shapes
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
// The Jadeite dark tokens, lifted from crates/rhema-code/src/palette.rs
|
||||||
|
// DARK: one design system across the TUI, the web UI, and this app.
|
||||||
|
// Dark is the design's primary; there is deliberately no light scheme
|
||||||
|
// and no dynamic color — the palette is the brand.
|
||||||
|
/** The design's dark ground; also the system-bar scrim. */
|
||||||
|
internal val JadeiteGround = Color(0xFF0F1413)
|
||||||
|
internal val JadeiteSurface = Color(0xFF171D1B)
|
||||||
|
internal val JadeiteElevated = Color(0xFF1F2624)
|
||||||
|
internal val JadeiteSunken = Color(0xFF0A0F0E)
|
||||||
|
internal val JadeiteBorder = Color(0xFF28312E)
|
||||||
|
internal val JadeiteBorderStrong = Color(0xFF3D4844)
|
||||||
|
internal val JadeiteText = Color(0xFFEEF3F1)
|
||||||
|
internal val JadeiteText2 = Color(0xFFB9C6C1)
|
||||||
|
internal val JadeiteText3 = Color(0xFF87948F)
|
||||||
|
internal val JadeiteAccent = Color(0xFF35A882)
|
||||||
|
internal val JadeiteAccentText = Color(0xFF64C9A3)
|
||||||
|
internal val JadeiteOnAccent = Color(0xFF07110D)
|
||||||
|
internal val JadeiteOk = Color(0xFF4FBF94)
|
||||||
|
internal val JadeiteWarn = Color(0xFFD9A643)
|
||||||
|
internal val JadeiteError = Color(0xFFE4687A)
|
||||||
|
internal val JadeiteInfo = Color(0xFF8FA6B8)
|
||||||
|
|
||||||
|
/** Machine spellings — keys, model ids, outcomes — render mono. */
|
||||||
|
internal val JadeiteMono: FontFamily = FontFamily.Monospace
|
||||||
|
|
||||||
|
private val JadeiteDark = darkColorScheme(
|
||||||
|
primary = JadeiteAccent,
|
||||||
|
onPrimary = JadeiteOnAccent,
|
||||||
|
primaryContainer = JadeiteElevated,
|
||||||
|
onPrimaryContainer = JadeiteAccentText,
|
||||||
|
secondary = JadeiteAccentText,
|
||||||
|
onSecondary = JadeiteOnAccent,
|
||||||
|
tertiary = JadeiteInfo,
|
||||||
|
background = JadeiteGround,
|
||||||
|
onBackground = JadeiteText,
|
||||||
|
surface = JadeiteSurface,
|
||||||
|
onSurface = JadeiteText,
|
||||||
|
surfaceVariant = JadeiteElevated,
|
||||||
|
onSurfaceVariant = JadeiteText2,
|
||||||
|
surfaceContainerLowest = JadeiteGround,
|
||||||
|
surfaceContainerLow = JadeiteSurface,
|
||||||
|
surfaceContainer = JadeiteSurface,
|
||||||
|
surfaceContainerHigh = JadeiteElevated,
|
||||||
|
surfaceContainerHighest = JadeiteElevated,
|
||||||
|
outline = JadeiteBorder,
|
||||||
|
outlineVariant = JadeiteBorderStrong,
|
||||||
|
error = JadeiteError,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The design's shape language: soft cards on a hairline grid, controls a
|
||||||
|
// step softer. Default Material radii read as someone else's app.
|
||||||
|
private val JadeiteShapes = Shapes(
|
||||||
|
extraSmall = RoundedCornerShape(6.dp),
|
||||||
|
small = RoundedCornerShape(8.dp),
|
||||||
|
medium = RoundedCornerShape(10.dp),
|
||||||
|
large = RoundedCornerShape(14.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The app's theme: Jadeite dark, always — matching the TUI and the web UI. */
|
||||||
|
@Composable
|
||||||
|
fun RhemaTheme(content: @Composable () -> Unit) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = JadeiteDark,
|
||||||
|
shapes = JadeiteShapes,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package rs.rhema.phone.ui
|
||||||
|
|
||||||
|
// Screen state holds the failure data, not a formatted string; user-visible
|
||||||
|
// text is resolved with stringResource at render time so configuration
|
||||||
|
// changes re-resolve it.
|
||||||
|
sealed interface UiMessage {
|
||||||
|
data class Http(val code: Int, val detail: String? = null) : UiMessage
|
||||||
|
data class Network(val message: String) : UiMessage
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package rs.rhema.phone.work
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
// WorkManager already re-enqueues its own periodic work after reboot; this
|
||||||
|
// receiver is the explicit boot kick. It is idempotent (KEEP policy).
|
||||||
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||||
|
schedulePeriodicWork(context.applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package rs.rhema.phone.work
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import rs.rhema.phone.RhemaApp
|
||||||
|
import rs.rhema.phone.net.ClawResult
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
|
// Periodic notification poll. Latency is bounded by the WorkManager interval
|
||||||
|
// (15 minutes, the OS floor); without push infrastructure that is the deal.
|
||||||
|
// The chat screen is always live via SSE regardless.
|
||||||
|
class NotificationsWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val container = (applicationContext as RhemaApp).container
|
||||||
|
val deviceId = container.settings.deviceId()
|
||||||
|
val since = container.status.notificationCursor.first()
|
||||||
|
|
||||||
|
val notifications = when (val result = container.api.notifications(deviceId, since)) {
|
||||||
|
is ClawResult.Ok -> result.value
|
||||||
|
is ClawResult.HttpError -> return if (result.code >= 500) Result.retry() else Result.failure()
|
||||||
|
is ClawResult.Network -> return Result.retry()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (notification in notifications) {
|
||||||
|
container.notifier.post(
|
||||||
|
notification.id.hashCode(),
|
||||||
|
notification.title ?: container.notifier.fallbackTitle(),
|
||||||
|
notification.body.orEmpty(),
|
||||||
|
)
|
||||||
|
// Acks are best-effort: the cursor means "seen", not "acked", so a
|
||||||
|
// failed ack does not re-notify on the next poll.
|
||||||
|
container.api.ackNotification(notification.id.toString())
|
||||||
|
container.status.setNotificationCursor(notification.id.toString())
|
||||||
|
}
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package rs.rhema.phone.work
|
||||||
|
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import rs.rhema.phone.RhemaApp
|
||||||
|
import rs.rhema.phone.di.AppContainer
|
||||||
|
import rs.rhema.phone.net.ClawResult
|
||||||
|
import rs.rhema.phone.net.PhoneCommand
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
|
||||||
|
// Periodic control-plane poll: the claw queues commands for this device
|
||||||
|
// once their phone consent is granted; each is executed in order, then
|
||||||
|
// answered. The result POST is the ack, so a failed post leaves the
|
||||||
|
// command queued and the next run repeats it — at-least-once delivery.
|
||||||
|
class PhoneCommandWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val container = (applicationContext as RhemaApp).container
|
||||||
|
val deviceId = container.settings.deviceId()
|
||||||
|
|
||||||
|
val commands = when (val result = container.api.phoneCommands(deviceId)) {
|
||||||
|
is ClawResult.Ok -> result.value
|
||||||
|
is ClawResult.HttpError -> return if (result.code >= 500) Result.retry() else Result.failure()
|
||||||
|
is ClawResult.Network -> return Result.retry()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (command in commands) {
|
||||||
|
val (ok, detail) = execute(container, command)
|
||||||
|
// Stop at the first failed ack: the queue is ordered, later
|
||||||
|
// commands must not jump ahead of an un-acked one.
|
||||||
|
if (container.api.postPhoneCommandResult(command.id, ok, detail) !is ClawResult.Ok) {
|
||||||
|
return Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
// One command, locally. The answer pair rides to the claw verbatim;
|
||||||
|
// the detail strings are wire content, not UI resources.
|
||||||
|
private fun execute(container: AppContainer, command: PhoneCommand): Pair<Boolean, String?> {
|
||||||
|
val notifications = applicationContext.getSystemService(NotificationManager::class.java)
|
||||||
|
return when (command.action) {
|
||||||
|
"dnd_on", "dnd_off" -> {
|
||||||
|
// Policy access is a settings grant, not a permission: it
|
||||||
|
// can only be given by the user in system settings, so a
|
||||||
|
// missing grant is reported back, never prompted here.
|
||||||
|
if (notifications?.isNotificationPolicyAccessGranted != true) {
|
||||||
|
false to DND_ACCESS_DETAIL
|
||||||
|
} else {
|
||||||
|
notifications.setInterruptionFilter(
|
||||||
|
if (command.action == "dnd_on") {
|
||||||
|
NotificationManager.INTERRUPTION_FILTER_PRIORITY
|
||||||
|
} else {
|
||||||
|
NotificationManager.INTERRUPTION_FILTER_ALL
|
||||||
|
},
|
||||||
|
)
|
||||||
|
true to null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"alert" -> {
|
||||||
|
val posted = container.notifier.postAlert(
|
||||||
|
command.id.hashCode(),
|
||||||
|
command.args.text("title") ?: "claw",
|
||||||
|
command.args.text("body") ?: command.action,
|
||||||
|
)
|
||||||
|
if (posted) {
|
||||||
|
true to null
|
||||||
|
} else {
|
||||||
|
true to "notification permission not granted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> false to "unknown action"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val DND_ACCESS_DETAIL =
|
||||||
|
"grant Do Not Disturb access to Rhema (Settings → Special app access)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only primitive strings count; anything else falls back to the
|
||||||
|
// caller's default.
|
||||||
|
private fun JsonObject.text(name: String): String? =
|
||||||
|
(this[name] as? JsonPrimitive)?.contentOrNull
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package rs.rhema.phone.work
|
||||||
|
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.os.BatteryManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import rs.rhema.phone.RhemaApp
|
||||||
|
import rs.rhema.phone.data.PhoneSnapshot
|
||||||
|
import rs.rhema.phone.net.ClawResult
|
||||||
|
import rs.rhema.phone.sensors.LocationFix
|
||||||
|
import rs.rhema.phone.sensors.ForegroundApp
|
||||||
|
import rs.rhema.phone.sensors.PhoneSensors
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
|
||||||
|
// Periodic phone-status source. Reads one snapshot, diffs it against the last
|
||||||
|
// one that was fully POSTed, and ingests only the transitions. If any post
|
||||||
|
// fails the snapshot is not saved, so the next run retries the transitions.
|
||||||
|
//
|
||||||
|
// Two kinds of topic leave this worker. Battery, network, and DND posts are
|
||||||
|
// conversation: the claw dispatches them and the agent hears the flip. The
|
||||||
|
// rest — location, app, device, wifi, alarm, heartbeat — are snapshot-only on
|
||||||
|
// the claw
|
||||||
|
// (its ingest door answers "observed" and never runs a turn), so their rules
|
||||||
|
// only bound write traffic, not model traffic.
|
||||||
|
class PhoneStatusWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val container = (applicationContext as RhemaApp).container
|
||||||
|
val deviceId = container.settings.deviceId()
|
||||||
|
// Suspend: the location read may wait out a fresh fix.
|
||||||
|
val reading = readSnapshot(applicationContext)
|
||||||
|
val snapshot = reading.sensors
|
||||||
|
val last = container.status.lastSnapshot()
|
||||||
|
|
||||||
|
val transitions = mutableListOf<Pair<String, JsonObject>>()
|
||||||
|
|
||||||
|
// Battery posts on charging flips and decile bucket changes only, so
|
||||||
|
// slow drain does not ping on every run.
|
||||||
|
val charging = snapshot.charging
|
||||||
|
val level = snapshot.batteryLevel
|
||||||
|
val batteryChanged = (charging != null && charging != last?.charging) ||
|
||||||
|
(level != null && bucket(level) != last?.batteryLevel?.let { bucket(it) })
|
||||||
|
if (batteryChanged) {
|
||||||
|
transitions += "battery" to buildJsonObject {
|
||||||
|
put("topic", "battery")
|
||||||
|
put("value", level?.toString() ?: "unknown")
|
||||||
|
put("charging", charging)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val network = snapshot.network
|
||||||
|
if (network != null && network != last?.network) {
|
||||||
|
transitions += "network" to buildJsonObject {
|
||||||
|
put("topic", "network")
|
||||||
|
put("value", network)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val dnd = snapshot.dnd
|
||||||
|
if (dnd != null && dnd != last?.dnd) {
|
||||||
|
transitions += "dnd" to buildJsonObject {
|
||||||
|
put("topic", "dnd")
|
||||||
|
put("value", dnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location posts when the phone moved a meaningful distance since the
|
||||||
|
// last posted fix (or never posted one). Stationary drift stays under
|
||||||
|
// the threshold; a commute clears it per stop. Snapshot-only: the fix
|
||||||
|
// lands in the claw's tables, no turn is run on it.
|
||||||
|
val fix = reading.fix
|
||||||
|
val metresMoved = fix?.let { PhoneSensors.metresBetween(last?.locationLat, last?.locationLon, it) }
|
||||||
|
if (fix != null && (metresMoved == null || metresMoved >= LOCATION_MOVE_M)) {
|
||||||
|
transitions += "location" to buildJsonObject {
|
||||||
|
put("topic", "location")
|
||||||
|
put("lat", fix.lat)
|
||||||
|
put("lon", fix.lon)
|
||||||
|
put("accuracy_m", fix.accuracyM)
|
||||||
|
put("provider", fix.provider)
|
||||||
|
put("age_s", fix.ageS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The foreground app posts on change; the age says how long it has
|
||||||
|
// been current, which is what keeps "currently open" honest at a
|
||||||
|
// 15-minute cadence. Snapshot-only.
|
||||||
|
val app = snapshot.app
|
||||||
|
if (app != null && app != last?.app) {
|
||||||
|
transitions += "app" to buildJsonObject {
|
||||||
|
put("topic", "app")
|
||||||
|
put("value", app)
|
||||||
|
put("label", reading.foreground?.label)
|
||||||
|
put("age_s", reading.foreground?.ageS ?: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The device identity posts once, and again whenever it changes (an
|
||||||
|
// OS upgrade, an app update): the claw keeps it as the fleet label,
|
||||||
|
// so this topic is what makes the settings screens human-readable.
|
||||||
|
// Snapshot-only.
|
||||||
|
if (snapshot.device != null && snapshot.device != last?.device) {
|
||||||
|
transitions += "device" to buildJsonObject {
|
||||||
|
put("topic", "device")
|
||||||
|
put("value", PhoneSensors.deviceName())
|
||||||
|
put("manufacturer", Build.MANUFACTURER)
|
||||||
|
put("model", Build.MODEL)
|
||||||
|
put("android", Build.VERSION.RELEASE)
|
||||||
|
put("sdk", Build.VERSION.SDK_INT)
|
||||||
|
put("app_version", PhoneSensors.appVersion(applicationContext))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val wifi = snapshot.wifi
|
||||||
|
if (wifi != null && wifi != last?.wifi) {
|
||||||
|
transitions += "wifi" to buildJsonObject {
|
||||||
|
put("topic", "wifi")
|
||||||
|
put("value", wifi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val alarm = snapshot.alarm
|
||||||
|
if (alarm != null && alarm != last?.alarm) {
|
||||||
|
transitions += "alarm" to buildJsonObject {
|
||||||
|
put("topic", "alarm")
|
||||||
|
put("value", alarm)
|
||||||
|
put("in_min", (alarm - System.currentTimeMillis()) / 60000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing changed is not nothing to say: the claw's fleet
|
||||||
|
// registry stamps "last seen" on whatever post lands, so a run
|
||||||
|
// with no transitions posts a bare heartbeat to keep its row
|
||||||
|
// fresh at the worker's cadence instead of the last flip. The
|
||||||
|
// topic is snapshot-only claw-side; no turn runs on it.
|
||||||
|
if (transitions.isEmpty()) {
|
||||||
|
transitions += "heartbeat" to buildJsonObject {
|
||||||
|
put("topic", "heartbeat")
|
||||||
|
put("ts", System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for ((topic, payload) in transitions) {
|
||||||
|
val result = container.api.ingest(SOURCE, "phone:$deviceId:$topic", payload)
|
||||||
|
if (result !is ClawResult.Ok) return Result.retry()
|
||||||
|
}
|
||||||
|
container.status.setSnapshot(snapshot)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val SOURCE = "phone"
|
||||||
|
|
||||||
|
// A fix counts as a move at this distance; below it, the phone has
|
||||||
|
// not gone anywhere worth a post.
|
||||||
|
const val LOCATION_MOVE_M = 300f
|
||||||
|
|
||||||
|
fun bucket(level: Int): Int = level / 10
|
||||||
|
|
||||||
|
private suspend fun readSnapshot(context: Context): Reading {
|
||||||
|
val battery = context.getSystemService(BatteryManager::class.java)
|
||||||
|
val level = battery
|
||||||
|
?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
|
||||||
|
?.takeIf { it in 1..100 }
|
||||||
|
|
||||||
|
// Peek the sticky battery intent (null receiver, so no export flag
|
||||||
|
// applies). This is the only reliable charging signal without
|
||||||
|
// listening to the chatty ACTION_BATTERY_CHANGED stream.
|
||||||
|
val sticky = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||||
|
val charging = sticky?.let { intent ->
|
||||||
|
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
||||||
|
status == BatteryManager.BATTERY_STATUS_CHARGING ||
|
||||||
|
status == BatteryManager.BATTERY_STATUS_FULL ||
|
||||||
|
intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||||
|
val network = when {
|
||||||
|
connectivity == null -> null
|
||||||
|
connectivity.activeNetwork == null -> "offline"
|
||||||
|
connectivity.isActiveNetworkMetered -> "metered"
|
||||||
|
else -> "wifi"
|
||||||
|
}
|
||||||
|
|
||||||
|
val notifications = context.getSystemService(NotificationManager::class.java)
|
||||||
|
val dnd = notifications?.currentInterruptionFilter
|
||||||
|
?.takeIf { it != NotificationManager.INTERRUPTION_FILTER_UNKNOWN }
|
||||||
|
?.let { if (it == NotificationManager.INTERRUPTION_FILTER_ALL) "off" else "on" }
|
||||||
|
|
||||||
|
// A fresh fix beats the last-known cache: on a stationary
|
||||||
|
// phone the cache is minutes-to-hours old and reports where
|
||||||
|
// the phone was, not where it is.
|
||||||
|
val fix = PhoneSensors.currentFix(context)
|
||||||
|
val foreground = PhoneSensors.foregroundApp(context)
|
||||||
|
val sensors = PhoneSnapshot(
|
||||||
|
charging = charging,
|
||||||
|
batteryLevel = level,
|
||||||
|
network = network,
|
||||||
|
dnd = dnd,
|
||||||
|
locationLat = fix?.lat,
|
||||||
|
locationLon = fix?.lon,
|
||||||
|
app = foreground?.pkg,
|
||||||
|
device = PhoneSensors.deviceFingerprint(context),
|
||||||
|
wifi = PhoneSensors.wifiSsid(context),
|
||||||
|
alarm = PhoneSensors.nextAlarm(context),
|
||||||
|
)
|
||||||
|
return Reading(sensors, fix, foreground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One read of every sensor: the diffable [PhoneSnapshot] the run
|
||||||
|
* persists on success, plus the rich readings whose payload fields (a
|
||||||
|
* fix's accuracy, an app's label) never need persisting.
|
||||||
|
*/
|
||||||
|
private data class Reading(
|
||||||
|
val sensors: PhoneSnapshot,
|
||||||
|
val fix: LocationFix?,
|
||||||
|
val foreground: ForegroundApp?,
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package rs.rhema.phone.work
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.BatteryManager
|
||||||
|
import rs.rhema.phone.RhemaApp
|
||||||
|
|
||||||
|
// Registered dynamically for the process lifetime (see RhemaApp): the OS
|
||||||
|
// refuses manifest registration for these implicit broadcasts. Dumb on
|
||||||
|
// purpose — it only enqueues work; the worker does all reading and posting.
|
||||||
|
class PowerEventReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
// In-memory only: coalesces ACTION_BATTERY_CHANGED (which fires on every
|
||||||
|
// percent point) down to charging flips seen by this process. Process
|
||||||
|
// death simply resets the baseline; the periodic worker covers the gap.
|
||||||
|
private var lastCharging: Boolean? = null
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val app = context.applicationContext as? RhemaApp ?: return
|
||||||
|
when (intent.action) {
|
||||||
|
Intent.ACTION_POWER_CONNECTED,
|
||||||
|
Intent.ACTION_POWER_DISCONNECTED,
|
||||||
|
-> enqueuePhoneStatusNow(app)
|
||||||
|
|
||||||
|
Intent.ACTION_BATTERY_CHANGED -> {
|
||||||
|
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
||||||
|
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
|
||||||
|
status == BatteryManager.BATTERY_STATUS_FULL
|
||||||
|
if (lastCharging != null && charging != lastCharging) {
|
||||||
|
enqueuePhoneStatusNow(app)
|
||||||
|
}
|
||||||
|
lastCharging = charging
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package rs.rhema.phone.work
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import java.time.Duration
|
||||||
|
|
||||||
|
private const val PHONE_STATUS = "phone_status"
|
||||||
|
private const val NOTIFICATIONS = "notifications"
|
||||||
|
private const val PHONE_COMMANDS = "phone_commands"
|
||||||
|
private const val PHONE_STATUS_KICK = "phone_status_kick"
|
||||||
|
private const val PHONE_COMMANDS_KICK = "phone_commands_kick"
|
||||||
|
|
||||||
|
private fun networked() = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// KEEP everywhere: re-running the schedule (app start, boot) must not reset
|
||||||
|
// the 15-minute cadence. 15 minutes is the WorkManager periodic floor.
|
||||||
|
fun schedulePeriodicWork(context: Context) {
|
||||||
|
val wm = WorkManager.getInstance(context)
|
||||||
|
wm.enqueueUniquePeriodicWork(
|
||||||
|
PHONE_STATUS,
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
|
PeriodicWorkRequestBuilder<PhoneStatusWorker>(Duration.ofMinutes(15))
|
||||||
|
.setConstraints(networked())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
wm.enqueueUniquePeriodicWork(
|
||||||
|
NOTIFICATIONS,
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
|
PeriodicWorkRequestBuilder<NotificationsWorker>(Duration.ofMinutes(15))
|
||||||
|
.setConstraints(networked())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
wm.enqueueUniquePeriodicWork(
|
||||||
|
PHONE_COMMANDS,
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
|
PeriodicWorkRequestBuilder<PhoneCommandWorker>(Duration.ofMinutes(15))
|
||||||
|
.setConstraints(networked())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receiver-side kick for charging flips; KEEP coalesces bursts into one run.
|
||||||
|
fun enqueuePhoneStatusNow(context: Context) {
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
PHONE_STATUS_KICK,
|
||||||
|
ExistingWorkPolicy.KEEP,
|
||||||
|
OneTimeWorkRequestBuilder<PhoneStatusWorker>()
|
||||||
|
.setConstraints(networked())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Foreground kick for the control queue: the app opening is when the human
|
||||||
|
// is around to see an alert or answer a question. KEEP coalesces resume
|
||||||
|
// bursts into one run.
|
||||||
|
fun enqueuePhoneCommandsNow(context: Context) {
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
PHONE_COMMANDS_KICK,
|
||||||
|
ExistingWorkPolicy.KEEP,
|
||||||
|
OneTimeWorkRequestBuilder<PhoneCommandWorker>()
|
||||||
|
.setConstraints(networked())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Single glyph used for both the launcher icon and the notification small
|
||||||
|
icon. The solid silhouette is intentional: notification small icons render
|
||||||
|
alpha only. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF2E7D6F"
|
||||||
|
android:pathData="M4,2h16c1.1,0 2,0.9 2,2v12c0,1.1 -0.9,2 -2,2H8l-4,4V4c0,-1.1 0.9,-2 2,-2zM7,7h10v2H7zM7,11h7v2H7z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Rhema</string>
|
||||||
|
|
||||||
|
<string name="channel_name">Agent notifications</string>
|
||||||
|
<string name="alert_channel_name">Agent alerts</string>
|
||||||
|
<string name="notif_default_title">Rhema</string>
|
||||||
|
|
||||||
|
<string name="settings_title">Connect to your claw</string>
|
||||||
|
<string name="field_base_url">Base URL (e.g. http://192.168.1.10:8080)</string>
|
||||||
|
<string name="field_token">Bearer token</string>
|
||||||
|
<string name="save_and_connect">Save and connect</string>
|
||||||
|
<string name="device_id_fmt">Device ID: %1$s</string>
|
||||||
|
<string name="settings_health_http">Saved. The claw answered /healthz with HTTP %1$d; check the URL and token.</string>
|
||||||
|
<string name="settings_health_network">Saved, but /healthz is unreachable: %1$s</string>
|
||||||
|
<string name="settings_hint">The token is stored in this app\'s private storage. The device ID namespaces this phone\'s status events on the claw.</string>
|
||||||
|
|
||||||
|
<string name="sessions_title">Sessions</string>
|
||||||
|
<string name="sessions_empty">No sessions yet.</string>
|
||||||
|
<string name="sessions_refresh">Refresh</string>
|
||||||
|
<string name="sessions_open_settings">Settings</string>
|
||||||
|
<string name="sessions_http_error">The claw returned HTTP %1$d</string>
|
||||||
|
<string name="sessions_network_error">Cannot reach the claw: %1$s</string>
|
||||||
|
<string name="sessions_unauthorized_hint">HTTP 401 means the bearer token is wrong or missing. Update it in Settings.</string>
|
||||||
|
|
||||||
|
<string name="chat_back">Back</string>
|
||||||
|
<string name="chat_send">Send</string>
|
||||||
|
<string name="chat_message_hint">Message the agent</string>
|
||||||
|
<string name="chat_status_connecting">connecting…</string>
|
||||||
|
<string name="chat_status_live">live</string>
|
||||||
|
<string name="chat_status_reconnecting">reconnecting…</string>
|
||||||
|
<string name="chat_status_failed">disconnected: %1$s</string>
|
||||||
|
<string name="chat_status_unauthorized">unauthorized; check the token in Settings</string>
|
||||||
|
<string name="chat_send_failed">Send failed: %1$s</string>
|
||||||
|
<string name="chat_tool_running">tool running: %1$s</string>
|
||||||
|
<string name="chat_tool_done">tool: %1$s</string>
|
||||||
|
<string name="chat_attachment_download">Download and open</string>
|
||||||
|
<string name="chat_attachment_downloading">downloading…</string>
|
||||||
|
<string name="chat_attachment_open">Open</string>
|
||||||
|
<string name="chat_attachment_open_failed">No app on this phone opens this file type.</string>
|
||||||
|
<string name="chat_attachment_failed">Download failed: %1$s</string>
|
||||||
|
<string name="consent_secret_hint">The secret the agent asked for</string>
|
||||||
|
<string name="consent_submit">Send secret</string>
|
||||||
|
<string name="consent_approve">Approve</string>
|
||||||
|
<string name="consent_deny">Deny</string>
|
||||||
|
<string name="consent_granted">granted</string>
|
||||||
|
<string name="consent_denied">denied</string>
|
||||||
|
<string name="consent_answer_failed">Answer failed: %1$s</string>
|
||||||
|
<string name="settings_page_title">Settings</string>
|
||||||
|
<string name="settings_back">Back</string>
|
||||||
|
<string name="settings_group_phone">Phone</string>
|
||||||
|
<string name="settings_group_claw">Claw</string>
|
||||||
|
<string name="settings_entry_reporting">Status reporting</string>
|
||||||
|
<string name="settings_entry_reporting_desc">Location, battery, DND, and the other sensors this phone sends</string>
|
||||||
|
<string name="settings_entry_devices">Devices</string>
|
||||||
|
<string name="settings_entry_devices_desc">Every phone the claw has heard from; forget the stale ones</string>
|
||||||
|
<string name="settings_entry_providers">Model providers</string>
|
||||||
|
<string name="settings_entry_providers_desc">Which backend and model answer the claw\'s turns</string>
|
||||||
|
<string name="settings_entry_connection">Connection</string>
|
||||||
|
<string name="settings_entry_connection_desc">The claw\'s URL and bearer token</string>
|
||||||
|
<string name="card_connection">Connection</string>
|
||||||
|
<string name="card_providers">Model providers</string>
|
||||||
|
<string name="providers_hint">What the claw answers with</string>
|
||||||
|
<string name="providers_field_provider">Provider</string>
|
||||||
|
<string name="providers_field_model">Model</string>
|
||||||
|
<string name="providers_default_option">(provider default)</string>
|
||||||
|
<string name="providers_none">No live listing; the provider\'s default model applies.</string>
|
||||||
|
<string name="providers_saved">Saved: %1$s / %2$s</string>
|
||||||
|
<string name="providers_loading">Loading providers…</string>
|
||||||
|
<string name="providers_staged">Change not applied</string>
|
||||||
|
<string name="providers_staged_detail">The staged pair takes effect on the claw\'s next turn. Save to apply it, discard to keep the running backend.</string>
|
||||||
|
<string name="providers_discard">Discard</string>
|
||||||
|
<string name="providers_save">Save</string>
|
||||||
|
<string name="providers_error">The claw answered: %1$s</string>
|
||||||
|
|
||||||
|
<string name="card_phones">Phones</string>
|
||||||
|
<string name="phones_hint">Every device the claw has heard from</string>
|
||||||
|
<string name="phones_loading">Loading phones…</string>
|
||||||
|
<string name="phones_empty">No phones have reported yet.</string>
|
||||||
|
<string name="phones_this_device">this phone</string>
|
||||||
|
<string name="phones_forget">Forget</string>
|
||||||
|
<string name="phones_forget_title">Forget %1$s?</string>
|
||||||
|
<string name="phones_forget_text">The claw drops this phone\'s status, queued commands, and identity. A phone that still reports comes back on its next run.</string>
|
||||||
|
<string name="phones_seen_just_now">last seen just now</string>
|
||||||
|
<string name="phones_seen_fmt">last seen %1$s ago</string>
|
||||||
|
<string name="phones_never_reported">never reported</string>
|
||||||
|
<string name="phones_forgot_fmt">Forgot %1$s.</string>
|
||||||
|
|
||||||
|
<string name="card_reporting">Status reporting</string>
|
||||||
|
<string name="reporting_hint">What this phone sends the claw</string>
|
||||||
|
<string name="reporting_location_title">Location</string>
|
||||||
|
<string name="reporting_location_desc">Where this phone is. A fix posts when it moves 300 m or more.</string>
|
||||||
|
<string name="reporting_usage_title">Current app</string>
|
||||||
|
<string name="reporting_usage_desc">The app in the foreground, through Android\'s usage access.</string>
|
||||||
|
<string name="reporting_dnd_title">Do Not Disturb</string>
|
||||||
|
<string name="reporting_dnd_desc">Lets the claw\'s dnd_on and dnd_off commands work.</string>
|
||||||
|
<string name="reporting_on">on</string>
|
||||||
|
<string name="reporting_allow">Allow</string>
|
||||||
|
|
||||||
|
<string name="sessions_group_interactive">Interactive</string>
|
||||||
|
<string name="sessions_group_other">Other keys</string>
|
||||||
|
|
||||||
|
<string name="chat_agent">rhema</string>
|
||||||
|
</resources>
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Pre-Android-12 counterpart of data_extraction_rules.xml: the stored
|
||||||
|
bearer token must not leave the device through adb/cloud backup. -->
|
||||||
|
<full-backup-content>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</full-backup-content>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Exclude everything, matching allowBackup=false: the stored bearer token
|
||||||
|
must not leave the device through cloud backup or device transfer. -->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- The cache subtree saved attachments land in before an ACTION_VIEW
|
||||||
|
hand-off; FileProvider maps files under it to content:// uris. -->
|
||||||
|
<paths>
|
||||||
|
<cache-path name="attachments" path="attachments/" />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- The claw is self-hosted: its address is whatever the operator points
|
||||||
|
at — a LAN name like kosmos.lan, a bare IP, or a Tailscale name — and
|
||||||
|
a publicly-trusted certificate is rarely available there. Cleartext is
|
||||||
|
therefore permitted app-wide and user-installed CAs (mkcert) are
|
||||||
|
trusted: only the operator's own claw is ever contacted, and the
|
||||||
|
bearer token is the auth. Prefer HTTPS wherever the deployment has a
|
||||||
|
real certificate. -->
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="true">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
<certificates src="user" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.13.2" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
|||||||
|
# The system default JDK (25) is the only one with a compiler on this machine;
|
||||||
|
# Gradle 9.3.1 runs on it. Pin nothing: `./gradlew` picks up JAVA_HOME or PATH.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
+248
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+93
@@ -0,0 +1,93 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "rhema-android"
|
||||||
|
include(":app")
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
[package]
|
||||||
|
name = "rhema-claw"
|
||||||
|
description = "The resident agent daemon: forge watches and the chat HTTP surface, the first consumer of rhema-autonomous-agent"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
# The resident tier is still indev: `cargo publish --workspace` skips
|
||||||
|
# it until the claw ships.
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "rhema_claw"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "rhema-claw"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# The endless canned provider for `--mock` debug runs; nothing ships in
|
||||||
|
# a real build.
|
||||||
|
mock = ["rhema-core/mock"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# The shared toolkit: the resident agent assembles its agent from the
|
||||||
|
# shell, the approval seam, paste expansion, and revive-on-resume
|
||||||
|
rhema-agent-tools = { path = "../rhema-agent-tools" }
|
||||||
|
rhema-autonomous-agent = { path = "../rhema-autonomous-agent" }
|
||||||
|
rhema-coding-agent = { path = "../rhema-coding-agent" }
|
||||||
|
rhema-config = { path = "../rhema-config" }
|
||||||
|
rhema-core = { path = "../rhema-core" }
|
||||||
|
rhema-calendar-integration = { path = "../../integrations/rhema-calendar-integration" }
|
||||||
|
rhema-forge-integration = { path = "../../integrations/rhema-forge-integration" }
|
||||||
|
# The accounts layer: identities hold the forge tokens the watches poll
|
||||||
|
# with, and the identity tools manage them over chat
|
||||||
|
rhema-identity = { path = "../rhema-identity" }
|
||||||
|
rhema-provider-anthropic = { path = "../rhema-provider-anthropic" }
|
||||||
|
rhema-provider-copilot = { path = "../rhema-provider-copilot" }
|
||||||
|
rhema-provider-google = { path = "../rhema-provider-google" }
|
||||||
|
rhema-provider-llama-cpp = { path = "../rhema-provider-llama-cpp" }
|
||||||
|
rhema-provider-openai-compatible = { path = "../rhema-provider-openai-compatible" }
|
||||||
|
rhema-provider-openrouter = { path = "../rhema-provider-openrouter" }
|
||||||
|
rhema-rsd = { path = "../rhema-rsd" }
|
||||||
|
# The resident tier's durable memory: its tools join the resolver chain
|
||||||
|
rhema-memory = { path = "../rhema-memory" }
|
||||||
|
# The shared ModelCard the backend registry's listings return
|
||||||
|
rhema-provider = { path = "../rhema-provider" }
|
||||||
|
# Args for the two-flag binary surface
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
serde_yaml = { workspace = true }
|
||||||
|
# Typed public errors
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
# A daemon: the runtime, the poll sleep, the ctrl-c signal; the driver's
|
||||||
|
# concurrent handler runs share it. `net` is the HTTP listener.
|
||||||
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "time", "sync", "net"] }
|
||||||
|
# The chat surface: native routes plus SSE, and the OpenAI shim
|
||||||
|
axum = { workspace = true }
|
||||||
|
# Broadcast receivers as SSE bodies
|
||||||
|
tokio-stream = { workspace = true }
|
||||||
|
# The watch table: a second connection to the same SQLite file as the
|
||||||
|
# dispatcher's store — the two-connections pattern rhema-rsd's tests use
|
||||||
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
|
# RFC3339 parsing for schedule_task's `at`
|
||||||
|
time = { version = "0.3", default-features = false, features = ["parsing"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
# In-process HTTP mocks for the offline e2e: zero network
|
||||||
|
wiremock = "0.6"
|
||||||
|
# The HTTP client for the surface tests, SSE bodies included
|
||||||
|
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||||
|
# Naming the SSE chunks' type in the surface tests
|
||||||
|
bytes = "1"
|
||||||
|
# The scripted provider for turn-level tests; mirrors rhema-code's mock
|
||||||
|
# feature without a feature of our own — tests are the only consumer
|
||||||
|
rhema-core = { path = "../rhema-core", features = ["mock"] }
|
||||||
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
## Setup
|
||||||
|
|
||||||
|
This is a first conversation: SOUL.md or USER.md is still unwritten.
|
||||||
|
Offer a short setup — who they are, what this machine is for, who you
|
||||||
|
should be — and let them decline; never interrogate. Ask at most a few
|
||||||
|
questions, spread across turns.
|
||||||
|
|
||||||
|
Write SOUL.md (your name, tone, purpose) and USER.md (who the user
|
||||||
|
is) with the bootstrap_save tool as facts arrive; incrementally is
|
||||||
|
fine. Set what you learn about your own presentation with the
|
||||||
|
identity_set tool as well — your name, a mark or initials, a tagline.
|
||||||
|
An avatar only if the user hands you an image file; they can also
|
||||||
|
upload one in the web interface's settings, and saying so beats
|
||||||
|
hunting one down. Never invent facts the user did not give.
|
||||||
|
|
||||||
|
When both files exist, say setup is done and stop offering.
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# Rhema Claw
|
||||||
|
|
||||||
|
Still Work-In-Progress.
|
||||||
|
|
||||||
|
Rhema Claw is an autonomous personal agent ("claw"), built on the Rhema agent framework.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
## The claw: `rhema-claw`
|
||||||
|
|
||||||
|
The resident agent. One daemon owns every session in one SQLite store:
|
||||||
|
yours (`interactive:<name>`, keyed per chat surface) and every watched
|
||||||
|
forge thread's. Talk to it over HTTP; it watches repos, answers forge
|
||||||
|
threads with coding-agent turns, and configures its own watches when
|
||||||
|
asked in chat (`watch_repo`/`unwatch_repo`/`list_watches` are tools the
|
||||||
|
model calls like any other).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rhema-claw # serve on 127.0.0.1:7799 (config: listen)
|
||||||
|
rhema-claw --once # one detect + one dispatch pass, then exit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coding jobs: the `coding_agent` tool
|
||||||
|
|
||||||
|
The claw is not the only agent it runs. The `coding_agent` tool drives
|
||||||
|
a full rhema-coding-agent session — its own shell, skills, and step
|
||||||
|
budget — in a named workspace, with one tool and an `action` argument:
|
||||||
|
|
||||||
|
- `add` pins a name to an existing directory (`rhema` → `~/rhema`).
|
||||||
|
The instance list is the audit surface for which checkouts the model
|
||||||
|
can drive a coding agent in; `drop` removes one, refused while a job
|
||||||
|
is live.
|
||||||
|
- `run` enqueues a job. Async by design: a coding run is minutes of
|
||||||
|
tool rounds, so the call answers with the job id in one model round
|
||||||
|
and the turn runs off the chat — its own dispatcher, `job:` keys
|
||||||
|
with forge-style leases, the same commit-before-execute discipline.
|
||||||
|
The instance's coding session carries to the next run unless
|
||||||
|
`fresh`; the final text lands in the job row and the user's
|
||||||
|
notifications.
|
||||||
|
- `list` names the instances, each with its live job at a glance;
|
||||||
|
`status` reads jobs — every recent one, one instance's, or one
|
||||||
|
job's result.
|
||||||
|
- One live job per instance: two coding agents in one workspace is
|
||||||
|
two agents editing the same files. Across instances, jobs run in
|
||||||
|
parallel.
|
||||||
|
|
||||||
|
For the debug loop, daemon plus UI against throwaway state and zero
|
||||||
|
credentials: `scripts/dev.sh --mock` (canned streamed replies; the UI
|
||||||
|
is at `localhost:3000`, the claw at `127.0.0.1:7799`). Drop `--mock`
|
||||||
|
and set the provider's key env var (`CLAW_DEV_PROVIDER` picks the
|
||||||
|
provider) to debug against a real model.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ~/.config/rhema/claw.yaml
|
||||||
|
provider: anthropic
|
||||||
|
model: claude-sonnet-4-5
|
||||||
|
listen: 127.0.0.1:7799
|
||||||
|
token: change-me # optional; when set, /v1/* requires it
|
||||||
|
poll_interval_secs: 120
|
||||||
|
max_turn_steps: 2048 # tool rounds one chat turn may run
|
||||||
|
watches: # a seed: imported while the table is empty
|
||||||
|
- forge: github
|
||||||
|
repo: acme/widgets
|
||||||
|
calendars: # feeds the claw reminds about
|
||||||
|
- name: home # the reminder session is calendar:home
|
||||||
|
kind: ics # an ics/webcal feed (Google's secret iCal
|
||||||
|
url: https://cloud.example.net/remote.php/dav/calendars/personal.ics
|
||||||
|
lead_minutes: [60, 15] # default [15]; horizon_hours default 24
|
||||||
|
- name: cloud
|
||||||
|
kind: caldav # a CalDAV collection (REPORT; Nextcloud,
|
||||||
|
url: https://cloud.example.net/remote.php/dav/calendars/me/personal/
|
||||||
|
username: me # Radicale); password falls back to
|
||||||
|
password: app-pass # CALDAV_PASSWORD; token: = bearer instead
|
||||||
|
- name: work
|
||||||
|
kind: google # the Google Calendar API
|
||||||
|
calendar_id: primary
|
||||||
|
api_key: pub-key # or token: (OAuth; env fallback CALENDAR_TOKEN)
|
||||||
|
```
|
||||||
|
|
||||||
|
`max_turn_steps` caps one turn's tool rounds (the coding driver's own
|
||||||
|
budget shape, and the same default, 2048): the claw is meant to work
|
||||||
|
unattended for a long time, so a cap this deep is a runaway guard, not
|
||||||
|
a work timer — a turn that hits it ends with a failed outcome and the
|
||||||
|
conversation intact; a follow-up message keeps going.
|
||||||
|
|
||||||
|
The surface refuses to start on a non-loopback `listen` with no
|
||||||
|
`token`: anything beyond loopback can reach the port, and the bearer
|
||||||
|
token is what stands between it and the agent's tools. Set a token, or
|
||||||
|
bind a loopback address. Resident sessions run the coding agent with
|
||||||
|
its approval seam auto-approved: tool calls, the shell included,
|
||||||
|
execute without a prompt. The daemon says so at startup, and the
|
||||||
|
bearer-gated surface plus the watch list are the trust boundary.
|
||||||
|
|
||||||
|
The native surface:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /healthz GET /v1/sessions
|
||||||
|
POST /v1/sessions {"name"} — create an empty interactive session
|
||||||
|
POST /v1/sessions/{key}/messages {"text": "..."} -> 202
|
||||||
|
GET /v1/sessions/{key}/events SSE turn events (see below)
|
||||||
|
GET /v1/watches POST /v1/watches DELETE /v1/watches/{forge}/{repo}
|
||||||
|
GET /v1/bootstrap {"soul": bool, "user": bool}
|
||||||
|
POST /v1/ingest {"source","key","payload"} -> 202
|
||||||
|
GET /v1/notifications?device=&since= POST /v1/notifications/{id}/ack
|
||||||
|
GET /v1/consents the asks a turn is waiting on
|
||||||
|
POST /v1/consents/{id} {"secret"} or {"grant": bool}
|
||||||
|
DELETE /v1/consents/{id} deny a pending ask
|
||||||
|
GET /v1/attachments/{id} an attached file's bytes
|
||||||
|
GET /v1/phone/commands?device= the device's queued, unconfirmed commands
|
||||||
|
POST /v1/phone/commands/{id}/result {"ok", "detail"?} — the device's report
|
||||||
|
GET /v1/providers the backend instances + the active pair
|
||||||
|
PUT /v1/providers/active {"provider","model"?} — switch, persisted
|
||||||
|
PUT /v1/providers/{id}/credentials add or replace an instance, persisted
|
||||||
|
DELETE /v1/providers/{id} remove one (the last is refused)
|
||||||
|
GET /v1/providers/{id}/models the live model listing
|
||||||
|
```
|
||||||
|
|
||||||
|
### Identity and first-run
|
||||||
|
|
||||||
|
The claw's prompt is assembled per turn from an in-binary base plus
|
||||||
|
three identity files under the state dir's `bootstrap/` (config:
|
||||||
|
`bootstrap_root`): **SOUL.md**, who the claw is, tone and purpose,
|
||||||
|
written with you; **USER.md**, the always-visible summary of who you
|
||||||
|
are; **ENVIRONMENT.md**, host, shell, and tool inventory, generated
|
||||||
|
deterministically at every boot (detection is code, not conversation;
|
||||||
|
hand edits are overwritten). The base renders the dispatch's own tool
|
||||||
|
roster too — every tool's name, description, and call schema, straight
|
||||||
|
from the resolver chain, so what the prompt lists is what the request
|
||||||
|
advertises. On first run the claw *offers* a short
|
||||||
|
setup in chat: who you are, what the machine is for, who it should be.
|
||||||
|
It writes the first two files itself through the `bootstrap_save`
|
||||||
|
tool as you answer, and — through the `identity_set` tool — sets its
|
||||||
|
own presentation the same way: its name, a mark or tagline, an avatar
|
||||||
|
from an image you hand it. The same tool is yours to invoke any time
|
||||||
|
("call yourself something else"); it is the same store the settings
|
||||||
|
pages write, so what it sets is what every client renders. Decline
|
||||||
|
and it simply keeps offering, never
|
||||||
|
interrogating. Both files stay hand-editable forever; `/v1/bootstrap`
|
||||||
|
lets a UI badge setup state.
|
||||||
|
|
||||||
|
SSE frames are JSON, `data: {..}`: `user_message`, `thinking_delta`,
|
||||||
|
`text_delta`, `tool_start`, `tool_result`, `turn_done`. A subscription
|
||||||
|
opens with the session's recorded turns replayed first and the live feed
|
||||||
|
after, cut atomically daemon-side — every streamed event is persisted as
|
||||||
|
it streams, so both a reload and a daemon restart rebuild the whole
|
||||||
|
transcript, not a recent window; a session that runs for weeks scrolls
|
||||||
|
back to its first message. A chat UI subscribes once,
|
||||||
|
POSTs messages, and resets its mirror whenever the stream (re)opens,
|
||||||
|
letting the replay rebuild it; the daemon owns the conversation, so the
|
||||||
|
client mirrors the stream rather than managing history. The OpenAI shim
|
||||||
|
and the message POST read the live feed only — replay never reaches a
|
||||||
|
turn that is starting.
|
||||||
|
|
||||||
|
Every provider whose credentials resolve (env, the claw's own stored
|
||||||
|
logins, or the stored rhema config) is constructed at boot as a backend
|
||||||
|
*instance* named by an id, the kind unless a login named it, so two
|
||||||
|
llama.cpp servers (or two keys of one cloud) coexist under two names.
|
||||||
|
One is active, and switching with `PUT /v1/providers/active` is runtime
|
||||||
|
state persisted in SQLite: the
|
||||||
|
config's `provider`/`model` pair is only the seed, and when the config
|
||||||
|
names no provider the first credential discovery actually finds (a
|
||||||
|
stored login, an env key, or the stored rhema config) is active; the
|
||||||
|
keyless llama.cpp fallback never wins a seed, so the chart pins
|
||||||
|
nothing and a switch never needs a restart.
|
||||||
|
|
||||||
|
The claw registers RSD sources four ways: watches (forge), calendar
|
||||||
|
reminders (`calendars:` in the config; each tick polls every feed for
|
||||||
|
the occurrences starting within its horizon, and an upcoming event
|
||||||
|
warns once per `lead_minutes` on its persistent `calendar:<name>`
|
||||||
|
session, a turn whose final text auto-notifies the phone; recurring
|
||||||
|
series arrive expanded, overrides and cancellations applied, all-day
|
||||||
|
events stay silent), scheduled
|
||||||
|
tasks the agent creates in chat (`schedule_task` with `at` or
|
||||||
|
`every_secs`; a due task dispatches through the resident agent — into
|
||||||
|
the session the tool named, its own `task:<id>` slot by default — and
|
||||||
|
a reply on a chat-less key auto-notifies the phone; a task whose
|
||||||
|
`session` names a chat conversation wakes *that* session with its full
|
||||||
|
context, so "pick this back up at 10 am" or "before my meeting,
|
||||||
|
summarize X" is one tool call, with `notify` reaching the user away
|
||||||
|
from the chat), and a generic door,
|
||||||
|
`POST /v1/ingest`, that any external emitter addresses
|
||||||
|
(`phone:<device>:<topic>`
|
||||||
|
keys from the Android app, for example). The resident agent owns
|
||||||
|
`interactive:`, `task:`, `phone:`, and `calendar:` keys; forge threads
|
||||||
|
own the rest. Its `notify` tool queues a notification for a device;
|
||||||
|
the device polls and acks.
|
||||||
|
|
||||||
|
When a turn needs something only a human can give, it asks and waits
|
||||||
|
inside the tool call: `ask_secret` raises a consent request — a card in
|
||||||
|
the chat stream (`consent_request` over SSE, plus `GET /v1/consents`
|
||||||
|
for a UI catching up after a reload) — and the human answers through
|
||||||
|
`POST /v1/consents/{id}` (`{"secret": ...}`) or denies with `DELETE`.
|
||||||
|
The secret travels once, from the answer into the agent accounts; the
|
||||||
|
tool wakes with the account id, never the secret, and a denial or a
|
||||||
|
15-minute silence comes back in-band so the turn can say so and move
|
||||||
|
on. Acting with a stored account is `identity_exec`: it runs one shell
|
||||||
|
command in the session workspace with the secret injected —
|
||||||
|
`ACCOUNT_SECRET` plus the service's own `GITHUB_TOKEN`/`GITEA_TOKEN`/
|
||||||
|
`GITLAB_TOKEN`, and a git credential helper — so `git clone` and API
|
||||||
|
calls work while the credential itself never reaches the transcript. A turn can also hand files over: `attach_file` copies a workspace
|
||||||
|
file (25 MiB cap, workspace-relative paths only) into the attachment
|
||||||
|
store beside the database and puts an `attachment` card on the stream;
|
||||||
|
the bytes are fetchable at `GET /v1/attachments/{id}` with the content
|
||||||
|
type the file's name implies, so a screenshot, a diff, or a report
|
||||||
|
reaches the chat as something to view or download rather than a
|
||||||
|
truncated snippet.
|
||||||
|
|
||||||
|
The phone is reachable too. Its status reports (`/v1/ingest`, source
|
||||||
|
`phone`, keys `phone:<device>:<topic>`) are conversation *and* state:
|
||||||
|
the payload lands in the phone tables, so the `phone` tool's
|
||||||
|
`status`/`devices` actions read the latest battery, charging, network,
|
||||||
|
and DND observations without asking anyone. Acting on the phone —
|
||||||
|
`dnd_on`, `dnd_off`, `alert` — is consent-gated end to end: the tool
|
||||||
|
raises a consent card, the human approves or denies, and only then does
|
||||||
|
the command join `GET /v1/phone/commands?device=`, the poll list the
|
||||||
|
device executes from and reports on via `POST
|
||||||
|
/v1/phone/commands/{id}/result`. The device confirms on its own clock
|
||||||
|
(the 15-minute WorkManager floor), so an answer may honestly be
|
||||||
|
"queued, not yet confirmed".
|
||||||
|
|
||||||
|
Stock OpenAI-speaking UIs point at the shim instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl http://127.0.0.1:7799/v1/chat/completions -H 'Content-Type: application/json' -d '{
|
||||||
|
"model": "claw", "user": "web", "stream": true,
|
||||||
|
"messages": [{"role": "user", "content": "what are you watching?"}]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The shim reads only the last user message (`user` picks the session,
|
||||||
|
default `web`), maps thinking deltas to `reasoning_content`, and
|
||||||
|
streams standard chat-completions chunks. There is one notion of
|
||||||
|
session, the daemon's, behind a wire format every web UI already speaks.
|
||||||
|
|
||||||
|
## Deploying the claw
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -f deploy/docker/claw.Dockerfile -t rhema-claw .
|
||||||
|
helm install claw deploy/helm/rhema-claw \
|
||||||
|
--set token=$(openssl rand -hex 24) \
|
||||||
|
--set secret.stringData.ANTHROPIC_API_KEY=... \
|
||||||
|
--set secret.stringData.GITHUB_TOKEN=... \
|
||||||
|
--set config.provider=anthropic --set config.model=claude-sonnet-4-5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expose it with the chart's optional ingress (`ingress.enabled` +
|
||||||
|
`ingress.host`, TLS strongly recommended, bearer tokens cross it) or
|
||||||
|
keep it cluster-private behind Tailscale; the claw is bearer-gated
|
||||||
|
either way. With the chart's UI on (`ui.enabled`, the default), the
|
||||||
|
same host serves both: the browser gets the web UI on `/`, the daemon
|
||||||
|
answers `/v1` and `/healthz`; one address for the browser and the
|
||||||
|
phone app, no CORS anywhere.
|
||||||
|
|
||||||
|
The image is the daemon plus `bash` and `git` (the agent's shell is
|
||||||
|
load-bearing); the chart runs exactly one replica, the claw is
|
||||||
|
single-node by design: one SQLite store, one dispatcher. Single-node
|
||||||
|
is what makes restarts instant: at boot the daemon releases every
|
||||||
|
lease the previous process left — outright, not at the dead lease's
|
||||||
|
TTL — so a turn an update cut mid-flight answers its next message
|
||||||
|
instead of stalling behind a ghost (the committed tool call revives
|
||||||
|
at the top of that next dispatch). The
|
||||||
|
store and workspaces live on a PVC, `claw.yaml` in a ConfigMap (db and
|
||||||
|
workspace paths are pinned to the mounted volume), credentials in a
|
||||||
|
Secret, and the bearer token is the `token` value, required since the
|
||||||
|
daemon binds all interfaces in-cluster. `persistence.enabled=false`
|
||||||
|
trades restart survival for disposability.
|
||||||
|
|
||||||
|
The UI in `web/claw` is one client of the same surface: `npm run dev`
|
||||||
|
proxies to a local claw, `npm run build` emits the static bundle an
|
||||||
|
ingress serves next to `/v1`. The phone is another. `android/` is the
|
||||||
|
Kotlin/Compose client: chat over the same SSE stream, phone status
|
||||||
|
(battery, charging, network, DND transitions) posted to `/v1/ingest`
|
||||||
|
as `phone:<device>:<topic>` events, and agent notifications polled
|
||||||
|
onto the lock screen (no push infrastructure; a 15-minute WorkManager
|
||||||
|
floor, live SSE while the app is open). See `android/README.md`.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
You are the resident agent on this machine. Your SOUL defines who you
|
||||||
|
are; until it is written you are rhema-claw, freshly installed. This
|
||||||
|
session's key is {{ session }}. You
|
||||||
|
answer chat here; your shell runs in the workspace at {{ workspace }}:
|
||||||
|
read code, run tests, make edits there. Keep replies terse and
|
||||||
|
concrete; do the work with tools instead of narrating it. Your
|
||||||
|
final message, the one you end the turn on with no tool calls after
|
||||||
|
it, is the reply the user sees.
|
||||||
|
|
||||||
|
{{ soul }}
|
||||||
|
|
||||||
|
{{ user }}
|
||||||
|
|
||||||
|
{{ environment }}
|
||||||
|
|
||||||
|
{% if tools %}
|
||||||
|
You act through these tools (name | description | arguments). The
|
||||||
|
arguments are the JSON schema a call must satisfy.
|
||||||
|
|
||||||
|
{% for tool in tools %}
|
||||||
|
- {{ tool.name }} | {{ tool.description }} | {{ tool.parameters | tojson }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
You manage your own watch list with the watch_repo, unwatch_repo, and
|
||||||
|
list_watches tools: a watched repository's activity (issues, pull
|
||||||
|
requests, comments) arrives as its own dispatch.
|
||||||
|
|
||||||
|
You can reach the user away from this chat with the notify tool: it
|
||||||
|
queues a notification (title, body, device; the phone by default)
|
||||||
|
for their device to pick up. You can also schedule your own future
|
||||||
|
turns with the schedule_task, list_tasks, and cancel_task tools: a
|
||||||
|
task's prompt runs as one of your turns at a given time (at, an
|
||||||
|
RFC3339 timestamp or epoch milliseconds) or on an interval
|
||||||
|
(every_secs); set exactly one. A task with no session runs cold and
|
||||||
|
its final message reaches the user as a notification; give session
|
||||||
|
(this session is {{ session }}) to wake that conversation instead, with
|
||||||
|
all of its context — pair it with notify when the user must see the
|
||||||
|
result away from the chat.
|
||||||
|
|
||||||
|
You keep persistent memory across sessions. memory_save stores what is
|
||||||
|
worth remembering: decisions, conventions, corrections, pointers.
|
||||||
|
memory_search recalls it; search before asking the user for something
|
||||||
|
they have likely said before. memory_forget removes a record on
|
||||||
|
request. Never save secrets into memory.
|
||||||
|
|
||||||
|
{% if onboarding %}
|
||||||
|
{{ onboarding }}
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,862 @@
|
|||||||
|
//! The identity tools: `identity_save`, `identity_list`,
|
||||||
|
//! `identity_forget`, and `identity_exec`, the model's surface over the
|
||||||
|
//! [`IdentityStore`].
|
||||||
|
//!
|
||||||
|
//! These are the one place the use-never-read contract could break, so
|
||||||
|
//! the answers are built to keep it: a save answers with the redacted
|
||||||
|
//! [`Account`] and the watch verdict — never the secret, which nothing
|
||||||
|
//! here re-renders; a list answers with the store's own redacted
|
||||||
|
//! records; a forget answers with what went. The store's `secret()`
|
||||||
|
//! has exactly one tool-side consumer, [`IDENTITY_EXEC`]: the credential
|
||||||
|
//! is injected into one child process's environment — the sanctioned
|
||||||
|
//! *destination* — and only that command's output returns, so the value
|
||||||
|
//! reaches its work without ever entering the conversation.
|
||||||
|
//!
|
||||||
|
//! These tools join the interactive chain only. A forge dispatch — whose
|
||||||
|
//! prompt is text a stranger wrote — never resolves them, so an injected
|
||||||
|
//! comment cannot ask its way to the accounts or act with their
|
||||||
|
//! credentials.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||||
|
use rhema_core::{ToolCall, ToolContext, ToolResolver, ToolResult, ToolSpec};
|
||||||
|
use rhema_identity::{Account, AccountDraft, IdentityStore, Secret, SecretKind};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::config::ForgeKind;
|
||||||
|
use crate::watch_store::ClawStore;
|
||||||
|
|
||||||
|
/// `identity_save`: store an account (label, service, url, username, and
|
||||||
|
/// the secret — required) and optionally register its forge watch.
|
||||||
|
struct IdentitySave {
|
||||||
|
accounts: Arc<dyn IdentityStore>,
|
||||||
|
watches: Arc<ClawStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The save's arguments. `secret_kind` defaults to a token; a password
|
||||||
|
/// is stored but is never model-accessible or pollable in v1.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct SaveArgs {
|
||||||
|
label: String,
|
||||||
|
service: String,
|
||||||
|
#[serde(default)]
|
||||||
|
url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
username: Option<String>,
|
||||||
|
secret: String,
|
||||||
|
#[serde(default)]
|
||||||
|
secret_kind: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
watch: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What saving an account earns: the redacted account, whether the
|
||||||
|
/// service resolved to a forge the daemon watches, and whether the watch
|
||||||
|
/// got registered.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||||
|
pub struct SavedAccount {
|
||||||
|
/// The stored account, redacted.
|
||||||
|
pub account: Account,
|
||||||
|
/// Whether `service`/`url` resolved to a watchable forge.
|
||||||
|
pub forge_known: bool,
|
||||||
|
/// Whether the forge watch got registered (asked for, forge known,
|
||||||
|
/// and not already watched).
|
||||||
|
pub watch_added: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The forge an account's `service` (then its `url` host) names, when it
|
||||||
|
/// is one the daemon watches. A self-hosted instance is only recognizable
|
||||||
|
/// through an explicit service name — a URL alone cannot distinguish a
|
||||||
|
/// Gitea from a GitLab.
|
||||||
|
pub fn forge_of(service: &str, url: Option<&str>) -> Option<ForgeKind> {
|
||||||
|
// Case-insensitive on the service: a user says "GitHub", the store
|
||||||
|
// normalizes to "github", and both should name the forge.
|
||||||
|
if let Some(kind) = ForgeKind::parse(service.trim().to_lowercase().as_str()) {
|
||||||
|
return Some(kind);
|
||||||
|
}
|
||||||
|
let url = url?;
|
||||||
|
let host = url
|
||||||
|
.split_once("://")
|
||||||
|
.map(|(_, rest)| rest)
|
||||||
|
.unwrap_or(url)
|
||||||
|
.split('/')
|
||||||
|
.next()?;
|
||||||
|
match host.trim_end_matches('/') {
|
||||||
|
"github.com" => Some(ForgeKind::GitHub),
|
||||||
|
"gitlab.com" => Some(ForgeKind::GitLab),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the optional `secret_kind` spelling; `None` (absent) means a
|
||||||
|
/// token. An unknown spelling is an in-band error. Shared with the HTTP
|
||||||
|
/// route, which validates the same shape.
|
||||||
|
pub fn parse_kind(kind: Option<&str>) -> Result<SecretKind, String> {
|
||||||
|
match kind.map(str::trim).filter(|name| !name.is_empty()) {
|
||||||
|
None => Ok(SecretKind::Token),
|
||||||
|
Some("token") => Ok(SecretKind::Token),
|
||||||
|
Some("password") => Ok(SecretKind::Password),
|
||||||
|
Some(other) => Err(format!(
|
||||||
|
"unknown secret_kind {other:?}: a kind is 'token' or 'password'"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores an account and, when asked and the service names a watchable
|
||||||
|
/// forge, registers its account watch — the explicit-at-creation sync:
|
||||||
|
/// no background reconciliation, so this is the one moment a watch
|
||||||
|
/// appears. The store's error and the watch verdict both land in-band.
|
||||||
|
pub fn save_account(
|
||||||
|
accounts: &dyn IdentityStore,
|
||||||
|
watches: &ClawStore,
|
||||||
|
draft: AccountDraft,
|
||||||
|
watch: bool,
|
||||||
|
) -> Result<SavedAccount, String> {
|
||||||
|
let account = accounts.put(draft).map_err(|err| err.to_string())?;
|
||||||
|
let forge = forge_of(&account.service, account.url.as_deref());
|
||||||
|
let mut saved = SavedAccount {
|
||||||
|
account,
|
||||||
|
forge_known: forge.is_some(),
|
||||||
|
watch_added: false,
|
||||||
|
};
|
||||||
|
if watch {
|
||||||
|
if let Some(kind) = forge {
|
||||||
|
let host = saved.account.url.as_deref().filter(|url| !url.is_empty());
|
||||||
|
saved.watch_added = watches
|
||||||
|
.add_account_watch(kind.name(), host, &saved.account.id)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
}
|
||||||
|
// Asked to watch a service the daemon cannot watch: the save
|
||||||
|
// stands, the watch verdict says why not.
|
||||||
|
}
|
||||||
|
Ok(saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forgets an account and cascades its derived account watches — the
|
||||||
|
/// other half of the explicit sync. Returns whether the account existed
|
||||||
|
/// and how many watches went with it.
|
||||||
|
pub fn forget_account(
|
||||||
|
accounts: &dyn IdentityStore,
|
||||||
|
watches: &ClawStore,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<(bool, usize), String> {
|
||||||
|
let forgot = accounts.forget(id).map_err(|err| err.to_string())?;
|
||||||
|
let removed = if forgot {
|
||||||
|
watches
|
||||||
|
.remove_account_watches_for(id)
|
||||||
|
.map_err(|err| err.to_string())?
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
Ok((forgot, removed))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for IdentitySave {
|
||||||
|
fn spec(&self) -> ToolSpec {
|
||||||
|
ToolSpec::new(
|
||||||
|
"identity_save",
|
||||||
|
"Store an account the daemon holds on a service: a label, the service's name (e.g. \
|
||||||
|
'github'), optionally its URL and the username, and the secret (an API token, or a \
|
||||||
|
password). Act with a stored account through identity_exec — the credential \
|
||||||
|
itself is never shown to you. With watch=true and a forge the daemon watches, \
|
||||||
|
the account's notifications are followed.",
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": { "type": "string",
|
||||||
|
"description": "Display name, e.g. 'acme releases'." },
|
||||||
|
"service": { "type": "string",
|
||||||
|
"description": "The service, lowercase: github, gitlab, gitea, or any name." },
|
||||||
|
"url": { "type": "string",
|
||||||
|
"description": "Base URL, for self-hosted instances." },
|
||||||
|
"username": { "type": "string",
|
||||||
|
"description": "The login name on the service." },
|
||||||
|
"secret": { "type": "string",
|
||||||
|
"description": "The credential to store. Never echoed back." },
|
||||||
|
"secret_kind": { "type": "string",
|
||||||
|
"enum": ["token", "password"],
|
||||||
|
"description": "What the secret is; default token." },
|
||||||
|
"watch": { "type": "boolean",
|
||||||
|
"description": "Follow this forge identity's notifications; default false." }
|
||||||
|
},
|
||||||
|
"required": ["label", "service", "secret"]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||||
|
let args: SaveArgs =
|
||||||
|
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||||
|
let kind = parse_kind(args.secret_kind.as_deref())?;
|
||||||
|
let mut draft = AccountDraft::new(args.label, args.service, Secret::new(kind, args.secret));
|
||||||
|
if let Some(url) = args.url {
|
||||||
|
draft = draft.with_url(url);
|
||||||
|
}
|
||||||
|
if let Some(username) = args.username {
|
||||||
|
draft = draft.with_username(username);
|
||||||
|
}
|
||||||
|
let saved = save_account(
|
||||||
|
self.accounts.as_ref(),
|
||||||
|
self.watches.as_ref(),
|
||||||
|
draft,
|
||||||
|
args.watch,
|
||||||
|
)?;
|
||||||
|
Ok(json!({
|
||||||
|
"saved": true,
|
||||||
|
"id": saved.account.id,
|
||||||
|
"label": saved.account.label,
|
||||||
|
"service": saved.account.service,
|
||||||
|
"secret_kind": saved.account.secret_kind,
|
||||||
|
"watched": saved.watch_added,
|
||||||
|
"watchable_forge": saved.forge_known,
|
||||||
|
})
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `identity_list`: every account, redacted — the store's own records,
|
||||||
|
/// which have no secret field to leak.
|
||||||
|
struct IdentityList {
|
||||||
|
accounts: Arc<dyn IdentityStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for IdentityList {
|
||||||
|
fn spec(&self) -> ToolSpec {
|
||||||
|
ToolSpec::new(
|
||||||
|
"identity_list",
|
||||||
|
"List the accounts the daemon holds. Secrets are never shown — only each account's \
|
||||||
|
label, service, url, username, and what kind of secret it keeps.",
|
||||||
|
json!({ "type": "object", "properties": {} }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke(&self, _args: Value) -> Result<String, String> {
|
||||||
|
let accounts = self.accounts.list().map_err(|err| err.to_string())?;
|
||||||
|
Ok(json!({ "accounts": accounts }).to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `identity_forget`: remove an account, its secret with it, and the
|
||||||
|
/// account watches derived from it.
|
||||||
|
struct IdentityForget {
|
||||||
|
accounts: Arc<dyn IdentityStore>,
|
||||||
|
watches: Arc<ClawStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct ForgetArgs {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for IdentityForget {
|
||||||
|
fn spec(&self) -> ToolSpec {
|
||||||
|
ToolSpec::new(
|
||||||
|
"identity_forget",
|
||||||
|
"Forget a stored account by id: the secret goes with it, and any forge watch that \
|
||||||
|
followed this identity is removed too.",
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "description": "The account id (identity_list's ids)." }
|
||||||
|
},
|
||||||
|
"required": ["id"]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||||
|
let args: ForgetArgs =
|
||||||
|
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||||
|
let (forgot, watches_removed) =
|
||||||
|
forget_account(self.accounts.as_ref(), self.watches.as_ref(), &args.id)?;
|
||||||
|
Ok(json!({
|
||||||
|
"forgot": forgot,
|
||||||
|
"watches_removed": watches_removed,
|
||||||
|
})
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The identity tool set over the account store and the watch table.
|
||||||
|
pub fn registry(accounts: Arc<dyn IdentityStore>, watches: Arc<ClawStore>) -> ToolRegistry {
|
||||||
|
let mut registry = ToolRegistry::new();
|
||||||
|
registry.register(Arc::new(IdentitySave {
|
||||||
|
accounts: accounts.clone(),
|
||||||
|
watches: watches.clone(),
|
||||||
|
}));
|
||||||
|
registry.register(Arc::new(IdentityList {
|
||||||
|
accounts: accounts.clone(),
|
||||||
|
}));
|
||||||
|
registry.register(Arc::new(IdentityForget { accounts, watches }));
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tool's name on the wire.
|
||||||
|
pub const IDENTITY_EXEC: &str = "identity_exec";
|
||||||
|
|
||||||
|
/// How long one command may run before it is killed.
|
||||||
|
const EXEC_TIMEOUT: Duration = Duration::from_secs(180);
|
||||||
|
|
||||||
|
/// The longest a caller may stretch a command's budget.
|
||||||
|
const EXEC_TIMEOUT_MAX: Duration = Duration::from_secs(600);
|
||||||
|
|
||||||
|
/// The output tail a result carries, mirroring the bash tool's caps:
|
||||||
|
/// past either the byte or the line limit the head is elided and the
|
||||||
|
/// tail kept — a failure's summary lives at the end.
|
||||||
|
fn render_truncated(bytes: &[u8]) -> String {
|
||||||
|
const LIMIT: usize = 50 * 1024;
|
||||||
|
const LINES: usize = 2000;
|
||||||
|
let text = String::from_utf8_lossy(bytes);
|
||||||
|
let mut lines: Vec<&str> = text.lines().collect();
|
||||||
|
if bytes.len() <= LIMIT && lines.len() <= LINES {
|
||||||
|
return text.into_owned();
|
||||||
|
}
|
||||||
|
if lines.len() > LINES {
|
||||||
|
lines = lines[lines.len() - LINES..].to_vec();
|
||||||
|
}
|
||||||
|
let mut kept = String::new();
|
||||||
|
for line in lines {
|
||||||
|
if kept.len() + line.len() + 1 > LIMIT {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
kept.push_str(line);
|
||||||
|
kept.push('\n');
|
||||||
|
}
|
||||||
|
let elided = bytes.len() - kept.len();
|
||||||
|
format!("[output truncated — {elided} bytes elided]\n{kept}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The service's own env names a token answers as, beyond the generic
|
||||||
|
/// `ACCOUNT_*` set: the names its CLI and API clients read.
|
||||||
|
fn service_env_names(service: &str) -> &'static [&'static str] {
|
||||||
|
match service.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"github" => &["GITHUB_TOKEN", "GH_TOKEN"],
|
||||||
|
"gitea" => &["GITEA_TOKEN"],
|
||||||
|
"gitlab" => &["GITLAB_TOKEN"],
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The git credential helper the command runs with: a shell snippet
|
||||||
|
/// that answers git's https prompts from the injected environment. The
|
||||||
|
/// secret lives only in the child process's env — never in the value
|
||||||
|
/// itself, never in the output.
|
||||||
|
const GIT_CREDENTIAL_HELPER: &str = "!f() { \
|
||||||
|
printf 'username=%s\\n' \"$ACCOUNT_USERNAME\"; \
|
||||||
|
printf 'password=%s\\n' \"$ACCOUNT_SECRET\"; }; f";
|
||||||
|
|
||||||
|
/// `identity_exec`: act with a stored account. The one tool-side
|
||||||
|
/// consumer of the store's `secret()` — the credential is injected into
|
||||||
|
/// one child process's environment and a git credential helper, the
|
||||||
|
/// command runs, and only its output returns. The value reaches its
|
||||||
|
/// destination; it never reaches the conversation.
|
||||||
|
pub struct IdentityExec {
|
||||||
|
accounts: Arc<dyn IdentityStore>,
|
||||||
|
workspace: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdentityExec {
|
||||||
|
/// The executor over `accounts`, running commands in `workspace`.
|
||||||
|
pub fn new(accounts: Arc<dyn IdentityStore>, workspace: impl Into<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
accounts,
|
||||||
|
workspace: workspace.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exec(
|
||||||
|
&self,
|
||||||
|
call: ToolCall,
|
||||||
|
progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
|
||||||
|
) -> ToolResult {
|
||||||
|
let id = match call.args.get("id").and_then(Value::as_str) {
|
||||||
|
Some(id) => id.trim().to_owned(),
|
||||||
|
None => return ToolResult::failure(call.id, "missing required argument: id"),
|
||||||
|
};
|
||||||
|
let command = match call.args.get("command").and_then(Value::as_str) {
|
||||||
|
Some(command) => command,
|
||||||
|
None => return ToolResult::failure(call.id, "missing required argument: command"),
|
||||||
|
};
|
||||||
|
let timeout = match call.args.get("timeout_secs") {
|
||||||
|
None | Some(Value::Null) => EXEC_TIMEOUT,
|
||||||
|
Some(value) => {
|
||||||
|
let Some(secs) = value.as_u64() else {
|
||||||
|
return ToolResult::failure(call.id, "timeout_secs must be a number");
|
||||||
|
};
|
||||||
|
Duration::from_secs(secs.clamp(1, EXEC_TIMEOUT_MAX.as_secs()))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(account) = self
|
||||||
|
.accounts
|
||||||
|
.get(&id)
|
||||||
|
.map_err(|err| err.to_string())
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
else {
|
||||||
|
return ToolResult::failure(
|
||||||
|
call.id,
|
||||||
|
format!("no account {id:?} — identity_list names them"),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let Some(secret) = self
|
||||||
|
.accounts
|
||||||
|
.secret(&id)
|
||||||
|
.map_err(|err| err.to_string())
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
else {
|
||||||
|
return ToolResult::failure(call.id, format!("account {id:?} has no secret"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cmd = tokio::process::Command::new("bash");
|
||||||
|
cmd.arg("-c")
|
||||||
|
.arg(command)
|
||||||
|
.current_dir(&self.workspace)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.env("ACCOUNT_ID", &account.id)
|
||||||
|
.env("ACCOUNT_SERVICE", &account.service)
|
||||||
|
.env(
|
||||||
|
"ACCOUNT_USERNAME",
|
||||||
|
account.username.as_deref().unwrap_or(""),
|
||||||
|
)
|
||||||
|
.env("ACCOUNT_SECRET", &secret.value)
|
||||||
|
// git asks https credentials from the helper, which reads
|
||||||
|
// the injected env; a failed auth must fail, not hang.
|
||||||
|
.env("GIT_CONFIG_COUNT", "1")
|
||||||
|
.env("GIT_CONFIG_KEY_0", "credential.helper")
|
||||||
|
.env("GIT_CONFIG_VALUE_0", GIT_CREDENTIAL_HELPER)
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0");
|
||||||
|
if secret.kind == SecretKind::Token {
|
||||||
|
for name in service_env_names(&account.service) {
|
||||||
|
cmd.env(name, &secret.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child = match cmd.spawn() {
|
||||||
|
Ok(child) => child,
|
||||||
|
Err(err) => {
|
||||||
|
return ToolResult::failure(call.id, format!("failed to spawn bash: {err}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
if let Some(stdout) = child.stdout.take() {
|
||||||
|
pump(stdout, false, tx.clone());
|
||||||
|
}
|
||||||
|
if let Some(stderr) = child.stderr.take() {
|
||||||
|
pump(stderr, true, tx.clone());
|
||||||
|
}
|
||||||
|
drop(tx);
|
||||||
|
let deadline = tokio::time::Instant::now() + timeout;
|
||||||
|
let mut stdout: Vec<u8> = Vec::new();
|
||||||
|
let mut stderr: Vec<u8> = Vec::new();
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep_until(deadline) => {
|
||||||
|
let _ = child.start_kill();
|
||||||
|
let _ = child.wait().await;
|
||||||
|
return ToolResult::failure(
|
||||||
|
call.id,
|
||||||
|
format!(
|
||||||
|
"command timed out after {}s and was killed",
|
||||||
|
timeout.as_secs()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
line = rx.recv() => match line {
|
||||||
|
Some((is_stderr, line)) => {
|
||||||
|
let sink = if is_stderr { &mut stderr } else { &mut stdout };
|
||||||
|
sink.extend_from_slice(line.as_bytes());
|
||||||
|
sink.push(b'\n');
|
||||||
|
progress(&format!("{line}\n"));
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let status = match tokio::time::timeout(timeout, child.wait()).await {
|
||||||
|
Ok(Ok(status)) => status,
|
||||||
|
_ => {
|
||||||
|
return ToolResult::failure(
|
||||||
|
call.id,
|
||||||
|
format!(
|
||||||
|
"command timed out after {}s and was killed",
|
||||||
|
timeout.as_secs()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let content = format!(
|
||||||
|
"exit code {}\n--- stdout ---\n{}\n--- stderr ---\n{}",
|
||||||
|
status
|
||||||
|
.code()
|
||||||
|
.map(|code| code.to_string())
|
||||||
|
.unwrap_or_else(|| "signal".to_owned()),
|
||||||
|
render_truncated(&stdout),
|
||||||
|
render_truncated(&stderr),
|
||||||
|
);
|
||||||
|
let result = if status.success() {
|
||||||
|
ToolResult::success(call.id, content)
|
||||||
|
} else {
|
||||||
|
ToolResult::failure(call.id, content)
|
||||||
|
};
|
||||||
|
match status.code() {
|
||||||
|
Some(code) => result.with_code(code),
|
||||||
|
None => result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `stream` line by line into `tx`, tagged with its stream, until
|
||||||
|
/// EOF — the live side of the command's output.
|
||||||
|
fn pump<R>(stream: R, is_stderr: bool, tx: tokio::sync::mpsc::UnboundedSender<(bool, String)>)
|
||||||
|
where
|
||||||
|
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||||
|
{
|
||||||
|
tokio::spawn(async move {
|
||||||
|
use tokio::io::AsyncBufReadExt;
|
||||||
|
let mut lines = tokio::io::BufReader::new(stream).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
if tx.send((is_stderr, line)).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ToolResolver for IdentityExec {
|
||||||
|
fn specs(&self) -> Vec<ToolSpec> {
|
||||||
|
vec![ToolSpec::new(
|
||||||
|
IDENTITY_EXEC,
|
||||||
|
"Act with a stored account's credential without ever seeing \
|
||||||
|
it: run one shell command in this session's workspace with \
|
||||||
|
the secret injected as environment variables — ACCOUNT_SECRET, \
|
||||||
|
the service's own (GITHUB_TOKEN/GH_TOKEN, GITEA_TOKEN, \
|
||||||
|
GITLAB_TOKEN) — and a git credential helper preconfigured, \
|
||||||
|
so plain `git clone`/`git push` over https and API calls \
|
||||||
|
(curl, gh) just work. Only the command's output comes back; \
|
||||||
|
the secret itself never enters the conversation. Pick `id` \
|
||||||
|
from identity_list.",
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string",
|
||||||
|
"description": "The account's id, as identity_list \
|
||||||
|
reports it." },
|
||||||
|
"command": { "type": "string",
|
||||||
|
"description": "The shell command to run." },
|
||||||
|
"timeout_secs": { "type": "integer",
|
||||||
|
"description": "Kill the command after this many \
|
||||||
|
seconds; 180 by default, 600 at most." }
|
||||||
|
},
|
||||||
|
"required": ["id", "command"]
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve(&self, call: ToolCall, _ctx: &ToolContext) -> ToolResult {
|
||||||
|
self.exec(call, &mut |_| {}).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_streaming(
|
||||||
|
&self,
|
||||||
|
call: ToolCall,
|
||||||
|
ctx: &ToolContext,
|
||||||
|
progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
|
||||||
|
) -> ToolResult {
|
||||||
|
if ctx.cancelled.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
return ToolResult::failure(call.id, "cancelled");
|
||||||
|
}
|
||||||
|
self.exec(call, progress).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn parts() -> (Arc<dyn IdentityStore>, Arc<ClawStore>) {
|
||||||
|
(
|
||||||
|
Arc::new(rhema_identity::VolatileIdentityStore::new()),
|
||||||
|
Arc::new(ClawStore::in_memory().expect("store opens")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(tool: &dyn Tool, args: Value) -> Result<String, String> {
|
||||||
|
futures_block_on(tool.invoke(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tools are async over a sync store: one block-on harness for
|
||||||
|
/// the tests.
|
||||||
|
fn futures_block_on<F: std::future::Future>(future: F) -> F::Output {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("runtime builds")
|
||||||
|
.block_on(future)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_answers_without_the_secret_and_watch_lands_for_a_known_forge() {
|
||||||
|
let (accounts, watches) = parts();
|
||||||
|
let tool = IdentitySave {
|
||||||
|
accounts: accounts.clone(),
|
||||||
|
watches: watches.clone(),
|
||||||
|
};
|
||||||
|
let answer = call(
|
||||||
|
&tool,
|
||||||
|
json!({
|
||||||
|
"label": "acme bot",
|
||||||
|
"service": "github",
|
||||||
|
"secret": "sekrit-token",
|
||||||
|
"watch": true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("saves");
|
||||||
|
assert!(!answer.contains("sekrit-token"), "{answer}");
|
||||||
|
assert!(answer.contains("\"watched\":true"), "{answer}");
|
||||||
|
|
||||||
|
let listed = call(
|
||||||
|
&IdentityList {
|
||||||
|
accounts: accounts.clone(),
|
||||||
|
},
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.expect("lists");
|
||||||
|
assert!(!listed.contains("sekrit-token"), "{listed}");
|
||||||
|
assert!(listed.contains("acme bot"), "{listed}");
|
||||||
|
|
||||||
|
let watched = watches.list_account_watches().expect("watches read");
|
||||||
|
assert_eq!(watched.len(), 1);
|
||||||
|
assert_eq!(watched[0].forge, "github");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forget_cascades_the_derived_watch() {
|
||||||
|
let (accounts, watches) = parts();
|
||||||
|
let saved = save_account(
|
||||||
|
accounts.as_ref(),
|
||||||
|
watches.as_ref(),
|
||||||
|
AccountDraft::new("acme", "github", Secret::new(SecretKind::Token, "t")),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("saves");
|
||||||
|
assert!(saved.watch_added);
|
||||||
|
|
||||||
|
let tool = IdentityForget {
|
||||||
|
accounts: accounts.clone(),
|
||||||
|
watches: watches.clone(),
|
||||||
|
};
|
||||||
|
let answer = call(&tool, json!({ "id": saved.account.id })).expect("forgets");
|
||||||
|
assert!(answer.contains("\"forgot\":true"), "{answer}");
|
||||||
|
assert!(answer.contains("\"watches_removed\":1"), "{answer}");
|
||||||
|
assert!(watches.list_account_watches().expect("reads").is_empty());
|
||||||
|
assert!(!accounts.secret(&saved.account.id).expect("reads").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_watch_request_on_an_unknown_service_saves_without_watching() {
|
||||||
|
let (accounts, watches) = parts();
|
||||||
|
let saved = save_account(
|
||||||
|
accounts.as_ref(),
|
||||||
|
watches.as_ref(),
|
||||||
|
AccountDraft::new("mail", "smtp", Secret::new(SecretKind::Password, "p"))
|
||||||
|
.with_url("https://mail.example.net"),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("saves");
|
||||||
|
assert!(!saved.watch_added);
|
||||||
|
assert!(!saved.forge_known);
|
||||||
|
assert!(watches.list_account_watches().expect("reads").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forge_of_matches_services_and_production_hosts_only() {
|
||||||
|
assert_eq!(forge_of("GitHub", None), Some(ForgeKind::GitHub));
|
||||||
|
assert_eq!(
|
||||||
|
forge_of("whatever", Some("https://github.com/acme")),
|
||||||
|
Some(ForgeKind::GitHub)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
forge_of("whatever", Some("https://gitlab.com/acme/w")),
|
||||||
|
Some(ForgeKind::GitLab)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
forge_of("whatever", Some("https://git.example.net/acme/w")),
|
||||||
|
None,
|
||||||
|
"a self-hosted host is unrecognizable by URL alone"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
forge_of("gitea", Some("https://git.example.net")),
|
||||||
|
Some(ForgeKind::Gitea)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_secret_kind_is_rejected_in_band() {
|
||||||
|
let (accounts, watches) = parts();
|
||||||
|
let tool = IdentitySave { accounts, watches };
|
||||||
|
let err = call(
|
||||||
|
&tool,
|
||||||
|
json!({
|
||||||
|
"label": "x",
|
||||||
|
"service": "github",
|
||||||
|
"secret": "t",
|
||||||
|
"secret_kind": "pin"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect_err("rejected");
|
||||||
|
assert!(err.contains("unknown secret_kind"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The use half of the accounts: the command runs with the
|
||||||
|
/// service's env injected, the git helper configured, and none of
|
||||||
|
/// the secret in the result.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn identity_exec_runs_with_the_credential_in_its_environment() {
|
||||||
|
let accounts = Arc::new(rhema_identity::VolatileIdentityStore::new());
|
||||||
|
let stored = accounts
|
||||||
|
.put(
|
||||||
|
AccountDraft::new(
|
||||||
|
"github releases",
|
||||||
|
"github",
|
||||||
|
Secret::new(SecretKind::Token, "tok-1234567890"),
|
||||||
|
)
|
||||||
|
.with_username("kosmos-ai-bot"),
|
||||||
|
)
|
||||||
|
.expect("the account stores");
|
||||||
|
let workspace = tempfile::tempdir().expect("tempdir");
|
||||||
|
let exec = IdentityExec::new(accounts, workspace.path());
|
||||||
|
let id = stored.id.clone();
|
||||||
|
|
||||||
|
async fn run(exec: &IdentityExec, id: &str, command: &str) -> rhema_core::ToolResult {
|
||||||
|
exec.resolve(
|
||||||
|
ToolCall {
|
||||||
|
id: "c1".into(),
|
||||||
|
name: IDENTITY_EXEC.into(),
|
||||||
|
args: json!({ "id": id, "command": command }),
|
||||||
|
provider_data: Value::Null,
|
||||||
|
},
|
||||||
|
&rhema_core::ToolContext::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// The service's token and the generic set are all the same
|
||||||
|
// value, and the username rode along.
|
||||||
|
let result = run(
|
||||||
|
&exec,
|
||||||
|
&id,
|
||||||
|
"[ -n \"$GITHUB_TOKEN\" ] && [ \"$GH_TOKEN\" = \"$ACCOUNT_SECRET\" ] \
|
||||||
|
&& [ \"$ACCOUNT_USERNAME\" = \"kosmos-ai-bot\" ] \
|
||||||
|
&& [ \"$GIT_CONFIG_COUNT\" = \"1\" ] && echo ok",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(!result.is_error, "{}", result.content);
|
||||||
|
assert!(result.content.contains("ok"), "{}", result.content);
|
||||||
|
assert!(
|
||||||
|
!result.content.contains("tok-1234567890"),
|
||||||
|
"the secret never rides the answer: {}",
|
||||||
|
result.content
|
||||||
|
);
|
||||||
|
|
||||||
|
// The helper answers git's prompt shape from the env.
|
||||||
|
let result = run(
|
||||||
|
&exec,
|
||||||
|
&id,
|
||||||
|
"printf 'username=%s password=%s' \"$ACCOUNT_USERNAME\" \"$ACCOUNT_SECRET\" \
|
||||||
|
| grep -c kosmos-ai-bot",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(!result.is_error, "{}", result.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn identity_exec_reports_exit_codes_and_unknown_accounts() {
|
||||||
|
let accounts = Arc::new(rhema_identity::VolatileIdentityStore::new());
|
||||||
|
let stored = accounts
|
||||||
|
.put(AccountDraft::new(
|
||||||
|
"gitea",
|
||||||
|
"gitea",
|
||||||
|
Secret::new(SecretKind::Token, "gt-1"),
|
||||||
|
))
|
||||||
|
.expect("the account stores");
|
||||||
|
let workspace = tempfile::tempdir().expect("tempdir");
|
||||||
|
let exec = IdentityExec::new(accounts.clone(), workspace.path());
|
||||||
|
let id = stored.id.clone();
|
||||||
|
|
||||||
|
let call = |id: &str, args: Value| ToolCall {
|
||||||
|
id: "c1".into(),
|
||||||
|
name: IDENTITY_EXEC.into(),
|
||||||
|
args: {
|
||||||
|
let mut args = args;
|
||||||
|
args["id"] = json!(id);
|
||||||
|
args
|
||||||
|
},
|
||||||
|
provider_data: Value::Null,
|
||||||
|
};
|
||||||
|
let failure = exec
|
||||||
|
.resolve(
|
||||||
|
call(&id, json!({ "command": "exit 3" })),
|
||||||
|
&rhema_core::ToolContext::default(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(failure.is_error);
|
||||||
|
assert_eq!(failure.code, Some(3));
|
||||||
|
assert!(failure.content.contains("exit code 3"));
|
||||||
|
|
||||||
|
// A gitea token answers as GITEA_TOKEN; a github account it is not.
|
||||||
|
let check = exec
|
||||||
|
.resolve(
|
||||||
|
call(
|
||||||
|
&id,
|
||||||
|
json!({
|
||||||
|
"command": "[ -n \"$GITEA_TOKEN\" ] && [ -z \"$GITHUB_TOKEN\" ]"
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
&rhema_core::ToolContext::default(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(!check.is_error, "{}", check.content);
|
||||||
|
|
||||||
|
let missing = exec
|
||||||
|
.resolve(
|
||||||
|
call("99", json!({ "command": "echo hi" })),
|
||||||
|
&rhema_core::ToolContext::default(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(missing.is_error);
|
||||||
|
assert!(
|
||||||
|
missing.content.contains("no account"),
|
||||||
|
"{}",
|
||||||
|
missing.content
|
||||||
|
);
|
||||||
|
|
||||||
|
// The command runs in the session's workspace.
|
||||||
|
let pwd = exec
|
||||||
|
.resolve(
|
||||||
|
call(&id, json!({ "command": "pwd" })),
|
||||||
|
&rhema_core::ToolContext::default(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
pwd.content
|
||||||
|
.contains(workspace.path().to_str().expect("utf8")),
|
||||||
|
"{}",
|
||||||
|
pwd.content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
//! The attachment store and the `attach_file` tool: how a turn hands
|
||||||
|
//! the human a file from its workspace — a screenshot it just took, a
|
||||||
|
//! diff, a log the transcript's snippets cut.
|
||||||
|
//!
|
||||||
|
//! A stored attachment is a copy, not a reference: the bytes move into
|
||||||
|
//! the attachments directory beside the database under an id, so the
|
||||||
|
//! turn's later work (or a workspace cleanup) cannot break a link the
|
||||||
|
//! transcript already served. The transcript carries the metadata —
|
||||||
|
//! name, size, media kind, caption — and a client fetches bytes
|
||||||
|
//! through `GET /v1/attachments/{id}`. A size cap keeps one tool call
|
||||||
|
//! from turning the store into a workspace mirror; a file past the cap
|
||||||
|
//! is an in-band error, and the model can cut it down first.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||||
|
use rhema_core::ToolSpec;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::hub::{EventHub, TurnEvent};
|
||||||
|
use rhema_rsd::ResourceKey;
|
||||||
|
|
||||||
|
/// How large one attachment may be. Generous for screenshots and
|
||||||
|
/// text; a whole dataset does not belong in a chat.
|
||||||
|
pub const MAX_ATTACHMENT_BYTES: usize = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Why an attachment operation failed.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AttachmentError {
|
||||||
|
/// The backend failed to read or write.
|
||||||
|
#[error("attachment store failed: {0}")]
|
||||||
|
Backend(#[source] std::io::Error),
|
||||||
|
/// A stored attachment's file went missing.
|
||||||
|
#[error("attachment {0} is gone from the store")]
|
||||||
|
Missing(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One stored attachment: the metadata the transcript carries.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||||
|
pub struct Stored {
|
||||||
|
/// The attachment's id (`att-…`), the fetch path's address.
|
||||||
|
pub id: String,
|
||||||
|
/// The original file's name, as the tool call named it.
|
||||||
|
pub name: String,
|
||||||
|
/// The byte size.
|
||||||
|
pub size: u64,
|
||||||
|
/// The media kind the name implies: `image`, `code`, or `file`.
|
||||||
|
pub media: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The attachments directory. Shareable behind an `Arc`; every
|
||||||
|
/// operation takes `&self`.
|
||||||
|
pub struct AttachmentStore {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AttachmentStore {
|
||||||
|
/// Opens (creating if needed) the attachments directory at `root` —
|
||||||
|
/// beside the database, the same state volume the transcript lives
|
||||||
|
/// on, so a served id never outlives its bytes.
|
||||||
|
pub fn open(root: impl Into<PathBuf>) -> Result<Self, AttachmentError> {
|
||||||
|
let root = root.into();
|
||||||
|
std::fs::create_dir_all(&root).map_err(AttachmentError::Backend)?;
|
||||||
|
Ok(Self { root })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores `bytes` under `name`. The name is sanitized to its file
|
||||||
|
/// name and rides the stored file name only for content-type
|
||||||
|
/// guesses and human downloads; the id is the address.
|
||||||
|
pub fn put(&self, name: &str, bytes: &[u8]) -> Result<Stored, AttachmentError> {
|
||||||
|
static ATTACHMENT_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64;
|
||||||
|
let id = format!(
|
||||||
|
"att-{}-{:04x}",
|
||||||
|
now,
|
||||||
|
ATTACHMENT_SEQ.fetch_add(1, Ordering::Relaxed)
|
||||||
|
);
|
||||||
|
let name = sanitize_name(name);
|
||||||
|
let media = media_of(&name);
|
||||||
|
std::fs::write(self.root.join(&id), bytes).map_err(AttachmentError::Backend)?;
|
||||||
|
// The name rides a sidecar: the transcript carries the original
|
||||||
|
// spelling, and the fetch route needs the file name back for
|
||||||
|
// its content type and Content-Disposition.
|
||||||
|
std::fs::write(self.root.join(format!("{id}.name")), &name)
|
||||||
|
.map_err(AttachmentError::Backend)?;
|
||||||
|
Ok(Stored {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
size: bytes.len() as u64,
|
||||||
|
media,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored attachment's bytes, when it exists.
|
||||||
|
pub fn get(&self, id: &str) -> Result<Option<(Stored, Vec<u8>)>, AttachmentError> {
|
||||||
|
if !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let path = self.root.join(id);
|
||||||
|
let metadata = match std::fs::metadata(&path) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(err) => return Err(AttachmentError::Backend(err)),
|
||||||
|
};
|
||||||
|
let bytes = std::fs::read(&path).map_err(AttachmentError::Backend)?;
|
||||||
|
let name = std::fs::read_to_string(self.root.join(format!("{id}.name")))
|
||||||
|
.ok()
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.unwrap_or_else(|| id.to_owned());
|
||||||
|
let stored = Stored {
|
||||||
|
id: id.to_owned(),
|
||||||
|
name,
|
||||||
|
size: metadata.len(),
|
||||||
|
media: "file",
|
||||||
|
};
|
||||||
|
Ok(Some((stored, bytes)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reduces a caller-supplied name to a safe single file name: its last
|
||||||
|
/// path component, path separators and control characters folded to
|
||||||
|
/// `_`, never empty. The original spelling travels in the transcript's
|
||||||
|
/// metadata; this form only guards the store's content-type guesses.
|
||||||
|
fn sanitize_name(name: &str) -> String {
|
||||||
|
let cleaned: String = name
|
||||||
|
.rsplit(['/', '\\'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or(name)
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_control() { '_' } else { c })
|
||||||
|
.collect();
|
||||||
|
let trimmed = cleaned.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
"attachment".to_owned()
|
||||||
|
} else {
|
||||||
|
trimmed.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The media kind a file name implies, the shape the wire's
|
||||||
|
/// `attachment` events carry: images render inline where a client can,
|
||||||
|
/// source files read as code, everything else downloads.
|
||||||
|
pub fn media_of(name: &str) -> &'static str {
|
||||||
|
let extension = Path::new(name)
|
||||||
|
.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
.map(|extension| extension.to_ascii_lowercase());
|
||||||
|
match extension.as_deref() {
|
||||||
|
Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "ico" | "avif") => "image",
|
||||||
|
Some(
|
||||||
|
"rs" | "go" | "c" | "h" | "cpp" | "hpp" | "py" | "rb" | "java" | "kt" | "swift" | "js"
|
||||||
|
| "mjs" | "cjs" | "ts" | "tsx" | "jsx" | "vue" | "svelte" | "sh" | "bash" | "zsh"
|
||||||
|
| "fish" | "sql" | "toml" | "yaml" | "yml" | "json" | "jsonc" | "xml" | "html" | "css"
|
||||||
|
| "scss" | "md" | "markdown" | "txt" | "diff" | "patch" | "lua" | "pl" | "php" | "cs"
|
||||||
|
| "scala" | "hs" | "ex" | "exs" | "zig" | "gradle" | "tf",
|
||||||
|
) => "code",
|
||||||
|
_ => "file",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The content type a file name implies, for the fetch route's header.
|
||||||
|
pub fn content_type(name: &str) -> &'static str {
|
||||||
|
let extension = Path::new(name)
|
||||||
|
.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
.map(|extension| extension.to_ascii_lowercase());
|
||||||
|
match extension.as_deref() {
|
||||||
|
Some("png") => "image/png",
|
||||||
|
Some("jpg" | "jpeg") => "image/jpeg",
|
||||||
|
Some("gif") => "image/gif",
|
||||||
|
Some("webp") => "image/webp",
|
||||||
|
Some("svg") => "image/svg+xml",
|
||||||
|
Some("bmp") => "image/bmp",
|
||||||
|
Some("ico") => "image/x-icon",
|
||||||
|
Some("avif") => "image/avif",
|
||||||
|
Some("json") => "application/json",
|
||||||
|
Some("pdf") => "application/pdf",
|
||||||
|
Some("html") => "text/html; charset=utf-8",
|
||||||
|
Some("css") => "text/css; charset=utf-8",
|
||||||
|
Some("md" | "markdown" | "txt" | "log" | "diff" | "patch") => "text/plain; charset=utf-8",
|
||||||
|
Some("xml") => "application/xml",
|
||||||
|
Some("wasm") => "application/wasm",
|
||||||
|
Some("zip") => "application/zip",
|
||||||
|
Some("tar") => "application/x-tar",
|
||||||
|
Some("gz") => "application/gzip",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tool's name on the wire.
|
||||||
|
pub const ATTACH_FILE: &str = "attach_file";
|
||||||
|
|
||||||
|
/// `attach_file`: copy a workspace file into the attachment store and
|
||||||
|
/// put its card on the stream. Built per dispatch — the event must
|
||||||
|
/// land on the asking session.
|
||||||
|
struct AttachFile {
|
||||||
|
store: Arc<AttachmentStore>,
|
||||||
|
workspace: PathBuf,
|
||||||
|
hub: Arc<EventHub>,
|
||||||
|
key: ResourceKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct AttachArgs {
|
||||||
|
path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
caption: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for AttachFile {
|
||||||
|
fn spec(&self) -> ToolSpec {
|
||||||
|
ToolSpec::new(
|
||||||
|
ATTACH_FILE,
|
||||||
|
"Attach a file from this session's workspace to the chat: the \
|
||||||
|
human gets a card with the file's name and can view or \
|
||||||
|
download it. Use it to hand over artifacts a reply snippet \
|
||||||
|
cannot carry — a screenshot, a diff, a generated report, a \
|
||||||
|
log. The path is workspace-relative. Large files are \
|
||||||
|
refused; attach the relevant cut instead.",
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": { "type": "string",
|
||||||
|
"description": "The workspace-relative file path, \
|
||||||
|
e.g. 'screenshots/failure.png'." },
|
||||||
|
"caption": { "type": "string",
|
||||||
|
"description": "One line about what the file is \
|
||||||
|
and why it matters." }
|
||||||
|
},
|
||||||
|
"required": ["path"]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||||
|
let args: AttachArgs =
|
||||||
|
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||||
|
let relative = Path::new(&args.path);
|
||||||
|
if relative.is_absolute()
|
||||||
|
|| relative
|
||||||
|
.components()
|
||||||
|
.any(|c| c == std::path::Component::ParentDir)
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"path must be relative to the workspace, without '..': {:?}",
|
||||||
|
args.path
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let path = self.workspace.join(relative);
|
||||||
|
let metadata = std::fs::metadata(&path)
|
||||||
|
.map_err(|err| format!("cannot attach {:?}: {err}", args.path))?;
|
||||||
|
if metadata.len() > MAX_ATTACHMENT_BYTES as u64 {
|
||||||
|
return Err(format!(
|
||||||
|
"cannot attach {:?}: {} bytes exceeds the {}-byte limit; attach a smaller cut",
|
||||||
|
args.path,
|
||||||
|
metadata.len(),
|
||||||
|
MAX_ATTACHMENT_BYTES
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let bytes =
|
||||||
|
std::fs::read(&path).map_err(|err| format!("cannot attach {:?}: {err}", args.path))?;
|
||||||
|
let stored = self
|
||||||
|
.store
|
||||||
|
.put(&args.path, &bytes)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
let caption = args
|
||||||
|
.caption
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|caption| !caption.is_empty())
|
||||||
|
.map(str::to_owned);
|
||||||
|
self.hub.emit(
|
||||||
|
&self.key,
|
||||||
|
TurnEvent::Attachment {
|
||||||
|
id: stored.id.clone(),
|
||||||
|
name: stored.name.clone(),
|
||||||
|
media: stored.media.to_owned(),
|
||||||
|
size: stored.size,
|
||||||
|
caption: caption.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(json!({
|
||||||
|
"attached": true,
|
||||||
|
"id": stored.id,
|
||||||
|
"name": stored.name,
|
||||||
|
"size": stored.size,
|
||||||
|
"media": stored.media,
|
||||||
|
"caption": caption,
|
||||||
|
})
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The attachment tool set over `store`: `attach_file`.
|
||||||
|
pub fn registry(
|
||||||
|
store: Arc<AttachmentStore>,
|
||||||
|
workspace: impl Into<PathBuf>,
|
||||||
|
hub: Arc<EventHub>,
|
||||||
|
key: ResourceKey,
|
||||||
|
) -> ToolRegistry {
|
||||||
|
let mut registry = ToolRegistry::new();
|
||||||
|
registry.register(Arc::new(AttachFile {
|
||||||
|
store,
|
||||||
|
workspace: workspace.into(),
|
||||||
|
hub,
|
||||||
|
key,
|
||||||
|
}));
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn store(dir: &Path) -> Arc<AttachmentStore> {
|
||||||
|
Arc::new(AttachmentStore::open(dir.join("attachments")).expect("store opens"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stored_attachment_round_trips_through_its_id() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let store = store(dir.path());
|
||||||
|
let stored = store
|
||||||
|
.put("screenshots/failure.png", b"\x89PNG")
|
||||||
|
.expect("puts");
|
||||||
|
assert_eq!(
|
||||||
|
stored.name, "failure.png",
|
||||||
|
"the name folds to its file name"
|
||||||
|
);
|
||||||
|
assert_eq!(stored.media, "image");
|
||||||
|
assert_eq!(stored.size, 4);
|
||||||
|
let (fetched, bytes) = store.get(&stored.id).expect("reads").expect("exists");
|
||||||
|
assert_eq!(bytes, b"\x89PNG");
|
||||||
|
assert_eq!(fetched.id, stored.id);
|
||||||
|
assert_eq!(
|
||||||
|
store.get("../escape").expect("reads"),
|
||||||
|
None,
|
||||||
|
"a path is not an id"
|
||||||
|
);
|
||||||
|
assert_eq!(store.get("att-with$igns").expect("reads"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_kinds_follow_the_extension() {
|
||||||
|
assert_eq!(media_of("a.PNG"), "image");
|
||||||
|
assert_eq!(media_of("src/main.rs"), "code");
|
||||||
|
assert_eq!(media_of("notes.md"), "code");
|
||||||
|
assert_eq!(media_of("archive.tar.gz"), "file");
|
||||||
|
assert_eq!(media_of("no-extension"), "file");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_types_follow_the_extension() {
|
||||||
|
assert_eq!(content_type("a.png"), "image/png");
|
||||||
|
assert_eq!(content_type("run.log"), "text/plain; charset=utf-8");
|
||||||
|
assert_eq!(content_type("blob.bin"), "application/octet-stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_name_that_sanitizes_to_nothing_becomes_a_placeholder() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let store = store(dir.path());
|
||||||
|
let stored = store.put("/", b"x").expect("puts");
|
||||||
|
assert_eq!(stored.name, "attachment");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tool, end to end: a workspace file in, a stored attachment
|
||||||
|
/// and a stream card out, with the caption riding.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn attach_file_copies_a_workspace_file_and_emits_the_card() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let workspace = dir.path().join("interactive");
|
||||||
|
std::fs::create_dir_all(workspace.join("out")).expect("workspace");
|
||||||
|
std::fs::write(workspace.join("out/report.md"), "# report").expect("file");
|
||||||
|
|
||||||
|
let store = store(dir.path());
|
||||||
|
let hub = Arc::new(EventHub::default());
|
||||||
|
let key = ResourceKey::from("interactive:web");
|
||||||
|
let tool = AttachFile {
|
||||||
|
store: store.clone(),
|
||||||
|
workspace: workspace.clone(),
|
||||||
|
hub: hub.clone(),
|
||||||
|
key: key.clone(),
|
||||||
|
};
|
||||||
|
let answer = tool
|
||||||
|
.invoke(json!({"path": "out/report.md", "caption": "the draft"}))
|
||||||
|
.await
|
||||||
|
.expect("in-band ok");
|
||||||
|
let answer: Value = serde_json::from_str(&answer).expect("the answer parses");
|
||||||
|
assert_eq!(answer["attached"], true);
|
||||||
|
assert_eq!(answer["name"], "report.md");
|
||||||
|
assert_eq!(answer["media"], "code");
|
||||||
|
let id = answer["id"].as_str().expect("an id").to_owned();
|
||||||
|
assert!(
|
||||||
|
store.get(&id).expect("reads").is_some(),
|
||||||
|
"the bytes are stored"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (history, _) = hub.subscribe_with_history(&key);
|
||||||
|
assert_eq!(
|
||||||
|
history.last(),
|
||||||
|
Some(&TurnEvent::Attachment {
|
||||||
|
id,
|
||||||
|
name: "report.md".into(),
|
||||||
|
media: "code".into(),
|
||||||
|
size: 8,
|
||||||
|
caption: Some("the draft".into()),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn escapes_oversized_files_and_missing_paths_fail_in_band() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let workspace = dir.path().join("interactive");
|
||||||
|
std::fs::create_dir_all(&workspace).expect("workspace");
|
||||||
|
let tool = AttachFile {
|
||||||
|
store: store(dir.path()),
|
||||||
|
workspace: workspace.clone(),
|
||||||
|
hub: Arc::new(EventHub::default()),
|
||||||
|
key: ResourceKey::from("interactive:web"),
|
||||||
|
};
|
||||||
|
for bad in [
|
||||||
|
json!({"path": "../outside.txt"}),
|
||||||
|
json!({"path": "/etc/hostname"}),
|
||||||
|
json!({"path": "missing.txt"}),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
tool.invoke(bad).await.is_err(),
|
||||||
|
"an escape or a miss is an in-band error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,982 @@
|
|||||||
|
//! The backend registry: every provider the deployment can speak to, one
|
||||||
|
//! active at a time, switchable at runtime.
|
||||||
|
//!
|
||||||
|
//! At boot the registry constructs every provider whose credentials
|
||||||
|
//! resolve — env vars win, the claw's own stored credentials serve
|
||||||
|
//! next, the stored rhema config after that, and the keyless
|
||||||
|
//! llama-cpp backend is always constructible, so a daemon can always
|
||||||
|
//! boot. The same construction runs over HTTP: credentials saved
|
||||||
|
//! through the surface insert (or replace) a backend in the live
|
||||||
|
//! registry and persist in the settings store. A backend is one
|
||||||
|
//! *instance*, named by its `id` — the kind unless a login named it,
|
||||||
|
//! so two llama.cpp servers coexist under two ids; ids of instances
|
||||||
|
//! discovered from env or the config are always their kind. One
|
||||||
|
//! backend is *active*; handlers read the registry per
|
||||||
|
//! dispatch, so a switch takes effect on the next turn without a
|
||||||
|
//! restart. The active pair is deployment state: seeded from the config
|
||||||
|
//! file, persisted in the settings store, changeable over `/v1/providers`
|
||||||
|
//! — the chart pins nothing.
|
||||||
|
//!
|
||||||
|
//! The per-kind default models are fallbacks only, in one place here:
|
||||||
|
//! openrouter's `openrouter/auto` deliberately replaces the old
|
||||||
|
//! copy-pasted google default — a router deserves its own router id.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::{Arc, PoisonError, RwLock, RwLockWriteGuard};
|
||||||
|
|
||||||
|
use rhema_autonomous_agent::async_trait;
|
||||||
|
use rhema_core::{EventSink, ModelOutput, ModelProvider, ModelRequest, ProviderError};
|
||||||
|
use rhema_provider_anthropic::{AnthropicProvider, ZAI_BASE_URL};
|
||||||
|
use rhema_provider_copilot::CopilotProvider;
|
||||||
|
use rhema_provider_google::GoogleProvider;
|
||||||
|
use rhema_provider_llama_cpp::LlamaCppProvider;
|
||||||
|
use rhema_provider_openai_compatible::OpenAiCompatibleProvider;
|
||||||
|
use rhema_provider_openrouter::OpenRouterProvider;
|
||||||
|
|
||||||
|
use rhema_provider::ModelCard;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The default model per backend kind, when neither the operator nor the
|
||||||
|
/// settings store names one.
|
||||||
|
pub fn default_model_for(kind: &str) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
"llama-cpp" => "local",
|
||||||
|
"anthropic" => "claude-sonnet-4-5",
|
||||||
|
"openai" => "gpt-5",
|
||||||
|
"openrouter" => "openrouter/auto",
|
||||||
|
"deepseek" => "deepseek-chat",
|
||||||
|
// The Coding Plan's flagship, as Z.ai's own client spells it.
|
||||||
|
"zai" => "GLM-5.3",
|
||||||
|
"copilot" => "gpt-4.1",
|
||||||
|
_ => "gemini-3.8-flash",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The listing of a card-backed backend, boxed for the registry; a
|
||||||
|
/// backend without one (the mock, llama's raw id list) carries `None`.
|
||||||
|
pub type ModelListing = Pin<Box<dyn Future<Output = Result<Vec<ModelCard>, ProviderError>> + Send>>;
|
||||||
|
|
||||||
|
/// One constructible backend instance: the provider seam, the id the
|
||||||
|
/// registry keys it under, its kind, its model fallback, and how to
|
||||||
|
/// list its models.
|
||||||
|
pub struct Backend {
|
||||||
|
/// The instance id: the kind unless the login named it. The
|
||||||
|
/// registry's key, the credentials row's suffix, and the active
|
||||||
|
/// pair's first half are all this string.
|
||||||
|
pub id: String,
|
||||||
|
/// The backend kind: `"google"`, `"llama-cpp"`, `"openrouter"`,
|
||||||
|
/// `"anthropic"`, `"openai"`, `"mock"`.
|
||||||
|
pub kind: String,
|
||||||
|
/// The provider behind the daemon's `ModelProvider` seam.
|
||||||
|
pub provider: Arc<dyn ModelProvider>,
|
||||||
|
/// The model used when none is set for this kind.
|
||||||
|
pub default_model: String,
|
||||||
|
/// The live model listing, when the backend has one worth calling.
|
||||||
|
list_models: Option<Arc<dyn Fn() -> ModelListing + Send + Sync>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backend {
|
||||||
|
/// A backend without a live listing (the mock). The id doubles as
|
||||||
|
/// the kind: the registry-injected kinds are singletons.
|
||||||
|
pub fn headless(kind: impl Into<String>, provider: Arc<dyn ModelProvider>) -> Self {
|
||||||
|
let kind = kind.into();
|
||||||
|
Self {
|
||||||
|
id: kind.clone(),
|
||||||
|
kind,
|
||||||
|
provider,
|
||||||
|
default_model: "mock".to_owned(),
|
||||||
|
list_models: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The currently active backend instance: id plus the model turns run on.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct Active {
|
||||||
|
id: String,
|
||||||
|
model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of the registry listing: the instance id, its kind, and the
|
||||||
|
/// model fallback.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct BackendRow {
|
||||||
|
/// The instance id.
|
||||||
|
pub id: String,
|
||||||
|
/// The backend kind.
|
||||||
|
pub kind: String,
|
||||||
|
/// The model used when none is set for this instance.
|
||||||
|
pub default_model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The deployment's providers and the active pair, shared by every
|
||||||
|
/// handler so a switch takes effect on the next dispatch. The map is
|
||||||
|
/// keyed by instance id, not kind — two instances of one kind are two
|
||||||
|
/// rows.
|
||||||
|
pub struct BackendRegistry {
|
||||||
|
backends: RwLock<BTreeMap<String, Arc<Backend>>>,
|
||||||
|
active: RwLock<Active>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackendRegistry {
|
||||||
|
/// A registry over `backends`. The active pair is `seed` when that
|
||||||
|
/// id exists, else the first backend's id and default model. An
|
||||||
|
/// empty list is a construction error: the registry exists so
|
||||||
|
/// handlers never face an absent backend.
|
||||||
|
pub fn new(
|
||||||
|
backends: Vec<Backend>,
|
||||||
|
seed: Option<(String, Option<String>)>,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
if backends.is_empty() {
|
||||||
|
return Err(
|
||||||
|
"no provider backend is constructible: no key resolved for any provider".to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let map: BTreeMap<String, Arc<Backend>> = backends
|
||||||
|
.into_iter()
|
||||||
|
.map(|backend| (backend.id.clone(), Arc::new(backend)))
|
||||||
|
.collect();
|
||||||
|
let active = match seed {
|
||||||
|
Some((id, model)) if map.contains_key(&id) => {
|
||||||
|
let default = map[&id].default_model.clone();
|
||||||
|
Active {
|
||||||
|
id,
|
||||||
|
model: model.unwrap_or(default),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let first = map.values().next().expect("non-empty above");
|
||||||
|
Active {
|
||||||
|
id: first.id.clone(),
|
||||||
|
model: first.default_model.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
backends: RwLock::new(map),
|
||||||
|
active: RwLock::new(active),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts (or replaces) `backend` under its id in the live
|
||||||
|
/// registry: the write behind the credentials routes. The active
|
||||||
|
/// pair is untouched — a replaced backend keeps serving under its
|
||||||
|
/// id.
|
||||||
|
pub fn insert(&self, backend: Backend) {
|
||||||
|
let mut backends = self.backends_lock();
|
||||||
|
backends.insert(backend.id.clone(), Arc::new(backend));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The backend registered under `id`, when one is.
|
||||||
|
pub fn get(&self, id: &str) -> Option<Arc<Backend>> {
|
||||||
|
self.backends_lock().get(id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the instance `id` from the live registry. Removing the
|
||||||
|
/// last backend is refused: handlers never face an absent backend.
|
||||||
|
/// When the removed instance was active, the first remaining
|
||||||
|
/// backend (ascending) takes over with its default model — the
|
||||||
|
/// caller re-persists the pair it reads back from [`Self::active`].
|
||||||
|
pub fn remove(&self, id: &str) -> Result<(), String> {
|
||||||
|
let mut backends = self.backends_lock();
|
||||||
|
if backends.len() == 1 && backends.contains_key(id) {
|
||||||
|
return Err("cannot remove the last provider backend".to_owned());
|
||||||
|
}
|
||||||
|
let removed_was_active = self.active_lock().id == id;
|
||||||
|
if backends.remove(id).is_none() {
|
||||||
|
return Err(format!("unknown provider backend {id:?}"));
|
||||||
|
}
|
||||||
|
if removed_was_active {
|
||||||
|
let first = backends.values().next().expect("a backend remains");
|
||||||
|
*self.active_lock() = Active {
|
||||||
|
id: first.id.clone(),
|
||||||
|
model: first.default_model.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The provider and model the next dispatch runs on.
|
||||||
|
pub fn current(&self) -> (Arc<dyn ModelProvider>, String) {
|
||||||
|
let active = self.active_lock();
|
||||||
|
let backend = self.backends_lock()[&active.id].clone();
|
||||||
|
(backend.provider.clone(), active.model.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active pair as data: instance id and model.
|
||||||
|
pub fn active(&self) -> (String, String) {
|
||||||
|
let active = self.active_lock();
|
||||||
|
(active.id.clone(), active.model.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switches the active pair. An unknown instance id is an error; a
|
||||||
|
/// model left unset falls back to that instance's default.
|
||||||
|
pub fn set_active(&self, id: &str, model: Option<&str>) -> Result<(), String> {
|
||||||
|
let default = {
|
||||||
|
let backends = self.backends_lock();
|
||||||
|
let backend = backends
|
||||||
|
.get(id)
|
||||||
|
.ok_or_else(|| format!("unknown provider backend {id:?}"))?;
|
||||||
|
backend.default_model.clone()
|
||||||
|
};
|
||||||
|
let model = model.map(str::to_owned).unwrap_or_else(|| default);
|
||||||
|
*self.active_lock() = Active {
|
||||||
|
id: id.to_owned(),
|
||||||
|
model,
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every constructible backend as listing rows, ascending by id.
|
||||||
|
pub fn list(&self) -> Vec<BackendRow> {
|
||||||
|
self.backends_lock()
|
||||||
|
.values()
|
||||||
|
.map(|backend| BackendRow {
|
||||||
|
id: backend.id.clone(),
|
||||||
|
kind: backend.kind.clone(),
|
||||||
|
default_model: backend.default_model.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live model listing of the instance `id`, when the backend
|
||||||
|
/// carries one.
|
||||||
|
pub fn list_models(&self, id: &str) -> Option<ModelListing> {
|
||||||
|
let backend = self.backends_lock().get(id)?.clone();
|
||||||
|
backend.list_models.as_ref().map(|list| list())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_lock(&self) -> RwLockWriteGuard<'_, Active> {
|
||||||
|
self.active.write().unwrap_or_else(PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backends_lock(&self) -> RwLockWriteGuard<'_, BTreeMap<String, Arc<Backend>>> {
|
||||||
|
self.backends
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The credential-backed seed for the active pair when the config
|
||||||
|
/// names no provider: the first discovered backend that resolved an
|
||||||
|
/// actual credential — a stored row, an env key, or the stored rhema
|
||||||
|
/// profile. The keyless llama.cpp fallback is skipped: it exists so
|
||||||
|
/// the registry is never empty, not because anyone chose it, and with
|
||||||
|
/// nothing keyed the registry serves it while the UI's add-a-provider
|
||||||
|
/// state stays the honest answer. The model stays unset — the
|
||||||
|
/// registry fills each kind's default.
|
||||||
|
pub fn credential_seed(backends: &[Backend]) -> Option<(String, Option<String>)> {
|
||||||
|
backends
|
||||||
|
.iter()
|
||||||
|
.find(|backend| backend.kind != "llama-cpp")
|
||||||
|
.map(|backend| (backend.id.clone(), None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The concrete providers behind [`Backend`]: an enum because the live
|
||||||
|
/// model listings are inherent methods, and the registry stores the
|
||||||
|
/// provider behind the object-safe seam. Shared by `Arc`, never cloned.
|
||||||
|
pub enum AnyProvider {
|
||||||
|
/// The Gemini adapter.
|
||||||
|
Google(GoogleProvider),
|
||||||
|
/// The llama.cpp server adapter.
|
||||||
|
LlamaCpp(LlamaCppProvider),
|
||||||
|
/// The OpenRouter adapter.
|
||||||
|
OpenRouter(OpenRouterProvider),
|
||||||
|
/// The Anthropic Messages adapter.
|
||||||
|
Anthropic(AnthropicProvider),
|
||||||
|
/// The generic OpenAI-compatible adapter.
|
||||||
|
OpenAiCompatible(OpenAiCompatibleProvider),
|
||||||
|
/// DeepSeek through the OpenAI-compatible adapter at its own base.
|
||||||
|
DeepSeek(OpenAiCompatibleProvider),
|
||||||
|
/// Z.ai's GLM Coding Plan through the Anthropic-compatible route.
|
||||||
|
Zai(AnthropicProvider),
|
||||||
|
/// GitHub Copilot through the Copilot adapter.
|
||||||
|
Copilot(CopilotProvider),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnyProvider {
|
||||||
|
/// The kind's live model listing. Llama servers list their own
|
||||||
|
/// `LlamaModel` rows, which carry ids only: mapped into the shared
|
||||||
|
/// card shape with nothing but the id filled.
|
||||||
|
pub async fn list_models(&self) -> Result<Vec<ModelCard>, ProviderError> {
|
||||||
|
match self {
|
||||||
|
AnyProvider::Google(provider) => provider.list_models().await,
|
||||||
|
AnyProvider::Anthropic(provider) => provider.list_models().await,
|
||||||
|
AnyProvider::OpenRouter(provider) => provider.list_models().await,
|
||||||
|
AnyProvider::OpenAiCompatible(provider) => provider.list_models().await,
|
||||||
|
AnyProvider::DeepSeek(provider) => provider.list_models().await,
|
||||||
|
AnyProvider::Zai(provider) => provider.list_models().await,
|
||||||
|
AnyProvider::Copilot(provider) => provider.list_models().await,
|
||||||
|
AnyProvider::LlamaCpp(provider) => {
|
||||||
|
let models = provider.list_models().await?;
|
||||||
|
Ok(models
|
||||||
|
.into_iter()
|
||||||
|
.map(|model| ModelCard {
|
||||||
|
display_name: model.id.clone(),
|
||||||
|
id: model.id,
|
||||||
|
description: String::new(),
|
||||||
|
input_token_limit: model.context_length,
|
||||||
|
output_token_limit: None,
|
||||||
|
generation_methods: vec!["generateContent".to_owned()],
|
||||||
|
pricing: None,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The boxed listing a [`Backend`] carries.
|
||||||
|
pub fn boxed_listing(provider: Arc<Self>) -> Arc<dyn Fn() -> ModelListing + Send + Sync> {
|
||||||
|
Arc::new(move || {
|
||||||
|
let provider = provider.clone();
|
||||||
|
Box::pin(async move { provider.list_models().await })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ModelProvider for AnyProvider {
|
||||||
|
async fn complete(
|
||||||
|
&self,
|
||||||
|
request: ModelRequest,
|
||||||
|
sink: &mut dyn EventSink,
|
||||||
|
) -> Result<ModelOutput, ProviderError> {
|
||||||
|
match self {
|
||||||
|
AnyProvider::Google(provider) => provider.complete(request, sink).await,
|
||||||
|
AnyProvider::LlamaCpp(provider) => provider.complete(request, sink).await,
|
||||||
|
AnyProvider::OpenRouter(provider) => provider.complete(request, sink).await,
|
||||||
|
AnyProvider::Anthropic(provider) => provider.complete(request, sink).await,
|
||||||
|
AnyProvider::OpenAiCompatible(provider) => provider.complete(request, sink).await,
|
||||||
|
AnyProvider::DeepSeek(provider) => provider.complete(request, sink).await,
|
||||||
|
AnyProvider::Zai(provider) => provider.complete(request, sink).await,
|
||||||
|
AnyProvider::Copilot(provider) => provider.complete(request, sink).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The env variable each keyed backend reads, in precedence order
|
||||||
|
/// (checked before the stored rhema config's value, mirroring the
|
||||||
|
/// frontend's env-wins rule).
|
||||||
|
/// The env variable names a kind's credential reads, in precedence
|
||||||
|
/// order — the list route's `source` computation needs the names, the
|
||||||
|
/// discovery the values.
|
||||||
|
pub fn env_key_names(kind: &str) -> &'static [&'static str] {
|
||||||
|
env_vars_for(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_vars_for(kind: &str) -> &'static [&'static str] {
|
||||||
|
match kind {
|
||||||
|
"google" => &["GOOGLE_API_KEY", "GEMINI_API_KEY"],
|
||||||
|
"anthropic" => &["ANTHROPIC_API_KEY"],
|
||||||
|
"openai" => &["OPENAI_API_KEY"],
|
||||||
|
"openrouter" => &["OPENROUTER_API_KEY"],
|
||||||
|
"deepseek" => &["DEEPSEEK_API_KEY"],
|
||||||
|
"zai" => &["ZAI_API_KEY", "Z_AI_API_KEY"],
|
||||||
|
"copilot" => &["GITHUB_COPILOT_TOKEN"],
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DeepSeek speaks the OpenAI-compatible dialect at its own base.
|
||||||
|
const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1";
|
||||||
|
|
||||||
|
/// The settings-store key prefix under which a backend instance's
|
||||||
|
/// credentials persist: `provider.<id>` holds a [`ProviderParams`]
|
||||||
|
/// JSON. Legacy rows stored `provider.<kind>` — the id doubling as the
|
||||||
|
/// kind, which is what ids default to.
|
||||||
|
pub const PROVIDER_KEY_PREFIX: &str = "provider.";
|
||||||
|
|
||||||
|
/// The settings key for one backend instance's stored credentials.
|
||||||
|
pub fn provider_settings_key(id: &str) -> String {
|
||||||
|
format!("{PROVIDER_KEY_PREFIX}{id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `id` may name a backend instance: trimmed already by the
|
||||||
|
/// caller, non-empty, no whitespace, no `/`, at most 64 chars.
|
||||||
|
/// Comparisons are exact.
|
||||||
|
pub fn valid_instance_id(id: &str) -> bool {
|
||||||
|
!id.is_empty() && id.len() <= 64 && !id.chars().any(|c| c.is_whitespace() || c == '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The credentials a backend instance constructs from, as the settings
|
||||||
|
/// store persists them: the kind the params construct, an API key, and
|
||||||
|
/// for server-addressed kinds the URL. `api_key` never logs and never
|
||||||
|
/// round-trips through the list route — the list says only where a
|
||||||
|
/// credential came from.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub struct ProviderParams {
|
||||||
|
/// The kind these params construct.
|
||||||
|
pub kind: String,
|
||||||
|
/// The API key (or GitHub token for copilot).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
/// The server address (llama.cpp).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every kind a runtime login can construct, ascending: the addable
|
||||||
|
/// list the HTTP surface serves and the UI renders forms from.
|
||||||
|
pub fn addable_kinds() -> &'static [&'static str] {
|
||||||
|
&[
|
||||||
|
"anthropic",
|
||||||
|
"copilot",
|
||||||
|
"deepseek",
|
||||||
|
"google",
|
||||||
|
"llama-cpp",
|
||||||
|
"openai",
|
||||||
|
"openrouter",
|
||||||
|
"zai",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One field of a kind's login form: the name the credentials body
|
||||||
|
/// spells it with, and whether the daemon refuses the login without it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||||
|
pub struct ParamField {
|
||||||
|
/// The field name as the credentials route reads it.
|
||||||
|
pub name: &'static str,
|
||||||
|
/// Whether constructing without the field fails.
|
||||||
|
pub required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parameter fields a kind's login takes, in form order — the wire
|
||||||
|
/// shape under `addable` the UI builds its inputs from. `required`
|
||||||
|
/// states what the daemon demands, not what the form shows: llama.cpp
|
||||||
|
/// serves keyless on a default url, so neither of its fields gates the
|
||||||
|
/// login; the six keyed kinds cannot construct without an `api_key`.
|
||||||
|
pub fn param_fields_for(kind: &str) -> &'static [ParamField] {
|
||||||
|
match kind {
|
||||||
|
"llama-cpp" => &[
|
||||||
|
ParamField {
|
||||||
|
name: "url",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
ParamField {
|
||||||
|
name: "api_key",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
_ => &[ParamField {
|
||||||
|
name: "api_key",
|
||||||
|
required: true,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constructs one backend from `params`, its id defaulting to the
|
||||||
|
/// kind. The per-kind requirements: keyed kinds want an `api_key`;
|
||||||
|
/// llama.cpp wants a `url` (the keyless default still applies when
|
||||||
|
/// neither is given). Unknown kinds are an error — "mock" is
|
||||||
|
/// registry-injected, never constructed.
|
||||||
|
pub fn construct(kind: &str, params: &ProviderParams) -> Result<Backend, String> {
|
||||||
|
let provider = match kind {
|
||||||
|
"google" => AnyProvider::Google(GoogleProvider::new(require_key(kind, params)?)),
|
||||||
|
"anthropic" => AnyProvider::Anthropic(AnthropicProvider::new(require_key(kind, params)?)),
|
||||||
|
"openai" => {
|
||||||
|
AnyProvider::OpenAiCompatible(OpenAiCompatibleProvider::new(require_key(kind, params)?))
|
||||||
|
}
|
||||||
|
"openrouter" => {
|
||||||
|
AnyProvider::OpenRouter(OpenRouterProvider::new(require_key(kind, params)?))
|
||||||
|
}
|
||||||
|
"deepseek" => AnyProvider::DeepSeek(
|
||||||
|
OpenAiCompatibleProvider::new(require_key(kind, params)?)
|
||||||
|
.with_base_url(DEEPSEEK_BASE_URL),
|
||||||
|
),
|
||||||
|
// Bearer auth: the scheme the Coding Plan route documents.
|
||||||
|
"zai" => AnyProvider::Zai(
|
||||||
|
AnthropicProvider::new(require_key(kind, params)?)
|
||||||
|
.with_base_url(ZAI_BASE_URL)
|
||||||
|
.with_bearer_auth(),
|
||||||
|
),
|
||||||
|
"copilot" => AnyProvider::Copilot(CopilotProvider::new(require_key(kind, params)?)),
|
||||||
|
"llama-cpp" => {
|
||||||
|
let mut provider = LlamaCppProvider::new(
|
||||||
|
params
|
||||||
|
.url
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| rhema_config::Profile::default().llama_cpp_url()),
|
||||||
|
);
|
||||||
|
if let Some(key) = ¶ms.api_key {
|
||||||
|
provider = provider.with_api_key(key);
|
||||||
|
}
|
||||||
|
AnyProvider::LlamaCpp(provider)
|
||||||
|
}
|
||||||
|
_ => return Err(format!("unknown provider kind {kind:?}")),
|
||||||
|
};
|
||||||
|
let default_model = default_model_for(kind).to_owned();
|
||||||
|
let provider = Arc::new(provider);
|
||||||
|
Ok(Backend {
|
||||||
|
id: kind.to_owned(),
|
||||||
|
kind: kind.to_owned(),
|
||||||
|
// Turns ride the retry, so a transient failure costs a pause
|
||||||
|
// instead of the turn; the listing stays on the raw adapter —
|
||||||
|
// `list_models` is an inherent method the wrapper has no.
|
||||||
|
provider: Arc::new(rhema_provider::Retrying::new(provider.clone())),
|
||||||
|
default_model,
|
||||||
|
list_models: Some(AnyProvider::boxed_listing(provider)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kind's API key, or the error the credentials route answers with.
|
||||||
|
fn require_key(kind: &str, params: &ProviderParams) -> Result<String, String> {
|
||||||
|
params
|
||||||
|
.api_key
|
||||||
|
.clone()
|
||||||
|
.filter(|key| !key.trim().is_empty())
|
||||||
|
.ok_or_else(|| format!("{kind} needs an api_key"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discovers every constructible backend instance from the claw's own
|
||||||
|
/// stored credentials, the environment, and the stored rhema config.
|
||||||
|
/// Stored instances — the `provider.<id>` rows the HTTP surface wrote —
|
||||||
|
/// boot under the id they were saved with, the row's inner `kind`
|
||||||
|
/// naming what to construct (a legacy row without one keeps its id
|
||||||
|
/// doubling as the kind). For each kind, the env credential outranks
|
||||||
|
/// everything: it constructs the kind's own id, pushing a stored twin
|
||||||
|
/// of the same id aside while the row stays persisted for a
|
||||||
|
/// post-restart return. A stored instance of a kind outranks the
|
||||||
|
/// profile fallback, which only constructs when neither env nor a
|
||||||
|
/// stored row resolved. The keyless llama.cpp backend always
|
||||||
|
/// constructs while no stored row of its kind exists, so the registry
|
||||||
|
/// is never empty. The config's `provider`/`model` pair is the
|
||||||
|
/// caller's seed for the active backend — deployment defaults, not a
|
||||||
|
/// constraint. `stored_rows` reads the daemon's settings store, every
|
||||||
|
/// `provider.*` row as `(id, params)`; a row that no longer constructs
|
||||||
|
/// never blocks the boot.
|
||||||
|
pub fn discover(
|
||||||
|
config: &crate::config::ClawConfig,
|
||||||
|
stored: &rhema_config::Profile,
|
||||||
|
env: &dyn Fn(&str) -> Option<String>,
|
||||||
|
stored_rows: &dyn Fn() -> Vec<(String, ProviderParams)>,
|
||||||
|
secrets: Option<&dyn rhema_config::secrets::SecretStore>,
|
||||||
|
) -> Vec<Backend> {
|
||||||
|
let rows = stored_rows();
|
||||||
|
let mut backends = Vec::new();
|
||||||
|
|
||||||
|
// The env credential each kind resolves from, if any.
|
||||||
|
let env_key = |kind: &str| {
|
||||||
|
env_vars_for(kind)
|
||||||
|
.iter()
|
||||||
|
.find_map(|name| env(name))
|
||||||
|
.filter(|key| !key.trim().is_empty())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stored instances first, under the id their row names. An
|
||||||
|
// env-backed kind takes the id back from a stored twin of the same
|
||||||
|
// id below — env wins the per-kind slot, the row stays persisted.
|
||||||
|
for (id, params) in &rows {
|
||||||
|
if id == ¶ms.kind && env_key(¶ms.kind).is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(mut backend) = construct(¶ms.kind, params) {
|
||||||
|
backend.id = id.clone();
|
||||||
|
seed_llama_model(&mut backend, config);
|
||||||
|
backends.push(backend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for kind in [
|
||||||
|
"llama-cpp",
|
||||||
|
"google",
|
||||||
|
"anthropic",
|
||||||
|
"openai",
|
||||||
|
"openrouter",
|
||||||
|
"deepseek",
|
||||||
|
"zai",
|
||||||
|
"copilot",
|
||||||
|
] {
|
||||||
|
let stored_of_kind = rows.iter().any(|(_, params)| params.kind == kind);
|
||||||
|
let params = if kind == "llama-cpp" {
|
||||||
|
if stored_of_kind {
|
||||||
|
// The stored rows own the kind now; the keyless default
|
||||||
|
// would undo a removal of the kind's own id.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The keyless server backend always constructs, so the
|
||||||
|
// registry is never empty.
|
||||||
|
ProviderParams {
|
||||||
|
kind: kind.to_owned(),
|
||||||
|
api_key: None,
|
||||||
|
url: Some(stored.llama_cpp_url()),
|
||||||
|
}
|
||||||
|
} else if let Some(api_key) = env_key(kind) {
|
||||||
|
ProviderParams {
|
||||||
|
kind: kind.to_owned(),
|
||||||
|
api_key: Some(api_key),
|
||||||
|
url: None,
|
||||||
|
}
|
||||||
|
} else if stored_of_kind {
|
||||||
|
// The stored instance of the kind wins over the profile
|
||||||
|
// fallback — the surface's login is the newer decision.
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
// The stored rhema profile's key for the kind: the matching
|
||||||
|
// named instance's. The keyless llama.cpp backend never
|
||||||
|
// takes a key from the profile.
|
||||||
|
let api_key = match kind {
|
||||||
|
"llama-cpp" => None,
|
||||||
|
_ => stored.stored_provider_key(kind, secrets),
|
||||||
|
};
|
||||||
|
ProviderParams {
|
||||||
|
kind: kind.to_owned(),
|
||||||
|
api_key,
|
||||||
|
url: None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let has_creds = match kind {
|
||||||
|
// The keyless server backend always constructs.
|
||||||
|
"llama-cpp" => true,
|
||||||
|
_ => params.api_key.is_some(),
|
||||||
|
};
|
||||||
|
if !has_creds {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match construct(kind, ¶ms) {
|
||||||
|
Ok(mut backend) => {
|
||||||
|
seed_llama_model(&mut backend, config);
|
||||||
|
backends.push(backend);
|
||||||
|
}
|
||||||
|
// An unconstructible kind (a blank key) never blocks the boot.
|
||||||
|
Err(_) => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
backends
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The config file's model seeds llama's fallback, as discovery always
|
||||||
|
/// has: it names the deployment's local model when it names one at all.
|
||||||
|
fn seed_llama_model(backend: &mut Backend, config: &crate::config::ClawConfig) {
|
||||||
|
if backend.kind == "llama-cpp" {
|
||||||
|
if let Some(model) = &config.model {
|
||||||
|
backend.default_model = model.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rhema_config::secrets::SecretStore as _;
|
||||||
|
use rhema_core::{Message, Role, StopReason, Usage};
|
||||||
|
|
||||||
|
/// A minimal scripted stand-in: registry tests never complete a turn.
|
||||||
|
struct Stub;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ModelProvider for Stub {
|
||||||
|
async fn complete(
|
||||||
|
&self,
|
||||||
|
_req: ModelRequest,
|
||||||
|
_sink: &mut dyn EventSink,
|
||||||
|
) -> Result<ModelOutput, ProviderError> {
|
||||||
|
Ok(ModelOutput {
|
||||||
|
message: Message::text(Role::Assistant, "stub"),
|
||||||
|
stop_reason: StopReason::EndTurn,
|
||||||
|
usage: Usage::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backend(kind: &str) -> Backend {
|
||||||
|
// The real per-kind default, not headless's mock placeholder —
|
||||||
|
// the fallback assertions depend on it.
|
||||||
|
Backend {
|
||||||
|
id: kind.to_owned(),
|
||||||
|
kind: kind.to_owned(),
|
||||||
|
provider: Arc::new(Stub),
|
||||||
|
default_model: default_model_for(kind).to_owned(),
|
||||||
|
list_models: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_credential_seed_is_the_first_keyed_backend_in_discovery_order() {
|
||||||
|
// The keyless llama.cpp fallback never seeds: it exists so the
|
||||||
|
// registry is never empty, not because anyone chose it.
|
||||||
|
assert_eq!(
|
||||||
|
credential_seed(&[backend("llama-cpp")]),
|
||||||
|
None,
|
||||||
|
"nothing keyed: no seed, the keyless server serves"
|
||||||
|
);
|
||||||
|
// The first keyed backend wins, in discovery order.
|
||||||
|
assert_eq!(
|
||||||
|
credential_seed(&[
|
||||||
|
backend("llama-cpp"),
|
||||||
|
backend("google"),
|
||||||
|
backend("anthropic")
|
||||||
|
]),
|
||||||
|
Some(("google".to_owned(), None))
|
||||||
|
);
|
||||||
|
// A stored named row comes first in discovery order, whatever
|
||||||
|
// its kind sorts to in the registry.
|
||||||
|
let mut stored = backend("anthropic");
|
||||||
|
stored.id = "work-key".to_owned();
|
||||||
|
assert_eq!(
|
||||||
|
credential_seed(&[stored, backend("google")]),
|
||||||
|
Some(("work-key".to_owned(), None))
|
||||||
|
);
|
||||||
|
// The model stays unset: the registry fills the kind default.
|
||||||
|
let (id, model) = credential_seed(&[backend("openai")]).unwrap();
|
||||||
|
assert_eq!((id.as_str(), model), ("openai", None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_seed_wins_when_the_kind_exists() {
|
||||||
|
let registry = BackendRegistry::new(
|
||||||
|
vec![backend("anthropic"), backend("google")],
|
||||||
|
Some(("google".to_owned(), Some("gemini-3.8-flash".to_owned()))),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
registry.active(),
|
||||||
|
("google".to_owned(), "gemini-3.8-flash".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_seed_for_an_absent_kind_falls_back_to_the_first_backend() {
|
||||||
|
let registry = BackendRegistry::new(
|
||||||
|
vec![backend("anthropic"), backend("google")],
|
||||||
|
Some(("openai".to_owned(), None)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(registry.active().0, "anthropic", "ascending first");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_switch_falls_back_to_the_kind_default_without_a_model() {
|
||||||
|
let registry =
|
||||||
|
BackendRegistry::new(vec![backend("anthropic"), backend("google")], None).unwrap();
|
||||||
|
registry.set_active("google", None).unwrap();
|
||||||
|
assert_eq!(registry.active().1, "gemini-3.8-flash");
|
||||||
|
registry
|
||||||
|
.set_active("anthropic", Some("claude-opus-4-1"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(registry.active().1, "claude-opus-4-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_kind_is_rejected() {
|
||||||
|
let registry = BackendRegistry::new(vec![backend("google")], None).unwrap();
|
||||||
|
assert!(registry.set_active("openai", None).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_registry_is_a_construction_error() {
|
||||||
|
assert!(BackendRegistry::new(vec![], None).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn params(kind: &str, api_key: Option<&str>, url: Option<&str>) -> ProviderParams {
|
||||||
|
ProviderParams {
|
||||||
|
kind: kind.to_owned(),
|
||||||
|
api_key: api_key.map(str::to_owned),
|
||||||
|
url: url.map(str::to_owned),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instance_ids_exclude_whitespace_slashes_and_excess_length() {
|
||||||
|
assert!(valid_instance_id("local-llama"));
|
||||||
|
assert!(valid_instance_id(&"x".repeat(64)));
|
||||||
|
assert!(!valid_instance_id(""));
|
||||||
|
assert!(!valid_instance_id("a b"));
|
||||||
|
assert!(!valid_instance_id("a/b"));
|
||||||
|
assert!(!valid_instance_id(&"x".repeat(65)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_instances_of_a_kind_coexist_under_their_ids() {
|
||||||
|
let mut local_a = backend("llama-cpp");
|
||||||
|
local_a.id = "local-a".to_owned();
|
||||||
|
let mut local_b = backend("llama-cpp");
|
||||||
|
local_b.id = "local-b".to_owned();
|
||||||
|
let registry = BackendRegistry::new(vec![local_a, local_b], None).unwrap();
|
||||||
|
assert_eq!(registry.list().len(), 2);
|
||||||
|
registry.set_active("local-b", None).unwrap();
|
||||||
|
assert_eq!(registry.active().0, "local-b");
|
||||||
|
// Removing the active id hands the pair to the remaining one.
|
||||||
|
registry.remove("local-b").unwrap();
|
||||||
|
assert_eq!(registry.active().0, "local-a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn construct_demands_what_each_kind_needs() {
|
||||||
|
// Keyed kinds want a non-blank key.
|
||||||
|
assert!(construct("google", ¶ms("google", None, None)).is_err());
|
||||||
|
assert!(construct("google", ¶ms("google", Some(" "), None)).is_err());
|
||||||
|
assert!(construct("google", ¶ms("google", Some("k"), None)).is_ok());
|
||||||
|
assert!(construct("deepseek", ¶ms("deepseek", Some("k"), None)).is_ok());
|
||||||
|
assert!(construct("zai", ¶ms("zai", Some("k"), None)).is_ok());
|
||||||
|
assert!(construct("copilot", ¶ms("copilot", Some("t"), None)).is_ok());
|
||||||
|
assert!(construct("nope", ¶ms("nope", Some("k"), None)).is_err());
|
||||||
|
// llama.cpp serves keyless on its default url.
|
||||||
|
assert!(construct("llama-cpp", ¶ms("llama-cpp", None, None)).is_ok());
|
||||||
|
assert!(construct("llama-cpp", ¶ms("llama-cpp", None, Some("http://h:1"))).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discover_builds_exactly_the_kinds_with_a_source() {
|
||||||
|
let config = test_config();
|
||||||
|
let profile = profile_with_google();
|
||||||
|
let env = |_name: &str| None;
|
||||||
|
let stored_rows = || vec![("deepseek".to_owned(), params("deepseek", Some("cd"), None))];
|
||||||
|
let backends = discover(&config, &profile, &env, &stored_rows, None);
|
||||||
|
let mut kinds: Vec<&str> = backends.iter().map(|b| b.kind.as_str()).collect();
|
||||||
|
kinds.sort_unstable();
|
||||||
|
assert_eq!(kinds, vec!["deepseek", "google", "llama-cpp"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discover_boots_the_zai_kind_from_its_env_key() {
|
||||||
|
let config = test_config();
|
||||||
|
let profile = rhema_config::Profile::default();
|
||||||
|
let env = |name: &str| (name == "ZAI_API_KEY").then(|| "zk".to_owned());
|
||||||
|
let backends = discover(&config, &profile, &env, &|| Vec::new(), None);
|
||||||
|
let kinds: Vec<&str> = backends.iter().map(|b| b.kind.as_str()).collect();
|
||||||
|
assert!(
|
||||||
|
kinds.contains(&"zai"),
|
||||||
|
"the env key boots the zai backend: {kinds:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discover_boots_llama_even_when_nobody_stored_anything() {
|
||||||
|
let config = test_config();
|
||||||
|
let profile = rhema_config::Profile::default();
|
||||||
|
let env = |_name: &str| None;
|
||||||
|
let backends = discover(&config, &profile, &env, &|| Vec::new(), None);
|
||||||
|
assert_eq!(backends.len(), 1);
|
||||||
|
assert_eq!(backends[0].kind, "llama-cpp");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_keychain_ref_serves_the_profile_fallback_without_a_literal() {
|
||||||
|
let config = test_config();
|
||||||
|
// The stored instance logs in through the chain: no literal, a
|
||||||
|
// live ref. Discovery resolves it through the store tier.
|
||||||
|
let mut profile = rhema_config::Profile::default();
|
||||||
|
profile.upsert_instance(
|
||||||
|
"google",
|
||||||
|
rhema_config::ProviderInstance {
|
||||||
|
provider: "google".into(),
|
||||||
|
api_key: None,
|
||||||
|
secret_ref: Some("ref-1".into()),
|
||||||
|
base_url: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let store = rhema_config::secrets::VolatileSecretStore::new();
|
||||||
|
store.set("ref-1", "chain-key").unwrap();
|
||||||
|
let env = |_name: &str| None;
|
||||||
|
let backends = discover(&config, &profile, &env, &|| Vec::new(), Some(&store));
|
||||||
|
assert!(
|
||||||
|
backends.iter().any(|b| b.kind == "google"),
|
||||||
|
"the keychain key builds the backend"
|
||||||
|
);
|
||||||
|
// Without the store the ref is unreadable and the kind is not
|
||||||
|
// constructible — the honest logged-out shape, not a guess.
|
||||||
|
let backends = discover(&config, &profile, &env, &|| Vec::new(), None);
|
||||||
|
assert!(!backends.iter().any(|b| b.kind == "google"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_named_row_boots_under_its_id_and_keeps_the_default_slot_empty() {
|
||||||
|
let config = test_config();
|
||||||
|
let profile = profile_with_google();
|
||||||
|
let env = |_name: &str| None;
|
||||||
|
let stored_rows = || {
|
||||||
|
vec![(
|
||||||
|
"local-llama".to_owned(),
|
||||||
|
params("llama-cpp", None, Some("http://h:1")),
|
||||||
|
)]
|
||||||
|
};
|
||||||
|
let backends = discover(&config, &profile, &env, &stored_rows, None);
|
||||||
|
let mut ids: Vec<&str> = backends.iter().map(|b| b.id.as_str()).collect();
|
||||||
|
ids.sort_unstable();
|
||||||
|
assert_eq!(ids, vec!["google", "local-llama"]);
|
||||||
|
// The stored row owns the kind: the keyless default id does not
|
||||||
|
// resurrect beside it.
|
||||||
|
assert!(!ids.contains(&"llama-cpp"));
|
||||||
|
let llama = backends.iter().find(|b| b.id == "local-llama").unwrap();
|
||||||
|
assert_eq!(llama.kind, "llama-cpp");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_takes_the_kind_id_back_from_a_stored_twin() {
|
||||||
|
let config = test_config();
|
||||||
|
let profile = rhema_config::Profile::default();
|
||||||
|
let env = |name: &str| (name == "GOOGLE_API_KEY").then(|| "from-env".to_owned());
|
||||||
|
let stored_rows = || vec![("google".to_owned(), params("google", Some("stored"), None))];
|
||||||
|
let backends = discover(&config, &profile, &env, &stored_rows, None);
|
||||||
|
let google: Vec<&Backend> = backends.iter().filter(|b| b.kind == "google").collect();
|
||||||
|
assert_eq!(google.len(), 1, "env wins the id, no twin survives");
|
||||||
|
assert_eq!(google[0].id, "google");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_row_stored_under_its_kind_id_boots_as_its_kind() {
|
||||||
|
let config = test_config();
|
||||||
|
let profile = rhema_config::Profile::default();
|
||||||
|
let env = |_name: &str| None;
|
||||||
|
let stored_rows = || vec![("anthropic".to_owned(), params("anthropic", Some("k"), None))];
|
||||||
|
let backends = discover(&config, &profile, &env, &stored_rows, None);
|
||||||
|
let anthropic = backends
|
||||||
|
.iter()
|
||||||
|
.find(|b| b.id == "anthropic")
|
||||||
|
.expect("the stored row boots");
|
||||||
|
assert_eq!(anthropic.kind, "anthropic");
|
||||||
|
assert_eq!(anthropic.default_model, "claude-sonnet-4-5");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_replaces_and_remove_moves_the_active_pair() {
|
||||||
|
let registry = BackendRegistry::new(
|
||||||
|
vec![backend("anthropic"), backend("google")],
|
||||||
|
Some(("google".to_owned(), None)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// A replace: the kind count holds, the default may change.
|
||||||
|
registry.insert(backend("google"));
|
||||||
|
assert_eq!(registry.list().len(), 2);
|
||||||
|
|
||||||
|
// An addition joins the list.
|
||||||
|
registry.insert(backend("openai"));
|
||||||
|
assert_eq!(registry.list().len(), 3);
|
||||||
|
|
||||||
|
// Removing the active kind hands the pair to the first
|
||||||
|
// remaining backend, ascending.
|
||||||
|
registry.remove("google").unwrap();
|
||||||
|
assert_eq!(registry.active().0, "anthropic");
|
||||||
|
|
||||||
|
// The last backend refuses to go.
|
||||||
|
registry.remove("openai").unwrap();
|
||||||
|
assert!(registry.remove("anthropic").is_err());
|
||||||
|
assert_eq!(registry.list().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_config() -> crate::config::ClawConfig {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"poll_interval_secs": 60,
|
||||||
|
"watches": []
|
||||||
|
}))
|
||||||
|
.expect("a minimal claw config")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_with_google() -> rhema_config::Profile {
|
||||||
|
let mut profile = rhema_config::Profile::default();
|
||||||
|
profile.upsert_instance(
|
||||||
|
"google",
|
||||||
|
rhema_config::ProviderInstance {
|
||||||
|
provider: "google".into(),
|
||||||
|
api_key: Some("pg".into()),
|
||||||
|
secret_ref: None,
|
||||||
|
base_url: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
profile
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
//! The bootstrap layer: the resident agent's user-editable identity
|
||||||
|
//! files, the system prompt rendered from them per dispatch, and the
|
||||||
|
//! one tool the model writes them with.
|
||||||
|
//!
|
||||||
|
//! Context is earned (framework spec §9): one small always-injected
|
||||||
|
//! bootstrap doc, and nothing else force-injected — memory stays in
|
||||||
|
//! `rhema-memory`, pulled via tools. SOUL.md and USER.md are the earned
|
||||||
|
//! exceptions: tiny, stable, high-signal, and written only through
|
||||||
|
//! [`BootstrapSave`], so the model never free-writes config paths.
|
||||||
|
//! ENVIRONMENT.md lives in the same store but is generated at boot by
|
||||||
|
//! the environment module; the save path refuses it. The store's root
|
||||||
|
//! is handed over through [`Bootstrap::root`] for exactly that writer.
|
||||||
|
//!
|
||||||
|
//! The template ([`DEFAULT_SYSTEM_MD`]) is data next to the code, the
|
||||||
|
//! coding agent's house pattern, and it renders through the agent
|
||||||
|
//! toolkit's Jinja renderer — one template language for both agents:
|
||||||
|
//! `{{ workspace }}`, the three file sections from the live files, a
|
||||||
|
//! `{% for %}` over `tools` from the dispatch's own resolver chain, so
|
||||||
|
//! the roster the prompt shows is the roster the model gets. A present
|
||||||
|
//! file replaces its placeholder verbatim — never appended to, never
|
||||||
|
//! re-parsed; a missing one renders its stub. The `{% if onboarding %}`
|
||||||
|
//! section renders only while either file is unwritten, so the offer
|
||||||
|
//! self-terminates.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rhema_agent_tools::render_template;
|
||||||
|
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||||
|
use rhema_core::ToolSpec;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
/// The built-in system prompt, kept as data next to the code and read
|
||||||
|
/// in whole — placeholders filled per dispatch by [`render_system`].
|
||||||
|
pub const DEFAULT_SYSTEM_MD: &str = include_str!("../SYSTEM.md");
|
||||||
|
|
||||||
|
/// The cap on one bootstrap file: these documents enter every request,
|
||||||
|
/// so a bigger write is a misuse of the channel, refused loudly.
|
||||||
|
pub const MAX_CONTENT_BYTES: usize = 16 * 1024;
|
||||||
|
|
||||||
|
/// The save tool's name — allowlists reference it.
|
||||||
|
pub const BOOTSTRAP_SAVE: &str = "bootstrap_save";
|
||||||
|
|
||||||
|
/// One of the three bootstrap files. SOUL.md and USER.md are the
|
||||||
|
/// model-written pair; ENVIRONMENT.md is the boot-generated one.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum BootstrapKind {
|
||||||
|
/// SOUL.md — who the claw is: name, tone, purpose.
|
||||||
|
Soul,
|
||||||
|
/// USER.md — who the user is.
|
||||||
|
User,
|
||||||
|
/// ENVIRONMENT.md — the machine, written by the environment
|
||||||
|
/// module's boot pass; never through [`Bootstrap::save`].
|
||||||
|
Environment,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BootstrapKind {
|
||||||
|
/// The file name under the store's root — uppercase, the house
|
||||||
|
/// spelling.
|
||||||
|
pub fn file_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
BootstrapKind::Soul => "SOUL.md",
|
||||||
|
BootstrapKind::User => "USER.md",
|
||||||
|
BootstrapKind::Environment => "ENVIRONMENT.md",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the spelling the tool schema uses. `None` for anything
|
||||||
|
/// else.
|
||||||
|
pub fn parse(name: &str) -> Option<Self> {
|
||||||
|
match name.trim() {
|
||||||
|
"soul" => Some(BootstrapKind::Soul),
|
||||||
|
"user" => Some(BootstrapKind::User),
|
||||||
|
"environment" => Some(BootstrapKind::Environment),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a bootstrap operation failed.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum BootstrapError {
|
||||||
|
/// The store's directory could not be created, or a file could not
|
||||||
|
/// be read or written.
|
||||||
|
#[error(transparent)]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
/// ENVIRONMENT.md is boot-generated; the save path refuses it.
|
||||||
|
#[error("ENVIRONMENT.md is generated at boot; it is not saveable through bootstrap_save")]
|
||||||
|
EnvironmentNotSaveable,
|
||||||
|
/// The content is over [`MAX_CONTENT_BYTES`]: a bootstrap file
|
||||||
|
/// enters every request, so a bigger one is refused, not trimmed.
|
||||||
|
#[error("bootstrap content is {size} bytes; the cap is {MAX_CONTENT_BYTES} bytes")]
|
||||||
|
TooLarge {
|
||||||
|
/// The rejected content's size, in bytes.
|
||||||
|
size: usize,
|
||||||
|
},
|
||||||
|
/// The system-prompt template failed to render — bad syntax, unknown
|
||||||
|
/// filter — with the template position when known. The dispatch
|
||||||
|
/// refuses to run on a prompt it cannot fully render.
|
||||||
|
#[error("system prompt template: {0}")]
|
||||||
|
Template(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of the onboarding files exist — the badge the HTTP surface
|
||||||
|
/// serves and the onboarding section's off switch.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||||
|
pub struct BootstrapStatus {
|
||||||
|
/// SOUL.md exists.
|
||||||
|
pub soul: bool,
|
||||||
|
/// USER.md exists.
|
||||||
|
pub user: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The identity-file store: three markdown files under one root —
|
||||||
|
/// SOUL.md, USER.md, ENVIRONMENT.md. Its only writers are
|
||||||
|
/// [`BootstrapSave`] (the model's path, SOUL/USER only) and the
|
||||||
|
/// onboarding flow through it; the environment module writes
|
||||||
|
/// ENVIRONMENT.md directly at [`Bootstrap::root`].
|
||||||
|
pub struct Bootstrap {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bootstrap {
|
||||||
|
/// Opens the store at `root`, creating the directory: a fresh
|
||||||
|
/// install has no files, which is the pending-onboarding state, not
|
||||||
|
/// an error.
|
||||||
|
pub fn new(root: impl Into<PathBuf>) -> Result<Self, BootstrapError> {
|
||||||
|
let root = root.into();
|
||||||
|
std::fs::create_dir_all(&root)?;
|
||||||
|
Ok(Self { root })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The store's root — the documented seam the daemon hands to the
|
||||||
|
/// environment module, whose boot pass writes ENVIRONMENT.md here.
|
||||||
|
pub fn root(&self) -> &Path {
|
||||||
|
&self.root
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file's text, `None` while it is unwritten.
|
||||||
|
pub fn load(&self, kind: BootstrapKind) -> Result<Option<String>, BootstrapError> {
|
||||||
|
match std::fs::read_to_string(self.root.join(kind.file_name())) {
|
||||||
|
Ok(text) => Ok(Some(text)),
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(err) => Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overwrites the file with `content`, verbatim. ENVIRONMENT.md is
|
||||||
|
/// refused — it is generated at boot, never model-written.
|
||||||
|
pub fn save(&self, kind: BootstrapKind, content: &str) -> Result<(), BootstrapError> {
|
||||||
|
if kind == BootstrapKind::Environment {
|
||||||
|
return Err(BootstrapError::EnvironmentNotSaveable);
|
||||||
|
}
|
||||||
|
let size = content.len();
|
||||||
|
if size > MAX_CONTENT_BYTES {
|
||||||
|
return Err(BootstrapError::TooLarge { size });
|
||||||
|
}
|
||||||
|
std::fs::write(self.root.join(kind.file_name()), content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of the onboarding files exist, read live.
|
||||||
|
pub fn status(&self) -> Result<BootstrapStatus, BootstrapError> {
|
||||||
|
Ok(BootstrapStatus {
|
||||||
|
soul: self.load(BootstrapKind::Soul)?.is_some(),
|
||||||
|
user: self.load(BootstrapKind::User)?.is_some(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the onboarding flow still applies: either file is
|
||||||
|
/// unwritten. An unreadable store counts as pending — the worst
|
||||||
|
/// case is an offered setup, never a silently skipped one.
|
||||||
|
pub fn needs_onboarding(&self) -> bool {
|
||||||
|
!self.status().is_ok_and(|status| status.soul && status.user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stub a missing SOUL.md or USER.md renders.
|
||||||
|
const PENDING: &str = "(pending onboarding)";
|
||||||
|
|
||||||
|
/// The stub a missing ENVIRONMENT.md renders; the environment module's
|
||||||
|
/// boot pass writes the real file.
|
||||||
|
const GENERATED_AT_BOOT: &str = "(generated at boot)";
|
||||||
|
|
||||||
|
/// The offered onboarding, rendered while either file is unwritten. It
|
||||||
|
/// self-terminates: the per-dispatch status check stops rendering it
|
||||||
|
/// the moment both files exist. The offer is made in chat and may be
|
||||||
|
/// declined — never an unprompted interrogation. Compiled in like the
|
||||||
|
/// base template (`../ONBOARDING.md`), for the same reason: the prompt
|
||||||
|
/// is code-reviewed text, not runtime state.
|
||||||
|
const ONBOARDING_MD: &str = include_str!("../ONBOARDING.md");
|
||||||
|
|
||||||
|
/// One rendered markdown section: header, blank line, the file's text
|
||||||
|
/// — or the stub while it is unwritten.
|
||||||
|
fn section(header: &str, content: Option<&str>, stub: &str) -> String {
|
||||||
|
format!("{header}\n\n{}", content.unwrap_or(stub))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the system prompt for one dispatch through the agent
|
||||||
|
/// toolkit's Jinja renderer: `{{ workspace }}` and the three file
|
||||||
|
/// sections from the live store, `tools` from the dispatch's own
|
||||||
|
/// resolver specs, `{{ session }}` — the dispatching key, the address
|
||||||
|
/// the model schedules its own wakes to — and `onboarding`, the offer
|
||||||
|
/// the template's `{% if %}` shows only while a file is unwritten.
|
||||||
|
/// Fallible on purpose — a store that cannot be read surfaces loudly
|
||||||
|
/// instead of rendering a prompt without it, and a template that
|
||||||
|
/// cannot render fails the dispatch before any model call.
|
||||||
|
pub fn render_system(
|
||||||
|
template: &str,
|
||||||
|
session: &str,
|
||||||
|
workspace: &Path,
|
||||||
|
bootstrap: &Bootstrap,
|
||||||
|
tools: &[ToolSpec],
|
||||||
|
) -> Result<String, BootstrapError> {
|
||||||
|
let soul = bootstrap.load(BootstrapKind::Soul)?;
|
||||||
|
let user = bootstrap.load(BootstrapKind::User)?;
|
||||||
|
let environment = bootstrap.load(BootstrapKind::Environment)?;
|
||||||
|
let onboarding = if soul.is_none() || user.is_none() {
|
||||||
|
ONBOARDING_MD
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let context = json!({
|
||||||
|
"session": session,
|
||||||
|
"workspace": workspace.display().to_string(),
|
||||||
|
"soul": section("## SOUL", soul.as_deref(), PENDING),
|
||||||
|
"user": section("## USER", user.as_deref(), PENDING),
|
||||||
|
"environment": section("## ENVIRONMENT", environment.as_deref(), GENERATED_AT_BOOT),
|
||||||
|
"onboarding": onboarding,
|
||||||
|
"tools": tools,
|
||||||
|
});
|
||||||
|
render_template(template, &context).map_err(|err| {
|
||||||
|
let line = err
|
||||||
|
.line()
|
||||||
|
.map(|line| format!(", line {line}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
BootstrapError::Template(format!("{err}{line}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct SaveArgs {
|
||||||
|
kind: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes SOUL.md or USER.md through the [`Bootstrap`] store — the only
|
||||||
|
/// writer the model gets, deliberately allowlistable and deterministic.
|
||||||
|
pub struct BootstrapSave {
|
||||||
|
bootstrap: Arc<Bootstrap>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for BootstrapSave {
|
||||||
|
fn spec(&self) -> ToolSpec {
|
||||||
|
ToolSpec::new(
|
||||||
|
BOOTSTRAP_SAVE,
|
||||||
|
"Write one of your identity files. `kind` is `soul` (who you \
|
||||||
|
are: name, tone, purpose) or `user` (who the user is). \
|
||||||
|
Overwrites the file, so send the whole document every time. \
|
||||||
|
Both files enter every request: keep each one small, \
|
||||||
|
stable, and high-signal.",
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": { "type": "string", "enum": ["soul", "user"],
|
||||||
|
"description": "Which file to write: soul or user." },
|
||||||
|
"content": { "type": "string",
|
||||||
|
"description": "The file's full markdown contents." }
|
||||||
|
},
|
||||||
|
"required": ["kind", "content"]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||||
|
let args: SaveArgs =
|
||||||
|
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||||
|
let kind = BootstrapKind::parse(&args.kind)
|
||||||
|
.ok_or_else(|| format!("unknown kind {:?}: soul or user", args.kind))?;
|
||||||
|
self.bootstrap
|
||||||
|
.save(kind, &args.content)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
Ok(format!("saved {}", kind.file_name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bootstrap tool set over `store`: `bootstrap_save`, the shape the
|
||||||
|
/// interactive resolver chain composes.
|
||||||
|
pub fn registry(bootstrap: Arc<Bootstrap>) -> ToolRegistry {
|
||||||
|
let mut registry = ToolRegistry::new();
|
||||||
|
registry.register(Arc::new(BootstrapSave { bootstrap }));
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn store() -> (tempfile::TempDir, Bootstrap) {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let bootstrap = Bootstrap::new(dir.path().join("bootstrap")).expect("store opens");
|
||||||
|
(dir, bootstrap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ENVIRONMENT.md is written directly — the environment module's
|
||||||
|
/// boot pass is its only writer, never [`Bootstrap::save`].
|
||||||
|
fn write_environment(bootstrap: &Bootstrap, content: &str) {
|
||||||
|
std::fs::write(bootstrap.root().join("ENVIRONMENT.md"), content).expect("write");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_creates_the_store_directory() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let nested = dir.path().join("a").join("bootstrap");
|
||||||
|
let bootstrap = Bootstrap::new(&nested).expect("store opens");
|
||||||
|
assert!(nested.is_dir());
|
||||||
|
assert_eq!(bootstrap.root(), nested.as_path());
|
||||||
|
assert!(bootstrap.needs_onboarding(), "a fresh store is pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kinds_parse_and_name_their_files() {
|
||||||
|
assert_eq!(BootstrapKind::parse("soul"), Some(BootstrapKind::Soul));
|
||||||
|
assert_eq!(BootstrapKind::parse(" user "), Some(BootstrapKind::User));
|
||||||
|
assert_eq!(
|
||||||
|
BootstrapKind::parse("environment"),
|
||||||
|
Some(BootstrapKind::Environment)
|
||||||
|
);
|
||||||
|
assert_eq!(BootstrapKind::parse("soul.md"), None);
|
||||||
|
assert_eq!(BootstrapKind::parse(""), None);
|
||||||
|
for kind in [
|
||||||
|
BootstrapKind::Soul,
|
||||||
|
BootstrapKind::User,
|
||||||
|
BootstrapKind::Environment,
|
||||||
|
] {
|
||||||
|
let name = kind.file_name().to_lowercase();
|
||||||
|
assert_eq!(
|
||||||
|
BootstrapKind::parse(name.trim_end_matches(".md")),
|
||||||
|
Some(kind),
|
||||||
|
"{}",
|
||||||
|
kind.file_name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_then_load_round_trips_and_overwrites() {
|
||||||
|
let (_dir, bootstrap) = store();
|
||||||
|
assert_eq!(bootstrap.load(BootstrapKind::Soul).expect("reads"), None);
|
||||||
|
bootstrap
|
||||||
|
.save(BootstrapKind::Soul, "I am claw.\n")
|
||||||
|
.expect("saves");
|
||||||
|
assert_eq!(
|
||||||
|
bootstrap.load(BootstrapKind::Soul).expect("reads"),
|
||||||
|
Some("I am claw.\n".to_owned()),
|
||||||
|
"saved verbatim"
|
||||||
|
);
|
||||||
|
bootstrap
|
||||||
|
.save(BootstrapKind::Soul, "I am claw, v2.")
|
||||||
|
.expect("overwrites");
|
||||||
|
assert_eq!(
|
||||||
|
bootstrap.load(BootstrapKind::Soul).expect("reads"),
|
||||||
|
Some("I am claw, v2.".to_owned())
|
||||||
|
);
|
||||||
|
let status = bootstrap.status().expect("status reads");
|
||||||
|
assert!(status.soul);
|
||||||
|
assert!(!status.user);
|
||||||
|
assert!(bootstrap.needs_onboarding(), "user is still unwritten");
|
||||||
|
bootstrap
|
||||||
|
.save(BootstrapKind::User, "the user is Ana.")
|
||||||
|
.expect("saves");
|
||||||
|
assert!(!bootstrap.needs_onboarding(), "both files exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_size_cap_is_loud_not_trimmed() {
|
||||||
|
let (_dir, bootstrap) = store();
|
||||||
|
let too_big = "x".repeat(MAX_CONTENT_BYTES + 1);
|
||||||
|
let err = bootstrap
|
||||||
|
.save(BootstrapKind::Soul, &too_big)
|
||||||
|
.expect_err("over the cap is loud");
|
||||||
|
assert!(err.to_string().contains("bytes"), "{err}");
|
||||||
|
assert!(
|
||||||
|
bootstrap
|
||||||
|
.load(BootstrapKind::Soul)
|
||||||
|
.expect("reads")
|
||||||
|
.is_none(),
|
||||||
|
"nothing was written"
|
||||||
|
);
|
||||||
|
let at_cap = "x".repeat(MAX_CONTENT_BYTES);
|
||||||
|
bootstrap
|
||||||
|
.save(BootstrapKind::User, &at_cap)
|
||||||
|
.expect("exactly the cap saves");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_refuses_save_everywhere() {
|
||||||
|
let (_dir, bootstrap) = store();
|
||||||
|
let err = bootstrap
|
||||||
|
.save(BootstrapKind::Environment, "machine facts")
|
||||||
|
.expect_err("environment is not saveable");
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("generated at boot"),
|
||||||
|
"the refusal names the reason: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fresh_house_renders_stubs_and_the_onboarding_section() {
|
||||||
|
let (_dir, bootstrap) = store();
|
||||||
|
let tools = registry(Arc::new(Bootstrap {
|
||||||
|
root: bootstrap.root().to_path_buf(),
|
||||||
|
}))
|
||||||
|
.resolver_for(&rhema_autonomous_agent::Allowlist::permissive())
|
||||||
|
.specs();
|
||||||
|
let prompt = render_system(
|
||||||
|
DEFAULT_SYSTEM_MD,
|
||||||
|
"interactive:web",
|
||||||
|
Path::new("/ws"),
|
||||||
|
&bootstrap,
|
||||||
|
&tools,
|
||||||
|
)
|
||||||
|
.expect("renders");
|
||||||
|
assert!(prompt.contains("## SOUL"), "{prompt}");
|
||||||
|
assert!(prompt.contains("## USER"), "{prompt}");
|
||||||
|
assert!(prompt.contains("## ENVIRONMENT"), "{prompt}");
|
||||||
|
assert_eq!(prompt.matches(PENDING).count(), 2, "{prompt}");
|
||||||
|
assert!(prompt.contains(GENERATED_AT_BOOT), "{prompt}");
|
||||||
|
assert!(prompt.contains("## Setup"), "{prompt}");
|
||||||
|
assert!(prompt.contains("/ws"), "the workspace named: {prompt}");
|
||||||
|
assert!(prompt.contains("bootstrap_save"), "{prompt}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_full_house_renders_each_file_and_drops_the_onboarding_section() {
|
||||||
|
let (_dir, bootstrap) = store();
|
||||||
|
bootstrap
|
||||||
|
.save(BootstrapKind::Soul, "I am claw: terse, warm, relentless.")
|
||||||
|
.expect("saves");
|
||||||
|
bootstrap
|
||||||
|
.save(BootstrapKind::User, "The user is Ana; she ships compilers.")
|
||||||
|
.expect("saves");
|
||||||
|
write_environment(&bootstrap, "Linux, 32 cores, no GPU.");
|
||||||
|
let prompt = render_system(
|
||||||
|
DEFAULT_SYSTEM_MD,
|
||||||
|
"interactive:web",
|
||||||
|
Path::new("/ws"),
|
||||||
|
&bootstrap,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.expect("renders");
|
||||||
|
assert!(
|
||||||
|
prompt.contains("I am claw: terse, warm, relentless."),
|
||||||
|
"{prompt}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("The user is Ana; she ships compilers."),
|
||||||
|
"{prompt}"
|
||||||
|
);
|
||||||
|
assert!(prompt.contains("Linux, 32 cores, no GPU."), "{prompt}");
|
||||||
|
assert!(!prompt.contains(PENDING), "{prompt}");
|
||||||
|
assert!(!prompt.contains(GENERATED_AT_BOOT), "{prompt}");
|
||||||
|
assert!(!prompt.contains("## Setup"), "{prompt}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_user_file_replaces_its_placeholder_verbatim() {
|
||||||
|
let (_dir, bootstrap) = store();
|
||||||
|
let content = "the user is Ana.\n\n- braces stay: {{ keep }} and {soul}\n";
|
||||||
|
bootstrap.save(BootstrapKind::User, content).expect("saves");
|
||||||
|
let prompt = render_system(
|
||||||
|
DEFAULT_SYSTEM_MD,
|
||||||
|
"interactive:web",
|
||||||
|
Path::new("/ws"),
|
||||||
|
&bootstrap,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.expect("renders");
|
||||||
|
assert!(prompt.contains(content), "{prompt}");
|
||||||
|
assert!(
|
||||||
|
prompt.contains("{soul}"),
|
||||||
|
"a file's own placeholder-looking text is never substituted: {prompt}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!prompt.contains("## USER\n\n\n"),
|
||||||
|
"the file rides under its header, not appended: {prompt}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_tool_specs_render_with_their_schemas() {
|
||||||
|
let (_dir, bootstrap) = store();
|
||||||
|
let tools = vec![ToolSpec::new(
|
||||||
|
"my_tool",
|
||||||
|
"its purpose",
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": { "kind": { "type": "string" } },
|
||||||
|
"required": ["kind"]
|
||||||
|
}),
|
||||||
|
)];
|
||||||
|
let prompt = render_system(
|
||||||
|
DEFAULT_SYSTEM_MD,
|
||||||
|
"interactive:web",
|
||||||
|
Path::new("/ws"),
|
||||||
|
&bootstrap,
|
||||||
|
&tools,
|
||||||
|
)
|
||||||
|
.expect("renders");
|
||||||
|
assert!(prompt.contains("- my_tool | its purpose | "), "{prompt}");
|
||||||
|
assert!(
|
||||||
|
prompt.contains(r#""required": ["kind"]"#),
|
||||||
|
"the JSON schema rendered: {prompt}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_tool_writes_through_the_store_and_answers_one_line() {
|
||||||
|
use rhema_autonomous_agent::Allowlist;
|
||||||
|
use rhema_core::{ToolCall, ToolContext};
|
||||||
|
|
||||||
|
let (_dir, bootstrap) = store();
|
||||||
|
let resolver = registry(Arc::new(Bootstrap {
|
||||||
|
root: bootstrap.root().to_path_buf(),
|
||||||
|
}))
|
||||||
|
.resolver_for(&Allowlist::permissive());
|
||||||
|
let ctx = ToolContext::default();
|
||||||
|
let call = |id: &str, args: Value| {
|
||||||
|
resolver.resolve(
|
||||||
|
ToolCall {
|
||||||
|
id: id.into(),
|
||||||
|
name: BOOTSTRAP_SAVE.into(),
|
||||||
|
args,
|
||||||
|
provider_data: Value::Null,
|
||||||
|
},
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let saved = call(
|
||||||
|
"c1",
|
||||||
|
json!({"kind": "soul", "content": "I am claw: terse."}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(!saved.is_error, "{}", saved.content);
|
||||||
|
assert_eq!(saved.content, "saved SOUL.md");
|
||||||
|
assert_eq!(
|
||||||
|
bootstrap.load(BootstrapKind::Soul).expect("reads"),
|
||||||
|
Some("I am claw: terse.".to_owned())
|
||||||
|
);
|
||||||
|
|
||||||
|
// A kind the schema does not offer still cannot reach the file:
|
||||||
|
// the refusal comes back in band, with the reason.
|
||||||
|
let environment = call(
|
||||||
|
"c2",
|
||||||
|
json!({"kind": "environment", "content": "machine facts"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(environment.is_error);
|
||||||
|
assert!(
|
||||||
|
environment.content.contains("generated at boot"),
|
||||||
|
"{}",
|
||||||
|
environment.content
|
||||||
|
);
|
||||||
|
|
||||||
|
let unknown = call("c3", json!({"kind": "persona", "content": "x"})).await;
|
||||||
|
assert!(unknown.is_error);
|
||||||
|
assert!(
|
||||||
|
unknown.content.contains("unknown kind"),
|
||||||
|
"{}",
|
||||||
|
unknown.content
|
||||||
|
);
|
||||||
|
|
||||||
|
let malformed = call("c4", json!({"kind": "soul"})).await;
|
||||||
|
assert!(malformed.is_error);
|
||||||
|
assert!(
|
||||||
|
malformed.content.contains("bad arguments"),
|
||||||
|
"{}",
|
||||||
|
malformed.content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
//! The calendar source: deterministic detection of due reminders, the
|
||||||
|
//! task scheduler's shape over a calendar feed.
|
||||||
|
//!
|
||||||
|
//! The [`CalendarSource`] is a [`Source`] named `"calendars"` on the
|
||||||
|
//! resident dispatcher: its `detect` pass polls every configured
|
||||||
|
//! calendar for the occurrences starting within its horizon and
|
||||||
|
//! ingests one event per due reminder slot, a (occurrence, lead time)
|
||||||
|
//! pair whose moment has come. The dedup id carries all three facts
|
||||||
|
//! (`{calendar}:{occurrence id}:{lead}`), so a slot feeds once and its
|
||||||
|
//! repeats dedup away in the dispatcher, and the next lead's slot is a
|
||||||
|
//! different id firing later. A slot whose start has passed is never
|
||||||
|
//! fed: a reminder never fires after the fact. All-day occurrences
|
||||||
|
//! never fire: a lead in minutes against a midnight start is noise,
|
||||||
|
//! and a date-shaped nudge is what the task tools are for.
|
||||||
|
//!
|
||||||
|
//! The model is never reached here; the turn happens because the
|
||||||
|
//! dispatcher admitted the `calendar:<name>` key, exactly as for a
|
||||||
|
//! task firing, and the resident handler's delivery rule auto-notifies
|
||||||
|
//! the final text to the user's devices. One session per calendar
|
||||||
|
//! persists across its reminders, so the claw remembers what it said
|
||||||
|
//! about the last one. A dead feed is logged and skipped, never fatal:
|
||||||
|
//! an outage must not eat the other calendars' reminders on the same
|
||||||
|
//! pass. Cadence belongs to the daemon's loop.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rhema_autonomous_agent::{Source, SourceError, async_trait};
|
||||||
|
use rhema_calendar_integration::{
|
||||||
|
CaldavCalendar, Calendar, CalendarEvent, GoogleCalendar, IcsCalendar, Window,
|
||||||
|
};
|
||||||
|
use rhema_rsd::{Clock, Dispatcher, Event, KeyFn};
|
||||||
|
use serde_json::json;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use time::format_description::well_known::Rfc3339;
|
||||||
|
|
||||||
|
use crate::config::{CalendarKind, CalendarWatch};
|
||||||
|
use crate::interactive::passthrough_key_fn;
|
||||||
|
|
||||||
|
/// The source name the calendar reminders ingest under; registered on
|
||||||
|
/// the resident dispatcher, so allowlists and `/v1/ingest` can name it.
|
||||||
|
pub const CALENDARS_SOURCE: &str = "calendars";
|
||||||
|
|
||||||
|
/// The env variable a calendar's token falls back to.
|
||||||
|
pub const TOKEN_ENV: &str = "CALENDAR_TOKEN";
|
||||||
|
|
||||||
|
/// The env variable a CalDAV password falls back to.
|
||||||
|
pub const CALDAV_PASSWORD_ENV: &str = "CALDAV_PASSWORD";
|
||||||
|
|
||||||
|
/// One calendar's reminder key: `calendar:<name>` — the session the
|
||||||
|
/// reminder turns run in, persistent across the calendar's reminders.
|
||||||
|
pub fn calendar_key(name: &str) -> String {
|
||||||
|
format!("calendar:{name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One configured calendar, resolved to a client the source polls.
|
||||||
|
pub(crate) struct Feed {
|
||||||
|
name: String,
|
||||||
|
client: Arc<dyn Calendar>,
|
||||||
|
leads: Vec<u64>,
|
||||||
|
horizon_hours: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The client one calendar watch polls through. Credentials resolve
|
||||||
|
/// before construction: the api key wins for Google, else the token
|
||||||
|
/// (config first, then [`TOKEN_ENV`]), and a feed takes its token or
|
||||||
|
/// polls anonymous, which is how most published feed URLs work.
|
||||||
|
fn client_for(calendar: &CalendarWatch) -> Result<Arc<dyn Calendar>, String> {
|
||||||
|
match calendar.kind {
|
||||||
|
CalendarKind::Google => {
|
||||||
|
let id = calendar
|
||||||
|
.calendar_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|id| !id.is_empty())
|
||||||
|
.ok_or_else(|| "google needs calendar_id".to_owned())?;
|
||||||
|
if let Some(key) = calendar.resolved_api_key() {
|
||||||
|
return Ok(Arc::new(GoogleCalendar::with_api_key(key, id)));
|
||||||
|
}
|
||||||
|
let token = calendar
|
||||||
|
.resolved_token()
|
||||||
|
.ok_or_else(|| "google needs api_key or token".to_owned())?;
|
||||||
|
Ok(Arc::new(GoogleCalendar::new(token, id)))
|
||||||
|
}
|
||||||
|
CalendarKind::Ics => {
|
||||||
|
let url = calendar
|
||||||
|
.url
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|url| !url.is_empty())
|
||||||
|
.ok_or_else(|| "ics needs url".to_owned())?;
|
||||||
|
let mut feed = IcsCalendar::new(url);
|
||||||
|
if let Some(token) = calendar.resolved_token() {
|
||||||
|
feed = feed.with_token(token);
|
||||||
|
}
|
||||||
|
Ok(Arc::new(feed))
|
||||||
|
}
|
||||||
|
CalendarKind::Caldav => {
|
||||||
|
let url = calendar
|
||||||
|
.url
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|url| !url.is_empty())
|
||||||
|
.ok_or_else(|| "caldav needs url".to_owned())?;
|
||||||
|
let mut dav = CaldavCalendar::new(url);
|
||||||
|
// A username means Basic auth, the self-hosted norm, and
|
||||||
|
// the password must have resolved at validation; a token
|
||||||
|
// without one is the bearer spelling; neither polls
|
||||||
|
// anonymous, which is what open servers take.
|
||||||
|
if let Some(user) = calendar
|
||||||
|
.username
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|user| !user.is_empty())
|
||||||
|
{
|
||||||
|
let password = calendar.resolved_password().ok_or_else(|| {
|
||||||
|
"caldav needs a password: set password or CALDAV_PASSWORD".to_owned()
|
||||||
|
})?;
|
||||||
|
dav = dav.with_basic(user, password);
|
||||||
|
} else if let Some(token) = calendar.resolved_token() {
|
||||||
|
dav = dav.with_token(token);
|
||||||
|
}
|
||||||
|
Ok(Arc::new(dav))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reminder slots of one poll's events: (event, lead) pairs that
|
||||||
|
/// are due, in feed order, leads in configured order. Due means the
|
||||||
|
/// lead's warning moment has arrived and the event has not started;
|
||||||
|
/// all-day events and started events contribute nothing.
|
||||||
|
pub(crate) fn due_slots<'a>(
|
||||||
|
events: &'a [CalendarEvent],
|
||||||
|
leads: &[u64],
|
||||||
|
now: OffsetDateTime,
|
||||||
|
) -> Vec<(&'a CalendarEvent, u64)> {
|
||||||
|
let now_secs = now.unix_timestamp();
|
||||||
|
let mut slots = Vec::new();
|
||||||
|
for event in events {
|
||||||
|
if event.all_day {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let start = event.start.unix_timestamp();
|
||||||
|
if start <= now_secs {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for lead in leads {
|
||||||
|
if start - i64::try_from(*lead).unwrap_or(i64::MAX) * 60 <= now_secs {
|
||||||
|
slots.push((event, *lead));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slots
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The event one due slot becomes: keyed to the calendar's session,
|
||||||
|
/// its dedup id naming the calendar, the occurrence, and the lead, so
|
||||||
|
/// a refired slot dedups and the next lead does not.
|
||||||
|
pub(crate) fn reminder_event(
|
||||||
|
calendar: &str,
|
||||||
|
event: &CalendarEvent,
|
||||||
|
lead: u64,
|
||||||
|
now: OffsetDateTime,
|
||||||
|
received_at: u64,
|
||||||
|
) -> Event {
|
||||||
|
// The start is strictly future at this point, so the count is at
|
||||||
|
// least one.
|
||||||
|
let minutes = ((event.start - now).whole_seconds() + 59) / 60;
|
||||||
|
let title = match event.title.trim() {
|
||||||
|
"" => "(untitled)",
|
||||||
|
trimmed => trimmed,
|
||||||
|
};
|
||||||
|
let start = event.start.format(&Rfc3339).unwrap_or_default();
|
||||||
|
let mut lines = vec![
|
||||||
|
format!(
|
||||||
|
"Calendar reminder from the daemon (source `{CALENDARS_SOURCE}`), not a user message."
|
||||||
|
),
|
||||||
|
format!("Calendar {calendar:?}: {title:?} starts in {minutes} minutes at {start}."),
|
||||||
|
];
|
||||||
|
if let Some(location) = nonempty(event.location.as_deref()) {
|
||||||
|
lines.push(format!("Where: {location}"));
|
||||||
|
}
|
||||||
|
if let Some(notes) = nonempty(event.description.as_deref()) {
|
||||||
|
lines.push(format!("Notes: {notes}"));
|
||||||
|
}
|
||||||
|
if let Some(url) = nonempty(event.url.as_deref()) {
|
||||||
|
lines.push(format!("Link: {url}"));
|
||||||
|
}
|
||||||
|
if !event.attendees.is_empty() {
|
||||||
|
lines.push(format!("Attendees: {}", event.attendees.join(", ")));
|
||||||
|
}
|
||||||
|
if let Some(organizer) = nonempty(event.organizer.as_deref()) {
|
||||||
|
lines.push(format!("Organizer: {organizer}"));
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
"Reply with one short line telling the user; this session's reply \
|
||||||
|
auto-notifies their devices."
|
||||||
|
.to_owned(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Event {
|
||||||
|
id: format!("{calendar}:{}:{lead}", event.id),
|
||||||
|
source: CALENDARS_SOURCE.to_owned(),
|
||||||
|
payload: json!({ "key": calendar_key(calendar), "prompt": lines.join("\n") }),
|
||||||
|
received_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A trimmed string, or `None` when it is all whitespace.
|
||||||
|
fn nonempty(value: Option<&str>) -> Option<&str> {
|
||||||
|
value.map(str::trim).filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The detectable stream of calendar reminders: every configured
|
||||||
|
/// calendar, polled on the daemon's tick, feeding the resident
|
||||||
|
/// dispatcher.
|
||||||
|
pub struct CalendarSource {
|
||||||
|
dispatcher: Arc<Dispatcher>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
feeds: Vec<Feed>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalendarSource {
|
||||||
|
/// Registers the `calendars` source's key function on `dispatcher`
|
||||||
|
/// and resolves `calendars` into clients. Config validation ran at
|
||||||
|
/// load; a calendar that still fails here names itself in the
|
||||||
|
/// error, and the daemon refuses to start.
|
||||||
|
pub fn new(
|
||||||
|
dispatcher: &Arc<Dispatcher>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
calendars: &[CalendarWatch],
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
let mut feeds = Vec::with_capacity(calendars.len());
|
||||||
|
for calendar in calendars {
|
||||||
|
feeds.push(Feed {
|
||||||
|
name: calendar.name.trim().to_owned(),
|
||||||
|
client: client_for(calendar)?,
|
||||||
|
leads: calendar.leads(),
|
||||||
|
horizon_hours: calendar.horizon(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dispatcher.register_source(CALENDARS_SOURCE, passthrough_key_fn());
|
||||||
|
Ok(Self {
|
||||||
|
dispatcher: dispatcher.clone(),
|
||||||
|
clock,
|
||||||
|
feeds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A source over ready-made feeds. Tests use this; production
|
||||||
|
/// builds from the config with [`CalendarSource::new`].
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn from_feeds(
|
||||||
|
dispatcher: &Arc<Dispatcher>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
feeds: Vec<Feed>,
|
||||||
|
) -> Self {
|
||||||
|
dispatcher.register_source(CALENDARS_SOURCE, passthrough_key_fn());
|
||||||
|
Self {
|
||||||
|
dispatcher: dispatcher.clone(),
|
||||||
|
clock,
|
||||||
|
feeds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Source for CalendarSource {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
CALENDARS_SOURCE
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_fn(&self) -> Arc<KeyFn> {
|
||||||
|
passthrough_key_fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn detect(&self) -> Result<usize, SourceError> {
|
||||||
|
let now_ms = self.clock.now_millis() as i64;
|
||||||
|
let now = OffsetDateTime::from_unix_timestamp_nanos(now_ms as i128 * 1_000_000)
|
||||||
|
.map_err(|err| SourceError::Backend(format!("clock: {err}")))?;
|
||||||
|
let mut fed = 0;
|
||||||
|
for feed in &self.feeds {
|
||||||
|
let window = Window::new(
|
||||||
|
now,
|
||||||
|
now + time::Duration::hours(i64::from(feed.horizon_hours)),
|
||||||
|
);
|
||||||
|
let events = match feed.client.events(&window).await {
|
||||||
|
Ok(events) => events,
|
||||||
|
// A dead feed is this tick's loss, not the daemon's:
|
||||||
|
// the next pass polls it again, and the other
|
||||||
|
// calendars' reminders never wait behind it.
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("[claw] warning: calendar {} failed: {err}", feed.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (event, lead) in due_slots(&events, &feed.leads, now) {
|
||||||
|
self.dispatcher.ingest(reminder_event(
|
||||||
|
&feed.name,
|
||||||
|
event,
|
||||||
|
lead,
|
||||||
|
now,
|
||||||
|
now_ms as u64,
|
||||||
|
))?;
|
||||||
|
fed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(fed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rhema_rsd::{DispatcherConfig, FixedClock, ResourceKey, SystemClock, VolatileStore};
|
||||||
|
|
||||||
|
/// 2026-10-20T00:00:00Z, in milliseconds.
|
||||||
|
const NOW_MS: i64 = 1_792_444_800_000;
|
||||||
|
|
||||||
|
fn dispatcher() -> Arc<Dispatcher> {
|
||||||
|
Arc::new(Dispatcher::new(
|
||||||
|
DispatcherConfig {
|
||||||
|
debounce_window_millis: 0,
|
||||||
|
..DispatcherConfig::default()
|
||||||
|
},
|
||||||
|
Arc::new(SystemClock),
|
||||||
|
Arc::new(VolatileStore::new()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A feed that answers with fixed events, whatever the window.
|
||||||
|
struct Fixed(Vec<CalendarEvent>);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Calendar for Fixed {
|
||||||
|
async fn events(
|
||||||
|
&self,
|
||||||
|
_window: &Window,
|
||||||
|
) -> Result<Vec<CalendarEvent>, rhema_calendar_integration::CalendarError> {
|
||||||
|
Ok(self.0.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event(id: &str, title: &str, minutes_from_now: i64) -> CalendarEvent {
|
||||||
|
let start = OffsetDateTime::from_unix_timestamp(NOW_MS / 1000 + minutes_from_now * 60)
|
||||||
|
.expect("timestamp");
|
||||||
|
CalendarEvent {
|
||||||
|
id: format!("ics:{id}@{}", start.unix_timestamp()),
|
||||||
|
title: title.to_owned(),
|
||||||
|
start,
|
||||||
|
end: Some(start + time::Duration::minutes(30)),
|
||||||
|
all_day: false,
|
||||||
|
location: None,
|
||||||
|
description: None,
|
||||||
|
attendees: Vec::new(),
|
||||||
|
organizer: None,
|
||||||
|
url: None,
|
||||||
|
raw: serde_json::json!({}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source(
|
||||||
|
dispatcher: &Arc<Dispatcher>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Vec<CalendarEvent>,
|
||||||
|
) -> CalendarSource {
|
||||||
|
CalendarSource::from_feeds(
|
||||||
|
dispatcher,
|
||||||
|
clock,
|
||||||
|
vec![Feed {
|
||||||
|
name: "home".to_owned(),
|
||||||
|
client: Arc::new(Fixed(events)),
|
||||||
|
leads: vec![60, 15],
|
||||||
|
horizon_hours: 24,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inbox_depth(dispatcher: &Dispatcher) -> usize {
|
||||||
|
dispatcher
|
||||||
|
.status(&ResourceKey::from(calendar_key("home")))
|
||||||
|
.expect("status reads")
|
||||||
|
.inbox_depth
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_due_slot_fires_once_and_stays_deduped() {
|
||||||
|
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
|
||||||
|
let dispatcher = dispatcher();
|
||||||
|
let source = source(
|
||||||
|
&dispatcher,
|
||||||
|
clock.clone(),
|
||||||
|
vec![event("standup", "standup", 90)],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ninety minutes out, neither lead is due yet.
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 0);
|
||||||
|
|
||||||
|
// Sixty minutes out the first slot is due: one event on the
|
||||||
|
// calendar's key.
|
||||||
|
clock.advance(30 * 60 * 1000);
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||||
|
assert_eq!(inbox_depth(&dispatcher), 1);
|
||||||
|
|
||||||
|
// The next pass replays the same dedup id: fed, deduped away.
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||||
|
assert_eq!(inbox_depth(&dispatcher), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn every_lead_fires_once_at_its_own_moment() {
|
||||||
|
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
|
||||||
|
let dispatcher = dispatcher();
|
||||||
|
// Starts in 90 minutes: the 60 lead is not due yet.
|
||||||
|
let source = source(&dispatcher, clock.clone(), vec![event("demo", "demo", 90)]);
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 0);
|
||||||
|
|
||||||
|
// At 60 minutes out the first slot fires.
|
||||||
|
clock.advance(30 * 60 * 1000);
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||||
|
|
||||||
|
// At 15 minutes out the second slot fires under its own id.
|
||||||
|
// The count includes the first slot's replay, which dedups:
|
||||||
|
// the inbox holds one event per lead, never more.
|
||||||
|
clock.advance(45 * 60 * 1000);
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 2);
|
||||||
|
assert_eq!(inbox_depth(&dispatcher), 2);
|
||||||
|
|
||||||
|
// Past the start: nothing more, ever.
|
||||||
|
clock.advance(20 * 60 * 1000);
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 0);
|
||||||
|
assert_eq!(inbox_depth(&dispatcher), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_slot_first_seen_late_fires_late_once() {
|
||||||
|
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
|
||||||
|
let dispatcher = dispatcher();
|
||||||
|
// The daemon only meets this event 15 minutes before it
|
||||||
|
// starts: the 60 slot's moment came and went unseen, the 15
|
||||||
|
// slot is due. Both are fed on that first pass, then dedup.
|
||||||
|
let source = source(&dispatcher, clock.clone(), vec![event("late", "late", 15)]);
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 2);
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 2);
|
||||||
|
assert_eq!(inbox_depth(&dispatcher), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_started_or_all_day_event_never_fires() {
|
||||||
|
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
|
||||||
|
let dispatcher = dispatcher();
|
||||||
|
let mut all_day = event("offsite", "offsite", 10);
|
||||||
|
all_day.all_day = true;
|
||||||
|
let started = event("past", "already going", -5);
|
||||||
|
let source = source(&dispatcher, clock.clone(), vec![all_day, started]);
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 0);
|
||||||
|
assert_eq!(inbox_depth(&dispatcher), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_dead_feed_is_skipped_not_fatal() {
|
||||||
|
struct Dead;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Calendar for Dead {
|
||||||
|
async fn events(
|
||||||
|
&self,
|
||||||
|
_window: &Window,
|
||||||
|
) -> Result<Vec<CalendarEvent>, rhema_calendar_integration::CalendarError> {
|
||||||
|
Err(rhema_calendar_integration::CalendarError::Network(
|
||||||
|
"down".to_owned(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
|
||||||
|
let dispatcher = dispatcher();
|
||||||
|
let source = CalendarSource::from_feeds(
|
||||||
|
&dispatcher,
|
||||||
|
clock.clone(),
|
||||||
|
vec![
|
||||||
|
Feed {
|
||||||
|
name: "dead".to_owned(),
|
||||||
|
client: Arc::new(Dead),
|
||||||
|
leads: vec![15],
|
||||||
|
horizon_hours: 24,
|
||||||
|
},
|
||||||
|
Feed {
|
||||||
|
name: "alive".to_owned(),
|
||||||
|
client: Arc::new(Fixed(vec![event("alive-event", "alive", 10)])),
|
||||||
|
leads: vec![15],
|
||||||
|
horizon_hours: 24,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||||
|
assert_eq!(
|
||||||
|
dispatcher
|
||||||
|
.status(&ResourceKey::from(calendar_key("alive")))
|
||||||
|
.expect("status reads")
|
||||||
|
.inbox_depth,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_prompt_names_everything_a_reply_needs() {
|
||||||
|
let now = OffsetDateTime::from_unix_timestamp(NOW_MS / 1000).expect("timestamp");
|
||||||
|
let mut event = event("dentist", "dentist", 15);
|
||||||
|
event.location = Some("rue de la Paix".to_owned());
|
||||||
|
event.description = Some("cleaning".to_owned());
|
||||||
|
event.attendees = vec!["dr@example.net".to_owned()];
|
||||||
|
event.organizer = Some("dr@example.net".to_owned());
|
||||||
|
event.url = Some("https://meet.example.net/x".to_owned());
|
||||||
|
|
||||||
|
let slot = reminder_event("home", &event, 15, now, NOW_MS as u64);
|
||||||
|
assert_eq!(slot.source, CALENDARS_SOURCE);
|
||||||
|
assert_eq!(
|
||||||
|
slot.id,
|
||||||
|
format!("home:{}:15", event.id),
|
||||||
|
"the dedup id names calendar, occurrence, and lead"
|
||||||
|
);
|
||||||
|
assert_eq!(slot.payload["key"], json!(calendar_key("home")));
|
||||||
|
let prompt = slot.payload["prompt"].as_str().expect("prompt");
|
||||||
|
assert!(prompt.contains("\"dentist\""), "got: {prompt}");
|
||||||
|
assert!(prompt.contains("in 15 minutes"), "got: {prompt}");
|
||||||
|
assert!(prompt.contains("home"), "got: {prompt}");
|
||||||
|
assert!(prompt.contains("rue de la Paix"), "got: {prompt}");
|
||||||
|
assert!(prompt.contains("cleaning"), "got: {prompt}");
|
||||||
|
assert!(prompt.contains("dr@example.net"), "got: {prompt}");
|
||||||
|
assert!(
|
||||||
|
prompt.contains("https://meet.example.net/x"),
|
||||||
|
"got: {prompt}"
|
||||||
|
);
|
||||||
|
assert!(prompt.contains("auto-notifies"), "got: {prompt}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_untitled_event_still_reads_as_a_reminder() {
|
||||||
|
let now = OffsetDateTime::from_unix_timestamp(NOW_MS / 1000).expect("timestamp");
|
||||||
|
let event = event("bare", " ", 3);
|
||||||
|
let slot = reminder_event("home", &event, 15, now, NOW_MS as u64);
|
||||||
|
let prompt = slot.payload["prompt"].as_str().expect("prompt");
|
||||||
|
assert!(prompt.contains("(untitled)"), "got: {prompt}");
|
||||||
|
assert!(prompt.contains("in 3 minutes"), "got: {prompt}");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,751 @@
|
|||||||
|
//! The consent round-trip: how a running turn asks the human for
|
||||||
|
//! something only a human can give — a credential to store, permission
|
||||||
|
//! to act — and waits, inside the tool call, for the answer.
|
||||||
|
//!
|
||||||
|
//! The shape is one table plus one bridge. [`ConsentStore`] keeps the
|
||||||
|
//! request rows: what was asked, of which session, and how it
|
||||||
|
//! resolved — never the secret itself, which travels once, from the
|
||||||
|
//! answering client through the HTTP route into the identity store,
|
||||||
|
//! and is gone. [`WaitBridge`] is how the HTTP route wakes the tool
|
||||||
|
//! that awaits its row's resolution; the tool re-reads the row around
|
||||||
|
//! every wake, so a resolution that raced the waiter's registration is
|
||||||
|
//! caught by the store, not lost.
|
||||||
|
//!
|
||||||
|
//! Unanswered requests expire by age: [`CONSENT_WAIT`] bounds both the
|
||||||
|
//! tool's patience and the `pending` list a UI polls, so a daemon that
|
||||||
|
//! restarted under a waiting tool (the waiter is gone) stops offering
|
||||||
|
//! a stale card. The tool answers a denial or an expiry in-band — a
|
||||||
|
//! turn that asked and was refused is a turn that can say so.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex, PoisonError};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||||
|
use rhema_core::ToolSpec;
|
||||||
|
use rusqlite::params;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
|
use crate::hub::{EventHub, TurnEvent};
|
||||||
|
use rhema_rsd::ResourceKey;
|
||||||
|
|
||||||
|
/// How long a consent request stays answerable — the tool's patience
|
||||||
|
/// and the pending list's horizon, one number so neither lies about
|
||||||
|
/// the other.
|
||||||
|
pub const CONSENT_WAIT: Duration = Duration::from_secs(15 * 60);
|
||||||
|
|
||||||
|
fn now_millis() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What kind of answer a request waits for.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ConsentKind {
|
||||||
|
/// A credential: the human pastes a secret, it lands in the
|
||||||
|
/// identity store, the tool learns only the account id.
|
||||||
|
Secret,
|
||||||
|
/// Permission: the human approves or denies an action.
|
||||||
|
Phone,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConsentKind {
|
||||||
|
/// The wire spelling, in consent rows and on the SSE stream.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ConsentKind::Secret => "secret",
|
||||||
|
ConsentKind::Phone => "phone",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the stored spelling; `None` for anything else.
|
||||||
|
pub fn parse(kind: &str) -> Option<Self> {
|
||||||
|
match kind {
|
||||||
|
"secret" => Some(ConsentKind::Secret),
|
||||||
|
"phone" => Some(ConsentKind::Phone),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One consent request: asked, and maybe answered.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||||
|
pub struct Consent {
|
||||||
|
/// The request's id (`con-…`).
|
||||||
|
pub id: String,
|
||||||
|
/// What kind of answer it waits for.
|
||||||
|
pub kind: ConsentKind,
|
||||||
|
/// The one-line ask, as the UI shows it.
|
||||||
|
pub summary: String,
|
||||||
|
/// The ask's context — the model's own reason.
|
||||||
|
pub detail: String,
|
||||||
|
/// The session that asked.
|
||||||
|
pub session_key: String,
|
||||||
|
/// For a secret request: the account fields to store the answer
|
||||||
|
/// under (label, service, url, username, secret_kind) — the draft
|
||||||
|
/// without its secret. `None` for permission requests.
|
||||||
|
pub draft: Option<Value>,
|
||||||
|
/// When the request was made, epoch millis.
|
||||||
|
pub created_at: u64,
|
||||||
|
/// When it resolved, epoch millis, if it did.
|
||||||
|
pub resolved_at: Option<u64>,
|
||||||
|
/// Whether it was granted, if it resolved.
|
||||||
|
pub granted: Option<bool>,
|
||||||
|
/// For a resolved secret request: the account the secret landed in.
|
||||||
|
pub account_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a consent-store operation failed.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ConsentStoreError {
|
||||||
|
/// The backend failed to read or write.
|
||||||
|
#[error("consent store failed: {0}")]
|
||||||
|
Backend(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The consent table over the claw's shared database.
|
||||||
|
pub struct ConsentStore {
|
||||||
|
conn: Mutex<rusqlite::Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConsentStore {
|
||||||
|
/// Opens (creating if needed) the consent table on the database at
|
||||||
|
/// `path`.
|
||||||
|
pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, ConsentStoreError> {
|
||||||
|
let conn = rusqlite::Connection::open(path)
|
||||||
|
.map_err(|err| ConsentStoreError::Backend(err.to_string()))?;
|
||||||
|
Self::init(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the consent table over an in-memory database.
|
||||||
|
pub fn in_memory() -> Result<Self, ConsentStoreError> {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory()
|
||||||
|
.map_err(|err| ConsentStoreError::Backend(err.to_string()))?;
|
||||||
|
Self::init(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(conn: rusqlite::Connection) -> Result<Self, ConsentStoreError> {
|
||||||
|
let conn = conn;
|
||||||
|
conn.busy_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.map_err(|err| ConsentStoreError::Backend(err.to_string()))?;
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS consents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
detail TEXT NOT NULL,
|
||||||
|
session_key TEXT NOT NULL,
|
||||||
|
draft TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
resolved_at INTEGER,
|
||||||
|
granted INTEGER,
|
||||||
|
account_id TEXT
|
||||||
|
);",
|
||||||
|
)
|
||||||
|
.map_err(|err| ConsentStoreError::Backend(err.to_string()))?;
|
||||||
|
Ok(Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a request, durable before it returns. The id is unique
|
||||||
|
/// per request (a wall-clock millisecond plus a process counter).
|
||||||
|
pub fn create(
|
||||||
|
&self,
|
||||||
|
kind: ConsentKind,
|
||||||
|
summary: &str,
|
||||||
|
detail: &str,
|
||||||
|
session_key: &str,
|
||||||
|
draft: Option<Value>,
|
||||||
|
) -> Result<Consent, ConsentStoreError> {
|
||||||
|
static CONSENT_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
|
let now = now_millis();
|
||||||
|
let id = format!(
|
||||||
|
"con-{}-{:04x}",
|
||||||
|
now,
|
||||||
|
CONSENT_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||||
|
);
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO consents (id, kind, summary, detail, session_key, draft, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
|
params![
|
||||||
|
id,
|
||||||
|
kind.as_str(),
|
||||||
|
summary,
|
||||||
|
detail,
|
||||||
|
session_key,
|
||||||
|
draft.as_ref().map(Value::to_string),
|
||||||
|
now as i64,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|err| ConsentStoreError::Backend(err.to_string()))?;
|
||||||
|
Ok(Consent {
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
summary: summary.to_owned(),
|
||||||
|
detail: detail.to_owned(),
|
||||||
|
session_key: session_key.to_owned(),
|
||||||
|
draft,
|
||||||
|
created_at: now,
|
||||||
|
resolved_at: None,
|
||||||
|
granted: None,
|
||||||
|
account_id: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The request with this id, when it exists.
|
||||||
|
pub fn get(&self, id: &str) -> Result<Option<Consent>, ConsentStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, kind, summary, detail, session_key, draft, created_at,
|
||||||
|
resolved_at, granted, account_id
|
||||||
|
FROM consents WHERE id = ?1",
|
||||||
|
)
|
||||||
|
.map_err(backend)?;
|
||||||
|
let mut rows = stmt.query_map(params![id], row_consent).map_err(backend)?;
|
||||||
|
rows.next().transpose().map_err(backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every unanswered, unexpired request, oldest first — the list a
|
||||||
|
/// UI polls after a reload, so a card is never lost to one.
|
||||||
|
pub fn pending(&self) -> Result<Vec<Consent>, ConsentStoreError> {
|
||||||
|
let horizon = (now_millis().saturating_sub(CONSENT_WAIT.as_millis() as u64)) as i64;
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, kind, summary, detail, session_key, draft, created_at,
|
||||||
|
resolved_at, granted, account_id
|
||||||
|
FROM consents
|
||||||
|
WHERE resolved_at IS NULL AND created_at >= ?1
|
||||||
|
ORDER BY created_at, id",
|
||||||
|
)
|
||||||
|
.map_err(backend)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![horizon], row_consent)
|
||||||
|
.map_err(backend)?;
|
||||||
|
rows.collect::<Result<Vec<_>, _>>().map_err(backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks a secret request resolved and names the account the secret
|
||||||
|
/// landed in. `false` when the id is unknown or already resolved —
|
||||||
|
/// the answer is consumed exactly once.
|
||||||
|
pub fn resolve_secret(&self, id: &str, account_id: &str) -> Result<bool, ConsentStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let updated = conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE consents SET resolved_at = ?2, granted = 1, account_id = ?3
|
||||||
|
WHERE id = ?1 AND resolved_at IS NULL",
|
||||||
|
params![id, now_millis() as i64, account_id],
|
||||||
|
)
|
||||||
|
.map_err(backend)?;
|
||||||
|
Ok(updated > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks a permission request granted or denied. `false` when the id
|
||||||
|
/// is unknown or already resolved.
|
||||||
|
pub fn resolve_decision(&self, id: &str, granted: bool) -> Result<bool, ConsentStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let updated = conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE consents SET resolved_at = ?2, granted = ?3
|
||||||
|
WHERE id = ?1 AND resolved_at IS NULL",
|
||||||
|
params![id, now_millis() as i64, granted],
|
||||||
|
)
|
||||||
|
.map_err(backend)?;
|
||||||
|
Ok(updated > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backend(err: rusqlite::Error) -> ConsentStoreError {
|
||||||
|
ConsentStoreError::Backend(err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_consent(row: &rusqlite::Row<'_>) -> rusqlite::Result<Consent> {
|
||||||
|
let kind: String = row.get(1)?;
|
||||||
|
Ok(Consent {
|
||||||
|
id: row.get(0)?,
|
||||||
|
kind: ConsentKind::parse(&kind).unwrap_or(ConsentKind::Secret),
|
||||||
|
summary: row.get(2)?,
|
||||||
|
detail: row.get(3)?,
|
||||||
|
session_key: row.get(4)?,
|
||||||
|
draft: row
|
||||||
|
.get::<_, Option<String>>(5)?
|
||||||
|
.map(|json| serde_json::from_str(&json).unwrap_or(Value::Null)),
|
||||||
|
created_at: row.get::<_, i64>(6)?.max(0) as u64,
|
||||||
|
resolved_at: row
|
||||||
|
.get::<_, Option<i64>>(7)?
|
||||||
|
.map(|millis| millis.max(0) as u64),
|
||||||
|
granted: row.get::<_, Option<i64>>(8)?.map(|flag| flag != 0),
|
||||||
|
account_id: row.get(9)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wakes tools waiting on a row's resolution. A wake for an id nobody
|
||||||
|
/// waits on yet is not an error and not lost: the waiter re-reads its
|
||||||
|
/// row around every wait, so the store, not the signal, is the truth.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct WaitBridge {
|
||||||
|
waiters: Mutex<HashMap<String, Arc<Notify>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WaitBridge {
|
||||||
|
/// An empty bridge.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The notify handle for `id`, creating it on first use. A waiter
|
||||||
|
/// holds this handle across its wait; the resolver and the phone
|
||||||
|
/// command wait both wait through it.
|
||||||
|
pub(crate) fn waiter(&self, id: &str) -> Arc<Notify> {
|
||||||
|
self.waiters
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(PoisonError::into_inner)
|
||||||
|
.entry(id.to_owned())
|
||||||
|
.or_default()
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wakes whoever waits on `id`, if anyone.
|
||||||
|
pub fn wake(&self, id: &str) {
|
||||||
|
let waiter = self
|
||||||
|
.waiters
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(PoisonError::into_inner)
|
||||||
|
.get(id)
|
||||||
|
.cloned();
|
||||||
|
if let Some(waiter) = waiter {
|
||||||
|
waiter.notify_one();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a wait came back with.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum WaitOutcome {
|
||||||
|
/// Resolved in favor: for a secret request, the account id the
|
||||||
|
/// answer was stored under.
|
||||||
|
Granted(Option<String>),
|
||||||
|
/// Resolved against.
|
||||||
|
Denied,
|
||||||
|
/// [`CONSENT_WAIT`] elapsed with no answer.
|
||||||
|
TimedOut,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits until `id`'s row resolves or the budget runs out. The row —
|
||||||
|
/// not the wake — is what the answer reads, so a resolution that raced
|
||||||
|
/// the waiter is still seen.
|
||||||
|
pub async fn wait_for_resolution(
|
||||||
|
store: &ConsentStore,
|
||||||
|
bridge: &WaitBridge,
|
||||||
|
id: &str,
|
||||||
|
budget: Duration,
|
||||||
|
) -> WaitOutcome {
|
||||||
|
let deadline = tokio::time::Instant::now() + budget;
|
||||||
|
loop {
|
||||||
|
if let Ok(Some(consent)) = store.get(id) {
|
||||||
|
if let Some(granted) = consent.granted {
|
||||||
|
return if granted {
|
||||||
|
WaitOutcome::Granted(consent.account_id)
|
||||||
|
} else {
|
||||||
|
WaitOutcome::Denied
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let waiter = bridge.waiter(id);
|
||||||
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||||
|
match tokio::time::timeout(remaining, waiter.notified()).await {
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(_) => return WaitOutcome::TimedOut,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tool's name on the wire.
|
||||||
|
pub const ASK_SECRET: &str = "ask_secret";
|
||||||
|
|
||||||
|
/// `ask_secret`: store an account through the human. The tool asks, the
|
||||||
|
/// chat surface shows a card, the human pastes the secret, the HTTP
|
||||||
|
/// route stores it into the identity store, and the tool wakes with the
|
||||||
|
/// account id — never the secret, which nothing here re-renders.
|
||||||
|
struct AskSecret {
|
||||||
|
consents: Arc<ConsentStore>,
|
||||||
|
bridge: Arc<WaitBridge>,
|
||||||
|
hub: Arc<EventHub>,
|
||||||
|
key: ResourceKey,
|
||||||
|
/// The tool's patience; [`CONSENT_WAIT`] in production, a sliver in
|
||||||
|
/// tests.
|
||||||
|
wait: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct AskSecretArgs {
|
||||||
|
service: String,
|
||||||
|
#[serde(default)]
|
||||||
|
label: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
username: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
secret_kind: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for AskSecret {
|
||||||
|
fn spec(&self) -> ToolSpec {
|
||||||
|
ToolSpec::new(
|
||||||
|
ASK_SECRET,
|
||||||
|
"Ask the human for a credential and store it in the agent \
|
||||||
|
accounts: a card appears in their chat where they paste the \
|
||||||
|
secret. Use it when a task needs an account (a forge token, \
|
||||||
|
an API key) that identity_list does not show yet — never ask \
|
||||||
|
for something already stored. The answer carries no secret, \
|
||||||
|
only whether it was stored and the account's id. The human \
|
||||||
|
may decline, or answer never; both come back in-band. Act \
|
||||||
|
with the stored account afterwards through identity_exec.",
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"service": { "type": "string",
|
||||||
|
"description": "The service the credential is for, \
|
||||||
|
e.g. 'github' or an internal tool's name." },
|
||||||
|
"label": { "type": "string",
|
||||||
|
"description": "Display name for the account; \
|
||||||
|
defaults to the service name." },
|
||||||
|
"username": { "type": "string",
|
||||||
|
"description": "The login name on the service." },
|
||||||
|
"url": { "type": "string",
|
||||||
|
"description": "Base URL, for self-hosted instances." },
|
||||||
|
"secret_kind": { "type": "string",
|
||||||
|
"enum": ["token", "password"],
|
||||||
|
"description": "What the secret is; default token." },
|
||||||
|
"reason": { "type": "string",
|
||||||
|
"description": "Why you need it — shown on the card." }
|
||||||
|
},
|
||||||
|
"required": ["service"]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||||
|
let args: AskSecretArgs =
|
||||||
|
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||||
|
let service = args.service.trim();
|
||||||
|
if service.is_empty() {
|
||||||
|
return Err("service must not be empty".to_owned());
|
||||||
|
}
|
||||||
|
// Validate the kind spelling before the card exists: a typo'd
|
||||||
|
// enum must fail the call, not the human's answer.
|
||||||
|
let secret_kind = crate::accounts_tools::parse_kind(args.secret_kind.as_deref())?;
|
||||||
|
let label = args
|
||||||
|
.label
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|label| !label.is_empty())
|
||||||
|
.unwrap_or(service)
|
||||||
|
.to_owned();
|
||||||
|
let summary = match args
|
||||||
|
.username
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|u| !u.is_empty())
|
||||||
|
{
|
||||||
|
Some(username) => format!("secret for {service} ({username})"),
|
||||||
|
None => format!("secret for {service}"),
|
||||||
|
};
|
||||||
|
let detail = args
|
||||||
|
.reason
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|reason| !reason.is_empty())
|
||||||
|
.unwrap_or("no reason given")
|
||||||
|
.to_owned();
|
||||||
|
let draft = json!({
|
||||||
|
"label": label,
|
||||||
|
"service": service,
|
||||||
|
"url": args.url,
|
||||||
|
"username": args.username,
|
||||||
|
"secret_kind": match secret_kind {
|
||||||
|
rhema_identity::SecretKind::Token => "token",
|
||||||
|
rhema_identity::SecretKind::Password => "password",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let consent = self
|
||||||
|
.consents
|
||||||
|
.create(
|
||||||
|
ConsentKind::Secret,
|
||||||
|
&summary,
|
||||||
|
&detail,
|
||||||
|
self.key.as_str(),
|
||||||
|
Some(draft),
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
self.hub.emit(
|
||||||
|
&self.key,
|
||||||
|
TurnEvent::ConsentRequest {
|
||||||
|
id: consent.id.clone(),
|
||||||
|
kind: consent.kind.as_str().to_owned(),
|
||||||
|
summary: consent.summary.clone(),
|
||||||
|
detail: consent.detail.clone(),
|
||||||
|
created_at: consent.created_at,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
match wait_for_resolution(&self.consents, &self.bridge, &consent.id, self.wait).await {
|
||||||
|
WaitOutcome::Granted(account_id) => Ok(json!({
|
||||||
|
"stored": true,
|
||||||
|
"account_id": account_id,
|
||||||
|
"service": service,
|
||||||
|
})
|
||||||
|
.to_string()),
|
||||||
|
WaitOutcome::Denied => Ok(json!({"granted": false}).to_string()),
|
||||||
|
WaitOutcome::TimedOut => Ok(json!({"timed_out": true}).to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The consent tool set over `store`: `ask_secret`. Built per dispatch,
|
||||||
|
/// so the request names the session that asked.
|
||||||
|
pub fn registry(
|
||||||
|
consents: Arc<ConsentStore>,
|
||||||
|
bridge: Arc<WaitBridge>,
|
||||||
|
hub: Arc<EventHub>,
|
||||||
|
key: ResourceKey,
|
||||||
|
) -> ToolRegistry {
|
||||||
|
let mut registry = ToolRegistry::new();
|
||||||
|
registry.register(Arc::new(AskSecret {
|
||||||
|
consents,
|
||||||
|
bridge,
|
||||||
|
hub,
|
||||||
|
key,
|
||||||
|
wait: CONSENT_WAIT,
|
||||||
|
}));
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn store() -> Arc<ConsentStore> {
|
||||||
|
Arc::new(ConsentStore::in_memory().expect("store opens"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_created_request_round_trips_and_starts_pending() {
|
||||||
|
let store = store();
|
||||||
|
let consent = store
|
||||||
|
.create(
|
||||||
|
ConsentKind::Secret,
|
||||||
|
"secret for github",
|
||||||
|
"to post",
|
||||||
|
"interactive:web",
|
||||||
|
Some(json!({"label": "github"})),
|
||||||
|
)
|
||||||
|
.expect("creates");
|
||||||
|
assert_eq!(consent.kind, ConsentKind::Secret);
|
||||||
|
assert_eq!(consent.granted, None);
|
||||||
|
assert_eq!(consent.session_key, "interactive:web");
|
||||||
|
let fetched = store.get(&consent.id).expect("reads").expect("exists");
|
||||||
|
assert_eq!(fetched, consent);
|
||||||
|
assert_eq!(store.pending().expect("reads"), vec![consent]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_secret_resolution_names_the_account_exactly_once() {
|
||||||
|
let store = store();
|
||||||
|
let consent = store
|
||||||
|
.create(ConsentKind::Secret, "s", "d", "interactive:web", None)
|
||||||
|
.expect("creates");
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.resolve_secret(&consent.id, "acc-1")
|
||||||
|
.expect("resolves"),
|
||||||
|
"the first resolution lands"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!store
|
||||||
|
.resolve_secret(&consent.id, "acc-2")
|
||||||
|
.expect("resolves"),
|
||||||
|
"the answer is consumed once"
|
||||||
|
);
|
||||||
|
let resolved = store.get(&consent.id).expect("reads").expect("exists");
|
||||||
|
assert_eq!(resolved.account_id.as_deref(), Some("acc-1"));
|
||||||
|
assert_eq!(resolved.granted, Some(true));
|
||||||
|
assert!(
|
||||||
|
store.pending().expect("reads").is_empty(),
|
||||||
|
"resolved rows leave the pending list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_decision_resolves_once_with_its_verdict() {
|
||||||
|
let store = store();
|
||||||
|
let consent = store
|
||||||
|
.create(
|
||||||
|
ConsentKind::Phone,
|
||||||
|
"phone dnd_on",
|
||||||
|
"quiet",
|
||||||
|
"interactive:web",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("creates");
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.resolve_decision(&consent.id, false)
|
||||||
|
.expect("resolves"),
|
||||||
|
"a denial lands"
|
||||||
|
);
|
||||||
|
let resolved = store.get(&consent.id).expect("reads").expect("exists");
|
||||||
|
assert_eq!(resolved.granted, Some(false));
|
||||||
|
assert_eq!(resolved.account_id, None);
|
||||||
|
assert!(
|
||||||
|
!store.resolve_decision(&consent.id, true).expect("resolves"),
|
||||||
|
"a denial cannot be flipped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_expired_request_leaves_the_pending_list() {
|
||||||
|
let store = store();
|
||||||
|
{
|
||||||
|
let conn = store.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO consents (id, kind, summary, detail, session_key, created_at)
|
||||||
|
VALUES ('con-old', 'secret', 's', 'd', 'interactive:web', 1)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.expect("writes raw");
|
||||||
|
}
|
||||||
|
let fresh = store
|
||||||
|
.create(ConsentKind::Secret, "s", "d", "interactive:web", None)
|
||||||
|
.expect("creates");
|
||||||
|
let pending = store.pending().expect("reads");
|
||||||
|
assert_eq!(pending, vec![fresh], "only the unexpired request answers");
|
||||||
|
assert!(
|
||||||
|
store.get("con-old").expect("reads").is_some(),
|
||||||
|
"the expired row still reads"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full round-trip, tool-side: the ask creates the row and the
|
||||||
|
/// stream event, the human's answer (the route's half: resolve,
|
||||||
|
/// wake) ends the wait, and the answer names the account — the
|
||||||
|
/// secret never passes through the tool.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_ask_stored_through_a_consent_answers_with_the_account_id() {
|
||||||
|
use crate::hub::EventHub;
|
||||||
|
|
||||||
|
let store = store();
|
||||||
|
let bridge = Arc::new(WaitBridge::new());
|
||||||
|
let hub = Arc::new(EventHub::default());
|
||||||
|
let key = ResourceKey::from("interactive:web");
|
||||||
|
let tool = AskSecret {
|
||||||
|
consents: store.clone(),
|
||||||
|
bridge: bridge.clone(),
|
||||||
|
hub: hub.clone(),
|
||||||
|
key: key.clone(),
|
||||||
|
wait: std::time::Duration::from_secs(10),
|
||||||
|
};
|
||||||
|
let mut receiver = hub.subscribe(&key);
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
tool.invoke(json!({
|
||||||
|
"service": "github",
|
||||||
|
"username": "octocat",
|
||||||
|
"reason": "to file the issue you asked for",
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
// The ask lands: one pending row, one stream event.
|
||||||
|
let pending = loop {
|
||||||
|
let pending = store.pending().expect("reads");
|
||||||
|
if !pending.is_empty() {
|
||||||
|
break pending;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
};
|
||||||
|
assert_eq!(pending[0].summary, "secret for github (octocat)");
|
||||||
|
assert!(matches!(
|
||||||
|
receiver.try_recv(),
|
||||||
|
Ok(TurnEvent::ConsentRequest { .. })
|
||||||
|
));
|
||||||
|
// The route's half: store the secret elsewhere, resolve, wake.
|
||||||
|
store
|
||||||
|
.resolve_secret(&pending[0].id, "acc-9")
|
||||||
|
.expect("resolves");
|
||||||
|
bridge.wake(&pending[0].id);
|
||||||
|
let answer = task.await.expect("the tool returns").expect("in-band ok");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&answer).unwrap(),
|
||||||
|
json!({"stored": true, "account_id": "acc-9", "service": "github"}),
|
||||||
|
"{answer}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_denied_ask_answers_in_band_and_not_as_an_error() {
|
||||||
|
let store = store();
|
||||||
|
let bridge = Arc::new(WaitBridge::new());
|
||||||
|
let hub = Arc::new(EventHub::default());
|
||||||
|
let key = ResourceKey::from("interactive:web");
|
||||||
|
let tool = AskSecret {
|
||||||
|
consents: store.clone(),
|
||||||
|
bridge: bridge.clone(),
|
||||||
|
hub,
|
||||||
|
key,
|
||||||
|
wait: std::time::Duration::from_secs(10),
|
||||||
|
};
|
||||||
|
let task = tokio::spawn(async move { tool.invoke(json!({"service": "gitlab"})).await });
|
||||||
|
let pending = loop {
|
||||||
|
let pending = store.pending().expect("reads");
|
||||||
|
if !pending.is_empty() {
|
||||||
|
break pending;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.resolve_decision(&pending[0].id, false)
|
||||||
|
.expect("resolves");
|
||||||
|
bridge.wake(&pending[0].id);
|
||||||
|
let answer = task.await.expect("the tool returns").expect("in-band ok");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&answer).unwrap(),
|
||||||
|
json!({"granted": false}),
|
||||||
|
"{answer}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unanswered_ask_times_out_in_band() {
|
||||||
|
let store = store();
|
||||||
|
let bridge = Arc::new(WaitBridge::new());
|
||||||
|
let hub = Arc::new(EventHub::default());
|
||||||
|
let key = ResourceKey::from("interactive:web");
|
||||||
|
let tool = AskSecret {
|
||||||
|
consents: store,
|
||||||
|
bridge,
|
||||||
|
hub,
|
||||||
|
key,
|
||||||
|
wait: std::time::Duration::from_millis(50),
|
||||||
|
};
|
||||||
|
let answer = tool
|
||||||
|
.invoke(json!({"service": "github"}))
|
||||||
|
.await
|
||||||
|
.expect("in-band ok");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&answer).unwrap(),
|
||||||
|
json!({"timed_out": true}),
|
||||||
|
"{answer}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
//! The daemon: sources, drivers, the HTTP surface, and the cadence
|
||||||
|
//! assembled into one resident process.
|
||||||
|
//!
|
||||||
|
//! Two dispatchers share one SQLite store: the forge dispatcher (watches
|
||||||
|
//! polled by the sources, answered by [`ClawHandler`]) and the
|
||||||
|
//! interactive dispatcher — the resident dispatcher — whose keys carry
|
||||||
|
//! the `interactive:`/`task:`/`phone:` prefixes and whose inputs are the
|
||||||
|
//! chat routes' messages, the `/v1/ingest` door, and the task
|
||||||
|
//! scheduler's due firings, all answered by the interactive handler.
|
||||||
|
//! Sharing the store is what makes the whole thing one resident agent
|
||||||
|
//! with one inventory — and it is safe because the store's whole-record
|
||||||
|
//! revision compare-and-swap serializes across every instance holding
|
||||||
|
//! the file (rhema-rsd's cross-connection CAS tests pin that).
|
||||||
|
//!
|
||||||
|
//! Every timer here drives deterministic detection and admission only —
|
||||||
|
//! the model is reached exclusively inside handler runs (spec §7's
|
||||||
|
//! invariant: a resident process is not a process that burns tokens on a
|
||||||
|
//! schedule). The `--once` shape is the same assembly with a zero
|
||||||
|
//! debounce window — a cron pass acts on what it sees now — and one
|
||||||
|
//! detect-plus-driver pass instead of a loop, with no HTTP surface: cron
|
||||||
|
//! machines have no one to chat with.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use rhema_autonomous_agent::{Driver, Source, SourceError};
|
||||||
|
use rhema_rsd::{
|
||||||
|
DispatchError, Dispatcher, DispatcherConfig, Outcome, ResourceKey, SqliteStore, StoreError,
|
||||||
|
SystemClock,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::calendar::CalendarSource;
|
||||||
|
use crate::config::{ClawConfig, ConfigError, ForgeKind};
|
||||||
|
use crate::forge::ForgeSource;
|
||||||
|
use crate::handler::ClawHandler;
|
||||||
|
use crate::http::ClawState;
|
||||||
|
use crate::notifications::NotificationStore;
|
||||||
|
use crate::task_source::TaskScheduler;
|
||||||
|
use crate::task_store::TaskStore;
|
||||||
|
use rhema_memory::FileMemoryStore;
|
||||||
|
|
||||||
|
use crate::backends::BackendRegistry;
|
||||||
|
use crate::hub::EventHub;
|
||||||
|
use crate::interactive::InteractiveHandler;
|
||||||
|
use crate::watch_store::ClawStore;
|
||||||
|
|
||||||
|
/// Why the daemon could not run.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ClawError {
|
||||||
|
/// The config file is missing, malformed, or fails validation.
|
||||||
|
#[error(transparent)]
|
||||||
|
Config(#[from] ConfigError),
|
||||||
|
/// A directory or path the daemon needs could not be created, or the
|
||||||
|
/// HTTP listener could not bind.
|
||||||
|
#[error(transparent)]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
/// The SQLite store could not be opened.
|
||||||
|
#[error(transparent)]
|
||||||
|
Store(#[from] StoreError),
|
||||||
|
/// The watch table could not be opened or read.
|
||||||
|
#[error(transparent)]
|
||||||
|
Watches(#[from] crate::watch_store::WatchStoreError),
|
||||||
|
/// The runtime settings table could not be opened or read.
|
||||||
|
#[error(transparent)]
|
||||||
|
Settings(#[from] crate::settings::SettingsError),
|
||||||
|
/// The notification table could not be opened or read.
|
||||||
|
#[error(transparent)]
|
||||||
|
Notifications(#[from] crate::notifications::NotificationStoreError),
|
||||||
|
/// The task table could not be opened or read.
|
||||||
|
#[error(transparent)]
|
||||||
|
Tasks(#[from] crate::task_store::TaskStoreError),
|
||||||
|
/// The coding-job tables could not be opened or read.
|
||||||
|
#[error(transparent)]
|
||||||
|
Jobs(#[from] crate::job_store::JobStoreError),
|
||||||
|
/// The turn-history table could not be opened or read.
|
||||||
|
#[error(transparent)]
|
||||||
|
TurnHistory(#[from] crate::turn_store::TurnStoreError),
|
||||||
|
/// The consent table could not be opened or read.
|
||||||
|
#[error(transparent)]
|
||||||
|
Consents(#[from] crate::consents::ConsentStoreError),
|
||||||
|
/// The attachments directory could not be opened.
|
||||||
|
#[error(transparent)]
|
||||||
|
Attachments(#[from] crate::attachments::AttachmentError),
|
||||||
|
/// The phone tables could not be opened or read.
|
||||||
|
#[error(transparent)]
|
||||||
|
Phone(#[from] crate::phone::PhoneStoreError),
|
||||||
|
/// The backend registry could not be built: no provider constructed,
|
||||||
|
/// or the configured seed named an unknown kind.
|
||||||
|
#[error("backend registry failed: {0}")]
|
||||||
|
Backend(String),
|
||||||
|
/// The bootstrap store could not be opened.
|
||||||
|
#[error(transparent)]
|
||||||
|
Bootstrap(#[from] crate::bootstrap::BootstrapError),
|
||||||
|
/// A source's detect pass failed.
|
||||||
|
#[error(transparent)]
|
||||||
|
Source(#[from] SourceError),
|
||||||
|
/// The dispatcher rejected a step.
|
||||||
|
#[error(transparent)]
|
||||||
|
Dispatch(#[from] DispatchError),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One daemon pass: what detection fed, and what the runs reported.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TickReport {
|
||||||
|
/// Events fed by detection across all sources — replayed dedup ids
|
||||||
|
/// included; the dispatcher dedups.
|
||||||
|
pub detected: usize,
|
||||||
|
/// One outcome per forge key that became a run; a key that was
|
||||||
|
/// parked or raced is simply absent.
|
||||||
|
pub outcomes: Vec<Outcome>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The interactive dispatcher's timing: messages dispatch immediately —
|
||||||
|
/// an open surface has nothing to coalesce (spec §6.2) — leases run an
|
||||||
|
/// hour and renew at the handler's poll boundaries because turns can run
|
||||||
|
/// long, and the caps are generous for the same reason.
|
||||||
|
fn interactive_dispatch() -> DispatcherConfig {
|
||||||
|
DispatcherConfig {
|
||||||
|
max_concurrent_leases: 64,
|
||||||
|
max_concurrent_per_source: BTreeMap::new(),
|
||||||
|
debounce_window_millis: 0,
|
||||||
|
lease_ttl_millis: 3_600_000,
|
||||||
|
admission_ttl_millis: 60_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resident daemon: one source per forge kind, the calendar
|
||||||
|
/// reminders, the task scheduler, the coding-job source, the three
|
||||||
|
/// drivers, and the HTTP surface.
|
||||||
|
pub struct Daemon {
|
||||||
|
sources: Vec<ForgeSource>,
|
||||||
|
calendar_source: CalendarSource,
|
||||||
|
scheduler: TaskScheduler,
|
||||||
|
job_source: crate::jobs::JobsSource,
|
||||||
|
jobs: Arc<crate::job_store::CodingJobStore>,
|
||||||
|
forge_dispatcher: Arc<Dispatcher>,
|
||||||
|
interactive_dispatcher: Arc<Dispatcher>,
|
||||||
|
jobs_dispatcher: Arc<Dispatcher>,
|
||||||
|
forge_driver: Arc<Driver>,
|
||||||
|
interactive_driver: Arc<Driver>,
|
||||||
|
jobs_driver: Arc<Driver>,
|
||||||
|
poll_interval: Duration,
|
||||||
|
listen: String,
|
||||||
|
http: ClawState,
|
||||||
|
/// The registry the drivers' handlers read per dispatch; kept for
|
||||||
|
/// introspection (the active pair after a bootstrap).
|
||||||
|
registry: Arc<BackendRegistry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Daemon {
|
||||||
|
/// Assembles the daemon over `config`: the shared SQLite store, one
|
||||||
|
/// source per forge kind reading the watch table, the interactive
|
||||||
|
/// dispatcher, and both handlers running `provider`. `dispatch` is
|
||||||
|
/// the forge dispatcher's timing — the resident loop uses the
|
||||||
|
/// defaults, `--once` zeroes the debounce.
|
||||||
|
pub fn build(
|
||||||
|
config: &ClawConfig,
|
||||||
|
backends: Arc<BackendRegistry>,
|
||||||
|
dispatch: DispatcherConfig,
|
||||||
|
) -> Result<Self, ClawError> {
|
||||||
|
config.validate()?;
|
||||||
|
let db = config.db_path()?;
|
||||||
|
if let Some(parent) = db.parent() {
|
||||||
|
// The store holds provider keys and forge tokens: `0700` on
|
||||||
|
// the directory, `0600` on the file (below), both
|
||||||
|
// re-asserted on every boot.
|
||||||
|
rhema_config::privacy::private_dir(parent)?;
|
||||||
|
}
|
||||||
|
let workspace_root = config.workspace_root()?;
|
||||||
|
let interactive_workspace = workspace_root.join("interactive");
|
||||||
|
std::fs::create_dir_all(&interactive_workspace)?;
|
||||||
|
|
||||||
|
// One store file, several connections: the dispatcher's records,
|
||||||
|
// the watch table, the notification table, the task table, and
|
||||||
|
// the turn-history table live side by side.
|
||||||
|
let store = Arc::new(SqliteStore::open(&db)?);
|
||||||
|
// SQLite created the file under the umask; whatever it left
|
||||||
|
// wide, tighten. Best-effort: a filesystem that refuses mode
|
||||||
|
// changes must not stop the boot.
|
||||||
|
rhema_config::privacy::tighten_file(&db);
|
||||||
|
let watches = Arc::new(ClawStore::open(&db)?);
|
||||||
|
let notifications = Arc::new(NotificationStore::open(&db)?);
|
||||||
|
let tasks = Arc::new(TaskStore::open(&db)?);
|
||||||
|
let turns = Arc::new(crate::turn_store::TurnStore::open(&db)?);
|
||||||
|
// The coding-job tables: instances the `coding_agent` tool
|
||||||
|
// drives, jobs the jobs dispatcher answers.
|
||||||
|
let jobs = Arc::new(crate::job_store::CodingJobStore::open(&db)?);
|
||||||
|
|
||||||
|
// The YAML watch list is the one-time seed; the table rules
|
||||||
|
// after that.
|
||||||
|
let seeded = watches.seed_from_config(&config.watches)?;
|
||||||
|
if seeded > 0 {
|
||||||
|
eprintln!("[claw] seeded {seeded} watch(es) from the config");
|
||||||
|
}
|
||||||
|
|
||||||
|
let forge_dispatcher = Arc::new(Dispatcher::new(
|
||||||
|
dispatch,
|
||||||
|
Arc::new(SystemClock),
|
||||||
|
store.clone(),
|
||||||
|
));
|
||||||
|
let interactive_dispatcher = Arc::new(Dispatcher::new(
|
||||||
|
interactive_dispatch(),
|
||||||
|
Arc::new(SystemClock),
|
||||||
|
store.clone(),
|
||||||
|
));
|
||||||
|
// The jobs dispatcher answers `job:<instance>#<id>`: a coding
|
||||||
|
// run's turn can take minutes, so it leases on the resident
|
||||||
|
// timing and runs in its own driver, never blocking a chat.
|
||||||
|
let jobs_dispatcher = Arc::new(Dispatcher::new(
|
||||||
|
interactive_dispatch(),
|
||||||
|
Arc::new(SystemClock),
|
||||||
|
store.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// The account store the forge sources poll through: an account
|
||||||
|
// watch authenticates as its identity, and the secret reaches
|
||||||
|
// the forge client's constructor and nowhere else.
|
||||||
|
let accounts: Arc<dyn rhema_identity::IdentityStore> = Arc::new(
|
||||||
|
rhema_identity::SqliteIdentityStore::open(&db)
|
||||||
|
.map_err(|err| ClawError::Backend(err.to_string()))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
// One source per known forge kind, whatever the table currently
|
||||||
|
// holds: a watch added later (chat tool, HTTP route) of a kind
|
||||||
|
// nobody watched at startup still has its key function
|
||||||
|
// registered. A source over zero watches detects nothing.
|
||||||
|
let sources = [ForgeKind::GitHub, ForgeKind::Gitea, ForgeKind::GitLab]
|
||||||
|
.into_iter()
|
||||||
|
.map(|kind| {
|
||||||
|
ForgeSource::new(
|
||||||
|
&forge_dispatcher,
|
||||||
|
kind.name(),
|
||||||
|
watches.clone(),
|
||||||
|
accounts.clone(),
|
||||||
|
notifications.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// The forge handler resolves its watches through the table at
|
||||||
|
// dispatch time, so a watch added mid-run is answerable without a
|
||||||
|
// restart.
|
||||||
|
let forge_handler = ClawHandler::new(backends.clone(), workspace_root, watches.clone());
|
||||||
|
// Three drivers over one store admit from the same table, so
|
||||||
|
// each owns its slice of the key space: a forge pass must never
|
||||||
|
// pick up a resident or job event (it would fail it, and
|
||||||
|
// consume it), and the others likewise.
|
||||||
|
let forge_driver = Arc::new(
|
||||||
|
Driver::new(forge_dispatcher.clone(), Arc::new(forge_handler))
|
||||||
|
.with_holder("rhema-claw")
|
||||||
|
.owning(Arc::new(|key: &ResourceKey| {
|
||||||
|
!crate::interactive::is_resident_key(key.as_str())
|
||||||
|
&& !crate::jobs::is_job_key(key.as_str())
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The hub replays what past turns streamed: every event appends
|
||||||
|
// to the turn-history table as it streams, so a restart's replay
|
||||||
|
// reads the table back — a restart continues the transcript,
|
||||||
|
// whole, with no boot-time seed pass.
|
||||||
|
let hub = Arc::new(EventHub::with_record_store(turns.clone()));
|
||||||
|
// The consent table and its wake bridge: the `ask_secret` tool
|
||||||
|
// asks through them, the consent routes answer through them.
|
||||||
|
let consents = Arc::new(crate::consents::ConsentStore::open(&db)?);
|
||||||
|
let bridge = Arc::new(crate::consents::WaitBridge::new());
|
||||||
|
// The attachments live beside the database, the same state
|
||||||
|
// volume the transcript is durable on: a served id must not
|
||||||
|
// outlive its bytes.
|
||||||
|
let attachments = Arc::new(crate::attachments::AttachmentStore::open(
|
||||||
|
db.parent()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ClawError::Backend("the database has no parent directory".to_owned())
|
||||||
|
})?
|
||||||
|
.join("attachments"),
|
||||||
|
)?);
|
||||||
|
// The phone tables: status the ingest door feeds, commands the
|
||||||
|
// `phone` tool queues behind a consent.
|
||||||
|
let phone = Arc::new(crate::phone::PhoneStore::open(&db)?);
|
||||||
|
let memory = Arc::new(FileMemoryStore::new(config.memory_path()?)?);
|
||||||
|
let bootstrap = Arc::new(crate::bootstrap::Bootstrap::new(config.bootstrap_dir()?)?);
|
||||||
|
let settings = Arc::new(crate::settings::SettingsStore::open(&db)?);
|
||||||
|
// The identity store shares the settings table and keeps its
|
||||||
|
// images beside the database.
|
||||||
|
let identity = Arc::new(crate::identity::IdentityStore::new(
|
||||||
|
settings.clone(),
|
||||||
|
db.parent().map(|dir| dir.join("identity")).ok_or_else(|| {
|
||||||
|
ClawError::Backend("the database has no parent directory".to_owned())
|
||||||
|
})?,
|
||||||
|
));
|
||||||
|
// The persisted pair wins over the config seed — it is what a
|
||||||
|
// runtime switch wrote. A persisted id the registry no longer
|
||||||
|
// knows (a backend removed from the deployment) falls back to
|
||||||
|
// the seed silently: the daemon still boots.
|
||||||
|
let persisted_provider = settings.get(crate::settings::ACTIVE_PROVIDER_KEY)?;
|
||||||
|
let persisted_model = settings.get(crate::settings::ACTIVE_MODEL_KEY)?;
|
||||||
|
if let Some(id) = persisted_provider {
|
||||||
|
let _ = backends.set_active(&id, persisted_model.as_deref());
|
||||||
|
}
|
||||||
|
// The environment report is detection, not conversation: generated
|
||||||
|
// at every boot by deterministic discovery, never by the model,
|
||||||
|
// and regenerated over whatever the last boot wrote.
|
||||||
|
crate::environment::write_report(config.bootstrap_dir()?.join("ENVIRONMENT.md"))?;
|
||||||
|
let interactive_handler = InteractiveHandler::new(
|
||||||
|
backends.clone(),
|
||||||
|
interactive_workspace,
|
||||||
|
watches.clone(),
|
||||||
|
memory,
|
||||||
|
bootstrap.clone(),
|
||||||
|
hub.clone(),
|
||||||
|
notifications.clone(),
|
||||||
|
tasks.clone(),
|
||||||
|
jobs.clone(),
|
||||||
|
accounts.clone(),
|
||||||
|
config.max_turn_steps,
|
||||||
|
consents.clone(),
|
||||||
|
bridge.clone(),
|
||||||
|
attachments.clone(),
|
||||||
|
phone.clone(),
|
||||||
|
identity.clone(),
|
||||||
|
);
|
||||||
|
let interactive_driver = Arc::new(
|
||||||
|
Driver::new(
|
||||||
|
interactive_dispatcher.clone(),
|
||||||
|
Arc::new(interactive_handler),
|
||||||
|
)
|
||||||
|
.with_holder("rhema-claw-interactive")
|
||||||
|
.owning(Arc::new(|key: &ResourceKey| {
|
||||||
|
crate::interactive::is_resident_key(key.as_str())
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
// The coding jobs: one handler, one driver, one source. The
|
||||||
|
// final text of a job's turn notifies the user's device, so the
|
||||||
|
// handler needs the notification table too.
|
||||||
|
let jobs_handler = crate::jobs::CodingJobHandler::new(
|
||||||
|
backends.clone(),
|
||||||
|
jobs.clone(),
|
||||||
|
notifications.clone(),
|
||||||
|
config.max_turn_steps,
|
||||||
|
);
|
||||||
|
let jobs_driver = Arc::new(
|
||||||
|
Driver::new(jobs_dispatcher.clone(), Arc::new(jobs_handler))
|
||||||
|
.with_holder("rhema-claw-jobs")
|
||||||
|
.owning(Arc::new(|key: &ResourceKey| {
|
||||||
|
crate::jobs::is_job_key(key.as_str())
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
let job_source = crate::jobs::JobsSource::new(&jobs_dispatcher, jobs.clone());
|
||||||
|
crate::interactive::register_user(&interactive_dispatcher);
|
||||||
|
// The scheduler is the resident dispatcher's second own source:
|
||||||
|
// its detect pass (the daemon's tick drives it) is what turns a
|
||||||
|
// due task row into an event, never a timer near the model.
|
||||||
|
let scheduler = TaskScheduler::new(
|
||||||
|
&interactive_dispatcher,
|
||||||
|
Arc::new(SystemClock),
|
||||||
|
tasks.clone(),
|
||||||
|
);
|
||||||
|
// The calendar reminders join them: a due reminder is an
|
||||||
|
// event, never a timer near the model.
|
||||||
|
let calendar_source = CalendarSource::new(
|
||||||
|
&interactive_dispatcher,
|
||||||
|
Arc::new(SystemClock),
|
||||||
|
&config.calendars,
|
||||||
|
)
|
||||||
|
.map_err(ClawError::Backend)?;
|
||||||
|
|
||||||
|
let http = ClawState::new(
|
||||||
|
interactive_dispatcher.clone(),
|
||||||
|
interactive_driver.clone(),
|
||||||
|
store,
|
||||||
|
watches,
|
||||||
|
notifications,
|
||||||
|
bootstrap,
|
||||||
|
hub,
|
||||||
|
backends.clone(),
|
||||||
|
settings.clone(),
|
||||||
|
identity,
|
||||||
|
accounts,
|
||||||
|
consents,
|
||||||
|
bridge,
|
||||||
|
attachments,
|
||||||
|
phone,
|
||||||
|
config.token.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
sources,
|
||||||
|
calendar_source,
|
||||||
|
scheduler,
|
||||||
|
job_source,
|
||||||
|
jobs,
|
||||||
|
forge_dispatcher,
|
||||||
|
interactive_dispatcher,
|
||||||
|
jobs_dispatcher,
|
||||||
|
forge_driver,
|
||||||
|
interactive_driver,
|
||||||
|
jobs_driver,
|
||||||
|
// A zero poll interval would spin detect passes against the
|
||||||
|
// forge API; one second is the floor.
|
||||||
|
poll_interval: Duration::from_secs(config.poll_interval_secs.max(1)),
|
||||||
|
listen: config.listen_addr().to_owned(),
|
||||||
|
http,
|
||||||
|
registry: backends,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The HTTP router over the daemon's state — the exact app `run`
|
||||||
|
/// serves, exposed so a test can bind it anywhere.
|
||||||
|
pub fn router(&self) -> axum::Router {
|
||||||
|
crate::http::router(self.http.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The HTTP surface's configured address.
|
||||||
|
pub fn listen_addr(&self) -> &str {
|
||||||
|
&self.listen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The deployment's backend registry: providers and the active pair.
|
||||||
|
pub fn backends(&self) -> &BackendRegistry {
|
||||||
|
&self.registry
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Startup recovery: releases the leases the previous process left
|
||||||
|
/// behind — all of them, now, not at the dead lease's TTL (an hour
|
||||||
|
/// on the resident dispatcher), because the claw is single-node:
|
||||||
|
/// the only lease holder over this file is the process that just
|
||||||
|
/// died, and a key it held mid-turn must answer its next message
|
||||||
|
/// instead of stalling behind a ghost. A turn the restart cut
|
||||||
|
/// resumes then: the committed-but-unanswered call revives at the
|
||||||
|
/// top of the next dispatch (safe tools re-run, the rest report
|
||||||
|
/// interrupted). Idempotent; called before the first pass.
|
||||||
|
pub async fn bootstrap(&self) -> Result<(), ClawError> {
|
||||||
|
// The job rows first: a turn the restart cut is failed
|
||||||
|
// interrupted before anything re-runs, and a still-queued row
|
||||||
|
// is re-armed with a fresh dedup id.
|
||||||
|
let touched = self.jobs.recover()?;
|
||||||
|
if touched > 0 {
|
||||||
|
eprintln!("[claw] jobs: {touched} row(s) recovered at boot");
|
||||||
|
}
|
||||||
|
self.forge_dispatcher.recover_all()?;
|
||||||
|
self.jobs_dispatcher.recover_all()?;
|
||||||
|
self.interactive_dispatcher.recover_all()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pass: every source detects (due tasks first — a reminder is
|
||||||
|
/// the user-facing clock), the forge driver runs every key the
|
||||||
|
/// debounce released, and the interactive driver drains as a
|
||||||
|
/// backstop — a message delivered live into a running turn waits at
|
||||||
|
/// the key for a pass like this one.
|
||||||
|
pub async fn tick(&self) -> Result<TickReport, ClawError> {
|
||||||
|
let mut detected = self.scheduler.detect().await?;
|
||||||
|
detected += self.calendar_source.detect().await?;
|
||||||
|
detected += self.job_source.detect().await?;
|
||||||
|
for source in &self.sources {
|
||||||
|
detected += source.detect().await?;
|
||||||
|
}
|
||||||
|
// A queued coding job starts before the forge pass: it is the
|
||||||
|
// thing someone just asked for.
|
||||||
|
if let Err(err) = self.jobs_driver.run_once().await {
|
||||||
|
eprintln!("[claw] warning: jobs pass failed: {err}");
|
||||||
|
}
|
||||||
|
let outcomes = self.forge_driver.run_once().await?;
|
||||||
|
if !outcomes.is_empty() {
|
||||||
|
let handled = outcomes
|
||||||
|
.iter()
|
||||||
|
.filter(|outcome| matches!(outcome, Outcome::Handled))
|
||||||
|
.count();
|
||||||
|
let failed = outcomes
|
||||||
|
.iter()
|
||||||
|
.filter(|outcome| matches!(outcome, Outcome::Failed(_)))
|
||||||
|
.count();
|
||||||
|
eprintln!(
|
||||||
|
"[claw] pass: {detected} event(s) detected, {handled} handled, {failed} failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match self.interactive_driver.run_once().await {
|
||||||
|
Ok(runs) if !runs.is_empty() => {
|
||||||
|
eprintln!("[claw] interactive: {} queued run(s) drained", runs.len());
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) => eprintln!("[claw] warning: interactive pass failed: {err}"),
|
||||||
|
}
|
||||||
|
Ok(TickReport { detected, outcomes })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resident loop: the HTTP surface comes up first (a resident is
|
||||||
|
/// talked to), then detect, run, sleep, until ctrl-c. A failed pass
|
||||||
|
/// is logged, not fatal — a forge outage or a dead backend must not
|
||||||
|
/// end a daemon whose next pass may simply succeed.
|
||||||
|
pub async fn run(&self) -> Result<(), ClawError> {
|
||||||
|
self.bootstrap().await?;
|
||||||
|
let listener = tokio::net::TcpListener::bind(&self.listen).await?;
|
||||||
|
let addr = listener.local_addr()?;
|
||||||
|
let app = self.router();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
if let Err(err) = axum::serve(listener, app).await {
|
||||||
|
eprintln!("[claw] http server stopped: {err}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
eprintln!("[claw] http listening on {addr}");
|
||||||
|
loop {
|
||||||
|
if let Err(err) = self.tick().await {
|
||||||
|
eprintln!("[claw] warning: pass failed: {err}");
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
() = tokio::time::sleep(self.poll_interval) => {}
|
||||||
|
_ = tokio::signal::ctrl_c() => {
|
||||||
|
server.abort();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `--once` shape: assemble over a zero debounce window, recover, run
|
||||||
|
/// one detect-plus-driver pass, and return. The cron-friendly form for
|
||||||
|
/// ephemeral machines, and the shape the tests drive. No HTTP: a cron
|
||||||
|
/// pass has no one to chat with.
|
||||||
|
pub async fn run_once(
|
||||||
|
config: &ClawConfig,
|
||||||
|
backends: Arc<BackendRegistry>,
|
||||||
|
) -> Result<TickReport, ClawError> {
|
||||||
|
let dispatch = DispatcherConfig {
|
||||||
|
debounce_window_millis: 0,
|
||||||
|
..DispatcherConfig::default()
|
||||||
|
};
|
||||||
|
let daemon = Daemon::build(config, backends, dispatch)?;
|
||||||
|
daemon.bootstrap().await?;
|
||||||
|
daemon.tick().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_interactive_dispatcher_dispatches_now_and_leases_long() {
|
||||||
|
let dispatch = interactive_dispatch();
|
||||||
|
assert_eq!(dispatch.debounce_window_millis, 0, "chat is immediate");
|
||||||
|
assert_eq!(dispatch.lease_ttl_millis, 3_600_000, "an hour");
|
||||||
|
assert!(
|
||||||
|
dispatch.max_concurrent_leases >= 16,
|
||||||
|
"generous: turns are long"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
//! The boot-time environment report (`ENVIRONMENT.md`): what the machine
|
||||||
|
//! already knows about itself, written before the first model call so no
|
||||||
|
//! turn is spent rediscovering it (framework spec §1 — detection is
|
||||||
|
//! deterministic code; §9 — context is earned).
|
||||||
|
//!
|
||||||
|
//! Everything here is synchronous, offline, and best-effort. Host facts
|
||||||
|
//! come from `std::env::consts` plus a couple of tiny probes; every
|
||||||
|
//! external command runs on a fixed argv (no shell, nothing
|
||||||
|
//! interpolated) under a short budget whose expiry — like any other
|
||||||
|
//! probe failure — is a line in the report, never a failed boot. The
|
||||||
|
//! caller owns the path (the bootstrap store passes it in; the default
|
||||||
|
//! shape is `<state-dir>/bootstrap/ENVIRONMENT.md`) and rewrites the file
|
||||||
|
//! on every daemon start.
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use rhema_memory::record::format_utc;
|
||||||
|
|
||||||
|
/// The tools the report inventories, in report order. The list is what
|
||||||
|
/// v1's resident workflow reaches for; absence of any entry is ordinary.
|
||||||
|
const TOOLS: &[&str] = &["git", "cargo", "rustc", "docker", "node", "npm", "python3"];
|
||||||
|
|
||||||
|
/// The per-command budget: long enough for a cold `--version`, short
|
||||||
|
/// enough that a hung probe cannot stall a boot past it.
|
||||||
|
const COMMAND_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// How often a running probe is polled for exit.
|
||||||
|
const POLL_STEP: Duration = Duration::from_millis(10);
|
||||||
|
|
||||||
|
/// The report line stands in for absent facts that are not tools.
|
||||||
|
const UNKNOWN: &str = "unknown";
|
||||||
|
|
||||||
|
/// Generates the report and writes it to `path`, creating parent
|
||||||
|
/// directories as needed. Returns the generated text so the caller can
|
||||||
|
/// log or inject it without re-reading the file. The only failures are
|
||||||
|
/// the write's own I/O errors: generation itself cannot fail, because a
|
||||||
|
/// boot that cannot probe its machine must still start.
|
||||||
|
pub fn write_report(path: impl AsRef<Path>) -> Result<String, std::io::Error> {
|
||||||
|
let report = generate_report();
|
||||||
|
if let Some(parent) = path.as_ref().parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::write(path, &report)?;
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The document: host, user, tools, and the generation stamp. Ran at
|
||||||
|
/// daemon startup only, so its cost is bounded by the probe budgets, not
|
||||||
|
/// by anything the model or the network does.
|
||||||
|
fn generate_report() -> String {
|
||||||
|
let mut out = String::from(
|
||||||
|
"# Environment\n\n\
|
||||||
|
Generated at daemon boot; regenerated on every start; hand \
|
||||||
|
edits are overwritten.\n\n\
|
||||||
|
## Host\n\n",
|
||||||
|
);
|
||||||
|
out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
|
||||||
|
out.push_str(&format!("- Arch: {}\n", std::env::consts::ARCH));
|
||||||
|
out.push_str(&format!("- Hostname: {}\n\n", hostname()));
|
||||||
|
out.push_str("## User\n\n");
|
||||||
|
out.push_str(&format!("- User: {}\n", user_name()));
|
||||||
|
out.push_str(&format!("- Shell: {}\n\n", shell()));
|
||||||
|
out.push_str("## Tools\n\n");
|
||||||
|
for line in inventory() {
|
||||||
|
out.push_str(&line);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out.push_str(&format!("\nGenerated at {}.\n", now_utc()));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tool inventory, one line per probed tool in [`TOOLS`] order.
|
||||||
|
/// Probes run concurrently — one thread per tool — so a slow probe costs
|
||||||
|
/// its own budget, not the sum of the budgets.
|
||||||
|
fn inventory() -> Vec<String> {
|
||||||
|
let handles: Vec<_> = TOOLS
|
||||||
|
.iter()
|
||||||
|
.map(|name| {
|
||||||
|
let name = *name;
|
||||||
|
thread::spawn(move || tool_line(name))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
TOOLS
|
||||||
|
.iter()
|
||||||
|
.zip(handles)
|
||||||
|
.map(|(name, handle)| {
|
||||||
|
handle
|
||||||
|
.join()
|
||||||
|
.unwrap_or_else(|_| format!("- {name}: present but unreadable (probe failed)"))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One markdown inventory line for `name`: where it sits and what
|
||||||
|
/// version it reports, or the hard `absent` verdict when no probe can
|
||||||
|
/// place it.
|
||||||
|
fn tool_line(name: &str) -> String {
|
||||||
|
// `which` only names the location; the direct `--version` spawn
|
||||||
|
// below decides presence, so hosts without a `which` binary still
|
||||||
|
// report their tools.
|
||||||
|
let located = match run_within("which", &[name], COMMAND_TIMEOUT) {
|
||||||
|
RunOutcome::Output(path) => {
|
||||||
|
let path = bound(&path);
|
||||||
|
(!path.is_empty()).then_some(path)
|
||||||
|
}
|
||||||
|
RunOutcome::NotFound | RunOutcome::Failed(_) => None,
|
||||||
|
};
|
||||||
|
match run_within(name, &["--version"], COMMAND_TIMEOUT) {
|
||||||
|
RunOutcome::Output(version) => {
|
||||||
|
let version = bound(&version);
|
||||||
|
match (located.as_deref(), version.is_empty()) {
|
||||||
|
(Some(path), false) => format!("- {name}: {path} — {version}"),
|
||||||
|
(Some(path), true) => format!("- {name}: {path}"),
|
||||||
|
(None, false) => format!("- {name}: {version}"),
|
||||||
|
(None, true) => format!("- {name}: present but unreadable (no output)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RunOutcome::NotFound => format!("- {name}: absent"),
|
||||||
|
RunOutcome::Failed(reason) => match located {
|
||||||
|
Some(path) => {
|
||||||
|
let reason = bound(&reason);
|
||||||
|
format!("- {name}: {path} — present but unreadable ({reason})")
|
||||||
|
}
|
||||||
|
None => format!("- {name}: absent"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one external command produced. Every variant renders as a report
|
||||||
|
/// line; none reaches the caller as an error.
|
||||||
|
enum RunOutcome {
|
||||||
|
/// Exited within the budget; captured output, untrimmed.
|
||||||
|
Output(String),
|
||||||
|
/// The executable does not exist.
|
||||||
|
NotFound,
|
||||||
|
/// Exited non-zero or by signal, or outlived the budget.
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `program` with the fixed `args`, allowed `budget` to exit. The
|
||||||
|
/// argv is built only from literals and a tool name — never untrusted
|
||||||
|
/// input, never a shell. Stdin is null so a probe that wrongly reads it
|
||||||
|
/// sees EOF instead of hanging the boot. Pipes are read after the exit:
|
||||||
|
/// these targets are version printers whose output fits any pipe buffer,
|
||||||
|
/// so a post-exit read cannot block on them; anything still running at
|
||||||
|
/// the deadline is killed and reaped.
|
||||||
|
fn run_within(program: &str, args: &[&str], budget: Duration) -> RunOutcome {
|
||||||
|
let mut child = match Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(child) => child,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
return RunOutcome::NotFound;
|
||||||
|
}
|
||||||
|
Err(err) => return RunOutcome::Failed(format!("cannot spawn: {err}")),
|
||||||
|
};
|
||||||
|
let deadline = Instant::now() + budget;
|
||||||
|
let status = loop {
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(status)) => break Some(status),
|
||||||
|
Ok(None) if Instant::now() >= deadline => break None,
|
||||||
|
Ok(None) => thread::sleep(POLL_STEP),
|
||||||
|
Err(err) => return RunOutcome::Failed(format!("cannot wait: {err}")),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(status) = status else {
|
||||||
|
// Over budget: kill and reap so no child outlives the report.
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
return RunOutcome::Failed(format!("no exit within {budget:?}"));
|
||||||
|
};
|
||||||
|
if !status.success() {
|
||||||
|
return RunOutcome::Failed(format!("exit: {status}"));
|
||||||
|
}
|
||||||
|
let stdout = read_capped(child.stdout.take());
|
||||||
|
let stderr = read_capped(child.stderr.take());
|
||||||
|
// Version banners sometimes land on stderr (older python3), so the
|
||||||
|
// empty-stdout case falls back to it.
|
||||||
|
let raw = if stdout.trim().is_empty() {
|
||||||
|
stderr
|
||||||
|
} else {
|
||||||
|
stdout
|
||||||
|
};
|
||||||
|
RunOutcome::Output(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a child pipe up to 16 KiB, lossily UTF-8. The cap bounds the
|
||||||
|
/// read, not the report — [`bound`] cuts again at render time.
|
||||||
|
fn read_capped(pipe: Option<impl Read>) -> String {
|
||||||
|
let Some(mut pipe) = pipe else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
let _ = (&mut pipe).take(16 * 1024).read_to_end(&mut bytes);
|
||||||
|
String::from_utf8_lossy(&bytes).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The report-side bound on captured output: first line only, cut to 200
|
||||||
|
/// characters — chars, not bytes, so the cut never splits a code point.
|
||||||
|
fn bound(raw: &str) -> String {
|
||||||
|
let line = raw.trim().lines().next().unwrap_or("");
|
||||||
|
line.chars().take(200).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hostname: `/etc/hostname`'s first line, else the `hostname`
|
||||||
|
/// command, else a stand-in — the file is not portable and the command
|
||||||
|
/// is not guaranteed, so absence is a report line.
|
||||||
|
fn hostname() -> String {
|
||||||
|
if let Ok(raw) = std::fs::read_to_string("/etc/hostname") {
|
||||||
|
let name = bound(&raw);
|
||||||
|
if !name.is_empty() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match run_within("hostname", &[], COMMAND_TIMEOUT) {
|
||||||
|
RunOutcome::Output(name) => {
|
||||||
|
let name = bound(&name);
|
||||||
|
if name.is_empty() {
|
||||||
|
UNKNOWN.to_owned()
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RunOutcome::NotFound | RunOutcome::Failed(_) => UNKNOWN.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The account the daemon runs as: `whoami`, falling back to `$USER`,
|
||||||
|
/// else a stand-in — a daemon may run with neither available.
|
||||||
|
fn user_name() -> String {
|
||||||
|
if let RunOutcome::Output(name) = run_within("whoami", &[], COMMAND_TIMEOUT) {
|
||||||
|
let name = bound(&name);
|
||||||
|
if !name.is_empty() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::env::var("USER")
|
||||||
|
.ok()
|
||||||
|
.map(|user| bound(&user))
|
||||||
|
.filter(|user| !user.is_empty())
|
||||||
|
.unwrap_or_else(|| UNKNOWN.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The login shell from `$SHELL`; "unset" when the daemon runs without
|
||||||
|
/// one (a service unit, for instance).
|
||||||
|
fn shell() -> String {
|
||||||
|
std::env::var("SHELL")
|
||||||
|
.ok()
|
||||||
|
.map(|shell| bound(&shell))
|
||||||
|
.filter(|shell| !shell.is_empty())
|
||||||
|
.unwrap_or_else(|| "unset".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wall-clock UTC stamp, to the second, rhema-memory's rendering —
|
||||||
|
/// one timestamp shape across the resident tier.
|
||||||
|
fn now_utc() -> String {
|
||||||
|
let millis = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64;
|
||||||
|
format_utc(millis)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The report lands as UTF-8 markdown at the given path (parents
|
||||||
|
/// created), with the fixed sections and one line per inventoried
|
||||||
|
/// tool — whatever is or is not installed on this host — and closes
|
||||||
|
/// on a to-the-second UTC stamp.
|
||||||
|
#[test]
|
||||||
|
fn the_report_writes_back_as_markdown_with_every_section() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("bootstrap").join("ENVIRONMENT.md");
|
||||||
|
let report = write_report(&path).expect("writes");
|
||||||
|
let on_disk = String::from_utf8(std::fs::read(&path).expect("reads back"))
|
||||||
|
.expect("the file is utf-8");
|
||||||
|
assert_eq!(report, on_disk, "the returned text is what landed");
|
||||||
|
assert!(report.starts_with("# Environment\n"), "{report}");
|
||||||
|
assert!(report.contains("regenerated on every start"), "{report}");
|
||||||
|
assert!(report.contains("hand edits are overwritten"), "{report}");
|
||||||
|
for section in ["## Host", "## User", "## Tools"] {
|
||||||
|
assert!(report.contains(section), "missing {section}: {report}");
|
||||||
|
}
|
||||||
|
for tool in TOOLS {
|
||||||
|
assert!(
|
||||||
|
report.contains(&format!("- {tool}: ")),
|
||||||
|
"no line for {tool}: {report}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let footer = report.lines().last().expect("non-empty report");
|
||||||
|
assert_eq!(
|
||||||
|
footer.len(),
|
||||||
|
"Generated at 1970-01-01T00:00:00Z.".len(),
|
||||||
|
"a UTC stamp to the second: {footer}"
|
||||||
|
);
|
||||||
|
assert!(footer.ends_with("Z."), "{footer}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tool that exists nowhere still gets its line, through the same
|
||||||
|
/// runner the real inventory uses: the hard `absent` verdict, never
|
||||||
|
/// an error.
|
||||||
|
#[test]
|
||||||
|
fn a_missing_tool_gets_the_hard_absent_line() {
|
||||||
|
assert_eq!(
|
||||||
|
tool_line("rhema-claw-definitely-absent-probe"),
|
||||||
|
"- rhema-claw-definitely-absent-probe: absent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captured output cannot bloat the report: first line only, cut at
|
||||||
|
/// the cap, multibyte-safe.
|
||||||
|
#[test]
|
||||||
|
fn probe_output_is_bounded() {
|
||||||
|
let huge = format!("{}\n{}", "x".repeat(10 * 200), "second line");
|
||||||
|
let cut = bound(&huge);
|
||||||
|
assert_eq!(cut.chars().count(), 200);
|
||||||
|
assert!(!cut.contains('\n'), "{cut}");
|
||||||
|
let multibyte = "é".repeat(3 * 200);
|
||||||
|
assert_eq!(bound(&multibyte).chars().count(), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A probe that never exits is killed at its budget and reported as
|
||||||
|
/// failed; the report never waits past a budget. Tolerates hosts
|
||||||
|
/// without `sleep`.
|
||||||
|
#[test]
|
||||||
|
fn a_hanging_probe_is_cut_at_its_budget() {
|
||||||
|
match run_within("sleep", &["30"], Duration::from_millis(150)) {
|
||||||
|
RunOutcome::NotFound => {}
|
||||||
|
RunOutcome::Failed(_) => {}
|
||||||
|
RunOutcome::Output(_) => panic!("a 30s sleep outlived a 150ms budget"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
//! The sources: deterministic detection, one [`ForgeSource`] per forge
|
||||||
|
//! kind.
|
||||||
|
//!
|
||||||
|
//! A source owns one forge's watch set: its `detect` pass reads the
|
||||||
|
//! watch tables fresh — [`ClawStore::list`] and [`ClawStore::
|
||||||
|
//! list_account_watches`] — and loops the kind's repos through a
|
||||||
|
//! [`Watcher`] (poll, ingest, advance the per-repo cursor), then the
|
||||||
|
//! kind's account watches through the identity store's credentials:
|
||||||
|
//! poll the account's notifications, [`crate::triage::classify`] each
|
||||||
|
//! one, and either ingest it as a role-tagged thread event, surface it
|
||||||
|
//! to the account's human, or drop it. Neither pass touches the model
|
||||||
|
//! (spec §1). Reading the tables every pass is what makes a watch added
|
||||||
|
//! mid-flight (chat tool, HTTP route) take effect without a restart.
|
||||||
|
//! Repo-watch clients are constructed on first use and cached per
|
||||||
|
//! `(forge, host, token)`; account clients are built fresh every pass,
|
||||||
|
//! so a rotated identity secret is live on the next tick. Cadence
|
||||||
|
//! belongs to the daemon's loop; the source is one pass, nothing more.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex, PoisonError};
|
||||||
|
|
||||||
|
use rhema_autonomous_agent::{Source, SourceError, async_trait};
|
||||||
|
use rhema_forge_integration::forge_key_fn;
|
||||||
|
use rhema_forge_integration::{
|
||||||
|
Forge, GitHubForge, GitLabForge, GiteaForge, Notification, Watcher, register_forge,
|
||||||
|
};
|
||||||
|
use rhema_identity::{IdentityStore, SecretKind};
|
||||||
|
use rhema_rsd::{Clock, Dispatcher, Event, KeyFn, SystemClock};
|
||||||
|
|
||||||
|
use crate::config::{ForgeKind, resolve_token};
|
||||||
|
use crate::triage::{self, Triage};
|
||||||
|
use crate::watch_store::{AccountWatchRecord, ClawStore, WatchRecord};
|
||||||
|
|
||||||
|
/// The cache key of a forge client: everything that changes the client
|
||||||
|
/// is the forge kind, its host, and the token it authenticates with.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
struct ClientKey {
|
||||||
|
forge: String,
|
||||||
|
host: Option<String>,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The forge client one watch RECORD polls through: the kind's
|
||||||
|
/// production base unless the record names a `host`. A blank stored
|
||||||
|
/// token falls back to the forge's environment variable — the same
|
||||||
|
/// resolution a config watch gets — and a blank everywhere constructs a
|
||||||
|
/// client that polls anonymous; the poll fails loudly at the forge, not
|
||||||
|
/// silently here.
|
||||||
|
pub fn record_client(kind: ForgeKind, host: Option<&str>, token: &str) -> Arc<dyn Forge> {
|
||||||
|
let resolved = resolve_token(|name| std::env::var(name), kind, Some(token)).unwrap_or_default();
|
||||||
|
match kind {
|
||||||
|
ForgeKind::GitHub => {
|
||||||
|
let mut forge = GitHubForge::new(resolved);
|
||||||
|
if let Some(host) = host {
|
||||||
|
forge = forge.with_base_url(host.to_owned());
|
||||||
|
}
|
||||||
|
Arc::new(forge)
|
||||||
|
}
|
||||||
|
ForgeKind::Gitea => Arc::new(GiteaForge::new(
|
||||||
|
host.unwrap_or_default().to_owned(),
|
||||||
|
resolved,
|
||||||
|
)),
|
||||||
|
ForgeKind::GitLab => {
|
||||||
|
let mut forge = GitLabForge::new(resolved);
|
||||||
|
if let Some(host) = host {
|
||||||
|
forge = forge.with_base_url(host.to_owned());
|
||||||
|
}
|
||||||
|
Arc::new(forge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One forge kind's detectable input stream: every repo watch and every
|
||||||
|
/// account watch of that kind in the tables, polled into the dispatcher.
|
||||||
|
pub struct ForgeSource {
|
||||||
|
name: String,
|
||||||
|
watcher: Watcher,
|
||||||
|
dispatcher: Arc<Dispatcher>,
|
||||||
|
store: Arc<ClawStore>,
|
||||||
|
/// The account store the account watches authenticate through. A
|
||||||
|
/// secret stays inside this crate's code paths: it reaches the forge
|
||||||
|
/// client's constructor, never a log line or a tool answer.
|
||||||
|
accounts: Arc<dyn IdentityStore>,
|
||||||
|
/// The device-notification queue a [`Triage::Surface`] lands in.
|
||||||
|
notifications: Arc<crate::notifications::NotificationStore>,
|
||||||
|
clients: Mutex<HashMap<ClientKey, Arc<dyn Forge>>>,
|
||||||
|
/// One notifications cursor per account id.
|
||||||
|
account_cursors: Mutex<HashMap<String, rhema_forge_integration::Timestamp>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForgeSource {
|
||||||
|
/// Registers `name`'s key function on the dispatcher and returns the
|
||||||
|
/// source polling the watch table's `name` rows — repo watches and
|
||||||
|
/// account watches — under it. One source per kind, registered once,
|
||||||
|
/// before the first detect.
|
||||||
|
pub fn new(
|
||||||
|
dispatcher: &Arc<Dispatcher>,
|
||||||
|
name: impl Into<String>,
|
||||||
|
store: Arc<ClawStore>,
|
||||||
|
accounts: Arc<dyn IdentityStore>,
|
||||||
|
notifications: Arc<crate::notifications::NotificationStore>,
|
||||||
|
) -> Self {
|
||||||
|
let name = name.into();
|
||||||
|
register_forge(dispatcher, &name);
|
||||||
|
Self {
|
||||||
|
watcher: Watcher::new(dispatcher.clone(), name.clone()),
|
||||||
|
dispatcher: dispatcher.clone(),
|
||||||
|
name,
|
||||||
|
store,
|
||||||
|
accounts,
|
||||||
|
notifications,
|
||||||
|
clients: Mutex::new(HashMap::new()),
|
||||||
|
account_cursors: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The client for `record`: from the cache when a watch with the
|
||||||
|
/// same `(forge, host, token)` polled before, constructed otherwise.
|
||||||
|
fn client_for(&self, record: &WatchRecord) -> Arc<dyn Forge> {
|
||||||
|
let key = ClientKey {
|
||||||
|
forge: record.forge.clone(),
|
||||||
|
host: record.host.clone(),
|
||||||
|
token: record.token.clone(),
|
||||||
|
};
|
||||||
|
let mut clients = self.clients.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
clients
|
||||||
|
.entry(key)
|
||||||
|
.or_insert_with(|| {
|
||||||
|
let Some(kind) = ForgeKind::parse(&record.forge) else {
|
||||||
|
// Unreachable through the daemon's assembly, which
|
||||||
|
// only creates sources for known kinds; an unknown
|
||||||
|
// kind in the table would be skipped in `detect`
|
||||||
|
// before reaching here.
|
||||||
|
unreachable!("detect filters unknown forge kinds before client lookup")
|
||||||
|
};
|
||||||
|
record_client(kind, record.host.as_deref(), &record.token)
|
||||||
|
})
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Source for ForgeSource {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_fn(&self) -> Arc<KeyFn> {
|
||||||
|
forge_key_fn(&self.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn detect(&self) -> Result<usize, SourceError> {
|
||||||
|
let records = self
|
||||||
|
.store
|
||||||
|
.list()
|
||||||
|
.map_err(|err| SourceError::Backend(format!("watch store: {err}")))?;
|
||||||
|
let mut fed = 0;
|
||||||
|
for record in records {
|
||||||
|
if record.forge != self.name {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let repo = match record.repo_ref() {
|
||||||
|
Ok(repo) => repo,
|
||||||
|
Err(reason) => {
|
||||||
|
// A malformed row must not starve the kind's other
|
||||||
|
// watches; say so and move on. New rows cannot be
|
||||||
|
// malformed (every entry validates), so this guards
|
||||||
|
// only hand-edited tables.
|
||||||
|
eprintln!(
|
||||||
|
"[claw] warning: skipping watch {} {}: {reason}",
|
||||||
|
record.forge, record.repo
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let client = self.client_for(&record);
|
||||||
|
match self.watcher.poll(client.as_ref(), &repo).await {
|
||||||
|
Ok(count) => fed += count,
|
||||||
|
// One dead watch (a lapsed token, a forge outage) must
|
||||||
|
// not starve the kind's other watches either.
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"[claw] warning: watch {} {}: poll failed: {err}",
|
||||||
|
record.forge, record.repo
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The account watches: the same table-rules-every-pass doctrine,
|
||||||
|
// with the identity store supplying each poll's credential.
|
||||||
|
let accounts = self
|
||||||
|
.store
|
||||||
|
.list_account_watches()
|
||||||
|
.map_err(|err| SourceError::Backend(format!("watch store: {err}")))?;
|
||||||
|
for watch in accounts {
|
||||||
|
if watch.forge != self.name {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match self.poll_account(&watch).await {
|
||||||
|
Ok(count) => fed += count,
|
||||||
|
// One dead account (a revoked token, a forge outage)
|
||||||
|
// must not starve the kind's other watches either.
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"[claw] warning: account watch {} {}: poll failed: {err}",
|
||||||
|
watch.forge, watch.account_id
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(fed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForgeSource {
|
||||||
|
/// One account-watch poll: fetch the account's notifications as the
|
||||||
|
/// account, triage each, and land the verdicts — agent dispatches
|
||||||
|
/// ingest as role-tagged thread events, surfaces hit the
|
||||||
|
/// device-notification queue, ignores drop. Returns how many events
|
||||||
|
/// were fed to the dispatcher.
|
||||||
|
async fn poll_account(&self, watch: &AccountWatchRecord) -> Result<usize, SourceError> {
|
||||||
|
let Some(kind) = ForgeKind::parse(&watch.forge) else {
|
||||||
|
// Unreachable through the daemon's assembly, which only
|
||||||
|
// creates sources for known kinds; an unknown kind in the
|
||||||
|
// table would be skipped in `detect` before reaching here.
|
||||||
|
unreachable!("detect filters unknown forge kinds before account polls")
|
||||||
|
};
|
||||||
|
let secret = self
|
||||||
|
.accounts
|
||||||
|
.secret(&watch.account_id)
|
||||||
|
.map_err(|err| SourceError::Backend(format!("account store: {err}")))?;
|
||||||
|
let Some(secret) = secret else {
|
||||||
|
// The account is gone; its watch's cascade should have gone
|
||||||
|
// with it. Skip quietly — `identity_forget` removes both,
|
||||||
|
// and a hand-edited pair self-heals on the next forget.
|
||||||
|
return Ok(0);
|
||||||
|
};
|
||||||
|
if secret.kind != SecretKind::Token {
|
||||||
|
// A stored password is not a poll credential: nothing to
|
||||||
|
// authenticate the notifications call with. Say so and move
|
||||||
|
// on — the watch stays, a token rotation (or a future login
|
||||||
|
// action) revives it.
|
||||||
|
eprintln!(
|
||||||
|
"[claw] warning: account watch {} {}: the account holds a password, not a token; skipping the poll",
|
||||||
|
watch.forge, watch.account_id
|
||||||
|
);
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
// Fresh client every pass: a rotated secret is live on the next
|
||||||
|
// tick, and one client build per account per pass is nothing
|
||||||
|
// next to the poll itself.
|
||||||
|
let client = record_client(kind, watch.host.as_deref(), &secret.value);
|
||||||
|
let since = self
|
||||||
|
.account_cursors
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(PoisonError::into_inner)
|
||||||
|
.get(&watch.account_id)
|
||||||
|
.cloned();
|
||||||
|
let page = client
|
||||||
|
.notifications(since.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|err| SourceError::Backend(format!("notifications: {err}")))?;
|
||||||
|
let now = SystemClock.now_millis();
|
||||||
|
let mut fed = 0;
|
||||||
|
for notification in &page.notifications {
|
||||||
|
match triage::classify(notification) {
|
||||||
|
Triage::Ignore => {}
|
||||||
|
Triage::Surface => {
|
||||||
|
if let Err(err) = self.notifications.push(
|
||||||
|
crate::notifications::DEFAULT_DEVICE,
|
||||||
|
&format!(
|
||||||
|
"{}: {}#{}",
|
||||||
|
watch.forge,
|
||||||
|
notification.repo.path(),
|
||||||
|
notification.number
|
||||||
|
),
|
||||||
|
&surface_body(notification),
|
||||||
|
) {
|
||||||
|
eprintln!(
|
||||||
|
"[claw] warning: surface of {} failed: {err}",
|
||||||
|
notification.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Triage::Agent(role) => {
|
||||||
|
// The key function reads `repo` (as the
|
||||||
|
// `{owner, name}` row RepoRef serializes to) and
|
||||||
|
// `number` off the payload's top level, so the
|
||||||
|
// thread key is the same `forge:owner/repo#n` shape
|
||||||
|
// the repo watches use — one session per thread,
|
||||||
|
// whatever fed it.
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"repo": notification.repo,
|
||||||
|
"number": notification.number,
|
||||||
|
"role": role.as_str(),
|
||||||
|
"notification": notification,
|
||||||
|
});
|
||||||
|
self.dispatcher
|
||||||
|
.ingest(Event {
|
||||||
|
id: notification.id.clone(),
|
||||||
|
source: self.name.clone(),
|
||||||
|
payload,
|
||||||
|
received_at: now,
|
||||||
|
})
|
||||||
|
.map_err(SourceError::Dispatch)?;
|
||||||
|
fed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(cursor) = page.cursor {
|
||||||
|
self.account_cursors
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(PoisonError::into_inner)
|
||||||
|
.insert(watch.account_id.clone(), cursor);
|
||||||
|
}
|
||||||
|
Ok(fed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The device-notification text a [`Triage::Surface`] lands: the reason
|
||||||
|
/// and the thread title, so the human can judge without opening the
|
||||||
|
/// forge.
|
||||||
|
fn surface_body(notification: &Notification) -> String {
|
||||||
|
let title = notification.title.as_deref().unwrap_or("(untitled)");
|
||||||
|
format!(
|
||||||
|
"{} on {}#{}: {}",
|
||||||
|
notification.reason.as_str(),
|
||||||
|
notification.repo.path(),
|
||||||
|
notification.number,
|
||||||
|
title
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The parts a test source polls through, all in memory.
|
||||||
|
fn test_parts(
|
||||||
|
store: Arc<ClawStore>,
|
||||||
|
) -> (
|
||||||
|
Arc<Dispatcher>,
|
||||||
|
Arc<dyn IdentityStore>,
|
||||||
|
Arc<crate::notifications::NotificationStore>,
|
||||||
|
) {
|
||||||
|
let _ = store;
|
||||||
|
let dispatcher = Arc::new(Dispatcher::new(
|
||||||
|
rhema_rsd::DispatcherConfig::default(),
|
||||||
|
Arc::new(rhema_rsd::SystemClock),
|
||||||
|
Arc::new(rhema_rsd::VolatileStore::new()),
|
||||||
|
));
|
||||||
|
let accounts: Arc<dyn IdentityStore> =
|
||||||
|
Arc::new(rhema_identity::VolatileIdentityStore::new());
|
||||||
|
let notifications = Arc::new(
|
||||||
|
crate::notifications::NotificationStore::in_memory().expect("notification store opens"),
|
||||||
|
);
|
||||||
|
(dispatcher, accounts, notifications)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The client cache pays off when two watches share their
|
||||||
|
/// coordinates: one client, not two.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn same_coordinates_share_one_cached_client() {
|
||||||
|
let store = Arc::new(ClawStore::in_memory().expect("store opens"));
|
||||||
|
store
|
||||||
|
.add("github", "acme/widgets", None, "tok")
|
||||||
|
.expect("add");
|
||||||
|
store
|
||||||
|
.add("github", "acme/gadgets", None, "tok")
|
||||||
|
.expect("add");
|
||||||
|
store
|
||||||
|
.add("github", "acme/private", None, "other")
|
||||||
|
.expect("add");
|
||||||
|
let (dispatcher, accounts, notifications) = test_parts(store.clone());
|
||||||
|
let source = ForgeSource::new(&dispatcher, "github", store, accounts, notifications);
|
||||||
|
|
||||||
|
let records = source.store.list().expect("list reads");
|
||||||
|
let a = source.client_for(&records[0]);
|
||||||
|
let b = source.client_for(&records[1]);
|
||||||
|
let c = source.client_for(&records[2]);
|
||||||
|
assert!(Arc::ptr_eq(&a, &b), "same (forge, host, token): one client");
|
||||||
|
assert!(
|
||||||
|
!Arc::ptr_eq(&a, &c),
|
||||||
|
"a different token is a different client"
|
||||||
|
);
|
||||||
|
assert_eq!(source.clients.lock().unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One triaged notification ingest: the payload carries `repo` and
|
||||||
|
/// `number` at the top level, so the registered key function folds
|
||||||
|
/// it onto the same thread key a repo-watch event takes.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_triaged_notification_lands_on_the_thread_key() {
|
||||||
|
use rhema_forge_integration::{NotificationReason, RepoRef, Timestamp};
|
||||||
|
|
||||||
|
let store = Arc::new(ClawStore::in_memory().expect("store opens"));
|
||||||
|
let (_dispatcher, accounts, _notifications) = test_parts(store.clone());
|
||||||
|
let account = rhema_identity::AccountDraft::new(
|
||||||
|
"acme bot",
|
||||||
|
"github",
|
||||||
|
rhema_identity::Secret::new(rhema_identity::SecretKind::Token, "tok"),
|
||||||
|
);
|
||||||
|
let account = accounts.put(account).expect("account lands");
|
||||||
|
store
|
||||||
|
.add_account_watch("github", None, &account.id)
|
||||||
|
.expect("watch lands");
|
||||||
|
|
||||||
|
// The key function must fold the triaged payload onto the thread
|
||||||
|
// key: build a payload the way `poll_account` does and run it
|
||||||
|
// through the registered `forge_key_fn`.
|
||||||
|
let notification = Notification {
|
||||||
|
id: "github:notification:7@2026-10-08T00:00:00Z".to_owned(),
|
||||||
|
reason: NotificationReason::ReviewRequested,
|
||||||
|
repo: RepoRef::new("acme", "widgets"),
|
||||||
|
number: 142,
|
||||||
|
is_pr: true,
|
||||||
|
actor: None,
|
||||||
|
title: Some("Add the flange".to_owned()),
|
||||||
|
url: None,
|
||||||
|
timestamp: Timestamp::parse("2026-10-08T00:00:00Z").expect("parses"),
|
||||||
|
raw: serde_json::json!({}),
|
||||||
|
};
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"repo": notification.repo,
|
||||||
|
"number": notification.number,
|
||||||
|
"role": "reviewer",
|
||||||
|
"notification": notification,
|
||||||
|
});
|
||||||
|
let key_fn = forge_key_fn("github");
|
||||||
|
let key = key_fn(&Event {
|
||||||
|
id: notification.id.clone(),
|
||||||
|
source: "github".to_owned(),
|
||||||
|
payload,
|
||||||
|
received_at: 0,
|
||||||
|
});
|
||||||
|
assert_eq!(key.as_str(), "github:acme/widgets#142");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,805 @@
|
|||||||
|
//! The handler: one dispatch of a forge thread becomes one coding-agent
|
||||||
|
//! turn, and the turn's final text becomes the thread's comment reply.
|
||||||
|
//!
|
||||||
|
//! The deterministic part stays ahead of the model (spec §1, §8): the
|
||||||
|
//! watch's forge client is looked up by key, the snapshot is fetched, the
|
||||||
|
//! inbox is drained and decoded, the allowlist is resolved — v1 is
|
||||||
|
//! permissive, the spec's starting point, pending a real
|
||||||
|
//! profile-to-permissions mapping — and only then does the agent run.
|
||||||
|
//! One turn per dispatch; the final assistant text posts verbatim.
|
||||||
|
//!
|
||||||
|
//! Session continuity rides the dispatcher, not the filesystem: the
|
||||||
|
//! agent's session JSON is stashed into `ctx.session.core.driver_state`
|
||||||
|
//! before the run returns and restored with [`CodingAgent::from_session`]
|
||||||
|
//! on the next dispatch of the same key, so a resumed thread keeps its
|
||||||
|
//! conversation. Commit-before-execute rides it too: the run installs the
|
||||||
|
//! context's commit hook, so the intent is in the store before the first
|
||||||
|
//! tool runs, and a crash-left dangling call revives at turn entry.
|
||||||
|
//! Delivery is at-least-once: a re-delivered drained event
|
||||||
|
//! re-dispatches, and this handler treats a dispatch as work to do — the
|
||||||
|
//! dedup boundary is the dispatcher's, not re-derived here.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex, PoisonError};
|
||||||
|
|
||||||
|
use crate::config::ForgeKind;
|
||||||
|
use crate::forge::record_client;
|
||||||
|
use crate::triage::Role;
|
||||||
|
use crate::watch_store::ClawStore;
|
||||||
|
use rhema_agent_tools::AllowAll;
|
||||||
|
use rhema_autonomous_agent::{Handler, SessionContext, async_trait};
|
||||||
|
use rhema_coding_agent::{CodingAgent, CodingAgentConfig};
|
||||||
|
use rhema_core::{Content, Event as ModelEvent, EventSink, Message, Session};
|
||||||
|
use rhema_forge_integration::{
|
||||||
|
EventKind, Forge, ForgeError, ForgeEvent, Notification, RepoRef, Snapshot, ThreadState, decode,
|
||||||
|
parse_key,
|
||||||
|
};
|
||||||
|
use rhema_rsd::{InboxItem, Outcome, ResourceKey};
|
||||||
|
|
||||||
|
use crate::backends::BackendRegistry;
|
||||||
|
|
||||||
|
/// The handler's tool permissions, resolved deterministically before the
|
||||||
|
/// agent's first step (spec §8) — never by the model, never mid-run. V1
|
||||||
|
/// is permissive, the spec's starting point; a profile-to-permissions
|
||||||
|
/// mapping lands here as a per-deployment policy — the triage's
|
||||||
|
/// [`Role`] is the seam it hangs on, a reviewer dispatch deserving a
|
||||||
|
/// narrower policy than an implementer's — and a policy that refuses the
|
||||||
|
/// shell refuses the run (see [`ClawHandler::dispatch`]).
|
||||||
|
fn allowlist() -> rhema_autonomous_agent::Allowlist {
|
||||||
|
rhema_autonomous_agent::Allowlist::permissive()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The coding agent over one watch: a provider, a model, a workspace
|
||||||
|
/// root, and the watch table the dispatch keys resolve through — live,
|
||||||
|
/// so a watch added at runtime is answerable without a restart.
|
||||||
|
pub struct ClawHandler {
|
||||||
|
/// The deployment's providers and the active pair, read per dispatch.
|
||||||
|
backends: Arc<BackendRegistry>,
|
||||||
|
workspace_root: PathBuf,
|
||||||
|
watches: Arc<ClawStore>,
|
||||||
|
clients: Mutex<HashMap<String, Arc<dyn Forge>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClawHandler {
|
||||||
|
/// A handler whose per-thread workspaces live under `workspace_root`
|
||||||
|
/// and whose dispatch keys resolve through `watches` at dispatch time.
|
||||||
|
pub fn new(
|
||||||
|
backends: Arc<BackendRegistry>,
|
||||||
|
workspace_root: impl Into<PathBuf>,
|
||||||
|
watches: Arc<ClawStore>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
backends,
|
||||||
|
workspace_root: workspace_root.into(),
|
||||||
|
watches,
|
||||||
|
clients: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The forge client a dispatch key answers to: `"github:acme/widgets#7"`
|
||||||
|
/// resolves its watch record from the table at dispatch time — the
|
||||||
|
/// table is the source of truth, not a boot-time snapshot — and the
|
||||||
|
/// client is cached per `(forge, host, token)` so one poll pool serves
|
||||||
|
/// every thread of a watch.
|
||||||
|
pub fn watch_forge(&self, key: &ResourceKey) -> Result<Arc<dyn Forge>, String> {
|
||||||
|
let watch = watch_key(key)
|
||||||
|
.ok_or_else(|| format!("resource key {key} is not a forge thread key"))?;
|
||||||
|
let (kind, repo) = watch
|
||||||
|
.split_once(':')
|
||||||
|
.ok_or_else(|| format!("watch entry {watch} has no forge kind"))?;
|
||||||
|
let record = self
|
||||||
|
.watches
|
||||||
|
.list()
|
||||||
|
.map_err(|err| format!("watch lookup for {key} failed: {err}"))?
|
||||||
|
.into_iter()
|
||||||
|
.find(|record| record.forge == kind && record.repo == repo)
|
||||||
|
.ok_or_else(|| format!("no watch configured for {key}"))?;
|
||||||
|
let kind = ForgeKind::parse(&record.forge)
|
||||||
|
.ok_or_else(|| format!("watch entry {watch} names an unknown forge"))?;
|
||||||
|
let cache_key = format!("{}|{:?}|{}", record.forge, record.host, record.token);
|
||||||
|
let mut clients = self.clients.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
Ok(clients
|
||||||
|
.entry(cache_key)
|
||||||
|
.or_insert_with(|| record_client(kind, record.host.as_deref(), &record.token))
|
||||||
|
.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One dispatch, failed with a reason on any step. The session stash
|
||||||
|
/// lands before the comment posts, so a failed post still leaves the
|
||||||
|
/// conversation resumable.
|
||||||
|
async fn dispatch(&self, ctx: &mut SessionContext) -> Result<Outcome, String> {
|
||||||
|
// Owned: the key names the run through every step, while `ctx`
|
||||||
|
// itself goes mutable for the inbox drain.
|
||||||
|
let key = ctx.key.as_str().to_owned();
|
||||||
|
let (repo, number) = parse_key(&ctx.key)
|
||||||
|
.ok_or_else(|| format!("resource key {key} is not a forge thread key"))?;
|
||||||
|
let forge = self.watch_forge(&ctx.key)?;
|
||||||
|
|
||||||
|
let snapshot = forge
|
||||||
|
.snapshot(&repo, number)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("snapshot of {key} failed: {err}"))?;
|
||||||
|
let news = self.drain_news(ctx, &key).await?;
|
||||||
|
let role = dominant_role(&news);
|
||||||
|
|
||||||
|
// The permission check is load-bearing: this handler's one tool
|
||||||
|
// is the agent's shell, so a policy without it is a run that
|
||||||
|
// could only flail — fail it loudly instead.
|
||||||
|
let permissions = allowlist();
|
||||||
|
if !permissions.allows("bash") {
|
||||||
|
return Err(format!(
|
||||||
|
"tool policy for {key} does not permit the shell; nothing to run"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let workspace = self.workspace_for(&key, &repo, number)?;
|
||||||
|
let mut agent = self.resume(ctx, &workspace, &key)?;
|
||||||
|
// Commit-before-execute: the intent is in the store before the
|
||||||
|
// first tool runs; a lease that cannot be committed under fails
|
||||||
|
// the step before any side effect.
|
||||||
|
agent.set_commit_hook(Some(ctx.commit_hook()));
|
||||||
|
|
||||||
|
let prompt = build_prompt(&key, &snapshot, &news, workspace.as_path(), role);
|
||||||
|
let mut sink = TurnSink;
|
||||||
|
let output = agent
|
||||||
|
.turn(prompt, &mut sink, Arc::new(AllowAll))
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("turn for {key} failed: {err}"))?;
|
||||||
|
|
||||||
|
// Stash before posting: the turn's work survives a failed comment.
|
||||||
|
ctx.session.core.driver_state = serde_json::to_value(agent.session())
|
||||||
|
.map_err(|err| format!("session for {key} does not serialize: {err}"))?;
|
||||||
|
|
||||||
|
let reply = final_text(&output.message)
|
||||||
|
.ok_or_else(|| format!("the model returned no text to post for {key}"))?;
|
||||||
|
forge
|
||||||
|
.comment(&repo, number, &reply)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("comment on {key} failed: {err}"))?;
|
||||||
|
Ok(Outcome::Handled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renews the lease and drains the inbox, decoding every item into
|
||||||
|
/// the news it carries: a repo-watch forge event, or a triaged
|
||||||
|
/// account notification with its role. A payload that decodes to
|
||||||
|
/// neither is loud: a shape break must surface, not silently shrink
|
||||||
|
/// the prompt.
|
||||||
|
async fn drain_news(
|
||||||
|
&self,
|
||||||
|
ctx: &mut SessionContext,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<Vec<InboxNews>, String> {
|
||||||
|
let items = ctx
|
||||||
|
.poll_inbox()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("inbox poll for {key} failed: {err}"))?;
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(decode_news)
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|err| format!("an inbox item for {key} does not decode: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The agent for this dispatch: the stashed session resumed when the
|
||||||
|
/// key has history, a fresh one otherwise. A stash that does not parse
|
||||||
|
/// fails the run loudly — silently resetting would re-orient the agent
|
||||||
|
/// mid-conversation without anyone knowing.
|
||||||
|
fn resume(
|
||||||
|
&self,
|
||||||
|
ctx: &SessionContext,
|
||||||
|
workspace: &Path,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<CodingAgent, String> {
|
||||||
|
let (provider, model) = self.backends.current();
|
||||||
|
let config = CodingAgentConfig::new(model, workspace);
|
||||||
|
let agent = match &ctx.session.core.driver_state {
|
||||||
|
// A never-dispatched key carries no stash: start clean.
|
||||||
|
serde_json::Value::Null => CodingAgent::new(provider, config),
|
||||||
|
stashed => {
|
||||||
|
let session: Session = serde_json::from_value(stashed.clone())
|
||||||
|
.map_err(|err| format!("stashed session for {key} does not parse: {err}"))?;
|
||||||
|
CodingAgent::from_session(provider, config, session)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
agent.map_err(|err| format!("cannot start the coding agent for {key}: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-thread workspace: one directory under the root, created on
|
||||||
|
/// demand. The name mapping is not injective (separators collapse);
|
||||||
|
/// thread identity lives in the dispatch key — the directory is only
|
||||||
|
/// scratch space for the agent's tools.
|
||||||
|
fn workspace_for(&self, key: &str, repo: &RepoRef, number: u64) -> Result<PathBuf, String> {
|
||||||
|
let dir = self.workspace_root.join(workspace_name(key, repo, number));
|
||||||
|
std::fs::create_dir_all(&dir)
|
||||||
|
.map_err(|err| format!("cannot create the workspace {dir:?} for {key}: {err}"))?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Handler for ClawHandler {
|
||||||
|
async fn handle(&self, ctx: &mut SessionContext) -> Outcome {
|
||||||
|
match self.dispatch(ctx).await {
|
||||||
|
Ok(outcome) => outcome,
|
||||||
|
Err(reason) => Outcome::Failed(reason),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The watch-table key of a resource key: `"github:acme/widgets#7"` builds
|
||||||
|
/// `"github:acme/widgets"`. `None` for a key that is not a forge thread
|
||||||
|
/// key at all.
|
||||||
|
pub fn watch_key(key: &ResourceKey) -> Option<String> {
|
||||||
|
let (head, _) = key.as_str().rsplit_once('#')?;
|
||||||
|
head.contains(':').then(|| head.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The watch-table entry for a repo: `{kind}:{owner}/{name}` — the value
|
||||||
|
/// [`watch_key`] parses back out of a dispatch key. The pair is the
|
||||||
|
/// key-parse-to-watch-lookup seam.
|
||||||
|
pub fn watch_entry(kind: &str, repo: &RepoRef) -> String {
|
||||||
|
format!("{kind}:{}", repo.path())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The workspace directory name for a thread: kind, repo path, and number
|
||||||
|
/// flattened to filesystem-safe characters.
|
||||||
|
pub fn workspace_name(key: &str, repo: &RepoRef, number: u64) -> String {
|
||||||
|
let kind = key.split_once(':').map(|(kind, _)| kind).unwrap_or("forge");
|
||||||
|
format!("{kind}-{}-{number}", repo.path())
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '-' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One drained inbox item: a plain repo-watch forge event, or a triaged
|
||||||
|
/// account notification carrying the role the triage assigned it.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum InboxNews {
|
||||||
|
Event(ForgeEvent),
|
||||||
|
Notified {
|
||||||
|
role: Role,
|
||||||
|
notification: Notification,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The triaged payload [`crate::forge::ForgeSource`] ingests: `repo` and
|
||||||
|
/// `number` ride at the top level for the key function, the rest is the
|
||||||
|
/// notification and the role its triage earned.
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct TriagedPayload {
|
||||||
|
role: Role,
|
||||||
|
notification: Notification,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one inbox item into its news. A triaged notification is tried
|
||||||
|
/// first — a plain forge event has none of its fields — and the plain
|
||||||
|
/// event second; anything else is a loud parse error.
|
||||||
|
fn decode_news(item: &InboxItem) -> Result<InboxNews, ForgeError> {
|
||||||
|
if let Ok(triaged) = serde_json::from_value::<TriagedPayload>(item.event.payload.clone()) {
|
||||||
|
return Ok(InboxNews::Notified {
|
||||||
|
role: triaged.role,
|
||||||
|
notification: triaged.notification,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
decode(item).map(InboxNews::Event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The batch's dominant role, for the prompt's persona framing:
|
||||||
|
/// implementing subsumes reviewing — a batch that asks for both is a
|
||||||
|
/// dispatch where the work is the point. Plain events imply no persona:
|
||||||
|
/// the default framing runs.
|
||||||
|
fn dominant_role(news: &[InboxNews]) -> Option<Role> {
|
||||||
|
if news.iter().any(|news| {
|
||||||
|
matches!(
|
||||||
|
news,
|
||||||
|
InboxNews::Notified {
|
||||||
|
role: Role::Implementer,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
Some(Role::Implementer)
|
||||||
|
} else if news.iter().any(|news| {
|
||||||
|
matches!(
|
||||||
|
news,
|
||||||
|
InboxNews::Notified {
|
||||||
|
role: Role::Reviewer,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
Some(Role::Reviewer)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persona framing a dispatched role opens the prompt with. The key
|
||||||
|
/// stays the thread, so a role change across dispatches is the session's
|
||||||
|
/// continuity, not a new conversation.
|
||||||
|
fn persona_text(role: Role) -> &'static str {
|
||||||
|
match role {
|
||||||
|
Role::Reviewer => {
|
||||||
|
"You are acting as this pull request's reviewer. Check the change \
|
||||||
|
out in the workspace when you can, read the diff, and judge it: correctness, \
|
||||||
|
tests, edge cases, fit with the surrounding code. Your final reply is the \
|
||||||
|
review — findings first, ordered by severity, concrete and actionable; if \
|
||||||
|
there is nothing to fix, say so plainly and say what you checked."
|
||||||
|
}
|
||||||
|
Role::Implementer => {
|
||||||
|
"You are acting as this thread's implementer. Do the work the new \
|
||||||
|
activity asks for: address the review feedback or the assigned issue by \
|
||||||
|
changing the code in the workspace — build and test what you change. Your \
|
||||||
|
final reply is a short report of what you changed and why, or, when the \
|
||||||
|
whole change does not fit one turn, what landed now and the concrete plan \
|
||||||
|
for the rest."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The agent's prompt for one dispatch: the persona framing (when the
|
||||||
|
/// triage dispatched a role), the thread's standing state, the new news,
|
||||||
|
/// and the posting contract. The final assistant message is the comment
|
||||||
|
/// — everything else is orientation for the investigation.
|
||||||
|
fn build_prompt(
|
||||||
|
key: &str,
|
||||||
|
snapshot: &Snapshot,
|
||||||
|
news: &[InboxNews],
|
||||||
|
workspace: &Path,
|
||||||
|
role: Option<Role>,
|
||||||
|
) -> String {
|
||||||
|
let mut prompt = String::new();
|
||||||
|
if let Some(role) = role {
|
||||||
|
prompt.push_str(persona_text(role));
|
||||||
|
prompt.push_str("\n\n");
|
||||||
|
}
|
||||||
|
prompt.push_str(&format!(
|
||||||
|
"You are responding to new activity on forge thread {key}.\n\n\
|
||||||
|
Thread: {}\nKind: {}; state: {}\nOpened by: {}\nLabels: {}\nOpening body:\n{}\n\n",
|
||||||
|
snapshot.title,
|
||||||
|
if snapshot.is_pr {
|
||||||
|
"pull request"
|
||||||
|
} else {
|
||||||
|
"issue"
|
||||||
|
},
|
||||||
|
state_text(snapshot.state),
|
||||||
|
snapshot.author.as_deref().unwrap_or("unknown"),
|
||||||
|
if snapshot.labels.is_empty() {
|
||||||
|
"none".to_owned()
|
||||||
|
} else {
|
||||||
|
snapshot.labels.join(", ")
|
||||||
|
},
|
||||||
|
snapshot
|
||||||
|
.body
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|body| !body.is_empty())
|
||||||
|
.unwrap_or("(none)"),
|
||||||
|
));
|
||||||
|
prompt.push_str("New activity to respond to:\n");
|
||||||
|
for news in news {
|
||||||
|
match news {
|
||||||
|
InboxNews::Event(event) => prompt.push_str(&format!(
|
||||||
|
"- {} by {}: {}\n",
|
||||||
|
kind_text(event.kind),
|
||||||
|
event.actor.as_deref().unwrap_or("unknown"),
|
||||||
|
event
|
||||||
|
.body
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|body| !body.is_empty())
|
||||||
|
.unwrap_or("(no text)"),
|
||||||
|
)),
|
||||||
|
InboxNews::Notified { notification, .. } => prompt.push_str(&format!(
|
||||||
|
"- notification ({}) on {} #{} by {}: {}\n",
|
||||||
|
notification.reason.as_str(),
|
||||||
|
thread_kind(notification.is_pr),
|
||||||
|
notification.number,
|
||||||
|
notification.actor.as_deref().unwrap_or("unknown"),
|
||||||
|
notification
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|title| !title.is_empty())
|
||||||
|
.unwrap_or("(no title)"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prompt.push_str(&format!(
|
||||||
|
"\nThe workspace is at {}: investigate there with your shell tools (read\n\
|
||||||
|
code, run tests, reproduce reports) whenever the text alone does not\n\
|
||||||
|
settle it. Your FINAL reply — the message you end the turn on, with\n\
|
||||||
|
no tool calls after it — is posted to the thread verbatim: make it\n\
|
||||||
|
the exact comment text for the thread's participants, with no\n\
|
||||||
|
meta-commentary about this prompt.\n",
|
||||||
|
workspace.display(),
|
||||||
|
));
|
||||||
|
prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "pull request" or "issue", for the notification lines.
|
||||||
|
fn thread_kind(is_pr: bool) -> &'static str {
|
||||||
|
if is_pr { "pull request" } else { "issue" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The thread state, in words.
|
||||||
|
fn state_text(state: ThreadState) -> &'static str {
|
||||||
|
match state {
|
||||||
|
ThreadState::Open => "open",
|
||||||
|
ThreadState::Closed => "closed",
|
||||||
|
ThreadState::Merged => "merged",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The event kind, in words.
|
||||||
|
fn kind_text(kind: EventKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
EventKind::Opened => "opened",
|
||||||
|
EventKind::Reopened => "reopened",
|
||||||
|
EventKind::Closed => "closed",
|
||||||
|
EventKind::Merged => "merged",
|
||||||
|
EventKind::Comment => "comment",
|
||||||
|
EventKind::Review => "review",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text a turn ends on: the assistant message's text parts, joined
|
||||||
|
/// and trimmed. `None` when the model stopped on tool calls and never
|
||||||
|
/// wrote a reply.
|
||||||
|
pub(crate) fn final_text(message: &Message) -> Option<String> {
|
||||||
|
let joined = message
|
||||||
|
.content
|
||||||
|
.iter()
|
||||||
|
.filter_map(|content| match content {
|
||||||
|
Content::Text(text) => Some(text.as_str()),
|
||||||
|
Content::ToolCall(_) | Content::ToolResult(_) => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
let trimmed = joined.trim();
|
||||||
|
(!trimmed.is_empty()).then(|| trimmed.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The turn's stderr log: tool traffic only. The reply is not streamed —
|
||||||
|
/// it is posted to the thread, and a daemon log line per tool call is the
|
||||||
|
/// observability a resident run gets.
|
||||||
|
struct TurnSink;
|
||||||
|
|
||||||
|
impl EventSink for TurnSink {
|
||||||
|
fn emit(&mut self, event: ModelEvent<'_>) {
|
||||||
|
match event {
|
||||||
|
ModelEvent::ToolCallStart { name, .. } => eprintln!("[claw] tool call: {name}"),
|
||||||
|
ModelEvent::ToolResult {
|
||||||
|
content, is_error, ..
|
||||||
|
} => {
|
||||||
|
let mark = if is_error { "failed" } else { "ok" };
|
||||||
|
eprintln!("[claw] tool {mark}: {}", first_line(content));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first line of a tool result, cut for the stderr stream.
|
||||||
|
fn first_line(content: &str) -> String {
|
||||||
|
let line = content.lines().next().unwrap_or("");
|
||||||
|
let mut cut = line.chars().take(72).collect::<String>();
|
||||||
|
if line.chars().count() > 72 {
|
||||||
|
cut.push('…');
|
||||||
|
}
|
||||||
|
cut
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rhema_core::ModelProvider;
|
||||||
|
use rhema_forge_integration::NotificationReason;
|
||||||
|
|
||||||
|
fn snapshot() -> Snapshot {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"title": "crash on save",
|
||||||
|
"body": "steps to reproduce…",
|
||||||
|
"state": "open",
|
||||||
|
"is_pr": false,
|
||||||
|
"author": "alice",
|
||||||
|
"labels": ["bug", "cli"],
|
||||||
|
"url": "https://github.com/acme/widgets/issues/7",
|
||||||
|
"raw": {},
|
||||||
|
}))
|
||||||
|
.expect("snapshot fixture parses")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event(kind: EventKind, actor: &str, body: Option<&str>) -> ForgeEvent {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "github:comment:8001",
|
||||||
|
"kind": kind,
|
||||||
|
"repo": { "owner": "acme", "name": "widgets" },
|
||||||
|
"number": 7,
|
||||||
|
"is_pr": false,
|
||||||
|
"actor": actor,
|
||||||
|
"body": body,
|
||||||
|
"url": null,
|
||||||
|
"timestamp": "2026-10-03T09:00:00Z",
|
||||||
|
"raw": {},
|
||||||
|
}))
|
||||||
|
.expect("event fixture parses")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_prompt_carries_the_thread_the_events_and_the_contract() {
|
||||||
|
let prompt = build_prompt(
|
||||||
|
"github:acme/widgets#7",
|
||||||
|
&snapshot(),
|
||||||
|
&[InboxNews::Event(event(
|
||||||
|
EventKind::Comment,
|
||||||
|
"dave",
|
||||||
|
Some("any progress?"),
|
||||||
|
))],
|
||||||
|
Path::new("/tmp/workspaces/github-acme-widgets-7"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(prompt.contains("github:acme/widgets#7"), "key: {prompt}");
|
||||||
|
assert!(prompt.contains("crash on save"), "title: {prompt}");
|
||||||
|
assert!(prompt.contains("state: open"), "state: {prompt}");
|
||||||
|
assert!(prompt.contains("Labels: bug, cli"), "labels: {prompt}");
|
||||||
|
assert!(
|
||||||
|
prompt.contains("- comment by dave: any progress?"),
|
||||||
|
"events: {prompt}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("posted to the thread verbatim"),
|
||||||
|
"contract: {prompt}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thread_level_events_carry_no_body_and_the_prompt_says_so() {
|
||||||
|
let prompt = build_prompt(
|
||||||
|
"github:acme/widgets#7",
|
||||||
|
&snapshot(),
|
||||||
|
&[InboxNews::Event(event(EventKind::Opened, "alice", None))],
|
||||||
|
Path::new("/tmp/w"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("- opened by alice: (no text)"),
|
||||||
|
"got: {prompt}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notification(reason: NotificationReason) -> Notification {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "github:notification:42188@2026-10-03T09:00:00Z",
|
||||||
|
"reason": reason,
|
||||||
|
"repo": { "owner": "acme", "name": "widgets" },
|
||||||
|
"number": 142,
|
||||||
|
"is_pr": true,
|
||||||
|
"actor": "alice",
|
||||||
|
"title": "Add the widget flange",
|
||||||
|
"url": null,
|
||||||
|
"timestamp": "2026-10-03T09:00:00Z",
|
||||||
|
"raw": {},
|
||||||
|
}))
|
||||||
|
.expect("notification fixture parses")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn triaged_item(role: Role, reason: NotificationReason) -> InboxItem {
|
||||||
|
InboxItem {
|
||||||
|
event: rhema_rsd::Event {
|
||||||
|
id: "github:notification:42188@2026-10-03T09:00:00Z".to_owned(),
|
||||||
|
source: "github".to_owned(),
|
||||||
|
payload: serde_json::json!({
|
||||||
|
"repo": "acme/widgets",
|
||||||
|
"number": 142,
|
||||||
|
"role": role,
|
||||||
|
"notification": notification(reason),
|
||||||
|
}),
|
||||||
|
received_at: 0,
|
||||||
|
},
|
||||||
|
delivered_at: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_triaged_item_decodes_to_its_role_and_a_plain_event_to_its_event() {
|
||||||
|
let news = decode_news(&triaged_item(
|
||||||
|
Role::Reviewer,
|
||||||
|
NotificationReason::ReviewRequested,
|
||||||
|
))
|
||||||
|
.expect("decodes");
|
||||||
|
assert!(matches!(
|
||||||
|
news,
|
||||||
|
InboxNews::Notified {
|
||||||
|
role: Role::Reviewer,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
let plain = InboxItem {
|
||||||
|
event: rhema_rsd::Event {
|
||||||
|
id: "github:comment:8001".to_owned(),
|
||||||
|
source: "github".to_owned(),
|
||||||
|
payload: serde_json::to_value(event(EventKind::Comment, "dave", None))
|
||||||
|
.expect("serializes"),
|
||||||
|
received_at: 0,
|
||||||
|
},
|
||||||
|
delivered_at: 0,
|
||||||
|
};
|
||||||
|
assert!(matches!(decode_news(&plain), Ok(InboxNews::Event(_))));
|
||||||
|
let garbage = InboxItem {
|
||||||
|
event: rhema_rsd::Event {
|
||||||
|
id: "x".to_owned(),
|
||||||
|
source: "github".to_owned(),
|
||||||
|
payload: serde_json::json!({"neither": true}),
|
||||||
|
received_at: 0,
|
||||||
|
},
|
||||||
|
delivered_at: 0,
|
||||||
|
};
|
||||||
|
assert!(decode_news(&garbage).is_err(), "an unknown shape is loud");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_batchs_dominant_role_subordinates_implementing_to_nothing() {
|
||||||
|
assert_eq!(dominant_role(&[]), None);
|
||||||
|
assert_eq!(
|
||||||
|
dominant_role(&[InboxNews::Event(event(EventKind::Comment, "dave", None))]),
|
||||||
|
None,
|
||||||
|
"plain events imply no persona"
|
||||||
|
);
|
||||||
|
let review = InboxNews::Notified {
|
||||||
|
role: Role::Reviewer,
|
||||||
|
notification: notification(NotificationReason::ReviewRequested),
|
||||||
|
};
|
||||||
|
let work = InboxNews::Notified {
|
||||||
|
role: Role::Implementer,
|
||||||
|
notification: notification(NotificationReason::Assigned),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
dominant_role(std::slice::from_ref(&review)),
|
||||||
|
Some(Role::Reviewer)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
dominant_role(std::slice::from_ref(&work)),
|
||||||
|
Some(Role::Implementer)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
dominant_role(&[review, work]),
|
||||||
|
Some(Role::Implementer),
|
||||||
|
"doing the work subsumes judging it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reviewer_dispatch_frames_the_prompt_as_a_review() {
|
||||||
|
let prompt = build_prompt(
|
||||||
|
"github:acme/widgets#142",
|
||||||
|
&snapshot(),
|
||||||
|
&[InboxNews::Notified {
|
||||||
|
role: Role::Reviewer,
|
||||||
|
notification: notification(NotificationReason::ReviewRequested),
|
||||||
|
}],
|
||||||
|
Path::new("/tmp/w"),
|
||||||
|
Some(Role::Reviewer),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("acting as this pull request's reviewer"),
|
||||||
|
"persona: {prompt}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("notification (review_requested) on pull request #142"),
|
||||||
|
"news line: {prompt}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_final_text_joins_and_trims_but_not_tool_calls() {
|
||||||
|
let message = Message {
|
||||||
|
role: rhema_core::Role::Assistant,
|
||||||
|
content: vec![
|
||||||
|
Content::Text("on it".into()),
|
||||||
|
Content::Text(" — closing.".into()),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert_eq!(final_text(&message).as_deref(), Some("on it — closing."));
|
||||||
|
let tool_only = Message {
|
||||||
|
role: rhema_core::Role::Assistant,
|
||||||
|
content: vec![Content::ToolCall(rhema_core::ToolCall {
|
||||||
|
id: "call_1".into(),
|
||||||
|
name: "bash".into(),
|
||||||
|
args: serde_json::json!({}),
|
||||||
|
provider_data: serde_json::Value::Null,
|
||||||
|
})],
|
||||||
|
};
|
||||||
|
assert_eq!(final_text(&tool_only), None, "no text, no reply");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_key_resolves_to_its_watch_entry_live_from_the_table() {
|
||||||
|
let watches = Arc::new(ClawStore::in_memory().expect("store opens"));
|
||||||
|
watches
|
||||||
|
.add("github", "acme/widgets", None, "tok")
|
||||||
|
.expect("watch added");
|
||||||
|
let registry = Arc::new(
|
||||||
|
crate::backends::BackendRegistry::new(
|
||||||
|
vec![crate::backends::Backend::headless(
|
||||||
|
"mock",
|
||||||
|
Arc::new(NoopProvider),
|
||||||
|
)],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("constructs"),
|
||||||
|
);
|
||||||
|
let handler = ClawHandler::new(registry, "/tmp/w", watches);
|
||||||
|
|
||||||
|
// A watch added to the table AFTER the handler was built resolves:
|
||||||
|
// the table is the source of truth, not a boot-time snapshot.
|
||||||
|
let key = ResourceKey::from("github:acme/widgets#7");
|
||||||
|
assert_eq!(watch_key(&key).as_deref(), Some("github:acme/widgets"));
|
||||||
|
assert!(handler.watch_forge(&key).is_ok(), "the watch resolves");
|
||||||
|
assert!(
|
||||||
|
handler
|
||||||
|
.watch_forge(&ResourceKey::from("gitea:acme/widgets#7"))
|
||||||
|
.is_err(),
|
||||||
|
"another forge's key does not"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
handler
|
||||||
|
.watch_forge(&ResourceKey::from("github:nonsense"))
|
||||||
|
.is_err(),
|
||||||
|
"a non-thread key does not"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_workspace_name_flattens_to_filesystem_safe_characters() {
|
||||||
|
assert_eq!(
|
||||||
|
workspace_name("github:acme/widgets#7", &RepoRef::new("acme", "widgets"), 7),
|
||||||
|
"github-acme-widgets-7"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
workspace_name(
|
||||||
|
"gitlab:group/subgroup/widgets#7",
|
||||||
|
&RepoRef::new("group/subgroup", "widgets"),
|
||||||
|
7
|
||||||
|
),
|
||||||
|
"gitlab-group-subgroup-widgets-7"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_table_entry_round_trips_through_the_dispatch_key() {
|
||||||
|
let repo = RepoRef::new("acme", "widgets");
|
||||||
|
let entry = watch_entry("github", &repo);
|
||||||
|
let key = ResourceKey::from(format!("{entry}#7"));
|
||||||
|
assert_eq!(watch_key(&key).as_deref(), Some(entry.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stands in for the provider in pure-lookup tests; nothing calls it.
|
||||||
|
struct NoopProvider;
|
||||||
|
#[rhema_core::async_trait]
|
||||||
|
impl ModelProvider for NoopProvider {
|
||||||
|
async fn complete(
|
||||||
|
&self,
|
||||||
|
_req: rhema_core::ModelRequest,
|
||||||
|
_sink: &mut dyn EventSink,
|
||||||
|
) -> Result<rhema_core::ModelOutput, rhema_core::ProviderError> {
|
||||||
|
Err(rhema_core::ProviderError::Api("never called".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `allowlist()` pins the v1 policy: permissive. The assertion keeps
|
||||||
|
// the policy a decision someone wrote down, not an accident.
|
||||||
|
#[test]
|
||||||
|
fn the_v1_allowlist_is_permissive() {
|
||||||
|
let allowlist = allowlist();
|
||||||
|
assert!(allowlist.allows("bash"));
|
||||||
|
assert!(allowlist.allows("anything_at_all"));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,558 @@
|
|||||||
|
//! The event hub: per-key broadcast of live turn events, and the
|
||||||
|
//! per-key record a late subscriber replays.
|
||||||
|
//!
|
||||||
|
//! HTTP subscribers stream one key's turns through it: a chat UI holds
|
||||||
|
//! one `GET /v1/sessions/{key}/events` connection open and watches every
|
||||||
|
//! turn of that session go by — whoever triggered the turn. The hub is
|
||||||
|
//! a [`tokio::sync::broadcast`] channel per key behind a mutex'd map.
|
||||||
|
//! `emit` records the event and broadcasts in one lock; a subscriber
|
||||||
|
//! that asks for history gets the record and a live receiver cut under
|
||||||
|
//! that same lock — every event lands in exactly one of the two, so a
|
||||||
|
//! replay has no gap and no overlap with the live stream, ever.
|
||||||
|
//!
|
||||||
|
//! Where the record lives depends on the assembly. With a turn store
|
||||||
|
//! attached (the daemon), every event is appended there as it streams
|
||||||
|
//! and a replay reads the store back: the transcript is unbounded and
|
||||||
|
//! survives restarts, so a session that runs for weeks replays whole —
|
||||||
|
//! the appends serialize under the hub's lock, which is what keeps the
|
||||||
|
//! table's order the stream's order. Without one (tests, store-less
|
||||||
|
//! assemblies) the record is a bounded in-memory window
|
||||||
|
//! ([`RECORD_CAPACITY`] events per key, the oldest falling off), good
|
||||||
|
//! enough for a process that lives as long as its transcript needs to.
|
||||||
|
//!
|
||||||
|
//! Live delivery itself stays best-effort: a subscriber that falls more
|
||||||
|
//! than [`EventHub::capacity`] events behind drops the oldest ones
|
||||||
|
//! (`broadcast`'s contract) and the receiver surfaces the skip as a
|
||||||
|
//! `Lagged` error so clients can say so.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::sync::{Arc, Mutex, PoisonError};
|
||||||
|
|
||||||
|
use rhema_rsd::ResourceKey;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
use crate::turn_store::TurnStore;
|
||||||
|
|
||||||
|
/// How many events a subscriber may trail by before the oldest drop.
|
||||||
|
/// Generous: a turn's deltas arrive faster than any viewer, but rarely
|
||||||
|
/// past four digits.
|
||||||
|
pub const TURN_CHANNEL_CAPACITY: usize = 1024;
|
||||||
|
|
||||||
|
/// How many events one key's in-memory record holds when no turn store
|
||||||
|
/// is attached. Wide enough that a reload rebuilds whole recent turns —
|
||||||
|
/// thinking traces and tool rows are deltas, and they add up.
|
||||||
|
pub const RECORD_CAPACITY: usize = 8192;
|
||||||
|
|
||||||
|
/// One event of a turn, as the wire sees it. Serialized with an internal
|
||||||
|
/// `type` tag, snake-cased: `{"type":"text_delta","text":"..."}`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum TurnEvent {
|
||||||
|
/// The turn's input: the text (a batch folds into one) the turn
|
||||||
|
/// answers, recorded by the handler when the dispatch starts. It
|
||||||
|
/// exists for the replay — a chat surface shows its own messages
|
||||||
|
/// as it sends them, and skips the live echo.
|
||||||
|
UserMessage {
|
||||||
|
/// The input text, verbatim.
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
/// A piece of the model's reasoning trace, in stream order.
|
||||||
|
ThinkingDelta {
|
||||||
|
/// The text fragment.
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
/// A piece of the model's reply, in stream order.
|
||||||
|
TextDelta {
|
||||||
|
/// The text fragment.
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
/// A tool call begins.
|
||||||
|
ToolStart {
|
||||||
|
/// The tool's name.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// A tool result landed.
|
||||||
|
ToolResult {
|
||||||
|
/// The tool's name.
|
||||||
|
name: String,
|
||||||
|
/// Whether the call failed.
|
||||||
|
is_error: bool,
|
||||||
|
/// A bounded snippet of the result content.
|
||||||
|
summary: String,
|
||||||
|
/// The process exit code of command-like tools; `None` otherwise.
|
||||||
|
code: Option<i32>,
|
||||||
|
/// The command line a shell tool ran, parsed from the call's
|
||||||
|
/// streamed arguments; `None` for every other tool.
|
||||||
|
command: Option<String>,
|
||||||
|
},
|
||||||
|
/// The turn is over — the clean end marker subscribers wait on,
|
||||||
|
/// emitted by the handler itself, so it arrives whoever triggered
|
||||||
|
/// the turn.
|
||||||
|
TurnDone {
|
||||||
|
/// The turn's final assistant text, when it produced one.
|
||||||
|
reply: Option<String>,
|
||||||
|
/// `handled` or `failed`.
|
||||||
|
outcome: String,
|
||||||
|
},
|
||||||
|
/// The agent asked the human something only they can answer: a
|
||||||
|
/// credential to store, permission to act. A chat surface renders
|
||||||
|
/// the ask as a card and answers through the consent routes.
|
||||||
|
ConsentRequest {
|
||||||
|
/// The request's id — the answer addresses it.
|
||||||
|
id: String,
|
||||||
|
/// What kind of answer: `secret` or `phone`.
|
||||||
|
kind: String,
|
||||||
|
/// The one-line ask.
|
||||||
|
summary: String,
|
||||||
|
/// The ask's context, the model's own reason.
|
||||||
|
detail: String,
|
||||||
|
/// When the request was made, epoch millis — a card that has
|
||||||
|
/// outlived the answer window greys out against it.
|
||||||
|
created_at: u64,
|
||||||
|
},
|
||||||
|
/// A consent request settled. The tool's answer rides the turn like
|
||||||
|
/// any tool result; this is how the card learns it can stop waiting.
|
||||||
|
ConsentResolved {
|
||||||
|
/// The request's id.
|
||||||
|
id: String,
|
||||||
|
/// Whether it was granted.
|
||||||
|
granted: bool,
|
||||||
|
},
|
||||||
|
/// The agent attached a file from its workspace: the metadata a
|
||||||
|
/// card needs, the bytes behind `GET /v1/attachments/{id}`.
|
||||||
|
Attachment {
|
||||||
|
/// The attachment's id.
|
||||||
|
id: String,
|
||||||
|
/// The file's name, as the tool call named it.
|
||||||
|
name: String,
|
||||||
|
/// What the name implies: `image`, `code`, or `file`.
|
||||||
|
media: String,
|
||||||
|
/// The byte size.
|
||||||
|
size: u64,
|
||||||
|
/// One line about the file, when the tool call carried one.
|
||||||
|
caption: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TurnEvent {
|
||||||
|
/// The event's kind, spelled the way the wire's `type` tag spells
|
||||||
|
/// it — for logs that name what they dropped.
|
||||||
|
pub fn kind(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::UserMessage { .. } => "user_message",
|
||||||
|
Self::ThinkingDelta { .. } => "thinking_delta",
|
||||||
|
Self::TextDelta { .. } => "text_delta",
|
||||||
|
Self::ToolStart { .. } => "tool_start",
|
||||||
|
Self::ToolResult { .. } => "tool_result",
|
||||||
|
Self::TurnDone { .. } => "turn_done",
|
||||||
|
Self::ConsentRequest { .. } => "consent_request",
|
||||||
|
Self::ConsentResolved { .. } => "consent_resolved",
|
||||||
|
Self::Attachment { .. } => "attachment",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One key's entry: the live channel, plus the in-memory record a
|
||||||
|
/// replay reads when no turn store is attached. Both live behind one
|
||||||
|
/// mutex, which is what makes a history snapshot + subscription atomic.
|
||||||
|
struct KeyEntry {
|
||||||
|
history: VecDeque<TurnEvent>,
|
||||||
|
sender: broadcast::Sender<TurnEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-key record and broadcast of [`TurnEvent`]s.
|
||||||
|
pub struct EventHub {
|
||||||
|
entries: Mutex<HashMap<ResourceKey, KeyEntry>>,
|
||||||
|
capacity: usize,
|
||||||
|
/// Where events append as they stream; `None` keeps the record
|
||||||
|
/// memory-only (tests, and any assembly without a database).
|
||||||
|
record_store: Option<Arc<TurnStore>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EventHub {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::with_capacity(TURN_CHANNEL_CAPACITY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventHub {
|
||||||
|
/// A hub whose per-key channels hold `capacity` events.
|
||||||
|
pub fn with_capacity(capacity: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: Mutex::new(HashMap::new()),
|
||||||
|
capacity,
|
||||||
|
record_store: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hub that appends every event to `store` as it streams — the
|
||||||
|
/// replay a restart serves reads back exactly what streamed, with no
|
||||||
|
/// window and no end-marker boundary.
|
||||||
|
pub fn with_record_store(store: Arc<TurnStore>) -> Self {
|
||||||
|
Self {
|
||||||
|
record_store: Some(store),
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribes to `key`'s turns, creating the channel on first use.
|
||||||
|
/// Subscribe BEFORE triggering work: a turn that starts before the
|
||||||
|
/// subscriber exists is simply not streamed to it. The chat
|
||||||
|
/// surface's route uses [`Self::subscribe_with_history`] instead;
|
||||||
|
/// this live-only shape is for callers that already know where the
|
||||||
|
/// turn starts (the shim, the messages route).
|
||||||
|
pub fn subscribe(&self, key: &ResourceKey) -> broadcast::Receiver<TurnEvent> {
|
||||||
|
let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
Self::entry(&mut entries, key, self.capacity)
|
||||||
|
.sender
|
||||||
|
.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribes to `key`'s turns and hands back the record cut at the
|
||||||
|
/// same instant: everything emitted before this call is in
|
||||||
|
/// `history`, everything after arrives on the receiver. The pair is
|
||||||
|
/// cut under one lock, so no event is in both and none is in
|
||||||
|
/// neither — a client folds `history` first and the stream after,
|
||||||
|
/// and the transcript is whole.
|
||||||
|
pub fn subscribe_with_history(
|
||||||
|
&self,
|
||||||
|
key: &ResourceKey,
|
||||||
|
) -> (Vec<TurnEvent>, broadcast::Receiver<TurnEvent>) {
|
||||||
|
Self::subscribe_with_history_from(self, key, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::subscribe_with_history`] with a cursor: `skip` names how
|
||||||
|
/// many of the record's oldest events the caller already holds (the
|
||||||
|
/// count a client tracks for its local transcript cache), and the
|
||||||
|
/// replay drops them, so a reconnecting client that kept its
|
||||||
|
/// transcript receives only what is new. A `skip` past the record's
|
||||||
|
/// end clamps to it — the client cannot skip into events that do not
|
||||||
|
/// exist, and the live feed after the cut is unaffected either way.
|
||||||
|
pub fn subscribe_with_history_from(
|
||||||
|
&self,
|
||||||
|
key: &ResourceKey,
|
||||||
|
skip: usize,
|
||||||
|
) -> (Vec<TurnEvent>, broadcast::Receiver<TurnEvent>) {
|
||||||
|
let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let entry = Self::entry(&mut entries, key, self.capacity);
|
||||||
|
let mut history = match self.record_store.as_ref() {
|
||||||
|
Some(store) => store.events(key.as_str()).unwrap_or_else(|err| {
|
||||||
|
eprintln!("[claw] warning: replay for {key} failed: {err}");
|
||||||
|
Vec::new()
|
||||||
|
}),
|
||||||
|
None => entry.history.iter().cloned().collect(),
|
||||||
|
};
|
||||||
|
history.drain(..skip.min(history.len()));
|
||||||
|
(history, entry.sender.subscribe())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes one event to `key`: the record takes it, and every
|
||||||
|
/// live subscriber gets a copy. A key with nobody listening is not
|
||||||
|
/// an error — turns run whether or not anyone watches, and the
|
||||||
|
/// record keeps the turn replayable. With a store attached the
|
||||||
|
/// append happens under the same lock as the broadcast, so the
|
||||||
|
/// table's order is the stream's order no matter how events race in
|
||||||
|
/// across keys.
|
||||||
|
pub fn emit(&self, key: &ResourceKey, event: TurnEvent) {
|
||||||
|
let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let entry = Self::entry(&mut entries, key, self.capacity);
|
||||||
|
match self.record_store.as_ref() {
|
||||||
|
Some(store) => {
|
||||||
|
if let Err(err) = store.append(key.as_str(), std::slice::from_ref(&event)) {
|
||||||
|
eprintln!("[claw] warning: turn-history append for {key} failed: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
entry.history.push_back(event.clone());
|
||||||
|
if entry.history.len() > RECORD_CAPACITY {
|
||||||
|
entry.history.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = entry.sender.send(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The map entry for `key`, creating it (and its channel) on first
|
||||||
|
/// use. Entries live for the process lifetime once created — the
|
||||||
|
/// live channel is the point; with a store attached the record
|
||||||
|
/// outlives the process, without one it stays bounded by
|
||||||
|
/// `RECORD_CAPACITY`.
|
||||||
|
fn entry<'a>(
|
||||||
|
entries: &'a mut HashMap<ResourceKey, KeyEntry>,
|
||||||
|
key: &ResourceKey,
|
||||||
|
capacity: usize,
|
||||||
|
) -> &'a mut KeyEntry {
|
||||||
|
entries.entry(key.clone()).or_insert_with(|| KeyEntry {
|
||||||
|
history: VecDeque::new(),
|
||||||
|
sender: broadcast::channel(capacity).0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn key(name: &str) -> ResourceKey {
|
||||||
|
ResourceKey::from(format!("interactive:{name}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(text: &str) -> TurnEvent {
|
||||||
|
TurnEvent::TextDelta { text: text.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn done(reply: &str) -> TurnEvent {
|
||||||
|
TurnEvent::TurnDone {
|
||||||
|
reply: Some(reply.into()),
|
||||||
|
outcome: "handled".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn events_serialize_with_a_type_tag_and_snake_case() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&TurnEvent::TextDelta { text: "hi".into() }).unwrap(),
|
||||||
|
r#"{"type":"text_delta","text":"hi"}"#
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&TurnEvent::UserMessage { text: "hi".into() }).unwrap(),
|
||||||
|
r#"{"type":"user_message","text":"hi"}"#
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&TurnEvent::ToolResult {
|
||||||
|
name: "bash".into(),
|
||||||
|
is_error: true,
|
||||||
|
summary: "exit 1".into(),
|
||||||
|
code: Some(1),
|
||||||
|
command: Some("false".into()),
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
r#"{"type":"tool_result","name":"bash","is_error":true,"summary":"exit 1","code":1,"command":"false"}"#
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&TurnEvent::TurnDone {
|
||||||
|
reply: Some("done".into()),
|
||||||
|
outcome: "handled".into()
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
r#"{"type":"turn_done","reply":"done","outcome":"handled"}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_kind_is_the_wire_type_tag() {
|
||||||
|
assert_eq!(
|
||||||
|
TurnEvent::TextDelta { text: "x".into() }.kind(),
|
||||||
|
"text_delta"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
TurnEvent::UserMessage { text: "x".into() }.kind(),
|
||||||
|
"user_message"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
TurnEvent::TurnDone {
|
||||||
|
reply: None,
|
||||||
|
outcome: "handled".into()
|
||||||
|
}
|
||||||
|
.kind(),
|
||||||
|
"turn_done"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subscriber_receives_what_is_emitted_after_it_subscribed() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let key = key("web");
|
||||||
|
let mut receiver = hub.subscribe(&key);
|
||||||
|
hub.emit(
|
||||||
|
&key,
|
||||||
|
TurnEvent::ToolStart {
|
||||||
|
name: "bash".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
hub.emit(&key, done("done"));
|
||||||
|
assert_eq!(
|
||||||
|
receiver.try_recv().unwrap(),
|
||||||
|
TurnEvent::ToolStart {
|
||||||
|
name: "bash".into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(receiver.try_recv().unwrap(), done("done"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_history_subscription_replays_what_preceded_it_and_splits_exactly_once() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let key = key("web");
|
||||||
|
hub.emit(
|
||||||
|
&key,
|
||||||
|
TurnEvent::UserMessage {
|
||||||
|
text: "hello".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
hub.emit(&key, text("hi"));
|
||||||
|
|
||||||
|
let (history, mut receiver) = hub.subscribe_with_history(&key);
|
||||||
|
assert_eq!(
|
||||||
|
history,
|
||||||
|
vec![
|
||||||
|
TurnEvent::UserMessage {
|
||||||
|
text: "hello".into()
|
||||||
|
},
|
||||||
|
text("hi"),
|
||||||
|
],
|
||||||
|
"everything before the cut is in the record"
|
||||||
|
);
|
||||||
|
hub.emit(&key, done("hi back"));
|
||||||
|
assert_eq!(
|
||||||
|
receiver.try_recv().unwrap(),
|
||||||
|
done("hi back"),
|
||||||
|
"everything after the cut is on the stream"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A second cut contains the whole run — the live events were
|
||||||
|
// recorded too, and none of them twice.
|
||||||
|
let (history, _) = hub.subscribe_with_history(&key);
|
||||||
|
assert_eq!(history.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emitting_to_nobody_listening_is_quiet_and_still_recorded() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let key = key("alone");
|
||||||
|
// No subscriber ever: quiet — and the record still accrues, so
|
||||||
|
// a later viewer replays the turn nobody watched.
|
||||||
|
hub.emit(&key, text("x"));
|
||||||
|
assert!(
|
||||||
|
hub.entries
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&key)
|
||||||
|
.expect("the entry exists")
|
||||||
|
.sender
|
||||||
|
.receiver_count()
|
||||||
|
== 0
|
||||||
|
);
|
||||||
|
let (history, _) = hub.subscribe_with_history(&key);
|
||||||
|
assert_eq!(history, vec![text("x")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_do_not_hear_each_other() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let mut mine = hub.subscribe(&key("mine"));
|
||||||
|
hub.subscribe(&key("theirs"));
|
||||||
|
hub.emit(&key("theirs"), text("x"));
|
||||||
|
assert!(mine.try_recv().is_err(), "no cross-talk");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_lagging_subscriber_drops_the_oldest_and_is_told_so() {
|
||||||
|
let hub = EventHub::with_capacity(2);
|
||||||
|
let key = key("slow");
|
||||||
|
let mut receiver = hub.subscribe(&key);
|
||||||
|
for n in 1..=4 {
|
||||||
|
hub.emit(&key, text(&n.to_string()));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
receiver.try_recv(),
|
||||||
|
Err(broadcast::error::TryRecvError::Lagged(2))
|
||||||
|
),
|
||||||
|
"the skip is surfaced, not silent"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
receiver.try_recv().unwrap(),
|
||||||
|
text("3"),
|
||||||
|
"the newest capacity events survive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_memory_record_is_capped_at_the_newest_events() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let key = key("long");
|
||||||
|
for n in 0..RECORD_CAPACITY + 10 {
|
||||||
|
hub.emit(&key, text(&n.to_string()));
|
||||||
|
}
|
||||||
|
let (history, _) = hub.subscribe_with_history(&key);
|
||||||
|
assert_eq!(history.len(), RECORD_CAPACITY);
|
||||||
|
assert_eq!(
|
||||||
|
history.first(),
|
||||||
|
Some(&text("10")),
|
||||||
|
"the oldest fell off, not the newest"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The restart story: a store-backed hub's record outlives the
|
||||||
|
/// process, whole — a fresh hub over the same store replays
|
||||||
|
/// everything, and past the in-memory cap, too.
|
||||||
|
#[test]
|
||||||
|
fn a_stored_record_replays_whole_across_hubs_and_past_any_cap() {
|
||||||
|
let store = Arc::new(TurnStore::in_memory().expect("store opens"));
|
||||||
|
let hub = EventHub::with_record_store(store.clone());
|
||||||
|
let key = key("long");
|
||||||
|
for n in 0..RECORD_CAPACITY + 10 {
|
||||||
|
hub.emit(&key, text(&n.to_string()));
|
||||||
|
}
|
||||||
|
hub.emit(&key, done("hi back"));
|
||||||
|
|
||||||
|
let restarted = EventHub::with_record_store(store);
|
||||||
|
let (history, mut receiver) = restarted.subscribe_with_history(&key);
|
||||||
|
assert_eq!(
|
||||||
|
history.len(),
|
||||||
|
RECORD_CAPACITY + 11,
|
||||||
|
"nothing fell off: the table, not a window, is the record"
|
||||||
|
);
|
||||||
|
assert_eq!(history.first(), Some(&text("0")));
|
||||||
|
assert_eq!(history.last(), Some(&done("hi back")));
|
||||||
|
restarted.emit(&key, text("in this life"));
|
||||||
|
assert_eq!(
|
||||||
|
receiver.try_recv().unwrap(),
|
||||||
|
text("in this life"),
|
||||||
|
"the live cut is exact: replayed events never re-deliver"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_memory_record_replays_and_keeps_taking_new_events() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let key = key("booted");
|
||||||
|
hub.emit(
|
||||||
|
&key,
|
||||||
|
TurnEvent::UserMessage {
|
||||||
|
text: "earlier".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let (history, mut receiver) = hub.subscribe_with_history(&key);
|
||||||
|
assert_eq!(history.len(), 1);
|
||||||
|
hub.emit(&key, text("in this life"));
|
||||||
|
assert_eq!(receiver.try_recv().unwrap(), text("in this life"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cursor_skips_exactly_the_events_the_caller_already_holds() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let key = key("cached");
|
||||||
|
for n in 0..4 {
|
||||||
|
hub.emit(&key, text(&n.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client that cached the first three receives only the fourth;
|
||||||
|
// the live cut is untouched by the skip.
|
||||||
|
let (history, mut receiver) = hub.subscribe_with_history_from(&key, 3);
|
||||||
|
assert_eq!(history, vec![text("3")], "only the unseen part replays");
|
||||||
|
hub.emit(&key, done("hi back"));
|
||||||
|
assert_eq!(receiver.try_recv().unwrap(), done("hi back"));
|
||||||
|
|
||||||
|
// A cursor past the record's end clamps to it: nothing replays,
|
||||||
|
// and nothing the record still holds is skipped twice.
|
||||||
|
let (history, _) = hub.subscribe_with_history_from(&key, 100);
|
||||||
|
assert!(history.is_empty());
|
||||||
|
|
||||||
|
// Zero behaves as the plain replay.
|
||||||
|
let (history, _) = hub.subscribe_with_history_from(&key, 0);
|
||||||
|
assert_eq!(history.len(), 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
//! The agent's presentation identity: the name, mark, tagline, and
|
||||||
|
//! images the interface renders everywhere — the rail, the chat, the
|
||||||
|
//! window title, the notifications' author. Stored claw-side so every
|
||||||
|
//! client reads the same identity over `GET /v1/agent`.
|
||||||
|
//!
|
||||||
|
//! Two kinds of facts live here. The text fields are keys in the
|
||||||
|
//! settings store; the images are files next to the daemon's database
|
||||||
|
//! with their content type in the store, because the bearer-guarded
|
||||||
|
//! route serves them back byte for byte. Behaviour — how the agent
|
||||||
|
//! talks, what it is like — is deliberately not here: that is SOUL.md,
|
||||||
|
//! the bootstrap store's domain, edited with the agent over onboarding.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||||
|
use rhema_core::ToolSpec;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::settings::SettingsStore;
|
||||||
|
|
||||||
|
/// Why an identity operation failed.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum IdentityError {
|
||||||
|
/// The settings store or the image directory failed.
|
||||||
|
#[error("identity store failed: {0}")]
|
||||||
|
Backend(String),
|
||||||
|
/// An image exceeded [`MAX_IMAGE_BYTES`].
|
||||||
|
#[error("image too large: {0} bytes, the limit is {1}")]
|
||||||
|
TooLarge(usize, usize),
|
||||||
|
/// An upload carried a non-image content type.
|
||||||
|
#[error("not an image: {0:?}")]
|
||||||
|
NotAnImage(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cap on an uploaded avatar or logo: enough for a crisp square at
|
||||||
|
/// every size the UI renders, small enough that the route stays cheap.
|
||||||
|
pub const MAX_IMAGE_BYTES: usize = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// The agent's identity as the wire sees it: the optional text fields
|
||||||
|
/// (each `null` when unset) and whether the images exist — the images
|
||||||
|
/// themselves live behind their own routes.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AgentIdentity {
|
||||||
|
/// The agent's display name: message author, window title,
|
||||||
|
/// notifications.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// The glyph or emoji in the rail, the favicon, the empty states.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mark: Option<String>,
|
||||||
|
/// Used when there is no mark and no avatar.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub initials: Option<String>,
|
||||||
|
/// One line under the name on the agent pane.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tagline: Option<String>,
|
||||||
|
/// Appended to messages the agent sends outside the interface.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub signoff: Option<String>,
|
||||||
|
/// Whether an avatar image is set (served at `/v1/agent/avatar`).
|
||||||
|
pub avatar: bool,
|
||||||
|
/// Whether a logo image is set (served at `/v1/agent/logo`).
|
||||||
|
pub logo: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The five text fields, for a whole-identity save.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct IdentityTexts {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub mark: Option<String>,
|
||||||
|
pub initials: Option<String>,
|
||||||
|
pub tagline: Option<String>,
|
||||||
|
pub signoff: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One of the two identity images.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ImageKind {
|
||||||
|
/// The square face the chat and rail render.
|
||||||
|
Avatar,
|
||||||
|
/// The mark the empty states and headers render.
|
||||||
|
Logo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageKind {
|
||||||
|
/// The wire path segment and file stem.
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ImageKind::Avatar => "avatar",
|
||||||
|
ImageKind::Logo => "logo",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text field's settings key.
|
||||||
|
fn text_key(field: &str) -> String {
|
||||||
|
format!("identity.{field}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The identity store: the text fields in the settings table, the
|
||||||
|
/// images in `dir` with their content types alongside in the store.
|
||||||
|
pub struct IdentityStore {
|
||||||
|
settings: Arc<SettingsStore>,
|
||||||
|
dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdentityStore {
|
||||||
|
/// Opens the store over `settings`, keeping images in `dir`
|
||||||
|
/// (created on first write).
|
||||||
|
pub fn new(settings: Arc<SettingsStore>, dir: impl Into<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
settings,
|
||||||
|
dir: dir.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current identity; unset fields read as `None`.
|
||||||
|
pub fn get(&self) -> Result<AgentIdentity, IdentityError> {
|
||||||
|
Ok(AgentIdentity {
|
||||||
|
name: self.text("name")?,
|
||||||
|
mark: self.text("mark")?,
|
||||||
|
initials: self.text("initials")?,
|
||||||
|
tagline: self.text("tagline")?,
|
||||||
|
signoff: self.text("signoff")?,
|
||||||
|
avatar: self.image_meta(ImageKind::Avatar)?.is_some(),
|
||||||
|
logo: self.image_meta(ImageKind::Logo)?.is_some(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the text fields whole: a `None` clears the field, a value
|
||||||
|
/// is trimmed and length-checked (the UI's inputs cap them, the
|
||||||
|
/// store enforces it).
|
||||||
|
pub fn set_texts(&self, texts: &IdentityTexts) -> Result<AgentIdentity, IdentityError> {
|
||||||
|
let fields = [
|
||||||
|
("name", &texts.name, 64usize),
|
||||||
|
("mark", &texts.mark, 8),
|
||||||
|
("initials", &texts.initials, 4),
|
||||||
|
("tagline", &texts.tagline, 120),
|
||||||
|
("signoff", &texts.signoff, 120),
|
||||||
|
];
|
||||||
|
for (field, value, cap) in fields {
|
||||||
|
let cleaned = value.as_deref().map(str::trim).filter(|v| !v.is_empty());
|
||||||
|
if let Some(value) = cleaned {
|
||||||
|
if value.chars().count() > cap {
|
||||||
|
return Err(IdentityError::Backend(format!(
|
||||||
|
"{field} is over {cap} characters"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.settings
|
||||||
|
.set(
|
||||||
|
&text_key(field),
|
||||||
|
&serde_json::to_string(&cleaned).expect("an option serializes"),
|
||||||
|
)
|
||||||
|
.map_err(|err| IdentityError::Backend(err.to_string()))?;
|
||||||
|
}
|
||||||
|
self.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores an image: the bytes in `dir`, the content type in the
|
||||||
|
/// settings store. Content types outside `image/` are refused, as
|
||||||
|
/// are bodies over [`MAX_IMAGE_BYTES`].
|
||||||
|
pub fn set_image(
|
||||||
|
&self,
|
||||||
|
kind: ImageKind,
|
||||||
|
bytes: &[u8],
|
||||||
|
content_type: &str,
|
||||||
|
) -> Result<AgentIdentity, IdentityError> {
|
||||||
|
let content_type = content_type
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if !content_type.starts_with("image/") || content_type == "image/svg+xml" {
|
||||||
|
// SVG is refused deliberately: it is the one image format
|
||||||
|
// that can carry scripts, and the route serves these bytes
|
||||||
|
// back to a browser.
|
||||||
|
return Err(IdentityError::NotAnImage(content_type));
|
||||||
|
}
|
||||||
|
if bytes.len() > MAX_IMAGE_BYTES {
|
||||||
|
return Err(IdentityError::TooLarge(bytes.len(), MAX_IMAGE_BYTES));
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(&self.dir)
|
||||||
|
.map_err(|err| IdentityError::Backend(err.to_string()))?;
|
||||||
|
let file = self.image_file(kind);
|
||||||
|
std::fs::write(&file, bytes).map_err(|err| IdentityError::Backend(err.to_string()))?;
|
||||||
|
self.settings
|
||||||
|
.set(&format!("identity.{}.type", kind.name()), &content_type)
|
||||||
|
.map_err(|err| IdentityError::Backend(err.to_string()))?;
|
||||||
|
self.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The image bytes and their content type, when one is set.
|
||||||
|
pub fn image(&self, kind: ImageKind) -> Result<Option<(Vec<u8>, String)>, IdentityError> {
|
||||||
|
self.image_meta(kind)?
|
||||||
|
.map(|content_type| {
|
||||||
|
std::fs::read(self.image_file(kind))
|
||||||
|
.map(|bytes| (bytes, content_type))
|
||||||
|
.map_err(|err| IdentityError::Backend(err.to_string()))
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops the image and its content type; a no-op when unset.
|
||||||
|
pub fn clear_image(&self, kind: ImageKind) -> Result<AgentIdentity, IdentityError> {
|
||||||
|
match std::fs::remove_file(self.image_file(kind)) {
|
||||||
|
Ok(()) => {}
|
||||||
|
// Absent is the cleared state, not a failure.
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(err) => return Err(IdentityError::Backend(err.to_string())),
|
||||||
|
}
|
||||||
|
self.settings
|
||||||
|
.delete(&format!("identity.{}.type", kind.name()))
|
||||||
|
.map_err(|err| IdentityError::Backend(err.to_string()))?;
|
||||||
|
self.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored content type, `None` when no image is set.
|
||||||
|
fn image_meta(&self, kind: ImageKind) -> Result<Option<String>, IdentityError> {
|
||||||
|
self.settings
|
||||||
|
.get(&format!("identity.{}.type", kind.name()))
|
||||||
|
.map_err(|err| IdentityError::Backend(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_file(&self, kind: ImageKind) -> PathBuf {
|
||||||
|
self.dir.join(format!("{}.bin", kind.name()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(&self, field: &str) -> Result<Option<String>, IdentityError> {
|
||||||
|
Ok(self
|
||||||
|
.settings
|
||||||
|
.get(&text_key(field))
|
||||||
|
.map_err(|err| IdentityError::Backend(err.to_string()))?
|
||||||
|
.and_then(|json| serde_json::from_str::<Option<String>>(&json).ok())
|
||||||
|
.flatten())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tool's name on the wire.
|
||||||
|
pub const IDENTITY_SET: &str = "identity_set";
|
||||||
|
|
||||||
|
/// `identity_set`: the model's surface over its own presentation —
|
||||||
|
/// the same store the settings pages write, so what it sets here is
|
||||||
|
/// what every client renders. Personality stays out of reach: that is
|
||||||
|
/// SOUL.md, and the tool has no path to it.
|
||||||
|
struct IdentitySet {
|
||||||
|
store: Arc<IdentityStore>,
|
||||||
|
workspace: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for IdentitySet {
|
||||||
|
fn spec(&self) -> ToolSpec {
|
||||||
|
ToolSpec::new(
|
||||||
|
IDENTITY_SET,
|
||||||
|
"Set your own presentation identity: the name, mark, \
|
||||||
|
initials, tagline, and signoff the interfaces render, and \
|
||||||
|
your avatar or logo images. This is how you look, not who \
|
||||||
|
you are — personality lives in SOUL.md. Leave a field out \
|
||||||
|
to keep what is set; an empty string clears it. The avatar \
|
||||||
|
and logo take a workspace-relative path to an image file \
|
||||||
|
(png, jpeg, gif, or webp, under 2 MiB — svg is refused).",
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string",
|
||||||
|
"description": "Display name: message author, \
|
||||||
|
window title, notifications. Up to 64 chars." },
|
||||||
|
"mark": { "type": "string",
|
||||||
|
"description": "Glyph or emoji for the rail and \
|
||||||
|
favicon. Up to 8 chars." },
|
||||||
|
"initials": { "type": "string",
|
||||||
|
"description": "Shown when there is no mark and \
|
||||||
|
no avatar. Up to 4 chars." },
|
||||||
|
"tagline": { "type": "string",
|
||||||
|
"description": "One line under the name. Up to \
|
||||||
|
120 chars." },
|
||||||
|
"signoff": { "type": "string",
|
||||||
|
"description": "Appended to messages sent \
|
||||||
|
outside the interface. Up to 120 chars." },
|
||||||
|
"avatar": { "type": "string",
|
||||||
|
"description": "Workspace-relative path to the \
|
||||||
|
avatar image." },
|
||||||
|
"logo": { "type": "string",
|
||||||
|
"description": "Workspace-relative path to the \
|
||||||
|
logo image." },
|
||||||
|
"clear": { "type": "array",
|
||||||
|
"items": { "type": "string",
|
||||||
|
"enum": ["avatar", "logo"] },
|
||||||
|
"description": "Images to remove." }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||||
|
if !args.is_object() {
|
||||||
|
return Err("bad arguments: expected an object".to_owned());
|
||||||
|
}
|
||||||
|
// The texts save whole, so an argument the call omits must carry
|
||||||
|
// the stored value forward — the same absent-means-keep rule the
|
||||||
|
// HTTP route applies. An empty string means clear.
|
||||||
|
let current = self.store.get().map_err(|err| err.to_string())?;
|
||||||
|
let field = |arg: Option<&str>, stored: Option<&str>| {
|
||||||
|
arg.map(str::to_owned).or_else(|| stored.map(str::to_owned))
|
||||||
|
};
|
||||||
|
let texts = IdentityTexts {
|
||||||
|
name: field(args["name"].as_str(), current.name.as_deref()),
|
||||||
|
mark: field(args["mark"].as_str(), current.mark.as_deref()),
|
||||||
|
initials: field(args["initials"].as_str(), current.initials.as_deref()),
|
||||||
|
tagline: field(args["tagline"].as_str(), current.tagline.as_deref()),
|
||||||
|
signoff: field(args["signoff"].as_str(), current.signoff.as_deref()),
|
||||||
|
};
|
||||||
|
// Every image is read and validated before anything is written,
|
||||||
|
// so a bad path fails the call without half a new identity.
|
||||||
|
let mut images = Vec::new();
|
||||||
|
for (field, kind) in [("avatar", ImageKind::Avatar), ("logo", ImageKind::Logo)] {
|
||||||
|
if let Some(path) = args[field].as_str() {
|
||||||
|
let file = workspace_image(&self.workspace, path)?;
|
||||||
|
let bytes =
|
||||||
|
std::fs::read(&file).map_err(|err| format!("cannot read {path:?}: {err}"))?;
|
||||||
|
images.push((kind, bytes, crate::attachments::content_type(path)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut clears = Vec::new();
|
||||||
|
if let Some(list) = args["clear"].as_array() {
|
||||||
|
for name in list {
|
||||||
|
let name = name.as_str().ok_or("clear must name avatar or logo")?;
|
||||||
|
clears.push(match name {
|
||||||
|
"avatar" => ImageKind::Avatar,
|
||||||
|
"logo" => ImageKind::Logo,
|
||||||
|
other => return Err(format!("unknown image {other:?}")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut identity = self
|
||||||
|
.store
|
||||||
|
.set_texts(&texts)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
for (kind, bytes, content_type) in images {
|
||||||
|
identity = self
|
||||||
|
.store
|
||||||
|
.set_image(kind, &bytes, content_type)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
}
|
||||||
|
for kind in clears {
|
||||||
|
identity = self
|
||||||
|
.store
|
||||||
|
.clear_image(kind)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(json!({ "saved": true, "identity": identity }).to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a workspace-relative image path — absolute paths and `..`
|
||||||
|
/// escapes are refused, the same discipline the attachment tool keeps.
|
||||||
|
fn workspace_image(workspace: &Path, path: &str) -> Result<PathBuf, String> {
|
||||||
|
let relative = Path::new(path);
|
||||||
|
if relative.is_absolute()
|
||||||
|
|| relative
|
||||||
|
.components()
|
||||||
|
.any(|component| component == std::path::Component::ParentDir)
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"path must be relative to the workspace, without '..': {path:?}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(workspace.join(relative))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The identity tool set over `store`: `identity_set`.
|
||||||
|
pub fn registry(store: Arc<IdentityStore>, workspace: impl Into<PathBuf>) -> ToolRegistry {
|
||||||
|
let mut registry = ToolRegistry::new();
|
||||||
|
registry.register(Arc::new(IdentitySet {
|
||||||
|
store,
|
||||||
|
workspace: workspace.into(),
|
||||||
|
}));
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The store over a tempdir, with the guard returned so the
|
||||||
|
/// directory outlives the calls under test.
|
||||||
|
fn store() -> (IdentityStore, tempfile::TempDir) {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let settings = Arc::new(SettingsStore::in_memory().expect("the store opens"));
|
||||||
|
(
|
||||||
|
IdentityStore::new(settings, dir.path().join("identity")),
|
||||||
|
dir,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_fields_round_trip_and_clear() {
|
||||||
|
let (store, _dir) = store();
|
||||||
|
let saved = store
|
||||||
|
.set_texts(&IdentityTexts {
|
||||||
|
name: Some(" Kosmos ".to_owned()),
|
||||||
|
mark: Some("✦".to_owned()),
|
||||||
|
initials: None,
|
||||||
|
tagline: Some("order".to_owned()),
|
||||||
|
signoff: Some(String::new()),
|
||||||
|
})
|
||||||
|
.expect("the texts save");
|
||||||
|
assert_eq!(saved.name.as_deref(), Some("Kosmos"), "trimmed");
|
||||||
|
assert_eq!(saved.mark.as_deref(), Some("✦"));
|
||||||
|
assert_eq!(saved.initials, None);
|
||||||
|
assert_eq!(saved.signoff, None, "an empty string clears");
|
||||||
|
assert!(!saved.avatar);
|
||||||
|
|
||||||
|
let cleared = store
|
||||||
|
.set_texts(&IdentityTexts::default())
|
||||||
|
.expect("the clear saves");
|
||||||
|
assert_eq!(cleared.name, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_overlong_field_is_refused() {
|
||||||
|
let (store, _dir) = store();
|
||||||
|
let long = "x".repeat(65);
|
||||||
|
let err = store
|
||||||
|
.set_texts(&IdentityTexts {
|
||||||
|
name: Some(long),
|
||||||
|
..IdentityTexts::default()
|
||||||
|
})
|
||||||
|
.expect_err("over the cap");
|
||||||
|
assert!(err.to_string().contains("over 64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn images_round_trip_and_clear() {
|
||||||
|
let (store, _dir) = store();
|
||||||
|
let saved = store
|
||||||
|
.set_image(ImageKind::Avatar, &[1, 2, 3], "image/png; charset=binary")
|
||||||
|
.expect("the avatar saves");
|
||||||
|
assert!(saved.avatar);
|
||||||
|
let (bytes, content_type) = store
|
||||||
|
.image(ImageKind::Avatar)
|
||||||
|
.expect("the image reads")
|
||||||
|
.expect("an image is set");
|
||||||
|
assert_eq!(bytes, vec![1, 2, 3]);
|
||||||
|
assert_eq!(content_type, "image/png", "parameters stripped");
|
||||||
|
|
||||||
|
let cleared = store
|
||||||
|
.clear_image(ImageKind::Avatar)
|
||||||
|
.expect("the clear saves");
|
||||||
|
assert!(!cleared.avatar);
|
||||||
|
assert!(store.image(ImageKind::Avatar).expect("reads").is_none());
|
||||||
|
// Clearing an unset image is a no-op, not an error.
|
||||||
|
store
|
||||||
|
.clear_image(ImageKind::Avatar)
|
||||||
|
.expect("the second clear is a no-op");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_images_and_oversized_bodies_are_refused() {
|
||||||
|
let (store, _dir) = store();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.set_image(ImageKind::Logo, b"<svg/>", "image/svg+xml")
|
||||||
|
.is_err(),
|
||||||
|
"svg carries scripts"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.set_image(ImageKind::Logo, b"plain", "text/plain")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let big = vec![0u8; MAX_IMAGE_BYTES + 1];
|
||||||
|
assert!(store.set_image(ImageKind::Logo, &big, "image/png").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tool, over a real workspace file: the texts save, the image
|
||||||
|
/// lands with its extension's content type, and the answer carries
|
||||||
|
/// the resulting identity.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn identity_set_saves_texts_and_a_workspace_avatar() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let workspace = dir.path().join("interactive");
|
||||||
|
std::fs::create_dir_all(&workspace).expect("workspace");
|
||||||
|
std::fs::write(workspace.join("face.png"), b"png-bytes").expect("the image writes");
|
||||||
|
let settings = Arc::new(SettingsStore::in_memory().expect("the store opens"));
|
||||||
|
let store = Arc::new(IdentityStore::new(settings, dir.path().join("identity")));
|
||||||
|
let tool = IdentitySet {
|
||||||
|
store: store.clone(),
|
||||||
|
workspace: workspace.clone(),
|
||||||
|
};
|
||||||
|
let answer = tool
|
||||||
|
.invoke(json!({
|
||||||
|
"name": "Kosmos",
|
||||||
|
"mark": "✦",
|
||||||
|
"avatar": "face.png",
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.expect("in-band ok");
|
||||||
|
let answer: Value = serde_json::from_str(&answer).expect("parses");
|
||||||
|
assert_eq!(answer["saved"], true);
|
||||||
|
assert_eq!(answer["identity"]["name"], "Kosmos");
|
||||||
|
assert_eq!(answer["identity"]["avatar"], true);
|
||||||
|
let (bytes, content_type) = store
|
||||||
|
.image(ImageKind::Avatar)
|
||||||
|
.expect("reads")
|
||||||
|
.expect("the avatar is set");
|
||||||
|
assert_eq!(bytes, b"png-bytes");
|
||||||
|
assert_eq!(content_type, "image/png");
|
||||||
|
// An absent field carried the stored value forward.
|
||||||
|
tool.invoke(json!({ "tagline": "order" }))
|
||||||
|
.await
|
||||||
|
.expect("saves");
|
||||||
|
let identity = store.get().expect("reads");
|
||||||
|
assert_eq!(identity.name.as_deref(), Some("Kosmos"), "kept");
|
||||||
|
assert_eq!(identity.tagline.as_deref(), Some("order"), "added");
|
||||||
|
assert!(identity.avatar, "untouched by a text-only save");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn identity_set_clears_fields_and_images_and_refuses_escapes() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let workspace = dir.path().join("interactive");
|
||||||
|
std::fs::create_dir_all(&workspace).expect("workspace");
|
||||||
|
let settings = Arc::new(SettingsStore::in_memory().expect("the store opens"));
|
||||||
|
let store = Arc::new(IdentityStore::new(settings, dir.path().join("identity")));
|
||||||
|
store
|
||||||
|
.set_image(ImageKind::Logo, b"bytes", "image/png")
|
||||||
|
.expect("the logo saves");
|
||||||
|
let tool = IdentitySet {
|
||||||
|
store: store.clone(),
|
||||||
|
workspace: workspace.clone(),
|
||||||
|
};
|
||||||
|
let answer = tool
|
||||||
|
.invoke(json!({
|
||||||
|
"name": "",
|
||||||
|
"clear": ["logo"],
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.expect("in-band ok");
|
||||||
|
let answer: Value = serde_json::from_str(&answer).expect("parses");
|
||||||
|
assert_eq!(
|
||||||
|
answer["identity"]["name"],
|
||||||
|
Value::Null,
|
||||||
|
"an empty string clears"
|
||||||
|
);
|
||||||
|
assert_eq!(answer["identity"]["logo"], false);
|
||||||
|
|
||||||
|
for bad in [
|
||||||
|
json!({ "avatar": "../outside.png" }),
|
||||||
|
json!({ "avatar": "/etc/hostname" }),
|
||||||
|
json!({ "avatar": "missing.png" }),
|
||||||
|
json!({ "clear": ["soul"] }),
|
||||||
|
json!({ "name": "x".repeat(65) }),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
tool.invoke(bad.clone()).await.is_err(),
|
||||||
|
"refused in band: {bad}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,604 @@
|
|||||||
|
//! The coding-job store: the instances the resident may drive a coding
|
||||||
|
//! agent in, and the jobs queued against them.
|
||||||
|
//!
|
||||||
|
//! An **instance** is a named workspace: the audit unit for "which
|
||||||
|
//! checkouts can the model drive a coding agent in". A **job** is one
|
||||||
|
//! `CodingAgent` turn over an instance's workspace: prompt in, final
|
||||||
|
//! text out, run off the chat turn by the jobs dispatcher (see
|
||||||
|
//! [`crate::jobs`]). The conversation carries across a job boundary in
|
||||||
|
//! the instance row's session blob — the same stash the forge handler
|
||||||
|
//! keeps in the dispatcher's `driver_state`, keyed by instance instead
|
||||||
|
//! of by thread, so "run the tests" after "fix the lint" continues the
|
||||||
|
//! same coding session.
|
||||||
|
//!
|
||||||
|
//! Two invariants the schema itself holds: at most one **live** job
|
||||||
|
//! (queued or running) per instance — a partial unique index refuses
|
||||||
|
//! the second enqueue, because two coding agents in one workspace is
|
||||||
|
//! two agents editing the same files — and [`CodingJobStore::recover`]
|
||||||
|
//! is the boot pass that makes a crash honest: a row left `running` by
|
||||||
|
//! a dead process is failed as interrupted, and a row still `queued`
|
||||||
|
//! gets its timestamp bumped so its re-ingest carries a fresh dedup id
|
||||||
|
//! (the old one may already sit in the dispatcher's consumed set).
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Mutex, PoisonError};
|
||||||
|
|
||||||
|
use rusqlite::params;
|
||||||
|
|
||||||
|
/// Why a store operation failed.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum JobStoreError {
|
||||||
|
/// The SQLite layer failed.
|
||||||
|
#[error(transparent)]
|
||||||
|
Sqlite(#[from] rusqlite::Error),
|
||||||
|
/// The instance name is empty or carries a character the dispatch
|
||||||
|
/// key cannot hold (`#`, `/`, `:`) or whitespace.
|
||||||
|
#[error("invalid instance name {name:?}: use letters, digits, dash, dot, underscore")]
|
||||||
|
InvalidName {
|
||||||
|
/// The rejected name.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// An instance with this name already exists.
|
||||||
|
#[error("instance {name:?} already exists")]
|
||||||
|
Duplicate {
|
||||||
|
/// The refused name.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// The instance is not in the table.
|
||||||
|
#[error("no instance named {name:?}")]
|
||||||
|
UnknownInstance {
|
||||||
|
/// The name that resolved to nothing.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// The instance has a live job; the operation (a second enqueue, a
|
||||||
|
/// drop) is refused until it finishes.
|
||||||
|
#[error("instance {name:?} has a job in flight; check its status or wait")]
|
||||||
|
Busy {
|
||||||
|
/// The busy instance.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far a job has got.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum JobStatus {
|
||||||
|
/// Enqueued; the jobs source will ingest it.
|
||||||
|
Queued,
|
||||||
|
/// A coding-agent turn is running for it.
|
||||||
|
Running,
|
||||||
|
/// The turn's final text landed in `result`.
|
||||||
|
Done,
|
||||||
|
/// The turn failed; `result` carries the reason.
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobStatus {
|
||||||
|
/// The spelling `status` answers carry.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
JobStatus::Queued => "queued",
|
||||||
|
JobStatus::Running => "running",
|
||||||
|
JobStatus::Done => "done",
|
||||||
|
JobStatus::Failed => "failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(raw: &str) -> Self {
|
||||||
|
match raw {
|
||||||
|
"running" => JobStatus::Running,
|
||||||
|
"done" => JobStatus::Done,
|
||||||
|
"failed" => JobStatus::Failed,
|
||||||
|
_ => JobStatus::Queued,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the job still has a turn ahead of it.
|
||||||
|
pub fn is_live(self) -> bool {
|
||||||
|
matches!(self, JobStatus::Queued | JobStatus::Running)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One named workspace the resident may drive a coding agent in.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct InstanceRecord {
|
||||||
|
/// The name the tools address it by.
|
||||||
|
pub name: String,
|
||||||
|
/// The workspace directory the coding agent's shell runs in.
|
||||||
|
pub path: String,
|
||||||
|
/// The coding session the next job resumes, as JSON; `None` until
|
||||||
|
/// the first job's turn returns.
|
||||||
|
pub session: Option<String>,
|
||||||
|
/// When the instance was added.
|
||||||
|
pub created_at_millis: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One queued or finished coding run.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct JobRecord {
|
||||||
|
/// The row id; the dispatch key and the `status` answer carry it.
|
||||||
|
pub id: i64,
|
||||||
|
/// The instance the job runs against.
|
||||||
|
pub instance: String,
|
||||||
|
/// The prompt the coding agent turns on.
|
||||||
|
pub prompt: String,
|
||||||
|
/// How far the job has got.
|
||||||
|
pub status: JobStatus,
|
||||||
|
/// The final text on done, the reason on failed.
|
||||||
|
pub result: Option<String>,
|
||||||
|
/// When the job was enqueued.
|
||||||
|
pub created_at_millis: i64,
|
||||||
|
/// The last transition; also the dedup id's half, so a re-queued
|
||||||
|
/// row re-ingests after a restart.
|
||||||
|
pub updated_at_millis: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The instances and jobs, in one SQLite file beside the daemon's other
|
||||||
|
/// tables.
|
||||||
|
pub struct CodingJobStore {
|
||||||
|
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodingJobStore {
|
||||||
|
/// Opens the store at `path`, creating the tables.
|
||||||
|
pub fn open(path: impl AsRef<Path>) -> Result<Self, JobStoreError> {
|
||||||
|
let conn = rusqlite::Connection::open(path)?;
|
||||||
|
Self::init(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A memory-backed store; the tests' shape.
|
||||||
|
pub fn in_memory() -> Result<Self, JobStoreError> {
|
||||||
|
Self::init(rusqlite::Connection::open_in_memory()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(conn: rusqlite::Connection) -> Result<Self, JobStoreError> {
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS coding_instances (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
session TEXT,
|
||||||
|
created_at_millis INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS coding_jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
instance TEXT NOT NULL,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
result TEXT,
|
||||||
|
created_at_millis INTEGER NOT NULL,
|
||||||
|
updated_at_millis INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS coding_jobs_one_live_per_instance
|
||||||
|
ON coding_jobs(instance) WHERE status IN ('queued', 'running');",
|
||||||
|
)?;
|
||||||
|
Ok(Self {
|
||||||
|
conn: Arc::new(Mutex::new(conn)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds an instance. The name is validated here — the dispatch key
|
||||||
|
/// is `job:<name>#<id>`, so a `#` in the name would break the parse
|
||||||
|
/// the jobs handler depends on.
|
||||||
|
pub fn add_instance(&self, name: &str, path: &str) -> Result<InstanceRecord, JobStoreError> {
|
||||||
|
if name.is_empty()
|
||||||
|
|| name
|
||||||
|
.chars()
|
||||||
|
.any(|c| c.is_whitespace() || matches!(c, '#' | '/' | ':'))
|
||||||
|
{
|
||||||
|
return Err(JobStoreError::InvalidName { name: name.into() });
|
||||||
|
}
|
||||||
|
let created_at = now_millis();
|
||||||
|
let conn = self.lock();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO coding_instances (name, path, session, created_at_millis)
|
||||||
|
VALUES (?1, ?2, NULL, ?3)",
|
||||||
|
params![name, path, created_at],
|
||||||
|
)
|
||||||
|
.map_err(|err| match err {
|
||||||
|
rusqlite::Error::SqliteFailure(_, Some(text)) if text.contains("UNIQUE") => {
|
||||||
|
JobStoreError::Duplicate { name: name.into() }
|
||||||
|
}
|
||||||
|
other => other.into(),
|
||||||
|
})?;
|
||||||
|
Ok(InstanceRecord {
|
||||||
|
name: name.to_owned(),
|
||||||
|
path: path.to_owned(),
|
||||||
|
session: None,
|
||||||
|
created_at_millis: created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes an instance. Refused while a job is live on it: a drop
|
||||||
|
/// under a running turn would orphan the run's store writes.
|
||||||
|
pub fn drop_instance(&self, name: &str) -> Result<bool, JobStoreError> {
|
||||||
|
let conn = self.lock();
|
||||||
|
if Self::live_locked(&conn, name)?.is_some() {
|
||||||
|
return Err(JobStoreError::Busy { name: name.into() });
|
||||||
|
}
|
||||||
|
let dropped = conn.execute(
|
||||||
|
"DELETE FROM coding_instances WHERE name = ?1",
|
||||||
|
params![name],
|
||||||
|
)?;
|
||||||
|
Ok(dropped > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every instance, oldest first.
|
||||||
|
pub fn instances(&self) -> Result<Vec<InstanceRecord>, JobStoreError> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT name, path, session, created_at_millis
|
||||||
|
FROM coding_instances ORDER BY created_at_millis, name",
|
||||||
|
)?;
|
||||||
|
let rows = statement.query_map([], row_instance)?;
|
||||||
|
rows.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(JobStoreError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One instance by name.
|
||||||
|
pub fn instance(&self, name: &str) -> Result<Option<InstanceRecord>, JobStoreError> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT name, path, session, created_at_millis
|
||||||
|
FROM coding_instances WHERE name = ?1",
|
||||||
|
)?;
|
||||||
|
let mut rows = statement.query_map(params![name], row_instance)?;
|
||||||
|
rows.next().transpose().map_err(JobStoreError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The instance's session blob, `None` until the first job's turn
|
||||||
|
/// returns or after a `fresh` run cleared it.
|
||||||
|
pub fn session(&self, name: &str) -> Result<Option<String>, JobStoreError> {
|
||||||
|
self.instance(name)?
|
||||||
|
.map(|instance| instance.session)
|
||||||
|
.ok_or_else(|| JobStoreError::UnknownInstance { name: name.into() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the instance's session blob — the coding conversation
|
||||||
|
/// the next job resumes.
|
||||||
|
pub fn save_session(&self, name: &str, session: &str) -> Result<(), JobStoreError> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let updated = conn.execute(
|
||||||
|
"UPDATE coding_instances SET session = ?2 WHERE name = ?1",
|
||||||
|
params![name, session],
|
||||||
|
)?;
|
||||||
|
(updated > 0)
|
||||||
|
.then_some(())
|
||||||
|
.ok_or_else(|| JobStoreError::UnknownInstance { name: name.into() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops the session blob: the next `run` starts a fresh coding
|
||||||
|
/// conversation.
|
||||||
|
pub fn clear_session(&self, name: &str) -> Result<(), JobStoreError> {
|
||||||
|
self.save_session_name(name, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_session_name(&self, name: &str, session: Option<&str>) -> Result<(), JobStoreError> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let updated = conn.execute(
|
||||||
|
"UPDATE coding_instances SET session = ?2 WHERE name = ?1",
|
||||||
|
params![name, session],
|
||||||
|
)?;
|
||||||
|
(updated > 0)
|
||||||
|
.then_some(())
|
||||||
|
.ok_or_else(|| JobStoreError::UnknownInstance { name: name.into() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enqueues one job. Refused when the instance is unknown or
|
||||||
|
/// already has a live job.
|
||||||
|
pub fn enqueue(&self, instance: &str, prompt: &str) -> Result<JobRecord, JobStoreError> {
|
||||||
|
let conn = self.lock();
|
||||||
|
if conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM coding_instances WHERE name = ?1",
|
||||||
|
params![instance],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Err(JobStoreError::UnknownInstance {
|
||||||
|
name: instance.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let now = now_millis();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO coding_jobs (instance, prompt, status, created_at_millis, updated_at_millis)
|
||||||
|
VALUES (?1, ?2, 'queued', ?3, ?3)",
|
||||||
|
params![instance, prompt, now],
|
||||||
|
)
|
||||||
|
.map_err(|err| match err {
|
||||||
|
rusqlite::Error::SqliteFailure(_, Some(text))
|
||||||
|
if text.contains("UNIQUE") || text.contains("constraint") =>
|
||||||
|
{
|
||||||
|
JobStoreError::Busy { name: instance.into() }
|
||||||
|
}
|
||||||
|
other => other.into(),
|
||||||
|
})?;
|
||||||
|
let id = conn.last_insert_rowid();
|
||||||
|
Ok(JobRecord {
|
||||||
|
id,
|
||||||
|
instance: instance.to_owned(),
|
||||||
|
prompt: prompt.to_owned(),
|
||||||
|
status: JobStatus::Queued,
|
||||||
|
result: None,
|
||||||
|
created_at_millis: now,
|
||||||
|
updated_at_millis: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One job by id.
|
||||||
|
pub fn job(&self, id: i64) -> Result<Option<JobRecord>, JobStoreError> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT id, instance, prompt, status, result, created_at_millis, updated_at_millis
|
||||||
|
FROM coding_jobs WHERE id = ?1",
|
||||||
|
)?;
|
||||||
|
let mut rows = statement.query_map(params![id], row_job)?;
|
||||||
|
rows.next().transpose().map_err(JobStoreError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The instance's jobs, newest first, at most `limit`.
|
||||||
|
pub fn jobs_for(&self, instance: &str, limit: u32) -> Result<Vec<JobRecord>, JobStoreError> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT id, instance, prompt, status, result, created_at_millis, updated_at_millis
|
||||||
|
FROM coding_jobs WHERE instance = ?1
|
||||||
|
ORDER BY id DESC LIMIT ?2",
|
||||||
|
)?;
|
||||||
|
let rows = statement.query_map(params![instance, limit], row_job)?;
|
||||||
|
rows.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(JobStoreError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most recent jobs across every instance, newest first — the
|
||||||
|
/// no-argument `status` answer.
|
||||||
|
pub fn recent(&self, limit: u32) -> Result<Vec<JobRecord>, JobStoreError> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT id, instance, prompt, status, result, created_at_millis, updated_at_millis
|
||||||
|
FROM coding_jobs ORDER BY id DESC LIMIT ?1",
|
||||||
|
)?;
|
||||||
|
let rows = statement.query_map(params![limit], row_job)?;
|
||||||
|
rows.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(JobStoreError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The queued rows, oldest first — the jobs source's detect pass.
|
||||||
|
pub fn queued(&self) -> Result<Vec<JobRecord>, JobStoreError> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT id, instance, prompt, status, result, created_at_millis, updated_at_millis
|
||||||
|
FROM coding_jobs WHERE status = 'queued' ORDER BY id",
|
||||||
|
)?;
|
||||||
|
let rows = statement.query_map([], row_job)?;
|
||||||
|
rows.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(JobStoreError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The instance's live job, if any — the busy check the tools and
|
||||||
|
/// the drop path share.
|
||||||
|
pub fn live_job(&self, instance: &str) -> Result<Option<JobRecord>, JobStoreError> {
|
||||||
|
let conn = self.lock();
|
||||||
|
Self::live_locked(&conn, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the job running; the turn has begun.
|
||||||
|
pub fn mark_running(&self, id: i64) -> Result<(), JobStoreError> {
|
||||||
|
let conn = self.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE coding_jobs SET status = 'running', updated_at_millis = ?2
|
||||||
|
WHERE id = ?1",
|
||||||
|
params![id, now_millis()],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finishes the job with the turn's final text.
|
||||||
|
pub fn finish(&self, id: i64, result: &str) -> Result<(), JobStoreError> {
|
||||||
|
self.transition(id, "done", Some(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fails the job with the reason; the model can read it via status.
|
||||||
|
pub fn fail(&self, id: i64, reason: &str) -> Result<(), JobStoreError> {
|
||||||
|
self.transition(id, "failed", Some(reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transition(&self, id: i64, status: &str, result: Option<&str>) -> Result<(), JobStoreError> {
|
||||||
|
let conn = self.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE coding_jobs SET status = ?2, result = ?3, updated_at_millis = ?4
|
||||||
|
WHERE id = ?1",
|
||||||
|
params![id, status, result, now_millis()],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The boot pass: a `running` row belonged to the process that just
|
||||||
|
/// died and is failed as interrupted; a `queued` row gets its
|
||||||
|
/// timestamp bumped, so the source's re-ingest carries a dedup id
|
||||||
|
/// the previous process's consumed set cannot swallow. Returns the
|
||||||
|
/// number of rows it touched.
|
||||||
|
pub fn recover(&self) -> Result<usize, JobStoreError> {
|
||||||
|
let conn = self.lock();
|
||||||
|
let now = now_millis();
|
||||||
|
let interrupted = conn.execute(
|
||||||
|
"UPDATE coding_jobs
|
||||||
|
SET status = 'failed',
|
||||||
|
result = 'interrupted: the daemon restarted mid-job; run it again',
|
||||||
|
updated_at_millis = ?1
|
||||||
|
WHERE status = 'running'",
|
||||||
|
params![now],
|
||||||
|
)?;
|
||||||
|
let requeued = conn.execute(
|
||||||
|
"UPDATE coding_jobs SET updated_at_millis = ?1 WHERE status = 'queued'",
|
||||||
|
params![now],
|
||||||
|
)?;
|
||||||
|
Ok(interrupted + requeued)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn live_locked(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
instance: &str,
|
||||||
|
) -> Result<Option<JobRecord>, JobStoreError> {
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT id, instance, prompt, status, result, created_at_millis, updated_at_millis
|
||||||
|
FROM coding_jobs WHERE instance = ?1 AND status IN ('queued', 'running')
|
||||||
|
ORDER BY id LIMIT 1",
|
||||||
|
)?;
|
||||||
|
let mut rows = statement.query_map(params![instance], row_job)?;
|
||||||
|
rows.next().transpose().map_err(JobStoreError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock(&self) -> std::sync::MutexGuard<'_, rusqlite::Connection> {
|
||||||
|
self.conn.lock().unwrap_or_else(PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_instance(row: &rusqlite::Row<'_>) -> rusqlite::Result<InstanceRecord> {
|
||||||
|
Ok(InstanceRecord {
|
||||||
|
name: row.get(0)?,
|
||||||
|
path: row.get(1)?,
|
||||||
|
session: row.get(2)?,
|
||||||
|
created_at_millis: row.get(3)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_job(row: &rusqlite::Row<'_>) -> rusqlite::Result<JobRecord> {
|
||||||
|
Ok(JobRecord {
|
||||||
|
id: row.get(0)?,
|
||||||
|
instance: row.get(1)?,
|
||||||
|
prompt: row.get(2)?,
|
||||||
|
status: JobStatus::parse(&row.get::<_, String>(3)?),
|
||||||
|
result: row.get(4)?,
|
||||||
|
created_at_millis: row.get(5)?,
|
||||||
|
updated_at_millis: row.get(6)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Milliseconds since the Unix epoch, the table's clock everywhere.
|
||||||
|
fn now_millis() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_lists_and_drops_instances() {
|
||||||
|
let store = CodingJobStore::in_memory().unwrap();
|
||||||
|
store.add_instance("rhema", "/home/me/rhema").unwrap();
|
||||||
|
store.add_instance("widgets", "/home/me/widgets").unwrap();
|
||||||
|
let instances = store.instances().unwrap();
|
||||||
|
assert_eq!(instances.len(), 2);
|
||||||
|
assert_eq!(instances[0].name, "rhema");
|
||||||
|
assert_eq!(instances[0].session, None);
|
||||||
|
assert!(store.drop_instance("widgets").unwrap());
|
||||||
|
assert_eq!(store.instances().unwrap().len(), 1);
|
||||||
|
assert!(!store.drop_instance("widgets").unwrap(), "already gone");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_the_key_cannot_hold_are_refused() {
|
||||||
|
let store = CodingJobStore::in_memory().unwrap();
|
||||||
|
for bad in ["", "a#b", "a/b", "a:b", "a b"] {
|
||||||
|
let err = store.add_instance(bad, "/tmp").unwrap_err();
|
||||||
|
assert!(matches!(err, JobStoreError::InvalidName { .. }), "{bad}");
|
||||||
|
}
|
||||||
|
store.add_instance("ok-name_1", "/tmp").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_duplicate_add_names_the_collision() {
|
||||||
|
let store = CodingJobStore::in_memory().unwrap();
|
||||||
|
store.add_instance("rhema", "/tmp/rhema").unwrap();
|
||||||
|
let err = store.add_instance("rhema", "/tmp/other").unwrap_err();
|
||||||
|
assert!(matches!(err, JobStoreError::Duplicate { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_live_job_per_instance() {
|
||||||
|
let store = CodingJobStore::in_memory().unwrap();
|
||||||
|
store.add_instance("rhema", "/tmp/rhema").unwrap();
|
||||||
|
let first = store.enqueue("rhema", "fix the lint").unwrap();
|
||||||
|
assert_eq!(first.status, JobStatus::Queued);
|
||||||
|
let err = store.enqueue("rhema", "also this").unwrap_err();
|
||||||
|
assert!(matches!(err, JobStoreError::Busy { .. }));
|
||||||
|
store.finish(first.id, "lint fixed").unwrap();
|
||||||
|
// Done unblocks the next enqueue.
|
||||||
|
store.enqueue("rhema", "run the tests").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finishing_frees_but_a_second_instance_runs_parallel() {
|
||||||
|
let store = CodingJobStore::in_memory().unwrap();
|
||||||
|
store.add_instance("a", "/tmp/a").unwrap();
|
||||||
|
store.add_instance("b", "/tmp/b").unwrap();
|
||||||
|
store.enqueue("a", "one").unwrap();
|
||||||
|
store.enqueue("b", "two").unwrap();
|
||||||
|
assert_eq!(store.queued().unwrap().len(), 2);
|
||||||
|
assert!(store.live_job("a").unwrap().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enqueue_requires_the_instance_and_the_status_tool_finds_the_job() {
|
||||||
|
let store = CodingJobStore::in_memory().unwrap();
|
||||||
|
let err = store.enqueue("ghost", "x").unwrap_err();
|
||||||
|
assert!(matches!(err, JobStoreError::UnknownInstance { .. }));
|
||||||
|
store.add_instance("rhema", "/tmp/rhema").unwrap();
|
||||||
|
let job = store.enqueue("rhema", "do it").unwrap();
|
||||||
|
let read = store.job(job.id).unwrap().unwrap();
|
||||||
|
assert_eq!(read.prompt, "do it");
|
||||||
|
assert_eq!(store.jobs_for("rhema", 10).unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_drop_is_refused_while_a_job_is_live() {
|
||||||
|
let store = CodingJobStore::in_memory().unwrap();
|
||||||
|
store.add_instance("rhema", "/tmp/rhema").unwrap();
|
||||||
|
let job = store.enqueue("rhema", "long work").unwrap();
|
||||||
|
store.mark_running(job.id).unwrap();
|
||||||
|
let err = store.drop_instance("rhema").unwrap_err();
|
||||||
|
assert!(matches!(err, JobStoreError::Busy { .. }));
|
||||||
|
store.fail(job.id, "cancelled").unwrap();
|
||||||
|
assert!(store.drop_instance("rhema").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_session_ride_transitions_marking_and_recover() {
|
||||||
|
let store = CodingJobStore::in_memory().unwrap();
|
||||||
|
store.add_instance("rhema", "/tmp/rhema").unwrap();
|
||||||
|
store.save_session("rhema", r#"{"history":[]}"#).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.instance("rhema").unwrap().unwrap().session,
|
||||||
|
Some(r#"{"history":[]}"#.to_owned())
|
||||||
|
);
|
||||||
|
store.clear_session("rhema").unwrap();
|
||||||
|
assert_eq!(store.instance("rhema").unwrap().unwrap().session, None);
|
||||||
|
store.save_session("ghost", "x").unwrap_err();
|
||||||
|
|
||||||
|
let job = store.enqueue("rhema", "work").unwrap();
|
||||||
|
store.mark_running(job.id).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.job(job.id).unwrap().unwrap().status,
|
||||||
|
JobStatus::Running
|
||||||
|
);
|
||||||
|
// The boot pass: the running row is failed as interrupted; the
|
||||||
|
// other instance's queued row only has its timestamp moved, so
|
||||||
|
// its re-ingest after the restart carries a fresh dedup id.
|
||||||
|
store.add_instance("other", "/tmp/other").unwrap();
|
||||||
|
let queued = store.enqueue("other", "queued work").unwrap();
|
||||||
|
let touched = store.recover().unwrap();
|
||||||
|
assert_eq!(touched, 2);
|
||||||
|
let after = store.job(job.id).unwrap().unwrap();
|
||||||
|
assert_eq!(after.status, JobStatus::Failed);
|
||||||
|
assert!(after.result.unwrap().contains("interrupted"));
|
||||||
|
assert!(!after.status.is_live());
|
||||||
|
assert_eq!(
|
||||||
|
store.job(queued.id).unwrap().unwrap().status,
|
||||||
|
JobStatus::Queued,
|
||||||
|
"still queued, only touched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
//! The `coding_agent` tool: how the resident drives a coding agent —
|
||||||
|
//! named instances over real workspaces, and async jobs against them.
|
||||||
|
//!
|
||||||
|
//! One tool, an `action` argument, the [`crate::phone`] tool's shape:
|
||||||
|
//! `add` pins a name to a workspace directory, `drop` removes it,
|
||||||
|
//! `run` enqueues a job (a `CodingAgent` turn over the instance's
|
||||||
|
//! workspace, resumed from the instance's last coding session unless
|
||||||
|
//! `fresh`), `list` names the instances, and `status` reports jobs.
|
||||||
|
//! The run is
|
||||||
|
//! **async by design**: a coding job is minutes of tool rounds, so the
|
||||||
|
//! call returns the job id in one model round, the jobs dispatcher
|
||||||
|
//! runs the turn off the chat turn ([`crate::jobs`]), and the final
|
||||||
|
//! text lands in the job row and the user's notifications. The model
|
||||||
|
//! answers "it's running" honestly and reads the result later with
|
||||||
|
//! `status`.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||||
|
use rhema_core::ToolSpec;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::job_store::{CodingJobStore, JobStoreError};
|
||||||
|
|
||||||
|
/// The coding-agent tool set over `store`: one tool, `coding_agent`.
|
||||||
|
pub fn registry(store: Arc<CodingJobStore>) -> ToolRegistry {
|
||||||
|
let mut registry = ToolRegistry::new();
|
||||||
|
registry.register(Arc::new(CodingAgent { store }));
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Args {
|
||||||
|
action: String,
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
instance: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
prompt: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
fresh: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
job: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tool: one `coding_agent` call, `action` picks the operation.
|
||||||
|
struct CodingAgent {
|
||||||
|
store: Arc<CodingJobStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodingAgent {
|
||||||
|
fn add(&self, name: &str, path: &str) -> Result<String, String> {
|
||||||
|
if !std::path::Path::new(path).is_dir() {
|
||||||
|
return Err(format!(
|
||||||
|
"no directory {path:?}: the workspace must exist; create or clone it first"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.store
|
||||||
|
.add_instance(name, path)
|
||||||
|
.map(|_| json!({"added": name, "path": path}).to_string())
|
||||||
|
.map_err(in_band)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drop(&self, name: &str) -> Result<String, String> {
|
||||||
|
let dropped = self.store.drop_instance(name).map_err(in_band)?;
|
||||||
|
if dropped {
|
||||||
|
Ok(json!({"dropped": name}).to_string())
|
||||||
|
} else {
|
||||||
|
Err(format!("no instance named {name:?}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&self, instance: &str, prompt: &str, fresh: bool) -> Result<String, String> {
|
||||||
|
if prompt.trim().is_empty() {
|
||||||
|
return Err("`prompt` is empty; the coding agent needs a task".to_owned());
|
||||||
|
}
|
||||||
|
if fresh {
|
||||||
|
// The conversation reset lands before the enqueue: a fresh
|
||||||
|
// flag on a refused run must not wipe a session it did not
|
||||||
|
// replace.
|
||||||
|
self.store.clear_session(instance).map_err(in_band)?;
|
||||||
|
}
|
||||||
|
let job = self.store.enqueue(instance, prompt).map_err(in_band)?;
|
||||||
|
Ok(json!({
|
||||||
|
"job": job.id,
|
||||||
|
"instance": instance,
|
||||||
|
"status": "queued",
|
||||||
|
"note": "the job runs off this chat; its final text lands in \
|
||||||
|
coding_agent status and the user's notifications"
|
||||||
|
})
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The instances: the audit surface for which checkouts a coding
|
||||||
|
/// agent may drive, each with its live job at a glance.
|
||||||
|
fn list(&self) -> Result<String, String> {
|
||||||
|
let instances = self.store.instances().map_err(in_band)?;
|
||||||
|
let listed = instances
|
||||||
|
.iter()
|
||||||
|
.map(|instance| {
|
||||||
|
let live = self
|
||||||
|
.store
|
||||||
|
.live_job(&instance.name)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|job| json!({"id": job.id, "status": job.status.as_str()}));
|
||||||
|
json!({
|
||||||
|
"name": instance.name,
|
||||||
|
"path": instance.path,
|
||||||
|
"has_session": instance.session.is_some(),
|
||||||
|
"live_job": live,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Ok(json!({ "instances": listed }).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The jobs: every recent one, one instance's, or one job's result.
|
||||||
|
fn status(&self, instance: Option<&str>, job: Option<i64>) -> Result<String, String> {
|
||||||
|
if let Some(id) = job {
|
||||||
|
let record = self
|
||||||
|
.store
|
||||||
|
.job(id)
|
||||||
|
.map_err(in_band)?
|
||||||
|
.ok_or_else(|| format!("no job {id}"))?;
|
||||||
|
return Ok(job_json(&record).to_string());
|
||||||
|
}
|
||||||
|
match instance {
|
||||||
|
Some(name) => {
|
||||||
|
if self.store.instance(name).map_err(in_band)?.is_none() {
|
||||||
|
return Err(format!("no instance named {name:?}"));
|
||||||
|
}
|
||||||
|
let jobs = self.store.jobs_for(name, 20).map_err(in_band)?;
|
||||||
|
Ok(json!({
|
||||||
|
"instance": name,
|
||||||
|
"jobs": jobs.iter().map(job_json).collect::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let jobs = self.store.recent(20).map_err(in_band)?;
|
||||||
|
Ok(json!({
|
||||||
|
"jobs": jobs.iter().map(job_json).collect::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job_json(job: &crate::job_store::JobRecord) -> Value {
|
||||||
|
json!({
|
||||||
|
"job": job.id,
|
||||||
|
"instance": job.instance,
|
||||||
|
"status": job.status.as_str(),
|
||||||
|
"result": job.result,
|
||||||
|
"prompt": if job.status.is_live() { Some(job.prompt.as_str()) } else { None },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The store's typed refusals are the model's in-band answers.
|
||||||
|
fn in_band(err: JobStoreError) -> String {
|
||||||
|
err.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for CodingAgent {
|
||||||
|
fn spec(&self) -> ToolSpec {
|
||||||
|
ToolSpec::new(
|
||||||
|
"coding_agent",
|
||||||
|
"Drive a coding agent — a full rhema-coding-agent session with \
|
||||||
|
its own shell — in a named workspace, off this chat. `add` \
|
||||||
|
pins a name to an existing directory; `drop` removes it; \
|
||||||
|
`list` names the instances (the workspaces a coding agent \
|
||||||
|
may drive); `run` enqueues a job whose turn runs in the \
|
||||||
|
background (minutes, not seconds): the answer is the job \
|
||||||
|
id, the final text reaches `status` and the user's \
|
||||||
|
notifications, and the instance's coding session carries \
|
||||||
|
over to the next run unless `fresh`. `status` reads jobs — \
|
||||||
|
every recent one, one instance's, or one job's result. One \
|
||||||
|
live job per instance.",
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": { "type": "string",
|
||||||
|
"enum": ["add", "drop", "run", "list", "status"],
|
||||||
|
"description": "Which operation." },
|
||||||
|
"name": { "type": "string",
|
||||||
|
"description": "add/drop: the instance's name." },
|
||||||
|
"path": { "type": "string",
|
||||||
|
"description": "add: the workspace directory; it \
|
||||||
|
must already exist." },
|
||||||
|
"instance": { "type": "string",
|
||||||
|
"description": "run/status: the instance to act on." },
|
||||||
|
"prompt": { "type": "string",
|
||||||
|
"description": "run: what the coding agent should do." },
|
||||||
|
"fresh": { "type": "boolean",
|
||||||
|
"description": "run: start a fresh coding session \
|
||||||
|
instead of resuming the instance's last one." },
|
||||||
|
"job": { "type": "integer",
|
||||||
|
"description": "status: one job's id, for its result." }
|
||||||
|
},
|
||||||
|
"required": ["action"]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||||
|
let args: Args =
|
||||||
|
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||||
|
match args.action.as_str() {
|
||||||
|
"add" => self.add(
|
||||||
|
required(&args.name, "add needs `name`")?,
|
||||||
|
required(&args.path, "add needs `path`")?,
|
||||||
|
),
|
||||||
|
"drop" => self.drop(required(&args.name, "drop needs `name`")?),
|
||||||
|
"run" => self.run(
|
||||||
|
required(&args.instance, "run needs `instance`")?,
|
||||||
|
required(&args.prompt, "run needs `prompt`")?,
|
||||||
|
args.fresh.unwrap_or(false),
|
||||||
|
),
|
||||||
|
"list" => self.list(),
|
||||||
|
"status" => self.status(args.instance.as_deref(), args.job),
|
||||||
|
other => Err(format!(
|
||||||
|
"unknown action {other:?}; one of add, drop, run, list, status"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required<'a>(value: &'a Option<String>, complaint: &str) -> Result<&'a str, String> {
|
||||||
|
value
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.ok_or_else(|| complaint.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rhema_autonomous_agent::Allowlist;
|
||||||
|
use rhema_core::{ToolCall, ToolContext, ToolResolver};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::Arc as StdArc;
|
||||||
|
|
||||||
|
fn resolver(store: StdArc<CodingJobStore>) -> StdArc<dyn ToolResolver> {
|
||||||
|
registry(store).resolver_for(&Allowlist::permissive())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tempdir() -> std::path::PathBuf {
|
||||||
|
let dir = tempfile::tempdir().unwrap().keep();
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call(resolver: &StdArc<dyn ToolResolver>, args: Value) -> Result<String, String> {
|
||||||
|
let result = resolver
|
||||||
|
.resolve(
|
||||||
|
ToolCall {
|
||||||
|
id: "call_1".into(),
|
||||||
|
name: "coding_agent".into(),
|
||||||
|
args,
|
||||||
|
provider_data: Value::Null,
|
||||||
|
},
|
||||||
|
&ToolContext::default(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if result.is_error {
|
||||||
|
Err(result.content)
|
||||||
|
} else {
|
||||||
|
Ok(result.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn add_run_and_status_round_trip() {
|
||||||
|
let store = StdArc::new(CodingJobStore::in_memory().unwrap());
|
||||||
|
let resolver = resolver(store.clone());
|
||||||
|
let workspace = tempdir();
|
||||||
|
let added = call(
|
||||||
|
&resolver,
|
||||||
|
json!({"action": "add", "name": "rhema",
|
||||||
|
"path": workspace.to_str().unwrap()}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(added.contains("\"added\":\"rhema\""), "{added}");
|
||||||
|
|
||||||
|
let run = call(
|
||||||
|
&resolver,
|
||||||
|
json!({"action": "run", "instance": "rhema", "prompt": "fix the lint"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(run.contains("\"status\":\"queued\""), "{run}");
|
||||||
|
|
||||||
|
let listed = call(&resolver, json!({"action": "list"})).await.unwrap();
|
||||||
|
assert!(listed.contains("rhema"), "{listed}");
|
||||||
|
assert!(listed.contains("live_job"), "{listed}");
|
||||||
|
|
||||||
|
let recent = call(&resolver, json!({"action": "status"})).await.unwrap();
|
||||||
|
assert!(recent.contains("fix the lint"), "{recent}");
|
||||||
|
|
||||||
|
let status = call(&resolver, json!({"action": "status", "instance": "rhema"}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(status.contains("fix the lint"), "{status}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_refusals_are_in_band_and_named() {
|
||||||
|
let store = StdArc::new(CodingJobStore::in_memory().unwrap());
|
||||||
|
let resolver = resolver(store.clone());
|
||||||
|
let workspace = tempdir();
|
||||||
|
|
||||||
|
let err = call(&resolver, json!({"action": "explode"}))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("unknown action"), "{err}");
|
||||||
|
let err = call(&resolver, json!({"action": "add", "name": "x"}))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("add needs `path`"), "{err}");
|
||||||
|
let err = call(
|
||||||
|
&resolver,
|
||||||
|
json!({"action": "add", "name": "x", "path": "/nowhere-at-all"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("no directory"), "{err}");
|
||||||
|
|
||||||
|
call(
|
||||||
|
&resolver,
|
||||||
|
json!({"action": "add", "name": "rhema", "path": workspace.to_str().unwrap()}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = call(
|
||||||
|
&resolver,
|
||||||
|
json!({"action": "run", "instance": "ghost", "prompt": "x"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("no instance named"), "{err}");
|
||||||
|
|
||||||
|
call(
|
||||||
|
&resolver,
|
||||||
|
json!({"action": "run", "instance": "rhema", "prompt": "long work"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = call(
|
||||||
|
&resolver,
|
||||||
|
json!({"action": "run", "instance": "rhema", "prompt": "more"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("job in flight"), "{err}");
|
||||||
|
let err = call(&resolver, json!({"action": "drop", "name": "rhema"}))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("job in flight"), "{err}");
|
||||||
|
|
||||||
|
let err = call(&resolver, json!({"action": "status", "job": 999}))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("no job 999"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fresh_clears_the_session_only_on_a_valid_run() {
|
||||||
|
let store = StdArc::new(CodingJobStore::in_memory().unwrap());
|
||||||
|
store.add_instance("rhema", "/tmp/rhema").unwrap();
|
||||||
|
store.save_session("rhema", r#"{"history":[1]}"#).unwrap();
|
||||||
|
let resolver = resolver(store.clone());
|
||||||
|
// The refused run wipes nothing.
|
||||||
|
let err = call(
|
||||||
|
&resolver,
|
||||||
|
json!({"action": "run", "instance": "ghost", "prompt": "x", "fresh": true}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("no instance named"), "{err}");
|
||||||
|
assert!(store.instance("rhema").unwrap().unwrap().session.is_some());
|
||||||
|
// The accepted run clears before enqueueing.
|
||||||
|
call(
|
||||||
|
&resolver,
|
||||||
|
json!({"action": "run", "instance": "rhema", "prompt": "x", "fresh": true}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.instance("rhema").unwrap().unwrap().session.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
//! The coding jobs path: the source that ingests queued rows, and the
|
||||||
|
//! handler that turns one into a coding-agent turn off the chat.
|
||||||
|
//!
|
||||||
|
//! **The key:** `job:<instance>#<id>` — per job, like a forge thread is
|
||||||
|
//! per thread, so the dispatcher's dedup and lease machinery applies
|
||||||
|
//! per job while the *conversation* carries in the instance row's
|
||||||
|
//! session blob (`crate::job_store`), keyed by instance. A run the
|
||||||
|
//! restart cut is failed interrupted by the store's recover pass; its
|
||||||
|
//! next `run` resumes from the last saved session.
|
||||||
|
//!
|
||||||
|
//! **The handler** is the forge handler's sibling with a different
|
||||||
|
//! driver: the deterministic part stays ahead of the model — the
|
||||||
|
//! instance and job resolve through the store, the workspace is the
|
||||||
|
//! instance's path, the session blob (or a fresh agent) is loaded, the
|
||||||
|
//! allowlist is permissive exactly as the forge path's — and then one
|
||||||
|
//! `CodingAgent` turn runs. The final text lands in the job row and
|
||||||
|
//! the user's notifications; the turn itself streams nowhere, its
|
||||||
|
//! observability is the stderr tool log.
|
||||||
|
//!
|
||||||
|
//! The permission check is the forge handler's own argument: this
|
||||||
|
//! handler's one tool is the agent's shell, so a policy without it
|
||||||
|
//! fails before any model call. The trust boundary is unchanged from
|
||||||
|
//! the forge path — the bearer-gated surface and the instance list.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rhema_autonomous_agent::{Handler, SessionContext, Source, SourceError, async_trait};
|
||||||
|
use rhema_coding_agent::{AllowAll, CodingAgent, CodingAgentConfig};
|
||||||
|
use rhema_core::{Event as ModelEvent, EventSink};
|
||||||
|
use rhema_rsd::{Dispatcher, Event, KeyFn, Outcome, ResourceKey};
|
||||||
|
|
||||||
|
use crate::backends::BackendRegistry;
|
||||||
|
use crate::handler::final_text;
|
||||||
|
use crate::interactive::passthrough_key_fn;
|
||||||
|
use crate::job_store::{CodingJobStore, JobStatus};
|
||||||
|
use crate::notifications::{DEFAULT_DEVICE, NotificationStore};
|
||||||
|
|
||||||
|
/// The source name the coding jobs ingest under.
|
||||||
|
pub const JOBS_SOURCE: &str = "jobs";
|
||||||
|
|
||||||
|
/// One job's dispatch key: `job:<instance>#<id>`.
|
||||||
|
pub fn job_key(instance: &str, id: i64) -> String {
|
||||||
|
format!("job:{instance}#{id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `key` belongs to the coding-jobs context — the check the
|
||||||
|
/// drivers' ownership split keys on, alongside the resident prefixes
|
||||||
|
/// and the forge keys.
|
||||||
|
pub fn is_job_key(key: &str) -> bool {
|
||||||
|
key.starts_with("job:")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the dispatch key back into instance and job id.
|
||||||
|
pub fn parse_job_key(key: &ResourceKey) -> Option<(String, i64)> {
|
||||||
|
let raw = key.as_str().strip_prefix("job:")?;
|
||||||
|
let (instance, id) = raw.rsplit_once('#')?;
|
||||||
|
if instance.is_empty() || id.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((instance.to_owned(), id.parse().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The detectable stream of queued coding jobs: the job table feeding
|
||||||
|
/// the jobs dispatcher — the task scheduler's shape, one source,
|
||||||
|
/// registered once, before the first detect.
|
||||||
|
pub struct JobsSource {
|
||||||
|
store: Arc<CodingJobStore>,
|
||||||
|
dispatcher: Arc<Dispatcher>,
|
||||||
|
key_fn: Arc<KeyFn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobsSource {
|
||||||
|
/// Registers the `jobs` source's key function on `dispatcher` and
|
||||||
|
/// returns the source feeding it.
|
||||||
|
pub fn new(dispatcher: &Arc<Dispatcher>, store: Arc<CodingJobStore>) -> Self {
|
||||||
|
let key_fn = passthrough_key_fn();
|
||||||
|
dispatcher.register_source(JOBS_SOURCE, key_fn.clone());
|
||||||
|
Self {
|
||||||
|
store,
|
||||||
|
dispatcher: dispatcher.clone(),
|
||||||
|
key_fn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The event one queued row becomes. The dedup id carries the
|
||||||
|
/// row's `updated_at_millis`, so a row the recover pass re-queued
|
||||||
|
/// after a restart ingests fresh instead of drowning in the
|
||||||
|
/// previous process's consumed set.
|
||||||
|
fn event(job: &crate::job_store::JobRecord, now: u64) -> Event {
|
||||||
|
Event {
|
||||||
|
id: format!("job-{}@{}", job.id, job.updated_at_millis),
|
||||||
|
source: JOBS_SOURCE.to_owned(),
|
||||||
|
payload: json_key(&job.instance, job.id),
|
||||||
|
received_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_key(instance: &str, id: i64) -> serde_json::Value {
|
||||||
|
serde_json::json!({ "key": job_key(instance, id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Source for JobsSource {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
JOBS_SOURCE
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_fn(&self) -> Arc<KeyFn> {
|
||||||
|
self.key_fn.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn detect(&self) -> Result<usize, SourceError> {
|
||||||
|
let queued = self
|
||||||
|
.store
|
||||||
|
.queued()
|
||||||
|
.map_err(|err| SourceError::Backend(format!("job store: {err}")))?;
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64;
|
||||||
|
let mut fed = 0;
|
||||||
|
for job in queued {
|
||||||
|
self.dispatcher
|
||||||
|
.ingest(Self::event(&job, now))
|
||||||
|
.map_err(|err| SourceError::Backend(format!("job ingest: {err}")))?;
|
||||||
|
fed += 1;
|
||||||
|
}
|
||||||
|
Ok(fed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The coding-agent jobs handler: one dispatch of `job:<instance>#<id>`
|
||||||
|
/// is one coding-agent turn, and the turn's final text is the job's
|
||||||
|
/// result and the user's notification.
|
||||||
|
pub struct CodingJobHandler {
|
||||||
|
backends: Arc<BackendRegistry>,
|
||||||
|
store: Arc<CodingJobStore>,
|
||||||
|
notifications: Arc<NotificationStore>,
|
||||||
|
max_turn_steps: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodingJobHandler {
|
||||||
|
/// A handler whose turns run in the instance's workspace, read per
|
||||||
|
/// dispatch, with the deployment's active provider pair and the
|
||||||
|
/// resident turn's step budget.
|
||||||
|
pub fn new(
|
||||||
|
backends: Arc<BackendRegistry>,
|
||||||
|
store: Arc<CodingJobStore>,
|
||||||
|
notifications: Arc<NotificationStore>,
|
||||||
|
max_turn_steps: u32,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
backends,
|
||||||
|
store,
|
||||||
|
notifications,
|
||||||
|
max_turn_steps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One dispatch, failed with a reason on any step; every failure
|
||||||
|
/// lands in the job row before it lands in the outcome, so the
|
||||||
|
/// model's `status` read and the user's notification agree.
|
||||||
|
async fn dispatch(&self, ctx: &mut SessionContext) -> Result<Outcome, String> {
|
||||||
|
let (instance_name, job_id) = parse_job_key(&ctx.key)
|
||||||
|
.ok_or_else(|| format!("resource key {} is not a job key", ctx.key))?;
|
||||||
|
let job = self
|
||||||
|
.store
|
||||||
|
.job(job_id)
|
||||||
|
.map_err(|err| format!("job lookup for {job_id} failed: {err}"))?
|
||||||
|
.ok_or_else(|| format!("job {job_id} is not in the table"))?;
|
||||||
|
if job.status != JobStatus::Queued {
|
||||||
|
// A re-delivered event over a row already running or done:
|
||||||
|
// the dedup boundary is the dispatcher's; this handler
|
||||||
|
// treats the row as the truth.
|
||||||
|
return Ok(Outcome::Handled);
|
||||||
|
}
|
||||||
|
let instance = self
|
||||||
|
.store
|
||||||
|
.instance(&instance_name)
|
||||||
|
.map_err(|err| format!("instance lookup for {instance_name} failed: {err}"))?
|
||||||
|
.ok_or_else(|| format!("instance {instance_name:?} is gone; the job cannot run"))?;
|
||||||
|
let workspace = std::path::PathBuf::from(&instance.path);
|
||||||
|
if !workspace.is_dir() {
|
||||||
|
let reason = format!(
|
||||||
|
"the workspace {workspace:?} does not exist; add the instance again with a live path"
|
||||||
|
);
|
||||||
|
self.fail(job_id, &instance_name, &reason);
|
||||||
|
return Ok(Outcome::Failed(reason));
|
||||||
|
}
|
||||||
|
self.store
|
||||||
|
.mark_running(job_id)
|
||||||
|
.map_err(|err| format!("job {job_id} could not start: {err}"))?;
|
||||||
|
|
||||||
|
let (provider, model) = self.backends.current();
|
||||||
|
let mut config = CodingAgentConfig::new(model, &workspace);
|
||||||
|
config.max_turn_steps = self.max_turn_steps;
|
||||||
|
let stashed = self
|
||||||
|
.store
|
||||||
|
.session(&instance_name)
|
||||||
|
.map_err(|err| format!("the session lookup for {instance_name:?} failed: {err}"))?;
|
||||||
|
let mut agent = match stashed {
|
||||||
|
Some(blob) => {
|
||||||
|
let session: rhema_core::Session = serde_json::from_str(&blob).map_err(|err| {
|
||||||
|
let reason = format!(
|
||||||
|
"the stashed coding session for {instance_name:?} does not parse: {err}"
|
||||||
|
);
|
||||||
|
self.fail(job_id, &instance_name, &reason);
|
||||||
|
reason
|
||||||
|
})?;
|
||||||
|
CodingAgent::from_session(provider, config, session)
|
||||||
|
}
|
||||||
|
None => CodingAgent::new(provider, config),
|
||||||
|
}
|
||||||
|
.map_err(|err| {
|
||||||
|
let reason = format!("cannot start the coding agent for {instance_name:?}: {err}");
|
||||||
|
self.fail(job_id, &instance_name, &reason);
|
||||||
|
reason
|
||||||
|
})?;
|
||||||
|
// Commit-before-execute rides the dispatcher exactly as the
|
||||||
|
// forge handler's does: the intent is in the store before the
|
||||||
|
// first tool runs, and a crash-left dangling call revives at
|
||||||
|
// turn entry (CodingAgent::turn's own revive, over the resumed
|
||||||
|
// session).
|
||||||
|
agent.set_commit_hook(Some(ctx.commit_hook()));
|
||||||
|
|
||||||
|
let mut sink = JobSink;
|
||||||
|
let output = match agent.turn(&job.prompt, &mut sink, Arc::new(AllowAll)).await {
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(err) => {
|
||||||
|
let reason = format!("the coding turn for {instance_name:?} failed: {err}");
|
||||||
|
// The conversation up to the failure is still the
|
||||||
|
// instance's: saved, so a follow-up run continues it.
|
||||||
|
self.save_session(&instance_name, &agent);
|
||||||
|
self.fail(job_id, &instance_name, &reason);
|
||||||
|
return Ok(Outcome::Failed(reason));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.save_session(&instance_name, &agent);
|
||||||
|
let reply = final_text(&output.message).ok_or_else(|| {
|
||||||
|
let reason = format!("the model returned no text to record for job {job_id}");
|
||||||
|
self.fail(job_id, &instance_name, &reason);
|
||||||
|
reason
|
||||||
|
})?;
|
||||||
|
self.store
|
||||||
|
.finish(job_id, &reply)
|
||||||
|
.map_err(|err| format!("job {job_id} could not be finished: {err}"))?;
|
||||||
|
// The implicit channel, the task keys' rule: the reply reaches
|
||||||
|
// the user away from this chat; `status` is the model's way to
|
||||||
|
// read it again.
|
||||||
|
self.notify(&instance_name, &reply);
|
||||||
|
Ok(Outcome::Handled)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_session(&self, instance: &str, agent: &CodingAgent) {
|
||||||
|
match serde_json::to_string(agent.session()) {
|
||||||
|
Ok(blob) => {
|
||||||
|
let _ = self.store.save_session(instance, &blob);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!(
|
||||||
|
"[claw] jobs: the coding session for {instance:?} does not serialize: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(&self, job_id: i64, instance: &str, reason: &str) {
|
||||||
|
if let Err(err) = self.store.fail(job_id, reason) {
|
||||||
|
eprintln!("[claw] jobs: job {job_id} could not be failed: {err}");
|
||||||
|
}
|
||||||
|
self.notify(instance, &format!("coding job failed: {reason}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notify(&self, instance: &str, body: &str) {
|
||||||
|
if let Err(err) = self.notifications.push(DEFAULT_DEVICE, instance, body) {
|
||||||
|
eprintln!("[claw] jobs: the notification for {instance:?} failed: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Handler for CodingJobHandler {
|
||||||
|
async fn handle(&self, ctx: &mut SessionContext) -> Outcome {
|
||||||
|
match self.dispatch(ctx).await {
|
||||||
|
Ok(outcome) => outcome,
|
||||||
|
Err(reason) => Outcome::Failed(reason),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The job's stderr log: tool traffic only. Nothing streams to a chat
|
||||||
|
/// surface — the result is the row and the notification.
|
||||||
|
struct JobSink;
|
||||||
|
|
||||||
|
impl EventSink for JobSink {
|
||||||
|
fn emit(&mut self, event: ModelEvent<'_>) {
|
||||||
|
match event {
|
||||||
|
ModelEvent::ToolCallStart { name, .. } => eprintln!("[claw] job tool call: {name}"),
|
||||||
|
ModelEvent::ToolResult {
|
||||||
|
content, is_error, ..
|
||||||
|
} => {
|
||||||
|
let mark = if is_error { "failed" } else { "ok" };
|
||||||
|
let line = content.lines().next().unwrap_or("");
|
||||||
|
let mut cut = line.chars().take(72).collect::<String>();
|
||||||
|
if line.chars().count() > 72 {
|
||||||
|
cut.push('…');
|
||||||
|
}
|
||||||
|
eprintln!("[claw] job tool {mark}: {cut}");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::backends::Backend;
|
||||||
|
use rhema_autonomous_agent::Driver;
|
||||||
|
use rhema_core::{Message as CoreMessage, MockProvider, ModelOutput, Role, StopReason, Usage};
|
||||||
|
use rhema_rsd::{DispatcherConfig, SqliteStore, SystemClock};
|
||||||
|
|
||||||
|
/// A registry whose only backend is the scripted mock.
|
||||||
|
fn mock_handler(
|
||||||
|
replies: Vec<ModelOutput>,
|
||||||
|
store: Arc<CodingJobStore>,
|
||||||
|
notifications: Arc<NotificationStore>,
|
||||||
|
) -> CodingJobHandler {
|
||||||
|
let provider: Arc<dyn rhema_core::ModelProvider> = Arc::new(MockProvider::new(replies));
|
||||||
|
let backends = Arc::new(
|
||||||
|
BackendRegistry::new(vec![Backend::headless("mock", provider)], None)
|
||||||
|
.expect("a single mock backend always constructs"),
|
||||||
|
);
|
||||||
|
CodingJobHandler::new(backends, store, notifications, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(text: &str) -> ModelOutput {
|
||||||
|
ModelOutput {
|
||||||
|
message: CoreMessage::text(Role::Assistant, text),
|
||||||
|
stop_reason: StopReason::EndTurn,
|
||||||
|
usage: Usage::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tempdir() -> std::path::PathBuf {
|
||||||
|
let dir = tempfile::tempdir().unwrap().keep();
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_queued_job_runs_to_done_and_notifies() {
|
||||||
|
let workspace = tempdir();
|
||||||
|
let store = Arc::new(CodingJobStore::in_memory().unwrap());
|
||||||
|
store
|
||||||
|
.add_instance("rhema", workspace.to_str().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let job = store.enqueue("rhema", "fix the lint").unwrap();
|
||||||
|
let notifications = Arc::new(NotificationStore::in_memory().unwrap());
|
||||||
|
let handler = mock_handler(
|
||||||
|
vec![reply("lint fixed, all green")],
|
||||||
|
store.clone(),
|
||||||
|
notifications.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The whole path: source detects the queued row, the driver
|
||||||
|
// runs the handler over its own dispatcher.
|
||||||
|
let (source, driver) = harness(store.clone(), handler);
|
||||||
|
|
||||||
|
assert_eq!(source.detect().await.unwrap(), 1);
|
||||||
|
let outcomes = driver.run_once().await.unwrap();
|
||||||
|
assert_eq!(outcomes.len(), 1);
|
||||||
|
assert!(matches!(outcomes[0], Outcome::Handled));
|
||||||
|
|
||||||
|
let done = store.job(job.id).unwrap().unwrap();
|
||||||
|
assert_eq!(done.status, JobStatus::Done);
|
||||||
|
assert_eq!(done.result.as_deref(), Some("lint fixed, all green"));
|
||||||
|
let instance = store.instance("rhema").unwrap().unwrap();
|
||||||
|
assert!(instance.session.is_some(), "the session is stashed");
|
||||||
|
let pending = notifications.pending(DEFAULT_DEVICE, 0).unwrap();
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
assert_eq!(pending[0].body, "lint fixed, all green");
|
||||||
|
// The source finds nothing more to feed.
|
||||||
|
assert_eq!(source.detect().await.unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_second_job_resumes_the_first_session() {
|
||||||
|
let workspace = tempdir();
|
||||||
|
let store = Arc::new(CodingJobStore::in_memory().unwrap());
|
||||||
|
store
|
||||||
|
.add_instance("rhema", workspace.to_str().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let notifications = Arc::new(NotificationStore::in_memory().unwrap());
|
||||||
|
let handler = mock_handler(
|
||||||
|
vec![reply("first done"), reply("second done, history intact")],
|
||||||
|
store.clone(),
|
||||||
|
notifications.clone(),
|
||||||
|
);
|
||||||
|
let first = store.enqueue("rhema", "step one").unwrap();
|
||||||
|
let second = store.enqueue("rhema", "step two").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
second.to_string().contains("job in flight"),
|
||||||
|
"one live job per instance: {second}"
|
||||||
|
);
|
||||||
|
let (source, driver) = harness(store.clone(), handler);
|
||||||
|
|
||||||
|
assert_eq!(source.detect().await.unwrap(), 1);
|
||||||
|
driver.run_once().await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.job(first.id).unwrap().unwrap().status,
|
||||||
|
JobStatus::Done
|
||||||
|
);
|
||||||
|
|
||||||
|
// With the first job done, the follow-up enqueues and runs on
|
||||||
|
// the resumed session: the mock's second reply lands, and the
|
||||||
|
// stash the second turn loaded carried the first turn's rounds.
|
||||||
|
let follow = store.enqueue("rhema", "step two").unwrap();
|
||||||
|
assert_eq!(source.detect().await.unwrap(), 1);
|
||||||
|
driver.run_once().await.unwrap();
|
||||||
|
let done = store.job(follow.id).unwrap().unwrap();
|
||||||
|
assert_eq!(done.result.as_deref(), Some("second done, history intact"));
|
||||||
|
let session = store.instance("rhema").unwrap().unwrap().session.unwrap();
|
||||||
|
let session: rhema_core::Session = serde_json::from_str(&session).unwrap();
|
||||||
|
// Two turns: two user inputs, two assistant replies.
|
||||||
|
assert!(session.history.len() >= 4, "{:?}", session.history.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_missing_workspace_fails_the_job_in_band() {
|
||||||
|
let store = Arc::new(CodingJobStore::in_memory().unwrap());
|
||||||
|
store.add_instance("gone", "/nowhere-at-all").unwrap();
|
||||||
|
let job = store.enqueue("gone", "work").unwrap();
|
||||||
|
let notifications = Arc::new(NotificationStore::in_memory().unwrap());
|
||||||
|
let handler = mock_handler(
|
||||||
|
vec![reply("never reached")],
|
||||||
|
store.clone(),
|
||||||
|
notifications.clone(),
|
||||||
|
);
|
||||||
|
let (source, driver) = harness(store.clone(), handler);
|
||||||
|
|
||||||
|
assert_eq!(source.detect().await.unwrap(), 1);
|
||||||
|
let outcomes = driver.run_once().await.unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(&outcomes[0], Outcome::Failed(reason) if reason.contains("does not exist")),
|
||||||
|
"{outcomes:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.job(job.id).unwrap().unwrap().status,
|
||||||
|
JobStatus::Failed
|
||||||
|
);
|
||||||
|
let pending = notifications.pending(DEFAULT_DEVICE, 0).unwrap();
|
||||||
|
assert!(pending[0].body.contains("coding job failed"), "{pending:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The source and driver over one in-memory dispatcher, the shape
|
||||||
|
/// the daemon wires and these tests drive.
|
||||||
|
fn harness(store: Arc<CodingJobStore>, handler: CodingJobHandler) -> (JobsSource, Driver) {
|
||||||
|
let rsd_store = Arc::new(SqliteStore::open(tempdir().join("rsd.db")).unwrap());
|
||||||
|
// A zero debounce: the queued row is admitted on this pass,
|
||||||
|
// the shape the daemon's jobs dispatcher runs under.
|
||||||
|
let dispatcher = Arc::new(Dispatcher::new(
|
||||||
|
DispatcherConfig {
|
||||||
|
debounce_window_millis: 0,
|
||||||
|
..DispatcherConfig::default()
|
||||||
|
},
|
||||||
|
Arc::new(SystemClock),
|
||||||
|
rsd_store,
|
||||||
|
));
|
||||||
|
let source = JobsSource::new(&dispatcher, store);
|
||||||
|
let driver = Driver::new(dispatcher, Arc::new(handler))
|
||||||
|
.with_holder("test-jobs")
|
||||||
|
.owning(Arc::new(|key: &ResourceKey| is_job_key(key.as_str())));
|
||||||
|
(source, driver)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
//! `rhema-claw` — the resident agent daemon: forge watches plus the chat
|
||||||
|
//! HTTP surface, the first consumer of `rhema-autonomous-agent`.
|
||||||
|
//!
|
||||||
|
//! The spec invariants this crate exists to keep (specs/
|
||||||
|
//! rhema-framework-v1.md): detection is deterministic code, never the
|
||||||
|
//! model (§1) — every timer here drives a detect pass and an admission
|
||||||
|
//! step, and the model is reached only inside a handler run; there is no
|
||||||
|
//! periodic, unconditional model-invoking timer (§7) — a scheduled task
|
||||||
|
//! is an event the [`TaskScheduler`] detects, not a heartbeat; and the
|
||||||
|
//! open interactive surface never resolves keys (§6.2) — one resident
|
||||||
|
//! key is one owned session, forever. Two dispatchers share one SQLite
|
||||||
|
//! store: the forge dispatcher turns watch activity into [`ClawHandler`]
|
||||||
|
//! runs (one turn per dispatch, the final text posted back as the
|
||||||
|
//! thread's comment); the resident dispatcher — keys prefixed
|
||||||
|
//! `interactive:`, `task:`, `phone:`, or `calendar:`, the contract
|
||||||
|
//! [`interactive`] documents — turns chat messages, `/v1/ingest`
|
||||||
|
//! events, due tasks, and due calendar reminders into
|
||||||
|
//! [`InteractiveHandler`] runs (one turn per dispatch, streamed to a
|
||||||
|
//! chat subscriber or auto-notified to a device).
|
||||||
|
//!
|
||||||
|
//! Layout:
|
||||||
|
//! - [`config`] — the YAML config: the watch seed, paths, HTTP surface,
|
||||||
|
//! provider and model
|
||||||
|
//! - [`bootstrap`] — the SOUL/USER/ENVIRONMENT store, the system prompt
|
||||||
|
//! rendered from it per dispatch with the dispatch's tool roster, and
|
||||||
|
//! the `bootstrap_save` tool
|
||||||
|
//! - [`watch_store`] — the persistent watch table (SQLite, shared file)
|
||||||
|
//! - [`notifications`] — the notification table: the outbound queue the
|
||||||
|
//! devices poll and ack
|
||||||
|
//! - [`task_store`] — the task table: scheduled prompts, inert until the
|
||||||
|
//! scheduler detects them due
|
||||||
|
//! - [`turn_store`] — the turn-history table: what each key's stream
|
||||||
|
//! streamed, appended per event, replayed whole after a restart
|
||||||
|
//! - [`hub`] — the event hub: per-key record and broadcast of turn events
|
||||||
|
//! - [`identity`] — the agent's presentation identity: name, mark,
|
||||||
|
//! images, served over `/v1/agent` and settable by the agent itself
|
||||||
|
//! through the `identity_set` tool
|
||||||
|
//! - [`consents`] — the consent table and wake bridge: a running turn
|
||||||
|
//! asks the human (a secret to store, permission to act) and waits;
|
||||||
|
//! the chat surface answers through the consent routes
|
||||||
|
//! - [`attachments`] — the attachment store and the `attach_file` tool:
|
||||||
|
//! workspace files handed to the human as fetchable cards
|
||||||
|
//! - [`phone`] — the phone state and command queue: reported status to
|
||||||
|
//! read, consent-gated control actions the device executes
|
||||||
|
//! - [`watch_tools`] — the agent's watch-management tool set
|
||||||
|
//! - [`notify_tools`] — the `notify` tool: the queue a device polls
|
||||||
|
//! - [`task_tools`] — `schedule_task`, `list_tasks`, `cancel_task`
|
||||||
|
//! - [`forge`] — the sources: one detectable stream per forge kind
|
||||||
|
//! - [`calendar`] — the [`CalendarSource`]: due calendar reminders in,
|
||||||
|
//! events out
|
||||||
|
//! - [`task_source`] — the [`TaskScheduler`]: due tasks in, events out
|
||||||
|
//! - [`handler`] — the forge dispatch: snapshot, inbox, one turn, one
|
||||||
|
//! comment
|
||||||
|
//! - [`interactive`] — the resident dispatch: the key-prefix contract,
|
||||||
|
//! inbox, one turn, one streamed reply, auto-notify
|
||||||
|
//! - [`http`] — the routes: chat, sessions, watches, ingest,
|
||||||
|
//! notifications, the event stream
|
||||||
|
//! - [`openai`] — the `/v1/chat/completions` shim for stock web UIs
|
||||||
|
//! - [`daemon`] — the assembly and the loop (`run` with HTTP, and the
|
||||||
|
//! headless `--once` shape [`run_once`])
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
|
pub mod accounts_tools;
|
||||||
|
pub mod attachments;
|
||||||
|
pub mod backends;
|
||||||
|
pub mod bootstrap;
|
||||||
|
pub mod calendar;
|
||||||
|
pub mod config;
|
||||||
|
pub mod consents;
|
||||||
|
pub mod daemon;
|
||||||
|
pub mod environment;
|
||||||
|
pub mod forge;
|
||||||
|
pub mod handler;
|
||||||
|
pub mod http;
|
||||||
|
pub mod hub;
|
||||||
|
mod identity;
|
||||||
|
pub mod interactive;
|
||||||
|
pub mod job_store;
|
||||||
|
pub mod job_tools;
|
||||||
|
pub mod jobs;
|
||||||
|
#[cfg(feature = "mock")]
|
||||||
|
pub mod mock;
|
||||||
|
pub mod notifications;
|
||||||
|
pub mod notify_tools;
|
||||||
|
pub mod openai;
|
||||||
|
pub mod phone;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod task_source;
|
||||||
|
pub mod task_store;
|
||||||
|
pub mod task_tools;
|
||||||
|
pub mod triage;
|
||||||
|
pub mod turn_store;
|
||||||
|
pub mod watch_store;
|
||||||
|
pub mod watch_tools;
|
||||||
|
|
||||||
|
pub use backends::{Backend, BackendRegistry};
|
||||||
|
pub use bootstrap::{
|
||||||
|
BOOTSTRAP_SAVE, Bootstrap, BootstrapError, BootstrapKind, BootstrapStatus, DEFAULT_SYSTEM_MD,
|
||||||
|
render_system,
|
||||||
|
};
|
||||||
|
pub use calendar::CalendarSource;
|
||||||
|
pub use config::{ClawConfig, ConfigError, ForgeKind, Watch};
|
||||||
|
pub use daemon::{ClawError, Daemon, TickReport, run_once};
|
||||||
|
pub use environment::write_report;
|
||||||
|
pub use forge::ForgeSource;
|
||||||
|
pub use handler::ClawHandler;
|
||||||
|
pub use hub::{EventHub, TurnEvent};
|
||||||
|
pub use identity::{AgentIdentity, IdentityError, IdentityStore, ImageKind};
|
||||||
|
pub use interactive::InteractiveHandler;
|
||||||
|
#[cfg(feature = "mock")]
|
||||||
|
pub use mock::MockLoop;
|
||||||
|
pub use notifications::{NotificationRecord, NotificationStore, NotificationStoreError};
|
||||||
|
pub use settings::SettingsStore;
|
||||||
|
pub use task_source::{TASKS_SOURCE, TaskScheduler};
|
||||||
|
pub use task_store::{TaskKind, TaskRecord, TaskStore, TaskStoreError};
|
||||||
|
pub use turn_store::{TurnStore, TurnStoreError};
|
||||||
|
pub use watch_store::{ClawStore, WatchRecord, WatchStoreError};
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
//! Binary wiring only: parse the two args, resolve the model backend from
|
||||||
|
//! the stored rhema credentials, run the daemon. The loop and its
|
||||||
|
//! assembly live in `rhema_claw::daemon`.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use rhema_claw::{ClawConfig, ClawError, Daemon, run_once};
|
||||||
|
use rhema_rsd::{DispatcherConfig, Outcome};
|
||||||
|
|
||||||
|
/// `rhema-claw` — the resident forge-watching agent daemon.
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "rhema-claw",
|
||||||
|
version,
|
||||||
|
about = "Resident forge-watching agent daemon"
|
||||||
|
)]
|
||||||
|
struct Args {
|
||||||
|
/// Path to the claw config. Defaults to ~/.config/rhema/claw.yaml.
|
||||||
|
#[arg(long)]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Run one detect pass and one driver pass, then exit — the
|
||||||
|
/// cron-friendly shape for ephemeral machines.
|
||||||
|
#[arg(long)]
|
||||||
|
once: bool,
|
||||||
|
|
||||||
|
/// Answer every turn with canned streamed text: the zero-credential
|
||||||
|
/// debug backend. Requires the `mock` feature.
|
||||||
|
#[arg(long)]
|
||||||
|
mock: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
if let Err(err) = run(Args::parse()).await {
|
||||||
|
eprintln!("error: {err}");
|
||||||
|
let mut source: &dyn std::error::Error = &err;
|
||||||
|
while let Some(cause) = source.source() {
|
||||||
|
eprintln!(" caused by: {cause}");
|
||||||
|
source = cause;
|
||||||
|
}
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(args: Args) -> Result<(), ClawError> {
|
||||||
|
let path = match args.config {
|
||||||
|
Some(path) => path,
|
||||||
|
None => ClawConfig::default_path()?,
|
||||||
|
};
|
||||||
|
let config = ClawConfig::load_from(&path)?;
|
||||||
|
let backends = if args.mock {
|
||||||
|
mock_backends()
|
||||||
|
} else {
|
||||||
|
resolve_backends(&config)?
|
||||||
|
};
|
||||||
|
eprintln!(
|
||||||
|
"[claw] {} seeded watch(es), active {}/{}, db {}, http {}",
|
||||||
|
config.watches.len(),
|
||||||
|
backends.active().0,
|
||||||
|
backends.active().1,
|
||||||
|
config
|
||||||
|
.db_path()
|
||||||
|
.map(|db| db.display().to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
config.listen_addr(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if args.once {
|
||||||
|
// The approval posture is deliberate (spec §8): say it once at
|
||||||
|
// startup so the operator reads it next to the config that
|
||||||
|
// turns it on, not in a README three weeks in.
|
||||||
|
if !config.watches.is_empty() {
|
||||||
|
eprintln!("[claw] note: forge dispatches run tool calls without approval prompts");
|
||||||
|
}
|
||||||
|
let report = run_once(&config, backends.clone()).await?;
|
||||||
|
// A cron pass reports its failures through the exit code.
|
||||||
|
if report
|
||||||
|
.outcomes
|
||||||
|
.iter()
|
||||||
|
.any(|outcome| matches!(outcome, Outcome::Failed(_)))
|
||||||
|
{
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
// The resident daemon always serves HTTP: the chat surface is
|
||||||
|
// how it is talked to. Only `--once` runs headless. The
|
||||||
|
// surface's posture is checked before anything binds: a wide
|
||||||
|
// listen with no token never comes up. The approval posture is
|
||||||
|
// deliberate (spec §8): say it once at startup so the operator
|
||||||
|
// reads it next to the config that turns it on.
|
||||||
|
config.validate_surface()?;
|
||||||
|
eprintln!("[claw] note: resident sessions run tool calls without approval prompts");
|
||||||
|
let daemon = Daemon::build(&config, backends, DispatcherConfig::default())?;
|
||||||
|
daemon.run().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `--mock` backend: an endless canned provider behind the `mock`
|
||||||
|
/// feature; without it the flag is a loud startup failure naming the fix.
|
||||||
|
#[cfg(feature = "mock")]
|
||||||
|
fn mock_backends() -> Arc<rhema_claw::BackendRegistry> {
|
||||||
|
eprintln!("[claw] mock backend: every turn answers with canned streamed text");
|
||||||
|
Arc::new(
|
||||||
|
rhema_claw::BackendRegistry::new(
|
||||||
|
vec![rhema_claw::Backend::headless(
|
||||||
|
"mock",
|
||||||
|
rhema_claw::mock::MockLoop::provider(),
|
||||||
|
)],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("a single mock backend always constructs"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "mock"))]
|
||||||
|
fn mock_backends() -> Arc<rhema_claw::BackendRegistry> {
|
||||||
|
eprintln!("error: --mock needs a mock build: cargo build -p rhema-claw --features mock");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The deployment's backends: every provider whose credentials resolve
|
||||||
|
/// (env wins, the stored rhema config serves), with the config's
|
||||||
|
/// provider/model pair as the active seed. A daemon always boots —
|
||||||
|
/// the keyless llama-cpp backend is always constructible — and a
|
||||||
|
/// dispatch against an unreachable provider fails loudly per turn.
|
||||||
|
fn resolve_backends(config: &ClawConfig) -> Result<Arc<rhema_claw::BackendRegistry>, ClawError> {
|
||||||
|
let stored = match rhema_config::Config::load() {
|
||||||
|
Ok(stored) => stored,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("warning: ignoring unreadable rhema config: {err}");
|
||||||
|
rhema_config::Config::default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let profile = stored.effective(None);
|
||||||
|
// A second connection to the shared database, read-only in spirit:
|
||||||
|
// discovery reads the credentials the HTTP surface persisted. An
|
||||||
|
// unopenable store boots the daemon without them rather than not at
|
||||||
|
// all — the same grace the rhema config gets above.
|
||||||
|
let claw_creds = rhema_claw::SettingsStore::open(config.db_path()?).ok();
|
||||||
|
let claw_rows = move || {
|
||||||
|
claw_creds
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|store| {
|
||||||
|
store
|
||||||
|
.get_prefix(rhema_claw::backends::PROVIDER_KEY_PREFIX)
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(key, json)| {
|
||||||
|
let id = key.strip_prefix(rhema_claw::backends::PROVIDER_KEY_PREFIX)?;
|
||||||
|
serde_json::from_str(&json)
|
||||||
|
.ok()
|
||||||
|
.map(|params| (id.to_owned(), params))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
let env = |name: &str| std::env::var(name).ok();
|
||||||
|
// The daemon only reads the stored rhema config, keychain tier
|
||||||
|
// included; it never writes it, so no migration runs here — the
|
||||||
|
// file's logins belong to the frontends. In a container the
|
||||||
|
// keychain is unreachable and the literal tier serves.
|
||||||
|
let secrets = rhema_config::secrets::KeyringSecretStore::new();
|
||||||
|
let backends =
|
||||||
|
rhema_claw::backends::discover(config, &profile, &env, &claw_rows, Some(&secrets));
|
||||||
|
// The config's provider/model pair seeds the active backend when it
|
||||||
|
// names one. When it does not, the seed is the first credential
|
||||||
|
// discovery actually found — never a hardcoded default kind, and
|
||||||
|
// never the keyless llama.cpp fallback; with nothing keyed at all,
|
||||||
|
// the registry serves the keyless server and the UI's
|
||||||
|
// add-a-provider state is the honest answer.
|
||||||
|
let seed = config
|
||||||
|
.provider
|
||||||
|
.as_deref()
|
||||||
|
.map(|kind| (kind.to_owned(), config.model.clone()))
|
||||||
|
.or_else(|| rhema_claw::backends::credential_seed(&backends));
|
||||||
|
Ok(Arc::new(
|
||||||
|
rhema_claw::BackendRegistry::new(backends, seed).map_err(ClawError::Backend)?,
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//! The debug provider (`mock` feature): an endless scripted stand-in so
|
||||||
|
//! the daemon and a UI can run with zero credentials.
|
||||||
|
//!
|
||||||
|
//! [`MockProvider`](rhema_core::MockProvider) replays a fixed script and
|
||||||
|
//! errors once it runs dry — right for tests, wrong for a daemon a
|
||||||
|
//! developer chats with all afternoon. This one never dries up: every
|
||||||
|
//! turn answers with a numbered canned reply, streamed as deltas so a UI
|
||||||
|
//! exercises the same rendering path a real turn takes.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use rhema_core::{
|
||||||
|
Content, Event, EventSink, Message, ModelOutput, ModelProvider, ModelRequest, ProviderError,
|
||||||
|
Role, StopReason, Usage, async_trait,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Answers every request with a canned, streamed reply.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct MockLoop {
|
||||||
|
turns: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockLoop {
|
||||||
|
/// The provider, shared as the daemon's backend seam. (Named for what
|
||||||
|
/// it returns: a `new` returning `Arc<dyn ModelProvider>` would lie
|
||||||
|
/// about constructing `Self`.)
|
||||||
|
pub fn provider() -> Arc<dyn ModelProvider> {
|
||||||
|
Arc::new(Self::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ModelProvider for MockLoop {
|
||||||
|
async fn complete(
|
||||||
|
&self,
|
||||||
|
_req: ModelRequest,
|
||||||
|
sink: &mut dyn EventSink,
|
||||||
|
) -> Result<ModelOutput, ProviderError> {
|
||||||
|
let turn = self.turns.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
let text = format!(
|
||||||
|
"Mock claw, turn {turn}. No model is attached - every reply \
|
||||||
|
is this canned text, streamed in deltas like the real thing."
|
||||||
|
);
|
||||||
|
// Three char-safe deltas: the UI's streaming path gets exercised,
|
||||||
|
// not just the single-frame one.
|
||||||
|
let chars: Vec<&str> = {
|
||||||
|
let total = text.chars().count();
|
||||||
|
let boundary = |n: usize| -> usize {
|
||||||
|
text.char_indices()
|
||||||
|
.nth(n)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap_or(text.len())
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
&text[..boundary(total / 3)],
|
||||||
|
&text[boundary(total / 3)..boundary(2 * total / 3)],
|
||||||
|
&text[boundary(2 * total / 3)..],
|
||||||
|
]
|
||||||
|
};
|
||||||
|
for piece in chars {
|
||||||
|
sink.emit(Event::TextDelta(piece));
|
||||||
|
}
|
||||||
|
Ok(ModelOutput {
|
||||||
|
message: Message {
|
||||||
|
role: Role::Assistant,
|
||||||
|
content: vec![Content::Text(text)],
|
||||||
|
},
|
||||||
|
stop_reason: StopReason::EndTurn,
|
||||||
|
usage: Usage::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
//! The notification table: outbound claw-to-device messages in SQLite,
|
||||||
|
//! the same database file the dispatcher's
|
||||||
|
//! [`SqliteStore`](rhema_rsd::SqliteStore) and the watch table use.
|
||||||
|
//!
|
||||||
|
//! Notifications are how the resident reaches a device that is not the
|
||||||
|
//! live chat surface: the `notify` agent tool enqueues, a task or phone
|
||||||
|
//! turn's reply auto-notifies, and the device polls
|
||||||
|
//! `GET /v1/notifications` and acks what it delivered. Delivery is
|
||||||
|
//! at-least-once until the ack — an unacked row keeps answering the
|
||||||
|
//! poll, a crash between delivery and ack re-delivers.
|
||||||
|
//!
|
||||||
|
//! Two connections, one file: this store opens its own rusqlite
|
||||||
|
//! connection to the same database as the dispatcher's store — WAL and a
|
||||||
|
//! busy timeout, the pattern [`ClawStore`](crate::watch_store::ClawStore)
|
||||||
|
//! established — and the two interleave through SQLite's own locking.
|
||||||
|
//! Writes are single INSERT / UPDATE statements, so there is no
|
||||||
|
//! read-modify-write window to guard.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Mutex, PoisonError};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use rusqlite::params;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// The device a notification reaches when its producer names none: the
|
||||||
|
/// tool's default, the auto-notify rule's fallback, and the poll
|
||||||
|
/// route's default — one phone, one user (spec §7).
|
||||||
|
pub const DEFAULT_DEVICE: &str = "phone";
|
||||||
|
|
||||||
|
/// The notification table.
|
||||||
|
const SCHEMA: &str = "
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
device TEXT NOT NULL DEFAULT 'phone',
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
delivered_at INTEGER NULL
|
||||||
|
);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Why a notification-table operation failed.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum NotificationStoreError {
|
||||||
|
/// The SQLite backend failed.
|
||||||
|
#[error("notification store backend: {0}")]
|
||||||
|
Backend(#[from] rusqlite::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One outbound notification, as stored. Serializes whole: nothing here
|
||||||
|
/// is secret — this is the shape the poll route serves.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct NotificationRecord {
|
||||||
|
/// The row id; the ack route addresses it, `since` filters by it.
|
||||||
|
pub id: i64,
|
||||||
|
/// The device it is bound for (`phone` unless told otherwise).
|
||||||
|
pub device: String,
|
||||||
|
/// The one-line headline.
|
||||||
|
pub title: String,
|
||||||
|
/// The message body.
|
||||||
|
pub body: String,
|
||||||
|
/// Creation time, milliseconds since the Unix epoch.
|
||||||
|
pub created_at: i64,
|
||||||
|
/// When the device acked delivery; `None` while outstanding.
|
||||||
|
pub delivered_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persistent notification table over its own connection to the
|
||||||
|
/// daemon's SQLite file. Shareable behind an `Arc`; every operation
|
||||||
|
/// takes `&self`.
|
||||||
|
pub struct NotificationStore {
|
||||||
|
conn: Mutex<rusqlite::Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotificationStore {
|
||||||
|
/// Opens (creating if needed) the notification table in the database
|
||||||
|
/// at `path` — the same file the dispatcher's store holds. WAL keeps
|
||||||
|
/// the connections from blocking each other's readers; the busy
|
||||||
|
/// timeout rides out the other's write locks.
|
||||||
|
pub fn open(path: impl AsRef<Path>) -> Result<Self, NotificationStoreError> {
|
||||||
|
let conn = rusqlite::Connection::open(path).map_err(NotificationStoreError::from)?;
|
||||||
|
Self::init(conn, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a private in-memory table: the shape without the file, for
|
||||||
|
/// tests that need no sharing.
|
||||||
|
pub fn in_memory() -> Result<Self, NotificationStoreError> {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().map_err(NotificationStoreError::from)?;
|
||||||
|
Self::init(conn, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(conn: rusqlite::Connection, durable: bool) -> Result<Self, NotificationStoreError> {
|
||||||
|
if durable {
|
||||||
|
// Same pragmas as the dispatcher's store on the same file:
|
||||||
|
// SQLite stores the journal mode per database, and both
|
||||||
|
// connections must agree that it is WAL.
|
||||||
|
let _mode: String = conn
|
||||||
|
.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
|
||||||
|
.map_err(NotificationStoreError::from)?;
|
||||||
|
conn.busy_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.map_err(NotificationStoreError::from)?;
|
||||||
|
}
|
||||||
|
conn.execute_batch(SCHEMA)
|
||||||
|
.map_err(NotificationStoreError::from)?;
|
||||||
|
Ok(Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enqueues one notification and returns it with its row id. The
|
||||||
|
/// row is outstanding (`delivered_at` unset) until
|
||||||
|
/// [`Self::ack`].
|
||||||
|
pub fn push(
|
||||||
|
&self,
|
||||||
|
device: &str,
|
||||||
|
title: &str,
|
||||||
|
body: &str,
|
||||||
|
) -> Result<NotificationRecord, NotificationStoreError> {
|
||||||
|
let created_at = now_millis();
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO notifications (device, title, body, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
params![device, title, body, created_at],
|
||||||
|
)?;
|
||||||
|
Ok(NotificationRecord {
|
||||||
|
id: conn.last_insert_rowid(),
|
||||||
|
device: device.to_owned(),
|
||||||
|
title: title.to_owned(),
|
||||||
|
body: body.to_owned(),
|
||||||
|
created_at,
|
||||||
|
delivered_at: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The device's outstanding notifications with an id over `since`,
|
||||||
|
/// oldest first — exactly what a device that acks what it delivers
|
||||||
|
/// polls for. Delivered rows stop answering the poll; an unacked row
|
||||||
|
/// keeps coming back (at-least-once until the ack).
|
||||||
|
pub fn pending(
|
||||||
|
&self,
|
||||||
|
device: &str,
|
||||||
|
since: i64,
|
||||||
|
) -> Result<Vec<NotificationRecord>, NotificationStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, device, title, body, created_at, delivered_at
|
||||||
|
FROM notifications
|
||||||
|
WHERE device = ?1 AND delivered_at IS NULL AND id > ?2
|
||||||
|
ORDER BY id ASC",
|
||||||
|
)
|
||||||
|
.map_err(NotificationStoreError::from)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![device, since], |row| {
|
||||||
|
Ok(NotificationRecord {
|
||||||
|
id: row.get(0)?,
|
||||||
|
device: row.get(1)?,
|
||||||
|
title: row.get(2)?,
|
||||||
|
body: row.get(3)?,
|
||||||
|
created_at: row.get(4)?,
|
||||||
|
delivered_at: row.get(5)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(NotificationStoreError::from)?
|
||||||
|
.collect::<Result<Vec<_>, rusqlite::Error>>()
|
||||||
|
.map_err(NotificationStoreError::from)?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the notification delivered. Idempotent: `false` when the id
|
||||||
|
/// is unknown or already acked, never an error.
|
||||||
|
pub fn ack(&self, id: i64) -> Result<bool, NotificationStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let acked = conn.execute(
|
||||||
|
"UPDATE notifications SET delivered_at = ?2
|
||||||
|
WHERE id = ?1 AND delivered_at IS NULL",
|
||||||
|
params![id, now_millis()],
|
||||||
|
)?;
|
||||||
|
Ok(acked == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Milliseconds since the Unix epoch; stamps `created_at` and
|
||||||
|
/// `delivered_at`.
|
||||||
|
fn now_millis() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_notification_polls_until_acked() {
|
||||||
|
let store = NotificationStore::in_memory().expect("store opens");
|
||||||
|
let first = store
|
||||||
|
.push(DEFAULT_DEVICE, "claw", "first")
|
||||||
|
.expect("push lands");
|
||||||
|
let second = store
|
||||||
|
.push(DEFAULT_DEVICE, "claw", "second")
|
||||||
|
.expect("push lands");
|
||||||
|
assert!(first.id < second.id, "ids ascend");
|
||||||
|
assert_eq!(first.delivered_at, None, "outstanding until acked");
|
||||||
|
|
||||||
|
// The poll answers outstanding rows oldest first.
|
||||||
|
let pending = store.pending(DEFAULT_DEVICE, 0).expect("poll reads");
|
||||||
|
assert_eq!(
|
||||||
|
pending
|
||||||
|
.iter()
|
||||||
|
.map(|record| record.body.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["first", "second"],
|
||||||
|
);
|
||||||
|
|
||||||
|
// `since` skips what the device already saw.
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.pending(DEFAULT_DEVICE, first.id)
|
||||||
|
.expect("poll reads")
|
||||||
|
.len(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Acking delivers; the delivered row stops answering, and a
|
||||||
|
// re-ack is a quiet false, not an error.
|
||||||
|
assert!(store.ack(first.id).expect("ack lands"));
|
||||||
|
let pending = store.pending(DEFAULT_DEVICE, 0).expect("poll reads");
|
||||||
|
assert_eq!(
|
||||||
|
pending.iter().map(|record| record.id).collect::<Vec<_>>(),
|
||||||
|
[second.id],
|
||||||
|
);
|
||||||
|
assert!(!store.ack(first.id).expect("re-ack is quiet"));
|
||||||
|
assert!(!store.ack(10_000).expect("unknown id is quiet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn devices_do_not_hear_each_other() {
|
||||||
|
let store = NotificationStore::in_memory().expect("store opens");
|
||||||
|
store.push(DEFAULT_DEVICE, "claw", "phone").expect("push");
|
||||||
|
store.push("desk", "claw", "desk").expect("push");
|
||||||
|
let phones = store.pending(DEFAULT_DEVICE, 0).expect("poll reads");
|
||||||
|
assert_eq!(phones.len(), 1);
|
||||||
|
assert_eq!(phones[0].device, DEFAULT_DEVICE);
|
||||||
|
assert_eq!(phones[0].body, "phone");
|
||||||
|
assert_eq!(store.pending("desk", 0).expect("poll reads").len(), 1);
|
||||||
|
assert_eq!(store.pending("watch", 0).expect("poll reads").len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_connections_share_one_file() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("dispatch.db");
|
||||||
|
let writer = NotificationStore::open(&path).expect("first connection");
|
||||||
|
let reader = NotificationStore::open(&path).expect("second connection");
|
||||||
|
writer.push(DEFAULT_DEVICE, "claw", "hello").expect("push");
|
||||||
|
let pending = reader.pending(DEFAULT_DEVICE, 0).expect("poll reads");
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
assert_eq!(pending[0].body, "hello");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
//! The agent-side notify tool: how the resident reaches a device that is
|
||||||
|
//! not the live chat surface — "remind me when CI lands" becomes a tool
|
||||||
|
//! call, the row lands in the [`NotificationStore`], and the device's
|
||||||
|
//! next poll carries it. No restart, no second mechanism.
|
||||||
|
//!
|
||||||
|
//! This is a [`Tool`] implementation for a [`ToolRegistry`], so the
|
||||||
|
//! interactive handler offers it through the same allowlist-filtered
|
||||||
|
//! seam as everything else the model may call (spec §8). Answers are
|
||||||
|
//! short JSON; failures are in-band strings the model can react to.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||||
|
use rhema_core::ToolSpec;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::notifications::{DEFAULT_DEVICE, NotificationStore};
|
||||||
|
|
||||||
|
/// The notification tool set over `store`: `notify`.
|
||||||
|
pub fn registry(store: Arc<NotificationStore>) -> ToolRegistry {
|
||||||
|
let mut registry = ToolRegistry::new();
|
||||||
|
registry.register(Arc::new(Notify { store }));
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NotifyArgs {
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
#[serde(default)]
|
||||||
|
device: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends one notification: one row in the table, delivered at the
|
||||||
|
/// device's next poll-and-ack.
|
||||||
|
struct Notify {
|
||||||
|
store: Arc<NotificationStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for Notify {
|
||||||
|
fn spec(&self) -> ToolSpec {
|
||||||
|
ToolSpec::new(
|
||||||
|
"notify",
|
||||||
|
"Send a notification to one of the user's devices: the phone \
|
||||||
|
by default. Use it to reach the user away from this chat — \
|
||||||
|
a reminder they asked to be passed on, finished background \
|
||||||
|
work, anything that must not wait for their next message.",
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": { "type": "string",
|
||||||
|
"description": "The one-line headline." },
|
||||||
|
"body": { "type": "string",
|
||||||
|
"description": "The message body." },
|
||||||
|
"device": { "type": "string",
|
||||||
|
"description": "The target device; the phone \
|
||||||
|
when omitted." }
|
||||||
|
},
|
||||||
|
"required": ["title", "body"]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||||
|
let args: NotifyArgs =
|
||||||
|
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||||
|
let title = args.title.trim();
|
||||||
|
let body = args.body.trim();
|
||||||
|
if title.is_empty() {
|
||||||
|
return Err("title must not be empty".to_owned());
|
||||||
|
}
|
||||||
|
if body.is_empty() {
|
||||||
|
return Err("body must not be empty".to_owned());
|
||||||
|
}
|
||||||
|
let device = args
|
||||||
|
.device
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|device| !device.is_empty())
|
||||||
|
.unwrap_or(DEFAULT_DEVICE);
|
||||||
|
let record = self
|
||||||
|
.store
|
||||||
|
.push(device, title, body)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
Ok(json!({
|
||||||
|
"queued": true,
|
||||||
|
"id": record.id,
|
||||||
|
"device": record.device,
|
||||||
|
})
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn store() -> Arc<NotificationStore> {
|
||||||
|
Arc::new(NotificationStore::in_memory().expect("store opens"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call(store: &Arc<NotificationStore>, args: Value) -> rhema_core::ToolResult {
|
||||||
|
let resolver =
|
||||||
|
registry(store.clone()).resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
|
||||||
|
resolver
|
||||||
|
.resolve(
|
||||||
|
rhema_core::ToolCall {
|
||||||
|
id: "c1".into(),
|
||||||
|
name: "notify".into(),
|
||||||
|
args,
|
||||||
|
provider_data: Value::Null,
|
||||||
|
},
|
||||||
|
&rhema_core::ToolContext::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn notify_enqueues_a_row_and_answers_one_line() {
|
||||||
|
let store = store();
|
||||||
|
let result = call(
|
||||||
|
&store,
|
||||||
|
json!({"title": "CI green", "body": "all checks pass"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(!result.is_error, "{}", result.content);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&result.content).unwrap(),
|
||||||
|
json!({"queued": true, "id": 1, "device": "phone"}),
|
||||||
|
"{}",
|
||||||
|
result.content
|
||||||
|
);
|
||||||
|
let pending = store.pending(DEFAULT_DEVICE, 0).expect("poll reads");
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
assert_eq!(pending[0].title, "CI green");
|
||||||
|
assert_eq!(pending[0].body, "all checks pass");
|
||||||
|
assert_eq!(pending[0].device, "phone", "the default device");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_named_device_lands_on_its_own_queue() {
|
||||||
|
let store = store();
|
||||||
|
let result = call(
|
||||||
|
&store,
|
||||||
|
json!({"title": "done", "body": "shipped", "device": " desk "}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(!result.is_error, "{}", result.content);
|
||||||
|
assert!(
|
||||||
|
store.pending("desk", 0).expect("poll reads").len() == 1
|
||||||
|
&& store
|
||||||
|
.pending(DEFAULT_DEVICE, 0)
|
||||||
|
.expect("poll reads")
|
||||||
|
.is_empty(),
|
||||||
|
"the trimmed device owns the row"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_fields_and_bad_arguments_fail_in_band() {
|
||||||
|
let store = store();
|
||||||
|
for args in [
|
||||||
|
json!({"title": " ", "body": "x"}),
|
||||||
|
json!({"title": "x", "body": ""}),
|
||||||
|
json!({"title": "x"}),
|
||||||
|
] {
|
||||||
|
let result = call(&store, args).await;
|
||||||
|
assert!(result.is_error, "{}", result.content);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.pending(DEFAULT_DEVICE, 0)
|
||||||
|
.expect("poll reads")
|
||||||
|
.is_empty(),
|
||||||
|
"nothing landed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
//! The OpenAI-compatible shim: `POST /v1/chat/completions`, so a stock
|
||||||
|
//! web UI plugs into the resident agent without knowing the claw's own
|
||||||
|
//! routes.
|
||||||
|
//!
|
||||||
|
//! **The incoming history is ignored — deliberately, and this is the
|
||||||
|
//! load-bearing caveat of the whole shim.** The daemon owns the session:
|
||||||
|
//! one key, one persistent conversation, restored and stashed by the
|
||||||
|
//! interactive handler across every turn. A chat UI over this endpoint
|
||||||
|
//! MIRRORS the replies (streamed as deltas or returned whole); it does
|
||||||
|
//! not own history and cannot rewrite it. Sending a full transcript
|
||||||
|
//! changes nothing but which message triggers the turn: only the LAST
|
||||||
|
//! user message is read. The session key is `interactive:<user>`,
|
||||||
|
//! defaulting to `interactive:web`.
|
||||||
|
//!
|
||||||
|
//! The wire is the familiar subset: `stream: true` answers SSE
|
||||||
|
//! chat-completion chunks (`delta.reasoning_content` for thinking,
|
||||||
|
//! `delta.content` for text, a final chunk with `finish_reason: "stop"`,
|
||||||
|
//! then `data: [DONE]`); `stream: false` answers one completion object
|
||||||
|
//! once the turn's end marker arrives.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use rhema_rsd::{KeyState, ResourceKey};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use tokio::sync::broadcast::error::RecvError;
|
||||||
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
|
|
||||||
|
use crate::hub::TurnEvent;
|
||||||
|
use crate::interactive::KEY_PREFIX;
|
||||||
|
|
||||||
|
/// How long the non-stream path waits for the turn's end marker. Turns
|
||||||
|
/// can legitimately run minutes of shell work; a web UI's default
|
||||||
|
/// timeout is the caller's problem, this one is generous.
|
||||||
|
pub const TURN_WAIT: Duration = Duration::from_secs(600);
|
||||||
|
|
||||||
|
/// The session key when the request names no `user`.
|
||||||
|
pub const DEFAULT_USER: &str = "web";
|
||||||
|
|
||||||
|
/// The request body. `messages` is accepted in the OpenAI shape and
|
||||||
|
/// reduced to the last user message; everything else is ignored.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ChatRequest {
|
||||||
|
/// Echoed back when present; the daemon's configured model
|
||||||
|
/// otherwise.
|
||||||
|
pub model: Option<String>,
|
||||||
|
/// The transcript-shaped input. Only the last `user` entry matters.
|
||||||
|
pub messages: Vec<ChatMessage>,
|
||||||
|
/// Stream chunks (SSE) or wait for the whole reply.
|
||||||
|
#[serde(default)]
|
||||||
|
pub stream: bool,
|
||||||
|
/// The session name: key `interactive:<user>`, `web` when unset.
|
||||||
|
pub user: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One transcript entry; `content` is a string or an array of parts.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
/// `user`, `assistant`, `system`, `tool` — only `user` is read.
|
||||||
|
pub role: String,
|
||||||
|
/// String content, or OpenAI's array-of-parts.
|
||||||
|
pub content: ChatContent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI's two content shapes.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum ChatContent {
|
||||||
|
/// A plain string.
|
||||||
|
Text(String),
|
||||||
|
/// An array of typed parts; only the text parts are read.
|
||||||
|
Parts(Vec<ChatPart>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatContent {
|
||||||
|
/// The message's text: the string, or the text parts joined. A part
|
||||||
|
/// array with no text parts (images only, say) reads as empty.
|
||||||
|
pub fn text(&self) -> String {
|
||||||
|
match self {
|
||||||
|
ChatContent::Text(text) => text.clone(),
|
||||||
|
ChatContent::Parts(parts) => parts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|part| part.text.as_deref())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One content part; unknown parts carry no readable text.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ChatPart {
|
||||||
|
/// The part's text, for `{"type": "text"}` parts.
|
||||||
|
#[serde(default)]
|
||||||
|
pub text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last `user` message's text — the only input a turn gets.
|
||||||
|
pub fn last_user_text(messages: &[ChatMessage]) -> Option<String> {
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|message| message.role == "user")
|
||||||
|
.map(|message| message.content.text())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The completion/chunk id: `chatcmpl-` plus a process-unique suffix.
|
||||||
|
fn completion_id() -> String {
|
||||||
|
static SHIM_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!(
|
||||||
|
"chatcmpl-{}-{:04x}",
|
||||||
|
now.as_millis(),
|
||||||
|
SHIM_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seconds since the Unix epoch, the shim's `created`.
|
||||||
|
fn unix_secs() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The non-stream completion object. `content` is `null` when the turn
|
||||||
|
/// ended without a reply — OpenAI's shape allows it, and the session
|
||||||
|
/// records why.
|
||||||
|
fn completion_body(id: &str, created: u64, model: &str, reply: Option<String>) -> Value {
|
||||||
|
json!({
|
||||||
|
"id": id,
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": created,
|
||||||
|
"model": model,
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": reply},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One streaming chunk.
|
||||||
|
fn chunk_body(id: &str, created: u64, model: &str, delta: Value, finish: Option<&str>) -> Value {
|
||||||
|
json!({
|
||||||
|
"id": id,
|
||||||
|
"object": "chat.completion.chunk",
|
||||||
|
"created": created,
|
||||||
|
"model": model,
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"delta": delta,
|
||||||
|
"finish_reason": finish,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shim endpoint. Errors are OpenAI-shaped: `{"error": {"message":
|
||||||
|
/// ...}}` with a sensible status.
|
||||||
|
pub async fn chat_completions(
|
||||||
|
State(state): State<crate::http::ClawState>,
|
||||||
|
Json(request): Json<ChatRequest>,
|
||||||
|
) -> Response {
|
||||||
|
let Some(text) = last_user_text(&request.messages).filter(|t| !t.trim().is_empty()) else {
|
||||||
|
return error_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"no usable user message: the shim reads only the last `user` entry",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let user = request
|
||||||
|
.user
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|user| !user.is_empty())
|
||||||
|
.unwrap_or(DEFAULT_USER);
|
||||||
|
let key = ResourceKey::from(format!("{KEY_PREFIX}{user}"));
|
||||||
|
let model = request.model.clone().unwrap_or_else(|| {
|
||||||
|
let (_, active_model) = state.backends().active();
|
||||||
|
active_model
|
||||||
|
});
|
||||||
|
let id = completion_id();
|
||||||
|
let created = unix_secs();
|
||||||
|
|
||||||
|
// Subscribe first, ingest second, drain third — the same contract
|
||||||
|
// the messages route keeps.
|
||||||
|
let mut receiver = state.hub().subscribe(&key);
|
||||||
|
if let Err(err) = state
|
||||||
|
.dispatcher()
|
||||||
|
.ingest(crate::interactive::user_event(key.as_str(), &text))
|
||||||
|
{
|
||||||
|
return error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string());
|
||||||
|
}
|
||||||
|
if let Err(err) = crate::http::drain(state.driver(), state.dispatcher(), &key).await {
|
||||||
|
return error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.stream {
|
||||||
|
return stream_response(receiver, &id, created, &model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The drain ran the turn; its end marker is queued. A queued
|
||||||
|
// follow-up behind a foreign turn delays ours — keep waiting through
|
||||||
|
// drains until the key settles, bounded by TURN_WAIT.
|
||||||
|
let deadline = tokio::time::Instant::now() + TURN_WAIT;
|
||||||
|
let reply = loop {
|
||||||
|
let done = match tokio::time::timeout_at(deadline, receiver.recv()).await {
|
||||||
|
Ok(Ok(TurnEvent::TurnDone { reply, .. })) => reply,
|
||||||
|
Ok(Ok(_)) => continue,
|
||||||
|
Ok(Err(RecvError::Lagged(_))) => continue,
|
||||||
|
Ok(Err(RecvError::Closed)) => {
|
||||||
|
return error_response(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
"the event hub closed before the turn finished",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(_elapsed) => {
|
||||||
|
return error_response(
|
||||||
|
StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
"the turn did not finish in time; its reply may still arrive on the event stream",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let settled = state
|
||||||
|
.dispatcher()
|
||||||
|
.status(&key)
|
||||||
|
.map(|status| status.inbox_depth == 0 && status.state != KeyState::Reacquire);
|
||||||
|
match settled {
|
||||||
|
Ok(true) => break done,
|
||||||
|
Ok(false) => {
|
||||||
|
// More work is queued behind what just finished — ours,
|
||||||
|
// most likely: drain and wait for its end marker.
|
||||||
|
if let Err(err) = crate::http::drain(state.driver(), state.dispatcher(), &key).await
|
||||||
|
{
|
||||||
|
return error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Json(completion_body(&id, created, &model, reply)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `stream: true` answer: an SSE body of completion chunks pumped
|
||||||
|
/// from the hub. Thinking becomes `delta.reasoning_content`, text
|
||||||
|
/// becomes `delta.content`, and the turn's end marker becomes the final
|
||||||
|
/// `finish_reason: "stop"` chunk plus `data: [DONE]` — then the stream
|
||||||
|
/// closes. Tool traffic is not surfaced: it is the agent's plumbing.
|
||||||
|
fn stream_response(
|
||||||
|
mut receiver: tokio::sync::broadcast::Receiver<TurnEvent>,
|
||||||
|
id: &str,
|
||||||
|
created: u64,
|
||||||
|
model: &str,
|
||||||
|
) -> Response {
|
||||||
|
let id = id.to_owned();
|
||||||
|
let model = model.to_owned();
|
||||||
|
let (sender, body) =
|
||||||
|
tokio::sync::mpsc::channel::<Result<SseEvent, std::convert::Infallible>>(64);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let frame = match receiver.recv().await {
|
||||||
|
Ok(TurnEvent::ThinkingDelta { text }) => Ok(sse_chunk(
|
||||||
|
&id,
|
||||||
|
created,
|
||||||
|
&model,
|
||||||
|
json!({"reasoning_content": text}),
|
||||||
|
)),
|
||||||
|
Ok(TurnEvent::TextDelta { text }) => {
|
||||||
|
Ok(sse_chunk(&id, created, &model, json!({"content": text})))
|
||||||
|
}
|
||||||
|
Ok(TurnEvent::TurnDone { .. }) => {
|
||||||
|
// The turn is over regardless of who triggered it or
|
||||||
|
// how it ended: the client gets a clean stop.
|
||||||
|
let final_frame = Ok(sse_chunk_finish(
|
||||||
|
&id,
|
||||||
|
created,
|
||||||
|
&model,
|
||||||
|
json!({}),
|
||||||
|
Some("stop"),
|
||||||
|
));
|
||||||
|
let done = Ok(SseEvent::default().data("[DONE]"));
|
||||||
|
let _ = sender.send(final_frame).await;
|
||||||
|
let _ = sender.send(done).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// The claw's own clients get the tool rows, the
|
||||||
|
// consent cards, and the attachments; an OpenAI caller
|
||||||
|
// gets text, reasoning, and the end marker.
|
||||||
|
Ok(
|
||||||
|
TurnEvent::ToolStart { .. }
|
||||||
|
| TurnEvent::ToolResult { .. }
|
||||||
|
| TurnEvent::ConsentRequest { .. }
|
||||||
|
| TurnEvent::ConsentResolved { .. }
|
||||||
|
| TurnEvent::Attachment { .. },
|
||||||
|
) => continue,
|
||||||
|
// The turn's input rides the stream for the claw's own
|
||||||
|
// clients; an OpenAI caller named it in the request.
|
||||||
|
Ok(TurnEvent::UserMessage { .. }) => continue,
|
||||||
|
Err(RecvError::Lagged(dropped)) => Ok(SseEvent::default()
|
||||||
|
.comment(format!("lagged; {dropped} earlier event(s) dropped"))),
|
||||||
|
Err(RecvError::Closed) => break,
|
||||||
|
};
|
||||||
|
if sender.send(frame).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Sse::new(ReceiverStream::new(body))
|
||||||
|
.keep_alive(
|
||||||
|
KeepAlive::default()
|
||||||
|
.interval(crate::http::KEEP_ALIVE_INTERVAL)
|
||||||
|
.text("keep-alive"),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One delta (or the final) chunk as an SSE frame.
|
||||||
|
fn sse_chunk(id: &str, created: u64, model: &str, delta: Value) -> SseEvent {
|
||||||
|
sse_chunk_finish(id, created, model, delta, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sse_chunk_finish(
|
||||||
|
id: &str,
|
||||||
|
created: u64,
|
||||||
|
model: &str,
|
||||||
|
delta: Value,
|
||||||
|
finish: Option<&str>,
|
||||||
|
) -> SseEvent {
|
||||||
|
SseEvent::default().data(chunk_body(id, created, model, delta, finish).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An OpenAI-shaped error: `{"error": {"message": ...}}`.
|
||||||
|
fn error_response(status: StatusCode, message: &str) -> Response {
|
||||||
|
(status, Json(json!({"error": {"message": message}}))).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn message(role: &str, content: ChatContent) -> ChatMessage {
|
||||||
|
ChatMessage {
|
||||||
|
role: role.to_owned(),
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_last_user_message_is_read() {
|
||||||
|
let messages = vec![
|
||||||
|
message("system", ChatContent::Text("be brief".into())),
|
||||||
|
message("user", ChatContent::Text("first".into())),
|
||||||
|
message("assistant", ChatContent::Text("no".into())),
|
||||||
|
message("user", ChatContent::Text("second".into())),
|
||||||
|
];
|
||||||
|
assert_eq!(last_user_text(&messages).as_deref(), Some("second"));
|
||||||
|
assert_eq!(last_user_text(&messages[0..1]).as_deref(), None);
|
||||||
|
assert_eq!(last_user_text(&[]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn part_arrays_join_their_text_parts() {
|
||||||
|
let parts = ChatContent::Parts(vec![
|
||||||
|
ChatPart {
|
||||||
|
text: Some("look ".into()),
|
||||||
|
},
|
||||||
|
ChatPart {
|
||||||
|
text: None, // an image part, say
|
||||||
|
},
|
||||||
|
ChatPart {
|
||||||
|
text: Some("here".into()),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert_eq!(parts.text(), "look here");
|
||||||
|
assert_eq!(ChatContent::Text("plain".into()).text(), "plain");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_completion_shape_is_the_familiar_one() {
|
||||||
|
let body = completion_body("chatcmpl-1", 42, "m", Some("hi".into()));
|
||||||
|
assert_eq!(body["object"], "chat.completion");
|
||||||
|
assert_eq!(body["choices"][0]["message"]["content"], "hi");
|
||||||
|
assert_eq!(body["choices"][0]["finish_reason"], "stop");
|
||||||
|
let null = completion_body("chatcmpl-1", 42, "m", None);
|
||||||
|
assert!(null["choices"][0]["message"]["content"].is_null());
|
||||||
|
|
||||||
|
let chunk = chunk_body("chatcmpl-1", 42, "m", json!({"content": "x"}), None);
|
||||||
|
assert_eq!(chunk["object"], "chat.completion.chunk");
|
||||||
|
assert_eq!(chunk["choices"][0]["delta"]["content"], "x");
|
||||||
|
assert!(chunk["choices"][0]["finish_reason"].is_null());
|
||||||
|
let stop = chunk_body("chatcmpl-1", 42, "m", json!({}), Some("stop"));
|
||||||
|
assert_eq!(stop["choices"][0]["finish_reason"], "stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ids_are_unique_and_shaped() {
|
||||||
|
let a = completion_id();
|
||||||
|
let b = completion_id();
|
||||||
|
assert_ne!(a, b);
|
||||||
|
assert!(a.starts_with("chatcmpl-"), "{a}");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,115 @@
|
|||||||
|
//! Runtime settings the daemon persists: the key-value table behind the
|
||||||
|
//! deployment state that outlives a restart.
|
||||||
|
//!
|
||||||
|
//! The active provider/model pair lives here, not in `claw.yaml` — the
|
||||||
|
//! config file seeds it, the API changes it, and this store keeps it.
|
||||||
|
//! Same store shape as the crate's other tables: a second connection to
|
||||||
|
//! the shared SQLite file, WAL, busy timeout.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Mutex, PoisonError};
|
||||||
|
|
||||||
|
use rusqlite::OptionalExtension;
|
||||||
|
use rusqlite::params;
|
||||||
|
|
||||||
|
/// Why a settings operation failed.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SettingsError {
|
||||||
|
/// The backend failed to read or write.
|
||||||
|
#[error("settings store failed: {0}")]
|
||||||
|
Backend(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `settings` key-value table over the claw's shared database.
|
||||||
|
pub struct SettingsStore {
|
||||||
|
conn: Mutex<rusqlite::Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsStore {
|
||||||
|
/// Opens (creating if needed) the settings table on the database at
|
||||||
|
/// `path`.
|
||||||
|
pub fn open(path: impl AsRef<Path>) -> Result<Self, SettingsError> {
|
||||||
|
let conn = rusqlite::Connection::open(path)
|
||||||
|
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||||
|
Self::init(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the settings table over an in-memory database.
|
||||||
|
pub fn in_memory() -> Result<Self, SettingsError> {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory()
|
||||||
|
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||||
|
Self::init(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(conn: rusqlite::Connection) -> Result<Self, SettingsError> {
|
||||||
|
let conn = conn;
|
||||||
|
conn.busy_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);",
|
||||||
|
)
|
||||||
|
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||||
|
Ok(Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value stored for `key`, when one was set.
|
||||||
|
pub fn get(&self, key: &str) -> Result<Option<String>, SettingsError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let value: Option<String> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT value FROM settings WHERE key = ?1",
|
||||||
|
params![key],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `(key, value)` whose key starts with `prefix`, ascending
|
||||||
|
/// by key — discovery's view of the stored provider rows.
|
||||||
|
pub fn get_prefix(&self, prefix: &str) -> Result<Vec<(String, String)>, SettingsError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let mut statement = conn
|
||||||
|
.prepare("SELECT key, value FROM settings WHERE key LIKE (?1 || '%') ORDER BY key")
|
||||||
|
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||||
|
let rows = statement
|
||||||
|
.query_map(params![prefix], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
|
})
|
||||||
|
.map_err(|err| SettingsError::Backend(err.to_string()))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets `key` to `value`, replacing any previous value.
|
||||||
|
pub fn set(&self, key: &str, value: &str) -> Result<(), SettingsError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO settings (key, value) VALUES (?1, ?2)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
params![key, value],
|
||||||
|
)
|
||||||
|
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes `key` from the store; a no-op when it was absent.
|
||||||
|
pub fn delete(&self, key: &str) -> Result<(), SettingsError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
|
||||||
|
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persisted settings keys: the active backend pair.
|
||||||
|
pub const ACTIVE_PROVIDER_KEY: &str = "active_provider";
|
||||||
|
/// The persisted active model.
|
||||||
|
pub const ACTIVE_MODEL_KEY: &str = "active_model";
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
//! The task source: deterministic detection of due scheduled tasks, the
|
||||||
|
//! spec §1-compliant cron.
|
||||||
|
//!
|
||||||
|
//! The [`TaskScheduler`] is a [`Source`] named `"tasks"`: its `detect`
|
||||||
|
//! pass reads the task table against an injected
|
||||||
|
//! [`Clock`](rhema_rsd::Clock), ingests one event per due task into the
|
||||||
|
//! resident dispatcher, then advances the row — the one-shot is
|
||||||
|
//! deleted, the recurring task re-arms. The model is never reached here;
|
||||||
|
//! the turn happens because the dispatcher admitted the key, exactly as
|
||||||
|
//! for a chat message. Cadence belongs to the daemon's loop, which runs
|
||||||
|
//! this source's detect each tick alongside the forge sources.
|
||||||
|
//!
|
||||||
|
//! Due/re-arm semantics (the loop-fire guards):
|
||||||
|
//!
|
||||||
|
//! - A row fires at most once per detect pass: every acted-on row is
|
||||||
|
//! advanced before the pass returns, and `due` reads the table once.
|
||||||
|
//! - A recurring task re-arms to `next_due + interval`; when that is
|
||||||
|
//! still past (the process was down, the clock jumped), it re-arms to
|
||||||
|
//! `now + interval` — a missed task catches up with one firing, never
|
||||||
|
//! one per elapsed interval.
|
||||||
|
//! - A recurring row without a usable interval is deleted, not skipped:
|
||||||
|
//! a skipped due row would fire again on every pass forever.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rhema_autonomous_agent::{Source, SourceError, async_trait};
|
||||||
|
use rhema_rsd::{Clock, Dispatcher, Event, KeyFn};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::interactive::passthrough_key_fn;
|
||||||
|
use crate::task_store::{TaskKind, TaskStore};
|
||||||
|
|
||||||
|
/// The source name the scheduler ingests under; registered on the
|
||||||
|
/// resident dispatcher, so allowlists and `/v1/ingest` can name it.
|
||||||
|
pub const TASKS_SOURCE: &str = "tasks";
|
||||||
|
|
||||||
|
/// One scheduled task's dispatch key: `task:<id>` — the session the
|
||||||
|
/// prompt's turn runs in, persistent across the task's firings.
|
||||||
|
pub fn task_key(id: i64) -> String {
|
||||||
|
format!("task:{id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The event one due firing becomes. The key is the task's delivery
|
||||||
|
/// target — its own `task:<id>` slot, or the session the scheduler was
|
||||||
|
/// asked to wake (a chat conversation with its context, a phone key
|
||||||
|
/// whose reply auto-notifies). The dedup id carries the firing's
|
||||||
|
/// `next_due`, so a recurring task's next firing never dedups against
|
||||||
|
/// the last one still pending in the key's inbox.
|
||||||
|
pub fn task_event(task: &crate::task_store::TaskRecord, now: u64) -> Event {
|
||||||
|
let default_key = task_key(task.id);
|
||||||
|
let key = task
|
||||||
|
.deliver_to
|
||||||
|
.as_deref()
|
||||||
|
.filter(|key| !key.is_empty())
|
||||||
|
.unwrap_or(default_key.as_str());
|
||||||
|
Event {
|
||||||
|
id: format!("task-{}@{}", task.id, task.next_due_millis),
|
||||||
|
source: TASKS_SOURCE.to_owned(),
|
||||||
|
payload: json!({ "key": key, "prompt": task.prompt }),
|
||||||
|
received_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The detectable stream of due tasks: the task table against a clock,
|
||||||
|
/// feeding the resident dispatcher.
|
||||||
|
pub struct TaskScheduler {
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
store: Arc<TaskStore>,
|
||||||
|
dispatcher: Arc<Dispatcher>,
|
||||||
|
key_fn: Arc<KeyFn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskScheduler {
|
||||||
|
/// Registers the `tasks` source's key function on `dispatcher` and
|
||||||
|
/// returns the scheduler feeding it — the ForgeSource shape: one
|
||||||
|
/// source, registered once, before the first detect.
|
||||||
|
pub fn new(dispatcher: &Arc<Dispatcher>, clock: Arc<dyn Clock>, store: Arc<TaskStore>) -> Self {
|
||||||
|
let key_fn = passthrough_key_fn();
|
||||||
|
dispatcher.register_source(TASKS_SOURCE, key_fn.clone());
|
||||||
|
Self {
|
||||||
|
clock,
|
||||||
|
store,
|
||||||
|
dispatcher: dispatcher.clone(),
|
||||||
|
key_fn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Source for TaskScheduler {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
TASKS_SOURCE
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_fn(&self) -> Arc<KeyFn> {
|
||||||
|
self.key_fn.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn detect(&self) -> Result<usize, SourceError> {
|
||||||
|
let now = self.clock.now_millis() as i64;
|
||||||
|
let due = self
|
||||||
|
.store
|
||||||
|
.due(now)
|
||||||
|
.map_err(|err| SourceError::Backend(format!("task store: {err}")))?;
|
||||||
|
let mut fed = 0;
|
||||||
|
for task in due {
|
||||||
|
self.dispatcher.ingest(task_event(&task, now as u64))?;
|
||||||
|
fed += 1;
|
||||||
|
match task.kind {
|
||||||
|
TaskKind::At => {
|
||||||
|
// Fired once; the row's work is done.
|
||||||
|
self.store
|
||||||
|
.remove(task.id)
|
||||||
|
.map_err(|err| SourceError::Backend(format!("task store: {err}")))?;
|
||||||
|
}
|
||||||
|
TaskKind::Every => match task.interval_millis {
|
||||||
|
Some(interval) if interval > 0 => {
|
||||||
|
let mut next = task.next_due_millis.saturating_add(interval);
|
||||||
|
if next <= now {
|
||||||
|
// Catch-up: the scheduled next is already past
|
||||||
|
// (downtime, a clock jump). One firing settles
|
||||||
|
// the debt; the rest never happens.
|
||||||
|
next = now.saturating_add(interval);
|
||||||
|
}
|
||||||
|
self.store
|
||||||
|
.rearm(task.id, next)
|
||||||
|
.map_err(|err| SourceError::Backend(format!("task store: {err}")))?;
|
||||||
|
}
|
||||||
|
// A recurring task that cannot re-arm must not sit due
|
||||||
|
// forever, re-firing every pass: retire it, loudly.
|
||||||
|
interval => {
|
||||||
|
eprintln!(
|
||||||
|
"[claw] warning: task {} has no usable interval \
|
||||||
|
({interval:?}); removing it",
|
||||||
|
task.id
|
||||||
|
);
|
||||||
|
self.store
|
||||||
|
.remove(task.id)
|
||||||
|
.map_err(|err| SourceError::Backend(format!("task store: {err}")))?;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(fed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rhema_rsd::{DispatcherConfig, FixedClock, SystemClock, VolatileStore};
|
||||||
|
|
||||||
|
fn dispatcher() -> Arc<Dispatcher> {
|
||||||
|
Arc::new(Dispatcher::new(
|
||||||
|
DispatcherConfig {
|
||||||
|
debounce_window_millis: 0,
|
||||||
|
..DispatcherConfig::default()
|
||||||
|
},
|
||||||
|
Arc::new(SystemClock),
|
||||||
|
Arc::new(VolatileStore::new()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dispatcher status of one task's key: how many events wait on
|
||||||
|
/// it. A key never ingested reports the missing view — depth 0.
|
||||||
|
fn inbox_depth(dispatcher: &Dispatcher, id: i64) -> usize {
|
||||||
|
dispatcher
|
||||||
|
.status(&rhema_rsd::ResourceKey::from(task_key(id)))
|
||||||
|
.expect("status reads")
|
||||||
|
.inbox_depth
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_due_one_shot_fires_once_and_is_removed() {
|
||||||
|
let clock = Arc::new(FixedClock::new(1_000));
|
||||||
|
let store = Arc::new(TaskStore::in_memory().expect("store opens"));
|
||||||
|
let dispatcher = dispatcher();
|
||||||
|
let source = TaskScheduler::new(&dispatcher, clock.clone(), store.clone());
|
||||||
|
store
|
||||||
|
.schedule_at("remind the user: bread", 1_500, 1_000, None)
|
||||||
|
.expect("scheduled");
|
||||||
|
|
||||||
|
// Not yet due: skipped, nothing fed, no record.
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 0);
|
||||||
|
assert_eq!(inbox_depth(&dispatcher, 1), 0, "nothing fired");
|
||||||
|
|
||||||
|
// Due: exactly one event, on the task's key.
|
||||||
|
clock.advance(500);
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||||
|
assert_eq!(inbox_depth(&dispatcher, 1), 1);
|
||||||
|
|
||||||
|
// The row is gone; a second pass re-fires nothing.
|
||||||
|
assert!(store.list().expect("list reads").is_empty());
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 0);
|
||||||
|
assert_eq!(inbox_depth(&dispatcher, 1), 1, "the inbox keeps it");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_recurring_task_rearms_and_never_loop_fires() {
|
||||||
|
let clock = Arc::new(FixedClock::new(1_000));
|
||||||
|
let store = Arc::new(TaskStore::in_memory().expect("store opens"));
|
||||||
|
let dispatcher = dispatcher();
|
||||||
|
let source = TaskScheduler::new(&dispatcher, clock.clone(), store.clone());
|
||||||
|
store
|
||||||
|
.schedule_every("stand up", 1_000, 1_000, None)
|
||||||
|
.expect("scheduled");
|
||||||
|
|
||||||
|
// Due at 2000: one firing, re-armed to due + interval.
|
||||||
|
clock.advance(1_500); // now = 2500
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||||
|
let task = &store.list().expect("list reads")[0];
|
||||||
|
assert_eq!(task.next_due_millis, 2_000 + 1_000, "next_due + interval");
|
||||||
|
assert_eq!(inbox_depth(&dispatcher, 1), 1);
|
||||||
|
|
||||||
|
// Still past due after re-arm (the firing lagged): the next pass
|
||||||
|
// fires again — but one detect never fires the same task twice,
|
||||||
|
// and the catch-up rule re-arms from now, not from the backlog.
|
||||||
|
clock.advance(10_000); // now = 12500
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||||
|
let task = &store.list().expect("list reads")[0];
|
||||||
|
assert_eq!(
|
||||||
|
task.next_due_millis, 13_500,
|
||||||
|
"catch-up re-arms to now + interval, never loop-fires"
|
||||||
|
);
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 0);
|
||||||
|
assert_eq!(
|
||||||
|
inbox_depth(&dispatcher, 1),
|
||||||
|
2,
|
||||||
|
"one firing per pass: two passes, two events"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_delivery_target_routes_the_firing_to_that_session() {
|
||||||
|
let clock = Arc::new(FixedClock::new(1_000));
|
||||||
|
let store = Arc::new(TaskStore::in_memory().expect("store opens"));
|
||||||
|
let dispatcher = dispatcher();
|
||||||
|
let source = TaskScheduler::new(&dispatcher, clock.clone(), store.clone());
|
||||||
|
store
|
||||||
|
.schedule_at(
|
||||||
|
"summarize this evening's work",
|
||||||
|
1_500,
|
||||||
|
1_000,
|
||||||
|
Some("interactive:web"),
|
||||||
|
)
|
||||||
|
.expect("scheduled");
|
||||||
|
|
||||||
|
clock.advance(500);
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||||
|
// The firing woke the named session, not the task's own slot.
|
||||||
|
let depth = |key: &str| {
|
||||||
|
dispatcher
|
||||||
|
.status(&rhema_rsd::ResourceKey::from(key))
|
||||||
|
.expect("status reads")
|
||||||
|
.inbox_depth
|
||||||
|
};
|
||||||
|
assert_eq!(depth("interactive:web"), 1);
|
||||||
|
assert_eq!(depth(&task_key(1)), 0, "the default slot stays cold");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unusable_recurring_row_is_retired_not_loop_fired() {
|
||||||
|
let clock = Arc::new(FixedClock::new(1_000));
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("dispatch.db");
|
||||||
|
// The table exists before the hand edit, so the row is the only
|
||||||
|
// thing in it.
|
||||||
|
drop(TaskStore::open(&path).expect("store opens"));
|
||||||
|
{
|
||||||
|
// Simulate the hand-edited table the store's API cannot
|
||||||
|
// produce: an `every` row without an interval, due now. The
|
||||||
|
// CHECK constraint only pins the kind's spelling.
|
||||||
|
let conn = rusqlite::Connection::open(&path).expect("raw connection");
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tasks (prompt, kind, at_millis, interval_millis, next_due_millis, created_at)
|
||||||
|
VALUES ('tick', 'every', NULL, NULL, 0, 0)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.expect("the malformed row lands");
|
||||||
|
}
|
||||||
|
let store = Arc::new(TaskStore::open(&path).expect("store reopens"));
|
||||||
|
let dispatcher = dispatcher();
|
||||||
|
let source = TaskScheduler::new(&dispatcher, clock.clone(), store.clone());
|
||||||
|
|
||||||
|
// The bad row fires once (its prompt is real), then is retired:
|
||||||
|
// a skip would leave it due on every pass forever.
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||||
|
assert!(store.list().expect("list reads").is_empty());
|
||||||
|
assert_eq!(source.detect().await.expect("detect runs"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
//! The task table: user-scheduled prompts in SQLite, the same database
|
||||||
|
//! file the dispatcher's [`SqliteStore`](rhema_rsd::SqliteStore), the
|
||||||
|
//! watch table, and the notification table use.
|
||||||
|
//!
|
||||||
|
//! A task is the spec §1-compliant "cron": the row is inert data until
|
||||||
|
//! the [`TaskScheduler`](crate::task_source::TaskScheduler) — a
|
||||||
|
//! deterministic source, never the model — reads `next_due_millis`
|
||||||
|
//! against the clock and ingests the prompt as one event. No timer
|
||||||
|
//! invokes the model; a due task is an event, not a heartbeat.
|
||||||
|
//!
|
||||||
|
//! Two connections, one file: this store opens its own rusqlite
|
||||||
|
//! connection to the same database as the dispatcher's store — WAL and a
|
||||||
|
//! busy timeout, the pattern [`ClawStore`](crate::watch_store::ClawStore)
|
||||||
|
//! established — and the two interleave through SQLite's own locking.
|
||||||
|
//! Writes are single INSERT / UPDATE / DELETE statements, so there is no
|
||||||
|
//! read-modify-write window to guard.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Mutex, PoisonError};
|
||||||
|
|
||||||
|
use rusqlite::params;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// The task table. The CHECK pins the two kinds' column pairing to what
|
||||||
|
/// the code writes: `at` rows carry `at_millis`, `every` rows carry
|
||||||
|
/// `interval_millis`. `deliver_to` names the session a firing wakes —
|
||||||
|
/// `NULL` meaning the task's own `task:<id>` slot.
|
||||||
|
const SCHEMA: &str = "
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK(kind IN ('at','every')),
|
||||||
|
at_millis INTEGER NULL,
|
||||||
|
interval_millis INTEGER NULL,
|
||||||
|
next_due_millis INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
deliver_to TEXT NULL
|
||||||
|
);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Adds `deliver_to` to a table created before it existed. The column
|
||||||
|
/// is the only schema the first release lacked, and `ALTER TABLE ADD
|
||||||
|
/// COLUMN` with a `NULL` default is the one shape SQLite can add
|
||||||
|
/// in place.
|
||||||
|
fn migrate_add_deliver_to(conn: &rusqlite::Connection) -> Result<(), TaskStoreError> {
|
||||||
|
let present: bool = conn
|
||||||
|
.prepare("PRAGMA table_info(tasks)")?
|
||||||
|
.query_map([], |row| row.get::<_, String>(1))?
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.any(|name| name == "deliver_to");
|
||||||
|
if !present {
|
||||||
|
conn.execute("ALTER TABLE tasks ADD COLUMN deliver_to TEXT NULL", [])?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a task-table operation failed.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum TaskStoreError {
|
||||||
|
/// The SQLite backend failed.
|
||||||
|
#[error("task store backend: {0}")]
|
||||||
|
Backend(#[from] rusqlite::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What kind of task a row is: fire once at a time (`at`) or repeat on
|
||||||
|
/// an interval (`every`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TaskKind {
|
||||||
|
/// Fire once, at `at_millis`; the row leaves the table after
|
||||||
|
/// firing.
|
||||||
|
At,
|
||||||
|
/// Fire on every `interval_millis` tick; the row re-arms after each
|
||||||
|
/// firing.
|
||||||
|
Every,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskKind {
|
||||||
|
/// The name the table's `kind` column stores.
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TaskKind::At => "at",
|
||||||
|
TaskKind::Every => "every",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the column spelling. Private: rows only enter through
|
||||||
|
/// [`TaskStore::schedule_at`] / [`TaskStore::schedule_every`], and
|
||||||
|
/// this guards only hand-edited tables.
|
||||||
|
fn parse(name: &str) -> Option<Self> {
|
||||||
|
match name {
|
||||||
|
"at" => Some(TaskKind::At),
|
||||||
|
"every" => Some(TaskKind::Every),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One scheduled prompt, as stored. Serializes whole: this is the shape
|
||||||
|
/// `list_tasks` serves the model.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct TaskRecord {
|
||||||
|
/// The row id; the dispatch key (`task:<id>`) and `cancel_task`
|
||||||
|
/// address it.
|
||||||
|
pub id: i64,
|
||||||
|
/// The prompt the scheduled turn runs.
|
||||||
|
pub prompt: String,
|
||||||
|
/// Fire once or repeat.
|
||||||
|
pub kind: TaskKind,
|
||||||
|
/// The one-shot's firing time, milliseconds since the Unix epoch.
|
||||||
|
pub at_millis: Option<i64>,
|
||||||
|
/// The recurring task's interval, in milliseconds.
|
||||||
|
pub interval_millis: Option<i64>,
|
||||||
|
/// When the task next fires, milliseconds since the Unix epoch.
|
||||||
|
pub next_due_millis: i64,
|
||||||
|
/// Creation time, milliseconds since the Unix epoch.
|
||||||
|
pub created_at: i64,
|
||||||
|
/// The session a firing wakes; `None` meaning the task's own
|
||||||
|
/// `task:<id>` slot.
|
||||||
|
pub deliver_to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persistent task table over its own connection to the daemon's
|
||||||
|
/// SQLite file. Shareable behind an `Arc`; every operation takes
|
||||||
|
/// `&self`.
|
||||||
|
pub struct TaskStore {
|
||||||
|
conn: Mutex<rusqlite::Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskStore {
|
||||||
|
/// Opens (creating if needed) the task table in the database at
|
||||||
|
/// `path` — the same file the dispatcher's store holds. WAL keeps
|
||||||
|
/// the connections from blocking each other's readers; the busy
|
||||||
|
/// timeout rides out the other's write locks.
|
||||||
|
pub fn open(path: impl AsRef<Path>) -> Result<Self, TaskStoreError> {
|
||||||
|
let conn = rusqlite::Connection::open(path).map_err(TaskStoreError::from)?;
|
||||||
|
Self::init(conn, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a private in-memory table: the shape without the file, for
|
||||||
|
/// tests that need no sharing.
|
||||||
|
pub fn in_memory() -> Result<Self, TaskStoreError> {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().map_err(TaskStoreError::from)?;
|
||||||
|
Self::init(conn, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(conn: rusqlite::Connection, durable: bool) -> Result<Self, TaskStoreError> {
|
||||||
|
if durable {
|
||||||
|
// Same pragmas as the dispatcher's store on the same file:
|
||||||
|
// SQLite stores the journal mode per database, and both
|
||||||
|
// connections must agree that it is WAL.
|
||||||
|
let _mode: String = conn
|
||||||
|
.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
|
||||||
|
.map_err(TaskStoreError::from)?;
|
||||||
|
conn.busy_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.map_err(TaskStoreError::from)?;
|
||||||
|
}
|
||||||
|
conn.execute_batch(SCHEMA).map_err(TaskStoreError::from)?;
|
||||||
|
migrate_add_deliver_to(&conn)?;
|
||||||
|
Ok(Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedules a one-shot firing at `at_millis`, waking `deliver_to`
|
||||||
|
/// (the task's own `task:<id>` slot when `None`). A time already
|
||||||
|
/// past is accepted: the next detect pass fires it immediately — a
|
||||||
|
/// task scheduled for "15:00" at 15:01 still runs.
|
||||||
|
pub fn schedule_at(
|
||||||
|
&self,
|
||||||
|
prompt: &str,
|
||||||
|
at_millis: i64,
|
||||||
|
now: i64,
|
||||||
|
deliver_to: Option<&str>,
|
||||||
|
) -> Result<TaskRecord, TaskStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tasks (prompt, kind, at_millis, interval_millis, next_due_millis, created_at, deliver_to)
|
||||||
|
VALUES (?1, 'at', ?2, NULL, ?2, ?3, ?4)",
|
||||||
|
params![prompt, at_millis, now, deliver_to],
|
||||||
|
)?;
|
||||||
|
Ok(TaskRecord {
|
||||||
|
id: conn.last_insert_rowid(),
|
||||||
|
prompt: prompt.to_owned(),
|
||||||
|
kind: TaskKind::At,
|
||||||
|
at_millis: Some(at_millis),
|
||||||
|
interval_millis: None,
|
||||||
|
next_due_millis: at_millis,
|
||||||
|
created_at: now,
|
||||||
|
deliver_to: deliver_to.map(str::to_owned),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedules a recurring firing every `interval_millis`, the first
|
||||||
|
/// one `interval_millis` from `now`, waking `deliver_to`.
|
||||||
|
pub fn schedule_every(
|
||||||
|
&self,
|
||||||
|
prompt: &str,
|
||||||
|
interval_millis: i64,
|
||||||
|
now: i64,
|
||||||
|
deliver_to: Option<&str>,
|
||||||
|
) -> Result<TaskRecord, TaskStoreError> {
|
||||||
|
let next_due = now + interval_millis;
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tasks (prompt, kind, at_millis, interval_millis, next_due_millis, created_at, deliver_to)
|
||||||
|
VALUES (?1, 'every', NULL, ?2, ?3, ?4, ?5)",
|
||||||
|
params![prompt, interval_millis, next_due, now, deliver_to],
|
||||||
|
)?;
|
||||||
|
Ok(TaskRecord {
|
||||||
|
id: conn.last_insert_rowid(),
|
||||||
|
prompt: prompt.to_owned(),
|
||||||
|
kind: TaskKind::Every,
|
||||||
|
at_millis: None,
|
||||||
|
interval_millis: Some(interval_millis),
|
||||||
|
next_due_millis: next_due,
|
||||||
|
created_at: now,
|
||||||
|
deliver_to: deliver_to.map(str::to_owned),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every task, oldest first — the `list_tasks` view.
|
||||||
|
pub fn list(&self) -> Result<Vec<TaskRecord>, TaskStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, prompt, kind, at_millis, interval_millis, next_due_millis, created_at, deliver_to
|
||||||
|
FROM tasks ORDER BY id ASC",
|
||||||
|
)
|
||||||
|
.map_err(TaskStoreError::from)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], row_task)
|
||||||
|
.map_err(TaskStoreError::from)?
|
||||||
|
.collect::<Result<Vec<_>, rusqlite::Error>>()
|
||||||
|
.map_err(TaskStoreError::from)?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The due tasks at `now`, in firing order: `next_due_millis`
|
||||||
|
/// elapsed, oldest due first. One detect pass reads this once and
|
||||||
|
/// advances every row it acts on, so a task fires at most once per
|
||||||
|
/// pass.
|
||||||
|
pub fn due(&self, now: i64) -> Result<Vec<TaskRecord>, TaskStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, prompt, kind, at_millis, interval_millis, next_due_millis, created_at, deliver_to
|
||||||
|
FROM tasks WHERE next_due_millis <= ?1
|
||||||
|
ORDER BY next_due_millis ASC, id ASC",
|
||||||
|
)
|
||||||
|
.map_err(TaskStoreError::from)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![now], row_task)
|
||||||
|
.map_err(TaskStoreError::from)?
|
||||||
|
.collect::<Result<Vec<_>, rusqlite::Error>>()
|
||||||
|
.map_err(TaskStoreError::from)?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a task. `false` when there was none — the one-shot
|
||||||
|
/// completion path and `cancel_task` share it, and both treat "already
|
||||||
|
/// gone" as success.
|
||||||
|
pub fn remove(&self, id: i64) -> Result<bool, TaskStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let removed = conn.execute("DELETE FROM tasks WHERE id = ?1", params![id])?;
|
||||||
|
Ok(removed == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves a recurring task's next firing to `next_due_millis`. The
|
||||||
|
/// scheduler computes the value — never `now + interval` inside a
|
||||||
|
/// loop — so a task missed while the process was down catches up
|
||||||
|
/// with one firing, not one per elapsed interval.
|
||||||
|
pub fn rearm(&self, id: i64, next_due_millis: i64) -> Result<(), TaskStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE tasks SET next_due_millis = ?2 WHERE id = ?1",
|
||||||
|
params![id, next_due_millis],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps one row; the shared tail of [`TaskStore::list`] and
|
||||||
|
/// [`TaskStore::due`].
|
||||||
|
fn row_task(row: &rusqlite::Row<'_>) -> Result<TaskRecord, rusqlite::Error> {
|
||||||
|
let kind: String = row.get(2)?;
|
||||||
|
Ok(TaskRecord {
|
||||||
|
id: row.get(0)?,
|
||||||
|
prompt: row.get(1)?,
|
||||||
|
kind: TaskKind::parse(&kind)
|
||||||
|
.unwrap_or_else(|| unreachable!("the CHECK constraint only admits 'at' and 'every'")),
|
||||||
|
at_millis: row.get(3)?,
|
||||||
|
interval_millis: row.get(4)?,
|
||||||
|
next_due_millis: row.get(5)?,
|
||||||
|
created_at: row.get(6)?,
|
||||||
|
deliver_to: row.get(7)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tasks_round_trip_through_the_table() {
|
||||||
|
let store = TaskStore::in_memory().expect("store opens");
|
||||||
|
let at = store
|
||||||
|
.schedule_at("take the bread out", 1_500, 1_000, None)
|
||||||
|
.expect("at lands");
|
||||||
|
let every = store
|
||||||
|
.schedule_every("stand up", 1_800_000, 1_000, None)
|
||||||
|
.expect("every lands");
|
||||||
|
assert_ne!(at.id, every.id);
|
||||||
|
assert_eq!(at.deliver_to, None, "the default slot is the task's own");
|
||||||
|
|
||||||
|
let listed = store.list().expect("list reads");
|
||||||
|
assert_eq!(listed, vec![at.clone(), every.clone()]);
|
||||||
|
assert_eq!(at.kind, TaskKind::At);
|
||||||
|
assert_eq!(at.at_millis, Some(1_500));
|
||||||
|
assert_eq!(at.next_due_millis, 1_500, "a one-shot is due at its at");
|
||||||
|
assert_eq!(every.kind, TaskKind::Every);
|
||||||
|
assert_eq!(every.interval_millis, Some(1_800_000));
|
||||||
|
assert_eq!(every.next_due_millis, 1_801_000, "now + interval");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn due_selects_only_elapsed_tasks_in_firing_order() {
|
||||||
|
let store = TaskStore::in_memory().expect("store opens");
|
||||||
|
let late = store.schedule_at("late", 500, 0, None).expect("at lands");
|
||||||
|
let edge = store.schedule_at("edge", 1_000, 0, None).expect("at lands");
|
||||||
|
store
|
||||||
|
.schedule_at("future", 5_000, 0, None)
|
||||||
|
.expect("at lands");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
store.due(499).expect("due reads").is_empty(),
|
||||||
|
"nothing is due before the earliest"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.due(1_000)
|
||||||
|
.expect("due reads")
|
||||||
|
.iter()
|
||||||
|
.map(|task| task.id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[late.id, edge.id],
|
||||||
|
"the boundary is inclusive, in next-due order"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_and_rearm_advance_rows() {
|
||||||
|
let store = TaskStore::in_memory().expect("store opens");
|
||||||
|
let task = store.schedule_every("tick", 1_000, 0, None).expect("lands");
|
||||||
|
store.rearm(task.id, 9_999).expect("rearm lands");
|
||||||
|
let listed = store.list().expect("list reads");
|
||||||
|
assert_eq!(listed[0].next_due_millis, 9_999);
|
||||||
|
assert!(store.remove(task.id).expect("remove lands"));
|
||||||
|
assert!(!store.remove(task.id).expect("re-remove is quiet"));
|
||||||
|
assert!(store.list().expect("list reads").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_deliver_to_session_round_trips_and_defaults_absent() {
|
||||||
|
let store = TaskStore::in_memory().expect("store opens");
|
||||||
|
store
|
||||||
|
.schedule_at(
|
||||||
|
"summarize tonight's work",
|
||||||
|
1_500,
|
||||||
|
1_000,
|
||||||
|
Some("interactive:web"),
|
||||||
|
)
|
||||||
|
.expect("at lands");
|
||||||
|
let listed = store.list().expect("list reads");
|
||||||
|
assert_eq!(listed[0].deliver_to.as_deref(), Some("interactive:web"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A table from before `deliver_to` opens into the new shape; its
|
||||||
|
/// rows read as defaulting to the task's own slot.
|
||||||
|
#[test]
|
||||||
|
fn a_pre_deliver_to_table_migrates_at_open() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("dispatch.db");
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&path).expect("opens");
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE tasks (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK(kind IN ('at','every')),
|
||||||
|
at_millis INTEGER NULL,
|
||||||
|
interval_millis INTEGER NULL,
|
||||||
|
next_due_millis INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO tasks (prompt, kind, at_millis, next_due_millis, created_at)
|
||||||
|
VALUES ('old row', 'at', 42, 42, 0);",
|
||||||
|
)
|
||||||
|
.expect("legacy table");
|
||||||
|
}
|
||||||
|
let store = TaskStore::open(&path).expect("opens and migrates");
|
||||||
|
let listed = store.list().expect("list reads");
|
||||||
|
assert_eq!(listed.len(), 1);
|
||||||
|
assert_eq!(listed[0].deliver_to, None);
|
||||||
|
// The migrated row still schedules with a delivery target.
|
||||||
|
store
|
||||||
|
.schedule_at("new row", 50, 40, Some("task:9"))
|
||||||
|
.expect("schedules");
|
||||||
|
assert_eq!(store.list().expect("reads").len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_connections_share_one_file() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("dispatch.db");
|
||||||
|
let writer = TaskStore::open(&path).expect("first connection");
|
||||||
|
let reader = TaskStore::open(&path).expect("second connection");
|
||||||
|
writer.schedule_at("hello", 42, 0, None).expect("schedule");
|
||||||
|
let listed = reader.list().expect("the other connection sees it");
|
||||||
|
assert_eq!(listed.len(), 1);
|
||||||
|
assert_eq!(listed[0].prompt, "hello");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
//! The agent-side task tools: how the resident schedules its own future
|
||||||
|
//! turns — "remind me at 15:00" becomes a `schedule_task` call, the row
|
||||||
|
//! lands in the [`TaskStore`], and the next detect pass of the
|
||||||
|
//! [`TaskScheduler`](crate::task_source::TaskScheduler) picks it up. No
|
||||||
|
//! restart, no second mechanism, and no timer anywhere: a due task is
|
||||||
|
//! an event (spec §1), never a heartbeat.
|
||||||
|
//!
|
||||||
|
//! These are [`Tool`] implementations for a [`ToolRegistry`], so the
|
||||||
|
//! interactive handler offers them through the same allowlist-filtered
|
||||||
|
//! seam as everything else the model may call (spec §8). Answers are
|
||||||
|
//! short JSON; failures are in-band strings the model can react to.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||||
|
use rhema_core::ToolSpec;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use time::format_description::well_known::Rfc3339;
|
||||||
|
|
||||||
|
use crate::interactive::is_resident_key;
|
||||||
|
use crate::task_store::TaskStore;
|
||||||
|
|
||||||
|
/// The task tool set over `store`: `schedule_task`, `list_tasks`,
|
||||||
|
/// `cancel_task`.
|
||||||
|
pub fn registry(store: Arc<TaskStore>) -> ToolRegistry {
|
||||||
|
let mut registry = ToolRegistry::new();
|
||||||
|
registry.register(Arc::new(ScheduleTask {
|
||||||
|
store: store.clone(),
|
||||||
|
}));
|
||||||
|
registry.register(Arc::new(ListTasks {
|
||||||
|
store: store.clone(),
|
||||||
|
}));
|
||||||
|
registry.register(Arc::new(CancelTask { store }));
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ScheduleArgs {
|
||||||
|
prompt: String,
|
||||||
|
#[serde(default)]
|
||||||
|
at: Option<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
every_secs: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
session: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CancelArgs {
|
||||||
|
id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Milliseconds since the Unix epoch, the table's clock everywhere.
|
||||||
|
fn now_millis() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the `at` argument: epoch milliseconds as a JSON number, or a
|
||||||
|
/// string holding either epoch milliseconds or an RFC3339 timestamp.
|
||||||
|
fn parse_at_millis(value: Value) -> Result<i64, String> {
|
||||||
|
match value {
|
||||||
|
Value::Number(number) => number
|
||||||
|
.as_i64()
|
||||||
|
.ok_or_else(|| "`at` millis are out of range".to_owned()),
|
||||||
|
Value::String(raw) => {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if !trimmed.is_empty() && trimmed.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||||
|
trimmed
|
||||||
|
.parse::<i64>()
|
||||||
|
.map_err(|_| "`at` millis are out of range".to_owned())
|
||||||
|
} else {
|
||||||
|
let at = OffsetDateTime::parse(trimmed, &Rfc3339)
|
||||||
|
.map_err(|err| format!("`at` is not an RFC3339 timestamp: {err}"))?;
|
||||||
|
Ok(at.unix_timestamp() * 1000 + i64::from(at.millisecond()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Err("`at` is an RFC3339 timestamp or epoch milliseconds".to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedules one prompt: fire once at `at` (an RFC3339 timestamp or
|
||||||
|
/// epoch milliseconds) or repeatedly every `every_secs` seconds —
|
||||||
|
/// exactly one of the two.
|
||||||
|
struct ScheduleTask {
|
||||||
|
store: Arc<TaskStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for ScheduleTask {
|
||||||
|
fn spec(&self) -> ToolSpec {
|
||||||
|
ToolSpec::new(
|
||||||
|
"schedule_task",
|
||||||
|
"Schedule a future turn of yours. `prompt` runs as this \
|
||||||
|
agent's own turn — once at `at`, or repeatedly every \
|
||||||
|
`every_secs` seconds; set exactly one of the two. The \
|
||||||
|
scheduled turn has no chat surface: its reply reaches the \
|
||||||
|
user as a notification, so write prompts that produce one. \
|
||||||
|
Use list_tasks and cancel_task to manage what is scheduled.",
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"prompt": { "type": "string",
|
||||||
|
"description": "What the scheduled turn should do." },
|
||||||
|
"at": { "type": ["string", "number"],
|
||||||
|
"description": "When to fire once: an RFC3339 \
|
||||||
|
timestamp (\"2026-10-10T15:00:00Z\") or epoch \
|
||||||
|
milliseconds." },
|
||||||
|
"every_secs": { "type": "integer",
|
||||||
|
"description": "Repeat every this many seconds." }
|
||||||
|
},
|
||||||
|
"required": ["prompt"]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||||
|
let args: ScheduleArgs =
|
||||||
|
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||||
|
let prompt = args.prompt.trim();
|
||||||
|
if prompt.is_empty() {
|
||||||
|
return Err("prompt must not be empty".to_owned());
|
||||||
|
}
|
||||||
|
// A blank session reads as unset, and only a resident key can be
|
||||||
|
// woken: the delivery target must be a session the resident
|
||||||
|
// dispatcher answers.
|
||||||
|
let deliver_to = args
|
||||||
|
.session
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|session| !session.is_empty());
|
||||||
|
if let Some(session) = deliver_to {
|
||||||
|
if !is_resident_key(session) {
|
||||||
|
return Err(format!(
|
||||||
|
"session must be a resident key (interactive:, task:, or phone:), got {session:?}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let record = match (args.at, args.every_secs) {
|
||||||
|
(Some(at), None) => {
|
||||||
|
let at_millis = parse_at_millis(at)?;
|
||||||
|
self.store
|
||||||
|
.schedule_at(prompt, at_millis, now_millis(), deliver_to)
|
||||||
|
.map_err(|err| err.to_string())?
|
||||||
|
}
|
||||||
|
(None, Some(every_secs)) => {
|
||||||
|
if every_secs == 0 {
|
||||||
|
return Err("every_secs must be at least 1".to_owned());
|
||||||
|
}
|
||||||
|
let interval = every_secs
|
||||||
|
.checked_mul(1000)
|
||||||
|
.filter(|millis| *millis <= i64::MAX as u64)
|
||||||
|
.ok_or_else(|| "`every_secs` is out of range".to_owned())?;
|
||||||
|
self.store
|
||||||
|
.schedule_every(prompt, interval as i64, now_millis(), deliver_to)
|
||||||
|
.map_err(|err| err.to_string())?
|
||||||
|
}
|
||||||
|
(Some(_), Some(_)) => {
|
||||||
|
return Err("set exactly one of `at` or `every_secs`, not both".to_owned());
|
||||||
|
}
|
||||||
|
(None, None) => {
|
||||||
|
return Err("set one of `at` or `every_secs`".to_owned());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(json!({
|
||||||
|
"scheduled": true,
|
||||||
|
"id": record.id,
|
||||||
|
"kind": record.kind.name(),
|
||||||
|
"next_due_millis": record.next_due_millis,
|
||||||
|
"session": record.deliver_to,
|
||||||
|
})
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists what is scheduled.
|
||||||
|
struct ListTasks {
|
||||||
|
store: Arc<TaskStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for ListTasks {
|
||||||
|
fn spec(&self) -> ToolSpec {
|
||||||
|
ToolSpec::new(
|
||||||
|
"list_tasks",
|
||||||
|
"List the scheduled tasks: id, prompt, kind (at or every), \
|
||||||
|
and next_due_millis — when each one fires next.",
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke(&self, _args: Value) -> Result<String, String> {
|
||||||
|
let tasks = self.store.list().map_err(|err| err.to_string())?;
|
||||||
|
Ok(json!({ "tasks": tasks }).to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels one task by id.
|
||||||
|
struct CancelTask {
|
||||||
|
store: Arc<TaskStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for CancelTask {
|
||||||
|
fn spec(&self) -> ToolSpec {
|
||||||
|
ToolSpec::new(
|
||||||
|
"cancel_task",
|
||||||
|
"Cancel a scheduled task by id (as list_tasks reports it). \
|
||||||
|
Already-fired one-shots report cancelled: false.",
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer",
|
||||||
|
"description": "The task's id." }
|
||||||
|
},
|
||||||
|
"required": ["id"]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||||
|
let args: CancelArgs =
|
||||||
|
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||||
|
let removed = self.store.remove(args.id).map_err(|err| err.to_string())?;
|
||||||
|
Ok(json!({ "cancelled": removed, "id": args.id }).to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use crate::task_store::TaskKind;
|
||||||
|
|
||||||
|
fn store() -> Arc<TaskStore> {
|
||||||
|
Arc::new(TaskStore::in_memory().expect("store opens"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call(store: &Arc<TaskStore>, name: &str, args: Value) -> rhema_core::ToolResult {
|
||||||
|
let resolver =
|
||||||
|
registry(store.clone()).resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
|
||||||
|
resolver
|
||||||
|
.resolve(
|
||||||
|
rhema_core::ToolCall {
|
||||||
|
id: "c1".into(),
|
||||||
|
name: name.into(),
|
||||||
|
args,
|
||||||
|
provider_data: Value::Null,
|
||||||
|
},
|
||||||
|
&rhema_core::ToolContext::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn schedule_at_lands_a_one_shot_row() {
|
||||||
|
let store = store();
|
||||||
|
let result = call(
|
||||||
|
&store,
|
||||||
|
"schedule_task",
|
||||||
|
json!({"prompt": "remind the user: bread", "at": 2_000_000_000_000_i64}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(!result.is_error, "{}", result.content);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&result.content).unwrap(),
|
||||||
|
json!({"scheduled": true, "id": 1, "kind": "at", "next_due_millis": 2_000_000_000_000_i64, "session": null}),
|
||||||
|
"{}",
|
||||||
|
result.content
|
||||||
|
);
|
||||||
|
let listed = store.list().expect("list reads");
|
||||||
|
assert_eq!(listed.len(), 1);
|
||||||
|
assert_eq!(listed[0].kind, TaskKind::At);
|
||||||
|
assert_eq!(listed[0].prompt, "remind the user: bread");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_rfc3339_at_parses_to_millis() {
|
||||||
|
let store = store();
|
||||||
|
let result = call(
|
||||||
|
&store,
|
||||||
|
"schedule_task",
|
||||||
|
json!({"prompt": "standup", "at": "2026-10-10T15:00:00Z"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(!result.is_error, "{}", result.content);
|
||||||
|
let listed = store.list().expect("list reads");
|
||||||
|
assert_eq!(listed[0].kind, TaskKind::At);
|
||||||
|
assert_eq!(listed[0].next_due_millis, 1_791_644_400_000, "15:00Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn schedule_every_lands_a_recurring_row() {
|
||||||
|
let store = store();
|
||||||
|
let result = call(
|
||||||
|
&store,
|
||||||
|
"schedule_task",
|
||||||
|
json!({"prompt": "stretch", "every_secs": 1800}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(!result.is_error, "{}", result.content);
|
||||||
|
let listed = store.list().expect("list reads");
|
||||||
|
assert_eq!(listed.len(), 1);
|
||||||
|
assert_eq!(listed[0].kind, TaskKind::Every);
|
||||||
|
assert_eq!(listed[0].interval_millis, Some(1_800_000));
|
||||||
|
assert!(
|
||||||
|
listed[0].next_due_millis > now_millis(),
|
||||||
|
"the first firing is in the future"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bad_shapes_fail_in_band_and_land_nothing() {
|
||||||
|
let store = store();
|
||||||
|
for (args, reason) in [
|
||||||
|
(
|
||||||
|
json!({"prompt": "x", "at": 1, "every_secs": 1}),
|
||||||
|
"exactly one",
|
||||||
|
),
|
||||||
|
(json!({"prompt": "x"}), "one of"),
|
||||||
|
(json!({"prompt": " ", "at": 1}), "empty"),
|
||||||
|
(json!({"prompt": "x", "at": "not a time"}), "RFC3339"),
|
||||||
|
(json!({"prompt": "x", "at": [1]}), "epoch milliseconds"),
|
||||||
|
(json!({"prompt": "x", "every_secs": 0}), "at least 1"),
|
||||||
|
] {
|
||||||
|
let result = call(&store, "schedule_task", args).await;
|
||||||
|
assert!(result.is_error, "{}", result.content);
|
||||||
|
assert!(
|
||||||
|
result.content.contains(reason),
|
||||||
|
"{reason} in {}",
|
||||||
|
result.content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
store.list().expect("list reads").is_empty(),
|
||||||
|
"nothing landed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wake-itself shape: a session-named task lands with its
|
||||||
|
/// delivery target, and a non-resident target is refused in band.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_session_target_lands_and_a_foreign_one_is_refused() {
|
||||||
|
let store = store();
|
||||||
|
let result = call(
|
||||||
|
&store,
|
||||||
|
"schedule_task",
|
||||||
|
json!({
|
||||||
|
"prompt": "summarize this evening's work",
|
||||||
|
"at": "2026-10-15T08:00:00Z",
|
||||||
|
"session": "interactive:web",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(!result.is_error, "{}", result.content);
|
||||||
|
let answer: Value = serde_json::from_str(&result.content).unwrap();
|
||||||
|
assert_eq!(answer["session"], "interactive:web");
|
||||||
|
assert_eq!(
|
||||||
|
store.list().expect("reads")[0].deliver_to.as_deref(),
|
||||||
|
Some("interactive:web")
|
||||||
|
);
|
||||||
|
|
||||||
|
// A blank session reads as unset — the default task slot.
|
||||||
|
for blank in ["", " "] {
|
||||||
|
let accepted = call(
|
||||||
|
&store,
|
||||||
|
"schedule_task",
|
||||||
|
json!({"prompt": "x", "at": 1, "session": blank}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(!accepted.is_error, "{blank:?} reads as unset");
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.list()
|
||||||
|
.expect("reads")
|
||||||
|
.last()
|
||||||
|
.expect("lands")
|
||||||
|
.deliver_to,
|
||||||
|
None,
|
||||||
|
"{blank:?} schedules into the task's own slot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A non-resident target, or a bare name, cannot be woken.
|
||||||
|
for foreign in ["github:acme/w#7", "web"] {
|
||||||
|
let refused = call(
|
||||||
|
&store,
|
||||||
|
"schedule_task",
|
||||||
|
json!({"prompt": "x", "at": 1, "session": foreign}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(refused.is_error, "{foreign:?} must be refused");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.list()
|
||||||
|
.expect("reads")
|
||||||
|
.iter()
|
||||||
|
.filter(|task| task.deliver_to.is_some())
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"the named session is the only targeted row"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_and_cancel_round_trip_through_the_table() {
|
||||||
|
let store = store();
|
||||||
|
store.schedule_at("one", 42, 0, None).expect("seed");
|
||||||
|
store.schedule_every("two", 1_000, 0, None).expect("seed");
|
||||||
|
|
||||||
|
let listed = call(&store, "list_tasks", json!({})).await;
|
||||||
|
assert!(!listed.is_error, "{}", listed.content);
|
||||||
|
let value: Value = serde_json::from_str(&listed.content).unwrap();
|
||||||
|
assert_eq!(value["tasks"].as_array().map(Vec::len), Some(2));
|
||||||
|
assert_eq!(value["tasks"][0]["kind"], "at");
|
||||||
|
assert_eq!(value["tasks"][1]["kind"], "every");
|
||||||
|
|
||||||
|
let cancelled = call(&store, "cancel_task", json!({"id": 1})).await;
|
||||||
|
assert!(!cancelled.is_error, "{}", cancelled.content);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&cancelled.content).unwrap(),
|
||||||
|
json!({"cancelled": true, "id": 1})
|
||||||
|
);
|
||||||
|
let already_gone = call(&store, "cancel_task", json!({"id": 1})).await;
|
||||||
|
assert!(!already_gone.is_error, "{}", already_gone.content);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&already_gone.content).unwrap(),
|
||||||
|
json!({"cancelled": false, "id": 1})
|
||||||
|
);
|
||||||
|
assert_eq!(store.list().expect("list reads").len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
//! The triage: what a notification earns, decided deterministically
|
||||||
|
//! before any model call (spec §1 — the model is never the detector).
|
||||||
|
//!
|
||||||
|
//! A [`Notification`] carries the forge's own reason for bothering the
|
||||||
|
//! account; [`classify`] reduces it to one of three verdicts. An
|
||||||
|
//! [`Triage::Agent`] dispatch becomes an RSD event whose payload names
|
||||||
|
//! the [`Role`] — the persona the handler's prompt opens with, so one
|
||||||
|
//! thread can move from review-requested to implement-feedback across
|
||||||
|
//! dispatches with the session carrying the continuity. A
|
||||||
|
//! [`Triage::Surface`] never dispatches an agent: a mention is not a
|
||||||
|
//! assignment, and the account's human hears about it through the
|
||||||
|
//! device-notification queue instead. [`Triage::Ignore`] drops the noise
|
||||||
|
//! (CI churn) on the floor.
|
||||||
|
//!
|
||||||
|
//! The mapping is a pure function and this module's tests pin it: the
|
||||||
|
//! forges' vocabularies are external data, and the policy must not drift
|
||||||
|
//! with them.
|
||||||
|
|
||||||
|
use rhema_forge_integration::{Notification, NotificationReason};
|
||||||
|
|
||||||
|
/// The agent persona a dispatched notification runs as. The key stays
|
||||||
|
/// the thread — one session, one voice per thread, ever — and the role
|
||||||
|
/// rides the dispatch: it selects the prompt's opening framing and is
|
||||||
|
/// the seam where per-role tool policies land later.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Role {
|
||||||
|
/// Asked to review: judge the pull request and post the review.
|
||||||
|
Reviewer,
|
||||||
|
/// Asked to do the work: implement the issue or answer the feedback.
|
||||||
|
Implementer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Role {
|
||||||
|
/// The payload and prompt spelling.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Role::Reviewer => "reviewer",
|
||||||
|
Role::Implementer => "implementer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one notification earns.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Triage {
|
||||||
|
/// Dispatch the thread's agent as this role.
|
||||||
|
Agent(Role),
|
||||||
|
/// No agent: surface the notification to the account's human through
|
||||||
|
/// the device-notification queue.
|
||||||
|
Surface,
|
||||||
|
/// Drop it: noise the account should not hear about.
|
||||||
|
Ignore,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classifies one notification. A forge reason the vocabulary does not
|
||||||
|
/// know ([`NotificationReason::Other`]) surfaces rather than acts: an
|
||||||
|
/// unknown shape is the user's to judge, never the agent's to guess at.
|
||||||
|
pub fn classify(notification: &Notification) -> Triage {
|
||||||
|
match ¬ification.reason {
|
||||||
|
NotificationReason::ReviewRequested => Triage::Agent(Role::Reviewer),
|
||||||
|
NotificationReason::Assigned => Triage::Agent(Role::Implementer),
|
||||||
|
NotificationReason::ReviewFeedback => Triage::Agent(Role::Implementer),
|
||||||
|
NotificationReason::Author => Triage::Agent(Role::Implementer),
|
||||||
|
NotificationReason::Mention => Triage::Surface,
|
||||||
|
NotificationReason::CiActivity => Triage::Ignore,
|
||||||
|
NotificationReason::Other(_) => Triage::Surface,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rhema_forge_integration::{RepoRef, Timestamp};
|
||||||
|
|
||||||
|
fn notification(reason: NotificationReason) -> Notification {
|
||||||
|
Notification {
|
||||||
|
id: format!("github:notification:1@{}", reason.as_str()),
|
||||||
|
reason,
|
||||||
|
repo: RepoRef::new("acme", "widgets"),
|
||||||
|
number: 7,
|
||||||
|
is_pr: true,
|
||||||
|
actor: None,
|
||||||
|
title: None,
|
||||||
|
url: None,
|
||||||
|
timestamp: Timestamp::parse("2026-10-08T00:00:00Z").expect("parses"),
|
||||||
|
raw: serde_json::json!({}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asks_become_agent_dispatches_of_the_matching_role() {
|
||||||
|
assert_eq!(
|
||||||
|
classify(¬ification(NotificationReason::ReviewRequested)),
|
||||||
|
Triage::Agent(Role::Reviewer)
|
||||||
|
);
|
||||||
|
for reason in [
|
||||||
|
NotificationReason::Assigned,
|
||||||
|
NotificationReason::ReviewFeedback,
|
||||||
|
NotificationReason::Author,
|
||||||
|
] {
|
||||||
|
let triage = classify(¬ification(reason.clone()));
|
||||||
|
assert_eq!(
|
||||||
|
triage,
|
||||||
|
Triage::Agent(Role::Implementer),
|
||||||
|
"{} implements",
|
||||||
|
reason.as_str()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mentions_surface_ci_ignores_and_unknowns_surface() {
|
||||||
|
assert_eq!(
|
||||||
|
classify(¬ification(NotificationReason::Mention)),
|
||||||
|
Triage::Surface
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify(¬ification(NotificationReason::CiActivity)),
|
||||||
|
Triage::Ignore
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify(¬ification(NotificationReason::Other(
|
||||||
|
"subscription".to_owned()
|
||||||
|
))),
|
||||||
|
Triage::Surface
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roles_round_trip_through_serde() {
|
||||||
|
for role in [Role::Reviewer, Role::Implementer] {
|
||||||
|
let encoded = serde_json::to_value(role).expect("serializes");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<Role>(encoded).expect("deserializes"),
|
||||||
|
role
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
//! The turn-history table: one durable row per streamed event, in the
|
||||||
|
//! same database file the dispatcher's
|
||||||
|
//! [`SqliteStore`](rhema_rsd::SqliteStore) and the other claw tables
|
||||||
|
//! use.
|
||||||
|
//!
|
||||||
|
//! The table is append-only and unbounded, on purpose: it keeps the
|
||||||
|
//! *transcript* — what a viewer saw, tool rows and thinking traces
|
||||||
|
//! included — and a resident agent's sessions run for weeks, so a
|
||||||
|
//! rolling window would eat history no client could ever get back. The
|
||||||
|
//! event hub appends every event here as it streams and replays from
|
||||||
|
//! here on subscribe; the session stash keeps the *conversation* (what
|
||||||
|
//! the model sees). A pre-window table (`turn_history`, one JSON blob
|
||||||
|
//! per key rewritten whole) migrates once, at open, into this shape.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Mutex, PoisonError};
|
||||||
|
|
||||||
|
use rusqlite::params;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::hub::TurnEvent;
|
||||||
|
|
||||||
|
/// The turn-history table.
|
||||||
|
const SCHEMA: &str = "
|
||||||
|
CREATE TABLE IF NOT EXISTS turn_events (
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
seq INTEGER NOT NULL,
|
||||||
|
event TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (key, seq)
|
||||||
|
);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Why a turn-history operation failed. A row whose JSON does not parse
|
||||||
|
/// is its own variant: a transcript that cannot replay must say so, not
|
||||||
|
/// silently read as "no history".
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum TurnStoreError {
|
||||||
|
/// The SQLite backend failed.
|
||||||
|
#[error("turn history backend: {0}")]
|
||||||
|
Backend(#[from] rusqlite::Error),
|
||||||
|
/// A stored event is not a turn event.
|
||||||
|
#[error("turn history for {key} does not parse: {source}")]
|
||||||
|
Corrupt {
|
||||||
|
/// The key whose row failed.
|
||||||
|
key: String,
|
||||||
|
/// The parse failure.
|
||||||
|
#[source]
|
||||||
|
source: serde_json::Error,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persistent turn-history table over its own connection to the
|
||||||
|
/// daemon's SQLite file. Shareable behind an `Arc`; every operation
|
||||||
|
/// takes `&self`.
|
||||||
|
pub struct TurnStore {
|
||||||
|
conn: Mutex<rusqlite::Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TurnStore {
|
||||||
|
/// Opens (creating if needed) the turn-history table in the database
|
||||||
|
/// at `path`, migrating a legacy whole-blob table if one is present.
|
||||||
|
/// WAL keeps the connections from blocking each other's readers; the
|
||||||
|
/// busy timeout rides out the other's write locks.
|
||||||
|
pub fn open(path: impl AsRef<Path>) -> Result<Self, TurnStoreError> {
|
||||||
|
let conn = rusqlite::Connection::open(path).map_err(TurnStoreError::from)?;
|
||||||
|
Self::init(conn, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a private in-memory table: the shape without the file, for
|
||||||
|
/// tests that need no sharing.
|
||||||
|
pub fn in_memory() -> Result<Self, TurnStoreError> {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().map_err(TurnStoreError::from)?;
|
||||||
|
Self::init(conn, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(conn: rusqlite::Connection, durable: bool) -> Result<Self, TurnStoreError> {
|
||||||
|
if durable {
|
||||||
|
// Same pragmas as the dispatcher's store on the same file:
|
||||||
|
// SQLite stores the journal mode per database, and both
|
||||||
|
// connections must agree that it is WAL.
|
||||||
|
let _mode: String = conn
|
||||||
|
.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
|
||||||
|
.map_err(TurnStoreError::from)?;
|
||||||
|
conn.busy_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.map_err(TurnStoreError::from)?;
|
||||||
|
}
|
||||||
|
conn.execute_batch(SCHEMA).map_err(TurnStoreError::from)?;
|
||||||
|
migrate_legacy_blob_table(&conn)?;
|
||||||
|
Ok(Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends `events` to `key`'s stream, in order, durable before it
|
||||||
|
/// returns. The stream's sequence is the table's, so appends extend
|
||||||
|
/// and never rewrite what a replay already read.
|
||||||
|
pub fn append(&self, key: &str, events: &[TurnEvent]) -> Result<(), TurnStoreError> {
|
||||||
|
if events.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let tx = conn.transaction().map_err(TurnStoreError::from)?;
|
||||||
|
let next: i64 = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT COALESCE(MAX(seq) + 1, 0) FROM turn_events WHERE key = ?1",
|
||||||
|
params![key],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(TurnStoreError::from)?;
|
||||||
|
for (offset, event) in events.iter().enumerate() {
|
||||||
|
let json = serde_json::to_string(event).map_err(|err| TurnStoreError::Corrupt {
|
||||||
|
key: key.to_owned(),
|
||||||
|
source: err,
|
||||||
|
})?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO turn_events (key, seq, event) VALUES (?1, ?2, ?3)",
|
||||||
|
params![key, next + offset as i64, json],
|
||||||
|
)
|
||||||
|
.map_err(TurnStoreError::from)?;
|
||||||
|
}
|
||||||
|
tx.commit().map_err(TurnStoreError::from)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `key`'s whole recorded stream, oldest first — the replay a fresh
|
||||||
|
/// subscriber folds before the live feed.
|
||||||
|
pub fn events(&self, key: &str) -> Result<Vec<TurnEvent>, TurnStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let mut stmt = conn.prepare("SELECT event FROM turn_events WHERE key = ?1 ORDER BY seq")?;
|
||||||
|
let rows = stmt.query_map(params![key], |row| row.get::<_, String>(0))?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let json = row.map_err(TurnStoreError::from)?;
|
||||||
|
out.push(parse(key, &json)?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves a legacy `turn_history` table (one JSON-array blob per key,
|
||||||
|
/// rewritten whole) into per-event rows, then drops it. A blob that no
|
||||||
|
/// longer parses is skipped with a warning: half a migration beats a
|
||||||
|
/// boot that cannot open its history at all. No-op when the table is
|
||||||
|
/// absent (every database this code created fresh).
|
||||||
|
fn migrate_legacy_blob_table(conn: &rusqlite::Connection) -> Result<(), TurnStoreError> {
|
||||||
|
let legacy: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'turn_history'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.map(|count| count > 0)
|
||||||
|
.map_err(TurnStoreError::from)?;
|
||||||
|
if !legacy {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut stmt = conn.prepare("SELECT key, events FROM turn_history")?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
|
})?;
|
||||||
|
let mut migrated = 0usize;
|
||||||
|
for row in rows {
|
||||||
|
let (key, blob) = row.map_err(TurnStoreError::from)?;
|
||||||
|
let events: Vec<Value> = match serde_json::from_str(&blob) {
|
||||||
|
Ok(events) => events,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!(
|
||||||
|
"[claw] warning: legacy transcript for {key} is unreadable, dropped: {err}"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (seq, event) in events.into_iter().enumerate() {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO turn_events (key, seq, event) VALUES (?1, ?2, ?3)",
|
||||||
|
params![key, seq as i64, event.to_string()],
|
||||||
|
)
|
||||||
|
.map_err(TurnStoreError::from)?;
|
||||||
|
}
|
||||||
|
migrated += 1;
|
||||||
|
}
|
||||||
|
drop(stmt);
|
||||||
|
conn.execute("DROP TABLE turn_history", [])
|
||||||
|
.map_err(TurnStoreError::from)?;
|
||||||
|
if migrated > 0 {
|
||||||
|
eprintln!("[claw] migrated {migrated} legacy transcript(s) to the append-only table");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses one stored event. The JSON value step keeps the error message
|
||||||
|
/// about *what* the row holds, not just that serde choked.
|
||||||
|
fn parse(key: &str, json: &str) -> Result<TurnEvent, TurnStoreError> {
|
||||||
|
let value: Value = serde_json::from_str(json).map_err(|source| TurnStoreError::Corrupt {
|
||||||
|
key: key.to_owned(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
serde_json::from_value(value).map_err(|source| TurnStoreError::Corrupt {
|
||||||
|
key: key.to_owned(),
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn text_delta(text: &str) -> TurnEvent {
|
||||||
|
TurnEvent::TextDelta {
|
||||||
|
text: text.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_appended_history_round_trips_through_events() {
|
||||||
|
let store = TurnStore::in_memory().expect("store opens");
|
||||||
|
assert_eq!(store.events("interactive:web").expect("reads"), vec![]);
|
||||||
|
|
||||||
|
store
|
||||||
|
.append(
|
||||||
|
"interactive:web",
|
||||||
|
&[
|
||||||
|
TurnEvent::UserMessage {
|
||||||
|
text: "hello".into(),
|
||||||
|
},
|
||||||
|
text_delta("hi"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.expect("appends");
|
||||||
|
store
|
||||||
|
.append(
|
||||||
|
"interactive:web",
|
||||||
|
&[TurnEvent::TurnDone {
|
||||||
|
reply: Some("hi".into()),
|
||||||
|
outcome: "handled".into(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.expect("appends again");
|
||||||
|
assert_eq!(
|
||||||
|
store.events("interactive:web").expect("reads"),
|
||||||
|
vec![
|
||||||
|
TurnEvent::UserMessage {
|
||||||
|
text: "hello".into()
|
||||||
|
},
|
||||||
|
text_delta("hi"),
|
||||||
|
TurnEvent::TurnDone {
|
||||||
|
reply: Some("hi".into()),
|
||||||
|
outcome: "handled".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"appends extend the stream in order"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_append_is_quiet() {
|
||||||
|
let store = TurnStore::in_memory().expect("store opens");
|
||||||
|
store
|
||||||
|
.append("interactive:web", &[])
|
||||||
|
.expect("an empty append is a no-op");
|
||||||
|
assert_eq!(store.events("interactive:web").expect("reads"), vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_are_separate_streams() {
|
||||||
|
let store = TurnStore::in_memory().expect("store opens");
|
||||||
|
store.append("task:2", &[text_delta("b")]).expect("appends");
|
||||||
|
store
|
||||||
|
.append("interactive:web", &[text_delta("a")])
|
||||||
|
.expect("appends");
|
||||||
|
assert_eq!(
|
||||||
|
store.events("task:2").expect("reads"),
|
||||||
|
vec![text_delta("b")],
|
||||||
|
"one key's stream never leaks into another's"
|
||||||
|
);
|
||||||
|
assert_eq!(store.events("interactive:web").expect("reads").len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_corrupt_event_row_is_loud_not_empty() {
|
||||||
|
let store = TurnStore::in_memory().expect("store opens");
|
||||||
|
{
|
||||||
|
let conn = store.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO turn_events (key, seq, event) VALUES ('interactive:web', 0, 'garbage')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.expect("writes raw");
|
||||||
|
}
|
||||||
|
let err = store
|
||||||
|
.events("interactive:web")
|
||||||
|
.expect_err("corruption is loud");
|
||||||
|
assert!(err.to_string().contains("interactive:web"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one-time migration: a pre-window blob table opens into the
|
||||||
|
/// append-only shape, its rows gone.
|
||||||
|
#[test]
|
||||||
|
fn a_legacy_blob_table_migrates_into_event_rows() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("dispatch.db");
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&path).expect("opens");
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE turn_history (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
events TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO turn_history VALUES ('interactive:web',
|
||||||
|
'[{\"type\":\"text_delta\",\"text\":\"old days\"}]', 1);
|
||||||
|
INSERT INTO turn_history VALUES ('interactive:broke', 'not json', 2);",
|
||||||
|
)
|
||||||
|
.expect("legacy table");
|
||||||
|
}
|
||||||
|
let store = TurnStore::open(&path).expect("opens and migrates");
|
||||||
|
assert_eq!(
|
||||||
|
store.events("interactive:web").expect("reads"),
|
||||||
|
vec![text_delta("old days")],
|
||||||
|
"the readable blob migrated"
|
||||||
|
);
|
||||||
|
assert_eq!(store.events("interactive:broke").expect("reads"), vec![]);
|
||||||
|
{
|
||||||
|
let conn = store.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let legacy: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE name = 'turn_history'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("reads");
|
||||||
|
assert_eq!(legacy, 0, "the legacy table is gone");
|
||||||
|
}
|
||||||
|
// The migrated rows keep appending after the migration.
|
||||||
|
store
|
||||||
|
.append("interactive:web", &[text_delta("new days")])
|
||||||
|
.expect("appends");
|
||||||
|
assert_eq!(store.events("interactive:web").expect("reads").len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
//! The watch table: agent-managed watches in SQLite, the same database
|
||||||
|
//! file the dispatcher's [`SqliteStore`](rhema_rsd::SqliteStore) uses.
|
||||||
|
//!
|
||||||
|
//! Watches are data the daemon reconfigures at runtime — the chat
|
||||||
|
//! surface's [`watch_repo`](crate::watch_tools) tool and the HTTP
|
||||||
|
//! `POST /v1/watches` route both land here — so the table is read fresh
|
||||||
|
//! on every detect pass instead of living only in the YAML config. The
|
||||||
|
//! config's `watches:` list is a one-time seed ([`ClawStore::
|
||||||
|
//! seed_from_config`]): imported when the table is still empty, ignored
|
||||||
|
//! once the table has rows.
|
||||||
|
//!
|
||||||
|
//! Two connections, one file: this store opens its own rusqlite
|
||||||
|
//! connection to the same database as the dispatcher's store — WAL and a
|
||||||
|
//! busy timeout, the pattern rhema-rsd's SQLite tests use — and the two
|
||||||
|
//! interleave through SQLite's own locking. Writes are single INSERT /
|
||||||
|
//! DELETE statements, so there is no read-modify-write window to guard.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Mutex, PoisonError};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use rhema_forge_integration::RepoRef;
|
||||||
|
use rusqlite::params;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::config::{ForgeKind, Watch};
|
||||||
|
|
||||||
|
/// The watch table, and the account-watch table beside it: one row per
|
||||||
|
/// identity whose forge notifications the daemon polls. An account watch
|
||||||
|
/// is derived state — it names the [`rhema_identity`] account the
|
||||||
|
/// notifications belong to, and its token is resolved through the
|
||||||
|
/// identity store at poll time, never stored here.
|
||||||
|
const SCHEMA: &str = "
|
||||||
|
CREATE TABLE IF NOT EXISTS watches (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
forge TEXT NOT NULL,
|
||||||
|
repo TEXT NOT NULL,
|
||||||
|
host TEXT,
|
||||||
|
token TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(forge, repo)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS account_watches (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
forge TEXT NOT NULL,
|
||||||
|
host TEXT,
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(forge, account_id)
|
||||||
|
);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Why a watch-table operation failed.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum WatchStoreError {
|
||||||
|
/// The SQLite backend failed.
|
||||||
|
#[error("watch store backend: {0}")]
|
||||||
|
Backend(#[from] rusqlite::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One watched repository, as stored. `token` never serializes: it
|
||||||
|
/// reaches the forge clients, not the wire or the model.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct WatchRecord {
|
||||||
|
/// The row id.
|
||||||
|
pub id: i64,
|
||||||
|
/// The forge's source name: `github`, `gitea`, or `gitlab`.
|
||||||
|
pub forge: String,
|
||||||
|
/// The repository, `owner/name` (GitLab: `namespace/project`).
|
||||||
|
pub repo: String,
|
||||||
|
/// The instance base URL; `None` for the forges with production
|
||||||
|
/// bases. Required for gitea, which is self-hosted only.
|
||||||
|
pub host: Option<String>,
|
||||||
|
/// The forge token; blank means the forge's environment variable is
|
||||||
|
/// consulted at poll time. Kept out of every serialized form.
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub token: String,
|
||||||
|
/// Creation time, milliseconds since the Unix epoch.
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WatchRecord {
|
||||||
|
/// The repository coordinates, splitting `owner/name` — the same
|
||||||
|
/// shape check a config watch passes. GitLab's owner keeps its full
|
||||||
|
/// namespace path, which is why the split is lazy.
|
||||||
|
pub fn repo_ref(&self) -> Result<RepoRef, String> {
|
||||||
|
let (owner, name) = self
|
||||||
|
.repo
|
||||||
|
.split_once('/')
|
||||||
|
.ok_or_else(|| format!("repo must be owner/name, got {:?}", self.repo))?;
|
||||||
|
if owner.is_empty() || name.is_empty() {
|
||||||
|
return Err(format!("repo must be owner/name, got {:?}", self.repo));
|
||||||
|
}
|
||||||
|
Ok(RepoRef::new(owner, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persistent watch table over its own connection to the daemon's
|
||||||
|
/// SQLite file. Shareable behind an `Arc`; every operation takes `&self`.
|
||||||
|
pub struct ClawStore {
|
||||||
|
conn: Mutex<rusqlite::Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One watched identity: the daemon polls this forge's notifications as
|
||||||
|
/// the named account. `account_id` is the [`rhema_identity`] store's id —
|
||||||
|
/// the credential stays there, never in this table.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct AccountWatchRecord {
|
||||||
|
/// The row id.
|
||||||
|
pub id: i64,
|
||||||
|
/// The forge's source name: `github`, `gitea`, or `gitlab`.
|
||||||
|
pub forge: String,
|
||||||
|
/// The instance base URL; `None` for the forges with production
|
||||||
|
/// bases. Required for gitea, which is self-hosted only.
|
||||||
|
pub host: Option<String>,
|
||||||
|
/// The identity store's account id the watch polls as.
|
||||||
|
pub account_id: String,
|
||||||
|
/// Creation time, milliseconds since the Unix epoch.
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClawStore {
|
||||||
|
/// Opens (creating if needed) the watch table in the database at
|
||||||
|
/// `path` — the same file the dispatcher's store holds. WAL keeps the
|
||||||
|
/// two connections from blocking each other's readers; the busy
|
||||||
|
/// timeout rides out the other's write locks.
|
||||||
|
pub fn open(path: impl AsRef<Path>) -> Result<Self, WatchStoreError> {
|
||||||
|
let conn = rusqlite::Connection::open(path).map_err(WatchStoreError::from)?;
|
||||||
|
Self::init(conn, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a private in-memory table: the shape without the file, for
|
||||||
|
/// tests that need no sharing.
|
||||||
|
pub fn in_memory() -> Result<Self, WatchStoreError> {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().map_err(WatchStoreError::from)?;
|
||||||
|
Self::init(conn, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(conn: rusqlite::Connection, durable: bool) -> Result<Self, WatchStoreError> {
|
||||||
|
if durable {
|
||||||
|
// Same pragmas as the dispatcher's store on the same file:
|
||||||
|
// SQLite stores the journal mode per database, and both
|
||||||
|
// connections must agree that it is WAL.
|
||||||
|
let _mode: String = conn
|
||||||
|
.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
|
||||||
|
.map_err(WatchStoreError::from)?;
|
||||||
|
conn.busy_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.map_err(WatchStoreError::from)?;
|
||||||
|
}
|
||||||
|
conn.execute_batch(SCHEMA).map_err(WatchStoreError::from)?;
|
||||||
|
Ok(Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a watch. `false` when `(forge, repo)` is already watched —
|
||||||
|
/// the unique index turns a re-add into a no-op, so callers can be
|
||||||
|
/// liberal.
|
||||||
|
pub fn add(
|
||||||
|
&self,
|
||||||
|
forge: &str,
|
||||||
|
repo: &str,
|
||||||
|
host: Option<&str>,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<bool, WatchStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let inserted = conn.execute(
|
||||||
|
"INSERT INTO watches (forge, repo, host, token, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
ON CONFLICT(forge, repo) DO NOTHING",
|
||||||
|
params![
|
||||||
|
forge.trim(),
|
||||||
|
repo.trim(),
|
||||||
|
host.map(str::trim),
|
||||||
|
token.trim(),
|
||||||
|
now_millis()
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(inserted == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the watch for `(forge, repo)`; `false` when there was
|
||||||
|
/// none.
|
||||||
|
pub fn remove(&self, forge: &str, repo: &str) -> Result<bool, WatchStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let removed = conn.execute(
|
||||||
|
"DELETE FROM watches WHERE forge = ?1 AND repo = ?2",
|
||||||
|
params![forge.trim(), repo.trim()],
|
||||||
|
)?;
|
||||||
|
Ok(removed == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every watch, oldest first. Read fresh by every detect pass, so a
|
||||||
|
/// watch added at runtime takes effect without a restart.
|
||||||
|
pub fn list(&self) -> Result<Vec<WatchRecord>, WatchStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT id, forge, repo, host, token, created_at FROM watches ORDER BY id ASC")
|
||||||
|
.map_err(WatchStoreError::from)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(WatchRecord {
|
||||||
|
id: row.get(0)?,
|
||||||
|
forge: row.get(1)?,
|
||||||
|
repo: row.get(2)?,
|
||||||
|
host: row.get(3)?,
|
||||||
|
token: row.get(4)?,
|
||||||
|
created_at: row.get(5)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(WatchStoreError::from)?
|
||||||
|
.collect::<Result<Vec<_>, rusqlite::Error>>()
|
||||||
|
.map_err(WatchStoreError::from)?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The config seed: imports `watches` when the table is empty — the
|
||||||
|
/// one-time bootstrap of a fresh deployment — and reports how many
|
||||||
|
/// landed. A populated table is never rewritten by the seed: once
|
||||||
|
/// any watch exists, the table rules and the config list is inert.
|
||||||
|
/// Only the config's own token is stored; the forge's environment
|
||||||
|
/// variable still serves at poll time when the stored one is blank.
|
||||||
|
pub fn seed_from_config(&self, watches: &[Watch]) -> Result<usize, WatchStoreError> {
|
||||||
|
if !self.list()?.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let mut seeded = 0;
|
||||||
|
for watch in watches {
|
||||||
|
let token = watch.token.as_deref().map(str::trim).unwrap_or("");
|
||||||
|
if self.add(
|
||||||
|
watch.forge.name(),
|
||||||
|
&watch.repo,
|
||||||
|
watch.host.as_deref(),
|
||||||
|
token,
|
||||||
|
)? {
|
||||||
|
seeded += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(seeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers the account watch: the daemon polls `forge`'s
|
||||||
|
/// notifications as the account. `false` when the account is already
|
||||||
|
/// watched on that forge. The identity's own secret is *not* passed
|
||||||
|
/// here — the watch names the account, and the token is resolved
|
||||||
|
/// through the identity store at poll time.
|
||||||
|
pub fn add_account_watch(
|
||||||
|
&self,
|
||||||
|
forge: &str,
|
||||||
|
host: Option<&str>,
|
||||||
|
account_id: &str,
|
||||||
|
) -> Result<bool, WatchStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let inserted = conn.execute(
|
||||||
|
"INSERT INTO account_watches (forge, host, account_id, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(forge, account_id) DO NOTHING",
|
||||||
|
params![
|
||||||
|
forge.trim(),
|
||||||
|
host.map(str::trim),
|
||||||
|
account_id.trim(),
|
||||||
|
now_millis()
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(inserted == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the account watch for `(forge, account_id)`; `false` when
|
||||||
|
/// there was none.
|
||||||
|
pub fn remove_account_watch(
|
||||||
|
&self,
|
||||||
|
forge: &str,
|
||||||
|
account_id: &str,
|
||||||
|
) -> Result<bool, WatchStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let removed = conn.execute(
|
||||||
|
"DELETE FROM account_watches WHERE forge = ?1 AND account_id = ?2",
|
||||||
|
params![forge.trim(), account_id.trim()],
|
||||||
|
)?;
|
||||||
|
Ok(removed == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes every account watch of one account — the cascade its
|
||||||
|
/// `identity_forget` runs. Returns how many rows went.
|
||||||
|
pub fn remove_account_watches_for(&self, account_id: &str) -> Result<usize, WatchStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let removed = conn.execute(
|
||||||
|
"DELETE FROM account_watches WHERE account_id = ?1",
|
||||||
|
params![account_id.trim()],
|
||||||
|
)?;
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every account watch, oldest first. Read fresh by every detect
|
||||||
|
/// pass, like the repo watches.
|
||||||
|
pub fn list_account_watches(&self) -> Result<Vec<AccountWatchRecord>, WatchStoreError> {
|
||||||
|
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, forge, host, account_id, created_at
|
||||||
|
FROM account_watches ORDER BY id ASC",
|
||||||
|
)
|
||||||
|
.map_err(WatchStoreError::from)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(AccountWatchRecord {
|
||||||
|
id: row.get(0)?,
|
||||||
|
forge: row.get(1)?,
|
||||||
|
host: row.get(2)?,
|
||||||
|
account_id: row.get(3)?,
|
||||||
|
created_at: row.get(4)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(WatchStoreError::from)?
|
||||||
|
.collect::<Result<Vec<_>, rusqlite::Error>>()
|
||||||
|
.map_err(WatchStoreError::from)?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Milliseconds since the Unix epoch; stamps `created_at`.
|
||||||
|
fn now_millis() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shape checks every watch must pass, wherever it enters (the
|
||||||
|
/// config seed, the HTTP route, the agent tool): a parseable forge, an
|
||||||
|
/// `owner/name` repo, and — gitea being self-hosted only — a host for
|
||||||
|
/// gitea. Tokens are not required here: a blank one falls back to the
|
||||||
|
/// forge's environment variable at poll time.
|
||||||
|
pub fn validate_watch(forge: ForgeKind, repo: &str, host: Option<&str>) -> Result<(), String> {
|
||||||
|
let watch = Watch {
|
||||||
|
forge,
|
||||||
|
repo: repo.trim().to_owned(),
|
||||||
|
host: host.map(str::trim).map(str::to_owned),
|
||||||
|
token: None,
|
||||||
|
};
|
||||||
|
watch.repo_ref()?;
|
||||||
|
if forge == ForgeKind::Gitea && watch.host.as_deref().filter(|h| !h.is_empty()).is_none() {
|
||||||
|
return Err("gitea is self-hosted: set host to the instance base URL".to_owned());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_watch_round_trips_through_the_table() {
|
||||||
|
let store = ClawStore::in_memory().expect("store opens");
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.add("github", "acme/widgets", None, "tok")
|
||||||
|
.expect("add lands")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.add("gitea", "acme/barn", Some("https://git.example.net"), "")
|
||||||
|
.expect("add lands")
|
||||||
|
);
|
||||||
|
|
||||||
|
let watches = store.list().expect("list reads");
|
||||||
|
assert_eq!(watches.len(), 2);
|
||||||
|
assert_eq!(watches[0].forge, "github");
|
||||||
|
assert_eq!(watches[0].repo, "acme/widgets");
|
||||||
|
assert_eq!(watches[0].host, None);
|
||||||
|
assert_eq!(watches[0].token, "tok");
|
||||||
|
assert_eq!(watches[1].host.as_deref(), Some("https://git.example.net"));
|
||||||
|
assert_eq!(watches[1].token, "", "a missing token stores blank");
|
||||||
|
|
||||||
|
assert!(store.remove("github", "acme/widgets").expect("remove"));
|
||||||
|
assert!(!store.remove("github", "acme/widgets").expect("re-remove"));
|
||||||
|
assert_eq!(store.list().expect("list reads").len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_re_add_is_a_no_op_not_an_error() {
|
||||||
|
let store = ClawStore::in_memory().expect("store opens");
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.add("gitlab", "group/sub/prop", None, "")
|
||||||
|
.expect("first")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!store
|
||||||
|
.add("gitlab", "group/sub/prop", None, "")
|
||||||
|
.expect("second")
|
||||||
|
);
|
||||||
|
assert_eq!(store.list().expect("list reads").len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_record_repo_ref_splits_owner_from_name_lazily() {
|
||||||
|
let store = ClawStore::in_memory().expect("store opens");
|
||||||
|
store
|
||||||
|
.add("gitlab", "group/subgroup/widgets", None, "")
|
||||||
|
.expect("add");
|
||||||
|
let record = &store.list().expect("list reads")[0];
|
||||||
|
assert_eq!(
|
||||||
|
record.repo_ref().expect("parses").path(),
|
||||||
|
"group/subgroup/widgets"
|
||||||
|
);
|
||||||
|
let bad = WatchRecord {
|
||||||
|
id: 0,
|
||||||
|
forge: "github".into(),
|
||||||
|
repo: "ownerless".into(),
|
||||||
|
host: None,
|
||||||
|
token: String::new(),
|
||||||
|
created_at: 0,
|
||||||
|
};
|
||||||
|
assert!(bad.repo_ref().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_seed_imports_only_an_empty_table() {
|
||||||
|
let store = ClawStore::in_memory().expect("store opens");
|
||||||
|
let watches = vec![Watch {
|
||||||
|
forge: ForgeKind::GitHub,
|
||||||
|
repo: "acme/widgets".to_owned(),
|
||||||
|
host: None,
|
||||||
|
token: Some(" ".to_owned()),
|
||||||
|
}];
|
||||||
|
assert_eq!(
|
||||||
|
store.seed_from_config(&watches).expect("seeds"),
|
||||||
|
1,
|
||||||
|
"the empty table takes the seed"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.list().expect("list reads")[0].token,
|
||||||
|
"",
|
||||||
|
"blank stays blank"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.seed_from_config(&watches).expect("skips"),
|
||||||
|
0,
|
||||||
|
"a populated table ignores the seed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_checks_shape_and_giteas_host() {
|
||||||
|
assert!(validate_watch(ForgeKind::GitHub, "acme/widgets", None).is_ok());
|
||||||
|
assert!(validate_watch(ForgeKind::GitHub, "ownerless", None).is_err());
|
||||||
|
assert!(validate_watch(ForgeKind::GitHub, "acme/", None).is_err());
|
||||||
|
assert!(validate_watch(ForgeKind::Gitea, "acme/barn", None).is_err());
|
||||||
|
assert!(validate_watch(ForgeKind::Gitea, "acme/barn", Some(" ")).is_err());
|
||||||
|
assert!(
|
||||||
|
validate_watch(
|
||||||
|
ForgeKind::Gitea,
|
||||||
|
"acme/barn",
|
||||||
|
Some("https://git.example.net")
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
validate_watch(ForgeKind::GitLab, "group/subgroup/widgets", None).is_ok(),
|
||||||
|
"gitlab has a production base"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_token_never_serializes() {
|
||||||
|
let store = ClawStore::in_memory().expect("store opens");
|
||||||
|
store
|
||||||
|
.add("github", "acme/widgets", None, "sekrit")
|
||||||
|
.expect("add");
|
||||||
|
let json =
|
||||||
|
serde_json::to_string(&store.list().expect("list reads")[0]).expect("serializes");
|
||||||
|
assert!(!json.contains("sekrit"), "token leaked: {json}");
|
||||||
|
assert!(json.contains("acme/widgets"), "{json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_connections_share_one_file() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("dispatch.db");
|
||||||
|
let writer = ClawStore::open(&path).expect("first connection");
|
||||||
|
let reader = ClawStore::open(&path).expect("second connection");
|
||||||
|
writer.add("github", "acme/widgets", None, "").expect("add");
|
||||||
|
let watches = reader.list().expect("the other connection sees it");
|
||||||
|
assert_eq!(watches.len(), 1);
|
||||||
|
assert_eq!(watches[0].repo, "acme/widgets");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
//! The agent-side watch tools: how the resident agent reconfigures its
|
||||||
|
//! own watch list mid-conversation — "watch this repo too" becomes a
|
||||||
|
//! tool call, the row lands in the [`ClawStore`], and the next detect
|
||||||
|
//! pass picks it up. No restart, no second mechanism.
|
||||||
|
//!
|
||||||
|
//! These are [`Tool`] implementations for a [`ToolRegistry`], so the
|
||||||
|
//! interactive handler offers them through the same allowlist-filtered
|
||||||
|
//! seam as everything else the model may call (spec §8: the allowlist is
|
||||||
|
//! fixed before the first step, never decided mid-run). Answers are
|
||||||
|
//! short JSON; failures are in-band strings the model can react to.
|
||||||
|
//! Tokens never appear in an answer.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||||
|
use rhema_core::ToolSpec;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::config::ForgeKind;
|
||||||
|
use crate::watch_store::{ClawStore, validate_watch};
|
||||||
|
|
||||||
|
/// The watch-management tool set over `store`: `watch_repo`,
|
||||||
|
/// `unwatch_repo`, `list_watches`.
|
||||||
|
pub fn registry(store: Arc<ClawStore>) -> ToolRegistry {
|
||||||
|
let mut registry = ToolRegistry::new();
|
||||||
|
registry.register(Arc::new(WatchRepo {
|
||||||
|
store: store.clone(),
|
||||||
|
}));
|
||||||
|
registry.register(Arc::new(UnwatchRepo {
|
||||||
|
store: store.clone(),
|
||||||
|
}));
|
||||||
|
registry.register(Arc::new(ListWatches { store }));
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WatchArgs {
|
||||||
|
forge: String,
|
||||||
|
repo: String,
|
||||||
|
#[serde(default)]
|
||||||
|
host: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UnwatchArgs {
|
||||||
|
forge: String,
|
||||||
|
repo: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts watching: one row in the table.
|
||||||
|
struct WatchRepo {
|
||||||
|
store: Arc<ClawStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for WatchRepo {
|
||||||
|
fn spec(&self) -> ToolSpec {
|
||||||
|
ToolSpec::new(
|
||||||
|
"watch_repo",
|
||||||
|
"Start watching a repository: new activity (issues, pull \
|
||||||
|
requests, comments, reviews) arrives as its own dispatch. \
|
||||||
|
`forge` is `github`, `gitea`, or `gitlab`; `repo` is \
|
||||||
|
`owner/name` (GitLab: `namespace/project`); gitea also needs \
|
||||||
|
`host`, its instance base URL. `token` is optional and falls \
|
||||||
|
back to the forge's environment variable.",
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"forge": { "type": "string",
|
||||||
|
"description": "github, gitea, or gitlab." },
|
||||||
|
"repo": { "type": "string",
|
||||||
|
"description": "The repository as owner/name." },
|
||||||
|
"host": { "type": "string",
|
||||||
|
"description": "Instance base URL; required for gitea." },
|
||||||
|
"token": { "type": "string",
|
||||||
|
"description": "Forge token; optional, env fallback." }
|
||||||
|
},
|
||||||
|
"required": ["forge", "repo"]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||||
|
let args: WatchArgs =
|
||||||
|
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||||
|
let kind = ForgeKind::parse(&args.forge)
|
||||||
|
.ok_or_else(|| format!("unknown forge {:?}: github, gitea, or gitlab", args.forge))?;
|
||||||
|
let host = args.host.as_deref().map(str::trim);
|
||||||
|
validate_watch(kind, &args.repo, host)?;
|
||||||
|
let added = self
|
||||||
|
.store
|
||||||
|
.add(
|
||||||
|
kind.name(),
|
||||||
|
args.repo.trim(),
|
||||||
|
host,
|
||||||
|
args.token.as_deref().map(str::trim).unwrap_or(""),
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
Ok(json!({
|
||||||
|
"watched": true,
|
||||||
|
"added": added,
|
||||||
|
"forge": kind.name(),
|
||||||
|
"repo": args.repo.trim(),
|
||||||
|
})
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops watching: the row leaves the table.
|
||||||
|
struct UnwatchRepo {
|
||||||
|
store: Arc<ClawStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for UnwatchRepo {
|
||||||
|
fn spec(&self) -> ToolSpec {
|
||||||
|
ToolSpec::new(
|
||||||
|
"unwatch_repo",
|
||||||
|
"Stop watching a repository: no more activity arrives from \
|
||||||
|
it. Same `forge` and `repo` spelling as `watch_repo`.",
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"forge": { "type": "string",
|
||||||
|
"description": "github, gitea, or gitlab." },
|
||||||
|
"repo": { "type": "string",
|
||||||
|
"description": "The repository as owner/name." }
|
||||||
|
},
|
||||||
|
"required": ["forge", "repo"]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||||
|
let args: UnwatchArgs =
|
||||||
|
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||||
|
let kind = ForgeKind::parse(&args.forge)
|
||||||
|
.ok_or_else(|| format!("unknown forge {:?}: github, gitea, or gitlab", args.forge))?;
|
||||||
|
let removed = self
|
||||||
|
.store
|
||||||
|
.remove(kind.name(), args.repo.trim())
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
Ok(json!({
|
||||||
|
"watched": false,
|
||||||
|
"removed": removed,
|
||||||
|
"forge": kind.name(),
|
||||||
|
"repo": args.repo.trim(),
|
||||||
|
})
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists what is watched.
|
||||||
|
struct ListWatches {
|
||||||
|
store: Arc<ClawStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for ListWatches {
|
||||||
|
fn spec(&self) -> ToolSpec {
|
||||||
|
ToolSpec::new(
|
||||||
|
"list_watches",
|
||||||
|
"List the watched repositories: forge, repo, and host for \
|
||||||
|
each.",
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke(&self, _args: Value) -> Result<String, String> {
|
||||||
|
let watches = self.store.list().map_err(|err| err.to_string())?;
|
||||||
|
let rows: Vec<Value> = watches
|
||||||
|
.iter()
|
||||||
|
.map(|watch| {
|
||||||
|
json!({
|
||||||
|
"forge": watch.forge,
|
||||||
|
"repo": watch.repo,
|
||||||
|
"host": watch.host,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(json!({ "watches": rows }).to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn store() -> Arc<ClawStore> {
|
||||||
|
Arc::new(ClawStore::in_memory().expect("store opens"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn names(registry: &ToolRegistry) -> Vec<String> {
|
||||||
|
registry
|
||||||
|
.resolver_for(&rhema_autonomous_agent::Allowlist::permissive())
|
||||||
|
.specs()
|
||||||
|
.into_iter()
|
||||||
|
.map(|spec| spec.name)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_registry_offers_the_three_tools() {
|
||||||
|
let tools = registry(store());
|
||||||
|
assert_eq!(
|
||||||
|
names(&tools),
|
||||||
|
["list_watches", "unwatch_repo", "watch_repo"].map(str::to_owned),
|
||||||
|
"BTreeMap order: the registry sorts by name"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn watch_repo_lands_a_row_and_answers_json() {
|
||||||
|
let store = store();
|
||||||
|
let tools = registry(store.clone());
|
||||||
|
let resolver = tools.resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
|
||||||
|
let ctx = rhema_core::ToolContext::default();
|
||||||
|
let result = resolver
|
||||||
|
.resolve(
|
||||||
|
rhema_core::ToolCall {
|
||||||
|
id: "c1".into(),
|
||||||
|
name: "watch_repo".into(),
|
||||||
|
args: json!({
|
||||||
|
"forge": "github",
|
||||||
|
"repo": "acme/widgets",
|
||||||
|
"token": "sekrit",
|
||||||
|
}),
|
||||||
|
provider_data: Value::Null,
|
||||||
|
},
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(!result.is_error, "{}", result.content);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&result.content).unwrap(),
|
||||||
|
json!({"watched": true, "added": true, "forge": "github", "repo": "acme/widgets"}),
|
||||||
|
"{}",
|
||||||
|
result.content
|
||||||
|
);
|
||||||
|
let watches = store.list().expect("list reads");
|
||||||
|
assert_eq!(watches[0].repo, "acme/widgets");
|
||||||
|
assert_eq!(
|
||||||
|
watches[0].token, "sekrit",
|
||||||
|
"the token is stored, not echoed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!result.content.contains("sekrit"),
|
||||||
|
"answers never carry tokens"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_bad_watch_fails_in_band_with_the_reason() {
|
||||||
|
let store = store();
|
||||||
|
let tools = registry(store.clone());
|
||||||
|
let resolver = tools.resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
|
||||||
|
let ctx = rhema_core::ToolContext::default();
|
||||||
|
let call = |name: &str, args: Value| {
|
||||||
|
resolver.resolve(
|
||||||
|
rhema_core::ToolCall {
|
||||||
|
id: "c1".into(),
|
||||||
|
name: name.into(),
|
||||||
|
args,
|
||||||
|
provider_data: Value::Null,
|
||||||
|
},
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let hostless = call("watch_repo", json!({"forge": "gitea", "repo": "acme/barn"})).await;
|
||||||
|
assert!(hostless.is_error);
|
||||||
|
assert!(
|
||||||
|
hostless.content.contains("self-hosted"),
|
||||||
|
"{}",
|
||||||
|
hostless.content
|
||||||
|
);
|
||||||
|
|
||||||
|
let unknown = call(
|
||||||
|
"watch_repo",
|
||||||
|
json!({"forge": "sourcehut", "repo": "acme/barn"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(unknown.is_error);
|
||||||
|
assert!(
|
||||||
|
unknown.content.contains("unknown forge"),
|
||||||
|
"{}",
|
||||||
|
unknown.content
|
||||||
|
);
|
||||||
|
|
||||||
|
let malformed = call("watch_repo", json!({"forge": "github"})).await;
|
||||||
|
assert!(malformed.is_error);
|
||||||
|
assert!(
|
||||||
|
malformed.content.contains("bad arguments"),
|
||||||
|
"{}",
|
||||||
|
malformed.content
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
store.list().expect("list reads").is_empty(),
|
||||||
|
"nothing landed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unwatch_and_list_round_trip_through_the_same_table() {
|
||||||
|
let store = store();
|
||||||
|
store.add("github", "acme/widgets", None, "").expect("seed");
|
||||||
|
store.add("gitlab", "group/prop", None, "").expect("seed");
|
||||||
|
let tools = registry(store.clone());
|
||||||
|
let resolver = tools.resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
|
||||||
|
let ctx = rhema_core::ToolContext::default();
|
||||||
|
let call = |name: &str, args: Value| {
|
||||||
|
resolver.resolve(
|
||||||
|
rhema_core::ToolCall {
|
||||||
|
id: "c1".into(),
|
||||||
|
name: name.into(),
|
||||||
|
args,
|
||||||
|
provider_data: Value::Null,
|
||||||
|
},
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let listed = call("list_watches", json!({})).await;
|
||||||
|
assert!(!listed.is_error);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&listed.content).unwrap(),
|
||||||
|
json!({"watches": [
|
||||||
|
{"forge": "github", "repo": "acme/widgets", "host": null},
|
||||||
|
{"forge": "gitlab", "repo": "group/prop", "host": null},
|
||||||
|
]}),
|
||||||
|
"{}",
|
||||||
|
listed.content
|
||||||
|
);
|
||||||
|
|
||||||
|
let removed = call(
|
||||||
|
"unwatch_repo",
|
||||||
|
json!({"forge": "github", "repo": "acme/widgets"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(!removed.is_error, "{}", removed.content);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&removed.content).unwrap(),
|
||||||
|
json!({"watched": false, "removed": true, "forge": "github", "repo": "acme/widgets"}),
|
||||||
|
);
|
||||||
|
assert_eq!(store.list().expect("list reads").len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
//! Session continuity across processes: two `--once` passes over the same
|
||||||
|
//! SQLite db and the same thread. The first pass's conversation is
|
||||||
|
//! stashed into the dispatcher's `driver_state`; the second pass's agent
|
||||||
|
//! resumes from it — the MockProvider cannot record its requests, so the
|
||||||
|
//! stashed state itself is the proof: the restored history carries both
|
||||||
|
//! exchanges.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rhema_claw::{ClawConfig, ForgeKind, Watch, run_once};
|
||||||
|
use rhema_core::{Message, MockProvider, ModelOutput, Role, Session, StopReason, Usage};
|
||||||
|
use rhema_rsd::{KeyState, Outcome, ResourceKey, SqliteStore, Store};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use wiremock::matchers::{body_partial_json, method, path};
|
||||||
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
|
/// An open issue row, created and updated in one instant: its dedup id
|
||||||
|
/// `github:issue:1007@…` never changes, so every pass's poll re-feeds it
|
||||||
|
/// and delivery stays at-least-once — the second pass sees the replay
|
||||||
|
/// plus the new comment.
|
||||||
|
fn issue_row() -> Value {
|
||||||
|
json!({
|
||||||
|
"id": 1007,
|
||||||
|
"number": 7,
|
||||||
|
"state": "open",
|
||||||
|
"title": "crash on save",
|
||||||
|
"user": { "login": "alice" },
|
||||||
|
"labels": [{ "name": "bug" }],
|
||||||
|
"body": "steps to reproduce…",
|
||||||
|
"created_at": "2026-10-02T10:00:00Z",
|
||||||
|
"updated_at": "2026-10-02T10:00:00Z",
|
||||||
|
"closed_at": null,
|
||||||
|
"html_url": "https://github.com/acme/widgets/issues/7"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A comment row on thread 7.
|
||||||
|
fn comment_row(body: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"id": 8001,
|
||||||
|
"user": { "login": "dave" },
|
||||||
|
"body": body,
|
||||||
|
"created_at": "2026-10-03T09:00:00Z",
|
||||||
|
"updated_at": "2026-10-03T09:00:00Z",
|
||||||
|
"issue_url": "https://api.github.com/repos/acme/widgets/issues/7",
|
||||||
|
"html_url": "https://github.com/acme/widgets/issues/7#issuecomment-8001"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(text: &str) -> ModelOutput {
|
||||||
|
ModelOutput {
|
||||||
|
message: Message::text(Role::Assistant, text),
|
||||||
|
stop_reason: StopReason::EndTurn,
|
||||||
|
usage: Usage::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig {
|
||||||
|
ClawConfig {
|
||||||
|
db: Some(dir.path().join("dispatch.db")),
|
||||||
|
workspace_root: Some(dir.path().join("workspaces")),
|
||||||
|
memory_root: None,
|
||||||
|
bootstrap_root: None,
|
||||||
|
poll_interval_secs: 60,
|
||||||
|
max_turn_steps: rhema_claw::config::DEFAULT_MAX_TURN_STEPS,
|
||||||
|
provider: Some("google".to_owned()),
|
||||||
|
model: Some("test-model".to_owned()),
|
||||||
|
listen: None,
|
||||||
|
token: None,
|
||||||
|
watches: vec![Watch {
|
||||||
|
forge: ForgeKind::GitHub,
|
||||||
|
repo: "acme/widgets".to_owned(),
|
||||||
|
host: Some(server.uri()),
|
||||||
|
token: Some("tok".to_owned()),
|
||||||
|
}],
|
||||||
|
calendars: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The thread's standing state: open, unchanged across both passes.
|
||||||
|
async fn mount_snapshot(server: &MockServer) {
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/repos/acme/widgets/issues/7"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(issue_row()))
|
||||||
|
.mount(server)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_second_dispatch_resumes_the_first_conversation() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/repos/acme/widgets/issues"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!([issue_row()])))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
// The comments feed answers per pass: quiet first, then dave's
|
||||||
|
// comment. Same matchers, mounted in order — the first serves the
|
||||||
|
// first request only, then falls through.
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/repos/acme/widgets/issues/comments"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
|
||||||
|
.up_to_n_times(1)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/repos/acme/widgets/issues/comments"))
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(200).set_body_json(json!([comment_row("any progress?")])),
|
||||||
|
)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
mount_snapshot(&server).await;
|
||||||
|
let first_post = Mock::given(method("POST"))
|
||||||
|
.and(path("/repos/acme/widgets/issues/7/comments"))
|
||||||
|
.and(body_partial_json(json!({ "body": "on it" })))
|
||||||
|
.respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 43 })))
|
||||||
|
.expect(1)
|
||||||
|
.mount_as_scoped(&server)
|
||||||
|
.await;
|
||||||
|
let second_post = Mock::given(method("POST"))
|
||||||
|
.and(path("/repos/acme/widgets/issues/7/comments"))
|
||||||
|
.and(body_partial_json(json!({ "body": "still on it" })))
|
||||||
|
.respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 44 })))
|
||||||
|
.expect(1)
|
||||||
|
.mount_as_scoped(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let config = config(&server, &dir);
|
||||||
|
let key = ResourceKey::from("github:acme/widgets#7");
|
||||||
|
|
||||||
|
// Pass one: the opened event alone. The cron shape — a fresh process,
|
||||||
|
// a fresh watcher, the same db.
|
||||||
|
let first = run_once(
|
||||||
|
&config,
|
||||||
|
mock_registry(Arc::new(MockProvider::new([reply("on it")]))),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the first pass runs");
|
||||||
|
assert_eq!(first.detected, 1);
|
||||||
|
assert_eq!(first.outcomes, vec![Outcome::Handled]);
|
||||||
|
drop(first_post);
|
||||||
|
|
||||||
|
// Pass two: the replayed opened event plus dave's comment, both
|
||||||
|
// decoded into one prompt for one resumed run.
|
||||||
|
let second = run_once(
|
||||||
|
&config,
|
||||||
|
mock_registry(Arc::new(MockProvider::new([reply("still on it")]))),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the second pass runs");
|
||||||
|
assert_eq!(second.detected, 2, "the replay re-delivers, at-least-once");
|
||||||
|
assert_eq!(second.outcomes, vec![Outcome::Handled]);
|
||||||
|
drop(second_post);
|
||||||
|
|
||||||
|
// The proof: the second run's stashed session carries the first
|
||||||
|
// conversation — a fresh agent would hold only its own exchange.
|
||||||
|
let store = SqliteStore::open(dir.path().join("dispatch.db")).expect("db reopens");
|
||||||
|
let record = store
|
||||||
|
.load(&key)
|
||||||
|
.expect("the record loads")
|
||||||
|
.expect("the key has a record");
|
||||||
|
assert_eq!(record.state, KeyState::Idle);
|
||||||
|
let stashed: Session = serde_json::from_value(
|
||||||
|
record
|
||||||
|
.session
|
||||||
|
.expect("the key has a session")
|
||||||
|
.core
|
||||||
|
.driver_state,
|
||||||
|
)
|
||||||
|
.expect("the stash parses back into a session");
|
||||||
|
let user_prompts: Vec<&str> = stashed
|
||||||
|
.history
|
||||||
|
.iter()
|
||||||
|
.filter_map(|message| match &message.content[0] {
|
||||||
|
rhema_core::Content::Text(text) if message.role == Role::User => Some(text.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
user_prompts.len(),
|
||||||
|
2,
|
||||||
|
"both dispatches prompted: {user_prompts:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
user_prompts[0].contains("opened by alice"),
|
||||||
|
"pass one's prompt carried the opened event"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
user_prompts[1].contains("any progress?"),
|
||||||
|
"pass two's prompt carried the new comment"
|
||||||
|
);
|
||||||
|
let replies: Vec<&str> = stashed
|
||||||
|
.history
|
||||||
|
.iter()
|
||||||
|
.filter_map(|message| match &message.content[0] {
|
||||||
|
rhema_core::Content::Text(text) if message.role == Role::Assistant => {
|
||||||
|
Some(text.as_str())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
replies,
|
||||||
|
["on it", "still on it"],
|
||||||
|
"the conversation resumed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A registry whose only backend is the scripted mock — the shape every
|
||||||
|
/// integration test runs on.
|
||||||
|
fn mock_registry(provider: Arc<dyn rhema_core::ModelProvider>) -> Arc<rhema_claw::BackendRegistry> {
|
||||||
|
Arc::new(
|
||||||
|
rhema_claw::BackendRegistry::new(
|
||||||
|
vec![rhema_claw::Backend::headless("mock", provider)],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("a single mock backend always constructs"),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
//! Offline end-to-end: one `--once` pass over a wiremock GitHub — the
|
||||||
|
//! feed is polled, the MockProvider's reply lands as the thread's
|
||||||
|
//! comment, and the dispatch state persists in the SQLite db. Zero
|
||||||
|
//! network.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rhema_claw::{ClawConfig, ForgeKind, Watch, run_once};
|
||||||
|
use rhema_core::{Message, MockProvider, ModelOutput, Role, StopReason, Usage};
|
||||||
|
use rhema_rsd::{
|
||||||
|
Dispatcher, DispatcherConfig, KeyState, Outcome, ResourceKey, SqliteStore, SystemClock,
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use wiremock::matchers::{body_partial_json, method, path};
|
||||||
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
|
/// A closed issue row, updated at `updated_at`: the feed's event source.
|
||||||
|
fn issue_row(updated_at: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"id": 1007,
|
||||||
|
"number": 7,
|
||||||
|
"state": "closed",
|
||||||
|
"title": "crash on save",
|
||||||
|
"user": { "login": "alice" },
|
||||||
|
"labels": [{ "name": "bug" }],
|
||||||
|
"body": "steps to reproduce…",
|
||||||
|
"created_at": "2026-10-01T09:00:00Z",
|
||||||
|
"updated_at": updated_at,
|
||||||
|
"closed_at": "2026-10-02T10:00:00Z",
|
||||||
|
"html_url": "https://github.com/acme/widgets/issues/7"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scripted assistant turn: one text reply, nothing else.
|
||||||
|
fn reply(text: &str) -> ModelOutput {
|
||||||
|
ModelOutput {
|
||||||
|
message: Message::text(Role::Assistant, text),
|
||||||
|
stop_reason: StopReason::EndTurn,
|
||||||
|
usage: Usage::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The test's claw config: everything under one tempdir, the watch aimed
|
||||||
|
/// at the mock server. The provider never resolves through it — the
|
||||||
|
/// daemon library takes the built provider, and the test builds a mock.
|
||||||
|
fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig {
|
||||||
|
ClawConfig {
|
||||||
|
db: Some(dir.path().join("dispatch.db")),
|
||||||
|
workspace_root: Some(dir.path().join("workspaces")),
|
||||||
|
memory_root: None,
|
||||||
|
bootstrap_root: None,
|
||||||
|
poll_interval_secs: 60,
|
||||||
|
max_turn_steps: rhema_claw::config::DEFAULT_MAX_TURN_STEPS,
|
||||||
|
provider: Some("google".to_owned()),
|
||||||
|
model: Some("test-model".to_owned()),
|
||||||
|
listen: None,
|
||||||
|
token: None,
|
||||||
|
watches: vec![Watch {
|
||||||
|
forge: ForgeKind::GitHub,
|
||||||
|
repo: "acme/widgets".to_owned(),
|
||||||
|
host: Some(server.uri()),
|
||||||
|
token: Some("tok".to_owned()),
|
||||||
|
}],
|
||||||
|
calendars: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn one_pass_polls_reasons_and_posts() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
// The feeds a first poll reads: the issues list (one closed issue)
|
||||||
|
// and the quiet comments feed.
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/repos/acme/widgets/issues"))
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(200).set_body_json(json!([issue_row("2026-10-02T10:00:00Z")])),
|
||||||
|
)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/repos/acme/widgets/issues/comments"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
// The handler's orientation call: the thread snapshot.
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/repos/acme/widgets/issues/7"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(issue_row("2026-10-02T10:00:00Z")))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
// The reply, counted: the assertion is that it posts exactly once
|
||||||
|
// with the mock's text.
|
||||||
|
let comment = Mock::given(method("POST"))
|
||||||
|
.and(path("/repos/acme/widgets/issues/7/comments"))
|
||||||
|
.and(body_partial_json(json!({ "body": "on it" })))
|
||||||
|
.respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 43 })))
|
||||||
|
.expect(1)
|
||||||
|
.mount_as_scoped(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let config = config(&server, &dir);
|
||||||
|
let provider = Arc::new(MockProvider::new([reply("on it")]));
|
||||||
|
let report = run_once(&config, mock_registry(provider))
|
||||||
|
.await
|
||||||
|
.expect("the pass runs");
|
||||||
|
assert_eq!(report.detected, 1, "the feed's one event was fed");
|
||||||
|
assert_eq!(report.outcomes, vec![Outcome::Handled]);
|
||||||
|
drop(comment);
|
||||||
|
|
||||||
|
// Reopen the db cold: the dispatch state persisted — the key is idle,
|
||||||
|
// drained, and carries its outcome.
|
||||||
|
let store = SqliteStore::open(dir.path().join("dispatch.db")).expect("db reopens");
|
||||||
|
let dispatcher = Dispatcher::new(
|
||||||
|
DispatcherConfig::default(),
|
||||||
|
Arc::new(SystemClock),
|
||||||
|
Arc::new(store),
|
||||||
|
);
|
||||||
|
let key = ResourceKey::from("github:acme/widgets#7");
|
||||||
|
let status = dispatcher.status(&key).expect("status reads");
|
||||||
|
assert_eq!(status.state, KeyState::Idle);
|
||||||
|
assert_eq!(status.inbox_depth, 0);
|
||||||
|
assert_eq!(status.last_outcome, Some(Outcome::Handled));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A registry whose only backend is the scripted mock — the shape every
|
||||||
|
/// integration test runs on.
|
||||||
|
fn mock_registry(provider: Arc<dyn rhema_core::ModelProvider>) -> Arc<rhema_claw::BackendRegistry> {
|
||||||
|
Arc::new(
|
||||||
|
rhema_claw::BackendRegistry::new(
|
||||||
|
vec![rhema_claw::Backend::headless("mock", provider)],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("a single mock backend always constructs"),
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,85 @@
|
|||||||
|
# rhema-claw: the resident forge-watching daemon.
|
||||||
|
# Build from the repo root (the context is the repo):
|
||||||
|
# docker build -f deploy/docker/claw.Dockerfile .
|
||||||
|
|
||||||
|
# The builder tag tracks rust-toolchain.toml: the pinned channel is what
|
||||||
|
# every commit is tested against. The full image (not -slim) carries the C
|
||||||
|
# toolchain rusqlite's bundled SQLite build needs.
|
||||||
|
FROM rust:1.98.1 AS builder
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Manifests first: the registry cache this populates survives source edits.
|
||||||
|
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
|
||||||
|
COPY crates/rhema-acp/Cargo.toml crates/rhema-acp/Cargo.toml
|
||||||
|
COPY crates/rhema-agent-tools/Cargo.toml crates/rhema-agent-tools/Cargo.toml
|
||||||
|
COPY crates/rhema-autonomous-agent/Cargo.toml crates/rhema-autonomous-agent/Cargo.toml
|
||||||
|
COPY crates/rhema-claw/Cargo.toml crates/rhema-claw/Cargo.toml
|
||||||
|
COPY crates/rhema-code/Cargo.toml crates/rhema-code/Cargo.toml
|
||||||
|
COPY crates/rhema-coding-agent/Cargo.toml crates/rhema-coding-agent/Cargo.toml
|
||||||
|
COPY crates/rhema-config/Cargo.toml crates/rhema-config/Cargo.toml
|
||||||
|
COPY crates/rhema-core/Cargo.toml crates/rhema-core/Cargo.toml
|
||||||
|
COPY crates/rhema-identity/Cargo.toml crates/rhema-identity/Cargo.toml
|
||||||
|
COPY crates/rhema-memory/Cargo.toml crates/rhema-memory/Cargo.toml
|
||||||
|
COPY crates/rhema-provider/Cargo.toml crates/rhema-provider/Cargo.toml
|
||||||
|
COPY crates/rhema-provider-anthropic/Cargo.toml crates/rhema-provider-anthropic/Cargo.toml
|
||||||
|
COPY crates/rhema-provider-copilot/Cargo.toml crates/rhema-provider-copilot/Cargo.toml
|
||||||
|
COPY crates/rhema-provider-google/Cargo.toml crates/rhema-provider-google/Cargo.toml
|
||||||
|
COPY crates/rhema-provider-llama-cpp/Cargo.toml crates/rhema-provider-llama-cpp/Cargo.toml
|
||||||
|
COPY crates/rhema-provider-openai-compatible/Cargo.toml crates/rhema-provider-openai-compatible/Cargo.toml
|
||||||
|
COPY crates/rhema-provider-openrouter/Cargo.toml crates/rhema-provider-openrouter/Cargo.toml
|
||||||
|
COPY crates/rhema-rsd/Cargo.toml crates/rhema-rsd/Cargo.toml
|
||||||
|
COPY integrations/rhema-calendar-integration/Cargo.toml integrations/rhema-calendar-integration/Cargo.toml
|
||||||
|
COPY integrations/rhema-forge-integration/Cargo.toml integrations/rhema-forge-integration/Cargo.toml
|
||||||
|
|
||||||
|
# Resolution reads the vendored crossterm (the root manifest path-patches
|
||||||
|
# to it) even though the image builds the daemon, which does not link it.
|
||||||
|
COPY vendor/ vendor/
|
||||||
|
|
||||||
|
# Cargo validates explicit target
|
||||||
|
# paths at manifest-parse time, so the fetch needs stub files at every
|
||||||
|
# declared target path: src/ for every member, plus examples/tests/benches
|
||||||
|
# as declared (default path from the target name, or the explicit `path`).
|
||||||
|
# The loop drives off the crates/*/ and integrations/*/ globs because
|
||||||
|
# neither glob's */src matches anything until the stub exists — an
|
||||||
|
# unmatched glob would hand mkdir a literal path. The real sources land
|
||||||
|
# below. (The awk program must stay on one line: a Dockerfile line not
|
||||||
|
# ending in a backslash ends the RUN.)
|
||||||
|
RUN for crate in crates/*/ integrations/*/; do \
|
||||||
|
mkdir -p "$crate/src"; \
|
||||||
|
touch "$crate/src/lib.rs" "$crate/src/main.rs"; \
|
||||||
|
awk -F'"' '/^\[/ { d = ""; p = 0; if ($0 == "[[example]]") { d = "examples" } else if ($0 == "[[test]]") { d = "tests" } else if ($0 == "[[bench]]") { d = "benches" }; next } d == "" { next } /^name = / && p == 0 { print d "/" $2 ".rs" } /^path = / { p = 1; print $2 }' "$crate/Cargo.toml" \
|
||||||
|
| while IFS= read -r target; do \
|
||||||
|
mkdir -p "$crate$(dirname "$target")"; \
|
||||||
|
touch "$crate$target"; \
|
||||||
|
done; \
|
||||||
|
done \
|
||||||
|
&& cargo fetch
|
||||||
|
|
||||||
|
# Sources. target/ and .git never land here: the repo-root .dockerignore
|
||||||
|
# keeps them out of the context.
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --release -p rhema-claw
|
||||||
|
|
||||||
|
FROM debian:stable-slim
|
||||||
|
|
||||||
|
# bash and git are load-bearing, not conveniences: the coding agent shells
|
||||||
|
# out via `bash -c` and clones/fetches in its per-thread workspaces.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends bash git ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Numeric USER: Kubernetes cannot verify runAsNonRoot against a name.
|
||||||
|
RUN useradd --uid 1000 --user-group --create-home claw \
|
||||||
|
&& install -d -o 1000 -g 1000 /config /data
|
||||||
|
|
||||||
|
COPY --from=builder /build/target/release/rhema-claw /usr/local/bin/rhema-claw
|
||||||
|
|
||||||
|
# The chart passes --config /config/claw.yaml (the ConfigMap mount) and
|
||||||
|
# mounts the state PVC at /data; these defaults serve a bare `docker run`.
|
||||||
|
ENV XDG_CONFIG_HOME=/config \
|
||||||
|
XDG_STATE_HOME=/data/state
|
||||||
|
VOLUME /data
|
||||||
|
EXPOSE 7799
|
||||||
|
|
||||||
|
USER 1000:1000
|
||||||
|
ENTRYPOINT ["/usr/local/bin/rhema-claw"]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# rhema-claw-ui: the web UI's static bundle behind nginx.
|
||||||
|
# Build from the repo root (the context is the repo):
|
||||||
|
# docker build -f deploy/docker/ui.Dockerfile -t rhema-claw-ui .
|
||||||
|
|
||||||
|
# The build: the Nuxt app generates a fully static bundle (ssr is off —
|
||||||
|
# the UI is a client of the claw's API, nothing server-side to run).
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /web
|
||||||
|
COPY web/claw/package.json web/claw/package-lock.json ./
|
||||||
|
# The lock file pins the tree; ci is the reproducible install.
|
||||||
|
RUN npm ci
|
||||||
|
COPY web/claw ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# The runtime: nginx serves the bundle with an SPA fallback — the app is
|
||||||
|
# one page, but a browser hitting a stale deep link must land on it, not
|
||||||
|
# on a 404. Index is served no-cache (a new deploy must not be masked by
|
||||||
|
# the browser), the hashed _nuxt assets are forever-cacheable.
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY <<'NGINX' /etc/nginx/conf.d/default.conf
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /_nuxt/ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
location / {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
COPY --from=build /web/.output/public /usr/share/nginx/html
|
||||||
|
|
||||||
|
# The UI is stateless static files: any number of replicas, no PVC, and
|
||||||
|
# the claw's single-node invariant does not apply here.
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Patterns to ignore when building the package.
|
||||||
|
.DS_Store
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.nojekyll
|
||||||
|
*.swp
|
||||||
|
*.tmp
|
||||||
|
*.orig
|
||||||
|
*.rej
|
||||||
|
*.tgz
|
||||||
|
vault
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: rhema-claw
|
||||||
|
description: "The resident forge-watching agent daemon: forge watches and a
|
||||||
|
chat HTTP surface over one SQLite store."
|
||||||
|
type: application
|
||||||
|
version: 0.1.0
|
||||||
|
appVersion: "0.1.0"
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
rhema-claw {{ .Chart.AppVersion }} deployed as {{ include "rhema-claw.fullname" . }}.
|
||||||
|
|
||||||
|
The daemon is single-node by design: one SQLite store, one dispatcher
|
||||||
|
(specs/rhema-framework-v1.md §7) — do not scale the Deployment.
|
||||||
|
|
||||||
|
1. Talk to it (port-forward; the surface speaks OpenAI chat-completions too):
|
||||||
|
|
||||||
|
kubectl port-forward svc/{{ include "rhema-claw.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }}
|
||||||
|
curl http://127.0.0.1:{{ .Values.service.port }}/healthz
|
||||||
|
|
||||||
|
2. Config: values `config` renders into ConfigMap {{ include "rhema-claw.fullname" . }}
|
||||||
|
(claw.yaml, mounted at /config/claw.yaml). db and workspace_root are pinned
|
||||||
|
under /data; edit them through values, not the ConfigMap.
|
||||||
|
{{- if not (or .Values.secret.existingSecret .Values.secret.stringData) }}
|
||||||
|
|
||||||
|
3. No credentials Secret is configured — set values secret.stringData
|
||||||
|
(e.g. ANTHROPIC_API_KEY, GITHUB_TOKEN) or secret.existingSecret, or the
|
||||||
|
daemon refuses to start when a watch or dispatch needs the key.
|
||||||
|
{{- else }}
|
||||||
|
|
||||||
|
3. Credentials: injected from the Secret into the environment.
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
4. Token: /v1 routes are protected by the token set in values; /healthz
|
||||||
|
stays open either way. The render refuses an empty token — the daemon
|
||||||
|
binds all interfaces in-cluster and will not serve unguarded.
|
||||||
|
{{- if not .Values.persistence.enabled }}
|
||||||
|
|
||||||
|
WARNING: persistence.enabled=false — the SQLite store and workspaces live in
|
||||||
|
an emptyDir and are lost when the pod restarts.
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{{/* Chart-wide names and labels. */}}
|
||||||
|
|
||||||
|
{{- define "rhema-claw.name" -}}
|
||||||
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "rhema-claw.fullname" -}}
|
||||||
|
{{- if .Values.fullnameOverride }}
|
||||||
|
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||||
|
{{- if contains $name .Release.Name }}
|
||||||
|
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "rhema-claw.chart" -}}
|
||||||
|
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | quote }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "rhema-claw.labels" -}}
|
||||||
|
helm.sh/chart: {{ include "rhema-claw.chart" . }}
|
||||||
|
{{ include "rhema-claw.selectorLabels" . }}
|
||||||
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "rhema-claw.selectorLabels" -}}
|
||||||
|
app.kubernetes.io/name: {{ include "rhema-claw.name" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "rhema-claw.imageTag" -}}
|
||||||
|
{{- default .Chart.AppVersion .Values.image.tag }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
The single-node invariant, enforced at render time: one daemon owns every
|
||||||
|
session in one SQLite store, with one dispatcher — scaling past one replica
|
||||||
|
would corrupt the store, not share the load (specs/rhema-framework-v1.md §7).
|
||||||
|
*/}}
|
||||||
|
{{- define "rhema-claw.assertSingleReplica" -}}
|
||||||
|
{{- if ne (int .Values.replicaCount) 1 }}
|
||||||
|
{{- fail (printf "replicaCount must be 1, got %v: rhema-claw is single-node by design (one SQLite store, one dispatcher; specs/rhema-framework-v1.md §7)" .Values.replicaCount) }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{{- $cfg := deepCopy (.Values.config | default dict) }}
|
||||||
|
{{- /* The state volume is fixed at /data and the Service needs the process
|
||||||
|
bound to all interfaces, so these three win over whatever values say. */ -}}
|
||||||
|
{{- $_ := set $cfg "db" "/data/claw.db" }}
|
||||||
|
{{- $_ := set $cfg "workspace_root" "/data/workspaces" }}
|
||||||
|
{{- $_ := set $cfg "listen" (printf "0.0.0.0:%v" .Values.service.port) }}
|
||||||
|
{{- if .Values.token }}
|
||||||
|
{{- $_ := set $cfg "token" .Values.token }}
|
||||||
|
{{- else }}
|
||||||
|
{{- $_ := unset $cfg "token" }}
|
||||||
|
{{- end }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: {{ include "rhema-claw.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "rhema-claw.labels" . | nindent 4 }}
|
||||||
|
data:
|
||||||
|
claw.yaml: |
|
||||||
|
{{ toYaml $cfg | indent 4 }}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
{{- include "rhema-claw.assertSingleReplica" . }}
|
||||||
|
{{- /* The daemon binds 0.0.0.0 in-cluster (the Service needs every
|
||||||
|
interface) and refuses to serve a non-loopback surface without a
|
||||||
|
bearer token — fail the render here, before the pod does. */ -}}
|
||||||
|
{{- if not .Values.token }}
|
||||||
|
{{- fail "token is required: the daemon binds all interfaces in-cluster, and it refuses to serve a non-loopback surface without a bearer token (set `token` in values)" }}
|
||||||
|
{{- end }}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "rhema-claw.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "rhema-claw.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "rhema-claw.selectorLabels" . | nindent 6 }}
|
||||||
|
app.kubernetes.io/component: server
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "rhema-claw.selectorLabels" . | nindent 8 }}
|
||||||
|
app.kubernetes.io/component: server
|
||||||
|
spec:
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
securityContext:
|
||||||
|
# The image runs as uid 1000; fsGroup makes the PVC writable by it.
|
||||||
|
fsGroup: 1000
|
||||||
|
containers:
|
||||||
|
- name: claw
|
||||||
|
image: "{{ .Values.image.repository }}:{{ include "rhema-claw.imageTag" . }}"
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
args:
|
||||||
|
- --config
|
||||||
|
- /config/claw.yaml
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: {{ .Values.service.port }}
|
||||||
|
protocol: TCP
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 2
|
||||||
|
periodSeconds: 5
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
runAsGroup: 1000
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
{{- if or .Values.secret.existingSecret .Values.secret.stringData }}
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: {{ .Values.secret.existingSecret | default (include "rhema-claw.fullname" .) }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: config
|
||||||
|
mountPath: /config
|
||||||
|
readOnly: true
|
||||||
|
- name: data
|
||||||
|
mountPath: /data
|
||||||
|
volumes:
|
||||||
|
- name: config
|
||||||
|
configMap:
|
||||||
|
name: {{ include "rhema-claw.fullname" . }}
|
||||||
|
- name: data
|
||||||
|
{{- if .Values.persistence.enabled }}
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ include "rhema-claw.fullname" . }}
|
||||||
|
{{- else }}
|
||||||
|
emptyDir: {}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.nodeSelector }}
|
||||||
|
nodeSelector:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
{{- if not .Values.ingress.host }}
|
||||||
|
{{- fail "ingress.host is required when ingress.enabled: the claw is single-user and bearer-gated, and an unnamed host has no TLS story" }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if and .Values.ingress.tls.enabled (not .Values.ingress.tls.secretName) }}
|
||||||
|
{{- fail "ingress.tls.secretName is required when ingress.tls.enabled: bearer tokens cross this ingress in the clear without TLS" }}
|
||||||
|
{{- end }}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ include "rhema-claw.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "rhema-claw.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- with .Values.ingress.className }}
|
||||||
|
ingressClassName: {{ . }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingress.tls.enabled }}
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- {{ .Values.ingress.host | quote }}
|
||||||
|
secretName: {{ .Values.ingress.tls.secretName }}
|
||||||
|
{{- end }}
|
||||||
|
rules:
|
||||||
|
- host: {{ .Values.ingress.host | quote }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
{{- if .Values.ui.enabled }}
|
||||||
|
# One address, two services: the browser gets the UI on /,
|
||||||
|
# the daemon answers /v1 and /healthz (longest prefix wins),
|
||||||
|
# and the phone app points at the same host. One origin, so
|
||||||
|
# no CORS anywhere; the bearer token is the auth.
|
||||||
|
- path: /v1
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ include "rhema-claw.fullname" . }}
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
|
- path: /healthz
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ include "rhema-claw.fullname" . }}
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ include "rhema-claw.fullname" . }}-ui
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
|
{{- else }}
|
||||||
|
# No UI in the release: the daemon owns the whole host.
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ include "rhema-claw.fullname" . }}
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{{- if .Values.persistence.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ include "rhema-claw.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "rhema-claw.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
{{- with .Values.persistence.storageClass }}
|
||||||
|
storageClassName: {{ . | quote }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.persistence.size }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{{- if and (not .Values.secret.existingSecret) .Values.secret.stringData }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "rhema-claw.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "rhema-claw.labels" . | nindent 4 }}
|
||||||
|
stringData:
|
||||||
|
{{- toYaml .Values.secret.stringData | nindent 2 }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "rhema-claw.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "rhema-claw.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.service.type }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: {{ .Values.service.port }}
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
|
selector:
|
||||||
|
{{- include "rhema-claw.selectorLabels" . | nindent 4 }}
|
||||||
|
# The UI pods share the name/instance labels; without this the
|
||||||
|
# daemon's service load-balances chat traffic to nginx.
|
||||||
|
app.kubernetes.io/component: server
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{{- if .Values.ui.enabled }}
|
||||||
|
{{- if not .Values.ui.image.repository }}
|
||||||
|
{{- fail "ui.image.repository is required when ui.enabled: the UI image is built from deploy/docker/ui.Dockerfile" }}
|
||||||
|
{{- end }}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "rhema-claw.fullname" . }}-ui
|
||||||
|
labels:
|
||||||
|
{{- include "rhema-claw.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: ui
|
||||||
|
spec:
|
||||||
|
# Stateless static files: scaling the UI is safe. The single-node
|
||||||
|
# invariant belongs to the daemon, not to nginx.
|
||||||
|
replicas: {{ .Values.ui.replicas | default 1 }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "rhema-claw.selectorLabels" . | nindent 6 }}
|
||||||
|
app.kubernetes.io/component: ui
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "rhema-claw.labels" . | nindent 8 }}
|
||||||
|
app.kubernetes.io/component: ui
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: ui
|
||||||
|
image: "{{ .Values.ui.image.repository }}:{{ default .Chart.AppVersion .Values.ui.image.tag }}"
|
||||||
|
imagePullPolicy: {{ .Values.ui.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 80
|
||||||
|
# The shell answering is the whole contract: the bundle is
|
||||||
|
# static, the API calls happen browser-side against /v1.
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: http
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: http
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.ui.resources | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user