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:
vhaudiquet committed 2026-10-17 04:28:31 +08:00
commit 81da26b955
159 files changed
+50628

No files matched your search

+112
View File
@@ -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
+21
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
.gradle/
build/
/local.properties
.idea/
*.iml
.DS_Store
+318
View File
@@ -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.
+98
View File
@@ -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")
}
+68
View File
@@ -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>
+105
View File
@@ -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>
+6
View File
@@ -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.
+5
View File
@@ -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
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+248
View File
@@ -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" "$@"
+93
View File
@@ -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
+24
View File
@@ -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")
+82
View File
@@ -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"] }
+16
View File
@@ -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.
+275
View File
@@ -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`.
+49
View File
@@ -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 %}
+862
View File
@@ -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
);
}
}
+443
View File
@@ -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"
);
}
}
}
+982
View File
@@ -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) = &params.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 == &params.kind && env_key(&params.kind).is_some() {
continue;
}
if let Ok(mut backend) = construct(&params.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, &params) {
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", &params("google", None, None)).is_err());
assert!(construct("google", &params("google", Some(" "), None)).is_err());
assert!(construct("google", &params("google", Some("k"), None)).is_ok());
assert!(construct("deepseek", &params("deepseek", Some("k"), None)).is_ok());
assert!(construct("zai", &params("zai", Some("k"), None)).is_ok());
assert!(construct("copilot", &params("copilot", Some("t"), None)).is_ok());
assert!(construct("nope", &params("nope", Some("k"), None)).is_err());
// llama.cpp serves keyless on its default url.
assert!(construct("llama-cpp", &params("llama-cpp", None, None)).is_ok());
assert!(construct("llama-cpp", &params("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
}
}
+583
View File
@@ -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
);
}
}
+546
View File
@@ -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
+751
View File
@@ -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}"
);
}
}
+548
View File
@@ -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"
);
}
}
+343
View File
@@ -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"),
}
}
}
+434
View File
@@ -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");
}
}
+805
View File
@@ -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
+558
View File
@@ -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);
}
}
+566
View File
@@ -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
+604
View File
@@ -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"
);
}
}
+398
View File
@@ -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());
}
}
+477
View File
@@ -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)
}
}
+119
View File
@@ -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};
+186
View File
@@ -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)?,
))
}
+73
View File
@@ -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(),
})
}
}
+265
View File
@@ -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");
}
}
+180
View File
@@ -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"
);
}
}
+408
View File
@@ -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
+115
View File
@@ -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";
+291
View File
@@ -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);
}
}
+423
View File
@@ -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");
}
}
+439
View File
@@ -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);
}
}
+140
View File
@@ -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 &notification.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(&notification(NotificationReason::ReviewRequested)),
Triage::Agent(Role::Reviewer)
);
for reason in [
NotificationReason::Assigned,
NotificationReason::ReviewFeedback,
NotificationReason::Author,
] {
let triage = classify(&notification(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(&notification(NotificationReason::Mention)),
Triage::Surface
);
assert_eq!(
classify(&notification(NotificationReason::CiActivity)),
Triage::Ignore
);
assert_eq!(
classify(&notification(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
);
}
}
}
+340
View File
@@ -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);
}
}
+488
View File
@@ -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");
}
}
+353
View File
@@ -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);
}
}
+221
View File
@@ -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"),
)
}
+136
View File
@@ -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
+85
View File
@@ -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"]
+40
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
# Patterns to ignore when building the package.
.DS_Store
.git/
.gitignore
.nojekyll
*.swp
*.tmp
*.orig
*.rej
*.tgz
vault
+7
View File
@@ -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 }}
+17
View File
@@ -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