commit 81da26b955094f3f0664cfb3c90632d792ca7ca1 Author: Valentin Haudiquet Date: Sat Oct 17 04:28:31 2026 +0800 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. diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml new file mode 100644 index 0000000..5de485e --- /dev/null +++ b/.gitea/workflows/android.yml @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5d507bd --- /dev/null +++ b/LICENSE @@ -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. diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..fa7136f --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +/local.properties +.idea/ +*.iml +.DS_Store diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..be9ed6b --- /dev/null +++ b/android/README.md @@ -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=` 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=` — 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=&since=` (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 ` 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::`. +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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..fd73cab --- /dev/null +++ b/android/app/build.gradle.kts @@ -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") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ddecfda --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/rs/rhema/phone/MainActivity.kt b/android/app/src/main/java/rs/rhema/phone/MainActivity.kt new file mode 100644 index 0000000..f8187a3 --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/MainActivity.kt @@ -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(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 }, + ) + } +} diff --git a/android/app/src/main/java/rs/rhema/phone/RhemaApp.kt b/android/app/src/main/java/rs/rhema/phone/RhemaApp.kt new file mode 100644 index 0000000..9c211bf --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/RhemaApp.kt @@ -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, + ) + } +} diff --git a/android/app/src/main/java/rs/rhema/phone/chat/ChatController.kt b/android/app/src/main/java/rs/rhema/phone/chat/ChatController.kt new file mode 100644 index 0000000..6d3a95c --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/chat/ChatController.kt @@ -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>(emptyList()) + private set + var connection by mutableStateOf(Conn.Connecting) + private set + var sending by mutableStateOf(false) + private set + var sendError by mutableStateOf(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 = 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, events: List): List = + events.fold(items) { acc, event -> applyEvent(acc, event) } + + private fun applyEvent(items: List, event: ClawEvent): List { + 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 = + settle(consentId, granted = true) { api.revealSecret(consentId, secret) } + + suspend fun grantPhone(consentId: String, grant: Boolean): ClawResult = + settle(consentId, granted = grant) { api.grantPhone(consentId, grant) } + + suspend fun denyConsent(consentId: String): ClawResult = + settle(consentId, granted = false) { api.denyConsent(consentId) } + + private suspend fun settle( + consentId: String, + granted: Boolean, + call: suspend () -> ClawResult, + ): ClawResult { + val result = call() + if (result is ClawResult.Ok) items = flipConsent(items, consentId, granted) + return result + } + + private fun flipConsent(items: List, consentId: String, granted: Boolean): List { + 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 + } +} diff --git a/android/app/src/main/java/rs/rhema/phone/data/AgentIdentityRepository.kt b/android/app/src/main/java/rs/rhema/phone/data/AgentIdentityRepository.kt new file mode 100644 index 0000000..db9f457 --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/data/AgentIdentityRepository.kt @@ -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, + 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 = store.data.map { prefs -> + val identity = prefs[Keys.identityJson]?.let { raw -> + runCatching { json.decodeFromString(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 + } + } + } +} diff --git a/android/app/src/main/java/rs/rhema/phone/data/RhemaStore.kt b/android/app/src/main/java/rs/rhema/phone/data/RhemaStore.kt new file mode 100644 index 0000000..e131e3a --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/data/RhemaStore.kt @@ -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 by preferencesDataStore(name = "rhema") diff --git a/android/app/src/main/java/rs/rhema/phone/data/SessionCache.kt b/android/app/src/main/java/rs/rhema/phone/data/SessionCache.kt new file mode 100644 index 0000000..5bb8159 --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/data/SessionCache.kt @@ -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 = 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, 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(raw) }.getOrNull() + } ?: CachedSession() + + suspend fun put(sessionKey: String, cache: CachedSession) { + store.edit { it[key(sessionKey)] = json.encodeToString(cache) } + } +} diff --git a/android/app/src/main/java/rs/rhema/phone/data/SettingsRepository.kt b/android/app/src/main/java/rs/rhema/phone/data/SettingsRepository.kt new file mode 100644 index 0000000..004208d --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/data/SettingsRepository.kt @@ -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) { + + 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 = + 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 = credentials.map { it.configured } + + // UI-facing id stream; workers use the suspend deviceId() below. + val deviceIdFlow: Flow = 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 + } +} diff --git a/android/app/src/main/java/rs/rhema/phone/data/StatusRepository.kt b/android/app/src/main/java/rs/rhema/phone/data/StatusRepository.kt new file mode 100644 index 0000000..a4eb8ef --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/data/StatusRepository.kt @@ -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, 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(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 = + store.data.map { it[Keys.notificationCursor].orEmpty() } + + suspend fun setNotificationCursor(id: String) { + store.edit { it[Keys.notificationCursor] = id } + } +} diff --git a/android/app/src/main/java/rs/rhema/phone/di/AppContainer.kt b/android/app/src/main/java/rs/rhema/phone/di/AppContainer.kt new file mode 100644 index 0000000..e0fa6b6 --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/di/AppContainer.kt @@ -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) +} diff --git a/android/app/src/main/java/rs/rhema/phone/net/ClawApi.kt b/android/app/src/main/java/rs/rhema/phone/net/ClawApi.kt new file mode 100644 index 0000000..3c3edc6 --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/net/ClawApi.kt @@ -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 { + data class Ok(val value: T) : ClawResult + data class HttpError(val code: Int, val body: String?) : ClawResult + data class Network(val message: String) : ClawResult +} + +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 { + val base = baseOrNull() ?: return ClawResult.Network("no base URL configured") + return execute(Request.Builder().url("$base/healthz").build()) { } + } + + suspend fun sessions(): ClawResult> { + val builder = v1("sessions") ?: return ClawResult.Network("no base URL configured") + return execute(builder.get().build()) { response -> + json.decodeFromString>(response.body?.string() ?: "[]") + } + } + + // GET /v1/providers: every backend instance and the active pair. + suspend fun providers(): ClawResult { + val builder = v1("providers") ?: return ClawResult.Network("no base URL configured") + return execute(builder.get().build()) { response -> + json.decodeFromString(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 { + val builder = v1("providers", id, "models") + ?: return ClawResult.Network("no base URL configured") + return execute(builder.get().build()) { response -> + json.decodeFromString(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 { + 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(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 { + 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 { + 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 { + val builder = v1("phones") ?: return ClawResult.Network("no base URL configured") + return execute(builder.get().build()) { response -> + json.decodeFromString(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 { + 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> { + 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(response.body?.string() ?: "{}").notifications + } + } + + suspend fun ackNotification(id: String): ClawResult { + 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 { + 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 { + val builder = v1("agent", "avatar") + ?: return ClawResult.Network("no base URL configured") + return rawGet(builder) + } + + private suspend fun rawGet(builder: Request.Builder): ClawResult = + 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 { + val builder = v1("agent") ?: return ClawResult.Network("no base URL configured") + return execute(builder.get().build()) { response -> + json.decodeFromString(response.body?.string() ?: "{}") + } + } + + // GET /v1/consents: the pending questions, oldest first. + suspend fun consents(): ClawResult> { + val builder = v1("consents") ?: return ClawResult.Network("no base URL configured") + return execute(builder.get().build()) { response -> + json.decodeFromString(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 { + 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 { + 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 { + 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> { + 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(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 { + 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 = 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(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 execute(request: Request, parse: (Response) -> T): ClawResult = + 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) + } +} diff --git a/android/app/src/main/java/rs/rhema/phone/net/Models.kt b/android/app/src/main/java/rs/rhema/phone/net/Models.kt new file mode 100644 index 0000000..a044611 --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/net/Models.kt @@ -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) + +// 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 = 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 = 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 = emptyMap(), +) + +// GET /v1/notifications — the claw wraps the list in an object. +@Serializable +data class NotificationPage(val notifications: List = 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 = 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 = 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, +) diff --git a/android/app/src/main/java/rs/rhema/phone/notif/Notifier.kt b/android/app/src/main/java/rs/rhema/phone/notif/Notifier.kt new file mode 100644 index 0000000..1bd4bbe --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/notif/Notifier.kt @@ -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" + } +} diff --git a/android/app/src/main/java/rs/rhema/phone/sensors/PhoneSensors.kt b/android/app/src/main/java/rs/rhema/phone/sensors/PhoneSensors.kt new file mode 100644 index 0000000..7f7afa0 --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/sensors/PhoneSensors.kt @@ -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 { continuation -> + val settled = AtomicBoolean(false) + continuation.invokeOnCancellation { + settled.set(true) + signal.cancel() + } + val callback = androidx.core.util.Consumer { 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("", 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("|") + } + + /** ` `, 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] + } +} diff --git a/android/app/src/main/java/rs/rhema/phone/ui/ChatScreen.kt b/android/app/src/main/java/rs/rhema/phone/ui/ChatScreen.kt new file mode 100644 index 0000000..09a0dbf --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/ui/ChatScreen.kt @@ -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) : TurnGroup { + override val key: String = "a${items.first().id}" + } +} + +private fun turnGroups(items: List): List { + val groups = mutableListOf() + 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 { + val parts = mutableListOf() + 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.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(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) { + 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(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) +} diff --git a/android/app/src/main/java/rs/rhema/phone/ui/Components.kt b/android/app/src/main/java/rs/rhema/phone/ui/Components.kt new file mode 100644 index 0000000..fca00c4 --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/ui/Components.kt @@ -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>, + 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) + } +} diff --git a/android/app/src/main/java/rs/rhema/phone/ui/Markdown.kt b/android/app/src/main/java/rs/rhema/phone/ui/Markdown.kt new file mode 100644 index 0000000..330ff05 --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/ui/Markdown.kt @@ -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(), + ) +} diff --git a/android/app/src/main/java/rs/rhema/phone/ui/PhonesScreen.kt b/android/app/src/main/java/rs/rhema/phone/ui/PhonesScreen.kt new file mode 100644 index 0000000..ecdf0a4 --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/ui/PhonesScreen.kt @@ -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?>(null) } + var loadError by remember { mutableStateOf(null) } + var forgetCandidate by remember { mutableStateOf(null) } + var forgotName by remember { mutableStateOf(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() + + (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" + } +} diff --git a/android/app/src/main/java/rs/rhema/phone/ui/SessionsScreen.kt b/android/app/src/main/java/rs/rhema/phone/ui/SessionsScreen.kt new file mode 100644 index 0000000..6cb0243 --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/ui/SessionsScreen.kt @@ -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?>(null) } + var error by remember { mutableStateOf(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) + +private const val GROUP_INTERACTIVE = "interactive" +private const val GROUP_OTHER = "other" + +// `phone::` 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): List = + 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): List { + val byKey = compareBy { 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() + if (interactive.isNotEmpty()) { + groups += SessionGroup(GROUP_INTERACTIVE, interactive) + } + if (others.isNotEmpty()) { + groups += SessionGroup(GROUP_OTHER, others) + } + return groups +} + +@Composable +private fun SessionsList(sessions: List, 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 +} diff --git a/android/app/src/main/java/rs/rhema/phone/ui/SettingsScreen.kt b/android/app/src/main/java/rs/rhema/phone/ui/SettingsScreen.kt new file mode 100644 index 0000000..fdaf739 --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/ui/SettingsScreen.kt @@ -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(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) + +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(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(null) } + var loadError by remember { mutableStateOf(null) } + var listing by remember { mutableStateOf(ListingState.Idle) } + var draftProvider by remember { mutableStateOf(null) } + var draftModel by remember { mutableStateOf(DEFAULT_MODEL) } + var saving by remember { mutableStateOf(false) } + var savedPair by remember { mutableStateOf(null) } + var saveError by remember { mutableStateOf(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) : 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, +) diff --git a/android/app/src/main/java/rs/rhema/phone/ui/Theme.kt b/android/app/src/main/java/rs/rhema/phone/ui/Theme.kt new file mode 100644 index 0000000..fc70f9b --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/ui/Theme.kt @@ -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, + ) +} diff --git a/android/app/src/main/java/rs/rhema/phone/ui/UiMessage.kt b/android/app/src/main/java/rs/rhema/phone/ui/UiMessage.kt new file mode 100644 index 0000000..6e46de7 --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/ui/UiMessage.kt @@ -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 +} diff --git a/android/app/src/main/java/rs/rhema/phone/work/BootReceiver.kt b/android/app/src/main/java/rs/rhema/phone/work/BootReceiver.kt new file mode 100644 index 0000000..6da776f --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/work/BootReceiver.kt @@ -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) + } +} diff --git a/android/app/src/main/java/rs/rhema/phone/work/NotificationsWorker.kt b/android/app/src/main/java/rs/rhema/phone/work/NotificationsWorker.kt new file mode 100644 index 0000000..d0bd5fa --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/work/NotificationsWorker.kt @@ -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() + } +} diff --git a/android/app/src/main/java/rs/rhema/phone/work/PhoneCommandWorker.kt b/android/app/src/main/java/rs/rhema/phone/work/PhoneCommandWorker.kt new file mode 100644 index 0000000..7ca0d93 --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/work/PhoneCommandWorker.kt @@ -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 { + 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 diff --git a/android/app/src/main/java/rs/rhema/phone/work/PhoneStatusWorker.kt b/android/app/src/main/java/rs/rhema/phone/work/PhoneStatusWorker.kt new file mode 100644 index 0000000..2cf7824 --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/work/PhoneStatusWorker.kt @@ -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>() + + // 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?, +) diff --git a/android/app/src/main/java/rs/rhema/phone/work/PowerEventReceiver.kt b/android/app/src/main/java/rs/rhema/phone/work/PowerEventReceiver.kt new file mode 100644 index 0000000..186c336 --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/work/PowerEventReceiver.kt @@ -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 + } + } + } +} diff --git a/android/app/src/main/java/rs/rhema/phone/work/Scheduling.kt b/android/app/src/main/java/rs/rhema/phone/work/Scheduling.kt new file mode 100644 index 0000000..bd406fe --- /dev/null +++ b/android/app/src/main/java/rs/rhema/phone/work/Scheduling.kt @@ -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(Duration.ofMinutes(15)) + .setConstraints(networked()) + .build(), + ) + wm.enqueueUniquePeriodicWork( + NOTIFICATIONS, + ExistingPeriodicWorkPolicy.KEEP, + PeriodicWorkRequestBuilder(Duration.ofMinutes(15)) + .setConstraints(networked()) + .build(), + ) + wm.enqueueUniquePeriodicWork( + PHONE_COMMANDS, + ExistingPeriodicWorkPolicy.KEEP, + PeriodicWorkRequestBuilder(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() + .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() + .setConstraints(networked()) + .build(), + ) +} diff --git a/android/app/src/main/res/drawable/ic_rhema.xml b/android/app/src/main/res/drawable/ic_rhema.xml new file mode 100644 index 0000000..a95bf50 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_rhema.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..c19d7f2 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,105 @@ + + + Rhema + + Agent notifications + Agent alerts + Rhema + + Connect to your claw + Base URL (e.g. http://192.168.1.10:8080) + Bearer token + Save and connect + Device ID: %1$s + Saved. The claw answered /healthz with HTTP %1$d; check the URL and token. + Saved, but /healthz is unreachable: %1$s + The token is stored in this app\'s private storage. The device ID namespaces this phone\'s status events on the claw. + + Sessions + No sessions yet. + Refresh + Settings + The claw returned HTTP %1$d + Cannot reach the claw: %1$s + HTTP 401 means the bearer token is wrong or missing. Update it in Settings. + + Back + Send + Message the agent + connecting… + live + reconnecting… + disconnected: %1$s + unauthorized; check the token in Settings + Send failed: %1$s + tool running: %1$s + tool: %1$s + Download and open + downloading… + Open + No app on this phone opens this file type. + Download failed: %1$s + The secret the agent asked for + Send secret + Approve + Deny + granted + denied + Answer failed: %1$s +Settings + Back + Phone + Claw + Status reporting + Location, battery, DND, and the other sensors this phone sends + Devices + Every phone the claw has heard from; forget the stale ones + Model providers + Which backend and model answer the claw\'s turns + Connection + The claw\'s URL and bearer token + Connection + Model providers + What the claw answers with + Provider + Model + (provider default) + No live listing; the provider\'s default model applies. + Saved: %1$s / %2$s + Loading providers… + Change not applied + The staged pair takes effect on the claw\'s next turn. Save to apply it, discard to keep the running backend. + Discard + Save + The claw answered: %1$s + + Phones + Every device the claw has heard from + Loading phones… + No phones have reported yet. + this phone + Forget + Forget %1$s? + The claw drops this phone\'s status, queued commands, and identity. A phone that still reports comes back on its next run. + last seen just now + last seen %1$s ago + never reported + Forgot %1$s. + + Status reporting + What this phone sends the claw + Location + Where this phone is. A fix posts when it moves 300 m or more. + Current app + The app in the foreground, through Android\'s usage access. + Do Not Disturb + Lets the claw\'s dnd_on and dnd_off commands work. + on + Allow + + Interactive + Other keys + + rhema + + diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..59c1ab0 --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..5122155 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..8043193 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..6f95f72 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..a8fa474 --- /dev/null +++ b/android/build.gradle.kts @@ -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 +} diff --git a/android/debug.keystore b/android/debug.keystore new file mode 100644 index 0000000..7122e69 Binary files /dev/null and b/android/debug.keystore differ diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..61efd94 --- /dev/null +++ b/android/gradle.properties @@ -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 diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..61285a6 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f78a6 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/android/gradlew @@ -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" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/android/gradlew.bat @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..f2b5a2a --- /dev/null +++ b/android/settings.gradle.kts @@ -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") diff --git a/crates/rhema-claw/Cargo.toml b/crates/rhema-claw/Cargo.toml new file mode 100644 index 0000000..f9bc695 --- /dev/null +++ b/crates/rhema-claw/Cargo.toml @@ -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"] } diff --git a/crates/rhema-claw/ONBOARDING.md b/crates/rhema-claw/ONBOARDING.md new file mode 100644 index 0000000..14c9e73 --- /dev/null +++ b/crates/rhema-claw/ONBOARDING.md @@ -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. diff --git a/crates/rhema-claw/README.md b/crates/rhema-claw/README.md new file mode 100644 index 0000000..423dd47 --- /dev/null +++ b/crates/rhema-claw/README.md @@ -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:`, 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:` +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:` 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::` +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::`) 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::` 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`. \ No newline at end of file diff --git a/crates/rhema-claw/SYSTEM.md b/crates/rhema-claw/SYSTEM.md new file mode 100644 index 0000000..740f784 --- /dev/null +++ b/crates/rhema-claw/SYSTEM.md @@ -0,0 +1,49 @@ +You are the resident agent on this machine. Your SOUL defines who you +are; until it is written you are rhema-claw, freshly installed. This +session's key is {{ session }}. You +answer chat here; your shell runs in the workspace at {{ workspace }}: +read code, run tests, make edits there. Keep replies terse and +concrete; do the work with tools instead of narrating it. Your +final message, the one you end the turn on with no tool calls after +it, is the reply the user sees. + +{{ soul }} + +{{ user }} + +{{ environment }} + +{% if tools %} +You act through these tools (name | description | arguments). The +arguments are the JSON schema a call must satisfy. + +{% for tool in tools %} +- {{ tool.name }} | {{ tool.description }} | {{ tool.parameters | tojson }} +{% endfor %} +{% endif %} + +You manage your own watch list with the watch_repo, unwatch_repo, and +list_watches tools: a watched repository's activity (issues, pull +requests, comments) arrives as its own dispatch. + +You can reach the user away from this chat with the notify tool: it +queues a notification (title, body, device; the phone by default) +for their device to pick up. You can also schedule your own future +turns with the schedule_task, list_tasks, and cancel_task tools: a +task's prompt runs as one of your turns at a given time (at, an +RFC3339 timestamp or epoch milliseconds) or on an interval +(every_secs); set exactly one. A task with no session runs cold and +its final message reaches the user as a notification; give session +(this session is {{ session }}) to wake that conversation instead, with +all of its context — pair it with notify when the user must see the +result away from the chat. + +You keep persistent memory across sessions. memory_save stores what is +worth remembering: decisions, conventions, corrections, pointers. +memory_search recalls it; search before asking the user for something +they have likely said before. memory_forget removes a record on +request. Never save secrets into memory. + +{% if onboarding %} +{{ onboarding }} +{% endif %} diff --git a/crates/rhema-claw/src/accounts_tools.rs b/crates/rhema-claw/src/accounts_tools.rs new file mode 100644 index 0000000..bf7e449 --- /dev/null +++ b/crates/rhema-claw/src/accounts_tools.rs @@ -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, + watches: Arc, +} + +/// 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, + #[serde(default)] + username: Option, + secret: String, + #[serde(default)] + secret_kind: Option, + #[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 { + // 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 { + 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 { + 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 { + 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, +} + +#[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 { + 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, + watches: Arc, +} + +#[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 { + 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, watches: Arc) -> 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, + workspace: PathBuf, +} + +impl IdentityExec { + /// The executor over `accounts`, running commands in `workspace`. + pub fn new(accounts: Arc, workspace: impl Into) -> 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 = Vec::new(); + let mut stderr: Vec = 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(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 { + 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, Arc) { + ( + Arc::new(rhema_identity::VolatileIdentityStore::new()), + Arc::new(ClawStore::in_memory().expect("store opens")), + ) + } + + fn call(tool: &dyn Tool, args: Value) -> Result { + 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(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 + ); + } +} diff --git a/crates/rhema-claw/src/attachments.rs b/crates/rhema-claw/src/attachments.rs new file mode 100644 index 0000000..e16c0ad --- /dev/null +++ b/crates/rhema-claw/src/attachments.rs @@ -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) -> Result { + 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 { + 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)>, 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, + workspace: PathBuf, + hub: Arc, + key: ResourceKey, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AttachArgs { + path: String, + #[serde(default)] + caption: Option, +} + +#[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 { + 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, + workspace: impl Into, + hub: Arc, + 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 { + 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" + ); + } + } +} diff --git a/crates/rhema-claw/src/backends.rs b/crates/rhema-claw/src/backends.rs new file mode 100644 index 0000000..887d452 --- /dev/null +++ b/crates/rhema-claw/src/backends.rs @@ -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, 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, + /// 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 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, provider: Arc) -> 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>>, + active: RwLock, +} + +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, + seed: Option<(String, Option)>, + ) -> Result { + if backends.is_empty() { + return Err( + "no provider backend is constructible: no key resolved for any provider".to_owned(), + ); + } + let map: BTreeMap> = 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> { + 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, 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 { + 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 { + 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>> { + 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)> { + 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, 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) -> Arc 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 { + 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.` holds a [`ProviderParams`] +/// JSON. Legacy rows stored `provider.` — 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, + /// The server address (llama.cpp). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +/// 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 { + let provider = match kind { + "google" => AnyProvider::Google(GoogleProvider::new(require_key(kind, params)?)), + "anthropic" => AnyProvider::Anthropic(AnthropicProvider::new(require_key(kind, params)?)), + "openai" => { + AnyProvider::OpenAiCompatible(OpenAiCompatibleProvider::new(require_key(kind, params)?)) + } + "openrouter" => { + AnyProvider::OpenRouter(OpenRouterProvider::new(require_key(kind, params)?)) + } + "deepseek" => AnyProvider::DeepSeek( + OpenAiCompatibleProvider::new(require_key(kind, params)?) + .with_base_url(DEEPSEEK_BASE_URL), + ), + // Bearer auth: the scheme the Coding Plan route documents. + "zai" => AnyProvider::Zai( + AnthropicProvider::new(require_key(kind, params)?) + .with_base_url(ZAI_BASE_URL) + .with_bearer_auth(), + ), + "copilot" => AnyProvider::Copilot(CopilotProvider::new(require_key(kind, params)?)), + "llama-cpp" => { + let mut provider = LlamaCppProvider::new( + params + .url + .clone() + .unwrap_or_else(|| rhema_config::Profile::default().llama_cpp_url()), + ); + if let Some(key) = ¶ms.api_key { + provider = provider.with_api_key(key); + } + AnyProvider::LlamaCpp(provider) + } + _ => return Err(format!("unknown provider kind {kind:?}")), + }; + let default_model = default_model_for(kind).to_owned(); + let provider = Arc::new(provider); + Ok(Backend { + id: kind.to_owned(), + kind: kind.to_owned(), + // Turns ride the retry, so a transient failure costs a pause + // instead of the turn; the listing stays on the raw adapter — + // `list_models` is an inherent method the wrapper has no. + provider: Arc::new(rhema_provider::Retrying::new(provider.clone())), + default_model, + list_models: Some(AnyProvider::boxed_listing(provider)), + }) +} + +/// The kind's API key, or the error the credentials route answers with. +fn require_key(kind: &str, params: &ProviderParams) -> Result { + 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.` 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, + stored_rows: &dyn Fn() -> Vec<(String, ProviderParams)>, + secrets: Option<&dyn rhema_config::secrets::SecretStore>, +) -> Vec { + let rows = stored_rows(); + let mut backends = Vec::new(); + + // The env credential each kind resolves from, if any. + let env_key = |kind: &str| { + env_vars_for(kind) + .iter() + .find_map(|name| env(name)) + .filter(|key| !key.trim().is_empty()) + }; + + // Stored instances first, under the id their row names. An + // env-backed kind takes the id back from a stored twin of the same + // id below — env wins the per-kind slot, the row stays persisted. + for (id, params) in &rows { + if id == ¶ms.kind && env_key(¶ms.kind).is_some() { + continue; + } + if let Ok(mut backend) = construct(¶ms.kind, params) { + backend.id = id.clone(); + seed_llama_model(&mut backend, config); + backends.push(backend); + } + } + + for kind in [ + "llama-cpp", + "google", + "anthropic", + "openai", + "openrouter", + "deepseek", + "zai", + "copilot", + ] { + let stored_of_kind = rows.iter().any(|(_, params)| params.kind == kind); + let params = if kind == "llama-cpp" { + if stored_of_kind { + // The stored rows own the kind now; the keyless default + // would undo a removal of the kind's own id. + continue; + } + // The keyless server backend always constructs, so the + // registry is never empty. + ProviderParams { + kind: kind.to_owned(), + api_key: None, + url: Some(stored.llama_cpp_url()), + } + } else if let Some(api_key) = env_key(kind) { + ProviderParams { + kind: kind.to_owned(), + api_key: Some(api_key), + url: None, + } + } else if stored_of_kind { + // The stored instance of the kind wins over the profile + // fallback — the surface's login is the newer decision. + continue; + } else { + // The stored rhema profile's key for the kind: the matching + // named instance's. The keyless llama.cpp backend never + // takes a key from the profile. + let api_key = match kind { + "llama-cpp" => None, + _ => stored.stored_provider_key(kind, secrets), + }; + ProviderParams { + kind: kind.to_owned(), + api_key, + url: None, + } + }; + let has_creds = match kind { + // The keyless server backend always constructs. + "llama-cpp" => true, + _ => params.api_key.is_some(), + }; + if !has_creds { + continue; + } + match construct(kind, ¶ms) { + Ok(mut backend) => { + seed_llama_model(&mut backend, config); + backends.push(backend); + } + // An unconstructible kind (a blank key) never blocks the boot. + Err(_) => continue, + } + } + backends +} + +/// The config file's model seeds llama's fallback, as discovery always +/// has: it names the deployment's local model when it names one at all. +fn seed_llama_model(backend: &mut Backend, config: &crate::config::ClawConfig) { + if backend.kind == "llama-cpp" { + if let Some(model) = &config.model { + backend.default_model = model.clone(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rhema_config::secrets::SecretStore as _; + use rhema_core::{Message, Role, StopReason, Usage}; + + /// A minimal scripted stand-in: registry tests never complete a turn. + struct Stub; + + #[async_trait] + impl ModelProvider for Stub { + async fn complete( + &self, + _req: ModelRequest, + _sink: &mut dyn EventSink, + ) -> Result { + Ok(ModelOutput { + message: Message::text(Role::Assistant, "stub"), + stop_reason: StopReason::EndTurn, + usage: Usage::default(), + }) + } + } + + fn backend(kind: &str) -> Backend { + // The real per-kind default, not headless's mock placeholder — + // the fallback assertions depend on it. + Backend { + id: kind.to_owned(), + kind: kind.to_owned(), + provider: Arc::new(Stub), + default_model: default_model_for(kind).to_owned(), + list_models: None, + } + } + + #[test] + fn the_credential_seed_is_the_first_keyed_backend_in_discovery_order() { + // The keyless llama.cpp fallback never seeds: it exists so the + // registry is never empty, not because anyone chose it. + assert_eq!( + credential_seed(&[backend("llama-cpp")]), + None, + "nothing keyed: no seed, the keyless server serves" + ); + // The first keyed backend wins, in discovery order. + assert_eq!( + credential_seed(&[ + backend("llama-cpp"), + backend("google"), + backend("anthropic") + ]), + Some(("google".to_owned(), None)) + ); + // A stored named row comes first in discovery order, whatever + // its kind sorts to in the registry. + let mut stored = backend("anthropic"); + stored.id = "work-key".to_owned(); + assert_eq!( + credential_seed(&[stored, backend("google")]), + Some(("work-key".to_owned(), None)) + ); + // The model stays unset: the registry fills the kind default. + let (id, model) = credential_seed(&[backend("openai")]).unwrap(); + assert_eq!((id.as_str(), model), ("openai", None)); + } + + #[test] + fn the_seed_wins_when_the_kind_exists() { + let registry = BackendRegistry::new( + vec![backend("anthropic"), backend("google")], + Some(("google".to_owned(), Some("gemini-3.8-flash".to_owned()))), + ) + .unwrap(); + assert_eq!( + registry.active(), + ("google".to_owned(), "gemini-3.8-flash".to_owned()) + ); + } + + #[test] + fn a_seed_for_an_absent_kind_falls_back_to_the_first_backend() { + let registry = BackendRegistry::new( + vec![backend("anthropic"), backend("google")], + Some(("openai".to_owned(), None)), + ) + .unwrap(); + assert_eq!(registry.active().0, "anthropic", "ascending first"); + } + + #[test] + fn a_switch_falls_back_to_the_kind_default_without_a_model() { + let registry = + BackendRegistry::new(vec![backend("anthropic"), backend("google")], None).unwrap(); + registry.set_active("google", None).unwrap(); + assert_eq!(registry.active().1, "gemini-3.8-flash"); + registry + .set_active("anthropic", Some("claude-opus-4-1")) + .unwrap(); + assert_eq!(registry.active().1, "claude-opus-4-1"); + } + + #[test] + fn an_unknown_kind_is_rejected() { + let registry = BackendRegistry::new(vec![backend("google")], None).unwrap(); + assert!(registry.set_active("openai", None).is_err()); + } + + #[test] + fn an_empty_registry_is_a_construction_error() { + assert!(BackendRegistry::new(vec![], None).is_err()); + } + + fn params(kind: &str, api_key: Option<&str>, url: Option<&str>) -> ProviderParams { + ProviderParams { + kind: kind.to_owned(), + api_key: api_key.map(str::to_owned), + url: url.map(str::to_owned), + } + } + + #[test] + fn instance_ids_exclude_whitespace_slashes_and_excess_length() { + assert!(valid_instance_id("local-llama")); + assert!(valid_instance_id(&"x".repeat(64))); + assert!(!valid_instance_id("")); + assert!(!valid_instance_id("a b")); + assert!(!valid_instance_id("a/b")); + assert!(!valid_instance_id(&"x".repeat(65))); + } + + #[test] + fn two_instances_of_a_kind_coexist_under_their_ids() { + let mut local_a = backend("llama-cpp"); + local_a.id = "local-a".to_owned(); + let mut local_b = backend("llama-cpp"); + local_b.id = "local-b".to_owned(); + let registry = BackendRegistry::new(vec![local_a, local_b], None).unwrap(); + assert_eq!(registry.list().len(), 2); + registry.set_active("local-b", None).unwrap(); + assert_eq!(registry.active().0, "local-b"); + // Removing the active id hands the pair to the remaining one. + registry.remove("local-b").unwrap(); + assert_eq!(registry.active().0, "local-a"); + } + + #[test] + fn construct_demands_what_each_kind_needs() { + // Keyed kinds want a non-blank key. + assert!(construct("google", ¶ms("google", None, None)).is_err()); + assert!(construct("google", ¶ms("google", Some(" "), None)).is_err()); + assert!(construct("google", ¶ms("google", Some("k"), None)).is_ok()); + assert!(construct("deepseek", ¶ms("deepseek", Some("k"), None)).is_ok()); + assert!(construct("zai", ¶ms("zai", Some("k"), None)).is_ok()); + assert!(construct("copilot", ¶ms("copilot", Some("t"), None)).is_ok()); + assert!(construct("nope", ¶ms("nope", Some("k"), None)).is_err()); + // llama.cpp serves keyless on its default url. + assert!(construct("llama-cpp", ¶ms("llama-cpp", None, None)).is_ok()); + assert!(construct("llama-cpp", ¶ms("llama-cpp", None, Some("http://h:1"))).is_ok()); + } + + #[test] + fn discover_builds_exactly_the_kinds_with_a_source() { + let config = test_config(); + let profile = profile_with_google(); + let env = |_name: &str| None; + let stored_rows = || vec![("deepseek".to_owned(), params("deepseek", Some("cd"), None))]; + let backends = discover(&config, &profile, &env, &stored_rows, None); + let mut kinds: Vec<&str> = backends.iter().map(|b| b.kind.as_str()).collect(); + kinds.sort_unstable(); + assert_eq!(kinds, vec!["deepseek", "google", "llama-cpp"]); + } + + #[test] + fn discover_boots_the_zai_kind_from_its_env_key() { + let config = test_config(); + let profile = rhema_config::Profile::default(); + let env = |name: &str| (name == "ZAI_API_KEY").then(|| "zk".to_owned()); + let backends = discover(&config, &profile, &env, &|| Vec::new(), None); + let kinds: Vec<&str> = backends.iter().map(|b| b.kind.as_str()).collect(); + assert!( + kinds.contains(&"zai"), + "the env key boots the zai backend: {kinds:?}" + ); + } + + #[test] + fn discover_boots_llama_even_when_nobody_stored_anything() { + let config = test_config(); + let profile = rhema_config::Profile::default(); + let env = |_name: &str| None; + let backends = discover(&config, &profile, &env, &|| Vec::new(), None); + assert_eq!(backends.len(), 1); + assert_eq!(backends[0].kind, "llama-cpp"); + } + + #[test] + fn a_keychain_ref_serves_the_profile_fallback_without_a_literal() { + let config = test_config(); + // The stored instance logs in through the chain: no literal, a + // live ref. Discovery resolves it through the store tier. + let mut profile = rhema_config::Profile::default(); + profile.upsert_instance( + "google", + rhema_config::ProviderInstance { + provider: "google".into(), + api_key: None, + secret_ref: Some("ref-1".into()), + base_url: None, + }, + ); + let store = rhema_config::secrets::VolatileSecretStore::new(); + store.set("ref-1", "chain-key").unwrap(); + let env = |_name: &str| None; + let backends = discover(&config, &profile, &env, &|| Vec::new(), Some(&store)); + assert!( + backends.iter().any(|b| b.kind == "google"), + "the keychain key builds the backend" + ); + // Without the store the ref is unreadable and the kind is not + // constructible — the honest logged-out shape, not a guess. + let backends = discover(&config, &profile, &env, &|| Vec::new(), None); + assert!(!backends.iter().any(|b| b.kind == "google")); + } + + #[test] + fn a_named_row_boots_under_its_id_and_keeps_the_default_slot_empty() { + let config = test_config(); + let profile = profile_with_google(); + let env = |_name: &str| None; + let stored_rows = || { + vec![( + "local-llama".to_owned(), + params("llama-cpp", None, Some("http://h:1")), + )] + }; + let backends = discover(&config, &profile, &env, &stored_rows, None); + let mut ids: Vec<&str> = backends.iter().map(|b| b.id.as_str()).collect(); + ids.sort_unstable(); + assert_eq!(ids, vec!["google", "local-llama"]); + // The stored row owns the kind: the keyless default id does not + // resurrect beside it. + assert!(!ids.contains(&"llama-cpp")); + let llama = backends.iter().find(|b| b.id == "local-llama").unwrap(); + assert_eq!(llama.kind, "llama-cpp"); + } + + #[test] + fn env_takes_the_kind_id_back_from_a_stored_twin() { + let config = test_config(); + let profile = rhema_config::Profile::default(); + let env = |name: &str| (name == "GOOGLE_API_KEY").then(|| "from-env".to_owned()); + let stored_rows = || vec![("google".to_owned(), params("google", Some("stored"), None))]; + let backends = discover(&config, &profile, &env, &stored_rows, None); + let google: Vec<&Backend> = backends.iter().filter(|b| b.kind == "google").collect(); + assert_eq!(google.len(), 1, "env wins the id, no twin survives"); + assert_eq!(google[0].id, "google"); + } + + #[test] + fn a_row_stored_under_its_kind_id_boots_as_its_kind() { + let config = test_config(); + let profile = rhema_config::Profile::default(); + let env = |_name: &str| None; + let stored_rows = || vec![("anthropic".to_owned(), params("anthropic", Some("k"), None))]; + let backends = discover(&config, &profile, &env, &stored_rows, None); + let anthropic = backends + .iter() + .find(|b| b.id == "anthropic") + .expect("the stored row boots"); + assert_eq!(anthropic.kind, "anthropic"); + assert_eq!(anthropic.default_model, "claude-sonnet-4-5"); + } + + #[test] + fn insert_replaces_and_remove_moves_the_active_pair() { + let registry = BackendRegistry::new( + vec![backend("anthropic"), backend("google")], + Some(("google".to_owned(), None)), + ) + .unwrap(); + + // A replace: the kind count holds, the default may change. + registry.insert(backend("google")); + assert_eq!(registry.list().len(), 2); + + // An addition joins the list. + registry.insert(backend("openai")); + assert_eq!(registry.list().len(), 3); + + // Removing the active kind hands the pair to the first + // remaining backend, ascending. + registry.remove("google").unwrap(); + assert_eq!(registry.active().0, "anthropic"); + + // The last backend refuses to go. + registry.remove("openai").unwrap(); + assert!(registry.remove("anthropic").is_err()); + assert_eq!(registry.list().len(), 1); + } + + fn test_config() -> crate::config::ClawConfig { + serde_json::from_value(serde_json::json!({ + "poll_interval_secs": 60, + "watches": [] + })) + .expect("a minimal claw config") + } + + fn profile_with_google() -> rhema_config::Profile { + let mut profile = rhema_config::Profile::default(); + profile.upsert_instance( + "google", + rhema_config::ProviderInstance { + provider: "google".into(), + api_key: Some("pg".into()), + secret_ref: None, + base_url: None, + }, + ); + profile + } +} diff --git a/crates/rhema-claw/src/bootstrap.rs b/crates/rhema-claw/src/bootstrap.rs new file mode 100644 index 0000000..a2e494c --- /dev/null +++ b/crates/rhema-claw/src/bootstrap.rs @@ -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 { + 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) -> Result { + 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, 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 { + 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 { + 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, +} + +#[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 { + 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) -> 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 + ); + } +} diff --git a/crates/rhema-claw/src/calendar.rs b/crates/rhema-claw/src/calendar.rs new file mode 100644 index 0000000..4284bda --- /dev/null +++ b/crates/rhema-claw/src/calendar.rs @@ -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:` 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:` — 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, + leads: Vec, + 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, 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, + clock: Arc, + feeds: Vec, +} + +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, + clock: Arc, + calendars: &[CalendarWatch], + ) -> Result { + 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, + clock: Arc, + feeds: Vec, + ) -> 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 { + passthrough_key_fn() + } + + async fn detect(&self) -> Result { + 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 { + 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); + + #[async_trait] + impl Calendar for Fixed { + async fn events( + &self, + _window: &Window, + ) -> Result, 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, + clock: Arc, + events: Vec, + ) -> 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, 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}"); + } +} diff --git a/crates/rhema-claw/src/config.rs b/crates/rhema-claw/src/config.rs new file mode 100644 index 0000000..fd124cc --- /dev/null +++ b/crates/rhema-claw/src/config.rs @@ -0,0 +1,1236 @@ +//! The claw config file: what to watch, where state lives, and which +//! model answers the dispatches. +//! +//! YAML at `~/.config/rhema/claw.yaml` (`--config` overrides). Paths and +//! credentials fail loudly at startup: a watch with no token, a gitea +//! watch with no host, or an unresolvable state directory never starts a +//! daemon that would poll half-configured. Forge tokens fall back to the +//! per-forge environment variable; the model backend's keys resolve +//! through `rhema-config` exactly as the TUI's do (env first, stored +//! second) and are validated in the binary's startup, not here — the +//! daemon library takes the built provider, so tests can inject a mock. + +use std::path::PathBuf; + +use rhema_forge_integration::RepoRef; +use serde::{Deserialize, Serialize}; + +/// How often the resident loop runs a detect pass, when +/// [`ClawConfig::poll_interval_secs`] is unset. +pub const DEFAULT_POLL_INTERVAL_SECS: u64 = 60; + +/// How many tool rounds one chat turn may run, when +/// [`ClawConfig::max_turn_steps`] is unset — the coding driver's own +/// budget shape and its default: deep enough that a cap is a runaway +/// guard for an agent meant to work unattended, not a work timer. +pub const DEFAULT_MAX_TURN_STEPS: u32 = 2048; + +/// Where the HTTP surface listens when [`ClawConfig::listen`] is unset: +/// loopback only — the surface is for the machine's resident user. +pub const DEFAULT_LISTEN: &str = "127.0.0.1:7799"; + +/// The one reminder lead, in minutes, a calendar carries when +/// [`CalendarWatch::lead_minutes`] is unset: warn shortly before, and +/// let a busier lead list be configured where wanted. +pub const DEFAULT_LEAD_MINUTES: u64 = 15; + +/// How far ahead a calendar polls, in hours, when +/// [`CalendarWatch::horizon_hours`] is unset. Must exceed the longest +/// lead: a reminder cannot fire before the event is visible. +pub const DEFAULT_HORIZON_HOURS: u32 = 24; + +/// Why a config operation failed. +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + /// The config file does not exist. Claw has nothing to guess from: a + /// daemon without its watch list would silently watch nothing. + #[error("no claw config at {0}; pass --config or create one")] + Missing(PathBuf), + /// Reading or writing the file failed. + #[error(transparent)] + Io(#[from] std::io::Error), + /// The YAML file is not valid or not the expected shape. + #[error(transparent)] + Yaml(#[from] serde_yaml::Error), + /// The stored-rhema config dir (`~/.config/rhema`) could not be + /// located for the default claw.yaml path. + #[error(transparent)] + Stored(#[from] rhema_config::ConfigError), + /// Neither `XDG_STATE_HOME` nor `HOME` is set, so the default SQLite + /// path or workspace root cannot be located. + #[error("cannot locate the state directory: neither XDG_STATE_HOME nor HOME is set")] + NoStateHome, + /// One watch is unusable; the daemon refuses to start rather than + /// poll a half-configured watch set. + #[error("watch #{index} ({forge} {repo}): {reason}")] + Watch { + /// The watch's position in the config file, zero-based. + index: usize, + /// The watch's forge kind. + forge: String, + /// The watch's `owner/name` as written. + repo: String, + /// What is missing or malformed. + reason: String, + }, + /// One calendar watch is unusable; the daemon refuses to start + /// rather than poll a half-configured calendar. + #[error("calendar #{index} ({name}): {reason}")] + Calendar { + /// The calendar's position in the config file, zero-based. + index: usize, + /// The calendar's name as written. + name: String, + /// What is missing or malformed. + reason: String, + }, + /// The HTTP surface would listen beyond loopback with no bearer + /// token: anyone who can reach the port could run tools on this + /// machine. + #[error( + "listen {listen} is not a loopback address and the surface has no \ + token: set a token, or bind a loopback address" + )] + OpenSurface { + /// The listen address as configured. + listen: String, + }, +} + +/// Which forge a watch polls. The wire spelling is the source name the +/// dispatcher registers and the resource key carries (`github:acme/w#7`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ForgeKind { + /// GitHub (api.github.com, or a GitHub Enterprise host). + GitHub, + /// Gitea — self-hosted; a watch must name its instance. + Gitea, + /// GitLab (gitlab.com, or a self-hosted instance). + GitLab, +} + +impl ForgeKind { + /// The source name this kind registers and dispatches under. + pub fn name(self) -> &'static str { + match self { + ForgeKind::GitHub => "github", + ForgeKind::Gitea => "gitea", + ForgeKind::GitLab => "gitlab", + } + } + + /// The environment variable the token falls back to. + pub fn token_env(self) -> &'static str { + match self { + ForgeKind::GitHub => "GITHUB_TOKEN", + ForgeKind::Gitea => "GITEA_TOKEN", + ForgeKind::GitLab => "GITLAB_TOKEN", + } + } + + /// Parses a forge name as the config, the HTTP surface, and the + /// watch tools spell it: the source name (`github`, `gitea`, + /// `gitlab`). `None` for anything else. + pub fn parse(name: &str) -> Option { + match name.trim() { + "github" => Some(ForgeKind::GitHub), + "gitea" => Some(ForgeKind::Gitea), + "gitlab" => Some(ForgeKind::GitLab), + _ => None, + } + } + + /// The production base URL; `None` for the self-hosted-only forges. + pub fn default_base(self) -> Option<&'static str> { + match self { + ForgeKind::GitHub => Some(rhema_forge_integration::github::DEFAULT_BASE_URL), + ForgeKind::Gitea => None, + ForgeKind::GitLab => Some(rhema_forge_integration::gitlab::DEFAULT_BASE_URL), + } + } +} + +/// One watched repository. Unknown keys are rejected: a typo'd key +/// must not silently read as unset. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub struct Watch { + /// Which forge this watch polls. + pub forge: ForgeKind, + /// The repository, `owner/name` (GitLab: `namespace/project`). + pub repo: String, + /// The instance base URL. Required for gitea (self-hosted only); + /// github and gitlab default to their production bases. + pub host: Option, + /// The forge token. Falls back to `GITHUB_TOKEN` / `GITEA_TOKEN` / + /// `GITLAB_TOKEN`; a blank either way counts as unset. + pub token: Option, +} + +impl Watch { + /// The repository coordinates, splitting `owner/name`. GitLab's owner + /// keeps its full namespace path, which is why the split is lazy: the + /// first `/` separates owner from name, never a deeper one. + pub fn repo_ref(&self) -> Result { + 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 token to poll with: the config value, then the forge's env + /// variable; whitespace-only counts as unset everywhere. + pub fn resolve_token(&self) -> Option { + // A closure, not `std::env::var` itself: the generic fn does not + // implement `Fn(&str)` for all lifetimes. + resolve_token( + |name| std::env::var(name), + self.forge, + self.token.as_deref(), + ) + } +} + +/// Pure token resolution: the config value first, then the forge's env +/// variable; the first non-blank wins. Parameterized over the env lookup +/// so tests can hold the environment still. +pub fn resolve_token( + get_env: impl Fn(&str) -> Result, + forge: ForgeKind, + stored: Option<&str>, +) -> Option { + stored + .map(str::trim) + .filter(|token| !token.is_empty()) + .map(str::to_owned) + .or_else(|| { + get_env(forge.token_env()) + .ok() + .map(|token| token.trim().to_owned()) + .filter(|token| !token.is_empty()) + }) +} + +/// Which calendar feed a calendar watch polls. The wire spelling is +/// the source name the dispatcher registers and the reminder prompt +/// names. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CalendarKind { + /// An iCalendar feed over HTTP (`webcal://` counts): Google's + /// secret iCal address, a Nextcloud or Radicale share, Fastmail. + Ics, + /// A CalDAV collection, polled with a `calendar-query` REPORT: + /// Nextcloud, Radicale, Fastmail, a plain DAV server. + Caldav, + /// The Google Calendar API, against an OAuth access token or an + /// API key for public calendars. + Google, +} + +impl CalendarKind { + /// The spelling the config, the reminder source, and the prompts + /// use. + pub fn name(self) -> &'static str { + match self { + CalendarKind::Ics => "ics", + CalendarKind::Caldav => "caldav", + CalendarKind::Google => "google", + } + } + + /// Parses a calendar kind as the config spells it. `None` for + /// anything else. + pub fn parse(name: &str) -> Option { + match name.trim() { + "ics" => Some(CalendarKind::Ics), + "caldav" => Some(CalendarKind::Caldav), + "google" => Some(CalendarKind::Google), + _ => None, + } + } +} + +/// One watched calendar. Unknown keys are rejected, as on a watch. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub struct CalendarWatch { + /// The calendar's name: unique across the list; the reminder key + /// is `calendar:` and prompts name the calendar by it. + pub name: String, + /// Which feed kind this polls. + pub kind: CalendarKind, + /// The feed URL (`https://` or `webcal://`). Required for ics. + pub url: Option, + /// The Google calendar id (`primary`, an address, a hex id). + /// Required for google. + pub calendar_id: Option, + /// A bearer token (an OAuth access token for Google, auth for a + /// feed or collection that demands it). Falls back to + /// `CALENDAR_TOKEN`. + pub token: Option, + /// The CalDAV user, for Basic auth. Set only with a password (or + /// `CALDAV_PASSWORD`); a calendar with a username authenticates + /// Basic, and `token` is the fallback only when there is none. + pub username: Option, + /// The CalDAV password or app-specific password. Falls back to + /// `CALDAV_PASSWORD`. + pub password: Option, + /// A Google API key for a public calendar; wins over `token`. + pub api_key: Option, + /// Reminder lead times, minutes; each occurrence warns once per + /// lead. `[DEFAULT_LEAD_MINUTES]` when unset. + pub lead_minutes: Option>, + /// How far ahead the poll looks, hours. `[DEFAULT_HORIZON_HOURS]` + /// when unset; must exceed the longest lead. + pub horizon_hours: Option, +} + +impl CalendarWatch { + /// The reminder leads as configured, blank and zero entries + /// dropped; [`DEFAULT_LEAD_MINUTES`] alone when nothing usable is + /// configured. + pub fn leads(&self) -> Vec { + match self.lead_minutes.as_ref() { + Some(leads) => { + let usable: Vec = leads.iter().copied().filter(|lead| *lead > 0).collect(); + if usable.is_empty() { + vec![DEFAULT_LEAD_MINUTES] + } else { + usable + } + } + None => vec![DEFAULT_LEAD_MINUTES], + } + } + + /// The poll horizon as configured, [`DEFAULT_HORIZON_HOURS`] when + /// unset. Zero reads as the default: a horizon of nothing polls + /// nothing, and the config validation refuses it when it would + /// matter. + pub fn horizon(&self) -> u32 { + self.horizon_hours + .filter(|hours| *hours > 0) + .unwrap_or(DEFAULT_HORIZON_HOURS) + } + + /// A trimmed `token`/`api_key` value; blank counts as unset. + fn trimmed(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + } + + /// The API key as configured, if any. + pub fn resolved_api_key(&self) -> Option { + Self::trimmed(self.api_key.as_deref()) + } + + /// The CalDAV password to authenticate with: the config value, + /// then `CALDAV_PASSWORD`. Parameterized over the env lookup in + /// the pure [`CalendarWatch::resolve_password_with`]. + pub fn resolved_password(&self) -> Option { + Self::resolve_password_with(|name| std::env::var(name), self.password.as_deref()) + } + + /// Pure password resolution: the config value first, then + /// `CALDAV_PASSWORD`. Parameterized over the env lookup so tests + /// can hold the environment still. + pub fn resolve_password_with( + get_env: impl Fn(&str) -> Result, + stored: Option<&str>, + ) -> Option { + Self::trimmed(stored).or_else(|| { + get_env("CALDAV_PASSWORD") + .ok() + .and_then(|token| Self::trimmed(Some(&token))) + }) + } + + /// The token to poll with: the config value, then `CALENDAR_TOKEN`; + /// the first non-blank wins. Parameterized over the env lookup in + /// the pure [`CalendarWatch::resolve_token_with`]. + pub fn resolved_token(&self) -> Option { + Self::resolve_token_with(|name| std::env::var(name), self.token.as_deref()) + } + + /// Pure token resolution: the config value first, then + /// `CALENDAR_TOKEN`. Parameterized over the env lookup so tests can + /// hold the environment still. + pub fn resolve_token_with( + get_env: impl Fn(&str) -> Result, + stored: Option<&str>, + ) -> Option { + Self::trimmed(stored).or_else(|| { + get_env("CALENDAR_TOKEN") + .ok() + .and_then(|token| Self::trimmed(Some(&token))) + }) + } +} + +/// Whether a `host:port` listen address binds beyond loopback. +/// `localhost` (any case) and any loopback IP literal — `127.x.x.x`, +/// `::1` — are loopback; an empty host (`:7799`, the bind-everything +/// shape) is not. A malformed host is not loopback either: it will +/// fail at bind time, and until then it must not read as safe. +pub fn is_loopback_listen(listen: &str) -> bool { + let host = if listen.starts_with('[') { + // A bracketed IPv6 literal, with or without a port. + listen + .split(']') + .next() + .and_then(|host| host.strip_prefix('[')) + .unwrap_or(listen) + } else if listen.matches(':').count() > 1 { + // A bare IPv6 literal carries several colons; a port on one + // would be bracketed. + listen + } else { + listen.split(':').next().unwrap_or(listen) + }; + if host.eq_ignore_ascii_case("localhost") { + return true; + } + host.parse::() + .is_ok_and(|ip| ip.is_loopback()) +} + +/// The daemon's config file. Unknown keys are rejected: a typo'd key +/// must not silently read as its default, and a daemon is the worst +/// place to learn that three weeks late. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "snake_case", deny_unknown_fields)] +pub struct ClawConfig { + /// The dispatcher's SQLite database. Under the XDG state dir when + /// unset (`$XDG_STATE_HOME/rhema/claw/dispatch.db`). + pub db: Option, + /// The root the handler's per-thread workspaces live under. Under the + /// XDG state dir when unset. + pub workspace_root: Option, + /// The root of the resident tier's durable memory store. Under the + /// XDG state dir when unset (`$XDG_STATE_HOME/rhema/claw/memory`). + pub memory_root: Option, + /// The root of the bootstrap store — SOUL.md, USER.md, + /// ENVIRONMENT.md. Under the XDG state dir when unset + /// (`$XDG_STATE_HOME/rhema/claw/bootstrap`). + pub bootstrap_root: Option, + /// Seconds between detect passes of the resident loop. + pub poll_interval_secs: u64, + /// How many tool rounds one chat turn may run before it is cut off + /// with a failed outcome, the conversation intact. A follow-up + /// message keeps going. [`DEFAULT_MAX_TURN_STEPS`] when unset. + pub max_turn_steps: u32, + /// The handler's model backend: `google`, `llama-cpp`, `openrouter`, + /// `anthropic`, `openai`, or `zai`; google when unset. Keys resolve + /// through the stored rhema config's default profile, env vars + /// winning. + pub provider: Option, + /// The model identifier; a per-provider default when unset. + pub model: Option, + /// The HTTP surface's `host:port`. [`DEFAULT_LISTEN`] when unset. + pub listen: Option, + /// Bearer token for the `/v1` routes. Unset means the surface is + /// open on its interface; set it when the port is reachable from + /// anywhere but loopback. `/healthz` stays open either way. + pub token: Option, + /// What to watch — the optional seed for the persistent watch table. + /// Imported once, when the table is still empty; after that the + /// table rules, and watches are managed in chat or over HTTP. + pub watches: Vec, + /// The calendars to remind about. Config-file-only: a calendar is + /// read-only ambient state, not an agent-managed resource, and a + /// change takes the daemon restart it takes to edit the file. + pub calendars: Vec, +} + +impl Default for ClawConfig { + fn default() -> Self { + Self { + db: None, + workspace_root: None, + memory_root: None, + bootstrap_root: None, + poll_interval_secs: DEFAULT_POLL_INTERVAL_SECS, + max_turn_steps: DEFAULT_MAX_TURN_STEPS, + provider: None, + model: None, + listen: None, + token: None, + watches: Vec::new(), + calendars: Vec::new(), + } + } +} + +impl ClawConfig { + /// The default config path: `/rhema/claw.yaml`, the same + /// base the stored rhema config lives under. + pub fn default_path() -> Result { + Ok(rhema_config::Config::dir()?.join("claw.yaml")) + } + + /// The state dir: `$XDG_STATE_HOME/rhema/claw`, else + /// `$HOME/.local/state/rhema/claw`. + pub fn state_dir() -> Result { + match std::env::var("XDG_STATE_HOME") { + Ok(dir) if !dir.trim().is_empty() => Ok(PathBuf::from(dir).join("rhema").join("claw")), + _ => match std::env::var("HOME") { + Ok(home) if !home.trim().is_empty() => Ok(PathBuf::from(home) + .join(".local") + .join("state") + .join("rhema") + .join("claw")), + _ => Err(ConfigError::NoStateHome), + }, + } + } + + /// The dispatcher's SQLite path, defaulted under the state dir. + pub fn db_path(&self) -> Result { + self.db + .clone() + .map(Ok) + .unwrap_or_else(|| Self::state_dir().map(|dir| dir.join("dispatch.db"))) + } + + /// The workspace root, defaulted under the state dir. + pub fn workspace_root(&self) -> Result { + self.workspace_root + .clone() + .map(Ok) + .unwrap_or_else(|| Self::state_dir().map(|dir| dir.join("workspaces"))) + } + + /// The memory root, defaulted under the state dir: the durable store + /// the resident tier's memory tools read and write. + pub fn memory_path(&self) -> Result { + self.memory_root + .clone() + .map(Ok) + .unwrap_or_else(|| Self::state_dir().map(|dir| dir.join("memory"))) + } + + /// The bootstrap store's root, defaulted under the state dir: the + /// SOUL.md/USER.md the resident prompt renders and the + /// ENVIRONMENT.md the boot pass generates. + pub fn bootstrap_dir(&self) -> Result { + self.bootstrap_root + .clone() + .map(Ok) + .unwrap_or_else(|| Self::state_dir().map(|dir| dir.join("bootstrap"))) + } + + /// The HTTP surface's address: the config's, or + /// [`DEFAULT_LISTEN`] when unset. + pub fn listen_addr(&self) -> &str { + self.listen.as_deref().unwrap_or(DEFAULT_LISTEN) + } + + /// The surface's startup posture check: a listen address reachable + /// beyond loopback with no bearer token is an open agent API — + /// anyone who can reach the port could run tools on this machine. + /// Refused before the daemon binds, naming both facts and the fix. + pub fn validate_surface(&self) -> Result<(), ConfigError> { + let guarded = self + .token + .as_deref() + .map(str::trim) + .is_some_and(|token| !token.is_empty()); + if !guarded && !is_loopback_listen(self.listen_addr()) { + return Err(ConfigError::OpenSurface { + listen: self.listen_addr().to_owned(), + }); + } + Ok(()) + } + + /// The model identifier the handler runs: the config's, or the + /// provider's headless default (the same fallbacks the rhema + /// frontend's one-shot uses). An unknown provider string reads as + /// google here; the binary rejects it when it resolves the backend. + pub fn resolved_model(&self) -> String { + self.model.clone().unwrap_or_else(|| { + match self.provider.as_deref().unwrap_or("google") { + "llama-cpp" => "local", + "anthropic" => "claude-sonnet-4-5", + "openai" => "gpt-5", + // The Coding Plan's flagship, as Z.ai's own client spells it. + "zai" => "GLM-5.3", + _ => "gemini-3.8-flash", + } + .to_owned() + }) + } + + /// Loads the config from `path`; a missing file is an error, not a + /// default — a daemon started against a typo'd path must say so. + pub fn load_from(path: &impl AsRef) -> Result { + let raw = std::fs::read_to_string(path).map_err(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + ConfigError::Missing(path.as_ref().to_path_buf()) + } else { + ConfigError::Io(err) + } + })?; + Ok(serde_yaml::from_str(&raw)?) + } + + /// The startup checks: every seeded watch resolvable (shape, host, + /// token). Loud, before anything polls. An empty list is fine now — + /// the watch table is seeded from it, and the agent manages its own + /// watches from there; a daemon that starts with nothing to guard + /// still serves chat. + pub fn validate(&self) -> Result<(), ConfigError> { + for (index, watch) in self.watches.iter().enumerate() { + let fail = |reason: String| ConfigError::Watch { + index, + forge: watch.forge.name().to_owned(), + repo: watch.repo.clone(), + reason, + }; + watch.repo_ref().map_err(fail)?; + if watch.forge == ForgeKind::Gitea + && watch + .host + .as_deref() + .map(str::trim) + .filter(|h| !h.is_empty()) + .is_none() + { + return Err(fail( + "gitea is self-hosted: set host to the instance base URL".to_owned(), + )); + } + if watch.resolve_token().is_none() { + return Err(fail(format!( + "no token: set token in the config or {}", + watch.forge.token_env() + ))); + } + } + self.validate_calendars()?; + Ok(()) + } + + /// The calendar half of the startup checks: every calendar named, + /// complete for its kind, with leads a horizon can see. + fn validate_calendars(&self) -> Result<(), ConfigError> { + let mut seen = std::collections::HashSet::new(); + for (index, calendar) in self.calendars.iter().enumerate() { + let fail = |reason: String| ConfigError::Calendar { + index, + name: calendar.name.clone(), + reason, + }; + if calendar.name.trim().is_empty() { + return Err(fail("name must not be blank".to_owned())); + } + if !seen.insert(calendar.name.trim().to_owned()) { + return Err(fail( + "duplicate name: reminder keys would collide".to_owned(), + )); + } + match calendar.kind { + CalendarKind::Ics => { + let url = calendar + .url + .as_deref() + .map(str::trim) + .filter(|url| !url.is_empty()) + .ok_or_else(|| { + fail("ics is a feed URL: set url to the calendar's address".to_owned()) + })?; + if !(url.starts_with("https://") + || url.starts_with("http://") + || url.starts_with("webcal://")) + { + return Err(fail(format!( + "url must be https://, http://, or webcal://, got {url:?}" + ))); + } + } + CalendarKind::Caldav => { + let url = calendar + .url + .as_deref() + .map(str::trim) + .filter(|url| !url.is_empty()) + .ok_or_else(|| { + fail( + "caldav is a collection: set url to the calendar's DAV \ + address" + .to_owned(), + ) + })?; + if !(url.starts_with("https://") || url.starts_with("http://")) { + return Err(fail(format!( + "url must be https:// or http://, got {url:?}" + ))); + } + let has_user = calendar + .username + .as_deref() + .map(str::trim) + .is_some_and(|user| !user.is_empty()); + if has_user && calendar.resolved_password().is_none() { + return Err(fail( + "no password: set password in the config or CALDAV_PASSWORD".to_owned(), + )); + } + } + CalendarKind::Google => { + if calendar + .calendar_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .is_none() + { + return Err(fail( + "google needs a calendar: set calendar_id (\"primary\", an \ + address, a hex id)" + .to_owned(), + )); + } + if calendar.resolved_api_key().is_none() && calendar.resolved_token().is_none() + { + return Err(fail( + "no credential: set api_key (public calendars) or token in the \ + config, or CALENDAR_TOKEN" + .to_owned(), + )); + } + } + } + // The horizon is what the poll can see; a lead at or past + // it fires on an event that was never visible. + let horizon_minutes = u64::from(calendar.horizon()) * 60; + let longest = calendar.leads().into_iter().max().unwrap_or_default(); + if longest >= horizon_minutes { + return Err(fail(format!( + "lead {longest}m is not under the {horizon_minutes}m horizon: a \ + reminder cannot fire before its event is visible" + ))); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_full_file_parses_field_for_field() { + let raw = "\ +db: /var/lib/rhema/claw.db +workspace_root: /var/lib/rhema/claw-work +memory_root: /var/lib/rhema/claw-memory +bootstrap_root: /var/lib/rhema/claw-bootstrap +poll_interval_secs: 30 +max_turn_steps: 512 +provider: anthropic +model: claude-sonnet-4-5 +listen: 192.168.1.10:7800 +token: sekrit +watches: + - forge: github + repo: acme/widgets + - forge: gitea + repo: acme/barn + host: https://git.example.net + token: gitea-pat + - forge: gitlab + repo: group/subgroup/widgets +"; + let config: ClawConfig = serde_yaml::from_str(raw).expect("parses"); + assert_eq!(config.poll_interval_secs, 30); + assert_eq!(config.max_turn_steps, 512); + assert_eq!(config.provider.as_deref(), Some("anthropic")); + assert_eq!(config.model.as_deref(), Some("claude-sonnet-4-5")); + assert_eq!(config.listen.as_deref(), Some("192.168.1.10:7800")); + assert_eq!(config.token.as_deref(), Some("sekrit")); + assert_eq!( + config.db.as_deref(), + Some(std::path::Path::new("/var/lib/rhema/claw.db")) + ); + assert_eq!( + config.bootstrap_root.as_deref(), + Some(std::path::Path::new("/var/lib/rhema/claw-bootstrap")) + ); + assert_eq!(config.watches.len(), 3); + assert_eq!(config.watches[0].forge, ForgeKind::GitHub); + assert_eq!(config.watches[0].host, None); + assert_eq!(config.watches[1].forge, ForgeKind::Gitea); + assert_eq!( + config.watches[1].host.as_deref(), + Some("https://git.example.net") + ); + assert_eq!(config.watches[1].token.as_deref(), Some("gitea-pat")); + // GitLab's namespace path survives as the owner. + assert_eq!( + config.watches[2].repo_ref().expect("parses").path(), + "group/subgroup/widgets" + ); + } + + #[test] + fn an_unknown_key_is_rejected_and_named() { + let err = serde_yaml::from_str::("listne: 127.0.0.1:7799\n") + .expect_err("a typo'd key must not read as its default"); + assert!( + err.to_string().contains("listne") && err.to_string().contains("expected one of"), + "the error names the key: {err}" + ); + // The same strictness inside a watch. + let err = serde_yaml::from_str::( + "watches:\n - forge: github\n repo: acme/widgets\n hst: https://git.example.net\n", + ) + .expect_err("a typo'd watch key is loud too"); + assert!(err.to_string().contains("hst"), "got: {err}"); + } + + #[test] + fn defaults_fill_what_the_file_omits() { + let config: ClawConfig = + serde_yaml::from_str("watches:\n - forge: github\n repo: acme/widgets\n") + .expect("parses"); + assert_eq!(config.poll_interval_secs, DEFAULT_POLL_INTERVAL_SECS); + assert_eq!(config.max_turn_steps, DEFAULT_MAX_TURN_STEPS); + assert_eq!(config.provider, None); + assert_eq!(config.model, None); + assert_eq!(config.db, None); + assert_eq!(config.workspace_root, None); + assert_eq!(config.bootstrap_root, None); + assert_eq!(config.listen_addr(), DEFAULT_LISTEN); + assert_eq!(config.token, None); + } + + #[test] + fn a_calendar_section_parses_field_for_field() { + let raw = "calendars: + - name: home + kind: ics + url: https://cloud.example.net/remote.php/dav/calendars/personal.ics + lead_minutes: [60, 15] + horizon_hours: 48 + - name: work + kind: google + calendar_id: primary + api_key: pub-key + - name: shared + kind: google + calendar_id: abc123@group.calendar.google.com + token: oauth-token + - name: dav + kind: caldav + url: https://cloud.example.net/remote.php/dav/calendars/me/personal/ + username: me + password: app-pass +"; + let config: ClawConfig = serde_yaml::from_str(raw).expect("parses"); + assert_eq!(config.calendars.len(), 4); + let home = &config.calendars[0]; + assert_eq!(home.name, "home"); + assert_eq!(home.kind, CalendarKind::Ics); + assert_eq!(home.leads(), vec![60, 15]); + assert_eq!(home.horizon(), 48); + assert_eq!( + config.calendars[1].resolved_api_key().as_deref(), + Some("pub-key") + ); + assert_eq!( + config.calendars[2].resolved_token().as_deref(), + Some("oauth-token") + ); + assert_eq!(config.calendars[3].kind, CalendarKind::Caldav); + assert_eq!(config.calendars[3].username.as_deref(), Some("me")); + assert_eq!( + config.calendars[3].resolved_password().as_deref(), + Some("app-pass") + ); + config.validate().expect("a complete list validates"); + } + + #[test] + fn calendar_defaults_fill_what_the_file_omits() { + let config: ClawConfig = serde_yaml::from_str( + "calendars:\n - name: home\n kind: ics\n url: https://c.example.net/a.ics\n", + ) + .expect("parses"); + assert_eq!(config.calendars[0].leads(), vec![DEFAULT_LEAD_MINUTES]); + assert_eq!(config.calendars[0].horizon(), DEFAULT_HORIZON_HOURS); + // A blank or zero lead list reads as the default, not as + // silence. + let config: ClawConfig = serde_yaml::from_str( + "calendars:\n - name: home\n kind: ics\n url: https://c.example.net/a.ics\n lead_minutes: []\n", + ) + .expect("parses"); + assert_eq!(config.calendars[0].leads(), vec![DEFAULT_LEAD_MINUTES]); + } + + #[test] + fn a_calendar_typo_is_rejected_and_named() { + let err = serde_yaml::from_str::( + "calendars:\n - name: home\n kind: ics\n uurl: https://c.example.net/a.ics\n", + ) + .expect_err("a typo'd calendar key is loud"); + assert!(err.to_string().contains("uurl"), "got: {err}"); + } + + #[test] + fn calendar_validation_fails_loudly_and_specifically() { + let config = |calendars: Vec| ClawConfig { + calendars, + ..ClawConfig::default() + }; + let ics = |name: &str, url: Option<&str>| CalendarWatch { + name: name.to_owned(), + kind: CalendarKind::Ics, + url: url.map(str::to_owned), + calendar_id: None, + token: None, + username: None, + password: None, + api_key: None, + lead_minutes: None, + horizon_hours: None, + }; + let caldav = |name: &str, url: Option<&str>| CalendarWatch { + name: name.to_owned(), + kind: CalendarKind::Caldav, + url: url.map(str::to_owned), + calendar_id: None, + token: None, + username: None, + password: None, + api_key: None, + lead_minutes: None, + horizon_hours: None, + }; + let google = |name: &str, id: Option<&str>, api_key: Option<&str>| CalendarWatch { + name: name.to_owned(), + kind: CalendarKind::Google, + url: None, + calendar_id: id.map(str::to_owned), + token: None, + username: None, + password: None, + api_key: api_key.map(str::to_owned), + lead_minutes: None, + horizon_hours: None, + }; + + let err = config(vec![ics("", Some("https://c.example.net/a.ics"))]) + .validate() + .expect_err("a blank name is loud"); + assert!(err.to_string().contains("blank"), "got: {err}"); + + let err = config(vec![ + ics("home", Some("https://c.example.net/a.ics")), + ics("home", Some("webcal://c.example.net/b.ics")), + ]) + .validate() + .expect_err("a duplicate name is loud"); + assert!(err.to_string().contains("duplicate"), "got: {err}"); + + let err = config(vec![ics("home", None)]) + .validate() + .expect_err("a url-less feed is loud"); + assert!(err.to_string().contains("feed URL"), "got: {err}"); + + let err = config(vec![ics("home", Some("ftp://c.example.net/a.ics"))]) + .validate() + .expect_err("a junk scheme is loud"); + assert!(err.to_string().contains("https://"), "got: {err}"); + + let err = config(vec![caldav("home", None)]) + .validate() + .expect_err("a url-less collection is loud"); + assert!(err.to_string().contains("DAV"), "got: {err}"); + + let err = config(vec![caldav("home", Some("webcal://c.example.net/dav/"))]) + .validate() + .expect_err("webcal is not a dav spelling"); + assert!(err.to_string().contains("https://"), "got: {err}"); + + let err = config(vec![google("work", None, Some("k"))]) + .validate() + .expect_err("an id-less google calendar is loud"); + assert!(err.to_string().contains("calendar_id"), "got: {err}"); + + let err = config(vec![google("work", Some("primary"), None)]) + .validate() + .expect_err("a credential-less google calendar is loud"); + assert!(err.to_string().contains("CALENDAR_TOKEN"), "got: {err}"); + + let mut late = ics("home", Some("https://c.example.net/a.ics")); + late.lead_minutes = Some(vec![60]); + late.horizon_hours = Some(1); + let err = config(vec![late]) + .validate() + .expect_err("a lead past the horizon is loud"); + assert!(err.to_string().contains("horizon"), "got: {err}"); + + // A username with a password validates, as does a collection + // with none of it (an open server) or a token instead. + let mut basic = caldav("home", Some("https://c.example.net/dav/home/")); + basic.username = Some("me".to_owned()); + basic.password = Some("app-pass".to_owned()); + let mut bearer = caldav("shared", Some("http://10.0.0.5/dav/shared/")); + bearer.token = Some("sekrit".to_owned()); + config(vec![ + ics("feed", Some("webcal://c.example.net/a.ics")), + basic, + bearer, + google("work", Some("primary"), Some("k")), + ]) + .validate() + .expect("a complete list validates"); + } + + #[test] + fn the_caldav_password_falls_back_to_the_env_and_blank_counts_as_unset() { + let env = |name: &str| match name { + "CALDAV_PASSWORD" => Ok("from-env".to_owned()), + _ => Err(std::env::VarError::NotPresent), + }; + assert_eq!( + CalendarWatch::resolve_password_with(env, Some(" from-config ")), + Some("from-config".to_owned()), + "the config password wins" + ); + assert_eq!( + CalendarWatch::resolve_password_with(env, None), + Some("from-env".to_owned()), + "the env password serves when no config password" + ); + assert_eq!( + CalendarWatch::resolve_password_with( + |_| Err(std::env::VarError::NotPresent), + Some(" ") + ), + None, + "a blank password counts as unset" + ); + } + + #[test] + fn the_calendar_token_falls_back_to_the_env_and_blank_counts_as_unset() { + let env = |name: &str| match name { + "CALENDAR_TOKEN" => Ok("from-env".to_owned()), + _ => Err(std::env::VarError::NotPresent), + }; + assert_eq!( + CalendarWatch::resolve_token_with(env, Some(" from-config ")), + Some("from-config".to_owned()), + "the config token wins" + ); + assert_eq!( + CalendarWatch::resolve_token_with(env, None), + Some("from-env".to_owned()), + "the env token serves when no config token" + ); + assert_eq!( + CalendarWatch::resolve_token_with(|_| Err(std::env::VarError::NotPresent), Some(" ")), + None, + "a blank token counts as unset" + ); + } + + #[test] + fn forge_names_parse_as_the_sources_spell_them() { + assert_eq!(ForgeKind::parse("github"), Some(ForgeKind::GitHub)); + assert_eq!(ForgeKind::parse(" gitea "), Some(ForgeKind::Gitea)); + assert_eq!(ForgeKind::parse("gitlab"), Some(ForgeKind::GitLab)); + assert_eq!(ForgeKind::parse("sourcehut"), None); + assert_eq!(ForgeKind::parse(""), None); + for kind in [ForgeKind::GitHub, ForgeKind::Gitea, ForgeKind::GitLab] { + assert_eq!(ForgeKind::parse(kind.name()), Some(kind), "{}", kind.name()); + } + } + + #[test] + fn the_config_token_wins_over_env_and_blank_counts_as_unset() { + let env = |name: &str| match name { + "GITHUB_TOKEN" => Ok("from-env".to_owned()), + "GITEA_TOKEN" => Ok(" ".to_owned()), + _ => Err(std::env::VarError::NotPresent), + }; + assert_eq!( + resolve_token(env, ForgeKind::GitHub, Some(" from-config ")), + Some("from-config".to_owned()), + "the config token wins" + ); + assert_eq!( + resolve_token(env, ForgeKind::GitHub, None), + Some("from-env".to_owned()), + "the env token serves when no config token" + ); + assert_eq!( + resolve_token(env, ForgeKind::Gitea, Some(" ")), + None, + "a blank env token counts as unset" + ); + assert_eq!( + resolve_token( + |_| Err(std::env::VarError::NotPresent), + ForgeKind::GitLab, + None + ), + None + ); + } + + #[test] + fn validation_fails_a_watch_loudly_and_specifically() { + let watch = |forge, repo: &str, host: Option<&str>, token: Option<&str>| Watch { + forge, + repo: repo.to_owned(), + host: host.map(str::to_owned), + token: token.map(str::to_owned), + }; + let config = |watches: Vec| ClawConfig { + watches, + ..ClawConfig::default() + }; + + // An empty list is a valid starting point now: the table is + // seeded from it, and the agent manages watches from chat. + config(vec![]).validate().expect("an empty list validates"); + + let err = config(vec![watch(ForgeKind::GitHub, "acme", None, Some("t"))]) + .validate() + .expect_err("ownerless repo is loud"); + assert!(err.to_string().contains("owner/name"), "got: {err}"); + + let err = config(vec![watch(ForgeKind::Gitea, "acme/barn", None, Some("t"))]) + .validate() + .expect_err("hostless gitea is loud"); + assert!(err.to_string().contains("self-hosted"), "got: {err}"); + + let err = config(vec![watch(ForgeKind::GitLab, "acme/barn", None, None)]) + .validate() + .expect_err("tokenless watch is loud"); + assert!(err.to_string().contains("GITLAB_TOKEN"), "got: {err}"); + + config(vec![watch( + ForgeKind::GitHub, + "acme/widgets", + None, + Some("t"), + )]) + .validate() + .expect("a complete github watch validates"); + config(vec![watch( + ForgeKind::Gitea, + "acme/barn", + Some("https://git.example.net"), + Some("t"), + )]) + .validate() + .expect("a complete gitea watch validates"); + } + + #[test] + fn a_non_loopback_surface_demands_a_token() { + let config = |listen: Option<&str>, token: Option<&str>| ClawConfig { + listen: listen.map(str::to_owned), + token: token.map(str::to_owned), + ..ClawConfig::default() + }; + // The default listen is loopback: open is fine. + config(None, None) + .validate_surface() + .expect("default is safe"); + config(Some("localhost:7799"), None) + .validate_surface() + .expect("the localhost literal is loopback"); + config(Some("127.0.0.1:7799"), None) + .validate_surface() + .expect("an explicit loopback ip is loopback"); + config(Some("[::1]:7799"), None) + .validate_surface() + .expect("a bracketed ::1 is loopback"); + // A token guards a wide bind. + config(Some("0.0.0.0:7799"), Some("sekrit")) + .validate_surface() + .expect("a token guards the wide bind"); + // No token on a wide bind is refused, naming the fix. + let err = config(Some("0.0.0.0:7799"), None) + .validate_surface() + .expect_err("an unguarded wide bind is refused"); + assert!( + err.to_string().contains("0.0.0.0:7799") && err.to_string().contains("set a token"), + "got: {err}" + ); + // A blank token counts as unset, everywhere else too. + assert!( + config(Some("192.168.1.10:7800"), Some(" ")) + .validate_surface() + .is_err() + ); + } + + #[test] + fn loopback_detection_covers_the_listen_spellings() { + for listen in [ + "localhost:7799", + "LOCALHOST:7799", + "127.0.0.1:7799", + "127.8.8.8:80", + "::1", + "[::1]:7799", + "[::1]", + ] { + assert!(is_loopback_listen(listen), "{listen} is loopback"); + } + for listen in [ + "0.0.0.0:7799", + "192.168.1.10:7800", + "[::]:7799", + ":7799", + "example.com:7799", + "not an address", + ] { + assert!(!is_loopback_listen(listen), "{listen} is not loopback"); + } + } + + #[test] + fn a_missing_file_is_named_not_defaulted() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("does-not-exist.yaml"); + let err = ClawConfig::load_from(&path).expect_err("missing is loud"); + assert!(matches!(err, ConfigError::Missing(_)), "got: {err:?}"); + } + + #[test] + fn the_model_falls_back_per_provider() { + let config = |provider: Option<&str>, model: Option<&str>| ClawConfig { + provider: provider.map(str::to_owned), + model: model.map(str::to_owned), + ..ClawConfig::default() + }; + assert_eq!(config(None, None).resolved_model(), "gemini-3.8-flash"); + assert_eq!(config(Some("llama-cpp"), None).resolved_model(), "local"); + assert_eq!( + config(Some("anthropic"), None).resolved_model(), + "claude-sonnet-4-5" + ); + assert_eq!(config(Some("openai"), None).resolved_model(), "gpt-5"); + // An explicit model always wins, whatever the provider. + assert_eq!( + config(Some("openrouter"), Some("x/y")).resolved_model(), + "x/y" + ); + } +} diff --git a/crates/rhema-claw/src/consents.rs b/crates/rhema-claw/src/consents.rs new file mode 100644 index 0000000..e16aea8 --- /dev/null +++ b/crates/rhema-claw/src/consents.rs @@ -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 { + 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, + /// When the request was made, epoch millis. + pub created_at: u64, + /// When it resolved, epoch millis, if it did. + pub resolved_at: Option, + /// Whether it was granted, if it resolved. + pub granted: Option, + /// For a resolved secret request: the account the secret landed in. + pub account_id: Option, +} + +/// 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, +} + +impl ConsentStore { + /// Opens (creating if needed) the consent table on the database at + /// `path`. + pub fn open(path: impl AsRef) -> Result { + 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 { + let conn = rusqlite::Connection::open_in_memory() + .map_err(|err| ConsentStoreError::Backend(err.to_string()))?; + Self::init(conn) + } + + fn init(conn: rusqlite::Connection) -> Result { + 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, + ) -> Result { + 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, 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, 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::, _>>().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 { + 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 { + 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 { + 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>(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>(7)? + .map(|millis| millis.max(0) as u64), + granted: row.get::<_, Option>(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>>, +} + +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 { + 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), + /// 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, + bridge: Arc, + hub: Arc, + 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, + #[serde(default)] + username: Option, + #[serde(default)] + url: Option, + #[serde(default)] + secret_kind: Option, + #[serde(default)] + reason: Option, +} + +#[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 { + 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, + bridge: Arc, + hub: Arc, + 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 { + 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::(&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::(&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::(&answer).unwrap(), + json!({"timed_out": true}), + "{answer}" + ); + } +} diff --git a/crates/rhema-claw/src/daemon.rs b/crates/rhema-claw/src/daemon.rs new file mode 100644 index 0000000..6b53e2d --- /dev/null +++ b/crates/rhema-claw/src/daemon.rs @@ -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, +} + +/// 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, + calendar_source: CalendarSource, + scheduler: TaskScheduler, + job_source: crate::jobs::JobsSource, + jobs: Arc, + forge_dispatcher: Arc, + interactive_dispatcher: Arc, + jobs_dispatcher: Arc, + forge_driver: Arc, + interactive_driver: Arc, + jobs_driver: Arc, + 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, +} + +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, + dispatch: DispatcherConfig, + ) -> Result { + 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:#`: 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 = 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 { + 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, +) -> Result { + 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" + ); + } +} diff --git a/crates/rhema-claw/src/environment.rs b/crates/rhema-claw/src/environment.rs new file mode 100644 index 0000000..8d9fe40 --- /dev/null +++ b/crates/rhema-claw/src/environment.rs @@ -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 `/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) -> Result { + 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 { + 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) -> 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"), + } + } +} diff --git a/crates/rhema-claw/src/forge.rs b/crates/rhema-claw/src/forge.rs new file mode 100644 index 0000000..fc8dcfb --- /dev/null +++ b/crates/rhema-claw/src/forge.rs @@ -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, + 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 { + 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, + store: Arc, + /// 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, + /// The device-notification queue a [`Triage::Surface`] lands in. + notifications: Arc, + clients: Mutex>>, + /// One notifications cursor per account id. + account_cursors: Mutex>, +} + +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, + name: impl Into, + store: Arc, + accounts: Arc, + notifications: Arc, + ) -> 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 { + 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 { + forge_key_fn(&self.name) + } + + async fn detect(&self) -> Result { + 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 { + 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, + ) -> ( + Arc, + Arc, + Arc, + ) { + 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 = + 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"); + } +} diff --git a/crates/rhema-claw/src/handler.rs b/crates/rhema-claw/src/handler.rs new file mode 100644 index 0000000..2ffbc69 --- /dev/null +++ b/crates/rhema-claw/src/handler.rs @@ -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, + workspace_root: PathBuf, + watches: Arc, + clients: Mutex>>, +} + +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, + workspace_root: impl Into, + watches: Arc, + ) -> 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, 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 { + // 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, String> { + let items = ctx + .poll_inbox() + .await + .map_err(|err| format!("inbox poll for {key} failed: {err}"))?; + items + .iter() + .map(decode_news) + .collect::, _>>() + .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 { + 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 { + 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 { + 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 { + if let Ok(triaged) = serde_json::from_value::(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 { + 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, +) -> 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 { + let joined = message + .content + .iter() + .filter_map(|content| match content { + Content::Text(text) => Some(text.as_str()), + Content::ToolCall(_) | Content::ToolResult(_) => None, + }) + .collect::>() + .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::(); + 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 { + 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")); + } +} diff --git a/crates/rhema-claw/src/http.rs b/crates/rhema-claw/src/http.rs new file mode 100644 index 0000000..bcab745 --- /dev/null +++ b/crates/rhema-claw/src/http.rs @@ -0,0 +1,1651 @@ +//! The HTTP surface: the resident agent's chat routes, its session +//! inventory, its watch CRUD, its setup badge (`/v1/bootstrap`), the +//! external-event ingress (`/v1/ingest`), the notification queue the +//! devices poll, and the health probe that stays open when everything +//! else is tokened. +//! +//! The shape of a chat request: ingest the message, then drain the +//! interactive dispatcher — run passes until one admits nothing and the +//! key has nothing waiting — so queued follow-ups drain before the HTTP +//! answer returns. The reply itself arrives on the event stream, not in +//! the POST answer: the POST only says the message was accepted. That +//! stream answers with the key's recorded turns first and the live feed +//! after, cut atomically in the hub — a (re)connecting client resets its +//! transcript and lets the replay rebuild it, so a reload is not a blank +//! page and a dropped stream is not a gap. (The OpenAI shim is the one +//! caller that still subscribes before ingesting: it consumes the reply +//! off its own receiver.) +//! +//! The drain loop is capped. A pass that runs a foreign key's turn is +//! still a pass; a pathological backlog stops at +//! [`MAX_DRAIN_PASSES`] and leaves the rest to the daemon's tick, which +//! drains the interactive dispatcher as its backstop. + +use std::collections::BTreeSet; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +use axum::Json; +use axum::extract::{Path, Query, Request, State}; +use axum::http::StatusCode; +use axum::middleware::Next; +use axum::response::sse::{Event as SseEvent, KeepAlive, Sse}; +use axum::response::{IntoResponse, Response}; +use axum::routing::put; +use axum::routing::{delete, get, post}; +use axum::{Router, middleware}; +use rhema_autonomous_agent::Driver; +use rhema_rsd::{ + DispatchError, Dispatcher, IngestResult, KeyRecord, KeyState, ResourceKey, SqliteStore, Store, +}; +use serde::Deserialize; +use serde_json::{Value, json}; +use tokio::sync::broadcast::error::RecvError; +use tokio_stream::wrappers::ReceiverStream; + +use crate::backends::BackendRegistry; +use crate::bootstrap::Bootstrap; +use crate::hub::{EventHub, TurnEvent}; +use crate::notifications::NotificationStore; +use crate::watch_store::{ClawStore, validate_watch}; + +/// How many `run_once` passes one POST may spend draining. +pub(crate) const MAX_DRAIN_PASSES: usize = 10; + +/// How often the SSE keep-alive comment goes out. +pub(crate) const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(15); + +/// Everything the routes touch, shared behind `Arc`s. Built by the +/// daemon's assembly; [`ClawState::new`] is its only constructor. +#[derive(Clone)] +pub struct ClawState { + dispatcher: Arc, + driver: Arc, + store: Arc, + watches: Arc, + notifications: Arc, + bootstrap: Arc, + hub: Arc, + backends: Arc, + settings: Arc, + identity: Arc, + /// The accounts layer the identity routes serve. Like every consumer + /// of the store, the routes only ever see redacted records. + accounts: Arc, + /// The consent table the consent routes read and answer into — the + /// same rows the interactive handler's `ask_secret` tool waits on. + consents: Arc, + /// The wake bridge between those answers and the waiting tools. + bridge: Arc, + /// The attachment store the `attach_file` tool copies into and the + /// fetch route serves from. + attachments: Arc, + /// The phone tables: the command queue the device polls and the + /// reported state the ingest door feeds. + phone: Arc, + token: Option, + /// The generic sources `/v1/ingest` has registered on the + /// dispatcher, so a source name registers exactly once however many + /// events race in through it. + generic_sources: Arc>>, +} + +impl ClawState { + /// The state over the interactive dispatcher and its driver, the + /// shared dispatch store, the watch table, the notification table, + /// the bootstrap store, the event hub, the account store, the + /// consent table, the attachment store, and the configured bearer + /// `token`. + #[allow(clippy::too_many_arguments)] // fifteen parts, each load-bearing + pub fn new( + dispatcher: Arc, + driver: Arc, + store: Arc, + watches: Arc, + notifications: Arc, + bootstrap: Arc, + hub: Arc, + backends: Arc, + settings: Arc, + identity: Arc, + accounts: Arc, + consents: Arc, + bridge: Arc, + attachments: Arc, + phone: Arc, + token: Option, + ) -> Self { + Self { + dispatcher, + driver, + store, + watches, + notifications, + bootstrap, + hub, + backends, + settings, + identity, + accounts, + consents, + bridge, + attachments, + phone, + token, + generic_sources: Arc::new(Mutex::new(BTreeSet::new())), + } + } + + /// Every dispatch key with a record — interactive and forge alike, + /// both dispatchers share one store — with its status. + pub fn sessions(&self) -> Result, DispatchError> { + let mut sessions = Vec::new(); + for key in self.store.keys()? { + let status = self.dispatcher.status(&key)?; + sessions.push(json!({ + "key": status.key.as_str(), + "state": status.state, + "revision": status.revision, + "inbox_depth": status.inbox_depth, + "last_outcome": status.last_outcome, + })); + } + Ok(sessions) + } + + /// The interactive dispatcher: message ingest and key status. + pub fn dispatcher(&self) -> &Dispatcher { + &self.dispatcher + } + + /// The interactive driver: the drain loops run it. + pub fn driver(&self) -> &Driver { + &self.driver + } + + /// The event hub: turns are streamed from it. + pub fn hub(&self) -> &EventHub { + &self.hub + } + + /// The configured model — the shim's echo default. + /// The backend registry: the provider list and the active pair. + pub fn backends(&self) -> &BackendRegistry { + &self.backends + } + + /// The settings store: where a runtime switch persists. + pub fn settings(&self) -> &crate::settings::SettingsStore { + &self.settings + } + + /// The configured bearer token, when the surface is guarded. + pub fn token(&self) -> Option<&str> { + self.token.as_deref() + } + + /// The agent's presentation identity. + pub fn identity(&self) -> &crate::identity::IdentityStore { + &self.identity + } + + /// The accounts layer: the identity routes list, save, and forget + /// through it, redacted records only. + pub fn accounts(&self) -> &dyn rhema_identity::IdentityStore { + self.accounts.as_ref() + } + + /// The watch table: a saved account's forge watch registers here. + pub fn watches(&self) -> &ClawStore { + &self.watches + } +} + +/// The app router: `/healthz` open, `/v1` behind the bearer check when +/// the config set a token. +pub fn router(state: ClawState) -> Router { + let v1 = Router::new() + .route("/sessions", get(sessions).post(sessions_create)) + .route("/sessions/{key}/messages", post(messages)) + .route("/sessions/{key}/events", get(events)) + .route("/bootstrap", get(bootstrap_status)) + .route("/watches", get(watches_list).post(watches_add)) + .route("/watches/{forge}/{*repo}", delete(watches_remove)) + .route("/accounts", get(accounts_list).post(accounts_add)) + .route("/accounts/{id}", delete(accounts_remove)) + .route("/consents", get(consents_list)) + .route( + "/consents/{id}", + post(consents_resolve).delete(consents_deny), + ) + .route("/attachments/{id}", get(attachment_get)) + .route("/phone/commands", get(phone_commands_list)) + .route("/phone/commands/{id}/result", post(phone_command_result)) + .route("/phones", get(phones_list)) + .route("/phones/{device}", delete(phones_forget)) + .route("/ingest", post(ingest)) + .route("/notifications", get(notifications_list)) + .route("/notifications/{id}/ack", post(notifications_ack)) + .route("/agent", get(agent_get).put(agent_put)) + .route( + "/agent/{image}", + get(agent_image_get) + .put(agent_image_put) + .delete(agent_image_delete), + ) + .route("/providers", get(providers_list)) + .route("/providers/active", put(providers_active)) + .route( + "/providers/{id}/credentials", + put(providers_set_credentials), + ) + .route("/providers/{id}", delete(providers_remove)) + .route("/providers/{id}/models", get(providers_models)) + .route("/chat/completions", post(crate::openai::chat_completions)) + .layer(middleware::from_fn_with_state(state.clone(), require_auth)); + Router::new() + .route( + "/", + get(|| async { + // A bare host hit (a browser on a ui-less deployment, a + // curl probing the daemon) explains itself instead of + // 404ing into confusion. + Json(json!({ + "service": "rhema-claw", + "chat": "/v1/sessions", + "api": ["/v1/sessions", "/v1/providers", "/v1/watches", + "/v1/accounts", "/v1/notifications", "/v1/phones", + "/v1/bootstrap", "/v1/ingest"], + })) + }), + ) + .route("/healthz", get(|| async { "ok" })) + .nest("/v1", v1) + .with_state(state) +} + +/// Byte-length-safe constant-time equality over the two strings' UTF-8 +/// bytes: every compared byte is XOR-folded into one difference word +/// along with the length difference, so the answer takes the same work +/// whether the mismatch is in the first byte or the last. Not a MAC — +/// just enough that the bearer check does not leak the token a timing +/// probe at a time. +fn tokens_match(presented: &str, expected: &str) -> bool { + let (presented, expected) = (presented.as_bytes(), expected.as_bytes()); + let mut diff = presented.len() ^ expected.len(); + for (a, b) in presented.iter().zip(expected) { + diff |= usize::from(a ^ b); + } + diff == 0 +} + +/// The bearer check: with no configured token the surface is open; with +/// one, `Authorization: Bearer ` is required on every `/v1` +/// route. `/healthz` never reaches this. +async fn require_auth(State(state): State, request: Request, next: Next) -> Response { + let authorized = match &state.token { + None => true, + Some(expected) => request + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .strip_prefix("Bearer ") + .is_some_and(|presented| tokens_match(presented, expected)) + }), + }; + if authorized { + next.run(request).await + } else { + // A body, however small: a silent 401 reads as a network fault + // to every client debugging its token. + ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "missing or wrong bearer token"})), + ) + .into_response() + } +} + +async fn sessions( + State(state): State, +) -> Result>, (StatusCode, String)> { + state + .sessions() + .map(Json) + .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string())) +} + +/// `{"name": "my-notes"}` — a chat session to create. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SessionCreateBody { + /// The name under the `interactive:` prefix. + pub name: String, +} + +/// The session name a create route accepts: the shape a key's suffix +/// must take to stay legible in lists, URLs, and logs. +fn valid_session_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') +} + +/// `POST /v1/sessions`: create an empty `interactive:` session — +/// a record, not a conversation; nothing dispatches until a message +/// arrives. Sessions otherwise come into being implicitly, on their +/// first message, which is why a UI could not offer one before this. +async fn sessions_create( + State(state): State, + Json(body): Json, +) -> Response { + let name = body.name.trim(); + if !valid_session_name(name) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "name must be 1-64 chars of letters, digits, '.', '_', or '-'"})), + ) + .into_response(); + } + let key = ResourceKey::from(format!("{}{name}", crate::interactive::KEY_PREFIX)); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + // The CAS precondition does the duplicate check: `None` means the + // key must not exist yet, so a race between two creates resolves to + // one 201 and one 409, never two sessions under one key. + match state.store.save(KeyRecord::fresh(key.clone(), now), None) { + Ok(()) => ( + StatusCode::CREATED, + Json(json!({ "key": key.as_str(), "created": true })), + ) + .into_response(), + Err(rhema_rsd::StoreError::RevisionMismatch { .. }) => ( + StatusCode::CONFLICT, + Json(json!({"error": format!("session {:?} already exists", key.as_str())})), + ) + .into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": err.to_string()})), + ) + .into_response(), + } +} + +/// The setup badge: which of SOUL.md and USER.md exist, read live — a +/// UI badges first-run setup from it while the onboarding offer is +/// still open. +async fn bootstrap_status( + State(state): State, +) -> Result, (StatusCode, String)> { + state + .bootstrap + .status() + .map(Json) + .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string())) +} + +/// `{"text": "..."}` — one chat message. +#[derive(Deserialize)] +pub struct MessageBody { + /// The message text. + pub text: String, +} + +async fn messages( + State(state): State, + Path(key): Path, + Json(body): Json, +) -> Response { + if !crate::interactive::is_resident_key(&key) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "key must start with interactive:, task:, or phone:"})), + ) + .into_response(); + } + if body.text.trim().is_empty() { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(json!({"error": "text must not be empty"})), + ) + .into_response(); + } + let key = ResourceKey::from(key); + // No subscribe-before-ingest here: the hub records every turn + // whether or not anyone is watching, so a chat surface connecting + // later replays the whole exchange. The reply itself is consumed on + // the event stream, not here. + if let Err(err) = state + .dispatcher + .ingest(crate::interactive::user_event(key.as_str(), &body.text)) + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": err.to_string()})), + ) + .into_response(); + } + if let Err(err) = drain(&state.driver, &state.dispatcher, &key).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": err.to_string()})), + ) + .into_response(); + } + ( + StatusCode::ACCEPTED, + Json(json!({"key": key.as_str(), "accepted": true})), + ) + .into_response() +} + +/// The events route's query: `since` counts how many of the record's +/// oldest events the client already holds in its local transcript cache, +/// and those are skipped in the replay. Absent (the web UI keeps no +/// cache) the replay stays whole. +#[derive(Debug, Deserialize)] +struct EventStreamQuery { + since: Option, +} + +async fn events( + State(state): State, + Path(key): Path, + Query(query): Query, +) -> Response { + // The same resident-key rule the messages route applies: an SSE + // subscription to a forge key is a misuse, not a stream. + if !crate::interactive::is_resident_key(&key) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "key must start with interactive:, task:, or phone:"})), + ) + .into_response(); + } + // History first, live second, cut atomically: the snapshot and the + // subscription leave the hub under one lock, so a client folding the + // replay and then the stream sees every event exactly once. A reload + // rebuilds its transcript from this — nothing else backfills it. A + // client that cached the transcript opens with `since` and receives + // only the events it has not seen. + let (history, mut receiver) = state + .hub + .subscribe_with_history_from(&ResourceKey::from(key), query.since.unwrap_or(0)); + // The pump decouples the broadcast channel from the SSE body: the + // client's disconnect closes the body, which fails the send, which + // ends the pump. The stream never ends on its own — a chat UI + // subscribes once and chats forever. + let (sender, body) = + tokio::sync::mpsc::channel::>(64); + tokio::spawn(async move { + for turn in history { + if sender.send(Ok(sse_event(turn))).await.is_err() { + return; + } + } + loop { + let item = match receiver.recv().await { + Ok(turn) => sse_event(turn), + Err(RecvError::Lagged(dropped)) => SseEvent::default() + .comment(format!("lagged; {dropped} earlier event(s) dropped")), + Err(RecvError::Closed) => break, + }; + if sender.send(Ok(item)).await.is_err() { + break; + } + } + }); + Sse::new(ReceiverStream::new(body)) + .keep_alive( + KeepAlive::default() + .interval(KEEP_ALIVE_INTERVAL) + .text("keep-alive"), + ) + .into_response() +} + +/// One `TurnEvent` as an SSE frame: `data: {json}`. A lagged subscriber +/// gets a comment naming the drop — live delivery is best-effort; the +/// record replays what completed. A serialization failure is logged with +/// the event kind instead of vanishing: a dropped frame a client can +/// never see must at least reach the operator. +fn sse_event(turn: TurnEvent) -> SseEvent { + match serde_json::to_string(&turn) { + Ok(data) => SseEvent::default().data(data), + Err(err) => { + eprintln!( + "[claw] warning: dropped unserializable {} event: {err}", + turn.kind() + ); + SseEvent::default().comment("unserializable event dropped") + } + } +} + +async fn watches_list(State(state): State) -> Result, (StatusCode, String)> { + let watches = state + .watches + .list() + .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; + Ok(Json(json!({ "watches": watches }))) +} + +/// `{"forge", "repo", "host"?, "token"?}` — the shape every watch entry +/// validates the same, wherever it enters. +#[derive(Deserialize)] +pub struct WatchBody { + /// `github`, `gitea`, or `gitlab`. + pub forge: String, + /// `owner/name` (GitLab: `namespace/project`). + pub repo: String, + /// Instance base URL; required for gitea. + pub host: Option, + /// Forge token; falls back to the forge's environment variable. + pub token: Option, +} + +async fn watches_add(State(state): State, Json(body): Json) -> Response { + let Some(kind) = crate::config::ForgeKind::parse(&body.forge) else { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "forge must be github, gitea, or gitlab"})), + ) + .into_response(); + }; + if let Err(reason) = validate_watch(kind, &body.repo, body.host.as_deref()) { + return (StatusCode::BAD_REQUEST, Json(json!({"error": reason}))).into_response(); + } + let added = match state.watches.add( + kind.name(), + body.repo.trim(), + body.host.as_deref(), + body.token.as_deref().map(str::trim).unwrap_or(""), + ) { + Ok(added) => added, + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": err.to_string()})), + ) + .into_response(); + } + }; + let status = if added { + StatusCode::CREATED + } else { + StatusCode::OK + }; + ( + status, + Json(json!({ + "added": added, + "forge": kind.name(), + "repo": body.repo.trim(), + })), + ) + .into_response() +} + +async fn watches_remove( + State(state): State, + Path((forge, repo)): Path<(String, String)>, +) -> Response { + let removed = match crate::config::ForgeKind::parse(&forge) { + Some(kind) => state + .watches + .remove(kind.name(), repo.trim()) + .map_err(|err| err.to_string()), + None => Ok(false), + }; + match removed { + Ok(removed) => (StatusCode::OK, Json(json!({"removed": removed}))).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": err})), + ) + .into_response(), + } +} + +/// `{"label", "service", "url"?, "username"?, "secret", "secret_kind"?, +/// "watch"?}` — an account to store. The secret is write-only: it is +/// stored and never served back, by the shape of the store's records. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AccountBody { + /// The display name. + pub label: String, + /// The service, lowercase: `github`, `gitlab`, `gitea`, or any name. + pub service: String, + /// Base URL, for self-hosted instances. + pub url: Option, + /// The login name on the service. + pub username: Option, + /// The credential to store; never echoed back. + pub secret: String, + /// `token` (default) or `password`. + pub secret_kind: Option, + /// Also follow this forge identity's notifications. Default false. + pub watch: Option, +} + +async fn accounts_list(State(state): State) -> Response { + match state.accounts().list() { + Ok(accounts) => (StatusCode::OK, Json(json!({ "accounts": accounts }))).into_response(), + Err(err) => internal_error(err.to_string()).into_response(), + } +} + +async fn accounts_add(State(state): State, Json(body): Json) -> Response { + let kind = match crate::accounts_tools::parse_kind(body.secret_kind.as_deref()) { + Ok(kind) => kind, + Err(reason) => return bad_request(reason).into_response(), + }; + let mut draft = rhema_identity::AccountDraft::new( + body.label, + body.service, + rhema_identity::Secret::new(kind, body.secret), + ); + if let Some(url) = body.url { + draft = draft.with_url(url); + } + if let Some(username) = body.username { + draft = draft.with_username(username); + } + match crate::accounts_tools::save_account( + state.accounts(), + state.watches(), + draft, + body.watch.unwrap_or(false), + ) { + Ok(saved) => ( + StatusCode::CREATED, + Json(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, + })), + ) + .into_response(), + Err(reason) => bad_request(reason).into_response(), + } +} + +async fn accounts_remove(State(state): State, Path(id): Path) -> Response { + match crate::accounts_tools::forget_account(state.accounts(), state.watches(), &id) { + Ok((forgot, watches_removed)) => ( + StatusCode::OK, + Json(json!({ "forgot": forgot, "watches_removed": watches_removed })), + ) + .into_response(), + Err(reason) => internal_error(reason).into_response(), + } +} + +/// The outstanding consent requests — what a running turn has asked and +/// nobody has answered yet, oldest first. A UI polls this after a +/// reload so a card is never lost to one; the live ask also rides the +/// session's event stream as a `consent_request` event. +async fn consents_list( + State(state): State, +) -> Result, (StatusCode, String)> { + let consents = state + .consents + .pending() + .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; + Ok(Json(json!({ "consents": consents }))) +} + +/// The answer body: `{"secret": "..."}` for a `secret` request, +/// `{"grant": true|false}` for a `phone` one. The two shapes never mix — +/// the request's kind picks which field means anything. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConsentBody { + /// The credential, for a `secret` request. Stored into the identity + /// store; never echoed, never persisted in the consent row. + pub secret: Option, + /// The verdict, for a `phone` request. + pub grant: Option, +} + +async fn consents_resolve( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Response { + let consent = match state.consents.get(&id) { + Ok(Some(consent)) => consent, + Ok(None) => return not_found_consent(), + Err(err) => return internal_error(err.to_string()).into_response(), + }; + if consent.resolved_at.is_some() { + return ( + StatusCode::CONFLICT, + Json(json!({"error": "consent request already resolved"})), + ) + .into_response(); + } + match consent.kind { + crate::consents::ConsentKind::Secret => { + let secret = body + .secret + .as_deref() + .map(str::trim) + .filter(|secret| !secret.is_empty()) + .map(str::to_owned) + .ok_or_else(|| { + ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(json!({"error": "body must carry a non-empty secret"})), + ) + }); + let secret = match secret { + Ok(secret) => secret, + Err(response) => return response.into_response(), + }; + resolve_secret_consent(&state, &consent, secret).await + } + crate::consents::ConsentKind::Phone => { + let Some(granted) = body.grant else { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(json!({"error": "body must carry a grant boolean"})), + ) + .into_response(); + }; + settle_consent(&state, &consent, granted, None).await + } + } +} + +/// `DELETE /v1/consents/{id}`: deny (or cancel) a pending ask — the +/// tool wakes to an in-band refusal instead of a timeout. +async fn consents_deny(State(state): State, Path(id): Path) -> Response { + let consent = match state.consents.get(&id) { + Ok(Some(consent)) => consent, + Ok(None) => return not_found_consent(), + Err(err) => return internal_error(err.to_string()).into_response(), + }; + if consent.resolved_at.is_some() { + return ( + StatusCode::CONFLICT, + Json(json!({"error": "consent request already resolved"})), + ) + .into_response(); + } + settle_consent(&state, &consent, false, None).await +} + +/// The 404 shape for a consent id. +fn not_found_consent() -> Response { + ( + StatusCode::NOT_FOUND, + Json(json!({"error": "no such consent request"})), + ) + .into_response() +} + +/// `GET /v1/attachments/{id}`: a stored attachment's bytes, content +/// type from its name. A transcript card's fetch path — the route +/// serves a copy the turn made, so a workspace cleanup cannot break a +/// link the transcript already carried. +async fn attachment_get(State(state): State, Path(id): Path) -> Response { + match state.attachments.get(&id) { + Ok(Some((stored, bytes))) => ( + [ + ( + axum::http::header::CONTENT_TYPE, + crate::attachments::content_type(&stored.name).to_owned(), + ), + ( + axum::http::header::CONTENT_DISPOSITION, + format!("inline; filename=\"{}\"", stored.name.replace('"', "")), + ), + ], + bytes, + ) + .into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "no such attachment"})), + ) + .into_response(), + Err(err) => internal_error(err.to_string()).into_response(), + } +} + +/// The command poll's query: which device queue to read. +#[derive(Deserialize)] +pub struct PhoneCommandQuery { + /// The device queue to read. + pub device: String, +} + +/// `GET /v1/phones`: the fleet — every device that ever reported, with +/// its self-reported label, both clocks, and its latest observations +/// per topic. The client settings screens read this; `DELETE` below is +/// their management half. +async fn phones_list( + State(state): State, +) -> Result, (StatusCode, Json)> { + let devices = state.phone.devices().map_err(internal_error)?; + let mut phones = Vec::with_capacity(devices.len()); + for record in devices { + let snapshot = state + .phone + .snapshot(&record.device) + .map_err(internal_error)?; + phones.push(json!({ + "device": record.device, + "label": record.label, + "first_seen": record.first_seen, + "last_seen": record.last_seen, + "topics": snapshot["topics"], + })); + } + Ok(Json(json!({ "phones": phones }))) +} + +/// `DELETE /v1/phones/{device}`: retire a device — its observations, +/// its queued commands, and its registry row. Answers `forgotten` +/// rather than 404 on an unknown name: the caller raced another +/// manager, and "already gone" is the outcome it wanted. +async fn phones_forget( + State(state): State, + Path(device): Path, +) -> Result, (StatusCode, Json)> { + let device = device.trim(); + if device.is_empty() { + return Err(bad_request("device name must not be empty")); + } + let forgotten = state.phone.forget_device(device).map_err(internal_error)?; + Ok(Json(json!({ "forgotten": forgotten }))) +} + +/// `GET /v1/phone/commands?device=`: the device's queued commands, +/// oldest first, none of them confirmed yet — the poll list the phone +/// executes from and reports on. +async fn phone_commands_list( + State(state): State, + Query(query): Query, +) -> Result, (StatusCode, String)> { + let device = query.device.trim(); + if device.is_empty() { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + "device must not be empty".to_owned(), + )); + } + let commands = state + .phone + .pending_commands(device) + .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; + Ok(Json(json!({ "commands": commands }))) +} + +/// `{"ok": true, "detail": "..."}` — one command's execution report. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhoneCommandResult { + /// Whether execution succeeded. + pub ok: bool, + /// What happened, when the device has something to say. + pub detail: Option, +} + +/// `POST /v1/phone/commands/{id}/result`: the device's report. Consumed +/// once — a redelivered report is a 409, and the waiting tool saw the +/// first verdict. +async fn phone_command_result( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Response { + let command = match state.phone.get_command(&id) { + Ok(Some(command)) => command, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "no such command"})), + ) + .into_response(); + } + Err(err) => return internal_error(err.to_string()).into_response(), + }; + if command.result_ok.is_some() { + return ( + StatusCode::CONFLICT, + Json(json!({"error": "command already confirmed"})), + ) + .into_response(); + } + let detail = body + .detail + .as_deref() + .map(str::trim) + .filter(|detail| !detail.is_empty()) + .map(str::to_owned); + match state.phone.record_result(&id, body.ok, detail.as_deref()) { + Ok(true) => { + state.bridge.wake(&id); + (StatusCode::OK, Json(json!({ "acked": true }))).into_response() + } + Ok(false) => ( + StatusCode::CONFLICT, + Json(json!({"error": "command already confirmed"})), + ) + .into_response(), + Err(err) => internal_error(err.to_string()).into_response(), + } +} + +/// The secret request's second half: store the credential into the +/// identity store under the draft the ask carried, then resolve the row. +/// A store the identity layer refuses (bad kind, caps) leaves the +/// request pending — the human can retry with a corrected answer. +async fn resolve_secret_consent( + state: &ClawState, + consent: &crate::consents::Consent, + secret: String, +) -> Response { + let Some(draft) = consent.draft.as_ref() else { + return internal_error("the consent row carries no account draft").into_response(); + }; + let Some(label) = draft["label"].as_str() else { + return internal_error("the consent draft carries no label").into_response(); + }; + let Some(service) = draft["service"].as_str() else { + return internal_error("the consent draft carries no service").into_response(); + }; + let kind = match crate::accounts_tools::parse_kind(draft["secret_kind"].as_str()) { + Ok(kind) => kind, + Err(reason) => return internal_error(reason).into_response(), + }; + let mut account = rhema_identity::AccountDraft::new( + label.to_owned(), + service.to_owned(), + rhema_identity::Secret::new(kind, secret), + ); + if let Some(url) = draft["url"].as_str() { + account = account.with_url(url.to_owned()); + } + if let Some(username) = draft["username"].as_str() { + account = account.with_username(username.to_owned()); + } + let stored = match state.accounts().put(account) { + Ok(account) => account, + Err(err) => return bad_request(err.to_string()).into_response(), + }; + settle_consent(state, consent, true, Some(stored.id.clone())).await +} + +/// Marks the row resolved, wakes the waiting tool, and tells the +/// session's stream. `account_id` rides only a granted secret request. +async fn settle_consent( + state: &ClawState, + consent: &crate::consents::Consent, + granted: bool, + account_id: Option, +) -> Response { + let resolved = if consent.kind == crate::consents::ConsentKind::Secret && granted { + state + .consents + .resolve_secret(&consent.id, account_id.as_deref().unwrap_or("")) + } else { + state.consents.resolve_decision(&consent.id, granted) + }; + match resolved { + Ok(true) => {} + Ok(false) => { + return ( + StatusCode::CONFLICT, + Json(json!({"error": "consent request already resolved"})), + ) + .into_response(); + } + Err(err) => return internal_error(err.to_string()).into_response(), + } + state.bridge.wake(&consent.id); + state.hub.emit( + &ResourceKey::from(consent.session_key.clone()), + TurnEvent::ConsentResolved { + id: consent.id.clone(), + granted, + }, + ); + ( + StatusCode::OK, + Json(json!({ + "resolved": true, + "granted": granted, + "account_id": account_id, + })), + ) + .into_response() +} + +/// `{"source": "...", "key": "...", "payload": {...}}` — one external +/// event: the phone's reporting door, and any future emitter's. +#[derive(Deserialize)] +pub struct IngestBody { + /// The source name the event ingests under; registered on first use. + pub source: String, + /// The resource key the event dispatches to, verbatim. + pub key: String, + /// The event body; the key rides inside it for the handler. + pub payload: Value, +} + +/// The source names the daemon's own assembly registers. `/v1/ingest` +/// cannot re-register them: `register_source` replaces, and a generic +/// passthrough landing over the forge or task key functions would +/// misroute every later event of those sources. +fn is_reserved_source(source: &str) -> bool { + matches!( + source, + crate::interactive::SOURCE | crate::task_source::TASKS_SOURCE + ) || crate::config::ForgeKind::parse(source).is_some() +} + +/// The external event's dedup id: a hash of the request's coordinates +/// (key plus canonical payload), so an identical redelivery dedups and +/// any change re-ingests — the caller's retry and the dispatcher's +/// at-least-once contract meet here. +fn dedup_id(key: &str, payload: &Value) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + key.hash(&mut hasher); + payload.to_string().hash(&mut hasher); + format!("ext-{:016x}", hasher.finish()) +} + +/// What an ingest result says on the wire: the interactive dispatcher's +/// debounce window is zero, so `DebouncePending` means the event is +/// queued for the next driver pass — "dispatched". +fn ingest_tag(result: IngestResult) -> &'static str { + match result { + IngestResult::DeliveredLive => "live", + IngestResult::DebouncePending => "dispatched", + IngestResult::Duplicate => "duplicate", + } +} + +impl ClawState { + /// Ingests one external event into the resident dispatcher under + /// `source`. The source registers lazily on first use — the same + /// passthrough key function the resident context's own sources use, + /// reading `payload["key"]` verbatim with the infallible + /// unique-key fallback — so a source nobody has seen before needs no + /// restart and no config. + pub fn ingest_external( + &self, + source: &str, + key: &str, + payload: Value, + ) -> Result { + { + let mut known = self + .generic_sources + .lock() + .unwrap_or_else(PoisonError::into_inner); + if known.insert(source.to_owned()) { + self.dispatcher + .register_source(source, crate::interactive::passthrough_key_fn()); + } + } + // The key_fn reads the payload: the request's key rides inside + // it, top-level winning over anything the body carried. + let mut payload = payload; + payload["key"] = json!(key); + let event = rhema_rsd::Event { + id: dedup_id(key, &payload), + source: source.to_owned(), + payload, + received_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + }; + self.dispatcher.ingest(event) + } +} + +async fn ingest(State(state): State, Json(body): Json) -> Response { + let source = body.source.trim(); + if source.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "source must not be empty"})), + ) + .into_response(); + } + if is_reserved_source(source) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": format!( + "source {source:?} is reserved by the daemon's own sources" + )})), + ) + .into_response(); + } + let key = body.key.trim(); + if key.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "key must not be empty"})), + ) + .into_response(); + } + if !body.payload.is_object() { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(json!({"error": "payload must be a JSON object"})), + ) + .into_response(); + } + // A topic-shaped phone key (`phone::`) is telemetry: + // it lands in the phone tables and stops there. A dispatched turn + // per flip would cost a model call and, through auto-notify, a push + // notification per flip — and the report has nothing to say a + // `phone status` read cannot. A bare `phone:` key is a + // message on the device's resident key and dispatches like any + // resident key's. A store failure is logged, never fatal. + if source == crate::phone::SOURCE { + if let Some((device, topic)) = crate::phone::phone_key_parts(key) { + if let Err(err) = state.phone.observe(device, topic, &body.payload) { + eprintln!("[claw] warning: phone state for {key} not stored: {err}"); + } + return ( + StatusCode::ACCEPTED, + Json(json!({ "ingested": "observed" })), + ) + .into_response(); + } + } + match state.ingest_external(source, key, body.payload) { + Ok(result) => ( + StatusCode::ACCEPTED, + Json(json!({ "ingested": ingest_tag(result) })), + ) + .into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": err.to_string()})), + ) + .into_response(), + } +} + +/// The poll's query: which device, and the last notification id the +/// caller already saw. +#[derive(Deserialize)] +pub struct NotificationQuery { + /// The device queue to read; the phone when unset. + pub device: Option, + /// Only notifications with a greater id; `0` when unset. + pub since: Option, +} + +/// The device's outstanding notifications, ascending by id. Delivered +/// rows stop answering once acked; an unacked row keeps coming back, so +/// a poll-and-ack device sees every notification at least once. +async fn notifications_list( + State(state): State, + Query(query): Query, +) -> Result, (StatusCode, String)> { + let device = query + .device + .as_deref() + .map(str::trim) + .filter(|device| !device.is_empty()) + .unwrap_or(crate::notifications::DEFAULT_DEVICE); + let since = query.since.unwrap_or(0); + let notifications = state + .notifications + .pending(device, since) + .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; + Ok(Json(json!({ "notifications": notifications }))) +} + +/// Marks one notification delivered. Idempotent: `false` when the id is +/// unknown or already acked. +async fn notifications_ack(State(state): State, Path(id): Path) -> Response { + match state.notifications.ack(id) { + Ok(acked) => (StatusCode::OK, Json(json!({ "acked": acked }))).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": err.to_string()})), + ) + .into_response(), + } +} + +/// Drains the interactive dispatcher around one triggered message: run +/// passes until one admits nothing and `key` has nothing waiting, capped +/// at [`MAX_DRAIN_PASSES`] — a message delivered live into a running +/// turn waits for the daemon's tick instead of a fourth HTTP digit. +pub(crate) async fn drain( + driver: &Driver, + dispatcher: &Dispatcher, + key: &ResourceKey, +) -> Result<(), DispatchError> { + for _ in 0..MAX_DRAIN_PASSES { + if driver.run_once().await?.is_empty() { + let status = dispatcher.status(key)?; + if status.inbox_depth == 0 && status.state != KeyState::Reacquire { + return Ok(()); + } + } + } + Ok(()) +} + +/// `GET /v1/providers`: every constructible backend and the active pair. +/// The active pair is runtime state — the config file seeded it, the +/// settings store owns it now. Each provider is one instance, named by +/// its `id` (the kind unless a login named it), and carries `source`, +/// where its credential resolves from: `env` (returns on restart even +/// after a removal), `stored` (the credentials this surface persisted), +/// `profile` (the stored rhema config), or `keyless`. `addable` lists +/// the kinds a runtime login can add and the fields their form takes. +async fn providers_list(State(state): State) -> Json { + Json(providers_json(&state)) +} + +/// The providers state as the wire sees it: the shared body of the list +/// route and of every mutation that answers with the new state. +fn providers_json(state: &ClawState) -> Value { + let (provider, model) = state.backends().active(); + json!({ + "active": {"provider": provider, "model": model}, + "providers": state.backends().list() + .into_iter() + .map(|row| json!({ + "id": row.id, + "kind": row.kind, + "default_model": row.default_model, + "source": credential_source(state, &row.id, &row.kind), + })) + .collect::>(), + "addable": crate::backends::addable_kinds() + .iter() + .map(|kind| json!({ + "kind": kind, + "fields": crate::backends::param_fields_for(kind), + })) + .collect::>(), + }) +} + +/// Where an instance's credential resolves from, at this moment: env +/// wins, then the surface's stored credentials, then the stored rhema +/// config; llama.cpp serves keyless. Never the key itself. +fn credential_source(state: &ClawState, id: &str, kind: &str) -> &'static str { + if crate::backends::env_key_names(kind) + .iter() + .any(|name| std::env::var(name).is_ok()) + { + return "env"; + } + if state + .settings() + .get(&crate::backends::provider_settings_key(id)) + .ok() + .flatten() + .is_some() + { + return "stored"; + } + if kind == "llama-cpp" { + return "keyless"; + } + "profile" +} + +/// `PUT /v1/providers/{id}/credentials`: add a backend instance at +/// runtime, or replace an existing id's credentials. The body carries +/// the kind's fields (`api_key`, and `url` for llama.cpp) plus the +/// `kind` — required when the id is new and refused when it names an +/// instance of another kind. The backend constructs immediately — an +/// unreachable server or a wrong key surfaces on the next turn, not +/// here — and the credentials persist in the settings store, so the +/// next boot discovers them. The active pair is untouched. +async fn providers_set_credentials( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let id = id.trim(); + if !crate::backends::valid_instance_id(id) { + return Err(bad_request( + "provider name must be 1-64 chars with no whitespace or '/'", + )); + } + let kind = match state.backends().get(id) { + Some(existing) => { + if let Some(kind) = body["kind"].as_str() { + if kind != existing.kind { + return Err(bad_request(format!( + "provider {id:?} is a {} backend, not {kind:?}", + existing.kind + ))); + } + } + existing.kind.clone() + } + None => match body["kind"].as_str() { + Some(kind) if crate::backends::addable_kinds().contains(&kind) => kind.to_owned(), + Some(kind) => return Err(bad_request(format!("unknown provider kind {kind:?}"))), + None => { + return Err(bad_request(format!( + "unknown provider {id:?}: its first login must carry a kind" + ))); + } + }, + }; + let blank_is_absent = |value: Option<&str>| { + value + .map(str::to_owned) + .filter(|value| !value.trim().is_empty()) + }; + let params = crate::backends::ProviderParams { + kind: kind.clone(), + api_key: blank_is_absent(body["api_key"].as_str()), + url: blank_is_absent(body["url"].as_str()), + }; + let mut backend = crate::backends::construct(&kind, ¶ms).map_err(bad_request)?; + backend.id = id.to_owned(); + state.backends().insert(backend); + let stored = serde_json::to_string(¶ms).map_err(internal_error)?; + state + .settings() + .set(&crate::backends::provider_settings_key(id), &stored) + .map_err(internal_error)?; + Ok(Json(providers_json(&state))) +} + +/// `DELETE /v1/providers/{id}`: remove a backend instance from the live +/// registry and drop its stored credentials. An env-resolved credential +/// re-discovers on restart — the list's `source` says so. Removing the +/// last backend is refused; when the removed instance was active, the +/// first remaining backend takes over and the persisted pair moves with +/// it. +async fn providers_remove( + State(state): State, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + state.backends().remove(&id).map_err(bad_request)?; + state + .settings() + .delete(&crate::backends::provider_settings_key(&id)) + .map_err(internal_error)?; + let (provider, model) = state.backends().active(); + state + .settings() + .set(crate::settings::ACTIVE_PROVIDER_KEY, &provider) + .map_err(internal_error)?; + state + .settings() + .set(crate::settings::ACTIVE_MODEL_KEY, &model) + .map_err(internal_error)?; + Ok(Json(providers_json(&state))) +} + +/// `PUT /v1/providers/active`: switch the backend instance the next +/// dispatch runs on, and persist the choice across restarts. An unknown +/// instance id is a 400; a model left unset falls back to its default. +async fn providers_active( + State(state): State, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let Some(provider) = body["provider"].as_str() else { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({"error": "body must carry a provider string"})), + )); + }; + let model = body["model"].as_str(); + if let Err(reason) = state.backends().set_active(provider, model) { + return Err((StatusCode::BAD_REQUEST, Json(json!({"error": reason})))); + } + state + .settings() + .set(crate::settings::ACTIVE_PROVIDER_KEY, provider) + .map_err(internal_error)?; + let (_, active_model) = state.backends().active(); + state + .settings() + .set(crate::settings::ACTIVE_MODEL_KEY, &active_model) + .map_err(internal_error)?; + Ok(Json(json!( + {"active": {"provider": provider, "model": active_model}} + ))) +} + +/// `GET /v1/providers/{id}/models`: the instance's live listing, +/// straight from the provider. An id without a listing (unknown, or the +/// mock) is a 404. +async fn providers_models( + State(state): State, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + let Some(listing) = state.backends().list_models(&id) else { + return Err(( + StatusCode::NOT_FOUND, + Json(json!({"error": format!("no live listing for provider {id:?}")})), + )); + }; + let models = listing.await.map_err(|err| { + ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": format!("model listing failed: {err}")})), + ) + })?; + Ok(Json(json!({ "models": models }))) +} + +/// `GET /v1/agent`: the agent's presentation identity — the text fields +/// (each absent when unset) and whether the images are set. +async fn agent_get( + State(state): State, +) -> Result, (StatusCode, Json)> { + let identity = state.identity().get().map_err(internal_error)?; + Ok(Json( + serde_json::to_value(&identity).map_err(internal_error)?, + )) +} + +/// `PUT /v1/agent`: save the text fields. A field the body carries is +/// set (an empty string clears it); an absent field is left alone, so +/// a partial save cannot wipe what it did not mean to touch. +async fn agent_put( + State(state): State, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + // Absent fields carry the current value forward (a partial save + // touches only what it names); a present empty string clears. + let current = state.identity().get().map_err(internal_error)?; + let field = |body_value: Option<&str>, stored: &Option| { + body_value.map(str::to_owned).or_else(|| stored.clone()) + }; + let texts = crate::identity::IdentityTexts { + name: field(body["name"].as_str(), ¤t.name), + mark: field(body["mark"].as_str(), ¤t.mark), + initials: field(body["initials"].as_str(), ¤t.initials), + tagline: field(body["tagline"].as_str(), ¤t.tagline), + signoff: field(body["signoff"].as_str(), ¤t.signoff), + }; + let identity = state.identity().set_texts(&texts).map_err(bad_request)?; + Ok(Json( + serde_json::to_value(&identity).map_err(internal_error)?, + )) +} + +/// `PUT /v1/agent/{image}`: store the avatar or logo from the raw body, +/// content type from the header. Answers the updated identity. +async fn agent_image_put( + State(state): State, + Path(kind): Path, + headers: axum::http::HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let kind = image_kind(&kind)?; + let content_type = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok().map(str::to_owned)) + .unwrap_or_default(); + let identity = state + .identity() + .set_image(kind, &body, &content_type) + .map_err(|err| match err { + crate::identity::IdentityError::NotAnImage(_) + | crate::identity::IdentityError::TooLarge(_, _) => bad_request(err), + crate::identity::IdentityError::Backend(_) => internal_error(err), + })?; + Ok(Json( + serde_json::to_value(&identity).map_err(internal_error)?, + )) +} + +/// `GET /v1/agent/{image}`: the image bytes back, content type with +/// them. 404 when unset — the identity document's booleans are how a +/// client knows before asking. +async fn agent_image_get( + State(state): State, + Path(kind): Path, +) -> Result)> { + let kind = image_kind(&kind)?; + match state.identity().image(kind).map_err(internal_error)? { + Some((bytes, content_type)) => { + Ok(([(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response()) + } + None => Err(( + StatusCode::NOT_FOUND, + Json(json!({"error": "no image set"})), + )), + } +} + +/// `DELETE /v1/agent/{image}`: drop the image. Answers the identity. +async fn agent_image_delete( + State(state): State, + Path(kind): Path, +) -> Result, (StatusCode, Json)> { + let kind = image_kind(&kind)?; + let identity = state.identity().clear_image(kind).map_err(internal_error)?; + Ok(Json( + serde_json::to_value(&identity).map_err(internal_error)?, + )) +} + +fn image_kind(name: &str) -> Result)> { + match name { + "avatar" => Ok(crate::identity::ImageKind::Avatar), + "logo" => Ok(crate::identity::ImageKind::Logo), + _ => Err(bad_request(format!("unknown identity image {name:?}"))), + } +} + +/// The 400 shape: the request named a kind or fields the registry +/// cannot construct. +fn bad_request(reason: impl std::fmt::Display) -> (StatusCode, Json) { + ( + StatusCode::BAD_REQUEST, + Json(json!({"error": reason.to_string()})), + ) +} + +/// The 500 shape: the daemon's own machinery failed — no upstream +/// exists for these routes, so a 502 would misname the failure. +fn internal_error(err: impl std::fmt::Display) -> (StatusCode, Json) { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": err.to_string()})), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_wire_shape_of_a_session_entry_is_stable() { + // The sessions route serializes KeyState/Outcome straight from + // the dispatcher's vocabulary; pin the spellings clients see. + assert_eq!(serde_json::to_value(KeyState::Idle).unwrap(), "idle"); + assert_eq!( + serde_json::to_value(KeyState::Reacquire).unwrap(), + "reacquire" + ); + assert_eq!( + serde_json::to_value(rhema_rsd::Outcome::Handled).unwrap(), + "Handled" + ); + } + + #[test] + fn an_ingest_result_reads_as_the_wire_tag() { + assert_eq!(ingest_tag(IngestResult::DebouncePending), "dispatched"); + assert_eq!(ingest_tag(IngestResult::DeliveredLive), "live"); + assert_eq!(ingest_tag(IngestResult::Duplicate), "duplicate"); + } + + #[test] + fn a_dedup_id_is_stable_per_coordinates_and_moves_with_the_payload() { + let payload = json!({"text": "hello"}); + let first = dedup_id("phone:ana", &payload); + assert_eq!(first, dedup_id("phone:ana", &payload), "same post, same id"); + assert_ne!( + first, + dedup_id("phone:ana", &json!({"text": "hello again"})), + "a different payload re-ingests" + ); + assert_ne!( + first, + dedup_id("phone:bob", &payload), + "a different key re-ingests" + ); + } + + #[test] + fn the_bearer_comparison_agrees_with_equality() { + for (presented, expected, matches) in [ + ("sekrit", "sekrit", true), + ("", "", true), + ("sekrit", "sekri", false), + ("sekri", "sekrit", false), + ("sekrit", "sekritx", false), + ("wrong", "sekrit", false), + ("sékrit", "sekrit", false), + ] { + assert_eq!( + tokens_match(presented, expected), + matches, + "{presented:?} vs {expected:?}" + ); + } + } + + #[test] + fn the_daemons_own_source_names_are_reserved() { + for name in ["github", "gitea", "gitlab", "user", "tasks"] { + assert!(is_reserved_source(name), "{name} is reserved"); + } + for name in ["phone", "weather", "telegram"] { + assert!(!is_reserved_source(name), "{name} is free"); + } + } +} diff --git a/crates/rhema-claw/src/hub.rs b/crates/rhema-claw/src/hub.rs new file mode 100644 index 0000000..c59a2da --- /dev/null +++ b/crates/rhema-claw/src/hub.rs @@ -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, + /// The command line a shell tool ran, parsed from the call's + /// streamed arguments; `None` for every other tool. + command: Option, + }, + /// 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, + /// `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, + }, +} + +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, + sender: broadcast::Sender, +} + +/// The per-key record and broadcast of [`TurnEvent`]s. +pub struct EventHub { + entries: Mutex>, + capacity: usize, + /// Where events append as they stream; `None` keeps the record + /// memory-only (tests, and any assembly without a database). + record_store: Option>, +} + +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) -> 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 { + 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, broadcast::Receiver) { + 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, broadcast::Receiver) { + 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, + 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); + } +} diff --git a/crates/rhema-claw/src/identity.rs b/crates/rhema-claw/src/identity.rs new file mode 100644 index 0000000..695a3c6 --- /dev/null +++ b/crates/rhema-claw/src/identity.rs @@ -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, + /// The glyph or emoji in the rail, the favicon, the empty states. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mark: Option, + /// Used when there is no mark and no avatar. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub initials: Option, + /// One line under the name on the agent pane. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tagline: Option, + /// Appended to messages the agent sends outside the interface. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signoff: Option, + /// 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, + pub mark: Option, + pub initials: Option, + pub tagline: Option, + pub signoff: Option, +} + +/// 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, + dir: PathBuf, +} + +impl IdentityStore { + /// Opens the store over `settings`, keeping images in `dir` + /// (created on first write). + pub fn new(settings: Arc, dir: impl Into) -> Self { + Self { + settings, + dir: dir.into(), + } + } + + /// The current identity; unset fields read as `None`. + pub fn get(&self) -> Result { + 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 { + 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 { + 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, 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 { + 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, 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, IdentityError> { + Ok(self + .settings + .get(&text_key(field)) + .map_err(|err| IdentityError::Backend(err.to_string()))? + .and_then(|json| serde_json::from_str::>(&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, + 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 { + 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 { + 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, workspace: impl Into) -> 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"", "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}" + ); + } + } +} diff --git a/crates/rhema-claw/src/interactive.rs b/crates/rhema-claw/src/interactive.rs new file mode 100644 index 0000000..dd37208 --- /dev/null +++ b/crates/rhema-claw/src/interactive.rs @@ -0,0 +1,1671 @@ +//! The resident handler: one dispatch of a resident-context key is one +//! agent turn, and the turn's final text is the reply. +//! +//! **The prefix contract** — the one split between the two handlers on +//! the shared store, documented here and nowhere else: every key the +//! resident dispatcher's handler answers to starts with one of +//! [`RESIDENT_PREFIXES`] — `interactive:` for the live chat +//! surface, `task:` for scheduled tasks, `phone:` for the +//! phone client's sessions, `calendar:` for calendar reminders. +//! Forge keys (they carry `#`) belong to the forge handler. +//! [`is_resident_key`] is the contract's only arbiter; the HTTP +//! surface and the source key functions defer to it. +//! +//! The live interactive surface never resolves keys (spec §6.2): every +//! message on `interactive:` belongs to that session by +//! construction — the source's key function is the payload's `key` +//! verbatim, and the handler's only classification is "none". One +//! dispatch drains the inbox and folds every drained text into ONE user +//! input (the spec §3 batching rule), so a burst of quick messages costs +//! one reasoning step, not one per message. +//! +//! **The delivery rule:** `interactive:` keys reply into the live chat +//! surface (the event hub's stream). Task, phone, and calendar keys +//! have no chat surface to reply into, so the turn's final text +//! auto-notifies the key's device — `phone` unless the key names one — +//! through the notification table ([`crate::notifications`]); the +//! `notify` tool remains the model's explicit channel, this is the +//! implicit one. +//! +//! Continuity rides the dispatcher exactly as the forge handler's does: +//! the whole [`rhema_core::Session`] is stashed into +//! `ctx.session.core.driver_state` before the run returns and restored +//! from there on the next dispatch (`Null` means fresh). The session is +//! the record of the conversation — the context the model returns to — +//! and the hub's record is the transcript: the stream is replayable, +//! the live deltas are the only best-effort part left. 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 before the turn — safe tools re-run, +//! the rest report interrupted. +//! +//! Deterministic setup first (spec §1, §8): the allowlist — permissive, +//! v1's starting point — and the resolver chain (the shell toolset over +//! the interactive workspace, the memory tools, `bootstrap_save`, the +//! notify tool, the task tools, and the watch tools) are assembled +//! before the first model call; the model never decides its own +//! permissions. The system prompt is rendered per dispatch from the +//! bootstrap template over the live SOUL/USER files. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use rhema_agent_tools::{AllowAll, ExpandPastes, ShellTools, revive_dangling_calls}; +use rhema_autonomous_agent::{Allowlist, Handler, ResolverChain, SessionContext, async_trait}; +use rhema_core::{ + Agent, ChatParams, Content, Event as ModelEvent, EventSink, Message, ModelProvider, Role, + Session, ToolContext, ToolResolver, run_turn, +}; +use rhema_memory::{MemoryResolver, MemoryStore, Scope}; +use rhema_rsd::{Dispatcher, Event, KeyFn, Outcome, ResourceKey}; +use serde_json::{Value, json}; + +use crate::backends::BackendRegistry; +use crate::bootstrap::{Bootstrap, DEFAULT_SYSTEM_MD, render_system}; +use crate::hub::{EventHub, TurnEvent}; +use crate::notifications::NotificationStore; +use crate::watch_store::ClawStore; + +/// The key prefixes of the resident context (see the module docs): +/// `interactive:`, `task:`, `phone:`, and `calendar:`. Forge keys — +/// they carry `#` — belong to the forge handler; these four and +/// nothing else belong to this one. +pub const RESIDENT_PREFIXES: [&str; 4] = ["interactive:", "task:", "phone:", "calendar:"]; + +/// The prefix of the live chat surface's keys: `interactive:` — +/// the name is the caller's label for the session (`web`, a chat +/// window, a script). The OpenAI shim speaks this prefix only. +pub const KEY_PREFIX: &str = "interactive:"; + +/// The source name the interactive messages ingest under. +pub const SOURCE: &str = "user"; + +/// The title an auto-notified reply carries: the resident's name — the +/// reply itself is the body, the key it came from is the session. +const AUTO_NOTIFY_TITLE: &str = "claw"; + +/// Whether `key` belongs to the resident context ([`RESIDENT_PREFIXES`]) +/// — the check the messages route and the generic ingest gate keys on. +pub fn is_resident_key(key: &str) -> bool { + RESIDENT_PREFIXES + .iter() + .any(|prefix| key.starts_with(prefix)) +} + +/// The notification device a resident key replies to: the suffix a +/// `phone:` key names (`phone:desk` → `desk`), +/// [`DEFAULT_DEVICE`](crate::notifications::DEFAULT_DEVICE) otherwise — +/// `task:` and `calendar:` keys always notify the phone. +fn notify_device(key: &str) -> &str { + key.strip_prefix("phone:") + .filter(|device| !device.is_empty()) + .unwrap_or(crate::notifications::DEFAULT_DEVICE) +} + +/// The passthrough key function the resident context's sources share +/// (`user`, `tasks`, and every generic `/v1/ingest` source): the +/// payload's `key` verbatim — one key, one owned session (spec §6.2) — +/// with the infallible unique-key fallback the forge sources use, so a +/// keyless payload can neither clobber a session nor wedge the +/// dispatcher; it lands in the resident context's unroutable slot. +pub fn passthrough_key_fn() -> Arc { + Arc::new(|event: &Event| { + event + .payload + .get("key") + .and_then(Value::as_str) + .map(ResourceKey::from) + .unwrap_or_else(|| ResourceKey::from(format!("{KEY_PREFIX}unroutable#{}", event.id))) + }) +} + +/// Registers the interactive source on its dispatcher. Run once, before +/// the first ingest. +pub fn register_user(dispatcher: &Dispatcher) { + dispatcher.register_source(SOURCE, passthrough_key_fn()); +} + +/// The event one chat message becomes. The id is unique per message (a +/// wall-clock millisecond plus a process counter), so a redelivery +/// never dedups a different message away. +pub fn user_event(key: &str, text: &str) -> Event { + static MESSAGE_SEQ: AtomicU64 = AtomicU64::new(0); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + Event { + id: format!( + "msg-{}-{:04x}", + now.as_millis(), + MESSAGE_SEQ.fetch_add(1, Ordering::Relaxed) + ), + source: SOURCE.to_owned(), + payload: json!({ "key": key, "text": text }), + received_at: now.as_millis() as u64, + } +} + +/// The resident agent: a provider, a model, one shared workspace, the +/// bootstrap store its identity files live in, the watch table its +/// tools manage, the notification table it reaches devices through, and +/// the hub its turns stream through. +pub struct InteractiveHandler { + /// The deployment's providers and the active pair, read per dispatch + /// so a runtime switch is live on the next turn. + backends: Arc, + workspace: PathBuf, + tools: Arc, + watch_resolver: Arc, + /// The identity tools: interactive-only by construction — the forge + /// handler never resolves them, so a forge comment cannot reach the + /// accounts. + accounts_resolver: Arc, + /// The one tool that acts with a stored credential: the secret is + /// injected into a command's environment and never returns. + identity_exec: Arc, + notify_resolver: Arc, + task_resolver: Arc, + /// The durable cross-session store the memory tools act on; the + /// resolver is built per dispatch so its scope tracks the workspace. + memory: Arc, + /// The identity files: the system prompt renders from them per + /// dispatch, and the `bootstrap_save` resolver writes them. + bootstrap: Arc, + bootstrap_resolver: Arc, + hub: Arc, + /// The notification table: the `notify` tool writes it, and + /// non-interactive replies auto-notify through it. + notifications: Arc, + /// The consent table the `ask_secret` tool asks through and the + /// consent routes answer into. + consents: Arc, + /// The wake bridge between those routes and the tools waiting on + /// their rows. + bridge: Arc, + /// The presentation-identity tool: the same store the settings + /// pages write, offered to the model as `identity_set`. + identity_resolver: Arc, + /// The attachment store the `attach_file` tool copies into and the + /// fetch route serves from. + attachments: Arc, + /// The phone state and command queue the `phone` tool reads and + /// queues through. + phone: Arc, + /// The coding-agent tool: instances and async jobs the resident + /// drives a full rhema-coding-agent session through. + job_resolver: Arc, + /// How many tool rounds one turn may run before it is cut off — the + /// config's runaway guard for an agent meant to work unattended; a + /// follow-up message keeps going. + max_turn_steps: u32, +} + +impl InteractiveHandler { + /// A handler whose chat turns run in `workspace` (the interactive + /// scratch space under the daemon's workspace root), whose watch + /// tools mutate `watches`, whose notifications land in + /// `notifications`, whose task tools manage `tasks`, and whose + /// `coding_agent` tool enqueues jobs into `jobs`. Approval is + /// v1-permissive — the shell is the point of a resident coding + /// agent — installed once, since resolution is synchronous within a + /// turn. + #[allow(clippy::too_many_arguments)] // sixteen parts, each load-bearing + pub fn new( + backends: Arc, + workspace: impl Into, + watches: Arc, + memory: Arc, + bootstrap: Arc, + hub: Arc, + notifications: Arc, + tasks: Arc, + jobs: Arc, + accounts: Arc, + max_turn_steps: u32, + consents: Arc, + bridge: Arc, + attachments: Arc, + phone: Arc, + identity: Arc, + ) -> Self { + let workspace = workspace.into(); + let tools = Arc::new(ShellTools::new(&workspace)); + tools.install_approval(Arc::new(AllowAll)); + let watch_resolver = + crate::watch_tools::registry(watches.clone()).resolver_for(&Allowlist::permissive()); + let accounts_resolver = crate::accounts_tools::registry(accounts.clone(), watches) + .resolver_for(&Allowlist::permissive()); + let identity_exec: Arc = Arc::new( + crate::accounts_tools::IdentityExec::new(accounts, workspace.clone()), + ); + let notify_resolver = crate::notify_tools::registry(notifications.clone()) + .resolver_for(&Allowlist::permissive()); + let task_resolver = + crate::task_tools::registry(tasks).resolver_for(&Allowlist::permissive()); + let job_resolver = crate::job_tools::registry(jobs).resolver_for(&Allowlist::permissive()); + let bootstrap_resolver = + crate::bootstrap::registry(bootstrap.clone()).resolver_for(&Allowlist::permissive()); + let identity_resolver = crate::identity::registry(identity.clone(), workspace.clone()) + .resolver_for(&Allowlist::permissive()); + Self { + backends, + workspace, + tools, + job_resolver, + watch_resolver, + accounts_resolver, + identity_exec, + notify_resolver, + task_resolver, + memory, + bootstrap, + bootstrap_resolver, + identity_resolver, + hub, + notifications, + consents, + bridge, + attachments, + phone, + max_turn_steps, + } + } + + /// The agent for one dispatch: rebuilt per run over the restored + /// session (the engine's own step counter is per-agent; the + /// conversation lives in the session, not the agent). The system + /// prompt is re-rendered per dispatch, so a SOUL.md or USER.md the + /// last turn wrote is live this turn, the onboarding section is + /// gone the moment both files exist, and the tool roster is the + /// dispatch chain's own specs — what the prompt lists is what the + /// request advertises. + fn agent(&self, key: &ResourceKey) -> Result { + let (provider, model) = self.backends.current(); + // Chat input may carry embedded pastes — the compact chip form + // the UIs mint and the session keeps — so the provider wraps in + // the same expansion the coding driver wraps its own with: the + // model sees plain text, the transcript keeps the chips. + let provider: Arc = Arc::new(ExpandPastes::new(provider)); + let resolver = self.resolver(key); + let system = render_system( + DEFAULT_SYSTEM_MD, + key.as_str(), + &self.workspace, + &self.bootstrap, + &resolver.specs(), + ) + .map_err(|err| format!("cannot render the bootstrap system prompt: {err}"))?; + Agent::builder() + .provider(provider) + .resolver(resolver) + .system(system) + .params(ChatParams::new(model)) + .build() + .map_err(|err| format!("cannot build the interactive agent: {err}")) + } + + /// The resolver chain one dispatch runs under: the shell toolset + /// over the interactive workspace, then the resident toolsets. The + /// consent and attachment tools build per dispatch — their asks and + /// cards must land on the session that asked, and the handler + /// serves every session. + fn resolver(&self, key: &ResourceKey) -> Arc { + Arc::new(ResolverChain::new(vec![ + self.tools.clone(), + Arc::new(MemoryResolver::new( + self.memory.clone(), + Scope::Workspace(self.workspace.clone()), + )), + self.bootstrap_resolver.clone(), + self.notify_resolver.clone(), + self.task_resolver.clone(), + self.job_resolver.clone(), + self.watch_resolver.clone(), + self.accounts_resolver.clone(), + self.identity_exec.clone(), + self.identity_resolver.clone(), + crate::consents::registry( + self.consents.clone(), + self.bridge.clone(), + self.hub.clone(), + key.clone(), + ) + .resolver_for(&Allowlist::permissive()), + crate::attachments::registry( + self.attachments.clone(), + self.workspace.clone(), + self.hub.clone(), + key.clone(), + ) + .resolver_for(&Allowlist::permissive()), + crate::phone::registry( + self.phone.clone(), + self.consents.clone(), + self.bridge.clone(), + self.hub.clone(), + key.clone(), + ) + .resolver_for(&Allowlist::permissive()), + ])) + } + + /// One dispatch: drain, restore, turn, stash, reply. + async fn dispatch(&self, ctx: &mut SessionContext) -> Result, String> { + let key = ctx.key.as_str().to_owned(); + // Renews the lease and drains: every text since the last poll, + // folded into one input (spec §3's batching rule). Chat events + // carry `text`, scheduled tasks carry `prompt` — both are turn + // input, and a mixed batch folds in arrival order. + let items = ctx + .poll_inbox() + .await + .map_err(|err| format!("inbox poll for {key} failed: {err}"))?; + let input = items + .iter() + .filter_map(|item| { + item.event + .payload + .get("text") + .or_else(|| item.event.payload.get("prompt")) + .and_then(Value::as_str) + }) + .collect::>() + .join("\n\n"); + if input.trim().is_empty() { + return Err(format!("dispatch for {key} carried no text")); + } + // The transcript's question, recorded before anything else can + // stream: a replay shows what was asked, not only what was + // answered. Raw input — the notification-contract framing below + // is the model's business, not the viewer's. + self.hub.emit( + &ctx.key, + TurnEvent::UserMessage { + text: input.clone(), + }, + ); + // A non-chat resident key has no subscriber to read the reply: + // say so in the input, so the model writes for the notification + // the final message becomes. A chat key woken by its own + // schedule (the `prompt` payload field, never a typed message) + // is told why it fired: the reply lands in the conversation, and + // the user away from it needs notify. + let scheduled = items + .iter() + .any(|item| item.event.payload.get("prompt").is_some()); + let input = if is_resident_key(&key) && !key.starts_with(KEY_PREFIX) { + format!( + "[Turn on {key}: this session has no live chat surface — \ + your final message is delivered to the user as a \ + notification.]\n\n{input}" + ) + } else if scheduled { + format!( + "[Scheduled wake: this turn fired from your own \ + schedule_task, not from a chat message. The reply lands \ + in this conversation; reach the user away from it with \ + notify if they must see this now.]\n\n{input}" + ) + } else { + input + }; + + let mut session = restore_session(&ctx.session.core.driver_state, &key)?; + let resolver = self.resolver(&ctx.key); + let mut agent = self.agent(&ctx.key)?; + // Commit-before-execute: the intent is in the store before the + // first tool runs — and if the lease cannot be committed under + // (expired, lost), the turn fails before any side effect. A + // crash-left dangling call revives first: safe tools re-run, the + // rest report interrupted. + agent.set_commit_hook(Some(ctx.commit_hook())); + let tools = ToolContext { + cwd: self.workspace.clone(), + run_id: key.clone(), + cancelled: Arc::new(AtomicBool::new(false)), + }; + revive_dangling_calls(&mut session, resolver.as_ref(), &tools).await; + let mut sink = HubSink { + key: ctx.key.clone(), + hub: self.hub.clone(), + tool_names: HashMap::new(), + tool_args: HashMap::new(), + }; + let output = run_turn( + &mut agent, + &mut session, + Message::text(Role::User, input), + &mut sink, + &tools, + self.max_turn_steps, + ) + .await + .map_err(|err| format!("turn for {key} failed: {err}"))?; + + // Stash before anything else: the conversation survives even if + // the subscriber never sees the stream. + ctx.session.core.driver_state = serde_json::to_value(&session) + .map_err(|err| format!("session for {key} does not serialize: {err}"))?; + Ok(final_text(&output.message)) + } +} + +#[async_trait] +impl Handler for InteractiveHandler { + async fn handle(&self, ctx: &mut SessionContext) -> Outcome { + let key = ctx.key.clone(); + match self.dispatch(ctx).await { + Ok(reply) => { + self.auto_notify(&key, reply.as_deref()); + self.hub.emit( + &key, + TurnEvent::TurnDone { + reply, + outcome: "handled".to_owned(), + }, + ); + Outcome::Handled + } + Err(reason) => { + // The end marker matters most when the run failed: a + // stream that just goes quiet is indistinguishable from + // a hang. + self.hub.emit( + &key, + TurnEvent::TurnDone { + reply: None, + outcome: "failed".to_owned(), + }, + ); + Outcome::Failed(reason) + } + } + } +} + +impl InteractiveHandler { + /// The auto-notify contract (see the module docs): a non-chat + /// resident key's reply has no subscriber, so the final text posts + /// as a notification on the key's device — `phone` unless the key + /// names one. A failed push is logged, never failed over: the turn + /// ran, and a re-dispatch would find an empty inbox. Interactive + /// keys and reply-less turns notify nothing. + fn auto_notify(&self, key: &ResourceKey, reply: Option<&str>) { + if key.as_str().starts_with(KEY_PREFIX) { + return; + } + let Some(text) = reply else { + return; + }; + let device = notify_device(key.as_str()); + if let Err(err) = self.notifications.push(device, AUTO_NOTIFY_TITLE, text) { + eprintln!("[claw] warning: auto-notify for {key} failed: {err}"); + } + } +} + +/// The stashed conversation: a serialized [`Session`], `Null` meaning +/// fresh. A stash that does not parse is loud — silently resetting would +/// re-orient the agent mid-conversation without anyone knowing. +fn restore_session(stashed: &Value, key: &str) -> Result { + match stashed { + Value::Null => Ok(Session::default()), + stashed => serde_json::from_value(stashed.clone()) + .map_err(|err| format!("stashed session for {key} does not parse: {err}")), + } +} + +/// The text a turn ends on: the assistant message's text parts, joined +/// and trimmed. `None` when the model ended without writing a reply. +fn final_text(message: &Message) -> Option { + let joined = message + .content + .iter() + .filter_map(|content| match content { + Content::Text(text) => Some(text.as_str()), + Content::ToolCall(_) | Content::ToolResult(_) => None, + }) + .collect::>() + .join(""); + let trimmed = joined.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} + +/// A tool result's bounded summary for the stream: the first line, cut +/// at 72 characters like the daemon's own log lines. +fn snippet(content: &str) -> String { + let line = content.lines().next().unwrap_or(""); + let mut cut = line.chars().take(72).collect::(); + if line.chars().count() > 72 { + cut.push('…'); + } + cut +} + +/// Maps the engine's events onto the hub, live. Tool names ride the +/// `ToolCallStart` the engine guarantees before each result; a name the +/// sink somehow never heard falls back to the call id rather than an +/// unnamed event. +struct HubSink { + key: ResourceKey, + hub: Arc, + tool_names: HashMap, + /// The arguments streamed per call id. A shell result's command line + /// is only known once the call's args have fully streamed, and a + /// result event carries none of them — so the sink keeps what the + /// deltas delivered and parses it when the result lands. + tool_args: HashMap, +} + +/// The shell command a `bash` call ran, parsed out of the streamed +/// arguments JSON. A call whose args never streamed, or that parses to +/// something without a string `command`, renders without one — the TUI's +/// transcript makes the same bargain with the same deltas. +fn shell_command(args: Option<&str>) -> Option { + let parsed = serde_json::from_str::(args?).ok()?; + parsed.get("command")?.as_str().map(str::to_owned) +} + +impl EventSink for HubSink { + fn emit(&mut self, event: ModelEvent<'_>) { + let turn = match event { + ModelEvent::ThinkingDelta(text) => TurnEvent::ThinkingDelta { + text: text.to_owned(), + }, + ModelEvent::TextDelta(text) => TurnEvent::TextDelta { + text: text.to_owned(), + }, + ModelEvent::ToolCallStart { id, name } => { + self.tool_names.insert(id.to_owned(), name.to_owned()); + self.tool_args.insert(id.to_owned(), String::new()); + TurnEvent::ToolStart { + name: name.to_owned(), + } + } + ModelEvent::ToolCallArgsDelta { id, delta } => { + // The id, not the call that started last, decides: the + // wire may interleave parallel calls' fragments. + self.tool_args + .entry(id.to_owned()) + .or_default() + .push_str(delta); + return; + } + ModelEvent::ToolResult { + id, + content, + is_error, + code, + .. + } => { + let name = self + .tool_names + .get(id) + .cloned() + .unwrap_or_else(|| id.to_owned()); + let command = matches!(name.as_str(), "bash" | "run_command") + .then(|| shell_command(self.tool_args.get(id).map(String::as_str))) + .flatten(); + TurnEvent::ToolResult { + name, + is_error, + summary: snippet(content), + code, + command, + } + } + // Step bookkeeping is the engine's business, not the wire's; + // a running tool's live output lands with its result. + ModelEvent::StepStart { .. } + | ModelEvent::ToolOutputDelta { .. } + | ModelEvent::StepEnd { .. } => return, + }; + self.hub.emit(&self.key, turn); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + use crate::notifications::NotificationStore; + use crate::task_store::TaskStore; + + use crate::backends::{Backend, BackendRegistry}; + use rhema_core::{ + Content, Message as CoreMessage, MockProvider, ModelOutput, Role as CoreRole, StopReason, + ToolCall, ToolResolver, Usage, + }; + use rhema_rsd::{ + DispatcherConfig, KeyState, Outcome as RsdOutcome, ResourceKey as Key, VolatileStore, + }; + + /// A throwaway notification table, the shape the dispatch tests hand + /// the handler. + fn notifications() -> Arc { + Arc::new(NotificationStore::in_memory().expect("store opens")) + } + + /// A throwaway task table, the shape the dispatch tests hand the + /// handler. + fn tasks() -> Arc { + Arc::new(TaskStore::in_memory().expect("store opens")) + } + + /// A throwaway coding-job store: the `coding_agent` tool's tables. + fn jobs() -> Arc { + Arc::new(crate::job_store::CodingJobStore::in_memory().expect("store opens")) + } + + /// A throwaway account store: the identity tools need something to + /// stand in, and the dispatch tests never touch it. + fn accounts() -> Arc { + Arc::new(rhema_identity::VolatileIdentityStore::new()) + } + + /// A throwaway consent table, the shape the dispatch tests hand the + /// handler. + fn consents() -> Arc { + Arc::new(crate::consents::ConsentStore::in_memory().expect("store opens")) + } + + /// A fresh wake bridge, the shape the dispatch tests hand the + /// handler. + fn bridge() -> Arc { + Arc::new(crate::consents::WaitBridge::new()) + } + + /// A throwaway phone table, the shape the dispatch tests hand the + /// handler. + fn phone() -> Arc { + Arc::new(crate::phone::PhoneStore::in_memory().expect("store opens")) + } + + /// A throwaway identity store over a tempdir, the shape the + /// dispatch tests hand the handler. + fn identity() -> Arc { + static ROOT: std::sync::OnceLock = std::sync::OnceLock::new(); + let root = ROOT.get_or_init(|| { + let dir = std::env::temp_dir().join("rhema-claw-test-identity"); + let _ = std::fs::remove_dir_all(&dir); + dir + }); + Arc::new(crate::identity::IdentityStore::new( + Arc::new(crate::settings::SettingsStore::in_memory().expect("store opens")), + root, + )) + } + + /// A throwaway attachment store under one shared temp root: the + /// dispatch tests never read it back, and the store must outlive + /// the handlers it is handed to. + fn attachments() -> Arc { + static ROOT: std::sync::OnceLock = std::sync::OnceLock::new(); + let root = ROOT.get_or_init(|| { + let dir = std::env::temp_dir().join("rhema-claw-test-attachments"); + let _ = std::fs::remove_dir_all(&dir); + dir + }); + Arc::new(crate::attachments::AttachmentStore::open(root).expect("store opens")) + } + + fn test_registry(provider: Arc) -> Arc { + Arc::new( + BackendRegistry::new(vec![Backend::headless("mock", provider)], None) + .expect("a single mock backend always constructs"), + ) + } + + fn reply(text: &str) -> ModelOutput { + ModelOutput { + message: CoreMessage::text(CoreRole::Assistant, text), + stop_reason: StopReason::EndTurn, + usage: Usage::default(), + } + } + + /// A provider that keeps every request it is handed, so a test can + /// assert on what the model would have seen. + struct RecordingProvider { + requests: std::sync::Mutex>, + } + + impl RecordingProvider { + fn new() -> Arc { + Arc::new(Self { + requests: std::sync::Mutex::new(Vec::new()), + }) + } + + fn last_user_text(&self) -> String { + let requests = self.requests.lock().unwrap(); + let last = requests.last().expect("a request was made"); + last.messages + .iter() + .rev() + .find(|message| message.role == CoreRole::User) + .and_then(|message| match &message.content[0] { + Content::Text(text) => Some(text.clone()), + _ => None, + }) + .expect("a user text") + } + } + + #[async_trait] + impl rhema_core::ModelProvider for RecordingProvider { + async fn complete( + &self, + request: rhema_core::ModelRequest, + _sink: &mut dyn rhema_core::EventSink, + ) -> Result { + self.requests.lock().unwrap().push(request); + Ok(reply("noted.")) + } + } + + /// The paste contract across the provider seam: a chat input that + /// carries an embedded paste (the compact form the UIs mint) is + /// expanded before the request leaves, while the transcript the hub + /// streams keeps the compact form. + #[tokio::test] + async fn an_embedded_paste_expands_for_the_model_and_not_for_the_transcript() { + let recorder = RecordingProvider::new(); + let store = Arc::new(ClawStore::in_memory().expect("store opens")); + let hub = Arc::new(EventHub::default()); + let workspace = tempfile::tempdir().expect("tempdir"); + let handler = InteractiveHandler::new( + test_registry(recorder.clone()), + workspace.path().join("interactive"), + store, + Arc::new(rhema_memory::VolatileStore::new()), + Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), + hub.clone(), + notifications(), + tasks(), + jobs(), + accounts(), + crate::config::DEFAULT_MAX_TURN_STEPS, + consents(), + bridge(), + attachments(), + phone(), + identity(), + ); + let dispatcher = Arc::new(Dispatcher::new( + DispatcherConfig { + debounce_window_millis: 0, + ..DispatcherConfig::default() + }, + Arc::new(rhema_rsd::SystemClock), + Arc::new(VolatileStore::new()), + )); + register_user(&dispatcher); + let driver = rhema_autonomous_agent::Driver::new(dispatcher.clone(), Arc::new(handler)) + .with_holder("test"); + let key = Key::from("interactive:web"); + dispatcher + .ingest(user_event( + key.as_str(), + "review this:\u{FFFC}fn main() {}\nfn other() {}\u{FFFC}", + )) + .expect("ingests"); + driver.run_once().await.expect("the run happens"); + + assert_eq!( + recorder.last_user_text(), + "review this:fn main() {}\nfn other() {}", + "the model saw the payload, not the marks" + ); + let (history, _) = hub.subscribe_with_history(&key); + let saw_input = history.into_iter().find_map(|event| match event { + TurnEvent::UserMessage { text } => Some(text), + _ => None, + }); + assert!( + saw_input + .expect("the transcript saw the question") + .contains('\u{FFFC}'), + "the transcript keeps the compact chip form" + ); + } + + /// A scripted tool-call turn: the model asking for `name` with `args`. + fn scripted_call(id: &str, name: &str, args: serde_json::Value) -> ModelOutput { + ModelOutput { + message: CoreMessage { + role: CoreRole::Assistant, + content: vec![Content::ToolCall(ToolCall { + id: id.to_owned(), + name: name.to_owned(), + args, + provider_data: serde_json::Value::Null, + })], + }, + stop_reason: StopReason::ToolUse, + usage: Usage::default(), + } + } + + #[test] + fn the_prompt_renders_the_dispatch_chain_and_the_workspace() { + // The real chain, the shape a dispatch runs under: the prompt + // must name every tool the chain advertises, with its schema — + // what the prompt lists is what the request carries. + let workspace = tempfile::tempdir().expect("tempdir"); + let handler = InteractiveHandler::new( + test_registry(Arc::new(MockProvider::new([reply("hi back")]))), + workspace.path().join("interactive"), + Arc::new(ClawStore::in_memory().expect("store opens")), + Arc::new(rhema_memory::VolatileStore::new()), + Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), + Arc::new(EventHub::default()), + notifications(), + tasks(), + jobs(), + accounts(), + crate::config::DEFAULT_MAX_TURN_STEPS, + consents(), + bridge(), + attachments(), + phone(), + identity(), + ); + let specs = handler.resolver(&Key::from("interactive:web")).specs(); + assert!( + specs.iter().any(|spec| spec.name == "bash"), + "the shell toolset is in the chain: {specs:?}" + ); + let prompt = render_system( + DEFAULT_SYSTEM_MD, + "interactive:web", + Path::new("/state/claw/workspaces/interactive"), + &handler.bootstrap, + &specs, + ) + .expect("renders"); + assert!( + prompt.contains("session's key is interactive:web"), + "the model learns the address it schedules itself at: {prompt}" + ); + assert!( + prompt.contains("/state/claw/workspaces/interactive"), + "{prompt}" + ); + for spec in &specs { + assert!( + prompt.contains(&spec.name), + "missing {}: {prompt}", + spec.name + ); + assert!( + prompt.contains(&spec.description), + "missing {}'s description: {prompt}", + spec.name + ); + } + assert!( + prompt.contains(r#""required": ["command"]"#), + "bash's schema renders: {prompt}" + ); + assert!(prompt.contains("final message"), "{prompt}"); + // A fresh store is pending onboarding: the offered setup renders. + assert!(prompt.contains("## Setup"), "{prompt}"); + assert!(prompt.contains("(pending onboarding)"), "{prompt}"); + } + + #[test] + fn the_prefix_contract_splits_the_resident_context_from_the_forge() { + for key in ["interactive:web", "task:5", "phone:ana", "phone:"] { + assert!(is_resident_key(key), "{key} is resident"); + } + for key in [ + "github:acme/widgets#7", + "gitea:acme/barn#1", + "interactive", + "tasks", + "task", + ] { + assert!(!is_resident_key(key), "{key} is not resident"); + } + } + + #[test] + fn a_phone_key_names_its_device_and_a_task_key_notifies_the_phone() { + assert_eq!(notify_device("task:5"), "phone"); + assert_eq!(notify_device("phone:desk"), "desk"); + assert_eq!(notify_device("phone:"), "phone", "no suffix, the default"); + assert_eq!(notify_device("interactive:web"), "phone", "never reached"); + } + + #[test] + fn user_events_carry_the_key_and_unique_ids() { + let first = user_event("interactive:web", "hello"); + let second = user_event("interactive:web", "again"); + assert_eq!(first.source, "user"); + assert_eq!(first.id, first.id, "stable within itself"); + assert_ne!(first.id, second.id, "messages never share a dedup id"); + assert_eq!( + first.payload, + json!({"key": "interactive:web", "text": "hello"}) + ); + } + + #[test] + fn the_passthrough_key_fn_reads_the_payload_verbatim() { + let dispatcher = Dispatcher::new( + DispatcherConfig::default(), + Arc::new(rhema_rsd::SystemClock), + Arc::new(VolatileStore::new()), + ); + register_user(&dispatcher); + let event = user_event("interactive:web", "hello"); + // Ingesting resolves the key through the registered fn: the + // record lands under exactly the payload's key. + dispatcher.ingest(event).expect("ingests"); + let status = dispatcher + .status(&Key::from("interactive:web")) + .expect("status reads"); + assert_eq!(status.inbox_depth, 1); + + // A keyless payload still routes, to its own unparsable slot. + dispatcher + .ingest(Event { + id: "lonely".into(), + source: "user".into(), + payload: json!({ "text": "no key" }), + received_at: 1, + }) + .expect("ingests"); + assert!( + dispatcher + .status(&Key::from("interactive:unroutable#lonely")) + .expect("status reads") + .inbox_depth + >= 1 + ); + } + + #[test] + fn the_final_text_joins_and_trims_but_not_tool_calls() { + let message = Message { + role: Role::Assistant, + content: vec![ + Content::Text("on it".into()), + Content::Text(" — done.".into()), + ], + }; + assert_eq!(final_text(&message).as_deref(), Some("on it — done.")); + let empty = Message { + role: Role::Assistant, + content: vec![Content::Text(" ".into())], + }; + assert_eq!(final_text(&empty), None); + } + + #[test] + fn snippets_cut_the_first_line_at_72_characters() { + assert_eq!(snippet("exit code 0\n--- stdout ---"), "exit code 0"); + let long = "x".repeat(100); + let cut = snippet(&long); + assert_eq!(cut.chars().count(), 73, "72 plus the ellipsis: {cut}"); + assert!(cut.ends_with('…')); + } + + /// A bash result carries its command line and exit code on the wire: + /// the sink parses the command out of the call's streamed arguments. + #[test] + fn a_bash_result_streams_its_command_and_exit_code() { + let hub = Arc::new(EventHub::default()); + let key = Key::from("interactive:web"); + let mut receiver = hub.subscribe(&key); + let mut sink = HubSink { + key: key.clone(), + hub: hub.clone(), + tool_names: HashMap::new(), + tool_args: HashMap::new(), + }; + sink.emit(ModelEvent::ToolCallStart { + id: "c1", + name: "bash", + }); + sink.emit(ModelEvent::ToolCallArgsDelta { + id: "c1", + delta: r#"{"command": "ls""#, + }); + sink.emit(ModelEvent::ToolCallArgsDelta { + id: "c1", + delta: r#", "quiet": true}"#, + }); + sink.emit(ModelEvent::ToolResult { + id: "c1", + content: "exit 0\n--- stdout ---", + is_error: false, + code: Some(0), + }); + assert_eq!( + receiver.try_recv().unwrap(), + TurnEvent::ToolStart { + name: "bash".into() + } + ); + assert_eq!( + receiver.try_recv().unwrap(), + TurnEvent::ToolResult { + name: "bash".into(), + is_error: false, + summary: "exit 0".into(), + code: Some(0), + command: Some("ls".into()), + } + ); + } + + /// A tool whose args never streamed, or that is not a shell, reports + /// no command — the summary still rides. + #[test] + fn a_result_without_streamed_args_or_a_shell_name_carries_no_command() { + let hub = Arc::new(EventHub::default()); + let key = Key::from("interactive:web"); + let mut sink = HubSink { + key: key.clone(), + hub: hub.clone(), + tool_names: HashMap::new(), + tool_args: HashMap::new(), + }; + sink.emit(ModelEvent::ToolCallStart { + id: "c1", + name: "memory_save", + }); + sink.emit(ModelEvent::ToolResult { + id: "c1", + content: "saved", + is_error: false, + code: None, + }); + let (history, _) = hub.subscribe_with_history(&key); + assert_eq!( + history.last(), + Some(&TurnEvent::ToolResult { + name: "memory_save".into(), + is_error: false, + summary: "saved".into(), + code: None, + command: None, + }) + ); + } + + #[test] + fn a_null_stash_is_fresh_and_an_unparsable_one_is_loud() { + assert!( + restore_session(&Value::Null, "interactive:web") + .expect("null is fresh") + .history + .is_empty() + ); + let err = restore_session(&json!({"history": "garbage"}), "interactive:web") + .expect_err("garbage is loud"); + assert!(err.contains("does not parse"), "{err}"); + } + + /// The full dispatch, in-process: one message in, one scripted reply + /// out, the turn streamed through the hub, the conversation stashed. + #[tokio::test] + async fn a_dispatch_drains_replies_and_stashes_the_conversation() { + let store = Arc::new(ClawStore::in_memory().expect("store opens")); + let hub = Arc::new(EventHub::default()); + let workspace = tempfile::tempdir().expect("tempdir"); + let handler = InteractiveHandler::new( + test_registry(Arc::new(MockProvider::new([reply("hi back")]))), + workspace.path().join("interactive"), + store, + Arc::new(rhema_memory::VolatileStore::new()), + Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), + hub.clone(), + notifications(), + tasks(), + jobs(), + accounts(), + crate::config::DEFAULT_MAX_TURN_STEPS, + consents(), + bridge(), + attachments(), + phone(), + identity(), + ); + + let dispatcher = Arc::new(Dispatcher::new( + DispatcherConfig { + debounce_window_millis: 0, + ..DispatcherConfig::default() + }, + Arc::new(rhema_rsd::SystemClock), + Arc::new(VolatileStore::new()), + )); + register_user(&dispatcher); + + let key = Key::from("interactive:web"); + let mut receiver = hub.subscribe(&key); + dispatcher + .ingest(user_event(key.as_str(), "hello")) + .expect("ingests"); + + let driver = rhema_autonomous_agent::Driver::new(dispatcher.clone(), Arc::new(handler)) + .with_holder("test"); + let outcomes = driver.run_once().await.expect("the run happens"); + assert_eq!(outcomes, vec![RsdOutcome::Handled]); + + // The stream saw the question, the reply, and the end marker. + assert_eq!( + receiver.try_recv().unwrap(), + TurnEvent::UserMessage { + text: "hello".into() + } + ); + assert_eq!( + receiver.try_recv().unwrap(), + TurnEvent::TextDelta { + text: "hi back".into() + } + ); + assert_eq!( + receiver.try_recv().unwrap(), + TurnEvent::TurnDone { + reply: Some("hi back".into()), + outcome: "handled".into() + } + ); + // The key is back to idle: the dispatch is done. + assert_eq!( + dispatcher.status(&key).expect("status reads").state, + KeyState::Idle + ); + } + + /// Memory rides the resolver chain across dispatches: the model saves + /// in one session turn, and the record is in the durable store for + /// every session after — searchable by the model, never injected. + #[tokio::test] + async fn a_memory_save_in_one_dispatch_is_recalled_in_the_next() { + use rhema_memory::{MEMORY_SAVE, MemoryQuery}; + + let memory = Arc::new(rhema_memory::VolatileStore::new()); + let store = Arc::new(ClawStore::in_memory().expect("store opens")); + let hub = Arc::new(EventHub::default()); + let workspace = tempfile::tempdir().expect("tempdir"); + let workspace_path = workspace.path().join("interactive"); + let handler = InteractiveHandler::new( + test_registry(Arc::new(MockProvider::new([ + scripted_call( + "call-save", + MEMORY_SAVE, + json!({"body": "the release channel is #deploys", "tags": ["release"]}), + ), + reply("remembered."), + scripted_call("call-search", "memory_search", json!({"query": "release"})), + reply("the release channel is #deploys."), + ]))), + workspace_path.clone(), + store, + memory.clone(), + Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), + hub, + notifications(), + tasks(), + jobs(), + accounts(), + crate::config::DEFAULT_MAX_TURN_STEPS, + consents(), + bridge(), + attachments(), + phone(), + identity(), + ); + + let dispatcher = Arc::new(Dispatcher::new( + DispatcherConfig { + debounce_window_millis: 0, + ..DispatcherConfig::default() + }, + Arc::new(rhema_rsd::SystemClock), + Arc::new(VolatileStore::new()), + )); + register_user(&dispatcher); + let driver = rhema_autonomous_agent::Driver::new(dispatcher.clone(), Arc::new(handler)) + .with_holder("test"); + let key = Key::from("interactive:web"); + + // Dispatch 1: the model saves through the chain's memory tools. + dispatcher + .ingest(user_event(key.as_str(), "remember the channel")) + .expect("ingests"); + assert_eq!( + driver.run_once().await.expect("first run"), + vec![RsdOutcome::Handled] + ); + + // The record is in the store, in the handler's workspace scope. + let found = memory + .search(MemoryQuery::new("release")) + .expect("search reads"); + assert!( + found.iter().any(|record| record.body.contains("#deploys")), + "the saved record is durable: {found:?}" + ); + + // Dispatch 2: the model searches the same chain and answers from + // memory — a fresh session turn, the same durable store. + dispatcher + .ingest(user_event(key.as_str(), "where do releases go?")) + .expect("ingests"); + assert_eq!( + driver.run_once().await.expect("second run"), + vec![RsdOutcome::Handled] + ); + assert_eq!( + memory + .search(MemoryQuery::new("#deploys")) + .expect("search reads") + .len(), + 1, + "the search turn did not duplicate the record" + ); + } + + /// The bootstrap tool rides the resolver chain across dispatches, + /// exactly as the memory tools do: the model saves SOUL.md in one + /// turn and USER.md in the next, the files land through the store, + /// and the status the onboarding section renders from flips — gone + /// the moment both files exist. + #[tokio::test] + async fn a_bootstrap_save_in_one_dispatch_flips_the_status_for_the_next() { + use crate::bootstrap::BOOTSTRAP_SAVE; + + let dir = tempfile::tempdir().expect("tempdir"); + let bootstrap = + Arc::new(Bootstrap::new(dir.path().join("bootstrap")).expect("store opens")); + let store = Arc::new(ClawStore::in_memory().expect("store opens")); + let hub = Arc::new(EventHub::default()); + let workspace = tempfile::tempdir().expect("tempdir"); + let handler = InteractiveHandler::new( + test_registry(Arc::new(MockProvider::new([ + scripted_call( + "call-soul", + BOOTSTRAP_SAVE, + json!({"kind": "soul", "content": "I am claw: terse, warm."}), + ), + reply("noted."), + scripted_call( + "call-user", + BOOTSTRAP_SAVE, + json!({"kind": "user", "content": "The user is Ana."}), + ), + reply("setup is done."), + ]))), + workspace.path().join("interactive"), + store, + Arc::new(rhema_memory::VolatileStore::new()), + bootstrap.clone(), + hub, + notifications(), + tasks(), + jobs(), + accounts(), + crate::config::DEFAULT_MAX_TURN_STEPS, + consents(), + bridge(), + attachments(), + phone(), + identity(), + ); + + let dispatcher = Arc::new(Dispatcher::new( + DispatcherConfig { + debounce_window_millis: 0, + ..DispatcherConfig::default() + }, + Arc::new(rhema_rsd::SystemClock), + Arc::new(VolatileStore::new()), + )); + register_user(&dispatcher); + let driver = rhema_autonomous_agent::Driver::new(dispatcher.clone(), Arc::new(handler)) + .with_holder("test"); + let key = Key::from("interactive:web"); + + // Dispatch 1: the model writes SOUL.md through the chain. A key + // with a session continues, so both calls ride one key. + dispatcher + .ingest(user_event(key.as_str(), "call me claw")) + .expect("ingests"); + assert_eq!( + driver.run_once().await.expect("first run"), + vec![RsdOutcome::Handled] + ); + assert_eq!( + bootstrap + .load(crate::bootstrap::BootstrapKind::Soul) + .expect("reads"), + Some("I am claw: terse, warm.".to_owned()) + ); + let status = bootstrap.status().expect("status reads"); + assert!(status.soul); + assert!(!status.user); + assert!(bootstrap.needs_onboarding(), "user is still unwritten"); + + // Dispatch 2: USER.md, same chain — and the pending flag clears. + dispatcher + .ingest(user_event(key.as_str(), "and I am Ana")) + .expect("ingests"); + assert_eq!( + driver.run_once().await.expect("second run"), + vec![RsdOutcome::Handled] + ); + let status = bootstrap.status().expect("status reads"); + assert!(status.soul && status.user); + assert!(!bootstrap.needs_onboarding(), "both files exist"); + } + + /// The stash proof, done properly through the dispatcher's record. + #[tokio::test] + async fn a_dispatch_stashes_the_exchange_in_driver_state() { + use rhema_rsd::Store; + + let store = Arc::new(ClawStore::in_memory().expect("store opens")); + let hub = Arc::new(EventHub::default()); + let workspace = tempfile::tempdir().expect("tempdir"); + let handler = InteractiveHandler::new( + test_registry(Arc::new(MockProvider::new([reply("hi back")]))), + workspace.path().join("interactive"), + store, + Arc::new(rhema_memory::VolatileStore::new()), + Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), + hub, + notifications(), + tasks(), + jobs(), + accounts(), + crate::config::DEFAULT_MAX_TURN_STEPS, + consents(), + bridge(), + attachments(), + phone(), + identity(), + ); + let backing = Arc::new(VolatileStore::new()); + let dispatcher = Arc::new(Dispatcher::new( + DispatcherConfig { + debounce_window_millis: 0, + ..DispatcherConfig::default() + }, + Arc::new(rhema_rsd::SystemClock), + backing.clone(), + )); + register_user(&dispatcher); + let key = Key::from("interactive:web"); + dispatcher + .ingest(user_event(key.as_str(), "hello")) + .expect("ingests"); + let driver = rhema_autonomous_agent::Driver::new(dispatcher, Arc::new(handler)); + driver.run_once().await.expect("the run happens"); + + let record = backing + .load(&key) + .expect("the record loads") + .expect("the key has a record"); + let stashed: Session = + serde_json::from_value(record.session.expect("a session").core.driver_state) + .expect("the stash parses back into a session"); + let roles: Vec<(CoreRole, String)> = stashed + .history + .iter() + .filter_map(|message| match &message.content[0] { + Content::Text(text) => Some((message.role, text.clone())), + _ => None, + }) + .collect(); + assert_eq!( + roles, + vec![ + (CoreRole::User, "hello".to_owned()), + (CoreRole::Assistant, "hi back".to_owned()), + ] + ); + } + + /// The auto-notify contract, end to end: a `task:` dispatch runs the + /// prompt as a turn and its reply lands in the notification table on + /// the phone; an `interactive:` dispatch replies into the hub and + /// notifies nothing. + #[tokio::test] + async fn a_task_dispatch_runs_the_prompt_and_auto_notifies_the_phone() { + use rhema_rsd::Store; + + let notifications = notifications(); + let store = Arc::new(ClawStore::in_memory().expect("store opens")); + let hub = Arc::new(EventHub::default()); + let workspace = tempfile::tempdir().expect("tempdir"); + let backing = Arc::new(VolatileStore::new()); + let handler = Arc::new(InteractiveHandler::new( + test_registry(Arc::new(MockProvider::new([ + reply("the bread is done."), + reply("hi back"), + ]))), + workspace.path().join("interactive"), + store, + Arc::new(rhema_memory::VolatileStore::new()), + Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), + hub, + notifications.clone(), + tasks(), + jobs(), + accounts(), + crate::config::DEFAULT_MAX_TURN_STEPS, + consents(), + bridge(), + attachments(), + phone(), + identity(), + )); + + let dispatcher = Arc::new(Dispatcher::new( + DispatcherConfig { + debounce_window_millis: 0, + ..DispatcherConfig::default() + }, + Arc::new(rhema_rsd::SystemClock), + backing.clone(), + )); + register_user(&dispatcher); + // The source registration the daemon's assembly performs. + let _scheduler = crate::task_source::TaskScheduler::new( + &dispatcher, + Arc::new(rhema_rsd::SystemClock), + tasks(), + ); + let driver = + rhema_autonomous_agent::Driver::new(dispatcher.clone(), handler).with_holder("test"); + + // The event shape TaskScheduler::detect delivers: the prompt is + // the payload's `prompt`, the key is the task's. + dispatcher + .ingest(Event { + id: "task-1@1500".into(), + source: crate::task_source::TASKS_SOURCE.into(), + payload: json!({ "key": "task:1", "prompt": "check the bread" }), + received_at: 1_500, + }) + .expect("ingests"); + assert_eq!( + driver.run_once().await.expect("the run happens"), + vec![RsdOutcome::Handled] + ); + + // The reply reached the phone: the key names no device, so the + // auto-notify rule answers to the default. + let pending = notifications.pending("phone", 0).expect("poll reads"); + assert_eq!(pending.len(), 1, "{pending:?}"); + assert_eq!(pending[0].device, "phone"); + assert_eq!(pending[0].title, "claw"); + assert_eq!(pending[0].body, "the bread is done."); + + // The turn ran with the prompt — the stashed session carries it + // verbatim, under the delivery-contract framing. + let record = backing + .load(&Key::from("task:1")) + .expect("the record loads") + .expect("the key has a record"); + let stashed: Session = + serde_json::from_value(record.session.expect("a session").core.driver_state) + .expect("the stash parses"); + let user_texts: Vec<&str> = stashed + .history + .iter() + .filter_map(|message| match &message.content[0] { + Content::Text(text) if message.role == Role::User => Some(text.as_str()), + _ => None, + }) + .collect(); + assert_eq!(user_texts.len(), 1, "{user_texts:?}"); + assert!(user_texts[0].contains("check the bread"), "{user_texts:?}"); + assert!( + user_texts[0].contains("notification"), + "the delivery contract rode in: {user_texts:?}" + ); + + // An interactive reply is the chat surface's business: notified + // nowhere. + dispatcher + .ingest(user_event("interactive:web", "hello")) + .expect("ingests"); + assert_eq!( + driver.run_once().await.expect("the run happens"), + vec![RsdOutcome::Handled] + ); + assert_eq!( + notifications.pending("phone", 0).expect("poll reads").len(), + 1, + "still just the task's notification" + ); + } + /// The wake-itself path, end to end: a scheduled task whose target + /// is a chat session fires into that conversation — the model sees + /// the wake framing, the reply streams to the chat surface, and the + /// conversation's earlier context is still in the session. + #[tokio::test] + async fn a_scheduled_wake_fires_inside_the_chat_session_it_names() { + let reply = |text: &str| ModelOutput { + message: CoreMessage::text(CoreRole::Assistant, text), + stop_reason: StopReason::EndTurn, + usage: Usage::default(), + }; + let store = Arc::new(ClawStore::in_memory().expect("store opens")); + let hub = Arc::new(EventHub::default()); + let workspace = tempfile::tempdir().expect("tempdir"); + let handler = InteractiveHandler::new( + test_registry(Arc::new(MockProvider::new([ + reply("noted, leaving early."), + reply("evening summary: shipped the claw."), + ]))), + workspace.path().join("interactive"), + store, + Arc::new(rhema_memory::VolatileStore::new()), + Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")), + hub, + notifications(), + tasks(), + jobs(), + accounts(), + crate::config::DEFAULT_MAX_TURN_STEPS, + consents(), + bridge(), + attachments(), + phone(), + identity(), + ); + let backing = Arc::new(VolatileStore::new()); + let dispatcher = Arc::new(Dispatcher::new( + DispatcherConfig { + debounce_window_millis: 0, + ..DispatcherConfig::default() + }, + Arc::new(rhema_rsd::SystemClock), + backing.clone(), + )); + register_user(&dispatcher); + // The source registration the daemon's assembly performs. + let _scheduler = crate::task_source::TaskScheduler::new( + &dispatcher, + Arc::new(rhema_rsd::SystemClock), + tasks(), + ); + let driver = rhema_autonomous_agent::Driver::new(dispatcher.clone(), Arc::new(handler)) + .with_holder("test"); + let key = Key::from("interactive:web"); + + // The evening's work, in the chat session. + dispatcher + .ingest(user_event(key.as_str(), "we shipped the claw tonight")) + .expect("ingests"); + assert_eq!( + driver.run_once().await.expect("first run"), + vec![RsdOutcome::Handled] + ); + + // The scheduled wake: a `tasks` event delivered to the chat + // session — the shape task_event produces for a targeted task. + dispatcher + .ingest(Event { + id: "task-1@1500".into(), + source: crate::task_source::TASKS_SOURCE.into(), + payload: json!({ + "key": "interactive:web", + "prompt": "summarize this evening's work", + }), + received_at: 1_500, + }) + .expect("ingests"); + assert_eq!( + driver.run_once().await.expect("second run"), + vec![RsdOutcome::Handled] + ); + assert_eq!( + dispatcher.status(&key).expect("status reads").state, + KeyState::Idle, + "the wake ran in the named session, not a cold slot" + ); + + // The session kept its context: the stashed conversation holds + // the evening's message, then the framed wake. + use rhema_rsd::Store; + let record = backing + .load(&key) + .expect("the record loads") + .expect("the key has a record"); + let stashed: Session = + serde_json::from_value(record.session.expect("a session").core.driver_state) + .expect("the stash parses"); + let user_texts: Vec = stashed + .history + .iter() + .filter_map(|message| match &message.content[0] { + Content::Text(text) if message.role == Role::User => Some(text.clone()), + _ => None, + }) + .collect(); + assert_eq!(user_texts.len(), 2, "{user_texts:?}"); + assert!( + user_texts[0].contains("we shipped the claw tonight"), + "the first turn is the conversation's own: {user_texts:?}" + ); + assert!( + user_texts[1].contains("Scheduled wake") + && user_texts[1].contains("summarize this evening's work"), + "the wake names itself and carries the prompt: {user_texts:?}" + ); + } +} diff --git a/crates/rhema-claw/src/job_store.rs b/crates/rhema-claw/src/job_store.rs new file mode 100644 index 0000000..ae1676d --- /dev/null +++ b/crates/rhema-claw/src/job_store.rs @@ -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, + /// 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, + /// 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>, +} + +impl CodingJobStore { + /// Opens the store at `path`, creating the tables. + pub fn open(path: impl AsRef) -> Result { + let conn = rusqlite::Connection::open(path)?; + Self::init(conn) + } + + /// A memory-backed store; the tests' shape. + pub fn in_memory() -> Result { + Self::init(rusqlite::Connection::open_in_memory()?) + } + + fn init(conn: rusqlite::Connection) -> Result { + 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:#`, so a `#` in the name would break the parse + /// the jobs handler depends on. + pub fn add_instance(&self, name: &str, path: &str) -> Result { + 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 { + 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, 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::, _>>() + .map_err(JobStoreError::from) + } + + /// One instance by name. + pub fn instance(&self, name: &str) -> Result, 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, 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 { + 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, 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, 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::, _>>() + .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, 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::, _>>() + .map_err(JobStoreError::from) + } + + /// The queued rows, oldest first — the jobs source's detect pass. + pub fn queued(&self) -> Result, 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::, _>>() + .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, 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 { + 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, 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 { + 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 { + 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" + ); + } +} diff --git a/crates/rhema-claw/src/job_tools.rs b/crates/rhema-claw/src/job_tools.rs new file mode 100644 index 0000000..fda55ce --- /dev/null +++ b/crates/rhema-claw/src/job_tools.rs @@ -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) -> ToolRegistry { + let mut registry = ToolRegistry::new(); + registry.register(Arc::new(CodingAgent { store })); + registry +} + +#[derive(Deserialize)] +struct Args { + action: String, + #[serde(default)] + name: Option, + #[serde(default)] + path: Option, + #[serde(default)] + instance: Option, + #[serde(default)] + prompt: Option, + #[serde(default)] + fresh: Option, + #[serde(default)] + job: Option, +} + +/// The tool: one `coding_agent` call, `action` picks the operation. +struct CodingAgent { + store: Arc, +} + +impl CodingAgent { + fn add(&self, name: &str, path: &str) -> Result { + 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 { + 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 { + 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 { + 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::>(); + 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) -> Result { + 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::>(), + }) + .to_string()) + } + None => { + let jobs = self.store.recent(20).map_err(in_band)?; + Ok(json!({ + "jobs": jobs.iter().map(job_json).collect::>(), + }) + .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 { + 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, 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) -> StdArc { + 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, args: Value) -> Result { + 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()); + } +} diff --git a/crates/rhema-claw/src/jobs.rs b/crates/rhema-claw/src/jobs.rs new file mode 100644 index 0000000..1c28092 --- /dev/null +++ b/crates/rhema-claw/src/jobs.rs @@ -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:#` — 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:#`. +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, + dispatcher: Arc, + key_fn: Arc, +} + +impl JobsSource { + /// Registers the `jobs` source's key function on `dispatcher` and + /// returns the source feeding it. + pub fn new(dispatcher: &Arc, store: Arc) -> 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 { + self.key_fn.clone() + } + + async fn detect(&self) -> Result { + 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:#` +/// 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, + store: Arc, + notifications: Arc, + 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, + store: Arc, + notifications: Arc, + 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 { + 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::(); + 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, + store: Arc, + notifications: Arc, + ) -> CodingJobHandler { + let provider: Arc = 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, 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) + } +} diff --git a/crates/rhema-claw/src/lib.rs b/crates/rhema-claw/src/lib.rs new file mode 100644 index 0000000..6eb0c81 --- /dev/null +++ b/crates/rhema-claw/src/lib.rs @@ -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}; diff --git a/crates/rhema-claw/src/main.rs b/crates/rhema-claw/src/main.rs new file mode 100644 index 0000000..05e7924 --- /dev/null +++ b/crates/rhema-claw/src/main.rs @@ -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, + + /// 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 { + 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 { + 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, 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::>() + }; + 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)?, + )) +} diff --git a/crates/rhema-claw/src/mock.rs b/crates/rhema-claw/src/mock.rs new file mode 100644 index 0000000..36c3602 --- /dev/null +++ b/crates/rhema-claw/src/mock.rs @@ -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` would lie + /// about constructing `Self`.) + pub fn provider() -> Arc { + Arc::new(Self::default()) + } +} + +#[async_trait] +impl ModelProvider for MockLoop { + async fn complete( + &self, + _req: ModelRequest, + sink: &mut dyn EventSink, + ) -> Result { + 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(), + }) + } +} diff --git a/crates/rhema-claw/src/notifications.rs b/crates/rhema-claw/src/notifications.rs new file mode 100644 index 0000000..9bd3a3f --- /dev/null +++ b/crates/rhema-claw/src/notifications.rs @@ -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, +} + +/// 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, +} + +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) -> Result { + 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 { + let conn = rusqlite::Connection::open_in_memory().map_err(NotificationStoreError::from)?; + Self::init(conn, false) + } + + fn init(conn: rusqlite::Connection, durable: bool) -> Result { + 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 { + 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, 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::, 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 { + 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::>(), + ["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::>(), + [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"); + } +} diff --git a/crates/rhema-claw/src/notify_tools.rs b/crates/rhema-claw/src/notify_tools.rs new file mode 100644 index 0000000..659c6b6 --- /dev/null +++ b/crates/rhema-claw/src/notify_tools.rs @@ -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) -> 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, +} + +/// Sends one notification: one row in the table, delivered at the +/// device's next poll-and-ack. +struct Notify { + store: Arc, +} + +#[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 { + 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 { + Arc::new(NotificationStore::in_memory().expect("store opens")) + } + + async fn call(store: &Arc, 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::(&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" + ); + } +} diff --git a/crates/rhema-claw/src/openai.rs b/crates/rhema-claw/src/openai.rs new file mode 100644 index 0000000..8fc7cdb --- /dev/null +++ b/crates/rhema-claw/src/openai.rs @@ -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:`, +//! 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, + /// The transcript-shaped input. Only the last `user` entry matters. + pub messages: Vec, + /// Stream chunks (SSE) or wait for the whole reply. + #[serde(default)] + pub stream: bool, + /// The session name: key `interactive:`, `web` when unset. + pub user: Option, +} + +/// 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), +} + +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::>() + .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, +} + +/// The last `user` message's text — the only input a turn gets. +pub fn last_user_text(messages: &[ChatMessage]) -> Option { + 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) -> 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, + Json(request): Json, +) -> 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, + id: &str, + created: u64, + model: &str, +) -> Response { + let id = id.to_owned(); + let model = model.to_owned(); + let (sender, body) = + tokio::sync::mpsc::channel::>(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}"); + } +} diff --git a/crates/rhema-claw/src/phone.rs b/crates/rhema-claw/src/phone.rs new file mode 100644 index 0000000..bcd3c36 --- /dev/null +++ b/crates/rhema-claw/src/phone.rs @@ -0,0 +1,1191 @@ +//! The phone seam: what the claw knows about the user's phones, and the +//! consent-gated way it acts on them. +//! +//! The Android client reports its own state — battery, charging, +//! network, do-not-disturb, location, the foreground app — as +//! `phone::` events through `/v1/ingest`. Those events +//! are also conversation (the phone dispatches like any resident key), +//! but the conversation is the wrong place to answer "what's my +//! battery": a status question needs the latest observation, not a +//! transcript. [`PhoneStore`] keeps the latest payload per device and +//! topic, fed at the ingest door, plus a registry of the devices that +//! have ever reported ([`DeviceRecord`]). +//! +//! A device's identity lives for the life of one install: a reinstall, +//! a cleared app data, or a second phone each arrive under a fresh id. +//! The registry is what keeps the fleet honest — [`PhoneStore::devices`] +//! answers who reported, how it names itself (the `device` topic's +//! manufacturer and model), and when it was last heard — and +//! [`PhoneStore::forget_device`] retires an identity: its observations, +//! its queued commands, and its registry row go together. A forgotten +//! phone that still reports simply re-registers. Both surfaces — +//! `GET`/`DELETE /v1/phones` for the client's settings screen, the +//! `phone` tool's `devices`/`forget` actions for the agent — read and +//! write this one registry. +//! +//! Reports are observations, not conversation: a topic-shaped key +//! (`phone::`) lands in the tables and the ingest door +//! answers "observed" without dispatching a turn. The transition rules +//! bound the writes, but they cannot bound *value* — a decile drop +//! from 80 is not news, and each dispatched turn's reply auto-notifies +//! the phone, so per-flip turns would be a model call and a push +//! notification per flip. Anything the agent could do with a flip it +//! can do on demand: the `phone` tool reads the same snapshot at +//! decision time, and a scheduled task can check it and notify +//! deliberately. A bare `phone:` key (no topic) is a message on +//! the device's resident key and dispatches like any resident key. +//! +//! Acting on the phone crosses a line reading never does, so it is +//! consent-gated end to end: a control action raises a consent +//! request ([`crate::consents`]), the human approves or denies it in +//! the chat surface, and only then does the action join a command +//! queue the device polls ([`Command`]). Forgetting a device is +//! consent-gated the same way, minus the queue: nothing is sent to the +//! device, its records are dropped. The phone executes on its own +//! clock — a WorkManager floor of fifteen minutes — so the tool's +//! answer may honestly be "queued, not yet confirmed". + +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 crate::consents::{CONSENT_WAIT, ConsentKind, WaitBridge, wait_for_resolution}; +use crate::hub::{EventHub, TurnEvent}; +use rhema_rsd::ResourceKey; + +/// The device a status or action addresses when the tool call names +/// none — the same default the notification queue and the auto-notify +/// rule use. +pub const DEFAULT_DEVICE: &str = crate::notifications::DEFAULT_DEVICE; + +/// How long a queued command waits for the device's confirmation. The +/// phone polls commands on the WorkManager floor (fifteen minutes), so +/// this is the honest latency of the channel, not pessimism. +pub const COMMAND_WAIT: Duration = Duration::from_secs(20 * 60); + +/// The control actions a consent gate stands in front of. Status reads +/// never appear here: observing the phone is what the tool is for; +/// acting on it is what needs asking. +pub const CONTROL_ACTIONS: [&str; 3] = ["dnd_on", "dnd_off", "alert"]; + +/// The source name the phone client's reports ingest under. +pub const SOURCE: &str = "phone"; + +/// The topic that names a device: the client sends it once per status +/// run (it changes with the app version, so an upgrade re-sends it) and +/// its payload is where [`device_label`] reads the fleet's labels from. +pub const DEVICE_TOPIC: &str = "device"; + +/// Splits a `phone::` key into its device and topic; +/// `None` for anything else — the shape the Android client's status +/// reports use, and the only keys that feed [`PhoneStore::observe`]. +pub fn phone_key_parts(key: &str) -> Option<(&str, &str)> { + let rest = key.strip_prefix("phone:")?; + let (device, topic) = rest.split_once(':')?; + (!device.is_empty() && !topic.is_empty()).then_some((device, topic)) +} + +/// The label a `device` payload names: ` `, with +/// whichever part is missing simply left out. Anything else (a bare +/// "unknown", an empty payload) yields no label, and the fleet row +/// keeps whatever it had. +fn device_label(payload: &Value) -> Option { + let field = |name: &str| { + payload[name] + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("unknown")) + }; + let manufacturer = field("manufacturer"); + let model = field("model"); + match (manufacturer, model) { + (Some(manufacturer), Some(model)) if model.starts_with(manufacturer) => { + Some(model.to_owned()) + } + (Some(manufacturer), Some(model)) => Some(format!("{manufacturer} {model}")), + (Some(_), None) => manufacturer.map(str::to_owned), + (None, Some(model)) => Some(model.to_owned()), + (None, None) => None, + } +} + +/// Why a phone-store operation failed. +#[derive(Debug, thiserror::Error)] +pub enum PhoneStoreError { + /// The backend failed to read or write. + #[error("phone store failed: {0}")] + Backend(String), +} + +fn backend(err: rusqlite::Error) -> PhoneStoreError { + PhoneStoreError::Backend(err.to_string()) +} + +fn now_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +/// One queued device command: what to do, and what the device said. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct Command { + /// The command's id (`cmd-…`), the result route's address. + pub id: String, + /// The device that should execute it. + pub device: String, + /// The action: one of [`CONTROL_ACTIONS`]. + pub action: String, + /// Action arguments (an alert's title/body, say). + pub args: Value, + /// When the command was queued, epoch millis. + pub created_at: u64, + /// Whether execution succeeded, once the device reported. + pub result_ok: Option, + /// What the device said about it. + pub result_detail: Option, +} + +/// One identity in the fleet: the client's persistent install id, the +/// name it reported for itself, and the two clocks — first and last +/// report. A raw UUID says nothing a human can match to a phone in a +/// drawer, so the label is what the settings screens lead with. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct DeviceRecord { + /// The device id the client generated for this install. + pub device: String, + /// The self-reported name (` `), when the + /// client sent a `device` topic. + pub label: Option, + /// The first report, epoch millis. + pub first_seen: u64, + /// The latest report of any topic, epoch millis. + pub last_seen: u64, +} + +/// The phone tables over the claw's shared database: the latest +/// observation per device and topic, the command queue, and the device +/// registry. +pub struct PhoneStore { + conn: Mutex, +} + +impl PhoneStore { + /// Opens (creating if needed) the phone tables on the database at + /// `path`. + pub fn open(path: impl AsRef) -> Result { + let conn = rusqlite::Connection::open(path).map_err(backend)?; + Self::init(conn) + } + + /// Opens the phone tables over an in-memory database. + pub fn in_memory() -> Result { + let conn = rusqlite::Connection::open_in_memory().map_err(backend)?; + Self::init(conn) + } + + fn init(conn: rusqlite::Connection) -> Result { + let conn = conn; + conn.busy_timeout(std::time::Duration::from_secs(5)) + .map_err(backend)?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS phone_state ( + device TEXT NOT NULL, + topic TEXT NOT NULL, + payload TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (device, topic) + ); + CREATE TABLE IF NOT EXISTS phone_commands ( + id TEXT PRIMARY KEY, + device TEXT NOT NULL, + action TEXT NOT NULL, + args TEXT NOT NULL, + created_at INTEGER NOT NULL, + result_ok INTEGER, + result_detail TEXT, + result_at INTEGER + ); + CREATE TABLE IF NOT EXISTS phone_devices ( + device TEXT PRIMARY KEY, + label TEXT, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL + );", + ) + .map_err(backend)?; + // One-time migration for databases that predate the registry: + // every identity still present in the state table becomes a + // registry row. A later forget_device deletes the state rows + // with the identity, so the seed cannot resurrect it. + conn.execute_batch( + "INSERT OR IGNORE INTO phone_devices (device, label, first_seen, last_seen) + SELECT device, NULL, MIN(updated_at), MAX(updated_at) + FROM phone_state GROUP BY device", + ) + .map_err(backend)?; + Ok(Self { + conn: Mutex::new(conn), + }) + } + + /// Records the latest payload for one device topic — the ingest + /// door's write, one upsert per report. The report also registers + /// the device: first report opens the registry row, every report + /// refreshes `last_seen`, and a `device` payload names the label. + pub fn observe( + &self, + device: &str, + topic: &str, + payload: &Value, + ) -> Result<(), PhoneStoreError> { + let now = now_millis() as i64; + let label = if topic == DEVICE_TOPIC { + device_label(payload) + } else { + None + }; + let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner); + conn.execute( + "INSERT INTO phone_state (device, topic, payload, updated_at) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(device, topic) DO UPDATE SET payload = ?3, updated_at = ?4", + params![device, topic, payload.to_string(), now], + ) + .map_err(backend)?; + conn.execute( + "INSERT INTO phone_devices (device, label, first_seen, last_seen) VALUES (?1, ?2, ?3, ?3) + ON CONFLICT(device) DO UPDATE SET + last_seen = ?3, + label = COALESCE(?2, label)", + params![device, label, now], + ) + .map_err(backend)?; + Ok(()) + } + + /// The latest observations for one device, topic name to payload — + /// the status action's answer. + pub fn snapshot(&self, device: &str) -> Result { + let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner); + let mut stmt = conn + .prepare("SELECT topic, payload, updated_at FROM phone_state WHERE device = ?1") + .map_err(backend)?; + let rows = stmt + .query_map(params![device], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .map_err(backend)?; + let mut topics = serde_json::Map::new(); + for row in rows { + let (topic, payload, updated_at) = row.map_err(backend)?; + let mut payload: Value = serde_json::from_str(&payload).unwrap_or(Value::Null); + if let Some(object) = payload.as_object_mut() { + object.insert("observed_at".into(), json!(updated_at.max(0) as u64)); + } + topics.insert(topic, payload); + } + Ok(json!({ "device": device, "topics": topics })) + } + + /// The fleet: every device that has ever reported, oldest first. + pub fn devices(&self) -> Result, PhoneStoreError> { + let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner); + let mut stmt = conn + .prepare("SELECT device, label, first_seen, last_seen FROM phone_devices ORDER BY first_seen, device") + .map_err(backend)?; + let rows = stmt + .query_map([], |row| { + Ok(DeviceRecord { + device: row.get(0)?, + label: row.get(1)?, + first_seen: row.get::<_, i64>(2)?.max(0) as u64, + last_seen: row.get::<_, i64>(3)?.max(0) as u64, + }) + }) + .map_err(backend)?; + rows.collect::, _>>().map_err(backend) + } + + /// Retires a device: its observations, its queued commands, and its + /// registry row go together. Answers whether the identity was + /// known — `false` for an unknown name, which deleted nothing but + /// is still a valid answer, never an error. A phone that reports + /// after being forgotten re-registers itself. + pub fn forget_device(&self, device: &str) -> Result { + let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner); + conn.execute("DELETE FROM phone_state WHERE device = ?1", params![device]) + .map_err(backend)?; + conn.execute( + "DELETE FROM phone_commands WHERE device = ?1", + params![device], + ) + .map_err(backend)?; + let removed = conn + .execute( + "DELETE FROM phone_devices WHERE device = ?1", + params![device], + ) + .map_err(backend)?; + Ok(removed > 0) + } + + /// Queues a command for `device`, durable before it returns. The id + /// is unique per command (a wall-clock millisecond plus a process + /// counter). + pub fn enqueue_command( + &self, + device: &str, + action: &str, + args: Value, + ) -> Result { + static COMMAND_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let now = now_millis(); + let id = format!( + "cmd-{}-{:04x}", + now, + COMMAND_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ); + let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner); + conn.execute( + "INSERT INTO phone_commands (id, device, action, args, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![id, device, action, args.to_string(), now as i64], + ) + .map_err(backend)?; + Ok(Command { + id, + device: device.to_owned(), + action: action.to_owned(), + args, + created_at: now, + result_ok: None, + result_detail: None, + }) + } + + /// The commands a device has not confirmed yet, oldest first — the + /// poll list the client executes from. + pub fn pending_commands(&self, device: &str) -> Result, PhoneStoreError> { + let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner); + let mut stmt = conn + .prepare( + "SELECT id, device, action, args, created_at, result_ok, result_detail + FROM phone_commands + WHERE device = ?1 AND result_at IS NULL + ORDER BY created_at, id", + ) + .map_err(backend)?; + let rows = stmt + .query_map(params![device], row_command) + .map_err(backend)?; + rows.collect::, _>>().map_err(backend) + } + + /// Records a command's execution result. `false` when the id is + /// unknown or already confirmed — a result is consumed once, and a + /// redelivered report does not overwrite the first verdict. + pub fn record_result( + &self, + id: &str, + ok: bool, + detail: Option<&str>, + ) -> Result { + let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner); + let updated = conn + .execute( + "UPDATE phone_commands SET result_ok = ?2, result_detail = ?3, result_at = ?4 + WHERE id = ?1 AND result_at IS NULL", + params![id, ok, detail, now_millis() as i64], + ) + .map_err(backend)?; + Ok(updated > 0) + } + + /// The command with this id, when it exists. + pub fn get_command(&self, id: &str) -> Result, PhoneStoreError> { + let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner); + let mut stmt = conn + .prepare( + "SELECT id, device, action, args, created_at, result_ok, result_detail + FROM phone_commands WHERE id = ?1", + ) + .map_err(backend)?; + let mut rows = stmt.query_map(params![id], row_command).map_err(backend)?; + rows.next().transpose().map_err(backend) + } +} + +fn row_command(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Command { + id: row.get(0)?, + device: row.get(1)?, + action: row.get(2)?, + args: serde_json::from_str(&row.get::<_, String>(3)?).unwrap_or(Value::Null), + created_at: row.get::<_, i64>(4)?.max(0) as u64, + result_ok: row.get::<_, Option>(5)?.map(|flag| flag != 0), + result_detail: row.get(6)?, + }) +} + +/// What a command wait came back with. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandOutcome { + /// The device reported. + Confirmed { + /// Whether execution succeeded. + ok: bool, + /// What the device said about it. + detail: Option, + }, + /// [`COMMAND_WAIT`] elapsed without a report; the command stays + /// queued and the device may still execute it. + Unconfirmed, +} + +/// Waits until a queued command confirms or the budget runs out — the +/// consent wait's shape over the command table. +pub async fn wait_for_command_result( + store: &PhoneStore, + bridge: &WaitBridge, + id: &str, + budget: Duration, +) -> CommandOutcome { + let deadline = tokio::time::Instant::now() + budget; + loop { + if let Ok(Some(command)) = store.get_command(id) { + if let Some(ok) = command.result_ok { + return CommandOutcome::Confirmed { + ok, + detail: command.result_detail, + }; + } + } + 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 CommandOutcome::Unconfirmed, + } + } +} + +/// The tool's name on the wire. +pub const PHONE: &str = "phone"; + +/// `phone`: read the device's reported state, or — through a consent +/// request the human answers in chat — act on it. +struct PhoneTool { + store: Arc, + consents: Arc, + bridge: Arc, + hub: Arc, + key: ResourceKey, + /// The consent patience; [`CONSENT_WAIT`] in production, a sliver + /// in tests. + wait: Duration, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PhoneArgs { + action: String, + #[serde(default)] + device: Option, + #[serde(default)] + reason: Option, + /// An alert's headline and body; the device renders what it gets. + #[serde(default)] + title: Option, + #[serde(default)] + body: Option, +} + +#[async_trait] +impl Tool for PhoneTool { + fn spec(&self) -> ToolSpec { + ToolSpec::new( + PHONE, + "Read or act on the user's phones. status and devices are \ + read-only and always allowed: status is one phone's latest \ + reported state (battery, charging, network, do-not-disturb, \ + and — when the phone reports them — location, foreground \ + app, wifi, next alarm, each with its observed_at), devices \ + is the fleet with each phone's label and last-seen time. A \ + reinstall or upgrade can leave a stale identity behind; \ + devices shows it (old last-seen, no reports) and forget \ + retires it. The control actions — dnd_on, dnd_off, alert, \ + forget — first ask the human through a consent card in the \ + chat; an approved dnd_on/dnd_off/alert queues on the phone, \ + which confirms on its own poll (minutes, not seconds). Use \ + controls sparingly and always with a reason; a denial or a \ + silence comes back in-band.", + json!({ + "type": "object", + "properties": { + "action": { "type": "string", + "enum": ["status", "devices", "forget", "dnd_on", "dnd_off", "alert"], + "description": "status reads one phone's reported \ + state; devices lists the fleet with labels and \ + last-seen; forget retires a device's records \ + (with consent); the rest control the phone \ + (with consent)." }, + "device": { "type": "string", + "description": "The target device id; the default \ + phone when omitted. status and devices accept \ + any id; forget names the device to retire." }, + "reason": { "type": "string", + "description": "Why the action is needed — shown on \ + the consent card." }, + "title": { "type": "string", + "description": "An alert's headline." }, + "body": { "type": "string", + "description": "An alert's body." } + }, + "required": ["action"] + }), + ) + } + + async fn invoke(&self, args: Value) -> Result { + let args: PhoneArgs = + serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?; + let device = args + .device + .as_deref() + .map(str::trim) + .filter(|device| !device.is_empty()) + .unwrap_or(DEFAULT_DEVICE) + .to_owned(); + let action = args.action.trim().to_owned(); + match action.as_str() { + "status" => { + let snapshot = self + .store + .snapshot(&device) + .map_err(|err| err.to_string())?; + if snapshot["topics"] + .as_object() + .is_some_and(|topics| topics.is_empty()) + { + return Ok(json!({"observed": false, "device": device, + "note": "this device has never reported its status"}) + .to_string()); + } + Ok(json!({"observed": true, "snapshot": snapshot}).to_string()) + } + "devices" => { + let devices = self.store.devices().map_err(|err| err.to_string())?; + Ok(json!({ "devices": devices }).to_string()) + } + "forget" => self.forget(&device, args).await, + action if CONTROL_ACTIONS.contains(&action) => { + self.control(action, &device, args).await + } + other => Err(format!( + "unknown action {other:?}: status, devices, forget, or one of {CONTROL_ACTIONS:?}" + )), + } + } +} + +impl PhoneTool { + /// One control action, consent first, confirmation second. Both + /// waits answer in-band: a refused action is a turn that can say so. + async fn control(&self, action: &str, device: &str, args: PhoneArgs) -> Result { + match self + .ask( + &format!("phone {action} on {device}"), + action, + device, + &args, + ) + .await? + { + Ask::Granted => {} + Ask::Refused(answer) => return Ok(answer), + } + let command_args = match action { + "alert" => json!({ + "title": args.title.as_deref().map(str::trim).filter(|t| !t.is_empty()).unwrap_or("claw"), + "body": args.body.as_deref().map(str::trim).filter(|b| !b.is_empty()).unwrap_or(action), + }), + _ => json!({}), + }; + let command = self + .store + .enqueue_command(device, action, command_args) + .map_err(|err| err.to_string())?; + match wait_for_command_result(&self.store, &self.bridge, &command.id, COMMAND_WAIT).await { + CommandOutcome::Confirmed { ok, detail } => Ok(json!({ + "granted": true, + "done": ok, + "detail": detail, + "command_id": command.id, + }) + .to_string()), + CommandOutcome::Unconfirmed => Ok(json!({ + "granted": true, + "done": false, + "queued": true, + "command_id": command.id, + "note": "the device has not confirmed yet; it executes on its own poll", + }) + .to_string()), + } + } + + /// Retires a device's records after the human approves. Nothing is + /// sent to the device and nothing waits for it: a grant drops the + /// identity, its observations, and its queued commands at once. The + /// answer says whether the name was known, so the turn can tell a + /// typo from a retirement. + async fn forget(&self, device: &str, args: PhoneArgs) -> Result { + match self + .ask( + &format!("phone forget on {device}"), + "forget", + device, + &args, + ) + .await? + { + Ask::Granted => {} + Ask::Refused(answer) => return Ok(answer), + } + let forgotten = self + .store + .forget_device(device) + .map_err(|err| err.to_string())?; + Ok(json!({"granted": true, "forgotten": forgotten}).to_string()) + } + + /// The consent round-trip both mutating actions share: raise the + /// card, wait, and classify the human's answer. `Err` is the store + /// failing, not a refusal — a refusal is an [`Ask::Refused`] with + /// the in-band answer. + async fn ask( + &self, + summary: &str, + action: &str, + device: &str, + args: &PhoneArgs, + ) -> Result { + let reason = args + .reason + .as_deref() + .map(str::trim) + .filter(|reason| !reason.is_empty()) + .unwrap_or("no reason given"); + let detail = format!("{reason} (action: {action}, device: {device})"); + let consent = self + .consents + .create( + ConsentKind::Phone, + summary, + &detail, + self.key.as_str(), + None, + ) + .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, + }, + ); + Ok( + match wait_for_resolution(&self.consents, &self.bridge, &consent.id, self.wait).await { + crate::consents::WaitOutcome::Granted(_) => Ask::Granted, + crate::consents::WaitOutcome::Denied => { + Ask::Refused(json!({"granted": false}).to_string()) + } + crate::consents::WaitOutcome::TimedOut => { + Ask::Refused(json!({"timed_out": true}).to_string()) + } + }, + ) + } +} + +/// What [`PhoneTool::ask`] came back with: a grant, or the in-band +/// answer a refusal or a silence turns into. +enum Ask { + Granted, + Refused(String), +} + +/// The phone tool set over `store`: `phone`. Built per dispatch — a +/// consent ask must name the session that asked. +pub fn registry( + store: Arc, + consents: Arc, + bridge: Arc, + hub: Arc, + key: ResourceKey, +) -> ToolRegistry { + let mut registry = ToolRegistry::new(); + registry.register(Arc::new(PhoneTool { + store, + consents, + bridge, + hub, + key, + wait: CONSENT_WAIT, + })); + registry +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store() -> Arc { + Arc::new(PhoneStore::in_memory().expect("store opens")) + } + + #[test] + fn an_observed_topic_is_the_snapshot_until_replaced() { + let store = store(); + store + .observe( + "pixel", + "battery", + &json!({"topic": "battery", "value": "80"}), + ) + .expect("observes"); + store + .observe( + "pixel", + "network", + &json!({"topic": "network", "value": "wifi"}), + ) + .expect("observes"); + let snapshot = store.snapshot("pixel").expect("reads"); + assert_eq!(snapshot["topics"]["battery"]["value"], "80"); + assert_eq!(snapshot["topics"]["network"]["value"], "wifi"); + assert!(snapshot["topics"]["battery"]["observed_at"].is_u64()); + assert!( + store.snapshot("other").expect("reads")["topics"] + .as_object() + .expect("a map") + .is_empty() + ); + // A newer report replaces, never appends. + store + .observe( + "pixel", + "battery", + &json!({"topic": "battery", "value": "70"}), + ) + .expect("observes"); + assert_eq!( + store.snapshot("pixel").expect("reads")["topics"]["battery"]["value"], + "70" + ); + let devices = store.devices().expect("reads"); + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].device, "pixel"); + } + + #[test] + fn a_report_registers_the_device_and_its_label_rides_the_device_topic() { + let store = store(); + store + .observe( + "pixel", + "device", + &json!({"topic": "device", "manufacturer": "Google", "model": "Pixel 9"}), + ) + .expect("observes"); + store + .observe( + "pixel", + "battery", + &json!({"topic": "battery", "value": "80"}), + ) + .expect("observes"); + let devices = store.devices().expect("reads"); + assert_eq!(devices.len(), 1, "two topics, one device"); + assert_eq!(devices[0].label.as_deref(), Some("Google Pixel 9")); + assert!(devices[0].first_seen <= devices[0].last_seen); + + // A second install is a second identity, oldest first. + store + .observe( + "pixel-2", + "device", + &json!({"topic": "device", "model": "Pixel 8"}), + ) + .expect("observes"); + let devices = store.devices().expect("reads"); + assert_eq!( + devices + .iter() + .map(|d| d.device.as_str()) + .collect::>(), + vec!["pixel", "pixel-2"] + ); + assert_eq!(devices[1].label.as_deref(), Some("Pixel 8")); + + // Other topics never clobber the label. + store + .observe( + "pixel", + "network", + &json!({"topic": "network", "value": "wifi"}), + ) + .expect("observes"); + assert_eq!( + store.devices().expect("reads")[0].label.as_deref(), + Some("Google Pixel 9") + ); + } + + #[test] + fn forgetting_a_device_takes_its_state_its_commands_and_its_identity() { + let store = store(); + store + .observe( + "pixel", + "battery", + &json!({"topic": "battery", "value": "10"}), + ) + .expect("observes"); + store + .enqueue_command("pixel", "dnd_on", json!({})) + .expect("queues"); + store + .observe( + "pixel-2", + "battery", + &json!({"topic": "battery", "value": "90"}), + ) + .expect("observes"); + + assert!( + !store.forget_device("stranger").expect("forgets"), + "an unknown name forgets nothing" + ); + assert!(store.forget_device("pixel").expect("forgets")); + assert!( + store.snapshot("pixel").expect("reads")["topics"] + .as_object() + .expect("a map") + .is_empty() + ); + assert!(store.pending_commands("pixel").expect("reads").is_empty()); + assert_eq!( + store + .devices() + .expect("reads") + .iter() + .map(|d| d.device.as_str()) + .collect::>(), + vec!["pixel-2"] + ); + + // A forgotten phone that still reports re-registers. + store + .observe( + "pixel", + "battery", + &json!({"topic": "battery", "value": "9"}), + ) + .expect("observes"); + assert_eq!(store.devices().expect("reads").len(), 2); + } + + #[test] + fn a_store_reopened_over_an_old_database_seeds_the_registry_from_state() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("phones.db"); + { + let store = PhoneStore::open(&path).expect("opens"); + store + .observe( + "legacy", + "battery", + &json!({"topic": "battery", "value": "5"}), + ) + .expect("observes"); + } + let store = PhoneStore::open(&path).expect("reopens"); + let devices = store.devices().expect("reads"); + assert_eq!(devices.len(), 1, "the migration backfills the registry"); + assert_eq!(devices[0].device, "legacy"); + assert_eq!(devices[0].label, None); + } + + #[test] + fn a_device_label_reads_the_payload_and_ignores_placeholder_parts() { + assert_eq!( + device_label(&json!({"manufacturer": "Google", "model": "Pixel 9"})).as_deref(), + Some("Google Pixel 9") + ); + assert_eq!( + device_label(&json!({"manufacturer": "Google", "model": "Google Pixel 9"})).as_deref(), + Some("Google Pixel 9"), + "a model that repeats the manufacturer is not doubled" + ); + assert_eq!( + device_label(&json!({"model": "Pixel 9"})).as_deref(), + Some("Pixel 9") + ); + assert_eq!( + device_label(&json!({"manufacturer": "unknown", "model": "Pixel 9"})).as_deref(), + Some("Pixel 9") + ); + assert_eq!(device_label(&json!({})), None); + } + + #[test] + fn a_command_confirms_exactly_once() { + let store = store(); + let command = store + .enqueue_command("pixel", "dnd_on", json!({})) + .expect("queues"); + assert_eq!( + store.pending_commands("pixel").expect("reads").len(), + 1, + "unconfirmed commands stay on the poll list" + ); + assert!( + store + .record_result(&command.id, true, Some("dnd set")) + .expect("records"), + "the first report lands" + ); + assert!( + !store + .record_result(&command.id, false, Some("late")) + .expect("records"), + "a redelivery does not flip the verdict" + ); + let confirmed = store + .get_command(&command.id) + .expect("reads") + .expect("exists"); + assert_eq!(confirmed.result_ok, Some(true)); + assert_eq!(confirmed.result_detail.as_deref(), Some("dnd set")); + assert!(store.pending_commands("pixel").expect("reads").is_empty()); + } + + /// The full control path, tool-side: consent first, then the queued + /// command, then the device's confirmation — the tool answers with + /// all three facts and never the human's absence of an answer. + #[tokio::test] + async fn a_control_action_is_consent_gated_then_confirmed() { + let store = store(); + let consents = Arc::new(crate::consents::ConsentStore::in_memory().expect("opens")); + let bridge = Arc::new(WaitBridge::new()); + let hub = Arc::new(EventHub::default()); + let key = ResourceKey::from("interactive:web"); + let tool = PhoneTool { + store: store.clone(), + consents: consents.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!({ + "action": "dnd_on", + "device": "pixel", + "reason": "the movie starts", + })) + .await + }); + + // The consent card goes out; the human approves. + let pending = loop { + let pending = consents.pending().expect("reads"); + if !pending.is_empty() { + break pending; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + }; + assert_eq!(pending[0].kind, ConsentKind::Phone); + assert_eq!(pending[0].summary, "phone dnd_on on pixel"); + assert!(matches!( + receiver.try_recv(), + Ok(TurnEvent::ConsentRequest { .. }) + )); + consents + .resolve_decision(&pending[0].id, true) + .expect("resolves"); + bridge.wake(&pending[0].id); + + // The command queues; the device confirms on its poll. + let queued = loop { + let queued = store.pending_commands("pixel").expect("reads"); + if !queued.is_empty() { + break queued; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + }; + assert_eq!(queued[0].action, "dnd_on"); + store + .record_result(&queued[0].id, true, None) + .expect("records"); + bridge.wake(&queued[0].id); + + let answer = task.await.expect("the tool returns").expect("in-band ok"); + let answer: Value = serde_json::from_str(&answer).expect("parses"); + assert_eq!(answer["granted"], true); + assert_eq!(answer["done"], true); + } + + #[tokio::test] + async fn a_denied_control_never_queues_a_command() { + let store = store(); + let consents = Arc::new(crate::consents::ConsentStore::in_memory().expect("opens")); + let bridge = Arc::new(WaitBridge::new()); + let hub = Arc::new(EventHub::default()); + let key = ResourceKey::from("interactive:web"); + let tool = PhoneTool { + store: store.clone(), + consents: consents.clone(), + bridge: bridge.clone(), + hub, + key, + wait: std::time::Duration::from_secs(10), + }; + let task = tokio::spawn(async move { + tool.invoke(json!({"action": "alert", "reason": "found the keys"})) + .await + }); + let pending = loop { + let pending = consents.pending().expect("reads"); + if !pending.is_empty() { + break pending; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + }; + consents + .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::(&answer).unwrap(), + json!({"granted": false}), + "{answer}" + ); + assert!( + store.pending_commands("phone").expect("reads").is_empty(), + "nothing queues behind a denial" + ); + } + + /// The full forget path: the consent card first, and only a grant + /// drops the identity. The answer carries whether the name was + /// known, so a typo reads as data, not as silence. + #[tokio::test] + async fn forgetting_is_consent_gated_then_drops_the_identity() { + let store = store(); + store + .observe( + "pixel", + "battery", + &json!({"topic": "battery", "value": "1"}), + ) + .expect("observes"); + store + .enqueue_command("pixel", "alert", json!({})) + .expect("queues"); + let consents = Arc::new(crate::consents::ConsentStore::in_memory().expect("opens")); + let bridge = Arc::new(WaitBridge::new()); + let tool = PhoneTool { + store: store.clone(), + consents: consents.clone(), + bridge: bridge.clone(), + hub: Arc::new(EventHub::default()), + key: ResourceKey::from("interactive:web"), + wait: std::time::Duration::from_secs(10), + }; + let task = tokio::spawn(async move { + tool.invoke(json!({ + "action": "forget", + "device": "pixel", + "reason": "the old install, superseded", + })) + .await + }); + + // Until the human answers, nothing is dropped. + let pending = loop { + let pending = consents.pending().expect("reads"); + if !pending.is_empty() { + break pending; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + }; + assert_eq!(pending[0].summary, "phone forget on pixel"); + assert!( + store.devices().expect("reads").len() == 1 + && !store.pending_commands("pixel").expect("reads").is_empty(), + "a waiting consent has dropped nothing yet" + ); + consents + .resolve_decision(&pending[0].id, true) + .expect("resolves"); + bridge.wake(&pending[0].id); + + let answer = task.await.expect("the tool returns").expect("in-band ok"); + let answer: Value = serde_json::from_str(&answer).expect("parses"); + assert_eq!(answer["forgotten"], true, "{answer}"); + assert!(store.devices().expect("reads").is_empty()); + assert!( + store.snapshot("pixel").expect("reads")["topics"] + .as_object() + .expect("a map") + .is_empty() + ); + assert!(store.pending_commands("pixel").expect("reads").is_empty()); + } + + #[tokio::test] + async fn status_reads_without_asking() { + let store = store(); + store + .observe( + "pixel", + "battery", + &json!({"topic": "battery", "value": "42", "charging": false}), + ) + .expect("observes"); + let tool = PhoneTool { + store, + consents: Arc::new(crate::consents::ConsentStore::in_memory().expect("opens")), + bridge: Arc::new(WaitBridge::new()), + hub: Arc::new(EventHub::default()), + key: ResourceKey::from("interactive:web"), + wait: std::time::Duration::from_secs(1), + }; + let answer = tool + .invoke(json!({"action": "status", "device": "pixel"})) + .await + .expect("in-band ok"); + let answer: Value = serde_json::from_str(&answer).expect("parses"); + assert_eq!(answer["observed"], true); + assert_eq!(answer["snapshot"]["topics"]["battery"]["value"], "42"); + // No consent row: reading never asks. + assert!(tool.consents.pending().expect("reads").is_empty()); + } +} diff --git a/crates/rhema-claw/src/settings.rs b/crates/rhema-claw/src/settings.rs new file mode 100644 index 0000000..eb3a931 --- /dev/null +++ b/crates/rhema-claw/src/settings.rs @@ -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, +} + +impl SettingsStore { + /// Opens (creating if needed) the settings table on the database at + /// `path`. + pub fn open(path: impl AsRef) -> Result { + 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 { + let conn = rusqlite::Connection::open_in_memory() + .map_err(|err| SettingsError::Backend(err.to_string()))?; + Self::init(conn) + } + + fn init(conn: rusqlite::Connection) -> Result { + 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, SettingsError> { + let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner); + let value: Option = 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, 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::, _>>() + .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"; diff --git a/crates/rhema-claw/src/task_source.rs b/crates/rhema-claw/src/task_source.rs new file mode 100644 index 0000000..89094a0 --- /dev/null +++ b/crates/rhema-claw/src/task_source.rs @@ -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:` — 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:` 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, + store: Arc, + dispatcher: Arc, + key_fn: Arc, +} + +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, clock: Arc, store: Arc) -> 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 { + self.key_fn.clone() + } + + async fn detect(&self) -> Result { + 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 { + 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); + } +} diff --git a/crates/rhema-claw/src/task_store.rs b/crates/rhema-claw/src/task_store.rs new file mode 100644 index 0000000..824c5c9 --- /dev/null +++ b/crates/rhema-claw/src/task_store.rs @@ -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:` 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 { + 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:`) 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, + /// The recurring task's interval, in milliseconds. + pub interval_millis: Option, + /// 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:` slot. + pub deliver_to: Option, +} + +/// 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, +} + +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) -> Result { + 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 { + let conn = rusqlite::Connection::open_in_memory().map_err(TaskStoreError::from)?; + Self::init(conn, false) + } + + fn init(conn: rusqlite::Connection, durable: bool) -> Result { + 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:` 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 { + 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 { + 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, 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::, 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, 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::, 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 { + 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 { + 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::>(), + [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"); + } +} diff --git a/crates/rhema-claw/src/task_tools.rs b/crates/rhema-claw/src/task_tools.rs new file mode 100644 index 0000000..99fecde --- /dev/null +++ b/crates/rhema-claw/src/task_tools.rs @@ -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) -> 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, + #[serde(default)] + every_secs: Option, + #[serde(default)] + session: Option, +} + +#[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 { + 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::() + .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, +} + +#[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 { + 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, +} + +#[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 { + 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, +} + +#[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 { + 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 { + Arc::new(TaskStore::in_memory().expect("store opens")) + } + + async fn call(store: &Arc, 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::(&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::(&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::(&already_gone.content).unwrap(), + json!({"cancelled": false, "id": 1}) + ); + assert_eq!(store.list().expect("list reads").len(), 1); + } +} diff --git a/crates/rhema-claw/src/triage.rs b/crates/rhema-claw/src/triage.rs new file mode 100644 index 0000000..38ba619 --- /dev/null +++ b/crates/rhema-claw/src/triage.rs @@ -0,0 +1,140 @@ +//! The triage: what a notification earns, decided deterministically +//! before any model call (spec §1 — the model is never the detector). +//! +//! A [`Notification`] carries the forge's own reason for bothering the +//! account; [`classify`] reduces it to one of three verdicts. An +//! [`Triage::Agent`] dispatch becomes an RSD event whose payload names +//! the [`Role`] — the persona the handler's prompt opens with, so one +//! thread can move from review-requested to implement-feedback across +//! dispatches with the session carrying the continuity. A +//! [`Triage::Surface`] never dispatches an agent: a mention is not a +//! assignment, and the account's human hears about it through the +//! device-notification queue instead. [`Triage::Ignore`] drops the noise +//! (CI churn) on the floor. +//! +//! The mapping is a pure function and this module's tests pin it: the +//! forges' vocabularies are external data, and the policy must not drift +//! with them. + +use rhema_forge_integration::{Notification, NotificationReason}; + +/// The agent persona a dispatched notification runs as. The key stays +/// the thread — one session, one voice per thread, ever — and the role +/// rides the dispatch: it selects the prompt's opening framing and is +/// the seam where per-role tool policies land later. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + /// Asked to review: judge the pull request and post the review. + Reviewer, + /// Asked to do the work: implement the issue or answer the feedback. + Implementer, +} + +impl Role { + /// The payload and prompt spelling. + pub fn as_str(self) -> &'static str { + match self { + Role::Reviewer => "reviewer", + Role::Implementer => "implementer", + } + } +} + +/// What one notification earns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Triage { + /// Dispatch the thread's agent as this role. + Agent(Role), + /// No agent: surface the notification to the account's human through + /// the device-notification queue. + Surface, + /// Drop it: noise the account should not hear about. + Ignore, +} + +/// Classifies one notification. A forge reason the vocabulary does not +/// know ([`NotificationReason::Other`]) surfaces rather than acts: an +/// unknown shape is the user's to judge, never the agent's to guess at. +pub fn classify(notification: &Notification) -> Triage { + match ¬ification.reason { + NotificationReason::ReviewRequested => Triage::Agent(Role::Reviewer), + NotificationReason::Assigned => Triage::Agent(Role::Implementer), + NotificationReason::ReviewFeedback => Triage::Agent(Role::Implementer), + NotificationReason::Author => Triage::Agent(Role::Implementer), + NotificationReason::Mention => Triage::Surface, + NotificationReason::CiActivity => Triage::Ignore, + NotificationReason::Other(_) => Triage::Surface, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rhema_forge_integration::{RepoRef, Timestamp}; + + fn notification(reason: NotificationReason) -> Notification { + Notification { + id: format!("github:notification:1@{}", reason.as_str()), + reason, + repo: RepoRef::new("acme", "widgets"), + number: 7, + is_pr: true, + actor: None, + title: None, + url: None, + timestamp: Timestamp::parse("2026-10-08T00:00:00Z").expect("parses"), + raw: serde_json::json!({}), + } + } + + #[test] + fn asks_become_agent_dispatches_of_the_matching_role() { + assert_eq!( + classify(¬ification(NotificationReason::ReviewRequested)), + Triage::Agent(Role::Reviewer) + ); + for reason in [ + NotificationReason::Assigned, + NotificationReason::ReviewFeedback, + NotificationReason::Author, + ] { + let triage = classify(¬ification(reason.clone())); + assert_eq!( + triage, + Triage::Agent(Role::Implementer), + "{} implements", + reason.as_str() + ); + } + } + + #[test] + fn mentions_surface_ci_ignores_and_unknowns_surface() { + assert_eq!( + classify(¬ification(NotificationReason::Mention)), + Triage::Surface + ); + assert_eq!( + classify(¬ification(NotificationReason::CiActivity)), + Triage::Ignore + ); + assert_eq!( + classify(¬ification(NotificationReason::Other( + "subscription".to_owned() + ))), + Triage::Surface + ); + } + + #[test] + fn roles_round_trip_through_serde() { + for role in [Role::Reviewer, Role::Implementer] { + let encoded = serde_json::to_value(role).expect("serializes"); + assert_eq!( + serde_json::from_value::(encoded).expect("deserializes"), + role + ); + } + } +} diff --git a/crates/rhema-claw/src/turn_store.rs b/crates/rhema-claw/src/turn_store.rs new file mode 100644 index 0000000..df9d34b --- /dev/null +++ b/crates/rhema-claw/src/turn_store.rs @@ -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, +} + +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) -> Result { + 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 { + let conn = rusqlite::Connection::open_in_memory().map_err(TurnStoreError::from)?; + Self::init(conn, false) + } + + fn init(conn: rusqlite::Connection, durable: bool) -> Result { + 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, 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 = 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 { + 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); + } +} diff --git a/crates/rhema-claw/src/watch_store.rs b/crates/rhema-claw/src/watch_store.rs new file mode 100644 index 0000000..b864b56 --- /dev/null +++ b/crates/rhema-claw/src/watch_store.rs @@ -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, + /// 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 { + 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, +} + +/// 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, + /// 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) -> Result { + 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 { + let conn = rusqlite::Connection::open_in_memory().map_err(WatchStoreError::from)?; + Self::init(conn, false) + } + + fn init(conn: rusqlite::Connection, durable: bool) -> Result { + 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 { + 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 { + 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, 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::, 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 { + 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 { + 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 { + 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 { + 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, 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::, 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"); + } +} diff --git a/crates/rhema-claw/src/watch_tools.rs b/crates/rhema-claw/src/watch_tools.rs new file mode 100644 index 0000000..4d8dafe --- /dev/null +++ b/crates/rhema-claw/src/watch_tools.rs @@ -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) -> 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, + #[serde(default)] + token: Option, +} + +#[derive(Deserialize)] +struct UnwatchArgs { + forge: String, + repo: String, +} + +/// Starts watching: one row in the table. +struct WatchRepo { + store: Arc, +} + +#[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 { + 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, +} + +#[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 { + 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, +} + +#[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 { + let watches = self.store.list().map_err(|err| err.to_string())?; + let rows: Vec = 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 { + Arc::new(ClawStore::in_memory().expect("store opens")) + } + + fn names(registry: &ToolRegistry) -> Vec { + 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::(&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::(&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::(&removed.content).unwrap(), + json!({"watched": false, "removed": true, "forge": "github", "repo": "acme/widgets"}), + ); + assert_eq!(store.list().expect("list reads").len(), 1); + } +} diff --git a/crates/rhema-claw/tests/continuity.rs b/crates/rhema-claw/tests/continuity.rs new file mode 100644 index 0000000..cdf8b70 --- /dev/null +++ b/crates/rhema-claw/tests/continuity.rs @@ -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) -> Arc { + Arc::new( + rhema_claw::BackendRegistry::new( + vec![rhema_claw::Backend::headless("mock", provider)], + None, + ) + .expect("a single mock backend always constructs"), + ) +} diff --git a/crates/rhema-claw/tests/e2e.rs b/crates/rhema-claw/tests/e2e.rs new file mode 100644 index 0000000..62d4ee6 --- /dev/null +++ b/crates/rhema-claw/tests/e2e.rs @@ -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) -> Arc { + Arc::new( + rhema_claw::BackendRegistry::new( + vec![rhema_claw::Backend::headless("mock", provider)], + None, + ) + .expect("a single mock backend always constructs"), + ) +} diff --git a/crates/rhema-claw/tests/http.rs b/crates/rhema-claw/tests/http.rs new file mode 100644 index 0000000..d7d9056 --- /dev/null +++ b/crates/rhema-claw/tests/http.rs @@ -0,0 +1,2503 @@ +//! The chat surface, offline and end to end: a daemon over an ephemeral +//! HTTP port, a MockProvider scripted with the replies, wiremock for the +//! forge — zero network. The routes are exercised as a stock client +//! would: subscribe the event stream, POST a message, watch the turn go +//! by; add and remove watches over HTTP and watch the next detect pass +//! pick them up; talk `/v1/chat/completions` both streamed and whole; +//! feed `/v1/ingest` and poll the notification queue; and verify a +//! bearer token guards `/v1` while `/healthz` stays open. + +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use rhema_autonomous_agent::Allowlist; +use rhema_claw::watch_tools; +use rhema_claw::{ClawConfig, Daemon, ForgeKind, Watch}; +use rhema_core::{Content, Message, MockProvider, ModelOutput, Role, Session, StopReason, Usage}; +use rhema_rsd::{ResourceKey, SqliteStore, Store}; +use serde_json::{Value, json}; +use tokio_stream::StreamExt; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// 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 scripted assistant turn that asks for a tool by name. +fn tool_call(id: &str, name: &str, args: Value) -> ModelOutput { + ModelOutput { + message: Message { + role: Role::Assistant, + content: vec![Content::ToolCall(rhema_core::ToolCall { + id: id.to_owned(), + name: name.to_owned(), + args, + provider_data: Value::Null, + })], + }, + stop_reason: StopReason::ToolUse, + usage: Usage::default(), + } +} + +/// The test's claw config: everything under one tempdir, no seed +/// watches, no listen address of its own (the test binds ephemeral). +fn config(dir: &tempfile::TempDir, token: Option<&str>) -> ClawConfig { + ClawConfig { + db: Some(dir.path().join("dispatch.db")), + workspace_root: Some(dir.path().join("workspaces")), + memory_root: None, + bootstrap_root: Some(dir.path().join("bootstrap")), + 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: token.map(str::to_owned), + watches: Vec::new(), + calendars: Vec::new(), + } +} + +/// Binds the daemon's router on an ephemeral loopback port and returns +/// the base URL. +async fn serve(daemon: &Daemon) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the port binds"); + let addr = listener.local_addr().expect("the address reads"); + let app = daemon.router(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + format!("http://{addr}") +} + +/// Reads the SSE body until `needle` appears in the accumulated text. +/// The stream never ends on its own (a chat UI subscribes once and +/// chats forever), so the deadline is the backstop. +type SseBody = Pin> + Send>>; + +async fn read_until(stream: &mut SseBody, seen: &mut String, needle: &str) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while !seen.contains(needle) { + let chunk = match tokio::time::timeout_at(deadline, stream.next()).await { + Ok(Some(Ok(chunk))) => chunk, + Ok(_) => panic!( + "the stream ended early waiting for {needle:?}; seen: {}", + &seen[seen.len().saturating_sub(600)..] + ), + Err(_) => panic!( + "timed out waiting for {needle:?}; seen tail: {}", + &seen[seen.len().saturating_sub(600)..] + ), + }; + seen.push_str(&String::from_utf8_lossy(&chunk)); + } +} + +/// Pins and returns the SSE body of a streaming GET. +async fn sse_body(response: reqwest::Response) -> SseBody { + Box::pin(response.bytes_stream()) +} + +/// One quiet GitHub repo, for the watches test: an issues list with a +/// single closed issue and a quiet comments feed. +async fn mount_github(server: &MockServer) { + Mock::given(method("GET")) + .and(path("/repos/acme/widgets/issues")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([{ + "id": 1007, + "number": 7, + "state": "closed", + "title": "crash on save", + "user": { "login": "alice" }, + "labels": [], + "body": "steps to reproduce…", + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-02T10:00:00Z", + "closed_at": "2026-10-02T10:00:00Z", + "html_url": "https://github.com/acme/widgets/issues/7" + }]))) + .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; +} + +#[tokio::test] +async fn chat_streams_turns_and_remembers_across_messages() { + let provider = Arc::new(MockProvider::new([reply("on it"), reply("still on it")])); + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(provider.clone()), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + // The subscriber comes first: a chat UI subscribes once and chats + // forever. + let events = client + .get(format!("{base}/v1/sessions/interactive:web/events")) + .send() + .await + .expect("the events request goes out"); + assert_eq!(events.status(), 200); + let content_type = events + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .expect("a content type") + .to_owned(); + assert!( + content_type.starts_with("text/event-stream"), + "{content_type}" + ); + let mut stream = sse_body(events).await; + let mut seen = String::new(); + + // Inventory before anything happened: no keys have records. + let sessions: Vec = client + .get(format!("{base}/v1/sessions")) + .send() + .await + .expect("sessions reads") + .json() + .await + .expect("sessions parses"); + assert!(sessions.is_empty(), "{sessions:?}"); + + // Message one: accepted, and the turn streams. + let accepted = client + .post(format!("{base}/v1/sessions/interactive:web/messages")) + .json(&json!({"text": "hello"})) + .send() + .await + .expect("the message posts"); + assert_eq!(accepted.status(), 202); + assert_eq!( + accepted.json::().await.expect("body parses"), + json!({"key": "interactive:web", "accepted": true}) + ); + read_until(&mut stream, &mut seen, "on it").await; + read_until(&mut stream, &mut seen, r#""type":"turn_done""#).await; + assert!( + seen.contains(r#""type":"text_delta","text":"on it""#), + "the reply streamed as a delta: {seen}" + ); + assert!( + seen.contains(r#""reply":"on it","outcome":"handled""#), + "the end marker carries the reply: {seen}" + ); + + // Message two: same key, same stream — and the provider gets its + // SECOND scripted reply only if the conversation resumed. + client + .post(format!("{base}/v1/sessions/interactive:web/messages")) + .json(&json!({"text": "again"})) + .send() + .await + .expect("the second message posts") + .error_for_status() + .expect("the second message accepts"); + read_until(&mut stream, &mut seen, "still on it").await; + + // The proof, cold: the store's driver_state carries both exchanges. + let store = SqliteStore::open(dir.path().join("dispatch.db")).expect("db reopens"); + let key = ResourceKey::from("interactive:web"); + let record = store + .load(&key) + .expect("the record loads") + .expect("the key has a record"); + let stashed: Session = + serde_json::from_value(record.session.expect("a session").core.driver_state) + .expect("the stash parses"); + let turns: Vec<(Role, &str)> = stashed + .history + .iter() + .filter_map(|message| match &message.content[0] { + Content::Text(text) => Some((message.role, text.as_str())), + _ => None, + }) + .collect(); + assert_eq!( + turns, + [ + (Role::User, "hello"), + (Role::Assistant, "on it"), + (Role::User, "again"), + (Role::Assistant, "still on it"), + ], + "the conversation resumed across messages" + ); + + // The session is now in the inventory with its last outcome. + let sessions: Vec = client + .get(format!("{base}/v1/sessions")) + .send() + .await + .expect("sessions reads") + .json() + .await + .expect("sessions parses"); + assert_eq!(sessions.len(), 1, "{sessions:?}"); + assert_eq!(sessions[0]["key"], "interactive:web"); + assert_eq!(sessions[0]["state"], "idle"); + assert_eq!(sessions[0]["inbox_depth"], 0); + assert_eq!(sessions[0]["last_outcome"], "Handled"); + + // Non-interactive keys are refused. The key travels percent-encoded: + // a raw slash would leave the single-segment route. + let refused = client + .post(format!( + "{base}/v1/sessions/github%3Aacme%2Fwidgets%237/messages" + )) + .json(&json!({"text": "hello"})) + .send() + .await + .expect("the forged key posts"); + assert_eq!(refused.status(), 400, "only resident keys take messages"); +} + +/// A turn nobody watched is still replayable: a client arriving after +/// the fact — a reloaded browser, a reopened phone — subscribes cold and +/// gets the finished turns back before the live feed. The same holds +/// across a daemon restart: the flushed record seeds the next boot's +/// hub. +#[tokio::test] +async fn a_cold_subscription_replays_the_turns_it_missed_even_after_a_restart() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = config(&dir, None); + { + let daemon = Daemon::build( + &config, + mock_registry(Arc::new(MockProvider::new([reply("on it")]))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + // The turn runs with nobody watching. The POST's drain finishes + // it and flushes the record before the 202 returns. + let accepted = client + .post(format!("{base}/v1/sessions/interactive:web/messages")) + .json(&json!({"text": "hello"})) + .send() + .await + .expect("the message posts"); + assert_eq!(accepted.status(), 202); + } + + // A fresh daemon over the same database — the restart — serves the + // same transcript to the first subscriber. + let daemon = Daemon::build( + &config, + mock_registry(Arc::new(MockProvider::new([]))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the restarted daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + let events = client + .get(format!("{base}/v1/sessions/interactive:web/events")) + .send() + .await + .expect("the events request goes out"); + assert_eq!(events.status(), 200); + let mut stream = sse_body(events).await; + let mut seen = String::new(); + read_until( + &mut stream, + &mut seen, + r#""type":"user_message","text":"hello""#, + ) + .await; + read_until( + &mut stream, + &mut seen, + r#""type":"text_delta","text":"on it""#, + ) + .await; + read_until(&mut stream, &mut seen, r#""type":"turn_done""#).await; + assert!( + seen.find(r#""type":"user_message""#).unwrap() + < seen.find(r#""type":"text_delta""#).unwrap(), + "the replay arrives in stream order: {seen}" + ); +} + +/// The `since` cursor: a client that already holds the record's first +/// event receives the record minus it — the phone client's transcript +/// cache, so reopening a session is not a rebuild from zero. +#[tokio::test] +async fn a_since_cursor_skips_the_replays_oldest_events() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = config(&dir, None); + { + let daemon = Daemon::build( + &config, + mock_registry(Arc::new(MockProvider::new([reply("on it")]))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + // One turn, nobody watching; the drain flushes the record + // before the 202 returns. + let accepted = client + .post(format!("{base}/v1/sessions/interactive:web/messages")) + .json(&json!({"text": "hello"})) + .send() + .await + .expect("the message posts"); + assert_eq!(accepted.status(), 202); + } + + let daemon = Daemon::build( + &config, + mock_registry(Arc::new(MockProvider::new([]))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the restarted daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + let events = client + .get(format!("{base}/v1/sessions/interactive:web/events?since=1")) + .send() + .await + .expect("the events request goes out"); + assert_eq!(events.status(), 200); + let mut stream = sse_body(events).await; + let mut seen = String::new(); + read_until(&mut stream, &mut seen, r#""type":"text_delta""#).await; + read_until(&mut stream, &mut seen, r#""type":"turn_done""#).await; + assert!( + !seen.contains(r#""type":"user_message""#), + "the event the cursor names does not replay: {seen}" + ); +} + +/// The other two resident prefixes take chat messages, and their turns' +/// replies auto-notify: a `phone:` key delivers to the device its name +/// carries, a `task:` key to the phone. Forge keys stay refused. +#[tokio::test] +async fn phone_and_task_keys_take_messages_and_auto_notify_their_replies() { + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(Arc::new(MockProvider::new([reply("on it"), reply("done")]))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + // A phone-surface message: accepted like a chat message. + let accepted = client + .post(format!("{base}/v1/sessions/phone%3Aana/messages")) + .json(&json!({"text": "is CI green?"})) + .send() + .await + .expect("the message posts"); + assert_eq!(accepted.status(), 202, "phone: keys take messages"); + + // The reply had no subscriber: it landed in the notification table, + // on the device the key names. + let listed: Value = client + .get(format!("{base}/v1/notifications?device=ana")) + .send() + .await + .expect("the poll reads") + .json() + .await + .expect("the poll parses"); + let rows = listed["notifications"].as_array().expect("rows"); + assert_eq!(rows.len(), 1, "{listed}"); + assert_eq!(rows[0]["body"], "on it"); + assert_eq!(rows[0]["device"], "ana"); + assert_eq!(rows[0]["title"], "claw"); + + // A task-surface message notifies the phone: the key names no + // device, so the default answers. + client + .post(format!("{base}/v1/sessions/task%3A7/messages")) + .json(&json!({"text": "check the bread"})) + .send() + .await + .expect("the message posts") + .error_for_status() + .expect("task: keys take messages"); + let listed: Value = client + .get(format!("{base}/v1/notifications")) + .send() + .await + .expect("the poll reads") + .json() + .await + .expect("the poll parses"); + let rows = listed["notifications"].as_array().expect("rows"); + assert_eq!( + rows.iter() + .map(|row| row["body"].as_str()) + .collect::>(), + [Some("done")], + "the task reply is the only outstanding phone row: {listed}" + ); +} + +#[tokio::test] +async fn watches_added_over_http_feed_the_next_detect_pass() { + let server = MockServer::start().await; + mount_github(&server).await; + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + // One scripted reply, in case a forge run starts; this test + // asserts detection only. + mock_registry(Arc::new(MockProvider::new([reply("on it")]))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + // Empty config, empty table: nothing detects. + let report = daemon.tick().await.expect("the pass runs"); + assert_eq!(report.detected, 0); + + // Add a watch over HTTP, aimed at the wiremock. + let added = client + .post(format!("{base}/v1/watches")) + .json(&json!({ + "forge": "github", + "repo": "acme/widgets", + "host": server.uri(), + "token": "tok", + })) + .send() + .await + .expect("the watch posts"); + assert_eq!(added.status(), 201, "a new watch is created"); + assert_eq!( + added.json::().await.expect("body parses"), + json!({"added": true, "forge": "github", "repo": "acme/widgets"}) + ); + + // The table answers the list route, tokens never on the wire. + let listed: Value = client + .get(format!("{base}/v1/watches")) + .send() + .await + .expect("the list reads") + .json() + .await + .expect("the list parses"); + let watch = &listed["watches"][0]; + assert_eq!(watch["forge"], "github"); + assert_eq!(watch["repo"], "acme/widgets"); + assert_eq!(watch["host"], server.uri()); + assert!( + watch.get("token").is_none(), + "the token is not serialized: {listed}" + ); + + // The next pass detects through the new watch — no restart. + let report = daemon.tick().await.expect("the pass runs"); + assert_eq!(report.detected, 1, "the new watch's feed event was fed"); + + // A re-add is a no-op with a 200, not a duplicate row. + let re_added = client + .post(format!("{base}/v1/watches")) + .json(&json!({"forge": "github", "repo": "acme/widgets", "host": server.uri()})) + .send() + .await + .expect("the re-add posts"); + assert_eq!(re_added.status(), 200, "already watched"); + + // Deleting removes; a bogus watch is refused loudly. + let removed = client + .delete(format!("{base}/v1/watches/github/acme/widgets")) + .send() + .await + .expect("the delete goes out"); + assert_eq!( + removed.json::().await.expect("body parses"), + json!({"removed": true}) + ); + let bogus = client + .post(format!("{base}/v1/watches")) + .json(&json!({"forge": "gitea", "repo": "acme/barn"})) + .send() + .await + .expect("the bogus watch posts"); + assert_eq!(bogus.status(), 400, "a hostless gitea watch is refused"); + + // Gone from the table, the pass detects nothing. + let report = daemon.tick().await.expect("the pass runs"); + assert_eq!(report.detected, 0, "the watch list reads fresh each pass"); +} + +#[tokio::test] +async fn the_watch_tools_mutate_the_same_table_the_http_routes_read() { + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(Arc::new(MockProvider::new([reply("on it")]))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + // A second connection to the same db file — exactly what the + // handler's watch tools get — lands in the table the routes read. + let tools_store = + Arc::new(rhema_claw::ClawStore::open(dir.path().join("dispatch.db")).expect("reopens")); + let registry = watch_tools::registry(tools_store); + let resolver = registry.resolver_for(&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/gadgets", "token": "tok"}), + provider_data: Value::Null, + }, + &ctx, + ) + .await; + assert!(!result.is_error, "{}", result.content); + + // HTTP sees the tool's row; the tool sees HTTP's rows. + let listed: Value = client + .get(format!("{base}/v1/watches")) + .send() + .await + .expect("the list reads") + .json() + .await + .expect("the list parses"); + assert_eq!( + listed["watches"][0]["repo"], "acme/gadgets", + "the tool's write is the HTTP surface's read: {listed}" + ); + let tools_view = resolver + .resolve( + rhema_core::ToolCall { + id: "c2".into(), + name: "list_watches".into(), + args: json!({}), + provider_data: Value::Null, + }, + &ctx, + ) + .await; + assert!( + tools_view.content.contains("acme/gadgets"), + "{}", + tools_view.content + ); + assert!( + !tools_view.content.contains("tok"), + "tool answers never carry tokens: {}", + tools_view.content + ); + + // And unwatch_repo removes what watch_repo added. + let removed = resolver + .resolve( + rhema_core::ToolCall { + id: "c3".into(), + name: "unwatch_repo".into(), + args: json!({"forge": "github", "repo": "acme/gadgets"}), + provider_data: Value::Null, + }, + &ctx, + ) + .await; + assert!(!removed.is_error, "{}", removed.content); + let listed: Value = client + .get(format!("{base}/v1/watches")) + .send() + .await + .expect("the list reads") + .json() + .await + .expect("the list parses"); + assert_eq!(listed["watches"].as_array().map(Vec::len), Some(0)); +} + +#[tokio::test] +async fn the_shim_answers_whole_and_streamed_and_ignores_incoming_history() { + let provider = Arc::new(MockProvider::new([reply("on it"), reply("still here")])); + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(provider.clone()), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + let url = format!("{base}/v1/chat/completions"); + + // Non-stream: one completion object, the configured model echoed + // when the request names none. + let whole: Value = client + .post(&url) + .json(&json!({"messages": [{"role": "user", "content": "hello"}]})) + .send() + .await + .expect("the completion posts") + .json() + .await + .expect("the completion parses"); + assert_eq!(whole["object"], "chat.completion"); + assert_eq!(whole["model"], "mock"); + assert_eq!(whole["choices"][0]["message"]["role"], "assistant"); + assert_eq!(whole["choices"][0]["message"]["content"], "on it"); + assert_eq!(whole["choices"][0]["finish_reason"], "stop"); + assert!( + whole["id"] + .as_str() + .is_some_and(|id| id.starts_with("chatcmpl-")) + ); + assert!(whole["created"].as_u64().is_some()); + + // Stream: chunks of the reply, the stop chunk, then [DONE]. + let streamed = client + .post(&url) + .json(&json!({ + "model": "custom/m", + "stream": true, + "messages": [{"role": "user", "content": "again"}], + })) + .send() + .await + .expect("the streamed completion posts"); + assert_eq!(streamed.status(), 200); + let mut body = String::new(); + let mut stream = sse_body(streamed).await; + read_until(&mut stream, &mut body, "data: [DONE]").await; + assert!( + body.contains(r#""delta":{"content":"still here"}"#), + "the reply streamed as content chunks: {body}" + ); + assert!( + !body.contains(r#""delta":{"reasoning_content":"#), + "a text-only mock emits no reasoning chunks: {body}" + ); + assert!( + body.contains(r#""finish_reason":"stop""#), + "the final chunk stops: {body}" + ); + assert!( + body.contains(r#""model":"custom/m""#), + "the model echoes: {body}" + ); + assert!(body.trim_end().ends_with("data: [DONE]"), "{body}"); // The documented caveat, pinned: incoming history is ignored. Only + // the last user message reached the agent. + let store = SqliteStore::open(dir.path().join("dispatch.db")).expect("db reopens"); + let record = store + .load(&ResourceKey::from("interactive:web")) + .expect("the record loads") + .expect("the key has a record"); + let stashed: Session = + serde_json::from_value(record.session.expect("a session").core.driver_state) + .expect("the stash parses"); + let prompts: Vec<&str> = stashed + .history + .iter() + .filter_map(|message| match &message.content[0] { + Content::Text(text) if message.role == Role::User => Some(text.as_str()), + _ => None, + }) + .collect(); + assert_eq!(prompts, ["hello", "again"], "no transcript was replayed in"); +} + +#[tokio::test] +async fn the_shim_rejects_requests_without_a_user_message() { + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(Arc::new(MockProvider::new(Vec::new()))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + let refused = client + .post(format!("{base}/v1/chat/completions")) + .json(&json!({"messages": [{"role": "assistant", "content": "only me"}]})) + .send() + .await + .expect("the request posts"); + assert_eq!(refused.status(), 400); + let body: Value = refused.json().await.expect("the error parses"); + assert!( + body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("user")), + "{body}" + ); +} + +/// The bootstrap badge as the route serves it for `daemon`, with the +/// config's bearer. +async fn bootstrap_badge(daemon: &Daemon) -> Value { + let base = serve(daemon).await; + reqwest::Client::new() + .get(format!("{base}/v1/bootstrap")) + .header(reqwest::header::AUTHORIZATION, "Bearer sekrit") + .send() + .await + .expect("the badge reads") + .json() + .await + .expect("the badge parses") +} + +/// GET /v1/bootstrap reflects the store live, behind the same bearer +/// check as every /v1 route: a UI badges first-run setup from it while +/// the onboarding offer is open. +#[tokio::test] +async fn the_bootstrap_route_reports_the_identity_files_and_requires_auth() { + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, Some("sekrit")), + mock_registry(Arc::new(MockProvider::new(Vec::new()))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let url = format!("{base}/v1/bootstrap"); + let client = reqwest::Client::new(); + + // The badge is a /v1 route: open reads are refused. + assert_eq!( + client + .get(&url) + .send() + .await + .expect("the open read goes out") + .status(), + 401 + ); + + // A fresh install: both files unwritten. + assert_eq!( + bootstrap_badge(&daemon).await, + json!({"soul": false, "user": false}) + ); + + // The files the onboarding flow writes flip the badge live — the + // route reads the store per request, never a boot-time snapshot. + let root = dir.path().join("bootstrap"); + std::fs::write(root.join("SOUL.md"), "I am claw: terse.").expect("write"); + assert_eq!( + bootstrap_badge(&daemon).await, + json!({"soul": true, "user": false}) + ); + std::fs::write(root.join("USER.md"), "The user is Ana.").expect("write"); + assert_eq!( + bootstrap_badge(&daemon).await, + json!({"soul": true, "user": true}) + ); +} + +#[tokio::test] +async fn a_configured_token_guards_v1_and_leaves_healthz_open() { + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, Some("sekrit")), + mock_registry(Arc::new(MockProvider::new([reply("on it")]))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + // Health is open; /v1 is not. + assert_eq!( + client + .get(format!("{base}/healthz")) + .send() + .await + .expect("health reads") + .text() + .await + .expect("health body"), + "ok" + ); + assert_eq!( + client + .get(format!("{base}/v1/watches")) + .send() + .await + .expect("the unauthenticated read goes out") + .status(), + 401 + ); + assert_eq!( + client + .get(format!("{base}/v1/watches")) + .header(reqwest::header::AUTHORIZATION, "Bearer wrong") + .send() + .await + .expect("the mis-authenticated read goes out") + .status(), + 401 + ); + + // The right bearer opens every /v1 route, chat included. + let watches: Value = client + .get(format!("{base}/v1/watches")) + .header(reqwest::header::AUTHORIZATION, "Bearer sekrit") + .send() + .await + .expect("the authenticated read goes out") + .json() + .await + .expect("the list parses"); + assert_eq!(watches["watches"].as_array().map(Vec::len), Some(0)); + let whole: Value = client + .post(format!("{base}/v1/chat/completions")) + .header(reqwest::header::AUTHORIZATION, "Bearer sekrit") + .json(&json!({"messages": [{"role": "user", "content": "hello"}]})) + .send() + .await + .expect("the completion posts") + .json() + .await + .expect("the completion parses"); + assert_eq!(whole["choices"][0]["message"]["content"], "on it"); +} + +/// The watch list as the HTTP route serves it for `daemon`. +async fn list_watches(daemon: &Daemon) -> Value { + let base = serve(daemon).await; + reqwest::Client::new() + .get(format!("{base}/v1/watches")) + .send() + .await + .expect("the list reads") + .json() + .await + .expect("the list parses") +} + +/// The seed rule, through the daemon's assembly: an empty table takes +/// the config's watches; a populated table ignores the seed, so a +/// restart with a different YAML never rewrites what the table holds. +#[tokio::test] +async fn the_seed_imports_into_an_empty_table_and_then_the_table_rules() { + let dir = tempfile::tempdir().expect("tempdir"); + let yaml_seed = || ClawConfig { + watches: vec![Watch { + forge: ForgeKind::GitHub, + repo: "acme/seeded".to_owned(), + host: None, + token: Some("tok".to_owned()), + }], + ..config(&dir, None) + }; + let changed_seed = || ClawConfig { + watches: vec![Watch { + forge: ForgeKind::GitLab, + repo: "group/other".to_owned(), + host: None, + token: Some("tok".to_owned()), + }], + ..config(&dir, None) + }; + + // First boot: the empty table takes the seed. + let first = Daemon::build( + &yaml_seed(), + mock_registry(Arc::new(MockProvider::new(Vec::new()))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the first daemon assembles"); + let listed = list_watches(&first).await; + assert_eq!(listed["watches"][0]["repo"], "acme/seeded", "{listed}"); + + // Second boot, different YAML, populated table: the table rules. + let second = Daemon::build( + &changed_seed(), + mock_registry(Arc::new(MockProvider::new(Vec::new()))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the second daemon assembles"); + let listed = list_watches(&second).await; + assert_eq!( + listed["watches"].as_array().map(Vec::len), + Some(1), + "the seed never rewrites a populated table: {listed}" + ); + assert_eq!(listed["watches"][0]["repo"], "acme/seeded", "{listed}"); +} + +/// The ingest door: an external event lands on its key in the resident +/// dispatcher, an identical redelivery dedups, the daemon's own source +/// names cannot be re-registered, and the bearer guard covers it like +/// every `/v1` route. +#[tokio::test] +async fn ingest_feeds_the_resident_dispatcher_and_dedups_and_requires_auth() { + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, Some("sekrit")), + mock_registry(Arc::new(MockProvider::new(Vec::new()))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + let url = format!("{base}/v1/ingest"); + let auth = |request: reqwest::RequestBuilder| { + request.header(reqwest::header::AUTHORIZATION, "Bearer sekrit") + }; + let body = json!({"source": "phone", "key": "phone:ana", "payload": {"text": "hello"}}); + + // The door is a /v1 route: open posts are refused. + assert_eq!( + client + .post(&url) + .json(&body) + .send() + .await + .expect("the open post goes out") + .status(), + 401 + ); + + let first: Value = auth(client.post(&url)) + .json(&body) + .send() + .await + .expect("the event posts") + .json() + .await + .expect("the answer parses"); + assert_eq!(first, json!({"ingested": "dispatched"}), "{first}"); + + // The event sits on its key: the inventory — which reads the shared + // store through the resident dispatcher — shows the depth. + let sessions: Vec = auth(client.get(format!("{base}/v1/sessions"))) + .send() + .await + .expect("sessions reads") + .json() + .await + .expect("sessions parses"); + let entry = sessions + .iter() + .find(|session| session["key"] == "phone:ana") + .expect("the key is in the inventory"); + assert_eq!(entry["inbox_depth"], 1, "{entry}"); + assert_eq!(entry["state"], "idle"); + + // An identical redelivery dedups; the depth does not move. + let second: Value = auth(client.post(&url)) + .json(&body) + .send() + .await + .expect("the replay posts") + .json() + .await + .expect("the answer parses"); + assert_eq!(second, json!({"ingested": "duplicate"}), "{second}"); + let sessions: Vec = auth(client.get(format!("{base}/v1/sessions"))) + .send() + .await + .expect("sessions reads") + .json() + .await + .expect("sessions parses"); + let entry = sessions + .iter() + .find(|session| session["key"] == "phone:ana") + .expect("the key is still in the inventory"); + assert_eq!(entry["inbox_depth"], 1, "the dedup dropped it: {entry}"); + + // A changed payload re-ingests: the depth moves. + let changed: Value = auth(client.post(&url)) + .json(&json!({ + "source": "phone", + "key": "phone:ana", + "payload": {"text": "hello again"}, + })) + .send() + .await + .expect("the changed post goes out") + .json() + .await + .expect("the answer parses"); + assert_eq!(changed, json!({"ingested": "dispatched"}), "{changed}"); + + // Shape checks: empty key, non-object payload, and the daemon's own + // source names, which a re-register would misroute. + for (bad, status) in [ + (json!({"source": "phone", "key": " ", "payload": {}}), 400), + ( + json!({"source": "phone", "key": "phone:ana", "payload": [1]}), + 422, + ), + ( + json!({"source": "github", "key": "phone:ana", "payload": {}}), + 400, + ), + ( + json!({"source": "tasks", "key": "phone:ana", "payload": {}}), + 400, + ), + ] { + let response = auth(client.post(&url)) + .json(&bad) + .send() + .await + .expect("the bad post goes out"); + assert_eq!(response.status(), status, "{bad}"); + } +} + +/// The notification queue over HTTP: a second connection to the same db +/// file — exactly what the handler's notify tool gets — lands in the +/// queue the routes read; the poll returns a device's outstanding rows +/// ascending, `since` and `device` filter, and the ack delivers. +#[tokio::test] +async fn notifications_round_trip_over_http() { + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(Arc::new(MockProvider::new(Vec::new()))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + let store = Arc::new( + rhema_claw::NotificationStore::open(dir.path().join("dispatch.db")).expect("reopens"), + ); + let first = store.push("phone", "claw", "first").expect("push lands"); + let second = store.push("phone", "claw", "second").expect("push lands"); + store.push("watch", "claw", "wrist").expect("push lands"); + + // The default queue is the phone's: two outstanding, ascending. + let listed: Value = client + .get(format!("{base}/v1/notifications")) + .send() + .await + .expect("the poll reads") + .json() + .await + .expect("the poll parses"); + let rows = listed["notifications"].as_array().expect("rows"); + assert_eq!( + rows.iter() + .map(|row| row["body"].as_str()) + .collect::>(), + [Some("first"), Some("second")], + "{listed}" + ); + assert_eq!(rows[0]["device"], "phone"); + assert!(rows[0]["delivered_at"].is_null(), "outstanding"); + + // `since` skips what the device saw; `device` picks the queue. + let since = first.id; + let listed: Value = client + .get(format!( + "{base}/v1/notifications?device=phone&since={since}" + )) + .send() + .await + .expect("the poll reads") + .json() + .await + .expect("the poll parses"); + assert_eq!( + listed["notifications"].as_array().map(Vec::len), + Some(1), + "{listed}" + ); + let listed: Value = client + .get(format!("{base}/v1/notifications?device=watch")) + .send() + .await + .expect("the poll reads") + .json() + .await + .expect("the poll parses"); + assert_eq!(listed["notifications"][0]["body"], "wrist", "{listed}"); + + // Acking delivers: the row stops answering, a re-ack is a quiet + // false, and an unknown id is refused the same quiet way. + let acked: Value = client + .post(format!("{base}/v1/notifications/{}/ack", second.id)) + .send() + .await + .expect("the ack posts") + .json() + .await + .expect("the ack parses"); + assert_eq!(acked, json!({"acked": true}), "{acked}"); + let listed: Value = client + .get(format!("{base}/v1/notifications")) + .send() + .await + .expect("the poll reads") + .json() + .await + .expect("the poll parses"); + assert_eq!( + listed["notifications"].as_array().map(Vec::len), + Some(1), + "the acked row is delivered: {listed}" + ); + for id in [second.id, 10_000] { + let acked: Value = client + .post(format!("{base}/v1/notifications/{id}/ack")) + .send() + .await + .expect("the ack posts") + .json() + .await + .expect("the ack parses"); + assert_eq!(acked, json!({"acked": false}), "{acked}"); + } +} + +/// A registry whose only backend is the scripted mock. +fn mock_registry(provider: Arc) -> Arc { + Arc::new( + rhema_claw::BackendRegistry::new( + vec![rhema_claw::Backend::headless("mock", provider)], + None, + ) + .expect("a single mock backend always constructs"), + ) +} + +/// The providers surface: the registry's list, the active pair, a +/// runtime switch, and the switch's persistence across a daemon +/// rebuild — the whole point of the runtime registry. +#[tokio::test] +async fn providers_are_listed_switched_and_persisted() { + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(Arc::new(MockProvider::new([reply("on it")]))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + let listed: Value = client + .get(format!("{base}/v1/providers")) + .send() + .await + .expect("the list reads") + .json() + .await + .expect("the list parses"); + assert_eq!(listed["active"]["provider"], "mock"); + assert_eq!(listed["active"]["model"], "mock"); + let kinds: Vec<&str> = listed["providers"] + .as_array() + .expect("the providers array") + .iter() + .filter_map(|p| p["kind"].as_str()) + .collect(); + assert_eq!(kinds, vec!["mock"]); + + // The addable kinds name their login fields with what the daemon + // demands: keyed kinds cannot construct without an api_key, and + // llama.cpp serves keyless on a default url, so nothing gates it. + let addable = listed["addable"].as_array().expect("the addable array"); + let fields_of = |kind: &str| { + addable + .iter() + .find(|entry| entry["kind"] == kind) + .expect("the kind is addable")["fields"] + .clone() + }; + assert_eq!( + fields_of("google"), + json!([{ "name": "api_key", "required": true }]) + ); + assert_eq!( + fields_of("llama-cpp"), + json!([ + { "name": "url", "required": false }, + { "name": "api_key", "required": false } + ]) + ); + + // A switch to a model within the same backend. + let switched: Value = client + .put(format!("{base}/v1/providers/active")) + .json(&json!({"provider": "mock", "model": "mock-large"})) + .send() + .await + .expect("the switch posts") + .json() + .await + .expect("the switch parses"); + assert_eq!(switched["active"]["model"], "mock-large"); + + // An unknown kind is a 400, and the active pair is untouched. + let refused = client + .put(format!("{base}/v1/providers/active")) + .json(&json!({"provider": "nope"})) + .send() + .await + .expect("the refusal posts"); + assert_eq!(refused.status(), 400); + + // The live-listing route: the mock backend has none, so 404 — + // the shape, not the network. + let models = client + .get(format!("{base}/v1/providers/mock/models")) + .send() + .await + .expect("the models route answers"); + assert_eq!(models.status(), 404); + + // Persistence: a rebuilt daemon over the same db reopens the + // switched pair, not the config seed. + drop(base); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(Arc::new(MockProvider::new([reply("on it")]))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon reassembles"); + assert_eq!(daemon.backends().active().1, "mock-large"); +} + +/// The credentials surface: a runtime login adds a backend, the list +/// names where its credential came from, a re-add replaces it in place, +/// and a removal drops it — refusing to remove the last backend, and +/// moving the active pair when the removed kind was serving. +#[tokio::test] +async fn providers_gain_and_lose_backends_over_the_surface() { + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(Arc::new(MockProvider::new([reply("on it")]))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + // A login: llama.cpp takes a url, no key, and joins the registry + // under its kind's id. + let added: Value = client + .put(format!("{base}/v1/providers/llama-cpp/credentials")) + .json(&json!({"kind": "llama-cpp", "url": "http://127.0.0.1:9999"})) + .send() + .await + .expect("the login posts") + .json() + .await + .expect("the state parses"); + let row = added["providers"] + .as_array() + .expect("the providers array") + .iter() + .find(|p| p["kind"] == "llama-cpp") + .expect("the new backend listed") + .clone(); + assert_eq!(row["id"], "llama-cpp"); + assert_eq!(row["source"], "stored"); + assert_eq!(row["default_model"], "local"); + assert_eq!(added["active"]["provider"], "mock", "the pair is untouched"); + + // A re-add replaces the id in place instead of duplicating it. + let replaced: Value = client + .put(format!("{base}/v1/providers/llama-cpp/credentials")) + .json(&json!({"url": "http://127.0.0.1:10000"})) + .send() + .await + .expect("the re-add posts") + .json() + .await + .expect("the state parses"); + let llama_rows = replaced["providers"] + .as_array() + .expect("the providers array") + .iter() + .filter(|p| p["kind"] == "llama-cpp") + .count(); + assert_eq!(llama_rows, 1); + + // A keyed kind without its key is a 400, and nothing was added. + let refused = client + .put(format!("{base}/v1/providers/google/credentials")) + .json(&json!({"kind": "google"})) + .send() + .await + .expect("the refusal posts"); + assert_eq!(refused.status(), 400); + let listed: Value = client + .get(format!("{base}/v1/providers")) + .send() + .await + .expect("the list reads") + .json() + .await + .expect("the list parses"); + assert!( + !listed["providers"] + .as_array() + .expect("the providers array") + .iter() + .any(|p| p["kind"] == "google") + ); + + // An unknown kind is a 400 too, with or without the key. + for body in [ + json!({"api_key": "k"}), + json!({"kind": "nope", "api_key": "k"}), + ] { + let unknown = client + .put(format!("{base}/v1/providers/nope/credentials")) + .json(&body) + .send() + .await + .expect("the refusal posts"); + assert_eq!(unknown.status(), 400, "{body}"); + } + + // Removal: the active instance going away moves the pair to what + // remains, and the persisted pair moves with it. + let removed: Value = client + .delete(format!("{base}/v1/providers/mock")) + .send() + .await + .expect("the removal posts") + .json() + .await + .expect("the state parses"); + assert_eq!(removed["active"]["provider"], "llama-cpp"); + + // The last backend refuses to go. + let last = client + .delete(format!("{base}/v1/providers/llama-cpp")) + .send() + .await + .expect("the refusal posts"); + assert_eq!(last.status(), 400); + + // A removal of an unknown kind is a 400 as well. + let gone = client + .delete(format!("{base}/v1/providers/ethereal")) + .send() + .await + .expect("the refusal posts"); + assert_eq!(gone.status(), 400); +} + +/// The agent identity surface: the text fields round-trip with +/// partial-save semantics (an absent field is left alone, an empty one +/// clears), the avatar uploads and serves back with its content type, +/// and clearing it 404s the read. +#[tokio::test] +async fn the_agent_identity_is_editable_over_the_surface() { + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(Arc::new(MockProvider::new([reply("on it")]))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + let empty: Value = client + .get(format!("{base}/v1/agent")) + .send() + .await + .expect("the identity reads") + .json() + .await + .expect("the identity parses"); + assert_eq!(empty["avatar"], false); + assert_eq!(empty["logo"], false); + + // A save names the fields it touches. + let saved: Value = client + .put(format!("{base}/v1/agent")) + .json(&json!({"name": "Kosmos", "mark": "✦", "tagline": "order"})) + .send() + .await + .expect("the save posts") + .json() + .await + .expect("the save parses"); + assert_eq!(saved["name"], "Kosmos"); + assert_eq!(saved["mark"], "✦"); + assert_eq!(saved["avatar"], false); + + // A partial save leaves the fields it omitted. + let partial: Value = client + .put(format!("{base}/v1/agent")) + .json(&json!({"name": "Kosmos Prime"})) + .send() + .await + .expect("the partial save posts") + .json() + .await + .expect("the partial save parses"); + assert_eq!(partial["name"], "Kosmos Prime"); + assert_eq!(partial["mark"], "✦", "untouched by the partial save"); + + // An empty string clears its field. + let cleared: Value = client + .put(format!("{base}/v1/agent")) + .json(&json!({"mark": ""})) + .send() + .await + .expect("the clear posts") + .json() + .await + .expect("the clear parses"); + assert_eq!(cleared["mark"], serde_json::Value::Null); + + // An overlong field is a 400. + let refused = client + .put(format!("{base}/v1/agent")) + .json(&json!({"name": "x".repeat(65)})) + .send() + .await + .expect("the refusal posts"); + assert_eq!(refused.status(), 400); + + // The avatar: upload, serve, clear. + let uploaded: Value = client + .put(format!("{base}/v1/agent/avatar")) + .header("content-type", "image/png") + .body(vec![1, 2, 3, 4]) + .send() + .await + .expect("the upload posts") + .json() + .await + .expect("the upload parses"); + assert_eq!(uploaded["avatar"], true); + + let served = client + .get(format!("{base}/v1/agent/avatar")) + .send() + .await + .expect("the image reads"); + assert_eq!(served.status(), 200); + assert_eq!( + served + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("image/png") + ); + assert_eq!( + served.bytes().await.expect("the bytes read").as_ref(), + &[1, 2, 3, 4] + ); + + // A non-image upload is a 400. + let not_image = client + .put(format!("{base}/v1/agent/logo")) + .header("content-type", "text/html") + .body("no") + .send() + .await + .expect("the refusal posts"); + assert_eq!(not_image.status(), 400); + + let cleared_image: Value = client + .delete(format!("{base}/v1/agent/avatar")) + .send() + .await + .expect("the clear posts") + .json() + .await + .expect("the clear parses"); + assert_eq!(cleared_image["avatar"], false); + let gone = client + .get(format!("{base}/v1/agent/avatar")) + .send() + .await + .expect("the read answers"); + assert_eq!(gone.status(), 404); +} + +/// The registry a rebuilt daemon boots over the same db: discovery's +/// stored instances plus the scripted mock, so a persisted active pair +/// referencing a named instance restores against it. +fn discovered_registry(dir: &tempfile::TempDir) -> Arc { + let cfg = config(dir, None); + let profile = rhema_config::Profile::default(); + let stored_rows = || { + rhema_claw::SettingsStore::open(dir.path().join("dispatch.db")) + .ok() + .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(|p| (id.to_owned(), p)) + }) + .collect::>() + }; + let mut backends = + rhema_claw::backends::discover(&cfg, &profile, &|_| None, &stored_rows, None); + backends.push(rhema_claw::Backend::headless( + "mock", + Arc::new(MockProvider::new([reply("on it")])), + )); + Arc::new( + rhema_claw::BackendRegistry::new(backends, None) + .expect("the discovered registry always constructs"), + ) +} + +/// Named instances: two logins of one kind coexist under two ids, an +/// id replaces in place (kind optional), kind mismatches and kindless +/// new ids and bad names are refused, and the active pair pointing at +/// a named instance survives a daemon rebuild over the same store. +#[tokio::test] +async fn named_provider_instances_share_the_surface() { + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(Arc::new(MockProvider::new([reply("on it")]))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + // Two llama.cpp servers under two names, two urls. + for (name, url) in [ + ("local-a", "http://127.0.0.1:9001"), + ("local-b", "http://127.0.0.1:9002"), + ] { + let added: Value = client + .put(format!("{base}/v1/providers/{name}/credentials")) + .json(&json!({"kind": "llama-cpp", "url": url})) + .send() + .await + .expect("the login posts") + .json() + .await + .expect("the state parses"); + let row = added["providers"] + .as_array() + .expect("the providers array") + .iter() + .find(|p| p["id"] == name) + .unwrap_or_else(|| panic!("{name} listed: {added}")); + assert_eq!(row["kind"], "llama-cpp", "{row}"); + assert_eq!(row["source"], "stored", "{row}"); + } + + // Replace in place by id, kind omitted: still one local-a, still a + // llama.cpp. + let replaced: Value = client + .put(format!("{base}/v1/providers/local-a/credentials")) + .json(&json!({"url": "http://127.0.0.1:9003"})) + .send() + .await + .expect("the re-add posts") + .json() + .await + .expect("the state parses"); + let rows = replaced["providers"] + .as_array() + .expect("the providers array"); + assert_eq!(rows.iter().filter(|p| p["id"] == "local-a").count(), 1); + assert_eq!( + rows.iter().filter(|p| p["kind"] == "llama-cpp").count(), + 2, + "both named instances still serve: {rows:?}" + ); + + // A kind mismatch on replace is a 400. + let mismatched = client + .put(format!("{base}/v1/providers/local-a/credentials")) + .json(&json!({"kind": "google", "api_key": "k"})) + .send() + .await + .expect("the refusal posts"); + assert_eq!(mismatched.status(), 400); + + // A new id without a kind is a 400: the first login must say what + // it is constructing. + let kindless = client + .put(format!("{base}/v1/providers/newone/credentials")) + .json(&json!({"api_key": "k"})) + .send() + .await + .expect("the refusal posts"); + assert_eq!(kindless.status(), 400); + + // Bad names are refused: whitespace, a slash, over the length cap. + let long = "x".repeat(65); + for name in ["bad%20name", "a%2Fb", long.as_str()] { + let refused = client + .put(format!("{base}/v1/providers/{name}/credentials")) + .json(&json!({"kind": "llama-cpp"})) + .send() + .await + .expect("the refusal posts"); + assert_eq!(refused.status(), 400, "{name}"); + } + + // The active pair moves to a named instance and persists. + let switched: Value = client + .put(format!("{base}/v1/providers/active")) + .json(&json!({"provider": "local-b"})) + .send() + .await + .expect("the switch posts") + .json() + .await + .expect("the switch parses"); + assert_eq!(switched["active"]["provider"], "local-b"); + + drop(base); + let daemon = Daemon::build( + &config(&dir, None), + discovered_registry(&dir), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon reassembles"); + assert_eq!( + daemon.backends().active().0, + "local-b", + "the named instance is still active after the rebuild" + ); +} + +/// The consent round-trip, over the wire: a turn asks through +/// `ask_secret`, the message POST that triggered it holds until the +/// answer lands, the pending list shows the ask, and the secret ends up +/// in the accounts — never in the transcript. +#[tokio::test] +async fn a_secret_ask_is_answered_over_the_consent_routes() { + let provider = Arc::new(MockProvider::new([ + tool_call( + "c1", + "ask_secret", + json!({ + "service": "github", + "username": "octocat", + "reason": "to file the issue", + }), + ), + reply("stored, thanks."), + ])); + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(provider), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + let events = client + .get(format!("{base}/v1/sessions/interactive:web/events")) + .send() + .await + .expect("the events request goes out"); + let mut stream = sse_body(events).await; + let mut seen = String::new(); + + // The turn blocks inside the tool: the POST returns only when the + // human answers, so it runs on its own task. + let poster = tokio::spawn({ + let client = client.clone(); + let base = base.clone(); + async move { + client + .post(format!("{base}/v1/sessions/interactive:web/messages")) + .json(&json!({"text": "file the issue"})) + .send() + .await + .expect("the message posts") + } + }); + + // The ask appears on the pending list, then on the stream. + let consents: Value = loop { + let consents: Value = client + .get(format!("{base}/v1/consents")) + .send() + .await + .expect("consents reads") + .json() + .await + .expect("consents parses"); + if consents["consents"] + .as_array() + .is_some_and(|list| !list.is_empty()) + { + break consents; + } + tokio::time::sleep(Duration::from_millis(25)).await; + }; + let id = consents["consents"][0]["id"] + .as_str() + .expect("an id") + .to_owned(); + assert_eq!(consents["consents"][0]["kind"], "secret"); + read_until(&mut stream, &mut seen, r#""type":"consent_request""#).await; + + // A wrong-shaped answer is refused and the ask stays open. + let refused = client + .post(format!("{base}/v1/consents/{id}")) + .json(&json!({})) + .send() + .await + .expect("the answer posts"); + assert_eq!(refused.status(), 422); + + // The answer: the secret goes in, the POST unblocks, the account + // exists redacted. + let answered = client + .post(format!("{base}/v1/consents/{id}")) + .json(&json!({"secret": "gh_pat_123"})) + .send() + .await + .expect("the answer posts"); + assert_eq!(answered.status(), 200); + let settled: Value = answered.json().await.expect("the answer parses"); + assert_eq!(settled["granted"], true); + let account_id = settled["account_id"] + .as_str() + .expect("an account id") + .to_owned(); + + let posted = poster.await.expect("the post joins"); + assert_eq!(posted.status(), 202); + + let accounts: Value = client + .get(format!("{base}/v1/accounts")) + .send() + .await + .expect("accounts reads") + .json() + .await + .expect("accounts parses"); + assert_eq!(accounts["accounts"].as_array().expect("a list").len(), 1); + assert_eq!(accounts["accounts"][0]["service"], "github"); + assert_eq!(accounts["accounts"][0]["id"], account_id); + assert!( + !seen.contains("gh_pat_123"), + "the secret never reaches the transcript: {seen}" + ); + read_until(&mut stream, &mut seen, r#""type":"consent_resolved""#).await; + read_until(&mut stream, &mut seen, r#""type":"turn_done""#).await; + + // A second answer is a conflict, and the ask has left the list. + let again = client + .post(format!("{base}/v1/consents/{id}")) + .json(&json!({"secret": "x"})) + .send() + .await + .expect("the answer posts"); + assert_eq!(again.status(), 409); + let consents: Value = client + .get(format!("{base}/v1/consents")) + .send() + .await + .expect("consents reads") + .json() + .await + .expect("consents parses"); + assert!(consents["consents"].as_array().expect("a list").is_empty()); +} + +/// The attachment round-trip, over the wire: a turn calls +/// `attach_file`, the stream carries the card, and the bytes are +/// fetchable at the card's address with the content type its name +/// implies. +#[tokio::test] +async fn an_attached_file_is_fetchable_over_its_route() { + let workspace = |dir: &tempfile::TempDir| { + let workspace = dir.path().join("workspaces").join("interactive"); + std::fs::create_dir_all(&workspace).expect("workspace"); + workspace + }; + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + workspace(&dir).join("notes.md"), + "# notes\nthe thing broke\n", + ) + .expect("the file writes"); + let provider = Arc::new(MockProvider::new([ + tool_call( + "c1", + "attach_file", + json!({"path": "notes.md", "caption": "look"}), + ), + reply("attached."), + ])); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(provider), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + let events = client + .get(format!("{base}/v1/sessions/interactive:web/events")) + .send() + .await + .expect("the events request goes out"); + let mut stream = sse_body(events).await; + let mut seen = String::new(); + + let accepted = client + .post(format!("{base}/v1/sessions/interactive:web/messages")) + .json(&json!({"text": "hand me the notes"})) + .send() + .await + .expect("the message posts"); + assert_eq!(accepted.status(), 202); + + read_until(&mut stream, &mut seen, r#""type":"attachment""#).await; + read_until(&mut stream, &mut seen, r#""type":"turn_done""#).await; + let marker = seen + .split(r#""type":"attachment""#) + .nth(1) + .and_then(|rest| rest.split(r#""id":""#).nth(1)) + .and_then(|rest| rest.split('"').next()) + .expect("the card names its id") + .to_owned(); + + let fetched = client + .get(format!("{base}/v1/attachments/{marker}")) + .send() + .await + .expect("the fetch goes out"); + assert_eq!( + fetched.status(), + 200, + "marker {marker:?}; tail {:?}", + &seen[seen.len().saturating_sub(300)..] + ); + assert_eq!( + fetched + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("text/plain; charset=utf-8"), + "the content type follows the name" + ); + assert_eq!( + fetched.bytes().await.expect("the bytes arrive"), + "# notes\nthe thing broke\n" + ); + + let missing = client + .get(format!("{base}/v1/attachments/att-nowhere")) + .send() + .await + .expect("the fetch goes out"); + assert_eq!(missing.status(), 404); +} + +/// The phone control loop, over the wire: a turn acts through the +/// `phone` tool, the consent card is answered with a grant, the command +/// lands on the device's poll list, and the device's report is what the +/// tool finally answers with. +#[tokio::test] +async fn a_phone_control_runs_consent_queue_and_report() { + let provider = Arc::new(MockProvider::new([ + tool_call( + "c1", + "phone", + json!({"action": "dnd_on", "device": "pixel", "reason": "the movie"}), + ), + reply("done."), + ])); + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(provider), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + let events = client + .get(format!("{base}/v1/sessions/interactive:web/events")) + .send() + .await + .expect("the events request goes out"); + let mut stream = sse_body(events).await; + let mut seen = String::new(); + + let poster = tokio::spawn({ + let client = client.clone(); + let base = base.clone(); + async move { + client + .post(format!("{base}/v1/sessions/interactive:web/messages")) + .json(&json!({"text": "quiet the phone"})) + .send() + .await + .expect("the message posts") + } + }); + + // The consent card; the human grants it. + let consents: Value = loop { + let consents: Value = client + .get(format!("{base}/v1/consents")) + .send() + .await + .expect("consents reads") + .json() + .await + .expect("consents parses"); + if consents["consents"] + .as_array() + .is_some_and(|list| !list.is_empty()) + { + break consents; + } + tokio::time::sleep(Duration::from_millis(25)).await; + }; + assert_eq!(consents["consents"][0]["kind"], "phone"); + assert_eq!( + consents["consents"][0]["summary"], "phone dnd_on on pixel", + "{}", + consents + ); + let id = consents["consents"][0]["id"] + .as_str() + .expect("an id") + .to_owned(); + read_until(&mut stream, &mut seen, r#""type":"consent_request""#).await; + let granted = client + .post(format!("{base}/v1/consents/{id}")) + .json(&json!({"grant": true})) + .send() + .await + .expect("the grant posts"); + assert_eq!(granted.status(), 200); + + // The command joins the device's poll list; the device executes and + // reports. + let commands: Value = loop { + let commands: Value = client + .get(format!("{base}/v1/phone/commands?device=pixel")) + .send() + .await + .expect("commands reads") + .json() + .await + .expect("commands parses"); + if commands["commands"] + .as_array() + .is_some_and(|list| !list.is_empty()) + { + break commands; + } + tokio::time::sleep(Duration::from_millis(25)).await; + }; + assert_eq!(commands["commands"][0]["action"], "dnd_on"); + let command_id = commands["commands"][0]["id"] + .as_str() + .expect("an id") + .to_owned(); + let reported = client + .post(format!("{base}/v1/phone/commands/{command_id}/result")) + .json(&json!({"ok": true, "detail": "dnd is on"})) + .send() + .await + .expect("the report posts"); + assert_eq!(reported.status(), 200); + + let posted = poster.await.expect("the post joins"); + assert_eq!(posted.status(), 202); + read_until(&mut stream, &mut seen, r#""type":"consent_resolved""#).await; + read_until(&mut stream, &mut seen, r#""type":"turn_done""#).await; + + // A redelivered report is a conflict, and the poll list is empty. + let again = client + .post(format!("{base}/v1/phone/commands/{command_id}/result")) + .json(&json!({"ok": true})) + .send() + .await + .expect("the report posts"); + assert_eq!(again.status(), 409); + let commands: Value = client + .get(format!("{base}/v1/phone/commands?device=pixel")) + .send() + .await + .expect("commands reads") + .json() + .await + .expect("commands parses"); + assert!(commands["commands"].as_array().expect("a list").is_empty()); +} + +/// The phone's own status reports feed the state the `phone` tool +/// reads: an ingest to a `phone::` key is conversation +/// and observation at once. +#[tokio::test] +async fn an_ingested_phone_report_feeds_the_status_the_tool_reads() { + let provider = Arc::new(MockProvider::new([ + tool_call( + "c1", + "phone", + json!({"action": "status", "device": "pixel"}), + ), + reply("battery is fine."), + ])); + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(provider), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + // The report the PhoneStatusWorker's shape makes: a status event on + // its own key. + let ingested = client + .post(format!("{base}/v1/ingest")) + .json(&json!({ + "source": "phone", + "key": "phone:pixel:battery", + "payload": {"topic": "battery", "value": "64", "charging": false}, + })) + .send() + .await + .expect("the report posts"); + assert_eq!(ingested.status(), 202); + + let events = client + .get(format!("{base}/v1/sessions/interactive:web/events")) + .send() + .await + .expect("the events request goes out"); + let mut stream = sse_body(events).await; + let mut seen = String::new(); + let accepted = client + .post(format!("{base}/v1/sessions/interactive:web/messages")) + .json(&json!({"text": "how is the phone?"})) + .send() + .await + .expect("the message posts"); + assert_eq!(accepted.status(), 202); + read_until(&mut stream, &mut seen, r#""type":"tool_result""#).await; + read_until(&mut stream, &mut seen, r#""type":"turn_done""#).await; + // The summary is bounded, so the full payload never rides the + // stream — but the observed-true verdict and the device do. + assert!( + seen.contains(r#""summary":"{\"observed\":true,\"snapshot\":{\"device\":\"pixel\""#), + "the tool read the ingested observation: {seen}" + ); + assert!( + !seen.contains(r#""type":"consent_request""#), + "reading never asks: {seen}" + ); +} + +/// The fleet routes: reports register devices, the list carries the +/// self-reported label and every topic's latest observation, a +/// state-only topic ("observed") never joins the conversation, and a +/// forget drops the device — answering `forgotten: false` on an +/// already-gone name instead of a 404. +#[tokio::test] +async fn the_phone_fleet_lists_labels_and_forgets() { + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(Arc::new(MockProvider::new(Vec::new()))), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + async fn report(client: &reqwest::Client, base: &str, key: &str, payload: Value) -> Value { + client + .post(format!("{base}/v1/ingest")) + .json(&json!({"source": "phone", "key": key, "payload": payload})) + .send() + .await + .expect("the report posts") + .json::() + .await + .expect("the answer parses") + } + + let battery = report( + &client, + &base, + "phone:pixel-1:battery", + json!({"topic": "battery", "value": "64", "charging": false}), + ) + .await; + assert_eq!(battery, json!({"ingested": "observed"}), "{battery}"); + let location = report( + &client, + &base, + "phone:pixel-1:location", + json!({"topic": "location", "lat": 48.85, "lon": 2.35, "accuracy_m": 12.0}), + ) + .await; + assert_eq!(location, json!({"ingested": "observed"}), "{location}"); + report( + &client, + &base, + "phone:pixel-1:device", + json!({"topic": "device", "manufacturer": "Google", "model": "Pixel 9"}), + ) + .await; + report( + &client, + &base, + "phone:pixel-2:battery", + json!({"topic": "battery", "value": "10", "charging": true}), + ) + .await; + + // Reports are observations, not conversation: no report leaves a + // session key behind, whichever topic it carries. + let sessions: Vec = client + .get(format!("{base}/v1/sessions")) + .send() + .await + .expect("sessions reads") + .json() + .await + .expect("sessions parses"); + let keys = sessions + .iter() + .map(|session| session["key"].as_str().expect("a key")) + .collect::>(); + assert!( + keys.iter().all(|key| !key.starts_with("phone:")), + "a report never dispatches: {keys:?}" + ); + + let fleet: Value = client + .get(format!("{base}/v1/phones")) + .send() + .await + .expect("the fleet reads") + .json() + .await + .expect("the fleet parses"); + let phones = fleet["phones"].as_array().expect("a list"); + assert_eq!(phones.len(), 2, "{fleet}"); + assert_eq!(phones[0]["device"], "pixel-1"); + assert_eq!(phones[0]["label"], "Google Pixel 9"); + assert_eq!(phones[0]["topics"]["battery"]["value"], "64"); + assert_eq!(phones[0]["topics"]["location"]["lat"], 48.85); + assert_eq!(phones[1]["device"], "pixel-2"); + assert!( + phones[1]["label"].is_null(), + "no device topic, no label: {fleet}" + ); + + let forgotten: Value = client + .delete(format!("{base}/v1/phones/pixel-1")) + .send() + .await + .expect("the forget goes out") + .json() + .await + .expect("the answer parses"); + assert_eq!(forgotten, json!({"forgotten": true}), "{forgotten}"); + let fleet: Value = client + .get(format!("{base}/v1/phones")) + .send() + .await + .expect("the fleet reads") + .json() + .await + .expect("the fleet parses"); + assert_eq!(fleet["phones"].as_array().expect("a list").len(), 1); + assert_eq!(fleet["phones"][0]["device"], "pixel-2"); + + // Forgetting an already-gone name is data, not an error. + let again: Value = client + .delete(format!("{base}/v1/phones/pixel-1")) + .send() + .await + .expect("the forget goes out") + .json() + .await + .expect("the answer parses"); + assert_eq!(again, json!({"forgotten": false}), "{again}"); +} + +/// Creating a session over HTTP: an empty `interactive:` key exists +/// before its first message, a second create of the same name is a +/// conflict, and a name that would not read as a key is refused. +#[tokio::test] +async fn a_session_is_creatable_over_http() { + let provider = Arc::new(MockProvider::new([reply("on it")])); + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(provider), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + let created = client + .post(format!("{base}/v1/sessions")) + .json(&json!({"name": "my-notes"})) + .send() + .await + .expect("the create posts"); + assert_eq!(created.status(), 201); + assert_eq!( + created.json::().await.expect("body parses"), + json!({"key": "interactive:my-notes", "created": true}) + ); + + // The session exists idle, before any message — and a message to it + // turns it, like any other resident key. + let sessions: Value = client + .get(format!("{base}/v1/sessions")) + .send() + .await + .expect("sessions reads") + .json() + .await + .expect("sessions parses"); + let keys: Vec<&str> = sessions + .as_array() + .expect("a list") + .iter() + .filter_map(|session| session["key"].as_str()) + .collect(); + assert_eq!( + keys, + vec!["interactive:my-notes"], + "the created key is listed" + ); + + let again = client + .post(format!("{base}/v1/sessions")) + .json(&json!({"name": "my-notes"})) + .send() + .await + .expect("the create posts"); + assert_eq!(again.status(), 409); + + for bad in ["", "has space", "sl/ash", "a".repeat(65).as_str()] { + let refused = client + .post(format!("{base}/v1/sessions")) + .json(&json!({"name": bad})) + .send() + .await + .expect("the create posts"); + assert_eq!(refused.status(), 400, "refusing {bad:?}"); + } +} + +/// The wake-itself loop, over the wire: the model schedules a turn into +/// the chat session it is talking in, the daemon's detect pass fires it +/// when due, and the reply lands in that same session — one session, +/// one context, on both sides of the sleep. +#[tokio::test] +async fn a_scheduled_task_wakes_the_chat_session_it_names() { + let provider = Arc::new(MockProvider::new([ + tool_call( + "c1", + "schedule_task", + json!({ + "prompt": "summarize tonight's work", + "at": 1_791_734_400_000_i64, + "session": "interactive:web", + }), + ), + reply("scheduled for tomorrow morning."), + // The wake's turn. + reply("tonight you shipped the claw."), + ])); + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(provider), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + let events = client + .get(format!("{base}/v1/sessions/interactive:web/events")) + .send() + .await + .expect("the events request goes out"); + let mut stream = sse_body(events).await; + let mut seen = String::new(); + + let accepted = client + .post(format!("{base}/v1/sessions/interactive:web/messages")) + .json(&json!({"text": "wake me here tomorrow morning with a summary"})) + .send() + .await + .expect("the message posts"); + assert_eq!(accepted.status(), 202); + read_until(&mut stream, &mut seen, r#""type":"tool_result""#).await; + read_until(&mut stream, &mut seen, "scheduled for tomorrow morning.").await; + read_until(&mut stream, &mut seen, r#""type":"turn_done""#).await; + + // The scheduled instant is tomorrow, far past the test's patience: + // pull it due by hand (the row is the store's, the mechanism is the + // scheduler's) and run one daemon pass — detect, admit, run. + { + use rhema_claw::task_store::TaskStore; + let store = TaskStore::open(dir.path().join("dispatch.db")).expect("the task table opens"); + let task = &store.list().expect("the row reads")[0]; + store.rearm(task.id, 1).expect("pulled due"); + } + let report = daemon.tick().await.expect("the tick runs"); + assert_eq!(report.detected, 1, "the wake was detected"); + + // The wake fired in the chat session: its prompt is the next user + // message and the turn's reply streamed after it. + read_until(&mut stream, &mut seen, "summarize tonight's work").await; + read_until(&mut stream, &mut seen, "tonight you shipped the claw.").await; + read_until(&mut stream, &mut seen, r#""type":"turn_done""#).await; + assert_eq!( + seen.matches(r#""type":"user_message""#).count(), + 2, + "the chat's ask, then the wake: {seen}" + ); + // The session notified nothing: interactive replies stay in the chat. + let notifications: Value = client + .get(format!("{base}/v1/notifications")) + .send() + .await + .expect("notifications read") + .json() + .await + .expect("notifications parse"); + assert!( + notifications["notifications"] + .as_array() + .expect("a list") + .is_empty(), + "{notifications}" + ); +} + +/// The durability story, over the wire: a daemon restart cut a turn +/// mid-flight, leaving the dead process's lease on the key. At boot the +/// daemon releases it outright — single-node means the only lease +/// holder was the process that died — so the key answers its next +/// message instead of stalling behind a ghost for the dead lease's +/// remaining TTL. +#[tokio::test] +async fn a_restart_releases_a_dead_lease_and_the_key_answers() { + let dir = tempfile::tempdir().expect("tempdir"); + let db = dir.path().join("dispatch.db"); + { + // The old process, mid-turn: it holds a fresh lease on the chat + // session — hours left, the turn never released it. + use rhema_rsd::DispatcherConfig; + let store = Arc::new(SqliteStore::open(&db).expect("the store opens")); + let dispatcher = rhema_rsd::Dispatcher::new( + DispatcherConfig { + debounce_window_millis: 0, + lease_ttl_millis: 3_600_000, + ..DispatcherConfig::default() + }, + Arc::new(rhema_rsd::SystemClock), + store, + ); + let (lease, _) = dispatcher + .acquire(ResourceKey::from("interactive:web"), "the-old-process") + .expect("the old process takes its lease"); + assert_eq!(lease.holder, "the-old-process"); + // ...and dies here. The lease outlives it in the store. + } + + // The new process boots, recovers, and serves. + let provider = Arc::new(MockProvider::new([reply("back, and whole.")])); + let daemon = Daemon::build( + &config(&dir, None), + mock_registry(provider), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + daemon.bootstrap().await.expect("recovery runs"); + + let base = serve(&daemon).await; + let client = reqwest::Client::new(); + + // The key reads idle, not leased: the ghost lease is gone. + let sessions: Value = client + .get(format!("{base}/v1/sessions")) + .send() + .await + .expect("sessions reads") + .json() + .await + .expect("sessions parses"); + assert_eq!(sessions[0]["key"], "interactive:web"); + assert_eq!(sessions[0]["state"], "idle", "{sessions}"); + + // The next message dispatches instead of queueing behind the ghost. + let events = client + .get(format!("{base}/v1/sessions/interactive:web/events")) + .send() + .await + .expect("the events request goes out"); + let mut stream = sse_body(events).await; + let mut seen = String::new(); + let accepted = client + .post(format!("{base}/v1/sessions/interactive:web/messages")) + .json(&json!({"text": "are you back?"})) + .send() + .await + .expect("the message posts"); + assert_eq!(accepted.status(), 202); + read_until(&mut stream, &mut seen, "back, and whole.").await; + read_until(&mut stream, &mut seen, r#""type":"turn_done""#).await; +} diff --git a/deploy/docker/claw.Dockerfile b/deploy/docker/claw.Dockerfile new file mode 100644 index 0000000..40f24eb --- /dev/null +++ b/deploy/docker/claw.Dockerfile @@ -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"] diff --git a/deploy/docker/ui.Dockerfile b/deploy/docker/ui.Dockerfile new file mode 100644 index 0000000..61bccd1 --- /dev/null +++ b/deploy/docker/ui.Dockerfile @@ -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 diff --git a/deploy/helm/rhema-claw/.helmignore b/deploy/helm/rhema-claw/.helmignore new file mode 100644 index 0000000..14cd873 --- /dev/null +++ b/deploy/helm/rhema-claw/.helmignore @@ -0,0 +1,11 @@ +# Patterns to ignore when building the package. +.DS_Store +.git/ +.gitignore +.nojekyll +*.swp +*.tmp +*.orig +*.rej +*.tgz +vault diff --git a/deploy/helm/rhema-claw/Chart.yaml b/deploy/helm/rhema-claw/Chart.yaml new file mode 100644 index 0000000..a8fae5a --- /dev/null +++ b/deploy/helm/rhema-claw/Chart.yaml @@ -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" diff --git a/deploy/helm/rhema-claw/templates/NOTES.txt b/deploy/helm/rhema-claw/templates/NOTES.txt new file mode 100644 index 0000000..135b595 --- /dev/null +++ b/deploy/helm/rhema-claw/templates/NOTES.txt @@ -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 }} diff --git a/deploy/helm/rhema-claw/templates/_helpers.tpl b/deploy/helm/rhema-claw/templates/_helpers.tpl new file mode 100644 index 0000000..05b197b --- /dev/null +++ b/deploy/helm/rhema-claw/templates/_helpers.tpl @@ -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 }} diff --git a/deploy/helm/rhema-claw/templates/configmap.yaml b/deploy/helm/rhema-claw/templates/configmap.yaml new file mode 100644 index 0000000..25ae8e3 --- /dev/null +++ b/deploy/helm/rhema-claw/templates/configmap.yaml @@ -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 }} diff --git a/deploy/helm/rhema-claw/templates/deployment.yaml b/deploy/helm/rhema-claw/templates/deployment.yaml new file mode 100644 index 0000000..8cf3794 --- /dev/null +++ b/deploy/helm/rhema-claw/templates/deployment.yaml @@ -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 }} diff --git a/deploy/helm/rhema-claw/templates/ingress.yaml b/deploy/helm/rhema-claw/templates/ingress.yaml new file mode 100644 index 0000000..467d5c4 --- /dev/null +++ b/deploy/helm/rhema-claw/templates/ingress.yaml @@ -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 }} diff --git a/deploy/helm/rhema-claw/templates/pvc.yaml b/deploy/helm/rhema-claw/templates/pvc.yaml new file mode 100644 index 0000000..5256d4e --- /dev/null +++ b/deploy/helm/rhema-claw/templates/pvc.yaml @@ -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 }} diff --git a/deploy/helm/rhema-claw/templates/secret.yaml b/deploy/helm/rhema-claw/templates/secret.yaml new file mode 100644 index 0000000..26c0d68 --- /dev/null +++ b/deploy/helm/rhema-claw/templates/secret.yaml @@ -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 }} diff --git a/deploy/helm/rhema-claw/templates/service.yaml b/deploy/helm/rhema-claw/templates/service.yaml new file mode 100644 index 0000000..6c1321e --- /dev/null +++ b/deploy/helm/rhema-claw/templates/service.yaml @@ -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 diff --git a/deploy/helm/rhema-claw/templates/ui-deployment.yaml b/deploy/helm/rhema-claw/templates/ui-deployment.yaml new file mode 100644 index 0000000..ce4b3b8 --- /dev/null +++ b/deploy/helm/rhema-claw/templates/ui-deployment.yaml @@ -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 }} diff --git a/deploy/helm/rhema-claw/templates/ui-service.yaml b/deploy/helm/rhema-claw/templates/ui-service.yaml new file mode 100644 index 0000000..e6a3514 --- /dev/null +++ b/deploy/helm/rhema-claw/templates/ui-service.yaml @@ -0,0 +1,17 @@ +{{- if .Values.ui.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "rhema-claw.fullname" . }}-ui + labels: + {{- include "rhema-claw.labels" . | nindent 4 }} + app.kubernetes.io/component: ui +spec: + selector: + {{- include "rhema-claw.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: ui + ports: + - name: http + port: 80 + targetPort: http +{{- end }} diff --git a/deploy/helm/rhema-claw/values.yaml b/deploy/helm/rhema-claw/values.yaml new file mode 100644 index 0000000..777f08f --- /dev/null +++ b/deploy/helm/rhema-claw/values.yaml @@ -0,0 +1,93 @@ +# rhema-claw is single-node by design: one daemon owns every session in one +# SQLite store, with one dispatcher (specs/rhema-framework-v1.md §7). The +# templates fail the render when this is anything but 1 — it is an +# invariant, not a starting point. +replicaCount: 1 + +image: + repository: rhema-claw + # Defaults to Chart appVersion when empty. + tag: "" + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +# Bearer token for the /v1 routes, rendered into claw.yaml. Required: +# the daemon binds all interfaces in-cluster (the Service needs it) and +# refuses to serve a non-loopback surface without a token, so the render +# fails when this is empty. /healthz stays open either way. +token: "" + +# The claw.yaml content. db, workspace_root, and listen are pinned by the +# templates (the PVC is fixed at /data and the container must bind all +# interfaces for the Service); whatever these say here is overridden. +config: + poll_interval_secs: 60 + # Seed watches: imported once, while the watch table is still empty. + # Example: + # watches: + # - forge: github + # repo: acme/widgets + watches: [] + +service: + type: ClusterIP + port: 7799 + +ingress: + # Off by default: the claw is single-user and bearer-gated, and bearer + # tokens must not cross the public internet in the clear. When enabled, + # a host is required (the render refuses without one), TLS is strongly + # recommended, and the whole surface — chat, /v1, /healthz — is one + # origin. A UI on the same host's / path composes: put the UI's + # ingress first, or route /v1 here from its own host. + enabled: false + # Required when enabled; the render refuses an ingress without one. + host: "" + # The ingress controller's class (nginx, traefik, ...); empty means + # the cluster default. + className: "" + annotations: {} + tls: + enabled: false + # The cert-manager (or manually provisioned) secret carrying the + # certificate; required when tls.enabled. + secretName: "" + +ui: + # The web UI on the same host as the daemon: the ingress routes / + # here and /v1 (+ /healthz) to the daemon, so one address serves the + # browser and the phone app alike, with no CORS anywhere. Stateless + # static files — replicas may scale freely. + enabled: true + image: + # Built by deploy/docker/ui.Dockerfile; published by the ui job in + # the publish workflow. + repository: git.vhaudiquet.fr/vhaudiquet/rhema-claw-ui + # Defaults to the chart's appVersion when empty. + tag: "" + pullPolicy: IfNotPresent + resources: {} + +persistence: + enabled: true + storageClass: "" + size: 5Gi + +# Provider and forge credentials reach the daemon as env vars (the +# provider keys and GITHUB_TOKEN/GITEA_TOKEN/GITLAB_TOKEN fallbacks). +# One mechanism: either name an existingSecret, or list key/value pairs +# in stringData and the chart creates the Secret. +secret: + existingSecret: "" + stringData: {} + # stringData: + # ANTHROPIC_API_KEY: sk-ant-... + # GITHUB_TOKEN: ghp_... + +resources: {} +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/integrations/rhema-calendar-integration/Cargo.toml b/integrations/rhema-calendar-integration/Cargo.toml new file mode 100644 index 0000000..afbd11b --- /dev/null +++ b/integrations/rhema-calendar-integration/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "rhema-calendar-integration" +description = "The calendar integration: ICS/webcal feeds, CalDAV collections, and the Google Calendar API normalized into one occurrence vocabulary for reminder-style dispatch" +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 + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } +# HTTP against the three feed kinds: one bounded request per poll, no +# streaming +reqwest = { version = "0.12", features = ["json"] } +# The CalDAV adapter reads 207 multistatus bodies; the element set it +# cares about is small enough that a pull parser beats a DOM +quick-xml = "0.41" +# Every wire time (RFC 3339, iCalendar's compact `YYYYMMDDTHHMMSS[Z]`, +# `VALUE=DATE` all-days) normalizes to an OffsetDateTime, and the +# vocabulary's instants round-trip as RFC 3339. +time = { version = "0.3", default-features = false, features = [ + "parsing", + "formatting", + "serde-well-known", +] } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] } +# In-process HTTP mocks for offline tests +wiremock = "0.6" diff --git a/integrations/rhema-calendar-integration/src/caldav.rs b/integrations/rhema-calendar-integration/src/caldav.rs new file mode 100644 index 0000000..ae77c77 --- /dev/null +++ b/integrations/rhema-calendar-integration/src/caldav.rs @@ -0,0 +1,366 @@ +//! The CalDAV adapter: one `REPORT` per poll against a calendar +//! collection, the occurrences read out of the iCalendar objects the +//! server answers with. +//! +//! Wire choices and the constraints they pin: +//! - The request is a `CALDAV:calendar-query` `REPORT` with a +//! `time-range` on `VEVENT` and `Depth: 1`. The URL is the calendar +//! collection's own, a copy-paste from the server UI; principal and +//! home-set discovery are deliberately absent, because a hand-pasted +//! URL is one config line and discovery is a protocol zoo. +//! - The answer is a 207 multistatus whose `calendar-data` values are +//! whole iCalendar objects. Servers return recurring masters plus +//! overrides for the range, so the expansion in [`crate::ics`] does +//! the client-side work and this module only fetches and parses. +//! Occurrence ids carry the `caldav` prefix, so a calendar moved +//! between adapters changes its dedup ids: feed URLs and DAV +//! collections are different identities, not spellings of one. +//! - Auth is Basic (username plus password, the self-hosted norm), a +//! bearer token, or anonymous, whichever the server wants. +//! Credentials ride the `Authorization` header only, never a URL or +//! a log line. +//! - A `time-range` `REPORT` filters server-side by overlap, so the +//! adapter still clips client-side to the window's own contract: +//! starts within `[from, to)`. +//! - Non-event objects in the collection (a `VTODO` stored beside the +//! events) expand to nothing. A `VEVENT` without `UID` or `DTSTART` +//! fails the poll loudly, as in the ics adapter: a shape break must +//! surface, not shrink the calendar. + +use std::time::Duration; + +use async_trait::async_trait; +use quick_xml::Reader; +use quick_xml::escape::unescape; +use quick_xml::events::Event as XmlEvent; +use time::OffsetDateTime; + +use crate::calendar::{Calendar, finish}; +use crate::error::{CalendarError, ensure_success}; +use crate::event::{CalendarEvent, Window}; +use crate::ics::expand_as; + +/// The production spelling of nothing in particular: CalDAV has no +/// vendor base to default to, the calendar's URL is configured whole. +pub const USER_AGENT: &str = concat!("rhema-calendar-integration/", env!("CARGO_PKG_VERSION")); + +/// How the adapter authenticates. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Auth { + /// No credentials; servers that publish openly. + Anonymous, + /// Username plus password, the self-hosted norm. + Basic { user: String, password: String }, + /// A bearer token, for servers that issue them. + Bearer(String), +} + +/// CalDAV backend: a collection URL, an auth choice, one reqwest +/// client. +pub struct CaldavCalendar { + url: String, + auth: Auth, + http: reqwest::Client, + report: reqwest::Method, +} + +impl CaldavCalendar { + /// A calendar collection at `url` (`https://` or `http://`; a + /// self-hosted collection on the LAN is a legitimate http + /// citizen). Anonymous until told otherwise. + pub fn new(url: impl Into) -> Self { + let http = reqwest::Client::builder() + // One connect attempt: a dead server fails fast instead of + // stalling the poll loop. + .connect_timeout(Duration::from_secs(10)) + // Every DAV call is a bounded request/response, so one + // overall timeout per request is the right guard. + .timeout(Duration::from_secs(20)) + .user_agent(USER_AGENT) + .build() + .expect("a client builder with only timeouts cannot fail"); + Self { + url: url.into().trim_end_matches('/').to_owned(), + auth: Auth::Anonymous, + http, + // REPORT is an HTTP token, so this construction cannot + // fail; reqwest has no const constructor for it. + report: reqwest::Method::from_bytes(b"REPORT") + .expect("REPORT is a valid HTTP method token"), + } + } + + /// Authenticates with Basic credentials. + pub fn with_basic(mut self, user: impl Into, password: impl Into) -> Self { + self.auth = Auth::Basic { + user: user.into(), + password: password.into(), + }; + self + } + + /// Authenticates with a bearer token. + pub fn with_token(mut self, token: impl Into) -> Self { + self.auth = Auth::Bearer(token.into()); + self + } +} + +#[async_trait] +impl Calendar for CaldavCalendar { + async fn events(&self, window: &Window) -> Result, CalendarError> { + let mut request = self + .http + .request(self.report.clone(), &self.url) + .header("Depth", "1") + .header( + reqwest::header::CONTENT_TYPE, + "application/xml; charset=utf-8", + ) + .header(reqwest::header::ACCEPT, "application/xml") + .body(report_body(window)); + match &self.auth { + Auth::Anonymous => {} + Auth::Basic { user, password } => { + request = request.basic_auth(user, Some(password)); + } + Auth::Bearer(token) => { + request = request.bearer_auth(token); + } + } + let response = request.send().await.map_err(CalendarError::network)?; + let body = ensure_success(response) + .await? + .text() + .await + .map_err(CalendarError::network)?; + let mut events = Vec::new(); + for object in parse_multistatus(&body)? { + events.extend(expand_as(&object, "caldav", window)?); + } + Ok(finish(events, window)) + } +} + +/// The `calendar-query` body: the collection's VEVENTs, bounded by the +/// window's `time-range`. The bounds are RFC 4791's own spelling: +/// compact, UTC, `Z`. +fn report_body(window: &Window) -> String { + format!( + r#" + + + + + + + + + + + + +"#, + start = compact_z(window.from), + end = compact_z(window.to), + ) +} + +/// An instant as a CalDAV `time-range` bound: `YYYYMMDDTHHMMSSZ` in +/// UTC, whatever offset the instant carried. +fn compact_z(at: OffsetDateTime) -> String { + let at = at.to_offset(time::UtcOffset::UTC); + format!( + "{:04}{:02}{:02}T{:02}{:02}{:02}Z", + at.year(), + u8::from(at.month()), + at.day(), + at.hour(), + at.minute(), + at.second() + ) +} + +/// An element's local name: CalDAV's prefixes (`D:`, `C:`) are +/// conventions, not guarantees, and the answer's meaning lives in the +/// namespace URIs the element binds. Matching local names inside a +/// `prop` stat is the pragmatic read every DAV client ships. +fn local_name(name: quick_xml::name::QName<'_>) -> &str { + // The reader was built from a &str, so every borrowed name is + // UTF-8 already; a decoding failure cannot say anything truer than + // "not the element we were looking for". + std::str::from_utf8(name.local_name().into_inner()).unwrap_or("") +} + +/// Pulls every `calendar-data` value out of a 207 multistatus, in +/// document order. Whitespace-only values are dropped; anything else +/// the server said (hrefs, etags, statuses) is beneath the vocabulary. +fn parse_multistatus(xml: &str) -> Result, CalendarError> { + let mut reader = Reader::from_str(xml); + reader.config_mut().trim_text(true); + let mut datas = Vec::new(); + let mut capturing = false; + let mut current = String::new(); + loop { + match reader.read_event() { + Ok(XmlEvent::Start(element)) => { + if local_name(element.name()) == "calendar-data" { + capturing = true; + current.clear(); + } + } + Ok(XmlEvent::Text(text)) if capturing => { + // Escaped fragments ride Text; references ride their + // own event (below). A server writes ` ` + // where the iCalendar needs CRLF, and an unexpanded + // reference would fold the whole object into one line + // and read as quietly empty. + let decoded = text + .xml10_content() + .map_err(|err| CalendarError::Parse(format!("multistatus text: {err}")))?; + let decoded = unescape(&decoded) + .map_err(|err| CalendarError::Parse(format!("multistatus text: {err}")))?; + current.push_str(&decoded); + } + Ok(XmlEvent::GeneralRef(reference)) if capturing => { + match reference.resolve_char_ref() { + // ` ` and friends: one character. + Ok(Some(ch)) => current.push(ch), + // A named entity (`&`): the escape table's. + Ok(None) => { + let raw = reference.xml10_content().map_err(|err| { + CalendarError::Parse(format!("multistatus text: {err}")) + })?; + let entity = format!("&{raw};"); + let decoded = unescape(&entity).map_err(|err| { + CalendarError::Parse(format!("multistatus text: {err}")) + })?; + current.push_str(&decoded); + } + Err(err) => { + return Err(CalendarError::Parse(format!("multistatus text: {err}"))); + } + } + } + Ok(XmlEvent::CData(text)) if capturing => { + current.push_str(&String::from_utf8_lossy(text.as_ref())); + } + Ok(XmlEvent::End(element)) => { + if capturing && local_name(element.name()) == "calendar-data" { + capturing = false; + if !current.trim().is_empty() { + datas.push(std::mem::take(&mut current)); + } + } + } + Ok(XmlEvent::Eof) => break, + Ok(_) => {} + Err(err) => return Err(CalendarError::Parse(format!("multistatus: {err}"))), + } + } + Ok(datas) +} + +#[cfg(test)] +mod tests { + use super::*; + use time::format_description::well_known::Rfc3339; + + fn window(start: &str, hours: i64) -> Window { + let from = OffsetDateTime::parse(start, &time::format_description::well_known::Rfc3339) + .expect("parses"); + Window::new(from, from + time::Duration::hours(hours)) + } + + #[test] + fn time_range_bounds_are_compact_utc() { + // An offset instant normalizes: +02:00 noon is 10:00Z. + let at = OffsetDateTime::parse("2026-10-20T12:00:00+02:00", &Rfc3339).expect("parses"); + assert_eq!(compact_z(at), "20261020T100000Z"); + assert_eq!( + compact_z(window("2026-10-20T00:00:00Z", 24).to), + "20261021T000000Z" + ); + } + + #[test] + fn the_report_names_the_window_in_caldavs_own_spelling() { + let body = report_body(&window("2026-10-20T00:00:00Z", 24)); + assert!( + body.contains(r#""#) + ); + assert!(body.contains(r#""#)); + assert!(body.contains("calendar-query")); + } + + #[test] + fn multistatus_yields_every_calendar_data_in_order() { + let xml = r#" + + + /c/me/home/a.ics + + + "1" + BEGIN:VCALENDAR +FIRST:a +END:VCALENDAR + + HTTP/1.1 200 OK + + + + /c/me/home/b.ics + + + BEGIN:VCALENDAR +FIRST:b +END:VCALENDAR + + + +"#; + let datas = parse_multistatus(xml).expect("parses"); + assert_eq!(datas.len(), 2); + assert!(datas[0].contains("FIRST:a")); + assert!(datas[1].contains("FIRST:b")); + } + + #[test] + fn empty_and_missing_calendar_data_contribute_nothing() { + let xml = r#" + + + + + + + + + + + "2" + + + +"#; + assert!(parse_multistatus(xml).expect("parses").is_empty()); + } + + #[test] + fn garbage_xml_is_a_loud_parse_error() { + let err = parse_multistatus(" Result, CalendarError>; +} + +/// Folds collected events into an adapter's answer: keeps the rows +/// starting in `window`, drops duplicate ids (first wins), and sorts +/// by `(start, id)`. Adapters call this before returning, so a +/// consumer reads one deterministic order regardless of which feed +/// produced the rows. +pub(crate) fn finish(mut events: Vec, window: &Window) -> Vec { + events.retain(|event| window.starts_within(event.start)); + events.sort_by(|a, b| (&a.start, &a.id).cmp(&(&b.start, &b.id))); + events.dedup_by(|a, b| a.id == b.id); + events +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn event(id: &str, start: &str) -> CalendarEvent { + CalendarEvent { + id: id.to_owned(), + title: "x".to_owned(), + start: time::OffsetDateTime::parse( + start, + &time::format_description::well_known::Rfc3339, + ) + .expect("parses"), + end: None, + all_day: false, + location: None, + description: None, + attendees: Vec::new(), + organizer: None, + url: None, + raw: json!({}), + } + } + + #[test] + fn finish_clips_sorts_and_dedups() { + let window = Window::new(at("2026-10-20T00:00:00Z"), at("2026-10-21T00:00:00Z")); + let events = finish( + vec![ + event("b", "2026-10-20T10:00:00Z"), + event("z", "2026-10-19T10:00:00Z"), // before: dropped + event("a", "2026-10-20T10:00:00Z"), + event("b", "2026-10-20T10:00:00Z"), // duplicate id: dropped + event("c", "2026-10-21T00:00:00Z"), // at `to`: dropped + ], + &window, + ); + let ids: Vec<&str> = events.iter().map(|event| event.id.as_str()).collect(); + assert_eq!(ids, ["a", "b"]); + } + + fn at(raw: &str) -> time::OffsetDateTime { + time::OffsetDateTime::parse(raw, &time::format_description::well_known::Rfc3339) + .expect("parses") + } +} diff --git a/integrations/rhema-calendar-integration/src/error.rs b/integrations/rhema-calendar-integration/src/error.rs new file mode 100644 index 0000000..e0ffcda --- /dev/null +++ b/integrations/rhema-calendar-integration/src/error.rs @@ -0,0 +1,76 @@ +//! Why a calendar call failed. +//! +//! One error type for the whole crate: every adapter maps its transport, +//! HTTP, and decoding failures onto it, so callers match one shape no +//! matter which feed answered. + +/// Everything that can go wrong between a [`crate::Calendar`] call and +/// its answer. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum CalendarError { + /// The feed answered with a non-2xx status. `body` is a bounded + /// snippet of the response body, usually the server's error page or + /// JSON. + #[error("calendar api error (HTTP {status}): {body}")] + Api { + /// The HTTP status the feed answered with. + status: u16, + /// A bounded snippet of the response body. + body: String, + }, + /// A response body could not be decoded, or a wire row is missing a + /// field the normalized vocabulary requires. Loud by design: feeds + /// are machine-generated, and a shape break must surface, not + /// silently shrink the calendar. + #[error("malformed calendar response: {0}")] + Parse(String), + /// The server kept offering full pages, or offered a page cursor + /// that leads nowhere. Polling stopped rather than looping forever. + #[error("pagination failed: {0}")] + Pagination(String), + /// The request never got an answer (DNS, connect, timeout). + #[error("network error: {0}")] + Network(String), +} + +/// The longest body snippet an [`CalendarError::Api`] keeps: enough for +/// any server's error JSON, small enough that error lines stay bounded. +const BODY_SNIPPET_CAP: usize = 512; + +impl CalendarError { + /// Builds [`CalendarError::Api`] from a non-2xx status and the raw + /// body, truncating the body to [`BODY_SNIPPET_CAP`] characters. + pub(crate) fn api(status: reqwest::StatusCode, raw: &str) -> Self { + let body: String = raw.chars().take(BODY_SNIPPET_CAP).collect(); + CalendarError::Api { + status: status.as_u16(), + body, + } + } + + /// Maps a transport failure onto [`CalendarError::Network`]; + /// reqwest's error is neither `Clone` nor `Eq`, so only its display + /// text travels. + pub(crate) fn network(err: reqwest::Error) -> Self { + CalendarError::Network(err.to_string()) + } +} + +/// A wire field the normalized vocabulary cannot do without. A missing +/// one is a loud [`CalendarError::Parse`], not a silently skipped row. +pub(crate) fn required(field: Option, what: &str) -> Result { + field.ok_or_else(|| CalendarError::Parse(format!("calendar row without {what}"))) +} + +/// Passes a 2xx response through; turns anything else into +/// [`CalendarError::Api`] with a bounded body snippet. +pub(crate) async fn ensure_success( + response: reqwest::Response, +) -> Result { + let status = response.status(); + if status.is_success() { + return Ok(response); + } + let body = response.text().await.unwrap_or_default(); + Err(CalendarError::api(status, &body)) +} diff --git a/integrations/rhema-calendar-integration/src/event.rs b/integrations/rhema-calendar-integration/src/event.rs new file mode 100644 index 0000000..ad9d25a --- /dev/null +++ b/integrations/rhema-calendar-integration/src/event.rs @@ -0,0 +1,161 @@ +//! The normalized event vocabulary shared by every calendar backend. +//! +//! Constraints the normalization pins (each adapter module documents how +//! it meets them): +//! - An event is one occurrence, never a series. Recurring masters are +//! expanded (by the Google server with `singleEvents=true`, by the +//! `ics` adapter client-side), so a consumer never sees a rule, only +//! starts it can compare against its clock. +//! - [`CalendarEvent::id`] is the dedup id a dispatcher keys on: +//! `{provider}:{uid}@{unix start}`. Two polls of the same occurrence +//! produce the same id; the id changes only when the occurrence +//! itself is a different one. What a consumer does about the +//! repetition (dispatch dedup, reminder slots) is the consumer's +//! seam, not this crate's. +//! - Times are instants, not wall-clock strings. A wire time with no +//! offset reads as UTC, and an all-day date starts at its midnight +//! UTC with [`CalendarEvent::all_day`] set, so every `start` +//! compares against any clock without a timezone database on board. +//! - [`CalendarEvent::end`] is exclusive, as every wire format spells +//! it, and `None` when the row names no end. + +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use time::serde::rfc3339; + +/// One occurrence on a calendar: a meeting, a birthday, a block of +/// time. Everything a reminder needs to say, lifted from whichever +/// dialect produced it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CalendarEvent { + /// Stable dedup id, `{provider}:{uid}@{unix start}` (e.g. + /// `ics:c1f3a9@1792471200`, + /// `google:_70ojsm0k6nojajk6@1792471200`): one occurrence, one id, + /// across polls. A recurring series expands to one event per + /// occurrence, each with its own. + pub id: String, + /// The title (`SUMMARY` / `summary`); empty when the row has none. + pub title: String, + /// When the occurrence starts. + #[serde(with = "rfc3339")] + pub start: OffsetDateTime, + /// When it ends, exclusive; `None` when the row names no end. + #[serde(with = "rfc3339::option")] + pub end: Option, + /// Whether this is an all-day occurrence: the wire carried a date + /// with no time, and [`CalendarEvent::start`] is that date's + /// midnight UTC. + pub all_day: bool, + /// Where it happens, when the row says. + pub location: Option, + /// The row's notes, when it has any. + pub description: Option, + /// Who is invited, bare addresses (`mailto:` stripped, Google's + /// `email` fields). + pub attendees: Vec, + /// The organizer's address, when the row names one. + pub organizer: Option, + /// Human-facing URL of the event (`URL` / `htmlLink`). + pub url: Option, + /// The untouched wire row: the adapter's parsed property map for + /// ICS, the JSON object for Google. Parameters the vocabulary does + /// not lift (`TZID`, for one) ride here for a consumer that can do + /// more with them. + pub raw: serde_json::Value, +} + +impl CalendarEvent { + /// The event's duration: `end - start` when the row names an end, + /// zero otherwise. All-day rows without an `DTEND` are a zero-width + /// midnight: the wire says the day, the duration is not this + /// crate's guess to widen. + pub fn duration(&self) -> time::Duration { + self.end + .map(|end| end - self.start) + .unwrap_or(time::Duration::ZERO) + } +} + +/// The stretch of time a poll asks about, as starts: an event belongs +/// to a window when its [`CalendarEvent::start`] falls inside +/// `[from, to)`. Windows are absolute (instants, not cursors), which +/// is why a calendar poll needs no cursor: re-asking the same window +/// returns the same rows under the same ids. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Window { + /// The earliest start included. + pub from: OffsetDateTime, + /// The latest start excluded. + pub to: OffsetDateTime, +} + +impl Window { + /// A window from `from` (inclusive) to `to` (exclusive). + pub fn new(from: OffsetDateTime, to: OffsetDateTime) -> Self { + Self { from, to } + } + + /// Whether `start` falls inside the window. + pub fn starts_within(&self, start: OffsetDateTime) -> bool { + start >= self.from && start < self.to + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(raw: &str) -> OffsetDateTime { + OffsetDateTime::parse(raw, &time::format_description::well_known::Rfc3339) + .expect("rfc3339 parses") + } + + #[test] + fn a_window_bounds_by_start_inclusively_below_exclusively() { + let window = Window::new(at("2026-10-20T00:00:00Z"), at("2026-10-21T00:00:00Z")); + assert!(window.starts_within(at("2026-10-20T00:00:00Z"))); + assert!(window.starts_within(at("2026-10-20T23:59:59Z"))); + assert!(!window.starts_within(at("2026-10-19T23:59:59Z"))); + assert!(!window.starts_within(at("2026-10-21T00:00:00Z"))); + } + + #[test] + fn an_event_round_trips_through_serde() { + let event = CalendarEvent { + id: "ics:abc@1".into(), + title: "standup".into(), + start: at("2026-10-20T09:00:00Z"), + end: Some(at("2026-10-20T09:15:00Z")), + all_day: false, + location: None, + description: None, + attendees: vec!["a@example.net".into()], + organizer: Some("b@example.net".into()), + url: None, + raw: serde_json::json!({ "SUMMARY": "standup" }), + }; + let round: CalendarEvent = + serde_json::from_value(serde_json::to_value(&event).expect("serializes")) + .expect("deserializes"); + assert_eq!(round, event); + assert_eq!(round.duration(), time::Duration::minutes(15)); + } + + #[test] + fn an_endless_event_has_no_duration() { + let event = CalendarEvent { + id: "ics:abc@1".into(), + title: String::new(), + start: at("2026-10-20T09:00:00Z"), + end: None, + all_day: true, + location: None, + description: None, + attendees: Vec::new(), + organizer: None, + url: None, + raw: serde_json::json!({}), + }; + assert_eq!(event.duration(), time::Duration::ZERO); + } +} diff --git a/integrations/rhema-calendar-integration/src/google.rs b/integrations/rhema-calendar-integration/src/google.rs new file mode 100644 index 0000000..827fbd8 --- /dev/null +++ b/integrations/rhema-calendar-integration/src/google.rs @@ -0,0 +1,385 @@ +//! The Google Calendar adapter: the Calendar API v3 events list. +//! +//! Auth is one credential, chosen by what the deployment has: an OAuth +//! access token in `Authorization: Bearer` (the header only; the token +//! never lands in a URL or a log line), or an API key for public +//! calendars, which Google only accepts as the `key` query parameter. +//! +//! Endpoint choices and the constraints they pin: +//! - `singleEvents=true` makes the server expand recurring masters into +//! one row per occurrence, each with a stable `{id}_{timestamp}` id, +//! so an occurrence's dedup id survives re-polls. The client side +//! never evaluates a recurrence rule. +//! - `timeMin`/`timeMax` bound the poll server-side. Google's filter is +//! overlap-based, so the adapter still clips client-side to the +//! window's own contract: starts within `[from, to)`. +//! - Rows with `status: cancelled` are dropped. On a singleEvents poll +//! a cancelled row is the tombstone of an occurrence that will not +//! happen; keeping it would remind about a meeting that was called +//! off. +//! - Pagination rides `pageToken` until it disappears, with a hard +//! page cap, so a server that keeps issuing cursors is declared +//! misbehaving rather than polled forever. +//! - `startTime` ordering plus `singleEvents` is the only ordering +//! Google allows over expanded instances, which is the order the +//! normalized answer promises anyway (oldest first, id-broken ties). + +use std::time::Duration; + +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::Value; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +use crate::calendar::{Calendar, finish}; +use crate::error::{CalendarError, ensure_success, required}; +use crate::event::{CalendarEvent, Window}; + +/// The production Google APIs base. +pub const DEFAULT_BASE_URL: &str = "https://www.googleapis.com"; + +/// The user agent the adapter presents. +const USER_AGENT: &str = concat!("rhema-calendar-integration/", env!("CARGO_PKG_VERSION")); + +/// Rows requested per page: Google's own maximum. +const MAX_RESULTS: &str = "250"; + +/// Pages fetched before a server that keeps issuing cursors is +/// declared misbehaving rather than polled forever. +const MAX_PAGES: usize = 40; + +/// Which credential a client authenticates with. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Credential { + /// An OAuth access token, in `Authorization: Bearer`. + Bearer(String), + /// An API key for public calendars, as the `key` parameter + /// Google requires. + ApiKey(String), +} + +/// Google Calendar backend: a credential, a calendar id, one reqwest +/// client. The client is bound to one calendar, which is what the +/// [`Calendar`] contract promises anyway. +pub struct GoogleCalendar { + base_url: String, + calendar_id: String, + credential: Credential, + http: reqwest::Client, +} + +impl GoogleCalendar { + /// A calendar reading with an OAuth access `token` (private and + /// public calendars both). + pub fn new(token: impl Into, calendar_id: impl Into) -> Self { + Self::with(Credential::Bearer(token.into()), calendar_id) + } + + /// A calendar reading with an API `key`: public calendars only, + /// Google's rule, and the key rides the `key` query parameter + /// because that is the only form Google accepts for it. + pub fn with_api_key(key: impl Into, calendar_id: impl Into) -> Self { + Self::with(Credential::ApiKey(key.into()), calendar_id) + } + + fn with(credential: Credential, calendar_id: impl Into) -> Self { + let http = reqwest::Client::builder() + // One connect attempt: a dead endpoint fails fast instead + // of stalling the poll loop. + .connect_timeout(Duration::from_secs(10)) + // Every API call is a bounded request/response, so one + // overall timeout per request is the right guard. + .timeout(Duration::from_secs(20)) + .user_agent(USER_AGENT) + .build() + .expect("a client builder with only timeouts cannot fail"); + Self { + base_url: DEFAULT_BASE_URL.to_owned(), + calendar_id: calendar_id.into(), + credential, + http, + } + } + + /// Overrides the base URL (trailing slashes trimmed) — for a test + /// against a mock server. + pub fn with_base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into().trim_end_matches('/').to_owned(); + self + } + + /// The events-list URL, with the calendar id percent-encoded by + /// the URL builder: ids are email-shaped (`primary`, an address, + /// a hex id), but the encoding is the builder's job, not ours. + fn endpoint(&self) -> Result { + let mut url = reqwest::Url::parse(&self.base_url) + .map_err(|err| CalendarError::Parse(format!("base url: {err}")))?; + url.path_segments_mut() + .map_err(|_| CalendarError::Parse("base url is not a directory".to_owned()))? + .extend(["calendar", "v3", "calendars", &self.calendar_id, "events"]); + Ok(url) + } +} + +#[async_trait] +impl Calendar for GoogleCalendar { + async fn events(&self, window: &Window) -> Result, CalendarError> { + let time_min = window + .from + .format(&Rfc3339) + .map_err(|err| CalendarError::Parse(format!("window start: {err}")))?; + let time_max = window + .to + .format(&Rfc3339) + .map_err(|err| CalendarError::Parse(format!("window end: {err}")))?; + let mut rows = Vec::new(); + let mut page_token = String::new(); + for _page in 1..=MAX_PAGES { + let mut url = self.endpoint()?; + { + let mut query = url.query_pairs_mut(); + query.append_pair("timeMin", &time_min); + query.append_pair("timeMax", &time_max); + query.append_pair("singleEvents", "true"); + query.append_pair("orderBy", "startTime"); + query.append_pair("maxResults", MAX_RESULTS); + if let Credential::ApiKey(key) = &self.credential { + query.append_pair("key", key); + } + if !page_token.is_empty() { + query.append_pair("pageToken", &page_token); + } + } + let mut request = self.http.get(url); + if let Credential::Bearer(token) = &self.credential { + request = request.bearer_auth(token); + } + let response = request.send().await.map_err(CalendarError::network)?; + let body = ensure_success(response) + .await? + .text() + .await + .map_err(CalendarError::network)?; + let page: EventsPage = serde_json::from_str(&body) + .map_err(|err| CalendarError::Parse(format!("events page: {err}")))?; + rows.extend(page.items); + match page.next_page_token { + Some(token) => page_token = token, + None => return Ok(finish(try_rows(rows)?, window)), + } + } + Err(CalendarError::Pagination(format!( + "more than {MAX_PAGES} pages from the events list" + ))) + } +} + +/// One events-list page: the raw rows travel as JSON so each can keep +/// riding its event's `raw` untouched. +#[derive(Debug, Deserialize)] +struct EventsPage { + #[serde(default)] + items: Vec, + #[serde(rename = "nextPageToken", default)] + next_page_token: Option, +} + +/// Decodes every row of a page, dropping the tombstones. +fn try_rows(rows: Vec) -> Result, CalendarError> { + rows.into_iter() + .filter_map(|row| decode_row(row).transpose()) + .collect() +} + +/// Decodes one wire row; `None` for a cancelled tombstone. A missing +/// start is loud: without it there is no instant to remind about. +fn decode_row(row: Value) -> Result, CalendarError> { + let row: EventRow = serde_json::from_value(row) + .map_err(|err| CalendarError::Parse(format!("event row: {err}")))?; + if row.status.as_deref() == Some("cancelled") { + return Ok(None); + } + let start_block = required(row.start, "a start")?; + let (start, all_day) = start_block.read(&row.id)?; + let end = match row.end_time_unspecified { + true => None, + false => row + .end + .as_ref() + .map(|end| end.read(&row.id)) + .transpose()? + .map(|(when, _)| when), + }; + Ok(Some(CalendarEvent { + id: format!("google:{}@{}", row.id, start.unix_timestamp()), + title: row.summary.unwrap_or_default(), + start, + end, + all_day, + location: row.location, + description: row.description, + attendees: row + .attendees + .unwrap_or_default() + .into_iter() + .filter_map(|attendee| attendee.email) + .collect(), + organizer: row.organizer.and_then(|organizer| organizer.email), + url: row.html_link, + raw: row.raw, + })) +} + +/// A wire time block: `dateTime` for a timed row, `date` for an +/// all-day one (that date's midnight UTC). +#[derive(Debug, Deserialize)] +struct TimeBlock { + #[serde(default)] + date: Option, + #[serde(rename = "dateTime", default)] + date_time: Option, +} + +impl TimeBlock { + /// The instant plus the all-day flag; `None` when the block names + /// neither form. An unreadable spelling is loud, with the row's id + /// for context. + fn read(&self, id: &str) -> Result<(OffsetDateTime, bool), CalendarError> { + let loud = |what: &str, err: String| { + CalendarError::Parse(format!("event {id:?} has an unreadable {what}: {err}")) + }; + if let Some(when) = self.date.as_deref() { + // `YYYY-MM-DD`, at midnight UTC. + let (year, rest) = when + .split_once('-') + .ok_or_else(|| loud("date", "no year separator".to_owned()))?; + let (month, day) = rest + .split_once('-') + .ok_or_else(|| loud("date", "no month separator".to_owned()))?; + let date = time::Date::from_calendar_date( + year.parse::() + .map_err(|err| loud("date year", err.to_string()))?, + time::Month::try_from( + month + .parse::() + .map_err(|err| loud("date month", err.to_string()))?, + ) + .map_err(|err| loud("date month", err.to_string()))?, + day.parse::() + .map_err(|err| loud("date day", err.to_string()))?, + ) + .map_err(|err| loud("date", err.to_string()))?; + return Ok((date.with_time(time::Time::MIDNIGHT).assume_utc(), true)); + } + let when = self + .date_time + .as_deref() + .ok_or_else(|| loud("start", "neither date nor dateTime".to_owned()))?; + OffsetDateTime::parse(when, &Rfc3339) + .map(|start| (start, false)) + .map_err(|err| loud("dateTime", err.to_string())) + } +} + +/// The wire row, with the fields the vocabulary lifts named. +#[derive(Debug, Deserialize)] +struct EventRow { + id: String, + #[serde(default)] + status: Option, + #[serde(default)] + summary: Option, + #[serde(default)] + location: Option, + #[serde(default)] + description: Option, + #[serde(rename = "htmlLink", default)] + html_link: Option, + #[serde(default)] + start: Option, + #[serde(default)] + end: Option, + #[serde(rename = "endTimeUnspecified", default)] + end_time_unspecified: bool, + #[serde(default)] + attendees: Option>, + #[serde(default)] + organizer: Option, + /// Kept whole for [`CalendarEvent::raw`]. + #[serde(flatten)] + raw: Value, +} + +/// An attendee or organizer row; only the address is lifted. +#[derive(Debug, Deserialize)] +struct Party { + #[serde(default)] + email: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn timed(id: &str, start: &str) -> Value { + json!({ + "id": id, + "status": "confirmed", + "summary": "standup", + "start": { "dateTime": start }, + "end": { "dateTime": "2026-10-20T09:15:00Z" }, + "htmlLink": "https://calendar.google.com/x", + "attendees": [ { "email": "a@example.net" }, { "displayName": "no address" } ] + }) + } + + #[test] + fn a_timed_row_decodes_with_its_lifts() { + let event = decode_row(timed("abc123", "2026-10-20T09:00:00Z")) + .expect("decodes") + .expect("not cancelled"); + assert_eq!( + event.id, + format!( + "google:abc123@{}", + OffsetDateTime::parse("2026-10-20T09:00:00Z", &Rfc3339) + .expect("parses") + .unix_timestamp() + ) + ); + assert_eq!(event.title, "standup"); + assert!(!event.all_day); + assert_eq!(event.attendees, vec!["a@example.net".to_owned()]); + assert_eq!(event.url.as_deref(), Some("https://calendar.google.com/x")); + } + + #[test] + fn an_all_day_row_starts_at_its_midnight() { + let row = json!({ + "id": "bday", + "summary": "birthday", + "start": { "date": "2026-10-20" } + }); + let event = decode_row(row).expect("decodes").expect("not cancelled"); + assert!(event.all_day); + assert_eq!(event.end, None); + assert_eq!(event.start.hour(), 0); + } + + #[test] + fn a_cancelled_row_is_a_tombstone() { + let mut row = timed("abc123", "2026-10-20T09:00:00Z"); + row["status"] = json!("cancelled"); + assert_eq!(decode_row(row).expect("decodes"), None); + } + + #[test] + fn a_row_without_a_start_is_loud() { + let mut row = timed("abc123", "2026-10-20T09:00:00Z"); + row["start"] = json!({}); + let err = decode_row(row).expect_err("loud"); + assert!(err.to_string().contains("start"), "got: {err}"); + } +} diff --git a/integrations/rhema-calendar-integration/src/ics.rs b/integrations/rhema-calendar-integration/src/ics.rs new file mode 100644 index 0000000..04fb6a3 --- /dev/null +++ b/integrations/rhema-calendar-integration/src/ics.rs @@ -0,0 +1,1168 @@ +//! The ICS adapter: one HTTP GET of an iCalendar feed, expanded. +//! +//! The workhorse backend: Google Calendar's "secret address" (the +//! private iCal export), Nextcloud, Radicale, Fastmail, and most other +//! servers publish one URL that speaks it. Auth, when a feed demands +//! it, is the token in `Authorization: Bearer`; the token never lands +//! in a URL or a log line. +//! +//! Wire choices and the constraints they pin: +//! - Only `VEVENT` components count; `VTODO`/`VJOURNAL`/`VFREEBUSY` +//! are not reminders. Nested components inside an event (`VALARM`) +//! are skipped, not mistaken for properties. +//! - A feed carries master components plus overrides. The adapter +//! expands a master's `RRULE` client-side and applies +//! `RECURRENCE-ID` overrides on top: an override replaces the +//! occurrence at the instant it names (a cancelled one removes it), +//! and one with no master occurrence stands alone. `EXDATE` rows +//! drop occurrences the same way. +//! - The expansion support matrix, deliberately small: `FREQ` daily, +//! weekly (with `BYDAY`), monthly (with `BYMONTHDAY`, else +//! `DTSTART`'s day), yearly; `INTERVAL`; `COUNT`; `UNTIL`. Anything +//! else (`SECONDLY` and finer, `BYSETPOS`, `BYDAY` in a +//! monthly/yearly rule, `BYMONTH`, `WKST` other than the default +//! `MO`) degrades to the `DTSTART` occurrence only, so a rule the +//! adapter cannot expand still surfaces its first instance rather +//! than silently emptying the calendar. +//! - Text values are unescaped (`\n`, `\,`, `\;`); property names and +//! parameters are case-insensitive; continuation lines are unfolded; +//! quoted parameter values may contain colons. +//! - A floating time (no `Z`) reads as UTC, and a `TZID` parameter +//! rides [`CalendarEvent::raw`] for a consumer that can resolve +//! zones. All-day rows (`VALUE=DATE` or a bare date) start at their +//! midnight UTC. RFC 3339 spellings are accepted too, because some +//! exporters emit them. +//! - `STATUS:CANCELLED` rows are dropped. A row without `UID` or +//! `DTSTART` is a loud parse error: feeds are machine-generated, +//! and a shape break must surface, not shrink the calendar. + +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use async_trait::async_trait; +use serde_json::{Map, Value}; +use time::Date; +use time::Month; +use time::OffsetDateTime; +use time::Time; +use time::Weekday; +use time::format_description::well_known::Rfc3339; + +use crate::calendar::{Calendar, finish}; +use crate::error::{CalendarError, ensure_success, required}; +use crate::event::{CalendarEvent, Window}; + +/// The user agent the adapter presents; feeds log it, and some reject +/// requests without one. +const USER_AGENT: &str = concat!("rhema-calendar-integration/", env!("CARGO_PKG_VERSION")); + +/// The most occurrence candidates one expansion may feed before the +/// walk is declared pathological rather than continued. `COUNT` and +/// the window bound every well-formed rule; this cap bounds the rest. +const MAX_CANDIDATES: u64 = 100_000; + +/// ICS feed backend: a URL, an optional bearer token, one reqwest +/// client. An empty token polls anonymous, which is how most published +/// feed URLs work (the secret rides the URL itself). +pub struct IcsCalendar { + url: String, + token: String, + http: reqwest::Client, +} + +impl IcsCalendar { + /// A feed at `url` (an `https://` or `webcal://` address; webcal + /// is fetched as https). + pub fn new(url: impl Into) -> Self { + let http = reqwest::Client::builder() + // One connect attempt: a dead feed fails fast instead of + // stalling the poll loop. + .connect_timeout(Duration::from_secs(10)) + // Every feed call is a bounded request/response, so one + // overall timeout per request is the right guard. + .timeout(Duration::from_secs(20)) + .user_agent(USER_AGENT) + .build() + .expect("a client builder with only timeouts cannot fail"); + Self { + url: normalize_url(url.into()), + token: String::new(), + http, + } + } + + /// Authenticates feeds that demand it: the token rides the + /// `Authorization: Bearer` header only. + pub fn with_token(mut self, token: impl Into) -> Self { + self.token = token.into(); + self + } +} + +/// `webcal://` is iCalendar over https by convention; normalize it so +/// the reqwest client never sees a scheme it cannot speak. +fn normalize_url(url: String) -> String { + let trimmed = url.trim(); + trimmed + .strip_prefix("webcal://") + .map(|rest| format!("https://{rest}")) + .unwrap_or_else(|| trimmed.to_owned()) +} + +#[async_trait] +impl Calendar for IcsCalendar { + async fn events(&self, window: &Window) -> Result, CalendarError> { + let mut request = self.http.get(&self.url); + if !self.token.is_empty() { + request = request.bearer_auth(&self.token); + } + let response = request.send().await.map_err(CalendarError::network)?; + let body = ensure_success(response) + .await? + .text() + .await + .map_err(CalendarError::network)?; + expand(&body, window) + } +} + +/// One content line: `NAME;PARAM=value:value`, names and parameters +/// uppercased, the value untouched (unescaping is a text property's +/// business, not the line's). +#[derive(Debug, Clone)] +struct ContentLine { + name: String, + params: Vec<(String, String)>, + value: String, +} + +impl ContentLine { + /// A parameter's value, compared case-insensitively on the name. + fn param(&self, name: &str) -> Option<&str> { + self.params + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()) + } +} + +/// A run of ASCII digits at `range`, as its number. +fn digits(raw: &str, range: std::ops::Range) -> Option { + let slice = raw.get(range)?; + if slice.is_empty() || !slice.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + slice.parse().ok() +} + +/// Splits one unfolded line at the first colon outside double quotes, +/// so a quoted parameter value (`CN="Smith: Boss"`) cannot split the +/// line in half. +fn split_content_line(line: &str) -> Option { + let mut quoted = false; + let mut split_at = None; + for (index, ch) in line.char_indices() { + match ch { + '"' => quoted = !quoted, + ':' if !quoted => { + split_at = Some(index); + break; + } + _ => {} + } + } + let split_at = split_at?; + let head = &line[..split_at]; + let value = line[split_at + 1..].to_owned(); + let mut parts = head.split(';'); + let name = parts.next()?.trim().to_ascii_uppercase(); + if name.is_empty() { + return None; + } + let params = parts + .filter_map(|part| { + let (key, value) = part.split_once('=')?; + Some(( + key.trim().to_ascii_uppercase(), + value.trim().trim_matches('"').to_owned(), + )) + }) + .collect(); + Some(ContentLine { + name, + params, + value, + }) +} + +/// Unfolds a feed: a line starting with a single space or tab +/// continues the previous one, per RFC 5545 §3.1. +fn unfold(raw: &str) -> Vec { + let mut lines: Vec = Vec::new(); + for raw_line in raw.split('\n') { + let raw_line = raw_line.strip_suffix('\r').unwrap_or(raw_line); + if raw_line.starts_with(' ') || raw_line.starts_with('\t') { + // A continuation with nothing to continue is skipped. + if let Some(last) = lines.last_mut() { + last.push_str(&raw_line[1..]); + } + } else { + lines.push(raw_line.to_owned()); + } + } + lines +} + +/// Walks the component stack and collects each `VEVENT`'s own lines: +/// nested components (`VALARM`) inside an event contribute nothing. +fn vevent_blocks(raw: &str) -> Vec> { + let mut blocks = Vec::new(); + let mut current: Option> = None; + let mut stack: Vec = Vec::new(); + for line in unfold(raw) { + let Some(line) = split_content_line(&line) else { + continue; + }; + match line.name.as_str() { + "BEGIN" => { + let component = line.value.trim().to_ascii_uppercase(); + let is_event = component == "VEVENT"; + stack.push(component); + if is_event && current.is_none() { + current = Some(Vec::new()); + } + } + "END" => { + let component = line.value.trim().to_ascii_uppercase(); + if component == "VEVENT" { + if let Some(block) = current.take() { + blocks.push(block); + } + } + stack.pop(); + } + _ => { + if stack.last().map(String::as_str) == Some("VEVENT") { + if let Some(block) = current.as_mut() { + block.push(line); + } + } + } + } + } + blocks +} + +/// Unescapes a text value: the four RFC 5545 escapes, nothing more. +fn unescape(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut chars = value.chars(); + while let Some(ch) = chars.next() { + if ch != '\\' { + out.push(ch); + continue; + } + match chars.next() { + Some('n') | Some('N') => out.push('\n'), + Some(',') => out.push(','), + Some(';') => out.push(';'), + Some('\\') => out.push('\\'), + // An unknown escape keeps both characters: the feed's + // bytes, not this parser's guess, survive. + Some(other) => { + out.push('\\'); + out.push(other); + } + None => out.push('\\'), + } + } + out +} + +/// Parses iCalendar's compact datetime, `YYYYMMDDTHHMMSS` with an +/// optional trailing `Z`. The `Z` decides nothing here: both forms +/// read as UTC (a floating time is the feed saying "UTC or bust" to a +/// reader without a zone database). +fn parse_compact(value: &str) -> Option { + let bytes = value.as_bytes(); + if bytes.len() != 15 && bytes.len() != 16 { + return None; + } + if bytes[8] != b'T' && bytes[8] != b't' { + return None; + } + if bytes.len() == 16 && !(bytes[15] == b'Z' || bytes[15] == b'z') { + return None; + } + let year = digits(value, 0..4)?; + let month = digits(value, 4..6)?; + let day = digits(value, 6..8)?; + let hour = digits(value, 9..11)?; + let minute = digits(value, 11..13)?; + let second = digits(value, 13..15)?; + let date = Date::from_calendar_date(year as i32, Month::try_from(month as u8).ok()?, day as u8) + .ok()?; + let clock = Time::from_hms(hour as u8, minute as u8, second as u8).ok()?; + Some(date.with_time(clock).assume_utc()) +} + +/// Parses an iCalendar date, `YYYYMMDD`, as its midnight UTC. +fn parse_date(value: &str) -> Option { + if value.len() != 8 { + return None; + } + let year = digits(value, 0..4)?; + let month = digits(value, 4..6)?; + let day = digits(value, 6..8)?; + let date = Date::from_calendar_date(year as i32, Month::try_from(month as u8).ok()?, day as u8) + .ok()?; + Some(date.with_time(Time::MIDNIGHT).assume_utc()) +} + +/// Parses any `DTSTART`-shape value into an instant plus its all-day +/// flag. Accepts `VALUE=DATE`, the compact forms, a bare date, and an +/// RFC 3339 spelling (some exporters emit one). +fn parse_when(line: &ContentLine) -> Option<(OffsetDateTime, bool)> { + let value = line.value.trim(); + if line.param("VALUE").map(str::to_ascii_uppercase).as_deref() == Some("DATE") { + return parse_date(value).map(|start| (start, true)); + } + if let Some(start) = parse_compact(value) { + return Some((start, false)); + } + if let Some(start) = parse_date(value) { + return Some((start, true)); + } + OffsetDateTime::parse(value, &Rfc3339) + .ok() + .map(|start| (start, false)) +} + +/// Parses an iCalendar duration, `[+-]P[nW][nD][T[nH][nM][nS]]`. +/// Negative and unparsable values read as none: a duration is a +/// nicety on an end-less row, never worth failing a feed over. +fn parse_duration(value: &str) -> Option { + let (negative, rest) = match value.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, value.strip_prefix('+').unwrap_or(value)), + }; + let rest = rest.strip_prefix('P').or_else(|| rest.strip_prefix('p'))?; + let mut weeks = 0i64; + let mut days = 0i64; + let mut hours = 0i64; + let mut minutes = 0i64; + let mut seconds = 0i64; + let mut in_time = false; + let mut number = String::new(); + for ch in rest.chars() { + if ch.is_ascii_digit() { + number.push(ch); + continue; + } + // The T introducing the time part arrives with no number + // before it; every unit letter must follow one. + if !number.is_empty() { + let Ok(amount) = number.parse::() else { + return None; + }; + number.clear(); + match ch { + 'W' if !in_time => weeks = amount, + 'D' if !in_time => days = amount, + 'H' if in_time => hours = amount, + 'M' if in_time => minutes = amount, + 'S' if in_time => seconds = amount, + _ => return None, + } + } else if matches!(ch, 'T' | 't') && !in_time { + in_time = true; + } else { + return None; + } + } + if !number.is_empty() { + return None; + } + let total = weeks * 604_800 + days * 86_400 + hours * 3600 + minutes * 60 + seconds; + let duration = time::Duration::seconds(if negative { -total } else { total }); + (duration > time::Duration::ZERO).then_some(duration) +} + +/// Which cadence a rule repeats on. `Unsupported` is the honest +/// spelling for every shape the expansion matrix leaves out. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Freq { + Daily, + Weekly, + Monthly, + Yearly, + Unsupported, +} + +/// One `RRULE`, reduced to the shapes feeds emit. +#[derive(Debug, Clone)] +struct Rrule { + freq: Freq, + interval: u32, + count: Option, + until: Option, + by_day: Vec, + by_month_day: Vec, +} + +/// Parses an `RRULE` value. Any part outside the support matrix marks +/// the whole rule unsupported, and an unparsable piece degrades to its +/// default rather than failing the feed: an expansion that guesses is +/// worse than an expansion that declines. +fn parse_rrule(value: &str) -> Rrule { + let mut freq = Freq::Unsupported; + let mut interval = 1u32; + let mut count = None; + let mut until = None; + let mut by_day = Vec::new(); + let mut by_month_day = Vec::new(); + let mut unsupported = false; + for part in value.trim().split(';') { + let Some((key, value)) = part.split_once('=') else { + continue; + }; + match key.trim().to_ascii_uppercase().as_str() { + "FREQ" => { + freq = match value.trim().to_ascii_uppercase().as_str() { + "DAILY" => Freq::Daily, + "WEEKLY" => Freq::Weekly, + "MONTHLY" => Freq::Monthly, + "YEARLY" => Freq::Yearly, + _ => Freq::Unsupported, + }; + } + "INTERVAL" => { + interval = value.trim().parse().unwrap_or(1).max(1); + } + "COUNT" => count = value.trim().parse().ok(), + "UNTIL" => { + let line = ContentLine { + name: String::new(), + params: Vec::new(), + value: value.trim().to_owned(), + }; + until = parse_when(&line).map(|(when, _)| when); + } + "BYDAY" => { + for item in value.trim().split(',') { + // An ordinal prefix (`1MO`, `-1FR`) is the + // nth-weekday-of-month shape the matrix leaves out. + let bare = item.trim().trim_start_matches(|ch: char| { + ch.is_ascii_digit() || ch == '-' || ch == '+' + }); + if bare.len() != item.trim().len() { + unsupported = true; + } + match bare { + "MO" => by_day.push(Weekday::Monday), + "TU" => by_day.push(Weekday::Tuesday), + "WE" => by_day.push(Weekday::Wednesday), + "TH" => by_day.push(Weekday::Thursday), + "FR" => by_day.push(Weekday::Friday), + "SA" => by_day.push(Weekday::Saturday), + "SU" => by_day.push(Weekday::Sunday), + _ => unsupported = true, + } + } + } + "BYMONTHDAY" => { + for item in value.trim().split(',') { + let item = item.trim(); + match item.parse::() { + Ok(day) if (1..=31).contains(&day) => by_month_day.push(day), + // Negative ordinals count from the month's end; + // the matrix leaves them out. + _ => unsupported = true, + } + } + } + // BYSETPOS, BYMONTH, BYWEEKNO, BYYEARDAY, WKST, and every + // unknown part: unsupported, loudly, by the same rule. + _ => unsupported = true, + } + } + if unsupported { + freq = Freq::Unsupported; + } + Rrule { + freq, + interval, + count, + until, + by_day, + by_month_day, + } +} + +/// One parsed `VEVENT`: the fields the vocabulary lifts, the rule and +/// override marks the expansion needs, and the template event the +/// occurrences clone. +struct Parsed<'a> { + provider: &'a str, + uid: String, + start: OffsetDateTime, + duration: time::Duration, + template: CalendarEvent, + status: Option, + recurrence_id: Option, + exdates: Vec, + rrule: Option, +} + +/// Parses one `VEVENT` block. A row without `UID` or `DTSTART` fails +/// the feed loudly; everything else degrades politely. +fn parse_event<'a>(block: &[ContentLine], provider: &'a str) -> Result, CalendarError> { + let first = |name: &str| block.iter().find(|line| line.name == name); + let uid = required(first("UID"), "a UID")?.value.trim().to_owned(); + let dtstart = required(first("DTSTART"), "a DTSTART")?; + let (start, all_day) = parse_when(dtstart) + .ok_or_else(|| CalendarError::Parse(format!("event {uid:?} has an unreadable DTSTART")))?; + + // The end rides DTEND when present, else DURATION, else nothing. + // A negative span is the feed's typo, not ours to widen: it reads + // as zero. + let end = first("DTEND") + .and_then(parse_when) + .map(|(when, _)| when) + .or_else(|| { + first("DURATION") + .and_then(|line| parse_duration(&line.value)) + .and_then(|duration| start.checked_add(duration)) + }); + let duration = end + .map(|end| (end - start).max(time::Duration::ZERO)) + .unwrap_or(time::Duration::ZERO); + + let text = |name: &str| first(name).map(|line| unescape(&line.value)); + // `mailto:` is the scheme ICS addresses ride; strip it wherever it + // appears, case-insensitively. + let bare_address = |value: &str| match value.trim() { + trimmed if trimmed.len() >= 7 && trimmed[..7].eq_ignore_ascii_case("mailto:") => { + trimmed[7..].trim().to_owned() + } + trimmed => trimmed.to_owned(), + }; + + let mut raw = Map::new(); + for line in block { + let mut key = line.name.clone(); + if !line.params.is_empty() { + key.push(';'); + key.push_str( + &line + .params + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join(";"), + ); + } + use serde_json::map::Entry; + match raw.entry(key) { + Entry::Vacant(slot) => { + slot.insert(Value::String(line.value.clone())); + } + Entry::Occupied(mut slot) => match slot.get_mut() { + // A repeated property (ATTENDEE, for one) becomes an + // array on its second occurrence. + Value::Array(items) => items.push(Value::String(line.value.clone())), + existing => { + let first = existing.take(); + *slot.get_mut() = Value::Array(vec![first, Value::String(line.value.clone())]); + } + }, + } + } + + let template = CalendarEvent { + id: format!("{provider}:{uid}@{}", start.unix_timestamp()), + title: text("SUMMARY").unwrap_or_default(), + start, + end, + all_day, + location: text("LOCATION"), + description: text("DESCRIPTION"), + attendees: block + .iter() + .filter(|line| line.name == "ATTENDEE") + .map(|line| bare_address(&line.value)) + .collect(), + organizer: first("ORGANIZER").map(|line| bare_address(&line.value)), + url: text("URL"), + raw: Value::Object(raw), + }; + + Ok(Parsed { + provider, + uid, + start, + duration, + template, + status: first("STATUS").map(|line| line.value.trim().to_ascii_uppercase()), + recurrence_id: first("RECURRENCE-ID") + .and_then(parse_when) + .map(|(when, _)| when), + exdates: first("EXDATE") + .map(|line| { + line.value + .split(',') + .map(|value| { + let line = ContentLine { + name: String::new(), + params: line.params.clone(), + value: value.to_owned(), + }; + parse_when(&line).map(|(when, _)| when.unix_timestamp()) + }) + .collect::>>() + .unwrap_or_default() + }) + .unwrap_or_default(), + rrule: first("RRULE").map(|line| parse_rrule(&line.value)), + }) +} + +/// Expands a whole feed: parse, expand the masters, apply the +/// overrides, keep what starts in `window`. +pub(crate) fn expand(raw: &str, window: &Window) -> Result, CalendarError> { + expand_as(raw, "ics", window) +} + +/// Expands a whole feed, occurrence ids carrying `provider` as their +/// prefix: the CalDAV adapter re-runs this over the iCalendar objects +/// a `REPORT` returns, spelling itself `caldav`. +pub(crate) fn expand_as( + raw: &str, + provider: &str, + window: &Window, +) -> Result, CalendarError> { + let mut singles = Vec::new(); + let mut masters = Vec::new(); + let mut overrides: HashMap = HashMap::new(); + // A cancelled override is the tombstone of one occurrence: its + // slot suppresses the master occurrence, and nothing answers for + // it. A cancelled master takes its whole rule with it. + let mut cancelled_slots: HashSet = HashSet::new(); + for block in vevent_blocks(raw) { + let parsed = parse_event(&block, provider)?; + if parsed.status.as_deref() == Some("CANCELLED") { + if let Some(recurrence_id) = parsed.recurrence_id { + cancelled_slots.insert(recurrence_id.unix_timestamp()); + } + continue; + } + match parsed.recurrence_id { + Some(recurrence_id) => { + overrides.insert(recurrence_id.unix_timestamp(), parsed); + } + None if parsed.rrule.is_some() => masters.push(parsed), + None => singles.push(parsed), + } + } + + let mut suppress_all: HashSet = overrides.keys().copied().collect(); + suppress_all.extend(cancelled_slots); + let mut events = Vec::new(); + for master in &masters { + let mut suppress = suppress_all.clone(); + suppress.extend(master.exdates.iter().copied()); + expand_rule(master, window, &suppress, &mut events); + } + // Every override answers with its own occurrence, moved start + // included; the master occurrence it names is the one dropped. + // One whose master never generated its slot stands alone, which is + // the only reading a broken feed leaves open. + for parsed in overrides.into_values() { + events.push(parsed.template); + } + for single in singles { + events.push(single.template); + } + Ok(finish(events, window)) +} + +/// One occurrence of a master: the template with the occurrence's own +/// start, end, and id. +fn occurrence(parsed: &Parsed, start: OffsetDateTime) -> CalendarEvent { + let mut event = parsed.template.clone(); + event.id = format!( + "{}:{}@{}", + parsed.provider, + parsed.uid, + start.unix_timestamp() + ); + event.start = start; + event.end = start.checked_add(parsed.duration); + event +} + +/// Whole days since the epoch, UTC: the ruler every interval is +/// counted against. +fn day_number(date: Date) -> i64 { + date.with_time(Time::MIDNIGHT) + .assume_utc() + .unix_timestamp() + .div_euclid(86_400) +} + +/// Whole weeks since the epoch's Monday (1970-01-05), Monday-based: +/// the invariant weekly `INTERVAL`s are counted against. +fn week_number(date: Date) -> i64 { + (day_number(date) - 4).div_euclid(7) +} + +/// `year`/`month` shifted by `months`, out-of-range days handled by +/// the caller (a February 30th is a month without that day, not an +/// error). +fn add_months(year: i32, month: Month, months: i64) -> (i32, Month) { + let total = year as i64 * 12 + i64::from(u8::from(month)) - 1 + months; + ( + total.div_euclid(12) as i32, + Month::try_from((total.rem_euclid(12) + 1) as u8).expect("month is 1..=12"), + ) +} + +/// Expands one master's rule into `events`, candidates in +/// chronological order. A candidate an override or `EXDATE` claims is +/// walked (so `COUNT` stays true to the feed) but not emitted: the +/// claimant answers for it instead. +fn expand_rule( + parsed: &Parsed, + window: &Window, + suppress: &HashSet, + events: &mut Vec, +) { + let Some(rule) = parsed.rrule.as_ref() else { + return; + }; + let mut count_seen = 0u64; + let mut walked = 0u64; + let limit = rule.count.map(u64::from).unwrap_or(u64::MAX); + + // Feeds one candidate; false when the walk must stop. Candidates + // arrive in chronological order, so the first one past `until` or + // the window's end ends it, and `COUNT` counts occurrences, not + // walk steps. The walk cap is the pathological-feed guard: every + // well-formed rule is already bounded by `COUNT`, `until`, or the + // window itself. + let mut candidate = |start: OffsetDateTime| -> bool { + walked += 1; + if walked > MAX_CANDIDATES { + return false; + } + if let Some(until) = rule.until { + if start > until { + return false; + } + } + if start > window.to { + return false; + } + if count_seen >= limit { + return false; + } + count_seen += 1; + if !suppress.contains(&start.unix_timestamp()) && window.starts_within(start) { + events.push(occurrence(parsed, start)); + } + count_seen < limit + }; + + match rule.freq { + Freq::Unsupported => { + // The honest degradation: the first instance only. + if window.starts_within(parsed.start) + && !suppress.contains(&parsed.start.unix_timestamp()) + { + events.push(occurrence(parsed, parsed.start)); + } + } + Freq::Daily | Freq::Weekly => { + let start_week = week_number(parsed.start.date()); + let start_day = day_number(parsed.start.date()); + let mut day = parsed.start.date(); + loop { + let on_rule = match rule.freq { + Freq::Weekly => { + let weekday_ok = + rule.by_day.is_empty() || rule.by_day.contains(&day.weekday()); + let week_ok = + (week_number(day) - start_week) % i64::from(rule.interval) == 0; + weekday_ok && week_ok + } + _ => (day_number(day) - start_day) % i64::from(rule.interval) == 0, + }; + if !on_rule { + let Some(next) = day.next_day() else { + break; + }; + day = next; + if day > window.to.date() { + break; + } + continue; + } + let start = day.with_time(parsed.start.time()).assume_utc(); + if !candidate(start) { + break; + } + let Some(next) = day.next_day() else { + break; + }; + day = next; + if day > window.to.date() { + break; + } + } + } + Freq::Monthly => { + let days = if rule.by_month_day.is_empty() { + vec![parsed.start.day()] + } else { + let mut days = rule.by_month_day.clone(); + days.sort_unstable(); + days + }; + let mut month = 0i64; + loop { + let (year, this_month) = add_months( + parsed.start.year(), + parsed.start.month(), + month * i64::from(rule.interval), + ); + let Ok(first) = Date::from_calendar_date(year, this_month, 1) else { + break; + }; + if first.with_time(Time::MIDNIGHT).assume_utc() > window.to { + break; + } + month += 1; + for day in &days { + if let Ok(date) = Date::from_calendar_date(year, this_month, *day) { + let start = date.with_time(parsed.start.time()).assume_utc(); + if !candidate(start) { + return; + } + } + // A month without that day (a 31st in February) + // contributes no occurrence, by the RFC's own rule. + } + } + } + Freq::Yearly => { + let mut year = 0i64; + loop { + let this_year = parsed.start.year() + (year * i64::from(rule.interval)) as i32; + year += 1; + let Ok(january) = Date::from_calendar_date(this_year, Month::January, 1) else { + break; + }; + if january.with_time(Time::MIDNIGHT).assume_utc() > window.to { + break; + } + // A year without that day (a February 29th) is + // skipped, not clamped. + if let Ok(date) = + Date::from_calendar_date(this_year, parsed.start.month(), parsed.start.day()) + { + let start = date.with_time(parsed.start.time()).assume_utc(); + if !candidate(start) { + return; + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn window(start: &str, hours: i64) -> Window { + let from = OffsetDateTime::parse(start, &Rfc3339).expect("window start parses"); + Window::new(from, from + time::Duration::hours(hours)) + } + + fn ids(events: &[CalendarEvent]) -> Vec { + events.iter().map(|event| event.id.clone()).collect() + } + + #[test] + fn webcal_urls_normalize_to_https() { + assert_eq!( + normalize_url("webcal://example.net/cal.ics".to_owned()), + "https://example.net/cal.ics" + ); + assert_eq!( + normalize_url(" https://example.net/cal.ics ".to_owned()), + "https://example.net/cal.ics" + ); + } + + #[test] + fn unfolding_joins_continuation_lines() { + let raw = "SUMMARY:a long\r\n title\r\nLOCATION:paris\n , france\nEND:VEVENT"; + assert_eq!( + unfold(raw), + vec![ + "SUMMARY:a longtitle".to_owned(), + "LOCATION:paris, france".to_owned(), + "END:VEVENT".to_owned(), + ] + ); + } + + #[test] + fn a_quoted_parameter_may_carry_a_colon() { + let line = + split_content_line(r#"ATTENDEE;CN="Smith: Boss";RSVP=TRUE:mailto:bob@example.net"#) + .expect("parses"); + assert_eq!(line.name, "ATTENDEE"); + assert_eq!(line.param("CN"), Some(r#"Smith: Boss"#)); + assert_eq!(line.value, "mailto:bob@example.net"); + } + + #[test] + fn compact_datetimes_and_dates_read_as_utc() { + let zulu = parse_compact("20261020T140000Z").expect("zulu parses"); + let floating = parse_compact("20261020T140000").expect("floating parses"); + assert_eq!(zulu, floating); + let date = parse_date("20261020").expect("date parses"); + assert_eq!(date, zulu.replace_time(Time::MIDNIGHT)); + let rfc = parse_when(&ContentLine { + name: "DTSTART".into(), + params: Vec::new(), + value: "2026-10-20T14:00:00Z".into(), + }) + .expect("rfc 3339 parses"); + assert_eq!(rfc.0, zulu); + } + + #[test] + fn durations_parse_in_every_unit() { + assert_eq!(parse_duration("PT1H30M"), Some(time::Duration::minutes(90))); + assert_eq!(parse_duration("P1D"), Some(time::Duration::days(1))); + assert_eq!(parse_duration("P2W"), Some(time::Duration::days(14))); + assert_eq!(parse_duration("PT15M"), Some(time::Duration::minutes(15))); + assert_eq!(parse_duration("-PT15M"), None, "negative reads as none"); + assert_eq!(parse_duration("garbage"), None); + } + + #[test] + fn a_single_event_expands_to_itself() { + let raw = "BEGIN:VEVENT\nUID:a@x\nDTSTART:20261020T140000Z\nDTEND:20261020T150000Z\n\ + SUMMARY:one-off\nEND:VEVENT\n"; + let events = expand(raw, &window("2026-10-20T00:00:00Z", 24)).expect("expands"); + let at = OffsetDateTime::parse("2026-10-20T14:00:00Z", &Rfc3339).expect("parses"); + assert_eq!( + ids(&events), + vec![format!("ics:a@x@{}", at.unix_timestamp())] + ); + assert_eq!(events[0].title, "one-off"); + assert_eq!(events[0].duration(), time::Duration::hours(1)); + } + + #[test] + fn text_values_unescape_and_addresses_strip_mailto() { + let raw = "BEGIN:VEVENT\nUID:a@x\nDTSTART:20261020T140000Z\n\ + SUMMARY:quarterly\\, review\\n Agenda: TBA\n\ + ORGANIZER;CN=Boss:MAILTO:boss@example.net\n\ + ATTENDEE:mailto:alice@example.net\nATTENDEE:bob@example.net\n\ + END:VEVENT\n"; + let events = expand(raw, &window("2026-10-20T00:00:00Z", 24)).expect("expands"); + assert_eq!(events[0].title, "quarterly, review\n Agenda: TBA"); + assert_eq!(events[0].organizer.as_deref(), Some("boss@example.net")); + assert_eq!( + events[0].attendees, + vec!["alice@example.net".to_owned(), "bob@example.net".to_owned()] + ); + } + + #[test] + fn a_weekly_byday_rule_expands_across_the_window() { + let raw = "BEGIN:VEVENT\nUID:standup@x\nDTSTART:20261005T090000Z\nDTEND:20261005T093000Z\n\ + RRULE:FREQ=WEEKLY;BYDAY=MO,WE\nEND:VEVENT\n"; + let events = expand(raw, &window("2026-10-19T00:00:00Z", 24 * 7)).expect("expands"); + // The window is Mon Oct 19 .. Sun Oct 25: the rule lands on + // Mon 19, Wed 21, Mon 26 is past `to`. + assert_eq!(ids(&events).len(), 2); + assert_eq!( + events[0].start, + OffsetDateTime::parse("2026-10-19T09:00:00Z", &Rfc3339).expect("parses") + ); + assert_eq!( + events[1].start, + OffsetDateTime::parse("2026-10-21T09:00:00Z", &Rfc3339).expect("parses") + ); + } + + #[test] + fn a_biweekly_rule_counts_weeks_from_dtstart() { + // DTSTART is Mon Oct 5; interval 2 puts Oct 19 in, Oct 12 out. + let raw = "BEGIN:VEVENT\nUID:fortnight@x\nDTSTART:20261005T100000Z\n\ + RRULE:FREQ=WEEKLY;INTERVAL=2\nEND:VEVENT\n"; + let events = expand(raw, &window("2026-10-12T00:00:00Z", 24 * 9)).expect("expands"); + let starts: Vec = events + .iter() + .map(|event| event.start.format(&Rfc3339).expect("formats")) + .collect(); + assert!( + starts + .iter() + .any(|start| start.contains("20261019") || start.contains("10-19")) + ); + assert!( + !starts + .iter() + .any(|start| start.contains("10-12") || start.contains("20261012")) + ); + } + + #[test] + fn count_bounds_the_expansion_even_past_the_window() { + let raw = "BEGIN:VEVENT\nUID:thrice@x\nDTSTART:20261001T080000Z\n\ + RRULE:FREQ=DAILY;COUNT=3\nEND:VEVENT\n"; + // The three occurrences are Oct 1..3; a mid-October window + // sees none of them. + let events = expand(raw, &window("2026-10-15T00:00:00Z", 24)).expect("expands"); + assert!(events.is_empty()); + } + + #[test] + fn a_monthly_rule_skips_months_without_the_day() { + let raw = "BEGIN:VEVENT\nUID:payday@x\nDTSTART:20260131T120000Z\n\ + RRULE:FREQ=MONTHLY\nEND:VEVENT\n"; + // February has no 31st; the walk lands on Mar 31. + let events = expand(raw, &window("2026-03-31T00:00:00Z", 24)).expect("expands"); + assert_eq!(ids(&events).len(), 1); + } + + #[test] + fn until_stops_the_rule() { + let raw = "BEGIN:VEVENT\nUID:course@x\nDTSTART:20261001T180000Z\n\ + RRULE:FREQ=DAILY;UNTIL=20261003T180000Z\nEND:VEVENT\n"; + let events = expand(raw, &window("2026-10-02T00:00:00Z", 24 * 7)).expect("expands"); + assert_eq!(ids(&events).len(), 2, "Oct 2 and Oct 3, never Oct 4"); + } + + #[test] + fn an_unsupported_rule_degrades_to_dtstart() { + let raw = "BEGIN:VEVENT\nUID:odd@x\nDTSTART:20261001T080000Z\n\ + RRULE:FREQ=MONTHLY;BYDAY=1MO\nEND:VEVENT\n"; + let events = expand(raw, &window("2026-10-01T00:00:00Z", 24)).expect("expands"); + assert_eq!(ids(&events).len(), 1); + assert_eq!(events[0].start.day(), 1); + } + + #[test] + fn an_override_replaces_its_occurrence_and_a_cancelled_one_removes_it() { + // Daily at 09:00; an override moves Oct 2's to 15:00, a + // cancelled one removes Oct 3's. + let raw = "BEGIN:VEVENT\nUID:week@x\nDTSTART:20261001T090000Z\n\ + RRULE:FREQ=DAILY;COUNT=5\nEND:VEVENT\n\ + BEGIN:VEVENT\nUID:week@x\nRECURRENCE-ID:20261002T090000Z\n\ + DTSTART:20261002T150000Z\nSUMMARY:moved\nEND:VEVENT\n\ + BEGIN:VEVENT\nUID:week@x\nRECURRENCE-ID:20261003T090000Z\n\ + DTSTART:20261003T090000Z\nSTATUS:CANCELLED\nEND:VEVENT\n"; + let events = expand(raw, &window("2026-10-01T00:00:00Z", 24 * 7)).expect("expands"); + let starts: Vec = events + .iter() + .map(|event| event.start.unix_timestamp()) + .collect(); + assert_eq!(starts.len(), 4, "5 occurrences, one moved, one cancelled"); + assert!( + !starts.contains( + &OffsetDateTime::parse("2026-10-02T09:00:00Z", &Rfc3339) + .expect("parses") + .unix_timestamp() + ) + ); + assert!( + starts.contains( + &OffsetDateTime::parse("2026-10-02T15:00:00Z", &Rfc3339) + .expect("parses") + .unix_timestamp() + ) + ); + } + + #[test] + fn an_override_moving_an_occurrence_into_the_window_does_not_double() { + let raw = "BEGIN:VEVENT\nUID:move@x\nDTSTART:20261001T090000Z\n\ + RRULE:FREQ=DAILY;COUNT=3\nEND:VEVENT\n\ + BEGIN:VEVENT\nUID:move@x\nRECURRENCE-ID:20261001T090000Z\n\ + DTSTART:20261020T150000Z\nEND:VEVENT\n"; + // The window sees only the moved instance; the Oct 1 original + // it replaced is outside and must not resurrect. + let events = expand(raw, &window("2026-10-20T00:00:00Z", 24)).expect("expands"); + let moved = OffsetDateTime::parse("2026-10-20T15:00:00Z", &Rfc3339).expect("parses"); + assert_eq!( + ids(&events), + vec![format!("ics:move@x@{}", moved.unix_timestamp())] + ); + } + + #[test] + fn exdate_drops_occurrences() { + let raw = "BEGIN:VEVENT\nUID:skip@x\nDTSTART:20261001T090000Z\n\ + RRULE:FREQ=DAILY;COUNT=5\nEXDATE:20261002T090000Z\nEND:VEVENT\n"; + let events = expand(raw, &window("2026-10-01T00:00:00Z", 24 * 7)).expect("expands"); + assert_eq!(events.len(), 4); + } + + #[test] + fn cancelled_masters_and_gtodos_are_ignored() { + let raw = "BEGIN:VTODO\nUID:task@x\nDTSTART:20261020T140000Z\nEND:VTODO\n\ + BEGIN:VEVENT\nUID:gone@x\nDTSTART:20261020T140000Z\nSTATUS:CANCELLED\nEND:VEVENT\n"; + let events = expand(raw, &window("2026-10-20T00:00:00Z", 24)).expect("expands"); + assert!(events.is_empty()); + } + + #[test] + fn an_event_inside_a_valarm_is_not_mistaken_for_a_property() { + let raw = "BEGIN:VEVENT\nUID:alarm@x\nDTSTART:20261020T140000Z\nSUMMARY:real\n\ + BEGIN:VALARM\nUID:fake@x\nDTSTART:20261020T134500Z\nSUMMARY:alarm\nEND:VALARM\n\ + END:VEVENT\n"; + let events = expand(raw, &window("2026-10-20T00:00:00Z", 24)).expect("expands"); + assert_eq!(events.len(), 1); + assert_eq!(events[0].title, "real"); + } + + #[test] + fn a_row_without_uid_or_dtstart_fails_the_feed_loudly() { + let no_uid = "BEGIN:VEVENT\nDTSTART:20261020T140000Z\nEND:VEVENT\n"; + let err = expand(no_uid, &window("2026-10-20T00:00:00Z", 24)).expect_err("loud"); + assert!(err.to_string().contains("UID"), "got: {err}"); + + let no_start = "BEGIN:VEVENT\nUID:a@x\nSUMMARY:when?\nEND:VEVENT\n"; + let err = expand(no_start, &window("2026-10-20T00:00:00Z", 24)).expect_err("loud"); + assert!(err.to_string().contains("DTSTART"), "got: {err}"); + } + + #[test] + fn an_all_day_row_starts_at_midnight_and_says_so() { + let raw = "BEGIN:VEVENT\nUID:day@x\nDTSTART;VALUE=DATE:20261020\nSUMMARY:offsite\n\ + DTEND;VALUE=DATE:20261021\nEND:VEVENT\n"; + let events = expand(raw, &window("2026-10-20T00:00:00Z", 24)).expect("expands"); + assert_eq!(events.len(), 1); + assert!(events[0].all_day); + assert_eq!(events[0].duration(), time::Duration::days(1)); + assert!(events[0].start.to_string().contains("00:00")); + } + + #[test] + fn a_tzid_parameter_rides_the_raw_row() { + let raw = "BEGIN:VEVENT\nUID:tz@x\nDTSTART;TZID=Europe/Paris:20261020T140000\nEND:VEVENT\n"; + let events = expand(raw, &window("2026-10-20T00:00:00Z", 24)).expect("expands"); + assert_eq!( + events[0].raw.get("DTSTART;TZID=Europe/Paris"), + Some(&Value::String("20261020T140000".to_owned())) + ); + } +} diff --git a/integrations/rhema-calendar-integration/src/lib.rs b/integrations/rhema-calendar-integration/src/lib.rs new file mode 100644 index 0000000..76beeec --- /dev/null +++ b/integrations/rhema-calendar-integration/src/lib.rs @@ -0,0 +1,79 @@ +//! `rhema-calendar-integration` — the calendar integration: one +//! normalized occurrence vocabulary and one client contract for the +//! calendar feeds a resident agent can remind about, with the adapters +//! that read them. +//! +//! An integration, not an agent: no driver, no loop, and no reminder +//! policy lives here. A consumer composes the crate with its own agent +//! tier — it picks a [`crate::Window`], polls a [`crate::Calendar`] +//! for the occurrences starting inside it, and decides what a start +//! time means (the claw daemon today: one reminder slot per lead time, +//! dispatched into the resident agent). Every [`crate::CalendarEvent`] +//! carries an id that stays the same across polls of the same +//! occurrence, so a consumer re-polling on its own cadence never +//! re-fires a reminder it already fed; the repetition is the +//! consumer's dispatcher's dedup, not this crate's problem. +//! +//! Recurrence is expanded before a poll answers, so a consumer never +//! sees a rule, only starts it can compare against its clock: +//! - The Google Calendar adapter asks the server to expand +//! (`singleEvents=true`). +//! - The ICS and CalDAV adapters expand client-side, covering the +//! rule shapes servers actually emit (daily, weekly with `BYDAY`, +//! monthly, yearly; `INTERVAL`, `COUNT`, `UNTIL`, `EXDATE`) and +//! applying `RECURRENCE-ID` overrides. Anything outside those +//! shapes surfaces once, at its `DTSTART`, never silently dropped. +//! CalDAV shares the exact expansion: the server returns masters +//! plus overrides in range, and the adapter reads them out of the +//! multistatus. Where a server exposes both, the feed URL and the +//! DAV collection are different identities: their occurrence ids +//! carry different provider prefixes. +//! +//! Times are instants, not wall-clock strings. Every wire format (RFC +//! 3339, iCalendar's compact `YYYYMMDDTHHMMSS[Z]`, `VALUE=DATE` +//! all-days) normalizes to a `time::OffsetDateTime`; a floating +//! (offset-less) time reads as UTC, and a `TZID` parameter rides the +//! event's `raw` for a consumer that can resolve zones. +//! +//! ``` +//! use rhema_calendar_integration::{Calendar, IcsCalendar, Window}; +//! use time::format_description::well_known::Rfc3339; +//! +//! // A feed is one URL (webcal counts as https); the poll is one GET. +//! let feed = IcsCalendar::new("https://example.net/nowhere/calendar.ics"); +//! let from = time::OffsetDateTime::parse("2026-10-20T00:00:00Z", &Rfc3339) +//! .expect("parses"); +//! let window = Window::new(from, from + time::Duration::hours(24)); +//! // `feed.events(&window).await` answers with the occurrences whose +//! // start falls inside the window, oldest first; this doctest only +//! // assembles them. +//! let _ = (&feed, &window); +//! ``` +//! +//! Layout: +//! - [`event`] — the normalized vocabulary: `CalendarEvent`, `Window` +//! - [`error`] — the crate's single calendar error type +//! - [`calendar`] — the `Calendar` client contract +//! - [`ics`] — the ICS/webcal adapter (one GET, expanded client-side) +//! - [`caldav`] — the CalDAV adapter (one REPORT, the same expansion) +//! - [`google`] — the Google Calendar API adapter (expanded server-side) + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +pub mod caldav; +pub mod calendar; +pub mod error; +pub mod event; +pub mod google; +pub mod ics; + +// Re-exported so implementors of the async seam can apply #[async_trait] +// without adding the crate themselves. +pub use async_trait::async_trait; +pub use caldav::CaldavCalendar; +pub use calendar::Calendar; +pub use error::CalendarError; +pub use event::{CalendarEvent, Window}; +pub use google::GoogleCalendar; +pub use ics::IcsCalendar; diff --git a/integrations/rhema-calendar-integration/tests/caldav.rs b/integrations/rhema-calendar-integration/tests/caldav.rs new file mode 100644 index 0000000..4e1e4de --- /dev/null +++ b/integrations/rhema-calendar-integration/tests/caldav.rs @@ -0,0 +1,144 @@ +//! Offline integration tests: the CalDAV backend against an in-process +//! wiremock server. Zero network. + +use rhema_calendar_integration::{CaldavCalendar, Calendar, CalendarError, Window}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; +use wiremock::matchers::{body_string_contains, header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn window() -> Window { + // Oct 1 00:00 to Oct 3 00:00: wide enough that the recurring + // master's second day lands inside, and the `time-range` bound in + // the request reads `end=20261003T000000Z`. + let from = OffsetDateTime::parse("2026-10-01T00:00:00Z", &Rfc3339).expect("parses"); + Window::new(from, from + time::Duration::hours(48)) +} + +/// The 207 answer: one single event, one recurring master with a moved +/// override. The iCalendar objects carry ` ` where CRLF +/// belongs, because that is how servers spell an ICS inside XML, and +/// an unexpanded reference would fold the object into one unreadable +/// line. +const MULTISTATUS: &str = r#" + + + /calendars/me/home/kickoff.ics + + + "1" + BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:kickoff@x DTSTART:20261001T140000Z DTEND:20261001T150000Z SUMMARY:kickoff END:VEVENT END:VCALENDAR + + HTTP/1.1 200 OK + + + + /calendars/me/home/standup.ics + + + "2" + BEGIN:VCALENDAR BEGIN:VEVENT UID:standup@x DTSTART:20261001T090000Z RRULE:FREQ=DAILY;COUNT=3 SUMMARY:standup END:VEVENT BEGIN:VEVENT UID:standup@x RECURRENCE-ID:20261001T090000Z DTSTART:20261001T160000Z SUMMARY:standup moved END:VEVENT END:VCALENDAR + + HTTP/1.1 200 OK + + +"#; + +fn calendar(server: &MockServer) -> CaldavCalendar { + CaldavCalendar::new(format!("{}/calendars/me/home/", server.uri())).with_basic("me", "app-pass") +} + +fn starts(events: &[rhema_calendar_integration::CalendarEvent]) -> Vec { + events + .iter() + .map(|event| event.start.format(&Rfc3339).expect("formats")) + .collect() +} + +#[tokio::test] +async fn a_poll_is_one_report_and_expands_the_answer() { + let server = MockServer::start().await; + Mock::given(method("REPORT")) + .and(path("/calendars/me/home")) + .and(header("Depth", "1")) + .and(body_string_contains( + r#""#, + )) + .respond_with(ResponseTemplate::new(207).set_body_string(MULTISTATUS)) + .mount(&server) + .await; + + let events = calendar(&server).events(&window()).await.expect("polls"); + // The master's Oct 1 occurrence is suppressed by the override that + // moved it to 16:00; the Oct 2 occurrence is in the window, and + // the Oct 3 one (COUNT=3) is past its end. + assert_eq!(events.len(), 3, "{:?}", starts(&events)); + assert_eq!(events[0].title, "kickoff"); + assert_eq!(events[1].title, "standup moved"); + assert_eq!(events[2].title, "standup"); + for event in &events { + assert!( + event.id.starts_with("caldav:"), + "occurrence ids spell the adapter: {}", + event.id + ); + } + assert!(starts(&events)[0].contains("14:00")); + assert!(starts(&events)[1].contains("16:00")); + assert!(starts(&events)[2].contains("2026-10-02T09:00")); +} + +#[tokio::test] +async fn basic_credentials_ride_the_authorization_header_only() { + let server = MockServer::start().await; + Mock::given(method("REPORT")) + .and(header("Authorization", "Basic bWU6YXBwLXBhc3M=")) + .respond_with(ResponseTemplate::new(207).set_body_string(MULTISTATUS)) + .mount(&server) + .await; + + calendar(&server).events(&window()).await.expect("polls"); +} + +#[tokio::test] +async fn a_bearer_token_is_the_other_auth_spelling() { + let server = MockServer::start().await; + Mock::given(method("REPORT")) + .and(header("Authorization", "Bearer sekrit")) + .respond_with(ResponseTemplate::new(207).set_body_string(MULTISTATUS)) + .mount(&server) + .await; + + let calendar = CaldavCalendar::new(server.uri()).with_token("sekrit"); + calendar.events(&window()).await.expect("polls"); +} + +#[tokio::test] +async fn a_server_error_is_named() { + let server = MockServer::start().await; + Mock::given(method("REPORT")) + .respond_with(ResponseTemplate::new(503).set_body_string("maintenance")) + .mount(&server) + .await; + + let err = calendar(&server).events(&window()).await.expect_err("loud"); + assert!( + matches!(err, CalendarError::Api { status: 503, .. }), + "got: {err:?}" + ); +} + +#[tokio::test] +async fn a_non_xml_answer_is_a_loud_parse_error() { + let server = MockServer::start().await; + // An HTML login page can arrive with a 200; almost none of it is + // well-formed XML, and a shape break must surface, not read as a + // quietly empty calendar. + Mock::given(method("REPORT")) + .respond_with(ResponseTemplate::new(200).set_body_string("")) + .mount(&server) + .await; + + let err = calendar(&server).events(&window()).await.expect_err("loud"); + assert!(matches!(err, CalendarError::Parse(_)), "got: {err:?}"); +} diff --git a/integrations/rhema-calendar-integration/tests/google.rs b/integrations/rhema-calendar-integration/tests/google.rs new file mode 100644 index 0000000..1a3ea49 --- /dev/null +++ b/integrations/rhema-calendar-integration/tests/google.rs @@ -0,0 +1,149 @@ +//! Offline integration tests: the Google Calendar backend against an +//! in-process wiremock server. Zero network. + +use rhema_calendar_integration::{Calendar, CalendarError, GoogleCalendar, Window}; +use serde_json::{Value, json}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; +use wiremock::matchers::{method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn window() -> Window { + let from = OffsetDateTime::parse("2026-10-20T00:00:00Z", &Rfc3339).expect("parses"); + Window::new(from, from + time::Duration::hours(24)) +} + +/// A timed row; `patch` overrides any field. +fn row(id: &str, patch: Value) -> Value { + let mut base = json!({ + "id": id, + "status": "confirmed", + "summary": format!("event {id}"), + "start": { "dateTime": "2026-10-20T14:00:00+02:00" }, + "end": { "dateTime": "2026-10-20T15:00:00+02:00" }, + "htmlLink": format!("https://calendar.google.com/x/{id}") + }); + if let (Value::Object(base), Value::Object(patch)) = (&mut base, patch) { + for (key, value) in patch { + base.insert(key, value); + } + } + base +} + +#[tokio::test] +async fn a_poll_asks_for_expanded_instances_ordered_by_start() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/calendar/v3/calendars/primary/events")) + .and(query_param("singleEvents", "true")) + .and(query_param("orderBy", "startTime")) + .and(query_param("timeMin", "2026-10-20T00:00:00Z")) + .and(query_param("timeMax", "2026-10-21T00:00:00Z")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "items": [ + row("a", json!({})), + ]}))) + .mount(&server) + .await; + + let calendar = GoogleCalendar::new("tok", "primary").with_base_url(server.uri()); + let events = calendar.events(&window()).await.expect("polls"); + assert_eq!(events.len(), 1); + // An offset start is an instant: the id pins the UTC second. + let at = OffsetDateTime::parse("2026-10-20T12:00:00Z", &Rfc3339).expect("parses"); + assert_eq!(events[0].id, format!("google:a@{}", at.unix_timestamp())); +} + +#[tokio::test] +async fn pages_are_followed_until_the_cursor_ends() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(query_param("pageToken", "cursor-2")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "items": [ + row("last", json!({})) + ]}))) + .mount(&server) + .await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "items": [row("first", json!({}))], + "nextPageToken": "cursor-2" + }))) + .mount(&server) + .await; + + let calendar = GoogleCalendar::new("tok", "primary").with_base_url(server.uri()); + let events = calendar.events(&window()).await.expect("polls"); + let ids: Vec<&str> = events + .iter() + .map(|event| event.id.split('@').next().unwrap_or("")) + .collect(); + assert_eq!(ids.len(), 2); +} + +#[tokio::test] +async fn an_api_key_rides_the_key_parameter_and_a_token_the_header() { + let server = MockServer::start().await; + // The key mock is mounted first and wins the requests it matches; + // everything else (the keyless bearer client below) falls through + // to the refusal. + Mock::given(method("GET")) + .and(query_param("key", "pub-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "items": [ + row("public", json!({})) + ]}))) + .mount(&server) + .await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(400)) + .mount(&server) + .await; + + let calendar = GoogleCalendar::with_api_key("pub-key", "primary").with_base_url(server.uri()); + assert_eq!(calendar.events(&window()).await.expect("polls").len(), 1); + + // A client built with a token never sends `key`, so it lands on + // the refusal: the credential kind decides the wire shape. + let calendar = GoogleCalendar::new("tok", "primary").with_base_url(server.uri()); + let err = calendar + .events(&window()) + .await + .expect_err("the keyless request is refused"); + assert!( + matches!(err, CalendarError::Api { status: 400, .. }), + "got: {err:?}" + ); +} + +#[tokio::test] +async fn cancelled_rows_do_not_become_reminders() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "items": [ + row("kept", json!({})), + row("gone", json!({ "status": "cancelled" })) + ]}))) + .mount(&server) + .await; + + let calendar = GoogleCalendar::new("tok", "primary").with_base_url(server.uri()); + let events = calendar.events(&window()).await.expect("polls"); + assert_eq!(events.len(), 1); + assert!(events[0].id.contains("kept")); +} + +#[tokio::test] +async fn a_server_error_is_named() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(403).set_body_string("quota")) + .mount(&server) + .await; + + let calendar = GoogleCalendar::new("tok", "primary").with_base_url(server.uri()); + let err = calendar.events(&window()).await.expect_err("loud"); + assert!( + matches!(err, CalendarError::Api { status: 403, .. }), + "got: {err:?}" + ); +} diff --git a/integrations/rhema-calendar-integration/tests/ics.rs b/integrations/rhema-calendar-integration/tests/ics.rs new file mode 100644 index 0000000..6c2363e --- /dev/null +++ b/integrations/rhema-calendar-integration/tests/ics.rs @@ -0,0 +1,76 @@ +//! Offline integration tests: the ICS backend against an in-process +//! wiremock server. Zero network. + +use rhema_calendar_integration::{Calendar, CalendarError, IcsCalendar, Window}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; +use wiremock::matchers::{header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const FEED_BODY: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ +BEGIN:VEVENT\r\nUID:meet-1@x\r\nDTSTART:20261020T140000Z\r\nDTEND:20261020T150000Z\r\n\ +SUMMARY:kickoff\\, phase one\r\nLOCATION:paris\r\n\ +ORGANIZER:mailto:boss@example.net\r\nATTENDEE:mailto:alice@example.net\r\n\ +URL:https://meet.example.net/1\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + +fn window() -> Window { + let from = OffsetDateTime::parse("2026-10-20T00:00:00Z", &Rfc3339).expect("parses"); + Window::new(from, from + time::Duration::hours(24)) +} + +#[tokio::test] +async fn a_poll_is_one_get_and_normalizes_the_feed() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/remote.php/dav/calendars/personal.ics")) + .respond_with(ResponseTemplate::new(200).set_body_string(FEED_BODY)) + .mount(&server) + .await; + + let feed = IcsCalendar::new(format!( + "{}/remote.php/dav/calendars/personal.ics", + server.uri() + )); + let events = feed.events(&window()).await.expect("polls"); + assert_eq!(events.len(), 1); + let event = &events[0]; + assert_eq!(event.title, "kickoff, phase one"); + assert_eq!(event.location.as_deref(), Some("paris")); + assert_eq!(event.organizer.as_deref(), Some("boss@example.net")); + assert_eq!(event.attendees, vec!["alice@example.net".to_owned()]); + assert_eq!(event.url.as_deref(), Some("https://meet.example.net/1")); + assert_eq!( + event.start, + OffsetDateTime::parse("2026-10-20T14:00:00Z", &Rfc3339).expect("parses") + ); +} + +#[tokio::test] +async fn a_token_rides_the_authorization_header_only() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(header("Authorization", "Bearer sekrit")) + .respond_with(ResponseTemplate::new(200).set_body_string(FEED_BODY)) + .mount(&server) + .await; + + let feed = IcsCalendar::new(server.uri()).with_token("sekrit"); + feed.events(&window()).await.expect("polls"); +} + +#[tokio::test] +async fn a_server_error_is_named_with_a_bounded_body() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(503).set_body_string("maintenance")) + .mount(&server) + .await; + + let feed = IcsCalendar::new(server.uri()); + let err = feed.events(&window()).await.expect_err("loud"); + assert!( + matches!(err, CalendarError::Api { status: 503, .. }), + "got: {err:?}" + ); + assert!(err.to_string().contains("maintenance"), "got: {err}"); +} diff --git a/integrations/rhema-forge-integration/Cargo.toml b/integrations/rhema-forge-integration/Cargo.toml new file mode 100644 index 0000000..e93edb5 --- /dev/null +++ b/integrations/rhema-forge-integration/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "rhema-forge-integration" +description = "The forge integration: GitHub, Gitea, and GitLab activity normalized and ingested into rhema-rsd dispatch" +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 + +[dependencies] +# The dispatch seam: watches ingest into a Dispatcher +rhema-rsd = { path = "../../crates/rhema-rsd", version = "0.1.0" } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } +# HTTP against the three forge REST APIs: JSON request/response bodies, +# no streaming +reqwest = { version = "0.12", features = ["json"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] } +# In-process HTTP mocks for offline tests +wiremock = "0.6" diff --git a/integrations/rhema-forge-integration/src/error.rs b/integrations/rhema-forge-integration/src/error.rs new file mode 100644 index 0000000..0e18a06 --- /dev/null +++ b/integrations/rhema-forge-integration/src/error.rs @@ -0,0 +1,73 @@ +//! Why a forge call failed. +//! +//! One error type for the whole crate: every backend maps its transport, +//! HTTP, and decoding failures onto it, so callers match one shape no +//! matter which forge answered. + +/// Everything that can go wrong between a [`crate::Forge`] call and its +/// answer. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ForgeError { + /// The forge answered with a non-2xx status. `body` is a bounded + /// snippet of the response body — usually the forge's error JSON. + #[error("forge api error (HTTP {status}): {body}")] + Api { + /// The HTTP status the forge answered with. + status: u16, + /// A bounded snippet of the response body. + body: String, + }, + /// A response body could not be decoded, or a wire row is missing a + /// field the normalized vocabulary requires. Loud by design: a shape + /// break must surface, not silently drop events. + #[error("malformed forge response: {0}")] + Parse(String), + /// The server kept offering full pages, or offered a page cursor that + /// leads nowhere. Polling stopped rather than looping forever. + #[error("pagination failed: {0}")] + Pagination(String), + /// The request never got an answer (DNS, connect, timeout). + #[error("network error: {0}")] + Network(String), +} + +/// The longest body snippet an [`ForgeError::Api`] keeps: enough for any +/// forge's error JSON, small enough that error lines stay bounded. +const BODY_SNIPPET_CAP: usize = 512; + +impl ForgeError { + /// Builds [`ForgeError::Api`] from a non-2xx status and the raw body, + /// truncating the body to [`BODY_SNIPPET_CAP`] characters. + pub(crate) fn api(status: reqwest::StatusCode, raw: &str) -> Self { + let body: String = raw.chars().take(BODY_SNIPPET_CAP).collect(); + ForgeError::Api { + status: status.as_u16(), + body, + } + } + + /// Maps a transport failure onto [`ForgeError::Network`]; reqwest's + /// error is neither `Clone` nor `Eq`, so only its display text travels. + pub(crate) fn network(err: reqwest::Error) -> Self { + ForgeError::Network(err.to_string()) + } +} + +/// A wire field the normalized vocabulary cannot do without. A missing one +/// is a loud [`ForgeError::Parse`], not a silently skipped row. +pub(crate) fn required(field: Option, what: &str) -> Result { + field.ok_or_else(|| ForgeError::Parse(format!("forge row without {what}"))) +} + +/// Passes a 2xx response through; turns anything else into +/// [`ForgeError::Api`] with a bounded body snippet. +pub(crate) async fn ensure_success( + response: reqwest::Response, +) -> Result { + let status = response.status(); + if status.is_success() { + return Ok(response); + } + let body = response.text().await.unwrap_or_default(); + Err(ForgeError::api(status, &body)) +} diff --git a/integrations/rhema-forge-integration/src/event.rs b/integrations/rhema-forge-integration/src/event.rs new file mode 100644 index 0000000..105ba57 --- /dev/null +++ b/integrations/rhema-forge-integration/src/event.rs @@ -0,0 +1,509 @@ +//! The normalized event vocabulary shared by every forge backend. +//! +//! Three REST dialects reduce to one shape here. Constraints the +//! normalization pins (each backend module documents how it meets them): +//! - An event is a thread-level fact ([`EventKind::Opened`], `Reopened`, +//! `Closed`, `Merged`), a comment ([`EventKind::Comment`]), or a +//! submitted review ([`EventKind::Review`]). Whether the thread is an +//! issue or a pull/merge request rides on [`ForgeEvent::is_pr`], so the +//! kind list stays small and the "issue comment vs PR comment" split is +//! `EventKind::Comment` + `is_pr`. +//! - [`ForgeEvent::id`] is the dedup id the RSD dispatcher keys on: the +//! feed's item id plus, for thread-level rows, the row's updated time — +//! stable while a row is unchanged, new the moment it changes, exactly +//! the "thread's updated_at as dedup id" scheme of RSD §8. Comment and +//! review rows are immutable enough that their own ids suffice. +//! - Thread-level polls read row snapshots, not transitions: the actor on +//! such an event is the thread author, never the (unknowable from a +//! poll) closer or merger. + +use core::cmp::Ordering; +use core::fmt; + +use serde::de::{self, Deserializer}; +use serde::ser::Serializer; +use serde::{Deserialize, Serialize}; + +/// What happened to a thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventKind { + /// The thread was created. + Opened, + /// A closed thread returned to open. + Reopened, + /// The thread was closed (a pull/merge request: closed unmerged). + Closed, + /// A pull/merge request was merged. Never an issue. + Merged, + /// A comment was posted; [`ForgeEvent::is_pr`] separates issue from + /// pull-request comments. + Comment, + /// A formal review was submitted on a pull/merge request. + Review, +} + +/// The coarse state of a thread as it stands right now. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadState { + /// Open for business. + Open, + /// Closed; a pull/merge request closed unmerged. + Closed, + /// A merged pull/merge request. + Merged, +} + +/// Repository coordinates. `owner` + `name` + a thread [`ForgeEvent::number`] +/// is the resource key the RSD dispatcher forms (`host:owner/repo#number`). +/// +/// GitLab's `owner` is the whole namespace path (`group/subgroup`), so +/// [`RepoRef::path`] is always the full project path the forge addresses. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct RepoRef { + /// Owner login, or the namespace path on GitLab. + pub owner: String, + /// Repository or project slug. + pub name: String, +} + +impl RepoRef { + /// Coordinates for `owner`/`name`. Neither is validated: callers pass + /// forge-correct names (the adapters build these from forge data). + pub fn new(owner: impl Into, name: impl Into) -> Self { + Self { + owner: owner.into(), + name: name.into(), + } + } + + /// `owner/name` — the full project path. + pub fn path(&self) -> String { + format!("{}/{}", self.owner, self.name) + } +} + +/// A thread's current state: everything a handler needs to orient without +/// a second API round-trip. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Snapshot { + /// The thread title. + pub title: String, + /// The opening body, when the thread has one. + pub body: Option, + /// Open, closed, or merged. + pub state: ThreadState, + /// Whether the thread is a pull/merge request. + pub is_pr: bool, + /// Who opened the thread. + pub author: Option, + /// Label names, bare. + pub labels: Vec, + /// Human-facing URL of the thread. + pub url: Option, + /// The untouched wire row the snapshot came from. + pub raw: serde_json::Value, +} + +/// One normalized piece of forge activity: a thread transition, a comment, +/// or a submitted review. +/// +/// The shape the RSD dispatcher consumes: [`ForgeEvent::repo`] plus +/// [`ForgeEvent::number`] form the resource key, [`ForgeEvent::id`] is the +/// dedup id, and [`ForgeEvent::raw`] carries the untouched wire row for +/// handler context. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ForgeEvent { + /// Stable dedup id: the feed's item id plus, for thread-level rows, + /// the row's updated time (e.g. `github:pull:1824@2026-10-08T12:00:00Z`, + /// `github:comment:29482`). Two polls of the same unchanged row produce + /// the same id; a changed row produces a new one. + pub id: String, + /// What happened. + pub kind: EventKind, + /// The repository the thread lives in. + pub repo: RepoRef, + /// The thread's number (GitLab: the iid). + pub number: u64, + /// Whether the thread is a pull/merge request rather than an issue. + pub is_pr: bool, + /// Who acted: the thread author on thread-level events (a poll sees row + /// snapshots, not transitions, so the closer/merger is unknowable), the + /// comment or review author otherwise. + pub actor: Option, + /// The comment or review text; `None` on thread-level events (the + /// thread body lives on the snapshot, not on the event). + pub body: Option, + /// Human-facing URL of the event, when the wire row carries one. + pub url: Option, + /// When the row was last touched (thread rows) or created/submitted + /// (comments, reviews). The poll cursor advances to the newest of + /// these. + pub timestamp: Timestamp, + /// The untouched wire row, minus the keys the event lifts. + pub raw: serde_json::Value, +} + +/// Why the account was notified. The forges' own reason vocabularies, +/// reduced to the handful a triage can act on; whatever a forge says that +/// does not map rides as [`NotificationReason::Other`] with the forge's +/// own spelling. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NotificationReason { + /// The account was asked to review a pull/merge request. + ReviewRequested, + /// New feedback landed on a thread the account authored or + /// participates in (a submitted review or a conversation comment). + ReviewFeedback, + /// The account was assigned the thread. + Assigned, + /// The account was mentioned or directly addressed. + Mention, + /// CI status on a thread the account is involved in. + CiActivity, + /// Activity on a thread the account authored. + Author, + /// Anything else, in the forge's own spelling. + Other(String), +} + +impl NotificationReason { + /// The forge-independent spelling, for triage matching and prompts. + pub fn as_str(&self) -> &str { + match self { + NotificationReason::ReviewRequested => "review_requested", + NotificationReason::ReviewFeedback => "review_feedback", + NotificationReason::Assigned => "assigned", + NotificationReason::Mention => "mention", + NotificationReason::CiActivity => "ci_activity", + NotificationReason::Author => "author", + NotificationReason::Other(raw) => raw, + } + } +} + +/// One account-scoped notification: something happened on a thread that +/// concerns the account the client authenticates as. Distinct from a +/// [`ForgeEvent`], which is a thread-level fact lifted from a +/// repo-scoped feed — the notification says *why the account cares*. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Notification { + /// Stable dedup id, `{source}:notification:{id}@{updated time}` + /// (e.g. `github:notification:42188@2026-10-08T12:00:00Z`): a + /// notification row that stays put produces the same id across + /// polls; a row that moves produces a new one. + pub id: String, + /// Why the account was notified. + pub reason: NotificationReason, + /// The repository the thread lives in. + pub repo: RepoRef, + /// The thread's number (GitLab: the iid). + pub number: u64, + /// Whether the thread is a pull/merge request rather than an issue. + pub is_pr: bool, + /// Who acted, when the forge names it. + pub actor: Option, + /// The thread's title, when the row carries it. + pub title: Option, + /// Human-facing URL of the thread, when the row carries one. + pub url: Option, + /// When the notification row was last touched. The poll cursor + /// advances to the newest of these. + pub timestamp: Timestamp, + /// The untouched wire row, minus the keys the notification lifts. + pub raw: serde_json::Value, +} + +/// One notifications poll's worth, with the cursor to continue from. +/// Same fold rules as [`ForgePage`]: oldest first, id-broken ties, and +/// the cursor never goes backwards. +#[derive(Debug, Clone, PartialEq)] +pub struct NotificationPage { + /// The notifications, oldest first, ties broken by id — a + /// deterministic order regardless of which feed produced a row. + pub notifications: Vec, + /// The cursor for the next poll: the newest event time seen, never + /// earlier than the poll's `since`. A backend with no server-side + /// time filter ignores the cursor; repetition is harmless under the + /// dedup ids. + pub cursor: Option, +} + +impl NotificationPage { + /// Folds collected notifications into a page: sorts them and advances + /// the cursor past everything seen. + pub(crate) fn finish(mut notifications: Vec, since: Option<&Timestamp>) -> Self { + notifications.sort_by(|a, b| (&a.timestamp, &a.id).cmp(&(&b.timestamp, &b.id))); + let mut cursor = since.cloned(); + for timestamp in notifications.iter().map(|row| &row.timestamp) { + cursor = Some(match cursor { + Some(current) if current >= *timestamp => current, + _ => timestamp.clone(), + }); + } + Self { + notifications, + cursor, + } + } +} + +/// An RFC 3339 timestamp, kept in the forge's own spelling. +/// +/// The raw string is what travels: it round-trips onto cursors and into +/// events unchanged. Ordering compares parsed instants, never text — the +/// forges disagree on fractional seconds, and `…:00Z` must sort before +/// `…:00.081Z`, which string comparison gets backwards. +#[derive(Debug, Clone)] +pub struct Timestamp { + raw: String, + /// Whole seconds since the Unix epoch (negative before 1970). + secs: i64, + /// Sub-second nanoseconds, `[0, 1_000_000_000)`. + nanos: u32, +} + +impl Timestamp { + /// Parses an RFC 3339 timestamp: `YYYY-MM-DDThh:mm:ss[.frac]` followed + /// by `Z` (either case) or a `±hh:mm` offset. `None` on anything else. + /// + /// Validation is structural, not calendrical (day-of-month is only + /// bounds-checked): the forges are the source of truth and only the + /// ordering key matters here. + pub fn parse(raw: &str) -> Option { + let bytes = raw.as_bytes(); + // `YYYY-MM-DDThh:mm:ss` is the 19-char floor; an offset must follow. + if bytes.len() < 20 || bytes[4] != b'-' || bytes[7] != b'-' { + return None; + } + if bytes[10] != b'T' && bytes[10] != b't' { + return None; + } + if bytes[13] != b':' || bytes[16] != b':' { + return None; + } + let year = digits(raw, 0..4)?; + let month = digits(raw, 5..7)?; + let day = digits(raw, 8..10)?; + let hour = digits(raw, 11..13)?; + let minute = digits(raw, 14..16)?; + let second = digits(raw, 17..19)?; + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + // 60 accepts the leap seconds RFC 3339 allows. + if hour > 23 || minute > 59 || second > 60 { + return None; + } + + let mut index = 19; + let mut nanos: u32 = 0; + if bytes[index] == b'.' { + index += 1; + let start = index; + while bytes.get(index).is_some_and(|b| b.is_ascii_digit()) { + index += 1; + } + if index == start { + return None; + } + // Truncate past nanosecond precision, pad short fractions. + let mut fraction = raw[start..index].to_owned(); + fraction.truncate(9); + while fraction.len() < 9 { + fraction.push('0'); + } + nanos = fraction.parse().ok()?; + } + + let offset: i64 = match bytes.get(index) { + Some(b'Z') | Some(b'z') => { + index += 1; + 0 + } + Some(sign @ (b'+' | b'-')) => { + index += 1; + if bytes.get(index + 2) != Some(&b':') { + return None; + } + let hours = digits(raw, index..index + 2)?; + let minutes = digits(raw, index + 3..index + 5)?; + if hours > 23 || minutes > 59 { + return None; + } + index += 5; + let total = hours * 3600 + minutes * 60; + if *sign == b'+' { total } else { -total } + } + _ => return None, + }; + if index != bytes.len() { + return None; // trailing garbage + } + + let secs = days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second + - offset; + Some(Self { + raw: raw.to_owned(), + secs, + nanos, + }) + } + + /// The raw string, in the forge's own spelling — the form cursors and + /// `since`-style query parameters carry. + pub fn raw(&self) -> &str { + &self.raw + } + + /// Whole seconds since the Unix epoch. + pub fn epoch_secs(&self) -> i64 { + self.secs + } +} + +/// A run of ASCII digits at `range`, as its number. +fn digits(raw: &str, range: core::ops::Range) -> Option { + let slice = raw.get(range)?; + if slice.is_empty() || !slice.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + slice.parse().ok() +} + +/// Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm). +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = if m > 2 { m - 3 } else { m + 9 }; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +impl PartialEq for Timestamp { + fn eq(&self, other: &Self) -> bool { + self.secs == other.secs && self.nanos == other.nanos + } +} +impl Eq for Timestamp {} + +impl PartialOrd for Timestamp { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Timestamp { + fn cmp(&self, other: &Self) -> Ordering { + (self.secs, self.nanos).cmp(&(other.secs, other.nanos)) + } +} + +impl fmt::Display for Timestamp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.raw) + } +} + +impl Serialize for Timestamp { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.raw) + } +} + +impl<'de> Deserialize<'de> for Timestamp { + fn deserialize>(deserializer: D) -> Result { + let raw = String::deserialize(deserializer)?; + Timestamp::parse(&raw) + .ok_or_else(|| de::Error::custom(format!("not an RFC 3339 timestamp: {raw:?}"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_the_shapes_the_forges_emit() { + // GitHub's shape. + let zulu = Timestamp::parse("2026-10-08T00:00:00Z").expect("zulu parses"); + assert_eq!(zulu.epoch_secs(), 1_791_417_600); + // GitLab's shape: fractional seconds. + let millis = Timestamp::parse("2026-10-08T00:00:00.081Z").expect("millis parse"); + assert_eq!(millis.epoch_secs(), zulu.epoch_secs()); + assert!(millis > zulu); + // Offsets compare by instant, not spelling. + let shifted = Timestamp::parse("2026-10-08T02:00:00+02:00").expect("offset parse"); + assert_eq!(shifted, zulu); + // RFC 3339 allows the lowercase forms. + assert_eq!( + Timestamp::parse("1970-01-01t00:00:00z") + .expect("lowercase parse") + .epoch_secs(), + 0 + ); + assert_eq!( + Timestamp::parse("2016-01-04T15:31:51.081Z") + .expect("gitlab fixture parse") + .epoch_secs(), + 1_451_921_511 + ); + } + + #[test] + fn rejects_malformed_timestamps() { + for raw in [ + "", + "2026-10-08", + "2026-10-08T00:00:00", + "2026-10-08 00:00:00Z", + "2026-13-01T00:00:00Z", + "2026-10-32T00:00:00Z", + "2026-10-08T24:00:00Z", + "2026-10-08T00:60:00Z", + "2026-10-08T00:00:00Z ", + "2026-10-08T00:00:00+0200", + "2026-10-08T00:00:00.Z", + "2026-10-08T00:00:00Q", + ] { + assert!(Timestamp::parse(raw).is_none(), "for {raw:?}"); + } + } + + #[test] + fn serde_keeps_the_raw_string() { + let ts = Timestamp::parse("2026-10-08T00:00:00.081Z").expect("parses"); + let encoded = serde_json::to_value(&ts).expect("serializes"); + assert_eq!(encoded, json!("2026-10-08T00:00:00.081Z")); + assert_eq!( + serde_json::from_value::(encoded).expect("deserializes"), + ts + ); + assert!(serde_json::from_value::(json!("nonsense")).is_err()); + } + + #[test] + fn forge_events_round_trip_through_serde() { + let event = ForgeEvent { + id: "github:issue:7@2026-10-08T00:00:00Z".into(), + kind: EventKind::Closed, + repo: RepoRef::new("acme", "widgets"), + number: 7, + is_pr: false, + actor: Some("alice".into()), + body: None, + url: None, + timestamp: Timestamp::parse("2026-10-08T00:00:00Z").expect("parses"), + raw: json!({ "number": 7 }), + }; + let round: ForgeEvent = + serde_json::from_value(serde_json::to_value(&event).expect("serializes")) + .expect("deserializes"); + assert_eq!(round, event); + } +} diff --git a/integrations/rhema-forge-integration/src/forge.rs b/integrations/rhema-forge-integration/src/forge.rs new file mode 100644 index 0000000..bc07faa --- /dev/null +++ b/integrations/rhema-forge-integration/src/forge.rs @@ -0,0 +1,145 @@ +//! The forge client contract: one trait, three calls. +//! +//! [`Forge`] is the whole surface a consumer needs: poll a repo's +//! activity against a cursor, post a comment, and fetch a thread +//! snapshot so an answering handler can orient without a second API +//! round-trip. Implementations are cheap to clone and share — the +//! reqwest client inside is reference-counted — and object-safe so a +//! backend can be swapped without generics. +//! +//! Polls are at-least-once. The forges' `since`-style filters match rows +//! at-or-after the cursor, so boundary rows repeat across polls, and a +//! row that changes between a backend's feeds can surface twice under +//! different ids. Both are harmless under the RSD dedup: consumers must +//! treat the event `id` as the only dedup key, never `(repo, number)`. + +use async_trait::async_trait; + +use crate::error::ForgeError; +use crate::event::{ForgeEvent, NotificationPage, RepoRef, Snapshot, Timestamp}; + +/// One poll's worth of activity, with the cursor to continue from. +#[derive(Debug, Clone, PartialEq)] +pub struct ForgePage { + /// The events, oldest first, ties broken by id — a deterministic + /// order regardless of which feed produced an event. + pub events: Vec, + /// The cursor for the next poll: the newest event time seen, never + /// earlier than the poll's `since`. `None` only when the poll had no + /// anchor and came back empty — the repo is simply quiet. + pub cursor: Option, +} + +impl ForgePage { + /// Folds collected events into a page: sorts them and advances the + /// cursor past everything seen. + pub(crate) fn finish(mut events: Vec, since: Option<&Timestamp>) -> Self { + events.sort_by(|a, b| (&a.timestamp, &a.id).cmp(&(&b.timestamp, &b.id))); + let mut cursor = since.cloned(); + for timestamp in events.iter().map(|event| &event.timestamp) { + cursor = Some(match cursor { + Some(current) if current >= *timestamp => current, + _ => timestamp.clone(), + }); + } + Self { events, cursor } + } +} + +/// A forge backend: the client for one REST dialect. +#[async_trait] +pub trait Forge: Send + Sync { + /// Recent activity for `repo`, oldest first, all pages collected. + /// + /// `since` anchors the poll: `None` lists from the beginning (a first + /// poll); `Some(cursor)` asks for rows at-or-after the cursor, whose + /// boundary rows may repeat — their stable ids make the repetition + /// dedupable downstream, never duplicated work. + async fn poll( + &self, + repo: &RepoRef, + since: Option<&Timestamp>, + ) -> Result; + + /// Posts `body` as a new comment on thread `number`. + async fn comment(&self, repo: &RepoRef, number: u64, body: &str) -> Result<(), ForgeError>; + + /// The thread's current state, so a handler can orient without a + /// second API round-trip. + async fn snapshot(&self, repo: &RepoRef, number: u64) -> Result; + + /// The account's notification feed: everything addressed to the + /// account this client authenticates as, across every repository it + /// can see — the account-scoped counterpart of [`Forge::poll`], + /// whose feeds are repo-scoped. `since` anchors the poll with the + /// same at-least-once semantics as `poll`: boundary rows repeat, and + /// the stable ids make the repetition dedupable downstream. + async fn notifications( + &self, + since: Option<&Timestamp>, + ) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::EventKind; + use serde_json::json; + + fn event(id: &str, at: &str) -> ForgeEvent { + ForgeEvent { + id: id.to_owned(), + kind: EventKind::Comment, + repo: RepoRef::new("acme", "widgets"), + number: 7, + is_pr: false, + actor: None, + body: None, + url: None, + timestamp: Timestamp::parse(at).expect("timestamp parses"), + raw: json!({}), + } + } + + #[test] + fn events_sort_oldest_first_with_id_ties() { + let page = ForgePage::finish( + vec![ + event("b", "2026-10-08T00:00:00Z"), + event("z", "2026-10-07T00:00:00Z"), + event("a", "2026-10-08T00:00:00Z"), + ], + None, + ); + let ids: Vec<&str> = page.events.iter().map(|event| event.id.as_str()).collect(); + assert_eq!(ids, ["z", "a", "b"]); + } + + #[test] + fn the_cursor_never_goes_backwards() { + // A since newer than everything seen holds: the next poll repeats + // the boundary instead of re-listing old history. + let since = Timestamp::parse("2026-10-09T00:00:00Z").expect("parses"); + let page = ForgePage::finish(vec![event("a", "2026-10-08T00:00:00Z")], Some(&since)); + assert_eq!(page.cursor, Some(since)); + + let page = ForgePage::finish( + vec![event("a", "2026-10-10T00:00:00Z")], + Some(&Timestamp::parse("2026-10-09T00:00:00Z").expect("parses")), + ); + assert_eq!( + page.cursor, + Some(Timestamp::parse("2026-10-10T00:00:00Z").expect("parses")) + ); + } + + #[test] + fn empty_pages_keep_or_drop_the_cursor_depending_on_the_anchor() { + let since = Timestamp::parse("2026-10-09T00:00:00Z").expect("parses"); + let page = ForgePage::finish(vec![], Some(&since)); + assert_eq!(page.cursor, Some(since)); + + let page = ForgePage::finish(vec![], None); + assert_eq!(page.cursor, None); + } +} diff --git a/integrations/rhema-forge-integration/src/gitea.rs b/integrations/rhema-forge-integration/src/gitea.rs new file mode 100644 index 0000000..84e8012 --- /dev/null +++ b/integrations/rhema-forge-integration/src/gitea.rs @@ -0,0 +1,829 @@ +//! The Gitea adapter: self-hosted, so the base URL is required and points +//! at the instance root (`https://forge.example`); every request path is +//! prefixed with `/api/v1`. Auth is the token in +//! `Authorization: token `. +//! +//! The endpoints mirror GitHub's shape, with the constraints that differ: +//! - Issues come from `GET /repos/{owner}/{repo}/issues` with +//! `state=all&type=issues&since=`; `type=issues` is explicit +//! so the feed is issues only, whatever the server's default. +//! - PRs come from `GET /repos/{owner}/{repo}/pulls` with +//! `state=all&sort=leastrecent`. Gitea's pulls list has no time filter, +//! so the poll pages the whole list and filters `updated_at` client-side +//! (belt and braces: `leastrecent` already orders oldest-updated first, +//! and the final page sort is deterministic either way). +//! - Unlike GitHub, Gitea's pulls rows carry the `merged` flag directly, +//! so no per-PR detail fetch is needed; only reviews fan out per +//! updated PR (`GET /repos/{owner}/{repo}/pulls/{index}/reviews`, no +//! repo-wide reviews feed). +//! - Comments come from the repo-wide +//! `GET /repos/{owner}/{repo}/issues/comments` with `since=`; PR +//! conversation comments are issue comments, `pull_request_url` set. +//! - The `since` filters and the client-side cutoff match rows +//! at-or-after the cursor, so boundary rows repeat across polls; stable +//! ids make the repetition dedupable. +//! - An updated-but-still-open thread with no close history yields no +//! thread event: the usual cause is a comment, which the comments feed +//! reports with its own id. +//! - Reviews carry `submitted`, falling back to `created` on servers that +//! leave it unset; `PENDING` reviews were never submitted and are +//! skipped. +//! - Pagination is `limit`/`page` until a short page, with a hard page +//! cap. `limit=50` because Gitea's default response cap is 50 rows. + +use std::time::Duration; + +use async_trait::async_trait; +use serde::Deserialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::error::{ForgeError, ensure_success, required}; +use crate::event::{ + EventKind, ForgeEvent, Notification, NotificationPage, NotificationReason, RepoRef, Snapshot, + ThreadState, Timestamp, +}; +use crate::forge::{Forge, ForgePage}; + +/// Rows requested per page: Gitea's default response cap. +const PER_PAGE: usize = 50; + +/// Pages fetched before a server that keeps returning full pages is +/// declared misbehaving rather than polled forever. +const MAX_PAGES: usize = 1000; + +/// Gitea backend: an instance base URL, a token, one reqwest client. +/// +/// An empty token is accepted but useless for writes; public reads work +/// anonymously. +pub struct GiteaForge { + base_url: String, + token: String, + http: reqwest::Client, +} + +impl GiteaForge { + /// A forge authenticating with `token` against the instance at + /// `base_url` (trailing slashes trimmed). Gitea is self-hosted: there + /// is no default base. + pub fn new(base_url: impl Into, token: impl Into) -> Self { + let http = reqwest::Client::builder() + // One connect attempt: a dead forge fails fast instead of + // stalling the poll loop. + .connect_timeout(Duration::from_secs(10)) + // Every forge call is a bounded request/response, unlike a + // model stream, so one overall timeout per request is the + // right guard. + .timeout(Duration::from_secs(20)) + .build() + .expect("a client builder with only timeouts cannot fail"); + Self { + base_url: base_url.into().trim_end_matches('/').to_owned(), + token: token.into(), + http, + } + } + + /// Overrides the base URL (trailing slashes trimmed) — for tests + /// against a mock server. + pub fn with_base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into().trim_end_matches('/').to_owned(); + self + } + + /// A GET builder for `path` with auth applied: the token rides only in + /// the `Authorization` header, never in a URL. + fn get(&self, path: &str) -> reqwest::RequestBuilder { + let base = &self.base_url; + let request = self.http.get(format!("{base}/api/v1{path}")); + self.auth(request) + } + + /// A POST builder for `path` with auth applied. + fn post(&self, path: &str) -> reqwest::RequestBuilder { + let base = &self.base_url; + let request = self.http.post(format!("{base}/api/v1{path}")); + self.auth(request) + } + + /// Gitea's token scheme: `Authorization: token `. + fn auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + if self.token.is_empty() { + return request; + } + request.header("Authorization", format!("token {}", self.token)) + } + + /// Follows `limit`/`page` pagination until a short page. + async fn paged( + &self, + path: &str, + query: &[(String, String)], + ) -> Result, ForgeError> { + let mut rows = Vec::new(); + for page in 1..=MAX_PAGES { + let mut query = query.to_vec(); + query.push(("limit".to_owned(), PER_PAGE.to_string())); + query.push(("page".to_owned(), page.to_string())); + let response = self + .get(path) + .query(&query) + .send() + .await + .map_err(ForgeError::network)?; + let batch: Vec = decode(response).await?; + let full = batch.len() == PER_PAGE; + rows.extend(batch); + if !full { + return Ok(rows); + } + } + Err(ForgeError::Pagination(format!( + "more than {MAX_PAGES} full pages from {path}" + ))) + } +} + +/// Decodes a response: non-2xx to [`ForgeError::Api`] with the body +/// snippet, undecodable bodies to [`ForgeError::Parse`]. +async fn decode(response: reqwest::Response) -> Result { + let response = ensure_success(response).await?; + response + .json() + .await + .map_err(|err| ForgeError::Parse(format!("malformed forge response: {err}"))) +} + +// --- Wire rows (private): the subset each event lifts, plus the +// --- untouched row under `raw`. + +/// A Gitea user object. +#[derive(Debug, Deserialize)] +struct WireUser { + login: Option, +} + +/// A label object. +#[derive(Debug, Deserialize)] +struct WireLabel { + name: Option, +} + +/// The `pull_request` meta on an issue row: its presence is the only PR +/// marker there; the fields it carries are not used. +#[derive(Debug, Deserialize)] +struct WirePullMarker {} + +/// One row of the issues list. +#[derive(Debug, Deserialize)] +struct WireIssue { + #[serde(default)] + id: Option, + #[serde(default)] + number: Option, + #[serde(default)] + state: Option, + #[serde(default)] + user: Option, + #[serde(default)] + labels: Vec, + #[serde(default)] + title: Option, + #[serde(default)] + body: Option, + #[serde(default)] + created_at: Option, + #[serde(default)] + updated_at: Option, + #[serde(default)] + closed_at: Option, + #[serde(default)] + html_url: Option, + #[serde(default)] + pull_request: Option, + #[serde(flatten)] + raw: serde_json::Map, +} + +/// One row of the pulls list; carries the `merged` flag directly. +#[derive(Debug, Deserialize)] +struct WirePull { + #[serde(default)] + id: Option, + #[serde(default)] + number: Option, + #[serde(default)] + state: Option, + #[serde(default)] + merged: bool, + #[serde(default)] + user: Option, + #[serde(default)] + labels: Vec, + #[serde(default)] + title: Option, + #[serde(default)] + body: Option, + #[serde(default)] + created_at: Option, + #[serde(default)] + updated_at: Option, + #[serde(default)] + closed_at: Option, + #[serde(default)] + html_url: Option, + #[serde(flatten)] + raw: serde_json::Map, +} + +/// One row of a PR's reviews. +#[derive(Debug, Deserialize)] +struct WireReview { + #[serde(default)] + id: Option, + #[serde(default)] + user: Option, + #[serde(default)] + body: Option, + #[serde(default)] + state: Option, + #[serde(default)] + submitted: Option, + #[serde(default)] + created: Option, + #[serde(flatten)] + raw: serde_json::Map, +} + +/// One row of the repo-wide comments feed. +#[derive(Debug, Deserialize)] +struct WireComment { + #[serde(default)] + id: Option, + #[serde(default)] + user: Option, + #[serde(default)] + body: Option, + #[serde(default)] + created_at: Option, + #[serde(default)] + updated_at: Option, + #[serde(default)] + html_url: Option, + /// HTML URL of the parent thread; its trailing segment is the thread + /// number. + #[serde(default)] + issue_url: Option, + /// Set (non-empty) exactly on PR conversation comments. + #[serde(default)] + pull_request_url: Option, + #[serde(flatten)] + raw: serde_json::Map, +} + +/// One row of the account notifications feed. +#[derive(Debug, Clone, Deserialize)] +struct WireNotification { + #[serde(default)] + id: Option, + #[serde(default)] + subject: Option, + #[serde(default)] + repository: Option, + #[serde(default)] + updated_at: Option, + #[serde(flatten)] + raw: serde_json::Map, +} + +/// The subject a notification is about. +#[derive(Debug, Clone, Deserialize)] +struct WireNotificationSubject { + #[serde(default)] + title: Option, + /// API URL of the thread; its trailing segment is the thread number. + #[serde(default)] + url: Option, + /// `Pull` or `Issue`. + #[serde(rename = "type", default)] + kind: Option, +} + +/// The repository a notification's thread lives in. +#[derive(Debug, Clone, Deserialize)] +struct WireNotificationRepo { + #[serde(default)] + full_name: Option, +} + +// --- Mapping: wire rows -> normalized events. Pure, so the async +// --- transport stays thin and the derivations are unit-testable. + +/// The thread event an issues-list row carries, if any. +fn issue_event(row: &WireIssue, repo: &RepoRef) -> Result, ForgeError> { + let id = required(row.id, "issue id")?; + let number = required(row.number, "issue number")?; + let updated = required(row.updated_at.clone(), "issue updated_at")?; + let created = required(row.created_at.clone(), "issue created_at")?; + let kind = if row.state.as_deref() == Some("closed") { + EventKind::Closed + } else if row.closed_at.is_some() { + EventKind::Reopened + } else if created == updated { + // Never touched since creation: opening is the only update. + EventKind::Opened + } else { + return Ok(None); + }; + Ok(Some(ForgeEvent { + id: format!("gitea:issue:{id}@{}", updated.raw()), + kind, + repo: repo.clone(), + number, + is_pr: false, + actor: row.user.as_ref().and_then(|user| user.login.clone()), + body: None, + url: row.html_url.clone(), + timestamp: updated, + raw: Value::Object(row.raw.clone()), + })) +} + +/// The thread event a pulls row carries, if any. `merged` wins over the +/// closed state: a merge closes the PR. +fn pull_event(row: &WirePull, repo: &RepoRef) -> Result, ForgeError> { + let id = required(row.id, "pull id")?; + let number = required(row.number, "pull number")?; + let updated = required(row.updated_at.clone(), "pull updated_at")?; + let created = required(row.created_at.clone(), "pull created_at")?; + let kind = if row.merged { + EventKind::Merged + } else if row.state.as_deref() == Some("closed") { + EventKind::Closed + } else if row.closed_at.is_some() { + EventKind::Reopened + } else if created == updated { + EventKind::Opened + } else { + return Ok(None); + }; + Ok(Some(ForgeEvent { + id: format!("gitea:pull:{id}@{}", updated.raw()), + kind, + repo: repo.clone(), + number, + is_pr: true, + actor: row.user.as_ref().and_then(|user| user.login.clone()), + body: None, + url: row.html_url.clone(), + timestamp: updated, + raw: Value::Object(row.raw.clone()), + })) +} + +/// The review event a reviews row carries, if any: never-submitted +/// (`PENDING`) rows and rows older than the cursor are skipped. The +/// timestamp falls back from `submitted` to `created`, which older +/// servers set alone. +fn review_event( + row: &WireReview, + repo: &RepoRef, + number: u64, + since: Option<&Timestamp>, +) -> Result, ForgeError> { + if row.state.as_deref() == Some("PENDING") { + return Ok(None); + } + let timestamp = match row.submitted.clone().or_else(|| row.created.clone()) { + Some(timestamp) => timestamp, + None => return Ok(None), + }; + if let Some(since) = since { + if timestamp < *since { + return Ok(None); + } + } + let id = required(row.id, "review id")?; + Ok(Some(ForgeEvent { + id: format!("gitea:review:{id}"), + kind: EventKind::Review, + repo: repo.clone(), + number, + is_pr: true, + actor: row.user.as_ref().and_then(|user| user.login.clone()), + body: row.body.clone(), + // Gitea review rows carry no URL of their own. + url: None, + timestamp, + raw: Value::Object(row.raw.clone()), + })) +} + +/// The comment event a repo-wide comments row carries. +fn comment_event(row: &WireComment, repo: &RepoRef) -> Result, ForgeError> { + let id = required(row.id, "comment id")?; + let issue_url = required(row.issue_url.clone(), "comment issue_url")?; + let number = parent_number(&issue_url).ok_or_else(|| { + ForgeError::Parse(format!( + "comment issue_url without a thread number: {issue_url}" + )) + })?; + let timestamp = required( + row.updated_at.clone().or_else(|| row.created_at.clone()), + "comment timestamp", + )?; + let is_pr = row + .pull_request_url + .as_deref() + .is_some_and(|url| !url.is_empty()); + Ok(Some(ForgeEvent { + id: format!("gitea:comment:{id}"), + kind: EventKind::Comment, + repo: repo.clone(), + number, + is_pr, + actor: row.user.as_ref().and_then(|user| user.login.clone()), + body: row.body.clone(), + url: row.html_url.clone(), + timestamp, + raw: Value::Object(row.raw.clone()), + })) +} + +/// The parent thread number: a forge thread URL's trailing segment. +fn parent_number(url: &str) -> Option { + url.trim_end_matches('/').rsplit('/').next()?.parse().ok() +} + +/// The notification a notifications row carries. Gitea's feed names no +/// per-row reason — the thread subject is all a row carries — so every +/// notification maps to [`NotificationReason::Other`] with a fixed +/// spelling, and the consumer's triage treats it as its default role. +fn notification_event(row: &WireNotification) -> Result, ForgeError> { + let id = required(row.id, "notification id")?; + let updated = required(row.updated_at.clone(), "notification updated_at")?; + let subject = required(row.subject.clone(), "notification subject")?; + let repo_row = required(row.repository.clone(), "notification repository")?; + let full_name = required(repo_row.full_name, "notification repository full_name")?; + let (owner, name) = full_name.split_once('/').ok_or_else(|| { + ForgeError::Parse(format!( + "notification repository full_name without a slash: {full_name}" + )) + })?; + let subject_url = subject.url.clone().unwrap_or_default(); + let number = parent_number(&subject_url).ok_or_else(|| { + ForgeError::Parse(format!( + "notification subject url without a thread number: {subject_url}" + )) + })?; + Ok(Some(Notification { + id: format!("gitea:notification:{id}@{}", updated.raw()), + reason: NotificationReason::Other("gitea".to_owned()), + repo: RepoRef::new(owner, name), + number, + is_pr: subject.kind.as_deref() == Some("Pull"), + actor: None, + title: subject.title.clone(), + url: None, + timestamp: updated, + raw: Value::Object(row.raw.clone()), + })) +} + +/// The snapshot an issues-list row yields. +fn snapshot_from_issue(row: &WireIssue) -> Result { + Ok(Snapshot { + title: required(row.title.clone(), "issue title")?, + body: row.body.clone(), + state: if row.state.as_deref() == Some("closed") { + ThreadState::Closed + } else { + ThreadState::Open + }, + is_pr: false, + author: row.user.as_ref().and_then(|user| user.login.clone()), + labels: row + .labels + .iter() + .filter_map(|label| label.name.clone()) + .collect(), + url: row.html_url.clone(), + raw: Value::Object(row.raw.clone()), + }) +} + +/// The snapshot a pulls row yields; the `merged` flag is authoritative. +fn snapshot_from_pull(row: &WirePull) -> Result { + Ok(Snapshot { + title: required(row.title.clone(), "pull title")?, + body: row.body.clone(), + state: if row.merged { + ThreadState::Merged + } else if row.state.as_deref() == Some("closed") { + ThreadState::Closed + } else { + ThreadState::Open + }, + is_pr: true, + author: row.user.as_ref().and_then(|user| user.login.clone()), + labels: row + .labels + .iter() + .filter_map(|label| label.name.clone()) + .collect(), + url: row.html_url.clone(), + raw: Value::Object(row.raw.clone()), + }) +} + +#[async_trait] +impl Forge for GiteaForge { + async fn poll( + &self, + repo: &RepoRef, + since: Option<&Timestamp>, + ) -> Result { + let mut events = Vec::new(); + + // Issue feed: server-side `since`, issues only. + let issues_path = format!("/repos/{}/{}/issues", repo.owner, repo.name); + let mut query = vec![ + ("state".to_owned(), "all".to_owned()), + ("type".to_owned(), "issues".to_owned()), + ]; + if let Some(since) = since { + query.push(("since".to_owned(), since.raw().to_owned())); + } + for row in self.paged::(&issues_path, &query).await? { + if let Some(event) = issue_event(&row, repo)? { + events.push(event); + } + } + + // Pull feed: no server-side time filter exists, so the poll pages + // the whole list and cuts off client-side. + let pulls_path = format!("/repos/{}/{}/pulls", repo.owner, repo.name); + let query = vec![ + ("state".to_owned(), "all".to_owned()), + ("sort".to_owned(), "leastrecent".to_owned()), + ]; + for row in self.paged::(&pulls_path, &query).await? { + let updated = required(row.updated_at.clone(), "pull updated_at")?; + if let Some(since) = since { + if updated < *since { + continue; + } + } + let number = required(row.number, "pull number")?; + if let Some(event) = pull_event(&row, repo)? { + events.push(event); + } + // Reviews fan out per updated PR: no repo-wide feed. + let reviews_path = + format!("/repos/{}/{}/pulls/{number}/reviews", repo.owner, repo.name); + for review in self.paged::(&reviews_path, &[]).await? { + if let Some(event) = review_event(&review, repo, number, since)? { + events.push(event); + } + } + } + + // Comment feed, repo-wide: covers issue and PR conversation + // comments in one shape. + let comments_path = format!("/repos/{}/{}/issues/comments", repo.owner, repo.name); + let mut query = Vec::new(); + if let Some(since) = since { + query.push(("since".to_owned(), since.raw().to_owned())); + } + for row in self.paged::(&comments_path, &query).await? { + if let Some(event) = comment_event(&row, repo)? { + events.push(event); + } + } + + Ok(ForgePage::finish(events, since)) + } + + async fn comment(&self, repo: &RepoRef, number: u64, body: &str) -> Result<(), ForgeError> { + // The issues endpoint covers PRs too: every PR is an issue here. + let path = format!( + "/repos/{}/{}/issues/{number}/comments", + repo.owner, repo.name + ); + let response = self + .post(&path) + .json(&serde_json::json!({ "body": body })) + .send() + .await + .map_err(ForgeError::network)?; + ensure_success(response).await?; + Ok(()) + } + + async fn snapshot(&self, repo: &RepoRef, number: u64) -> Result { + let issue_path = format!("/repos/{}/{}/issues/{number}", repo.owner, repo.name); + let response = self + .get(&issue_path) + .send() + .await + .map_err(ForgeError::network)?; + let issue: WireIssue = decode(response).await?; + if issue.pull_request.is_some() { + // The issue view's PR meta is a marker; the pulls row carries + // the authoritative merged flag. + let pull_path = format!("/repos/{}/{}/pulls/{number}", repo.owner, repo.name); + let response = self + .get(&pull_path) + .send() + .await + .map_err(ForgeError::network)?; + let pull: WirePull = decode(response).await?; + Ok(snapshot_from_pull(&pull)?) + } else { + Ok(snapshot_from_issue(&issue)?) + } + } + + async fn notifications( + &self, + since: Option<&Timestamp>, + ) -> Result { + // The unread-notifications feed: everything addressed to the + // account, every repository it can see. `since` filters on the + // row's updated time; boundary rows repeat, ids dedup them. + let mut query = vec![("status-types".to_owned(), "unread".to_owned())]; + if let Some(since) = since { + query.push(("since".to_owned(), since.raw().to_owned())); + } + let rows = self + .paged::("/notifications", &query) + .await?; + let mut notifications = Vec::new(); + for row in &rows { + if let Some(notification) = notification_event(row)? { + notifications.push(notification); + } + } + Ok(NotificationPage::finish(notifications, since)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn repo() -> RepoRef { + RepoRef::new("acme", "widgets") + } + + fn row(json: &str) -> WireIssue { + serde_json::from_str(json).expect("issue fixture parses") + } + + #[test] + fn a_closed_issue_row_maps_to_closed() { + let event = issue_event( + &row(r#"{"id": 107, "number": 7, "state": "closed", + "user": {"login": "alice"}, + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-02T10:00:00Z", + "closed_at": "2026-10-02T10:00:00Z", + "html_url": "https://forge.example/acme/widgets/issues/7"}"#), + &repo(), + ) + .expect("maps") + .expect("closed row carries an event"); + assert_eq!(event.kind, EventKind::Closed); + assert_eq!(event.id, "gitea:issue:107@2026-10-02T10:00:00Z"); + assert!(!event.is_pr); + } + + #[test] + fn reopen_is_derived_from_a_close_in_the_past() { + let event = issue_event( + &row(r#"{"id": 104, "number": 4, "state": "open", + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-07T09:00:00Z", + "closed_at": "2026-10-03T00:00:00Z"}"#), + &repo(), + ) + .expect("maps") + .expect("reopened row carries an event"); + assert_eq!(event.kind, EventKind::Reopened); + } + + #[test] + fn an_updated_open_row_without_close_history_emits_nothing() { + let event = issue_event( + &row(r#"{"id": 106, "number": 6, "state": "open", + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-05T09:00:00Z", + "closed_at": null}"#), + &repo(), + ) + .expect("maps"); + assert_eq!(event, None); + } + + fn pull(json: &str) -> WirePull { + serde_json::from_str(json).expect("pull fixture parses") + } + + #[test] + fn merged_beats_closed_on_a_pull_row() { + let merged = pull_event( + &pull( + r#"{"id": 203, "number": 3, "state": "closed", "merged": true, + "created_at": "2026-10-01T08:00:00Z", + "updated_at": "2026-10-06T12:00:00Z", + "closed_at": "2026-10-06T12:00:00Z"}"#, + ), + &repo(), + ) + .expect("maps") + .expect("merged pull carries an event"); + assert_eq!(merged.kind, EventKind::Merged); + assert_eq!(merged.id, "gitea:pull:203@2026-10-06T12:00:00Z"); + + let closed = pull_event( + &pull( + r#"{"id": 204, "number": 4, "state": "closed", "merged": false, + "created_at": "2026-10-01T08:00:00Z", + "updated_at": "2026-10-06T12:00:00Z", + "closed_at": "2026-10-06T12:00:00Z"}"#, + ), + &repo(), + ) + .expect("maps") + .expect("closed pull carries an event"); + assert_eq!(closed.kind, EventKind::Closed); + } + + #[test] + fn review_timestamp_falls_back_to_created() { + let review: WireReview = serde_json::from_str( + r#"{"id": 31, "state": "APPROVED", "submitted": "2026-10-06T13:00:00Z", + "created": "2026-10-06T13:00:00Z"}"#, + ) + .expect("review fixture parses"); + let event = review_event(&review, &repo(), 3, None) + .expect("maps") + .expect("review carries an event"); + assert_eq!( + event.timestamp, + Timestamp::parse("2026-10-06T13:00:00Z").expect("parses") + ); + + // Older servers set `created` alone. + let bare: WireReview = serde_json::from_str( + r#"{"id": 33, "state": "COMMENT", "created": "2026-10-06T14:00:00Z"}"#, + ) + .expect("review fixture parses"); + let event = review_event(&bare, &repo(), 3, None) + .expect("maps") + .expect("review carries an event"); + assert_eq!( + event.timestamp, + Timestamp::parse("2026-10-06T14:00:00Z").expect("parses") + ); + + // Neither timestamp: never treatable as submitted. + let timeless: WireReview = serde_json::from_str(r#"{"id": 34, "state": "COMMENT"}"#) + .expect("review fixture parses"); + assert_eq!( + review_event(&timeless, &repo(), 3, None).expect("maps"), + None + ); + } + + #[test] + fn a_pr_comment_is_detected_from_its_urls() { + let comment: WireComment = serde_json::from_str( + r#"{"id": 3001, "user": {"login": "erin"}, "body": "reviewed", + "created_at": "2026-10-06T14:00:00Z", + "updated_at": "2026-10-06T14:00:00Z", + "issue_url": "https://forge.example/acme/widgets/issues/3", + "pull_request_url": "https://forge.example/acme/widgets/pulls/3"}"#, + ) + .expect("comment fixture parses"); + let event = comment_event(&comment, &repo()) + .expect("maps") + .expect("comment carries an event"); + assert_eq!(event.kind, EventKind::Comment); + assert!(event.is_pr); + assert_eq!(event.number, 3); + + // An issue comment carries no pull_request_url. + let issue_comment: WireComment = serde_json::from_str( + r#"{"id": 7001, "created_at": "2026-10-02T11:00:00Z", + "updated_at": "2026-10-02T11:00:00Z", + "issue_url": "https://forge.example/acme/widgets/issues/7", + "pull_request_url": null}"#, + ) + .expect("comment fixture parses"); + let event = comment_event(&issue_comment, &repo()) + .expect("maps") + .expect("comment carries an event"); + assert!(!event.is_pr); + } +} diff --git a/integrations/rhema-forge-integration/src/github.rs b/integrations/rhema-forge-integration/src/github.rs new file mode 100644 index 0000000..46be658 --- /dev/null +++ b/integrations/rhema-forge-integration/src/github.rs @@ -0,0 +1,846 @@ +//! The GitHub adapter: REST v3, at [`DEFAULT_BASE_URL`] or a GitHub +//! Enterprise Server base. Auth is the token in `Authorization: Bearer` — +//! never a query parameter, so the token never lands in URLs or logs. +//! +//! Endpoint choices and the constraints they pin: +//! - Threads come from `GET /repos/{owner}/{repo}/issues` with +//! `state=all&sort=updated&direction=asc&since=`: the issues +//! list is the only thread feed with a server-side time filter, and PR +//! rows ride it behind the `pull_request` key. +//! - The merged flag and reviews are invisible from the issues list: the +//! pulls list has no time filter and reviews have no repo-wide feed, so +//! every PR row the issues list returns costs one `GET /pulls/{n}` +//! (merged detection) and one `GET /pulls/{n}/reviews` (filtered +//! client-side by `submitted_at`). +//! - Comments come from the repo-wide +//! `GET /repos/{owner}/{repo}/issues/comments` with +//! `sort=created&direction=asc&since=`; a PR conversation comment is the +//! same row shape, its `issue_url` ending in `/pulls/`. +//! - GitHub's `since` matches rows updated at-or-after the instant, so a +//! re-poll on the returned cursor repeats boundary rows; stable ids make +//! the repetition dedupable. +//! - An updated-but-still-open thread with no close history yields no +//! thread event: the usual cause is a comment, which the comments feed +//! reports with its own id. Title/label edits alone surface as nothing. +//! - A `PENDING` review was never submitted and is skipped; `DISMISSED` is +//! activity and normalizes as [`EventKind::Review`]. +//! - A comment edit changes the row's `updated_at` but not its id: the +//! edit reappears at the boundary and dedups away. Edits are not +//! re-dispatched. +//! - Pagination is `per_page`/`page` until a short page, with a hard page +//! cap. Page offsets over an updated-ascending sort can shift while a +//! poll is running; duplicates are absorbed by ids, misses only happen +//! to rows that update twice inside one poll window. + +use std::time::Duration; + +use async_trait::async_trait; +use serde::Deserialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::error::{ForgeError, ensure_success, required}; +use crate::event::{ + EventKind, ForgeEvent, Notification, NotificationPage, NotificationReason, RepoRef, Snapshot, + ThreadState, Timestamp, +}; +use crate::forge::{Forge, ForgePage}; + +/// The production GitHub API base. +pub const DEFAULT_BASE_URL: &str = "https://api.github.com"; + +/// GitHub rejects requests without a `User-Agent` outright. +const USER_AGENT: &str = concat!("rhema-forge-integration/", env!("CARGO_PKG_VERSION")); + +/// Rows requested per page. +const PER_PAGE: usize = 100; + +/// Pages fetched before a server that keeps returning full pages is +/// declared misbehaving rather than polled forever. +const MAX_PAGES: usize = 1000; + +/// GitHub backend: a base URL, a token, one reqwest client. +/// +/// An empty token is accepted but useless for writes; public reads work +/// anonymously. +pub struct GitHubForge { + base_url: String, + token: String, + http: reqwest::Client, +} + +impl GitHubForge { + /// A forge authenticating with `token` against [`DEFAULT_BASE_URL`]. + pub fn new(token: impl Into) -> Self { + let http = reqwest::Client::builder() + // One connect attempt: a dead forge fails fast instead of + // stalling the poll loop. + .connect_timeout(Duration::from_secs(10)) + // Every forge call is a bounded request/response, unlike a + // model stream, so one overall timeout per request is the + // right guard. + .timeout(Duration::from_secs(20)) + .user_agent(USER_AGENT) + .build() + .expect("a client builder with only timeouts cannot fail"); + Self { + base_url: DEFAULT_BASE_URL.to_owned(), + token: token.into(), + http, + } + } + + /// Overrides the base URL (trailing slashes trimmed) — for GitHub + /// Enterprise Server and for tests against a mock server. + pub fn with_base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into().trim_end_matches('/').to_owned(); + self + } + + /// A GET builder for `path` with auth applied: the token rides only in + /// the `Authorization: Bearer` header, never in a URL. + fn get(&self, path: &str) -> reqwest::RequestBuilder { + let base = &self.base_url; + let request = self.http.get(format!("{base}{path}")); + if self.token.is_empty() { + request + } else { + request.bearer_auth(&self.token) + } + } + + /// A POST builder for `path` with auth applied. + fn post(&self, path: &str) -> reqwest::RequestBuilder { + let base = &self.base_url; + let request = self.http.post(format!("{base}{path}")); + if self.token.is_empty() { + request + } else { + request.bearer_auth(&self.token) + } + } + + /// Follows `per_page`/`page` pagination until a short page. + async fn paged( + &self, + path: &str, + query: &[(String, String)], + ) -> Result, ForgeError> { + let mut rows = Vec::new(); + for page in 1..=MAX_PAGES { + let mut query = query.to_vec(); + query.push(("per_page".to_owned(), PER_PAGE.to_string())); + query.push(("page".to_owned(), page.to_string())); + let response = self + .get(path) + .query(&query) + .send() + .await + .map_err(ForgeError::network)?; + let batch: Vec = decode(response).await?; + let full = batch.len() == PER_PAGE; + rows.extend(batch); + if !full { + return Ok(rows); + } + } + Err(ForgeError::Pagination(format!( + "more than {MAX_PAGES} full pages from {path}" + ))) + } +} + +/// Decodes a response: non-2xx to [`ForgeError::Api`] with the body +/// snippet, undecodable bodies to [`ForgeError::Parse`]. +async fn decode(response: reqwest::Response) -> Result { + let response = ensure_success(response).await?; + response + .json() + .await + .map_err(|err| ForgeError::Parse(format!("malformed forge response: {err}"))) +} + +// --- Wire rows (private): the subset each event lifts, plus the +// --- untouched row under `raw`. + +/// A GitHub user object. +#[derive(Debug, Deserialize)] +struct WireUser { + login: Option, +} + +/// A label object. +#[derive(Debug, Deserialize)] +struct WireLabel { + name: Option, +} + +/// The `pull_request` key: its presence is the only PR marker on issue +/// rows; the fields it carries are not used. +#[derive(Debug, Deserialize)] +struct WirePullMarker {} + +/// One row of the issues list. +#[derive(Debug, Deserialize)] +struct WireIssue { + #[serde(default)] + id: Option, + #[serde(default)] + number: Option, + #[serde(default)] + state: Option, + #[serde(default)] + user: Option, + #[serde(default)] + labels: Vec, + #[serde(default)] + title: Option, + #[serde(default)] + body: Option, + #[serde(default)] + created_at: Option, + #[serde(default)] + updated_at: Option, + #[serde(default)] + closed_at: Option, + #[serde(default)] + html_url: Option, + #[serde(default)] + pull_request: Option, + #[serde(flatten)] + raw: serde_json::Map, +} + +/// One `GET /pulls/{n}` row. +#[derive(Debug, Deserialize)] +struct WirePull { + #[serde(default)] + id: Option, + #[serde(default)] + number: Option, + #[serde(default)] + state: Option, + #[serde(default)] + user: Option, + #[serde(default)] + labels: Vec, + #[serde(default)] + title: Option, + #[serde(default)] + body: Option, + #[serde(default)] + created_at: Option, + #[serde(default)] + updated_at: Option, + #[serde(default)] + closed_at: Option, + #[serde(default)] + merged_at: Option, + #[serde(default)] + html_url: Option, + #[serde(flatten)] + raw: serde_json::Map, +} + +/// One row of a PR's reviews. +#[derive(Debug, Deserialize)] +struct WireReview { + #[serde(default)] + id: Option, + #[serde(default)] + user: Option, + #[serde(default)] + body: Option, + #[serde(default)] + state: Option, + #[serde(default)] + submitted_at: Option, + #[serde(default)] + html_url: Option, + #[serde(flatten)] + raw: serde_json::Map, +} + +/// One row of the repo-wide comments feed. +#[derive(Debug, Deserialize)] +struct WireComment { + #[serde(default)] + id: Option, + #[serde(default)] + user: Option, + #[serde(default)] + body: Option, + #[serde(default)] + created_at: Option, + #[serde(default)] + updated_at: Option, + #[serde(default)] + html_url: Option, + /// API URL of the parent thread; its trailing segment is the thread + /// number, and `/pulls/` in it marks a PR comment. + #[serde(default)] + issue_url: Option, + #[serde(flatten)] + raw: serde_json::Map, +} + +/// One row of the account notifications feed. +#[derive(Debug, Clone, Deserialize)] +struct WireNotification { + #[serde(default)] + id: Option, + #[serde(default)] + reason: Option, + #[serde(default)] + subject: Option, + #[serde(default)] + repository: Option, + #[serde(default)] + updated_at: Option, + #[serde(flatten)] + raw: serde_json::Map, +} + +/// The subject a notification is about. +#[derive(Debug, Clone, Deserialize)] +struct WireNotificationSubject { + #[serde(default)] + title: Option, + /// API URL of the thread; its trailing segment is the thread number. + #[serde(default)] + url: Option, + /// `PullRequest`, `Issue`, `Release`, ... + #[serde(rename = "type", default)] + kind: Option, +} + +/// The repository a notification's thread lives in. +#[derive(Debug, Clone, Deserialize)] +struct WireNotificationRepo { + #[serde(default)] + full_name: Option, +} + +// --- Mapping: wire rows -> normalized events. Pure, so the async +// --- transport stays thin and the derivations are unit-testable. + +/// The thread event an issues-list row carries, if any. State transitions +/// are derivable from the row; other updates are not. +fn issue_event(row: &WireIssue, repo: &RepoRef) -> Result, ForgeError> { + let id = required(row.id, "issue id")?; + let number = required(row.number, "issue number")?; + let updated = required(row.updated_at.clone(), "issue updated_at")?; + let created = required(row.created_at.clone(), "issue created_at")?; + let kind = if row.state.as_deref() == Some("closed") { + EventKind::Closed + } else if row.closed_at.is_some() { + EventKind::Reopened + } else if created == updated { + // Never touched since creation: opening is the only update. + EventKind::Opened + } else { + return Ok(None); + }; + Ok(Some(ForgeEvent { + id: format!("github:issue:{id}@{}", updated.raw()), + kind, + repo: repo.clone(), + number, + is_pr: false, + actor: row.user.as_ref().and_then(|user| user.login.clone()), + body: None, + url: row.html_url.clone(), + timestamp: updated, + raw: Value::Object(row.raw.clone()), + })) +} + +/// The thread event a pulls row carries, if any. The merged flag wins over +/// the closed state: a merge closes the PR. +fn pull_event(row: &WirePull, repo: &RepoRef) -> Result, ForgeError> { + let id = required(row.id, "pull id")?; + let number = required(row.number, "pull number")?; + let updated = required(row.updated_at.clone(), "pull updated_at")?; + let created = required(row.created_at.clone(), "pull created_at")?; + let kind = if row.merged_at.is_some() { + EventKind::Merged + } else if row.state.as_deref() == Some("closed") { + EventKind::Closed + } else if row.closed_at.is_some() { + EventKind::Reopened + } else if created == updated { + EventKind::Opened + } else { + return Ok(None); + }; + Ok(Some(ForgeEvent { + id: format!("github:pull:{id}@{}", updated.raw()), + kind, + repo: repo.clone(), + number, + is_pr: true, + actor: row.user.as_ref().and_then(|user| user.login.clone()), + body: None, + url: row.html_url.clone(), + timestamp: updated, + raw: Value::Object(row.raw.clone()), + })) +} + +/// The review event a reviews row carries, if any: never-submitted +/// (`PENDING`) rows and rows older than the cursor are skipped. +fn review_event( + row: &WireReview, + repo: &RepoRef, + number: u64, + since: Option<&Timestamp>, +) -> Result, ForgeError> { + if row.state.as_deref() == Some("PENDING") { + return Ok(None); + } + // A review without a submission time was never submitted. + let timestamp = required(row.submitted_at.clone(), "review submitted_at")?; + if let Some(since) = since { + if timestamp < *since { + return Ok(None); + } + } + let id = required(row.id, "review id")?; + Ok(Some(ForgeEvent { + id: format!("github:review:{id}"), + kind: EventKind::Review, + repo: repo.clone(), + number, + is_pr: true, + actor: row.user.as_ref().and_then(|user| user.login.clone()), + body: row.body.clone(), + url: row.html_url.clone(), + timestamp, + raw: Value::Object(row.raw.clone()), + })) +} + +/// The comment event a repo-wide comments row carries. +fn comment_event(row: &WireComment, repo: &RepoRef) -> Result, ForgeError> { + let id = required(row.id, "comment id")?; + let issue_url = required(row.issue_url.clone(), "comment issue_url")?; + let number = parent_number(&issue_url).ok_or_else(|| { + ForgeError::Parse(format!( + "comment issue_url without a thread number: {issue_url}" + )) + })?; + let timestamp = required( + row.updated_at.clone().or_else(|| row.created_at.clone()), + "comment timestamp", + )?; + Ok(Some(ForgeEvent { + id: format!("github:comment:{id}"), + kind: EventKind::Comment, + repo: repo.clone(), + number, + is_pr: issue_url.contains("/pulls/"), + actor: row.user.as_ref().and_then(|user| user.login.clone()), + body: row.body.clone(), + url: row.html_url.clone(), + timestamp, + raw: Value::Object(row.raw.clone()), + })) +} + +/// The parent thread number: a forge thread URL's trailing segment. +fn parent_number(url: &str) -> Option { + url.trim_end_matches('/').rsplit('/').next()?.parse().ok() +} + +/// The notification a notifications row carries. GitHub's `reason` field +/// is the row's whole why; the subject's API URL yields the thread. +fn notification_event(row: &WireNotification) -> Result, ForgeError> { + let id = required(row.id, "notification id")?; + let updated = required(row.updated_at.clone(), "notification updated_at")?; + let subject = required(row.subject.clone(), "notification subject")?; + let repo_row = required(row.repository.clone(), "notification repository")?; + let full_name = required(repo_row.full_name, "notification repository full_name")?; + let (owner, name) = full_name.split_once('/').ok_or_else(|| { + ForgeError::Parse(format!( + "notification repository full_name without a slash: {full_name}" + )) + })?; + let subject_url = subject.url.clone().unwrap_or_default(); + let number = parent_number(&subject_url).ok_or_else(|| { + ForgeError::Parse(format!( + "notification subject url without a thread number: {subject_url}" + )) + })?; + Ok(Some(Notification { + id: format!("github:notification:{id}@{}", updated.raw()), + reason: github_reason(row.reason.as_deref()), + repo: RepoRef::new(owner, name), + number, + is_pr: subject.kind.as_deref() == Some("PullRequest"), + actor: None, + title: subject.title.clone(), + url: None, + timestamp: updated, + raw: Value::Object(row.raw.clone()), + })) +} + +/// GitHub's reason strings, reduced to the shared vocabulary. `comment` +/// means "activity on a thread you participate in" — for the account +/// this client authenticates as, that is overwhelmingly feedback on its +/// own threads, so it maps to [`NotificationReason::ReviewFeedback`]. +fn github_reason(reason: Option<&str>) -> NotificationReason { + match reason { + Some("review_requested") => NotificationReason::ReviewRequested, + Some("assign") => NotificationReason::Assigned, + Some("mention") | Some("team_mention") => NotificationReason::Mention, + Some("ci_activity") => NotificationReason::CiActivity, + Some("author") => NotificationReason::Author, + Some("comment") => NotificationReason::ReviewFeedback, + Some(other) => NotificationReason::Other(other.to_owned()), + None => NotificationReason::Other("unreasoned".to_owned()), + } +} + +/// The snapshot an issues-list row yields. +fn snapshot_from_issue(row: &WireIssue) -> Result { + Ok(Snapshot { + title: required(row.title.clone(), "issue title")?, + body: row.body.clone(), + state: if row.state.as_deref() == Some("closed") { + ThreadState::Closed + } else { + ThreadState::Open + }, + is_pr: false, + author: row.user.as_ref().and_then(|user| user.login.clone()), + labels: row + .labels + .iter() + .filter_map(|label| label.name.clone()) + .collect(), + url: row.html_url.clone(), + raw: Value::Object(row.raw.clone()), + }) +} + +/// The snapshot a pulls row yields; `merged_at` is the authoritative +/// merged flag the issue view of a PR cannot prove. +fn snapshot_from_pull(row: &WirePull) -> Result { + Ok(Snapshot { + title: required(row.title.clone(), "pull title")?, + body: row.body.clone(), + state: if row.merged_at.is_some() { + ThreadState::Merged + } else if row.state.as_deref() == Some("closed") { + ThreadState::Closed + } else { + ThreadState::Open + }, + is_pr: true, + author: row.user.as_ref().and_then(|user| user.login.clone()), + labels: row + .labels + .iter() + .filter_map(|label| label.name.clone()) + .collect(), + url: row.html_url.clone(), + raw: Value::Object(row.raw.clone()), + }) +} + +#[async_trait] +impl Forge for GitHubForge { + async fn poll( + &self, + repo: &RepoRef, + since: Option<&Timestamp>, + ) -> Result { + let mut events = Vec::new(); + + // Thread feed: the issues list, the only feed with a server-side + // time filter. PR rows ride behind the `pull_request` key. + let threads_path = format!("/repos/{}/{}/issues", repo.owner, repo.name); + let mut query = vec![ + ("state".to_owned(), "all".to_owned()), + ("sort".to_owned(), "updated".to_owned()), + ("direction".to_owned(), "asc".to_owned()), + ]; + if let Some(since) = since { + query.push(("since".to_owned(), since.raw().to_owned())); + } + let mut pull_numbers = Vec::new(); + for row in self.paged::(&threads_path, &query).await? { + if row.pull_request.is_some() { + pull_numbers.push(required(row.number, "issue number")?); + } else if let Some(event) = issue_event(&row, repo)? { + events.push(event); + } + } + + // Per updated PR: the merged flag and the reviews are invisible + // from the issues list. + for number in pull_numbers { + let detail_path = format!("/repos/{}/{}/pulls/{number}", repo.owner, repo.name); + let response = self + .get(&detail_path) + .send() + .await + .map_err(ForgeError::network)?; + let pull: WirePull = decode(response).await?; + if let Some(event) = pull_event(&pull, repo)? { + events.push(event); + } + let reviews = self + .paged::(&format!("{detail_path}/reviews"), &[]) + .await?; + for review in &reviews { + if let Some(event) = review_event(review, repo, number, since)? { + events.push(event); + } + } + } + + // Comment feed, repo-wide: covers issue and PR conversation + // comments in one shape. + let comments_path = format!("/repos/{}/{}/issues/comments", repo.owner, repo.name); + let mut query = vec![ + ("sort".to_owned(), "created".to_owned()), + ("direction".to_owned(), "asc".to_owned()), + ]; + if let Some(since) = since { + query.push(("since".to_owned(), since.raw().to_owned())); + } + for row in self.paged::(&comments_path, &query).await? { + if let Some(event) = comment_event(&row, repo)? { + events.push(event); + } + } + + Ok(ForgePage::finish(events, since)) + } + + async fn comment(&self, repo: &RepoRef, number: u64, body: &str) -> Result<(), ForgeError> { + // The issues endpoint covers PRs too: every PR is an issue here. + let path = format!( + "/repos/{}/{}/issues/{number}/comments", + repo.owner, repo.name + ); + let response = self + .post(&path) + .json(&serde_json::json!({ "body": body })) + .send() + .await + .map_err(ForgeError::network)?; + ensure_success(response).await?; + Ok(()) + } + + async fn snapshot(&self, repo: &RepoRef, number: u64) -> Result { + let issue_path = format!("/repos/{}/{}/issues/{number}", repo.owner, repo.name); + let response = self + .get(&issue_path) + .send() + .await + .map_err(ForgeError::network)?; + let issue: WireIssue = decode(response).await?; + if issue.pull_request.is_some() { + // The issue view of a PR lacks the authoritative merged flag. + let pull_path = format!("/repos/{}/{}/pulls/{number}", repo.owner, repo.name); + let response = self + .get(&pull_path) + .send() + .await + .map_err(ForgeError::network)?; + let pull: WirePull = decode(response).await?; + Ok(snapshot_from_pull(&pull)?) + } else { + Ok(snapshot_from_issue(&issue)?) + } + } + + async fn notifications( + &self, + since: Option<&Timestamp>, + ) -> Result { + // The unread-notifications feed: everything addressed to the + // account, every repository it can see. `since` filters on the + // row's updated time; boundary rows repeat, ids dedup them. + let mut query = Vec::new(); + if let Some(since) = since { + query.push(("since".to_owned(), since.raw().to_owned())); + } + let rows = self + .paged::("/notifications", &query) + .await?; + let mut notifications = Vec::new(); + for row in &rows { + if let Some(notification) = notification_event(row)? { + notifications.push(notification); + } + } + Ok(NotificationPage::finish(notifications, since)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn repo() -> RepoRef { + RepoRef::new("acme", "widgets") + } + + fn row(json: &str) -> WireIssue { + serde_json::from_str(json).expect("issue fixture parses") + } + + #[test] + fn a_closed_issue_row_maps_to_closed() { + let event = issue_event( + &row(r#"{"id": 1007, "number": 7, "state": "closed", + "user": {"login": "alice"}, "labels": [], + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-02T10:00:00Z", + "closed_at": "2026-10-02T10:00:00Z", + "html_url": "https://github.com/acme/widgets/issues/7"}"#), + &repo(), + ) + .expect("maps") + .expect("closed row carries an event"); + assert_eq!(event.kind, EventKind::Closed); + assert_eq!(event.id, "github:issue:1007@2026-10-02T10:00:00Z"); + assert_eq!(event.number, 7); + assert!(!event.is_pr); + assert_eq!(event.actor.as_deref(), Some("alice")); + assert_eq!(event.raw["title"], serde_json::Value::Null); + } + + #[test] + fn reopen_is_derived_from_a_close_in_the_past() { + let event = issue_event( + &row(r#"{"id": 1004, "number": 4, "state": "open", + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-07T09:00:00Z", + "closed_at": "2026-10-03T00:00:00Z"}"#), + &repo(), + ) + .expect("maps") + .expect("reopened row carries an event"); + assert_eq!(event.kind, EventKind::Reopened); + } + + #[test] + fn an_updated_open_row_without_close_history_emits_nothing() { + let event = issue_event( + &row(r#"{"id": 1006, "number": 6, "state": "open", + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-05T09:00:00Z", + "closed_at": null}"#), + &repo(), + ) + .expect("maps"); + assert_eq!(event, None); + } + + #[test] + fn a_row_missing_a_required_field_is_a_parse_error() { + let err = issue_event(&row(r#"{"number": 7, "state": "closed"}"#), &repo()) + .expect_err("missing id is loud"); + assert!(matches!(err, ForgeError::Parse(_)), "got: {err:?}"); + } + + fn pull(json: &str) -> WirePull { + serde_json::from_str(json).expect("pull fixture parses") + } + + #[test] + fn merged_beats_closed_on_a_pull_row() { + let merged = pull_event( + &pull( + r#"{"id": 9009, "number": 9, "state": "closed", + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-06T12:00:00Z", + "closed_at": "2026-10-06T12:00:00Z", + "merged_at": "2026-10-06T12:00:00Z"}"#, + ), + &repo(), + ) + .expect("maps") + .expect("merged pull carries an event"); + assert_eq!(merged.kind, EventKind::Merged); + assert!(merged.is_pr); + assert_eq!(merged.id, "github:pull:9009@2026-10-06T12:00:00Z"); + + let closed = pull_event( + &pull( + r#"{"id": 9010, "number": 10, "state": "closed", + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-06T12:00:00Z", + "closed_at": "2026-10-06T12:00:00Z", + "merged_at": null}"#, + ), + &repo(), + ) + .expect("maps") + .expect("closed pull carries an event"); + assert_eq!(closed.kind, EventKind::Closed); + } + + #[test] + fn pending_reviews_without_submission_are_skipped() { + let review: WireReview = + serde_json::from_str(r#"{"id": 82, "state": "PENDING", "submitted_at": null}"#) + .expect("review fixture parses"); + let event = review_event(&review, &repo(), 8, None).expect("maps"); + assert_eq!(event, None); + } + + #[test] + fn a_pr_comment_is_detected_from_its_issue_url() { + let comment: WireComment = serde_json::from_str( + r#"{"id": 8001, "user": {"login": "dave"}, "body": "looks", + "created_at": "2026-10-05T10:30:00Z", + "updated_at": "2026-10-05T10:30:00Z", + "issue_url": "https://api.github.com/repos/acme/widgets/pulls/8"}"#, + ) + .expect("comment fixture parses"); + let event = comment_event(&comment, &repo()) + .expect("maps") + .expect("comment carries an event"); + assert_eq!(event.kind, EventKind::Comment); + assert!(event.is_pr); + assert_eq!(event.number, 8); + assert_eq!(event.id, "github:comment:8001"); + } + + #[test] + fn a_comment_url_without_a_number_is_a_parse_error() { + let comment: WireComment = serde_json::from_str( + r#"{"id": 8002, "issue_url": "https://api.github.com/repos/acme/widgets/issues/"}"#, + ) + .expect("comment fixture parses"); + let err = comment_event(&comment, &repo()).expect_err("loud failure"); + assert!(matches!(err, ForgeError::Parse(_)), "got: {err:?}"); + } + + #[test] + fn the_raw_payload_keeps_the_fields_the_event_does_not_lift() { + let event = issue_event( + &row( + r#"{"id": 1007, "number": 7, "state": "closed", "title": "crash", + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-02T10:00:00Z", + "closed_at": "2026-10-02T10:00:00Z", + "node_id": "MDU6SXNzdWUxMDA3"}"#, + ), + &repo(), + ) + .expect("maps") + .expect("closed row carries an event"); + // Undeclared keys survive; the lifted ones (id, number, state, + // title, ...) do not. + assert_eq!(event.raw["node_id"], "MDU6SXNzdWUxMDA3"); + assert!(event.raw.get("title").is_none()); + } +} diff --git a/integrations/rhema-forge-integration/src/gitlab.rs b/integrations/rhema-forge-integration/src/gitlab.rs new file mode 100644 index 0000000..8aa264f --- /dev/null +++ b/integrations/rhema-forge-integration/src/gitlab.rs @@ -0,0 +1,714 @@ +//! The GitLab adapter: gitlab.com or self-hosted, at +//! [`DEFAULT_BASE_URL`] — every request path is prefixed with `/api/v4`, +//! and the project is addressed by its URL-encoded full path. Auth is the +//! token in the `PRIVATE-TOKEN` header. +//! +//! Endpoint choices and the constraints that differ: +//! - Issues and merge requests come from +//! `GET /projects/{encoded path}/{issues,merge_requests}` with +//! `updated_after=&order_by=updated_at&sort=asc`: two separate +//! feeds, since GitLab has no combined thread feed. +//! - Issue iids and merge-request iids are separate sequences: a number +//! can exist as both. Wherever a bare number must resolve, the issue +//! feed is tried first and the merge-request feed is the 404 fallback; +//! an iid that is neither surfaces the merge-request 404. +//! - GitLab has no repo-wide notes feed and the per-thread notes endpoint +//! has no updated filter, so every updated thread gets one paginated +//! `GET .../{issues,merge_requests}/{iid}/notes?sort=asc&order_by=created_at` +//! call and the `created_at` cutoff is client-side. Note edits do not +//! re-fire (notes are keyed by creation time). +//! - System notes (state changes, label flips, ...) are skipped: the +//! thread-level transitions they record already surface from the thread +//! rows. +//! - `updated_after` matches rows at-or-after the instant, so a re-poll on +//! the returned cursor repeats boundary rows; stable ids make the +//! repetition dedupable. +//! - Merge-request state `merged` maps to [`EventKind::Merged`], `closed` +//! (unmerged) to [`EventKind::Closed`]; issue state `closed` to +//! [`EventKind::Closed`]. +//! - Pagination is offset-based (`per_page`/`page`) with the next page in +//! the `X-Next-Page` response header. Offset pages over an +//! updated-ascending sort can shift while a poll runs; duplicates are +//! absorbed by ids, misses only happen to rows that update twice inside +//! one poll window. + +use std::time::Duration; + +use async_trait::async_trait; +use serde::Deserialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::error::{ForgeError, ensure_success, required}; +use crate::event::{ + EventKind, ForgeEvent, Notification, NotificationPage, NotificationReason, RepoRef, Snapshot, + ThreadState, Timestamp, +}; +use crate::forge::{Forge, ForgePage}; + +/// The production GitLab base. +pub const DEFAULT_BASE_URL: &str = "https://gitlab.com"; + +/// Rows requested per page. +const PER_PAGE: usize = 100; + +/// Pages fetched before a server that keeps offering pages is declared +/// misbehaving rather than polled forever. +const MAX_PAGES: usize = 1000; + +/// GitLab backend: a base URL, a token, one reqwest client. +/// +/// An empty token is accepted but useless for writes; public reads work +/// anonymously. +pub struct GitLabForge { + base_url: String, + token: String, + http: reqwest::Client, +} + +impl GitLabForge { + /// A forge authenticating with `token` against [`DEFAULT_BASE_URL`]. + pub fn new(token: impl Into) -> Self { + let http = reqwest::Client::builder() + // One connect attempt: a dead forge fails fast instead of + // stalling the poll loop. + .connect_timeout(Duration::from_secs(10)) + // Every forge call is a bounded request/response, unlike a + // model stream, so one overall timeout per request is the + // right guard. + .timeout(Duration::from_secs(20)) + .build() + .expect("a client builder with only timeouts cannot fail"); + Self { + base_url: DEFAULT_BASE_URL.to_owned(), + token: token.into(), + http, + } + } + + /// Overrides the base URL (trailing slashes trimmed) — for + /// self-hosted instances and for tests against a mock server. + pub fn with_base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into().trim_end_matches('/').to_owned(); + self + } + + /// A GET builder for `path` with auth applied: the token rides only in + /// the `PRIVATE-TOKEN` header, never in a URL. + fn get(&self, path: &str) -> reqwest::RequestBuilder { + let base = &self.base_url; + let request = self.http.get(format!("{base}/api/v4{path}")); + self.auth(request) + } + + /// A POST builder for `path` with auth applied. + fn post(&self, path: &str) -> reqwest::RequestBuilder { + let base = &self.base_url; + let request = self.http.post(format!("{base}/api/v4{path}")); + self.auth(request) + } + + /// GitLab's token scheme: the `PRIVATE-TOKEN` header. + fn auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + if self.token.is_empty() { + return request; + } + request.header("PRIVATE-TOKEN", &self.token) + } + + /// Follows offset pagination via the `X-Next-Page` response header: + /// absent or empty ends the feed. + async fn paged( + &self, + path: &str, + query: &[(String, String)], + ) -> Result, ForgeError> { + let mut rows = Vec::new(); + let mut page: u32 = 1; + loop { + let mut query = query.to_vec(); + query.push(("per_page".to_owned(), PER_PAGE.to_string())); + query.push(("page".to_owned(), page.to_string())); + let response = self + .get(path) + .query(&query) + .send() + .await + .map_err(ForgeError::network)?; + // The next-page cursor is a header: read it before the body + // moves. + let next = response + .headers() + .get("x-next-page") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned(); + rows.extend(decode::>(response).await?); + if next.is_empty() { + return Ok(rows); + } + page = next + .parse() + .map_err(|_| ForgeError::Pagination(format!("non-numeric x-next-page: {next}")))?; + if page > MAX_PAGES as u32 { + return Err(ForgeError::Pagination(format!( + "more than {MAX_PAGES} pages from {path}" + ))); + } + } + } +} + +/// Decodes a response: non-2xx to [`ForgeError::Api`] with the body +/// snippet, undecodable bodies to [`ForgeError::Parse`]. +async fn decode(response: reqwest::Response) -> Result { + let response = ensure_success(response).await?; + response + .json() + .await + .map_err(|err| ForgeError::Parse(format!("malformed forge response: {err}"))) +} + +/// The project path (`owner/name`, possibly with subgroups) as GitLab's +/// API needs it: the whole path percent-encoded, slashes included. +fn encode_project_path(path: &str) -> String { + let mut encoded = String::with_capacity(path.len()); + for byte in path.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + encoded.push(byte as char) + } + other => encoded.push_str(&format!("%{other:02X}")), + } + } + encoded +} + +// --- Wire rows (private): the subset each event lifts, plus the +// --- untouched row under `raw`. + +/// A GitLab author object; the login field is spelled `username` here. +#[derive(Debug, Clone, Deserialize)] +struct WireAuthor { + username: Option, +} + +/// One issue or merge-request row: the same subset lifts from both. +#[derive(Debug, Deserialize)] +struct WireThread { + #[serde(default)] + id: Option, + #[serde(default)] + iid: Option, + #[serde(default)] + state: Option, + #[serde(default)] + author: Option, + #[serde(default)] + labels: Vec, + #[serde(default)] + title: Option, + /// The opening body: GitLab spells it `description`. + #[serde(default)] + description: Option, + #[serde(default)] + created_at: Option, + #[serde(default)] + updated_at: Option, + #[serde(default)] + closed_at: Option, + #[serde(default)] + web_url: Option, + #[serde(flatten)] + raw: serde_json::Map, +} + +/// One note (comment) row of a thread's notes feed. +#[derive(Debug, Deserialize)] +struct WireNote { + #[serde(default)] + id: Option, + #[serde(default)] + body: Option, + #[serde(default)] + system: bool, + #[serde(default)] + author: Option, + #[serde(default)] + created_at: Option, + #[serde(flatten)] + raw: serde_json::Map, +} + +/// One row of the account's todo list — GitLab's notification feed. +#[derive(Debug, Clone, Deserialize)] +struct WireTodo { + #[serde(default)] + id: Option, + #[serde(default)] + project: Option, + #[serde(default)] + target_type: Option, + #[serde(default)] + target: Option, + #[serde(default)] + action_name: Option, + /// Human-facing URL of the target thread. + #[serde(default)] + target_url: Option, + #[serde(default)] + updated_at: Option, + #[serde(flatten)] + raw: serde_json::Map, +} + +/// The project a todo's thread lives in. +#[derive(Debug, Clone, Deserialize)] +struct WireTodoProject { + #[serde(default)] + path_with_namespace: Option, +} + +/// The thread a todo points at. +#[derive(Debug, Clone, Deserialize)] +struct WireTodoTarget { + #[serde(default)] + iid: Option, + #[serde(default)] + title: Option, + #[serde(default)] + author: Option, +} + +/// GitLab's `action_name` strings, reduced to the shared vocabulary. +fn gitlab_reason(action: Option<&str>) -> NotificationReason { + match action { + Some("review_requested") | Some("approval_required") => NotificationReason::ReviewRequested, + Some("assigned") => NotificationReason::Assigned, + Some("mentioned") | Some("directly_addressed") => NotificationReason::Mention, + Some("build_failed") | Some("build_success") => NotificationReason::CiActivity, + Some(other) => NotificationReason::Other(other.to_owned()), + None => NotificationReason::Other("unactioned".to_owned()), + } +} + +/// The notification a todo row carries. The todo list has no server-side +/// time filter, so the poll refetches every pending todo and the dedup +/// ids absorb the repetition; the cursor is advisory only here. +fn todo_notification(row: &WireTodo) -> Result, ForgeError> { + let id = required(row.id, "todo id")?; + let updated = required(row.updated_at.clone(), "todo updated_at")?; + let project = required(row.project.clone(), "todo project")?; + let path = required(project.path_with_namespace, "todo project path")?; + let (owner, name) = path + .rsplit_once('/') + .ok_or_else(|| ForgeError::Parse(format!("todo project path without a slash: {path}")))?; + let target = required(row.target.clone(), "todo target")?; + let iid = required(target.iid, "todo target iid")?; + let is_pr = row.target_type.as_deref() == Some("MergeRequest"); + Ok(Some(Notification { + id: format!("gitlab:notification:{id}@{}", updated.raw()), + reason: gitlab_reason(row.action_name.as_deref()), + repo: RepoRef::new(owner, name), + number: iid, + is_pr, + actor: target + .author + .as_ref() + .and_then(|author| author.username.clone()), + title: target.title.clone(), + url: row.target_url.clone(), + timestamp: updated, + raw: Value::Object(row.raw.clone()), + })) +} + +// --- Mapping: wire rows -> normalized events. Pure, so the async +// --- transport stays thin and the derivations are unit-testable. + +/// The thread event a thread row carries, if any. `merged` only exists on +/// merge requests; a still-open row with a close in its past reopened. +fn thread_event( + row: &WireThread, + repo: &RepoRef, + is_pr: bool, +) -> Result, ForgeError> { + let id = required(row.id, "thread id")?; + let iid = required(row.iid, "thread iid")?; + let updated = required(row.updated_at.clone(), "thread updated_at")?; + let created = required(row.created_at.clone(), "thread created_at")?; + let state = row.state.as_deref().unwrap_or_default(); + let kind = if is_pr && state == "merged" { + EventKind::Merged + } else if state == "closed" { + EventKind::Closed + } else if row.closed_at.is_some() { + EventKind::Reopened + } else if created == updated { + // Never touched since creation: opening is the only update. + EventKind::Opened + } else { + return Ok(None); + }; + let item = if is_pr { "mr" } else { "issue" }; + Ok(Some(ForgeEvent { + id: format!("gitlab:{item}:{id}@{}", updated.raw()), + kind, + repo: repo.clone(), + number: iid, + is_pr, + actor: row + .author + .as_ref() + .and_then(|author| author.username.clone()), + body: None, + url: row.web_url.clone(), + timestamp: updated, + raw: Value::Object(row.raw.clone()), + })) +} + +/// The comment event a note row carries, if any: system notes record +/// transitions the thread rows already surface, and rows older than the +/// cursor are skipped. +fn note_event( + row: &WireNote, + repo: &RepoRef, + iid: u64, + is_pr: bool, + since: Option<&Timestamp>, +) -> Result, ForgeError> { + if row.system { + return Ok(None); + } + let timestamp = required(row.created_at.clone(), "note created_at")?; + if let Some(since) = since { + if timestamp < *since { + return Ok(None); + } + } + let id = required(row.id, "note id")?; + Ok(Some(ForgeEvent { + id: format!("gitlab:note:{id}"), + kind: EventKind::Comment, + repo: repo.clone(), + number: iid, + is_pr, + actor: row + .author + .as_ref() + .and_then(|author| author.username.clone()), + body: row.body.clone(), + // Note rows carry no URL of their own; the thread's `web_url` is + // on the thread row. + url: None, + timestamp, + raw: Value::Object(row.raw.clone()), + })) +} + +/// The snapshot a thread row yields. +fn snapshot_from_row(row: &WireThread, is_pr: bool) -> Result { + Ok(Snapshot { + title: required(row.title.clone(), "thread title")?, + body: row.description.clone(), + state: match row.state.as_deref() { + Some("merged") => ThreadState::Merged, + Some("closed") => ThreadState::Closed, + _ => ThreadState::Open, + }, + is_pr, + author: row + .author + .as_ref() + .and_then(|author| author.username.clone()), + labels: row.labels.clone(), + url: row.web_url.clone(), + raw: Value::Object(row.raw.clone()), + }) +} + +#[async_trait] +impl Forge for GitLabForge { + async fn poll( + &self, + repo: &RepoRef, + since: Option<&Timestamp>, + ) -> Result { + let project = encode_project_path(&repo.path()); + let base = format!("/projects/{project}"); + let mut events = Vec::new(); + + // Thread feeds: issues and merge requests, updated-ascending. + let mut query = vec![ + ("order_by".to_owned(), "updated_at".to_owned()), + ("sort".to_owned(), "asc".to_owned()), + ]; + if let Some(since) = since { + query.push(("updated_after".to_owned(), since.raw().to_owned())); + } + let mut threads: Vec<(WireThread, bool)> = Vec::new(); + for row in self + .paged::(&format!("{base}/issues"), &query) + .await? + { + if let Some(event) = thread_event(&row, repo, false)? { + events.push(event); + } + threads.push((row, false)); + } + for row in self + .paged::(&format!("{base}/merge_requests"), &query) + .await? + { + if let Some(event) = thread_event(&row, repo, true)? { + events.push(event); + } + threads.push((row, true)); + } + + // Notes, per updated thread: no repo-wide feed, no updated filter + // on the endpoint, so each thread's notes are paged and cut off + // client-side. + for (row, is_pr) in &threads { + let iid = required(row.iid, "thread iid")?; + let segment = if *is_pr { "merge_requests" } else { "issues" }; + let notes_query = vec![ + ("order_by".to_owned(), "created_at".to_owned()), + ("sort".to_owned(), "asc".to_owned()), + ]; + let notes_path = format!("{base}/{segment}/{iid}/notes"); + for note in self.paged::(¬es_path, ¬es_query).await? { + if let Some(event) = note_event(¬e, repo, iid, *is_pr, since)? { + events.push(event); + } + } + } + + Ok(ForgePage::finish(events, since)) + } + + async fn comment(&self, repo: &RepoRef, number: u64, body: &str) -> Result<(), ForgeError> { + let project = encode_project_path(&repo.path()); + // Issue iids and MR iids are separate sequences: try the issue + // feed first, fall back on its 404. An iid that is neither + // surfaces the merge-request 404. + let notes_path = |segment: &str| format!("/projects/{project}/{segment}/{number}/notes"); + let response = self + .post(¬es_path("issues")) + .json(&serde_json::json!({ "body": body })) + .send() + .await + .map_err(ForgeError::network)?; + let response = if response.status() == reqwest::StatusCode::NOT_FOUND { + self.post(¬es_path("merge_requests")) + .json(&serde_json::json!({ "body": body })) + .send() + .await + .map_err(ForgeError::network)? + } else { + response + }; + ensure_success(response).await?; + Ok(()) + } + + async fn snapshot(&self, repo: &RepoRef, number: u64) -> Result { + let project = encode_project_path(&repo.path()); + let resource = |segment: &str| format!("/projects/{project}/{segment}/{number}"); + let response = self + .get(&resource("issues")) + .send() + .await + .map_err(ForgeError::network)?; + // Same iid sequences as `comment`: issues first, 404 fallback. + let (row, is_pr) = match decode::(response).await { + Ok(row) => (row, false), + Err(ForgeError::Api { status: 404, .. }) => { + let response = self + .get(&resource("merge_requests")) + .send() + .await + .map_err(ForgeError::network)?; + let row: WireThread = decode(response).await?; + (row, true) + } + Err(other) => return Err(other), + }; + Ok(snapshot_from_row(&row, is_pr)?) + } + + async fn notifications( + &self, + since: Option<&Timestamp>, + ) -> Result { + // The pending-todo feed: everything addressed to the account, + // every project it can see. The endpoint has no time filter, so + // `since` only shapes the returned cursor; the refetch is + // absorbed by the dedup ids. + let rows = self.paged::("/todos", &[]).await?; + let mut notifications = Vec::new(); + for row in &rows { + if let Some(notification) = todo_notification(row)? { + notifications.push(notification); + } + } + Ok(NotificationPage::finish(notifications, since)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn repo() -> RepoRef { + RepoRef::new("acme", "widgets") + } + + #[test] + fn project_paths_encode_their_slashes() { + assert_eq!(encode_project_path("acme/widgets"), "acme%2Fwidgets"); + assert_eq!(encode_project_path("group/sub/proj"), "group%2Fsub%2Fproj"); + // Unreserved characters pass through byte for byte. + assert_eq!(encode_project_path("safe-name_1.0~x"), "safe-name_1.0~x"); + assert_eq!(encode_project_path("spa ce"), "spa%20ce"); + } + + fn thread(json: &str) -> WireThread { + serde_json::from_str(json).expect("thread fixture parses") + } + + #[test] + fn a_closed_issue_row_maps_to_closed() { + let event = thread_event( + &thread( + r#"{"id": 407, "iid": 7, "state": "closed", + "author": {"username": "alice"}, + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-02T10:00:00.081Z", + "closed_at": "2026-10-02T10:00:00Z", + "web_url": "https://gitlab.com/acme/widgets/-/issues/7"}"#, + ), + &repo(), + false, + ) + .expect("maps") + .expect("closed row carries an event"); + assert_eq!(event.kind, EventKind::Closed); + // GitLab's fractional seconds survive verbatim in the id. + assert_eq!(event.id, "gitlab:issue:407@2026-10-02T10:00:00.081Z"); + assert_eq!(event.number, 7); + assert!(!event.is_pr); + } + + #[test] + fn reopened_and_opened_derive_from_the_close_history() { + let reopened = thread_event( + &thread( + r#"{"id": 404, "iid": 4, "state": "opened", + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-07T09:00:00Z", + "closed_at": "2026-10-03T00:00:00Z"}"#, + ), + &repo(), + false, + ) + .expect("maps") + .expect("reopened row carries an event"); + assert_eq!(reopened.kind, EventKind::Reopened); + + let opened = thread_event( + &thread( + r#"{"id": 405, "iid": 5, "state": "opened", + "created_at": "2026-10-03T08:00:00Z", + "updated_at": "2026-10-03T08:00:00Z", + "closed_at": null}"#, + ), + &repo(), + false, + ) + .expect("maps") + .expect("fresh row carries an event"); + assert_eq!(opened.kind, EventKind::Opened); + } + + #[test] + fn merged_merge_requests_map_to_merged() { + let event = thread_event( + &thread( + r#"{"id": 503, "iid": 3, "state": "merged", + "created_at": "2026-10-01T08:00:00Z", + "updated_at": "2026-10-06T12:00:00Z", + "closed_at": "2026-10-06T12:00:00Z"}"#, + ), + &repo(), + true, + ) + .expect("maps") + .expect("merged row carries an event"); + assert_eq!(event.kind, EventKind::Merged); + assert!(event.is_pr); + assert_eq!(event.id, "gitlab:mr:503@2026-10-06T12:00:00Z"); + + // Unmerged close on an MR stays Closed. + let closed = thread_event( + &thread( + r#"{"id": 504, "iid": 3, "state": "closed", + "created_at": "2026-10-01T08:00:00Z", + "updated_at": "2026-10-06T12:00:00Z", + "closed_at": "2026-10-06T12:00:00Z"}"#, + ), + &repo(), + true, + ) + .expect("maps") + .expect("closed row carries an event"); + assert_eq!(closed.kind, EventKind::Closed); + } + + #[test] + fn system_notes_and_stale_notes_are_skipped() { + let system: WireNote = serde_json::from_str( + r#"{"id": 301, "body": "changed the description", "system": true, + "created_at": "2026-10-02T11:00:00Z"}"#, + ) + .expect("note fixture parses"); + assert_eq!( + note_event(&system, &repo(), 7, false, None).expect("maps"), + None + ); + + let stale: WireNote = serde_json::from_str( + r#"{"id": 303, "body": "old", "system": false, + "created_at": "2025-01-01T00:00:00Z"}"#, + ) + .expect("note fixture parses"); + let since = Timestamp::parse("2026-10-01T00:00:00Z").expect("parses"); + assert_eq!( + note_event(&stale, &repo(), 7, false, Some(&since)).expect("maps"), + None + ); + } + + #[test] + fn snapshot_states_map_from_the_row_state() { + let snapshot = snapshot_from_row( + &thread( + r#"{"id": 503, "iid": 3, "state": "merged", "title": "fix crash", + "description": "the fix", "author": {"username": "bob"}, + "labels": ["bug", "critical"], + "web_url": "https://gitlab.com/acme/widgets/-/merge_requests/3"}"#, + ), + true, + ) + .expect("maps"); + assert_eq!(snapshot.state, ThreadState::Merged); + assert!(snapshot.is_pr); + assert_eq!( + snapshot.labels, + vec!["bug".to_owned(), "critical".to_owned()] + ); + assert_eq!(snapshot.author.as_deref(), Some("bob")); + assert_eq!(snapshot.body.as_deref(), Some("the fix")); + } +} diff --git a/integrations/rhema-forge-integration/src/lib.rs b/integrations/rhema-forge-integration/src/lib.rs new file mode 100644 index 0000000..6f0ed51 --- /dev/null +++ b/integrations/rhema-forge-integration/src/lib.rs @@ -0,0 +1,70 @@ +//! `rhema-forge-integration` — the forge integration: one normalized +//! event vocabulary and one client contract for GitHub, Gitea, and +//! GitLab, and the ingest path that turns forge activity into RSD +//! dispatch. +//! +//! An integration, not an agent: no driver, no model, no loop lives +//! here. A consumer composes the crate with its own agent tier — the +//! [`crate::Watcher`] polls a [`crate::Forge`] for activity and feeds +//! the events to a [`rhema_rsd::Dispatcher`](rhema_rsd) (the forge's +//! stable dedup id becomes the event id, and the resource key is spec +//! §8's `host:owner/repo#number` shape, so re-polling unchanged history +//! dedups away and a changed row re-dispatches), and a dispatched +//! thread is answered through the same [`crate::Forge`] capabilities, +//! with [`crate::decode`] recovering the event behind an inbox item. +//! Polling cadence, handler policy, and the loop itself are the +//! consumer's — the claw daemon today, any other agent consumer of the +//! framework tomorrow; the dispatch guarantees are the dispatcher's. +//! +//! ``` +//! use rhema_forge_integration::{Forge, GitHubForge, RepoRef}; +//! +//! # async fn demo() -> Result<(), rhema_forge_integration::ForgeError> { +//! let forge = GitHubForge::new(std::env::var("GITHUB_TOKEN").unwrap()); +//! // First poll: everything. Later polls: pass `page.cursor` back in. +//! let page = forge.poll(&RepoRef::new("acme", "widgets"), None).await?; +//! for event in &page.events { +//! println!("{:?} on {}#{}", event.kind, event.repo.path(), event.number); +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! Layout: +//! - [`event`] — the normalized vocabulary: `ForgeEvent`, `EventKind`, +//! `RepoRef`, `Snapshot`, `Timestamp` +//! - [`error`] — the crate's single forge error type +//! - [`forge`] — the `Forge` client contract and `ForgePage` +//! - [`github`] — the GitHub adapter (api.github.com) +//! - [`gitea`] — the Gitea adapter (self-hosted, `/api/v1`) +//! - [`gitlab`] — the GitLab adapter (gitlab.com or self-hosted, `/api/v4`) +//! - [`watcher`] — the dispatcher seam: polls a forge into an RSD +//! `Dispatcher` (keys, dedup, cursors) and decodes delivered inbox +//! items back into forge events + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +pub mod error; +pub mod event; +pub mod forge; +pub mod gitea; +pub mod github; +pub mod gitlab; +pub mod watcher; + +// Re-exported so implementors of the async seam can apply #[async_trait] +// without adding the crate themselves. +pub use async_trait::async_trait; +pub use error::ForgeError; +pub use event::{ + EventKind, ForgeEvent, Notification, NotificationPage, NotificationReason, RepoRef, Snapshot, + ThreadState, Timestamp, +}; +pub use forge::{Forge, ForgePage}; +pub use gitea::GiteaForge; +pub use github::GitHubForge; +pub use gitlab::GitLabForge; +pub use watcher::{ + WatchError, Watcher, decode, forge_key_fn, parse_key, register_forge, resource_key, +}; diff --git a/integrations/rhema-forge-integration/src/watcher.rs b/integrations/rhema-forge-integration/src/watcher.rs new file mode 100644 index 0000000..aa1e9fa --- /dev/null +++ b/integrations/rhema-forge-integration/src/watcher.rs @@ -0,0 +1,186 @@ +//! The dispatcher seam: poll a forge feed into the RSD dispatcher, and +//! decode a delivered inbox item back into the forge event it carried. +//! +//! The [`Watcher`] owns one cursor per repo (the newest event time fed to +//! the dispatcher) and turns forge events into +//! [`rhema_rsd::Event`]s: the forge's stable dedup id stays the event id, +//! the serialized [`ForgeEvent`](crate::event::ForgeEvent) is the payload, +//! and the resource key is the spec §8 shape — +//! `{source}:{owner}/{name}#{number}` (see [`resource_key`]). Re-polling +//! unchanged history therefore dedups away in the dispatcher, and a +//! changed row re-dispatches. +//! +//! The watcher is loop-free: one `poll` per call. Cadence belongs to the +//! embedder's loop. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use rhema_rsd::{Clock, Dispatcher, Event, InboxItem, KeyFn, ResourceKey, SystemClock}; + +use crate::error::ForgeError; +use crate::event::{ForgeEvent, RepoRef, Timestamp}; +use crate::forge::Forge; + +/// Why a watch step failed: the forge call, or the dispatcher seam behind +/// it. +#[derive(Debug, thiserror::Error)] +pub enum WatchError { + /// The poll itself failed. + #[error(transparent)] + Forge(#[from] ForgeError), + /// The dispatcher rejected or could not persist an event. + #[error(transparent)] + Dispatch(#[from] rhema_rsd::DispatchError), +} + +/// The resource key of a thread on `source`: `{source}:{path}#{number}` — +/// spec §8's `github:acme/widgets#142` shape. +pub fn resource_key(source: &str, repo: &RepoRef, number: u64) -> ResourceKey { + ResourceKey::from(format!("{source}:{}#{number}", repo.path())) +} + +/// Recovers the thread coordinates from [`resource_key`]'s shape. The +/// inverse of the key builder, so a dispatch never needs the original +/// event to reach the forge. +pub fn parse_key(key: &ResourceKey) -> Option<(RepoRef, u64)> { + let (head, number) = key.as_str().rsplit_once('#')?; + let path = head.split_once(':')?.1; + let (owner, name) = path.rsplit_once('/')?; + Some((RepoRef::new(owner, name), number.parse().ok()?)) +} + +/// The payload a watcher builds for every event: just the coordinates the +/// canonical key function reads, before the full [`ForgeEvent`]. +#[derive(serde::Deserialize)] +struct KeyParts { + repo: RepoRef, + number: u64, +} + +/// Registers `source` on `dispatcher` with the canonical key function — +/// the one [`resource_key`] implements. Do this once before the first +/// ingest. +pub fn register_forge(dispatcher: &Dispatcher, source: &str) { + let prefix = format!("{source}:"); + dispatcher.register_source( + source.to_owned(), + Arc::new(move |event: &Event| { + match serde_json::from_value::(event.payload.clone()) { + Ok(parts) => { + ResourceKey::from(format!("{prefix}{}#{}", parts.repo.path(), parts.number)) + } + // Infallible seam: a payload the watcher itself did not + // build still gets a unique key, so it can neither clobber + // another thread nor wedge the dispatcher. + Err(_) => ResourceKey::from(format!("{prefix}unparsable#{}", event.id)), + } + }), + ); +} + +/// The `KeyFn` [`register_forge`] installs, for embedders wiring the +/// source themselves. +pub fn forge_key_fn(source: &str) -> Arc { + let prefix = format!("{source}:"); + Arc::new( + move |event: &Event| match serde_json::from_value::(event.payload.clone()) { + Ok(parts) => { + ResourceKey::from(format!("{prefix}{}#{}", parts.repo.path(), parts.number)) + } + Err(_) => ResourceKey::from(format!("{prefix}unparsable#{}", event.id)), + }, + ) +} + +/// Polls forge feeds into a [`Dispatcher`], one repo at a time, keeping +/// each repo's cursor. +pub struct Watcher { + dispatcher: Arc, + source: String, + cursors: Mutex>, +} + +impl Watcher { + /// A watcher feeding `dispatcher` as `source` (e.g. `"github"`). + /// [`register_forge`] must have installed the source's key function. + pub fn new(dispatcher: Arc, source: impl Into) -> Self { + Self { + dispatcher, + source: source.into(), + cursors: Mutex::new(HashMap::new()), + } + } + + /// The cursor `repo` polls from next: `None` before the first poll + /// (everything the forge returns is fed), then the newest event time + /// seen. + pub fn cursor(&self, repo: &RepoRef) -> Option { + self.cursors + .lock() + .expect("cursor map lock") + .get(repo) + .cloned() + } + + /// One poll of `repo`: the page's events are ingested in feed order + /// and the cursor advances to the page's. Returns how many events were + /// fed to the dispatcher — replayed ids included, they dedup there. + pub async fn poll(&self, forge: &dyn Forge, repo: &RepoRef) -> Result { + let since = self.cursor(repo); + let page = forge.poll(repo, since.as_ref()).await?; + let now = SystemClock.now_millis(); + let mut fed = 0; + for event in &page.events { + let payload = serde_json::to_value(event) + .map_err(|err| ForgeError::Parse(format!("event does not serialize: {err}")))?; + self.dispatcher.ingest(Event { + id: event.id.clone(), + source: self.source.clone(), + payload, + received_at: now, + })?; + fed += 1; + } + if let Some(cursor) = page.cursor { + self.cursors + .lock() + .expect("cursor map lock") + .insert(repo.clone(), cursor); + } + Ok(fed) + } +} + +/// Decodes a delivered inbox item back into the forge event the watcher +/// fed. A payload this crate did not build is a loud parse error, never a +/// silently skipped event. +pub fn decode(item: &InboxItem) -> Result { + serde_json::from_value(item.event.payload.clone()) + .map_err(|err| ForgeError::Parse(format!("inbox payload is not a forge event: {err}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_key_shape_round_trips_through_parse() { + let repo = RepoRef::new("acme", "widgets"); + let key = resource_key("github", &repo, 142); + assert_eq!(key.as_str(), "github:acme/widgets#142"); + assert_eq!(parse_key(&key), Some((repo, 142))); + } + + #[test] + fn a_gitlab_subgroup_path_survives_the_round_trip() { + let repo = RepoRef::new("group/subgroup", "widgets"); + let key = resource_key("gitlab", &repo, 7); + assert_eq!(parse_key(&key), Some((repo, 7))); + } + + #[test] + fn a_key_without_a_number_does_not_parse() { + assert_eq!(parse_key(&ResourceKey::from("github:acme/widgets")), None); + } +} diff --git a/integrations/rhema-forge-integration/tests/gitea.rs b/integrations/rhema-forge-integration/tests/gitea.rs new file mode 100644 index 0000000..d7e7849 --- /dev/null +++ b/integrations/rhema-forge-integration/tests/gitea.rs @@ -0,0 +1,474 @@ +//! Offline integration tests: the Gitea backend against an in-process +//! wiremock server. Zero network. + +use rhema_forge_integration::{ + EventKind, Forge, ForgeError, GiteaForge, NotificationReason, RepoRef, Timestamp, +}; +use serde_json::{Value, json}; +use wiremock::matchers::{header, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const SINCE: &str = "2026-10-01T00:00:00Z"; + +fn forge(server: &MockServer) -> GiteaForge { + GiteaForge::new(server.uri(), "tok") +} + +/// An issues-list row; `patch` overrides any field. +fn issue_row(number: i64, patch: Value) -> Value { + with( + json!({ + "id": 1000 + number, + "number": number, + "state": "open", + "title": format!("thread {number}"), + "user": { "login": "alice" }, + "labels": [], + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-01T09:00:00Z", + "closed_at": Value::Null, + "html_url": format!("https://forge.example/acme/widgets/issues/{number}"), + "pull_request": Value::Null + }), + patch, + ) +} + +/// A pulls-list row; `patch` overrides any field. +fn pull_row(number: i64, patch: Value) -> Value { + with( + json!({ + "id": 2000 + number, + "number": number, + "state": "open", + "merged": false, + "title": format!("pr {number}"), + "user": { "login": "bob" }, + "labels": [], + "created_at": "2026-10-01T08:00:00Z", + "updated_at": "2026-10-01T08:00:00Z", + "closed_at": Value::Null, + "html_url": format!("https://forge.example/acme/widgets/pulls/{number}") + }), + patch, + ) +} + +/// Shallow-merges `patch` into `base`. +fn with(base: Value, patch: Value) -> Value { + match (base, patch) { + (Value::Object(mut base), Value::Object(patch)) => { + for (key, value) in patch { + base.insert(key, value); + } + Value::Object(base) + } + (base, _) => base, + } +} + +#[tokio::test] +async fn poll_pages_the_feeds_with_limit_and_filters_pulls_client_side() { + let server = MockServer::start().await; + // A full first page plus a short second one exercises pagination. + let page1: Vec = (0..50) + .map(|i| { + issue_row( + i, + json!({ + "created_at": "2026-10-02T09:00:00Z", + "updated_at": "2026-10-02T09:00:00Z" + }), + ) + }) + .collect(); + // Mount order matters: the page-2 mock must match before the page-1 one. + Mock::given(method("GET")) + .and(path("/api/v1/repos/acme/widgets/issues")) + .and(query_param("page", "2")) + .and(query_param("since", SINCE)) + .and(query_param("type", "issues")) + .and(header("authorization", "token tok")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([issue_row( + 200, + json!({ + "created_at": "2026-10-03T09:00:00Z", + "updated_at": "2026-10-03T09:00:00Z" + }) + )]))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/repos/acme/widgets/issues")) + .and(query_param("since", SINCE)) + .respond_with(ResponseTemplate::new(200).set_body_json(Value::Array(page1))) + .mount(&server) + .await; + // The pulls feed has no server-side time filter: it is always paged, + // and stale rows must be cut off client-side (no reviews fetch for + // them, no events either). + Mock::given(method("GET")) + .and(path("/api/v1/repos/acme/widgets/pulls")) + .and(query_param("sort", "leastrecent")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([pull_row( + 55, + json!({ + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + }) + )]))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/repos/acme/widgets/issues/comments")) + .and(query_param("since", SINCE)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) + .mount(&server) + .await; + + let since = Timestamp::parse(SINCE).expect("cursor parses"); + let page = forge(&server) + .poll(&RepoRef::new("acme", "widgets"), Some(&since)) + .await + .expect("poll succeeds"); + + assert_eq!(page.events.len(), 51); + assert_eq!( + page.cursor, + Some(Timestamp::parse("2026-10-03T09:00:00Z").expect("parses")) + ); + + let received = server + .received_requests() + .await + .expect("request recording is on"); + // Issues pages 1+2, the pulls page, the comments page — and no + // reviews fetch for the stale pull. + assert_eq!(received.len(), 4); + assert!(received[0].url.as_str().contains("limit=50")); + assert!(received[0].url.as_str().contains("page=1")); + assert!(received[1].url.as_str().contains("page=2")); + assert!(received[2].url.path() == "/api/v1/repos/acme/widgets/pulls"); + assert!(received[3].url.path() == "/api/v1/repos/acme/widgets/issues/comments"); + assert!( + received + .iter() + .all(|request| !request.url.path().contains("reviews")) + ); +} + +#[tokio::test] +async fn poll_normalizes_state_kinds_pr_distinction_reviews_and_comments() { + let server = MockServer::start().await; + let issues = json!([ + issue_row( + 7, + json!({ + "state": "closed", + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-02T10:00:00Z", + "closed_at": "2026-10-02T10:00:00Z" + }) + ), + issue_row( + 5, + json!({ + "created_at": "2026-10-03T08:00:00Z", + "updated_at": "2026-10-03T08:00:00Z" + }) + ), + issue_row( + 4, + json!({ + "state": "open", + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-07T09:00:00Z", + "closed_at": "2026-10-03T00:00:00Z" + }) + ), + ]); + Mock::given(method("GET")) + .and(path("/api/v1/repos/acme/widgets/issues")) + .respond_with(ResponseTemplate::new(200).set_body_json(issues)) + .mount(&server) + .await; + // A merged PR (updated inside the window) and a stale one. + Mock::given(method("GET")) + .and(path("/api/v1/repos/acme/widgets/pulls")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + pull_row( + 3, + json!({ + "state": "closed", + "merged": true, + "created_at": "2026-10-01T08:00:00Z", + "updated_at": "2026-10-06T12:00:00Z", + "closed_at": "2026-10-06T12:00:00Z" + }) + ), + pull_row( + 55, + json!({ + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + }) + ) + ]))) + .mount(&server) + .await; + // Reviews of the merged PR: one submitted, one never-submitted. + Mock::given(method("GET")) + .and(path("/api/v1/repos/acme/widgets/pulls/3/reviews")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { "id": 31, "user": { "login": "carol" }, "body": "lgtm", + "state": "APPROVED", "official": true, "stale": false, + "commit_id": "abc123", + "submitted": "2026-10-06T13:00:00Z", + "created": "2026-10-06T13:00:00Z", + "comments": [] }, + { "id": 32, "user": { "login": "carol" }, "body": "", + "state": "PENDING", "submitted": Value::Null, + "created": Value::Null, "comments": [] } + ]))) + .mount(&server) + .await; + // The repo-wide comments feed: one issue comment, one PR comment. + Mock::given(method("GET")) + .and(path("/api/v1/repos/acme/widgets/issues/comments")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { "id": 7001, "user": { "login": "dave" }, "body": "repro?", + "created_at": "2026-10-02T11:00:00Z", + "updated_at": "2026-10-02T11:00:00Z", + "html_url": "https://forge.example/acme/widgets/issues/7#comment-7001", + "issue_url": "https://forge.example/acme/widgets/issues/7", + "pull_request_url": Value::Null }, + { "id": 3001, "user": { "login": "erin" }, "body": "reviewed", + "created_at": "2026-10-06T14:00:00Z", + "updated_at": "2026-10-06T14:00:00Z", + "html_url": "https://forge.example/acme/widgets/pulls/3#comment-3001", + "issue_url": "https://forge.example/acme/widgets/issues/3", + "pull_request_url": "https://forge.example/acme/widgets/pulls/3" } + ]))) + .mount(&server) + .await; + + let since = Timestamp::parse(SINCE).expect("cursor parses"); + let page = forge(&server) + .poll(&RepoRef::new("acme", "widgets"), Some(&since)) + .await + .expect("poll succeeds"); + + // Oldest → newest; the stale pull and the PENDING review are gone. + let summary: Vec<(EventKind, u64, bool)> = page + .events + .iter() + .map(|event| (event.kind, event.number, event.is_pr)) + .collect(); + assert_eq!( + summary, + vec![ + (EventKind::Closed, 7, false), + (EventKind::Comment, 7, false), + (EventKind::Opened, 5, false), + (EventKind::Merged, 3, true), + (EventKind::Review, 3, true), + (EventKind::Comment, 3, true), + (EventKind::Reopened, 4, false), + ] + ); + assert_eq!(page.events[0].id, "gitea:issue:1007@2026-10-02T10:00:00Z"); + assert_eq!(page.events[1].id, "gitea:comment:7001"); + assert_eq!(page.events[3].id, "gitea:pull:2003@2026-10-06T12:00:00Z"); + assert_eq!(page.events[4].id, "gitea:review:31"); + assert_eq!(page.events[4].actor.as_deref(), Some("carol")); + assert_eq!( + page.cursor, + Some(Timestamp::parse("2026-10-07T09:00:00Z").expect("parses")) + ); + + // Gitea's token scheme rides on every request. + let received = server + .received_requests() + .await + .expect("request recording is on"); + assert!(received.iter().all(|request| { + request + .headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + == Some("token tok") + })); +} + +#[tokio::test] +async fn posting_a_comment_uses_the_token_header_and_the_issues_endpoint() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/v1/repos/acme/widgets/issues/4/comments")) + .and(header("authorization", "token tok")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 42 }))) + .mount(&server) + .await; + + forge(&server) + .comment(&RepoRef::new("acme", "widgets"), 4, "on it") + .await + .expect("comment posts"); + + let received = server + .received_requests() + .await + .expect("request recording is on"); + assert_eq!(received.len(), 1); + assert!(!received[0].url.as_str().contains("tok")); + let body: Value = serde_json::from_slice(&received[0].body).expect("json body"); + assert_eq!(body, json!({ "body": "on it" })); +} + +#[tokio::test] +async fn snapshot_reads_prs_through_the_pulls_row() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/repos/acme/widgets/issues/7")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(issue_row(7, json!({ "labels": [{ "name": "bug" }] }))), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/repos/acme/widgets/issues/3")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(issue_row(3, json!({ "pull_request": { "merged": true } }))), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/repos/acme/widgets/pulls/3")) + .respond_with(ResponseTemplate::new(200).set_body_json(pull_row( + 3, + json!({ + "state": "closed", + "merged": true, + "title": "fix crash", + "labels": [{ "name": "bug" }] + }), + ))) + .mount(&server) + .await; + + let repo = RepoRef::new("acme", "widgets"); + let issue = forge(&server) + .snapshot(&repo, 7) + .await + .expect("issue snapshot"); + assert_eq!(issue.state, rhema_forge_integration::ThreadState::Open); + assert!(!issue.is_pr); + assert_eq!(issue.labels, vec!["bug".to_owned()]); + + let pull = forge(&server) + .snapshot(&repo, 3) + .await + .expect("pull snapshot"); + assert_eq!(pull.state, rhema_forge_integration::ThreadState::Merged); + assert!(pull.is_pr); + assert_eq!(pull.title, "fix crash"); +} + +#[tokio::test] +async fn trailing_slashes_in_the_base_url_are_trimmed() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/repos/acme/widgets/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_row(7, json!({})))) + .mount(&server) + .await; + + let forge = GiteaForge::new(format!("{}/", server.uri()), "tok"); + forge + .snapshot(&RepoRef::new("acme", "widgets"), 7) + .await + .expect("the trimmed base reaches the same path"); +} + +#[tokio::test] +async fn a_non_2xx_response_maps_to_an_api_error_with_the_body_message() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/repos/acme/widgets/issues")) + .respond_with( + ResponseTemplate::new(401).set_body_string( + json!({ + "message": "user does not exist [uid: 0, name: ]", + "url": "https://forge.example/api/swagger" + }) + .to_string(), + ), + ) + .mount(&server) + .await; + + let err = forge(&server) + .poll(&RepoRef::new("acme", "widgets"), None) + .await + .expect_err("401 is an error"); + match err { + ForgeError::Api { status, body } => { + assert_eq!(status, 401); + assert!(body.contains("user does not exist"), "got: {body}"); + } + other => panic!("expected Api error, got: {other:?}"), + } +} + +#[tokio::test] +async fn a_transport_failure_maps_to_network() { + // An unroutable address: nothing is listening, so the connect fails. + let forge = GiteaForge::new("http://127.0.0.1:1", "tok"); + let err = forge + .poll(&RepoRef::new("acme", "widgets"), None) + .await + .expect_err("connection refused is an error"); + assert!(matches!(err, ForgeError::Network(_)), "got: {err:?}"); +} + +#[tokio::test] +async fn notifications_poll_maps_the_subject_feed() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/notifications")) + .and(query_param("status-types", "unread")) + .and(query_param("since", SINCE)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { + "id": 91, + "unread": true, + "updated_at": "2026-10-05T12:00:00Z", + "subject": { + "title": "Fix the gate", + "url": "https://forge.example/api/v1/repos/acme/widgets/pulls/9", + "type": "Pull" + }, + "repository": { "full_name": "acme/widgets" } + } + ]))) + .mount(&server) + .await; + + let since = Timestamp::parse(SINCE).expect("cursor parses"); + let page = forge(&server) + .notifications(Some(&since)) + .await + .expect("poll succeeds"); + + assert_eq!(page.notifications.len(), 1); + let note = &page.notifications[0]; + assert_eq!(note.id, "gitea:notification:91@2026-10-05T12:00:00Z"); + // Gitea's feed names no per-row reason: the fixed fallback spelling. + assert_eq!(note.reason, NotificationReason::Other("gitea".to_owned())); + assert_eq!(note.number, 9); + assert!(note.is_pr); + assert_eq!(note.repo, RepoRef::new("acme", "widgets")); + assert_eq!(note.title.as_deref(), Some("Fix the gate")); +} diff --git a/integrations/rhema-forge-integration/tests/github.rs b/integrations/rhema-forge-integration/tests/github.rs new file mode 100644 index 0000000..6129257 --- /dev/null +++ b/integrations/rhema-forge-integration/tests/github.rs @@ -0,0 +1,528 @@ +//! Offline integration tests: the GitHub backend against an in-process +//! wiremock server. Zero network. + +use rhema_forge_integration::{ + EventKind, Forge, ForgeError, GitHubForge, NotificationReason, RepoRef, Timestamp, +}; +use serde_json::{Value, json}; +use wiremock::matchers::{header, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const SINCE: &str = "2026-10-01T00:00:00Z"; + +fn forge(server: &MockServer) -> GitHubForge { + GitHubForge::new("tok").with_base_url(server.uri()) +} + +/// An issues-list row; `patch` overrides any field. +fn issue_row(number: i64, patch: Value) -> Value { + with( + json!({ + "id": 1000 + number, + "number": number, + "state": "open", + "title": format!("thread {number}"), + "user": { "login": "alice" }, + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-01T09:00:00Z", + "closed_at": Value::Null, + "html_url": format!("https://github.com/acme/widgets/issues/{number}") + }), + patch, + ) +} + +/// Shallow-merges `patch` into `base`. +fn with(base: Value, patch: Value) -> Value { + match (base, patch) { + (Value::Object(mut base), Value::Object(patch)) => { + for (key, value) in patch { + base.insert(key, value); + } + Value::Object(base) + } + (base, _) => base, + } +} + +#[tokio::test] +async fn poll_pages_the_thread_feed_and_the_comment_feed() { + let server = MockServer::start().await; + let page1: Vec = (0..100) + .map(|i| { + issue_row( + i, + json!({ + "created_at": "2026-10-02T09:00:00Z", + "updated_at": "2026-10-02T09:00:00Z" + }), + ) + }) + .collect(); + // Mount order matters: the page-2 mock must match before the page-1 one. + Mock::given(method("GET")) + .and(path("/repos/acme/widgets/issues")) + .and(query_param("page", "2")) + .and(query_param("since", SINCE)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([issue_row( + 200, + json!({ + "created_at": "2026-10-03T09:00:00Z", + "updated_at": "2026-10-03T09:00:00Z" + }) + )]))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/acme/widgets/issues")) + .and(query_param("since", SINCE)) + .respond_with(ResponseTemplate::new(200).set_body_json(Value::Array(page1))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/acme/widgets/issues/comments")) + .and(query_param("since", SINCE)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) + .mount(&server) + .await; + + let since = Timestamp::parse(SINCE).expect("cursor parses"); + let page = forge(&server) + .poll(&RepoRef::new("acme", "widgets"), Some(&since)) + .await + .expect("poll succeeds"); + + assert_eq!(page.events.len(), 101); + assert!( + page.events + .windows(2) + .all(|pair| pair[0].timestamp <= pair[1].timestamp) + ); + assert_eq!( + page.cursor, + Some(Timestamp::parse("2026-10-03T09:00:00Z").expect("parses")) + ); + + let received = server + .received_requests() + .await + .expect("request recording is on"); + assert_eq!(received.len(), 3); + assert!(received[0].url.as_str().contains("page=1")); + assert!(received[0].url.as_str().contains("per_page=100")); + assert!(received[0].url.as_str().contains("sort=updated")); + assert!(received[0].url.as_str().contains("state=all")); + assert!(received[1].url.as_str().contains("page=2")); + assert!(received[2].url.path() == "/repos/acme/widgets/issues/comments"); +} + +#[tokio::test] +async fn poll_normalizes_state_kinds_pr_distinction_reviews_and_comments() { + let server = MockServer::start().await; + // A closed issue, a fresh issue, a reopened issue, an opened PR row, + // and a closed PR row — the PRs ride the issues feed behind the + // `pull_request` key. + let issues = json!([ + issue_row( + 7, + json!({ + "state": "closed", + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-02T10:00:00Z", + "closed_at": "2026-10-02T10:00:00Z", + "node_id": "MDU6SXNzdWUxMDA3" + }) + ), + issue_row( + 5, + json!({ + "created_at": "2026-10-03T08:00:00Z", + "updated_at": "2026-10-03T08:00:00Z" + }) + ), + issue_row( + 4, + json!({ + "state": "open", + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-07T09:00:00Z", + "closed_at": "2026-10-03T00:00:00Z" + }) + ), + with( + issue_row( + 8, + json!({ + "created_at": "2026-10-04T08:00:00Z", + "updated_at": "2026-10-04T08:00:00Z", + "html_url": "https://github.com/acme/widgets/pull/8" + }) + ), + json!({ "pull_request": { "merged_at": Value::Null } }) + ), + with( + issue_row( + 9, + json!({ + "state": "closed", + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-06T09:00:00Z", + "closed_at": "2026-10-06T09:00:00Z", + "html_url": "https://github.com/acme/widgets/pull/9" + }) + ), + json!({ "pull_request": { "merged_at": Value::Null } }) + ), + ]); + Mock::given(method("GET")) + .and(path("/repos/acme/widgets/issues")) + .respond_with(ResponseTemplate::new(200).set_body_json(issues)) + .mount(&server) + .await; + // PR #8: open and unmerged as a pulls row → Opened. + Mock::given(method("GET")) + .and(path("/repos/acme/widgets/pulls/8")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": 9008, "number": 8, "state": "open", + "user": { "login": "alice" }, + "created_at": "2026-10-04T08:00:00Z", + "updated_at": "2026-10-04T08:00:00Z", + "closed_at": Value::Null, + "merged_at": Value::Null, + "html_url": "https://github.com/acme/widgets/pull/8" + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/acme/widgets/pulls/8/reviews")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { "id": 81, "user": { "login": "bob" }, "body": "ship it", + "state": "APPROVED", "submitted_at": "2026-10-05T10:00:00Z", + "html_url": "https://github.com/acme/widgets/pull/8#pullrequestreview-81" }, + { "id": 82, "user": { "login": "bob" }, "body": "", + "state": "PENDING", "submitted_at": Value::Null, + "html_url": "https://github.com/acme/widgets/pull/8#pullrequestreview-82" } + ]))) + .mount(&server) + .await; + // PR #9: closed and merged as a pulls row → Merged. + Mock::given(method("GET")) + .and(path("/repos/acme/widgets/pulls/9")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": 9009, "number": 9, "state": "closed", + "user": { "login": "alice" }, + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-06T12:00:00Z", + "closed_at": "2026-10-06T09:00:00Z", + "merged_at": "2026-10-06T12:00:00Z", + "html_url": "https://github.com/acme/widgets/pull/9" + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/acme/widgets/pulls/9/reviews")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { "id": 91, "user": { "login": "erin" }, "body": "one nit", + "state": "CHANGES_REQUESTED", "submitted_at": "2026-10-06T13:00:00Z", + "html_url": "https://github.com/acme/widgets/pull/9#pullrequestreview-91" } + ]))) + .mount(&server) + .await; + // The repo-wide comments feed: one issue comment, one PR comment. + Mock::given(method("GET")) + .and(path("/repos/acme/widgets/issues/comments")) + .and(query_param("sort", "created")) + .and(query_param("direction", "asc")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { "id": 7001, "user": { "login": "carol" }, "body": "repro?", + "created_at": "2026-10-02T11:00:00Z", + "updated_at": "2026-10-02T11:00:00Z", + "html_url": "https://github.com/acme/widgets/issues/7#issuecomment-7001", + "issue_url": "https://api.github.com/repos/acme/widgets/issues/7" }, + { "id": 8001, "user": { "login": "dave" }, "body": "left a review", + "created_at": "2026-10-05T10:30:00Z", + "updated_at": "2026-10-05T10:30:00Z", + "html_url": "https://github.com/acme/widgets/pull/8#issuecomment-8001", + "issue_url": "https://api.github.com/repos/acme/widgets/pulls/8" } + ]))) + .mount(&server) + .await; + + let since = Timestamp::parse(SINCE).expect("cursor parses"); + let page = forge(&server) + .poll(&RepoRef::new("acme", "widgets"), Some(&since)) + .await + .expect("poll succeeds"); + + // Oldest → newest; the PENDING review is gone. + let summary: Vec<(EventKind, u64, bool)> = page + .events + .iter() + .map(|event| (event.kind, event.number, event.is_pr)) + .collect(); + assert_eq!( + summary, + vec![ + (EventKind::Closed, 7, false), + (EventKind::Comment, 7, false), + (EventKind::Opened, 5, false), + (EventKind::Opened, 8, true), + (EventKind::Review, 8, true), + (EventKind::Comment, 8, true), + (EventKind::Merged, 9, true), + (EventKind::Review, 9, true), + (EventKind::Reopened, 4, false), + ] + ); + // Thread events id on the feed row's id + updated time; comment and + // review events on their own ids. + assert_eq!(page.events[0].id, "github:issue:1007@2026-10-02T10:00:00Z"); + assert_eq!(page.events[1].id, "github:comment:7001"); + assert_eq!(page.events[3].id, "github:pull:9008@2026-10-04T08:00:00Z"); + assert_eq!(page.events[4].id, "github:review:81"); + // Polls see row snapshots, not transitions: the actor of a thread + // event is the thread author; reviews carry the reviewer. + assert_eq!(page.events[0].actor.as_deref(), Some("alice")); + assert_eq!(page.events[4].actor.as_deref(), Some("bob")); + assert_eq!(page.events[4].body.as_deref(), Some("ship it")); + // The untouched wire row rides along for handler context: fields the + // event does not lift survive; lifted ones do not. + assert_eq!(page.events[0].raw["node_id"], "MDU6SXNzdWUxMDA3"); + assert!(page.events[0].raw.get("title").is_none()); + assert_eq!( + page.cursor, + Some(Timestamp::parse("2026-10-07T09:00:00Z").expect("parses")) + ); +} + +#[tokio::test] +async fn posting_a_comment_uses_the_bearer_header_and_the_issues_endpoint() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/repos/acme/widgets/issues/7/comments")) + .and(header("authorization", "Bearer tok")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 42 }))) + .mount(&server) + .await; + + forge(&server) + .comment(&RepoRef::new("acme", "widgets"), 7, "on it") + .await + .expect("comment posts"); + + let received = server + .received_requests() + .await + .expect("request recording is on"); + assert_eq!(received.len(), 1); + // The token is a header, never part of the URL. + assert!(!received[0].url.as_str().contains("tok")); + let body: Value = serde_json::from_slice(&received[0].body).expect("json body"); + assert_eq!(body, json!({ "body": "on it" })); +} + +#[tokio::test] +async fn snapshot_reads_prs_through_the_pulls_row() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/acme/widgets/issues/7")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(issue_row(7, json!({ "labels": [{ "name": "bug" }] }))), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/acme/widgets/issues/8")) + .respond_with(ResponseTemplate::new(200).set_body_json(with( + issue_row(8, json!({})), + json!({ "pull_request": { "merged_at": Value::Null } }), + ))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/acme/widgets/pulls/8")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": 9008, "number": 8, "state": "closed", + "title": "fix crash", + "user": { "login": "alice" }, + "labels": [{ "name": "bug" }], + "created_at": "2026-10-04T08:00:00Z", + "updated_at": "2026-10-06T12:00:00Z", + "merged_at": "2026-10-06T12:00:00Z", + "html_url": "https://github.com/acme/widgets/pull/8" + }))) + .mount(&server) + .await; + + let repo = RepoRef::new("acme", "widgets"); + let issue = forge(&server) + .snapshot(&repo, 7) + .await + .expect("issue snapshot"); + assert_eq!(issue.state, rhema_forge_integration::ThreadState::Open); + assert!(!issue.is_pr); + assert_eq!(issue.labels, vec!["bug".to_owned()]); + assert_eq!(issue.author.as_deref(), Some("alice")); + + let pull = forge(&server) + .snapshot(&repo, 8) + .await + .expect("pull snapshot"); + assert_eq!(pull.state, rhema_forge_integration::ThreadState::Merged); + assert!(pull.is_pr); + assert_eq!(pull.title, "fix crash"); +} + +#[tokio::test] +async fn trailing_slashes_in_the_base_url_are_trimmed() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/acme/widgets/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_row(7, json!({})))) + .mount(&server) + .await; + + let forge = GitHubForge::new("tok").with_base_url(format!("{}/", server.uri())); + forge + .snapshot(&RepoRef::new("acme", "widgets"), 7) + .await + .expect("the trimmed base reaches the same path"); +} + +#[tokio::test] +async fn a_non_2xx_response_maps_to_an_api_error_with_the_body_message() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/acme/widgets/issues")) + .respond_with( + ResponseTemplate::new(404).set_body_string( + json!({ + "message": "Not Found", + "documentation_url": "https://docs.github.com/rest" + }) + .to_string(), + ), + ) + .mount(&server) + .await; + + let err = forge(&server) + .poll(&RepoRef::new("acme", "widgets"), None) + .await + .expect_err("404 is an error"); + match err { + ForgeError::Api { status, body } => { + assert_eq!(status, 404); + assert!(body.contains("Not Found"), "got: {body}"); + } + other => panic!("expected Api error, got: {other:?}"), + } +} + +#[tokio::test] +async fn a_transport_failure_maps_to_network() { + // An unroutable address: nothing is listening, so the connect fails. + let forge = GitHubForge::new("tok").with_base_url("http://127.0.0.1:1"); + let err = forge + .poll(&RepoRef::new("acme", "widgets"), None) + .await + .expect_err("connection refused is an error"); + assert!(matches!(err, ForgeError::Network(_)), "got: {err:?}"); +} + +#[tokio::test] +async fn notifications_poll_maps_reasons_and_subjects() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/notifications")) + .and(query_param("since", SINCE)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { + "id": 42188, + "unread": true, + "reason": "review_requested", + "updated_at": "2026-10-05T12:00:00Z", + "subject": { + "title": "Add the widget flange", + "url": "https://api.github.com/repos/acme/widgets/pulls/142", + "type": "PullRequest" + }, + "repository": { "full_name": "acme/widgets" } + }, + { + "id": 42189, + "unread": true, + "reason": "assign", + "updated_at": "2026-10-04T08:00:00Z", + "subject": { + "title": "Ship the barn door", + "url": "https://api.github.com/repos/acme/widgets/issues/7", + "type": "Issue" + }, + "repository": { "full_name": "acme/widgets" } + } + ]))) + .mount(&server) + .await; + + let since = Timestamp::parse(SINCE).expect("cursor parses"); + let page = forge(&server) + .notifications(Some(&since)) + .await + .expect("poll succeeds"); + + assert_eq!(page.notifications.len(), 2); + // Oldest first: the assignment predates the review request. + let assignment = &page.notifications[0]; + assert_eq!( + assignment.id, + "github:notification:42189@2026-10-04T08:00:00Z" + ); + assert_eq!(assignment.reason, NotificationReason::Assigned); + assert_eq!(assignment.number, 7); + assert!(!assignment.is_pr); + assert_eq!(assignment.repo, RepoRef::new("acme", "widgets")); + let review = &page.notifications[1]; + assert_eq!(review.id, "github:notification:42188@2026-10-05T12:00:00Z"); + assert_eq!(review.reason, NotificationReason::ReviewRequested); + assert_eq!(review.number, 142); + assert!(review.is_pr); + assert_eq!( + page.cursor, + Some(Timestamp::parse("2026-10-05T12:00:00Z").expect("parses")) + ); +} + +#[tokio::test] +async fn notifications_without_a_cursor_still_return_the_newest_seen() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/notifications")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { + "id": 7, + "reason": "mention", + "updated_at": "2026-10-02T00:00:00Z", + "subject": { + "title": "Hello", + "url": "https://api.github.com/repos/acme/widgets/issues/1", + "type": "Issue" + }, + "repository": { "full_name": "acme/widgets" } + } + ]))) + .mount(&server) + .await; + + let page = forge(&server) + .notifications(None) + .await + .expect("poll succeeds"); + assert_eq!(page.notifications.len(), 1); + assert_eq!(page.notifications[0].reason, NotificationReason::Mention); + assert_eq!( + page.cursor, + Some(Timestamp::parse("2026-10-02T00:00:00Z").expect("parses")) + ); +} diff --git a/integrations/rhema-forge-integration/tests/gitlab.rs b/integrations/rhema-forge-integration/tests/gitlab.rs new file mode 100644 index 0000000..4b16ae7 --- /dev/null +++ b/integrations/rhema-forge-integration/tests/gitlab.rs @@ -0,0 +1,523 @@ +//! Offline integration tests: the GitLab backend against an in-process +//! wiremock server. Zero network. + +use rhema_forge_integration::{ + EventKind, Forge, ForgeError, GitLabForge, NotificationReason, RepoRef, Timestamp, +}; +use serde_json::{Value, json}; +use wiremock::matchers::{header, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const SINCE: &str = "2026-10-01T00:00:00Z"; + +fn forge(server: &MockServer) -> GitLabForge { + GitLabForge::new("tok").with_base_url(server.uri()) +} + +/// An issues-feed row; `patch` overrides any field. +fn issue_row(iid: i64, patch: Value) -> Value { + with( + json!({ + "id": 4000 + iid, + "iid": iid, + "state": "opened", + "title": format!("thread {iid}"), + "author": { "username": "alice" }, + "labels": [], + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-01T09:00:00Z", + "closed_at": Value::Null, + "web_url": format!("https://gitlab.com/acme/widgets/-/issues/{iid}") + }), + patch, + ) +} + +/// A merge-requests-feed row; `patch` overrides any field. +fn mr_row(iid: i64, patch: Value) -> Value { + with( + json!({ + "id": 5000 + iid, + "iid": iid, + "state": "opened", + "title": format!("mr {iid}"), + "author": { "username": "bob" }, + "labels": [], + "created_at": "2026-10-01T08:00:00Z", + "updated_at": "2026-10-01T08:00:00Z", + "closed_at": Value::Null, + "web_url": format!("https://gitlab.com/acme/widgets/-/merge_requests/{iid}") + }), + patch, + ) +} + +/// A notes row; `patch` overrides any field. +fn note_row(id: i64, created_at: &str, patch: Value) -> Value { + with( + json!({ + "id": id, + "body": "looks broken", + "system": false, + "author": { "username": "carol" }, + "created_at": created_at, + "updated_at": created_at + }), + patch, + ) +} + +/// Shallow-merges `patch` into `base`. +fn with(base: Value, patch: Value) -> Value { + match (base, patch) { + (Value::Object(mut base), Value::Object(patch)) => { + for (key, value) in patch { + base.insert(key, value); + } + Value::Object(base) + } + (base, _) => base, + } +} + +#[tokio::test] +async fn poll_pages_via_x_next_page_and_fans_out_notes() { + let server = MockServer::start().await; + // The project path rides the URL percent-encoded. + // Mount order matters: the page-2 mock must match before the page-1 one. + Mock::given(method("GET")) + .and(path("/api/v4/projects/acme%2Fwidgets/issues")) + .and(query_param("page", "2")) + .and(query_param("updated_after", SINCE)) + .and(header("private-token", "tok")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([issue_row( + 4, + json!({ + "created_at": "2026-10-03T09:00:00Z", + "updated_at": "2026-10-07T09:00:00Z", + "closed_at": "2026-10-03T00:00:00Z" + }) + )]))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v4/projects/acme%2Fwidgets/issues")) + .and(query_param("updated_after", SINCE)) + .respond_with( + ResponseTemplate::new(200) + .insert_header("x-next-page", "2") + .set_body_json(json!([ + issue_row( + 7, + json!({ + "state": "closed", + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-02T10:00:00Z", + "closed_at": "2026-10-02T10:00:00Z" + }) + ), + issue_row( + 5, + json!({ + "created_at": "2026-10-03T08:00:00Z", + "updated_at": "2026-10-03T08:00:00Z" + }) + ) + ])), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v4/projects/acme%2Fwidgets/merge_requests")) + .and(query_param("updated_after", SINCE)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) + .mount(&server) + .await; + // Notes fan out per updated thread, in feed order. + for (iid, notes) in [ + (7, json!([note_row(701, "2026-10-02T11:00:00Z", json!({}))])), + (5, json!([])), + (4, json!([])), + ] { + Mock::given(method("GET")) + .and(path(format!( + "/api/v4/projects/acme%2Fwidgets/issues/{iid}/notes" + ))) + .and(query_param("sort", "asc")) + .and(query_param("order_by", "created_at")) + .respond_with(ResponseTemplate::new(200).set_body_json(notes)) + .mount(&server) + .await; + } + + let since = Timestamp::parse(SINCE).expect("cursor parses"); + let page = forge(&server) + .poll(&RepoRef::new("acme", "widgets"), Some(&since)) + .await + .expect("poll succeeds"); + + // Three thread events across the two pages (#7 closed, #5 opened, + // #4 reopened), plus the note on #7. + assert_eq!(page.events.len(), 4); + assert_eq!( + page.cursor, + Some(Timestamp::parse("2026-10-07T09:00:00Z").expect("parses")) + ); + + let received = server + .received_requests() + .await + .expect("request recording is on"); + // Issues pages 1+2, merge requests page 1, then three notes feeds. + assert_eq!(received.len(), 6); + assert!(received[0].url.as_str().contains("per_page=100")); + assert!(received[0].url.as_str().contains("order_by=updated_at")); + assert!(received[0].url.as_str().contains("sort=asc")); + assert!(received[1].url.as_str().contains("page=2")); + assert!(received[2].url.path() == "/api/v4/projects/acme%2Fwidgets/merge_requests"); + assert!(received[3].url.path() == "/api/v4/projects/acme%2Fwidgets/issues/7/notes"); + assert!(received[4].url.path() == "/api/v4/projects/acme%2Fwidgets/issues/5/notes"); + assert!(received[5].url.path() == "/api/v4/projects/acme%2Fwidgets/issues/4/notes"); +} + +#[tokio::test] +async fn poll_normalizes_state_kinds_and_notes_on_both_feeds() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v4/projects/acme%2Fwidgets/issues")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + // Closed, with GitLab's fractional-seconds updated stamp. + issue_row( + 7, + json!({ + "state": "closed", + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-02T10:00:00.081Z", + "closed_at": "2026-10-02T10:00:00Z" + }) + ), + issue_row( + 5, + json!({ + "created_at": "2026-10-03T08:00:00Z", + "updated_at": "2026-10-03T08:00:00Z" + }) + ), + issue_row( + 4, + json!({ + "created_at": "2026-10-01T09:00:00Z", + "updated_at": "2026-10-07T09:00:00Z", + "closed_at": "2026-10-03T00:00:00Z" + }) + ) + ]))) + .mount(&server) + .await; + // A merged MR and an MR updated without a transition (no thread event, + // but its new note still surfaces). + Mock::given(method("GET")) + .and(path("/api/v4/projects/acme%2Fwidgets/merge_requests")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + mr_row( + 3, + json!({ + "state": "merged", + "created_at": "2026-10-01T08:00:00Z", + "updated_at": "2026-10-06T12:00:00Z", + "closed_at": "2026-10-06T12:00:00Z" + }) + ), + mr_row( + 9, + json!({ + "created_at": "2026-10-04T08:00:00Z", + "updated_at": "2026-10-08T09:00:00Z" + }) + ) + ]))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v4/projects/acme%2Fwidgets/issues/7/notes")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + // System notes and stale notes are filtered. + note_row( + 700, + "2026-10-02T10:00:00Z", + json!({ + "body": "changed the description", "system": true + }) + ), + note_row(701, "2026-10-02T11:00:00Z", json!({})), + note_row(702, "2025-01-01T00:00:00Z", json!({ "body": "old" })) + ]))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v4/projects/acme%2Fwidgets/issues/5/notes")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v4/projects/acme%2Fwidgets/issues/4/notes")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path( + "/api/v4/projects/acme%2Fwidgets/merge_requests/3/notes", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([note_row( + 301, + "2026-10-06T14:00:00Z", + json!({}) + )]))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path( + "/api/v4/projects/acme%2Fwidgets/merge_requests/9/notes", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([note_row( + 901, + "2026-10-08T09:00:00Z", + json!({}) + )]))) + .mount(&server) + .await; + + let since = Timestamp::parse(SINCE).expect("cursor parses"); + let page = forge(&server) + .poll(&RepoRef::new("acme", "widgets"), Some(&since)) + .await + .expect("poll succeeds"); + + // Oldest → newest; system and stale notes are gone, and the updated + // MR without a transition still surfaces its note. + let summary: Vec<(EventKind, u64, bool)> = page + .events + .iter() + .map(|event| (event.kind, event.number, event.is_pr)) + .collect(); + assert_eq!( + summary, + vec![ + (EventKind::Closed, 7, false), + (EventKind::Comment, 7, false), + (EventKind::Opened, 5, false), + (EventKind::Merged, 3, true), + (EventKind::Comment, 3, true), + (EventKind::Reopened, 4, false), + (EventKind::Comment, 9, true), + ] + ); + // GitLab's fractional seconds survive verbatim in the dedup id. + assert_eq!( + page.events[0].id, + "gitlab:issue:4007@2026-10-02T10:00:00.081Z" + ); + assert_eq!(page.events[1].id, "gitlab:note:701"); + assert_eq!(page.events[3].id, "gitlab:mr:5003@2026-10-06T12:00:00Z"); + assert_eq!(page.events[5].actor.as_deref(), Some("alice")); + assert_eq!(page.events[6].actor.as_deref(), Some("carol")); + assert_eq!( + page.cursor, + Some(Timestamp::parse("2026-10-08T09:00:00Z").expect("parses")) + ); +} + +#[tokio::test] +async fn posting_a_comment_falls_back_from_issues_to_merge_requests() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/v4/projects/acme%2Fwidgets/issues/4/notes")) + .and(header("private-token", "tok")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 42 }))) + .mount(&server) + .await; + // An iid that is not an issue falls through to the MR feed. + Mock::given(method("POST")) + .and(path("/api/v4/projects/acme%2Fwidgets/issues/9/notes")) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({ + "message": "404 Issue Not Found" + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path( + "/api/v4/projects/acme%2Fwidgets/merge_requests/9/notes", + )) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 43 }))) + .mount(&server) + .await; + + let repo = RepoRef::new("acme", "widgets"); + forge(&server) + .comment(&repo, 4, "on it") + .await + .expect("the issue comment posts"); + forge(&server) + .comment(&repo, 9, "on the MR") + .await + .expect("the MR comment posts via the fallback"); + + let received = server + .received_requests() + .await + .expect("request recording is on"); + assert_eq!(received.len(), 3); + let first_body: Value = serde_json::from_slice(&received[0].body).expect("json body"); + assert_eq!(first_body, json!({ "body": "on it" })); + let last_body: Value = serde_json::from_slice(&received[2].body).expect("json body"); + assert_eq!(last_body, json!({ "body": "on the MR" })); + assert!(received[2].url.path() == "/api/v4/projects/acme%2Fwidgets/merge_requests/9/notes"); +} + +#[tokio::test] +async fn snapshot_prefers_issues_and_falls_back_to_merge_requests() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v4/projects/acme%2Fwidgets/issues/7")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(issue_row(7, json!({ "labels": ["bug", "critical"] }))), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v4/projects/acme%2Fwidgets/issues/3")) + .respond_with( + ResponseTemplate::new(404).set_body_json(json!({ "message": "404 Issue Not Found" })), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v4/projects/acme%2Fwidgets/merge_requests/3")) + .respond_with(ResponseTemplate::new(200).set_body_json(mr_row( + 3, + json!({ + "state": "merged", + "title": "fix crash", + "labels": ["bug"] + }), + ))) + .mount(&server) + .await; + + let repo = RepoRef::new("acme", "widgets"); + let issue = forge(&server) + .snapshot(&repo, 7) + .await + .expect("issue snapshot"); + assert_eq!(issue.state, rhema_forge_integration::ThreadState::Open); + assert!(!issue.is_pr); + assert_eq!(issue.labels, vec!["bug".to_owned(), "critical".to_owned()]); + + let mr = forge(&server) + .snapshot(&repo, 3) + .await + .expect("mr snapshot"); + assert_eq!(mr.state, rhema_forge_integration::ThreadState::Merged); + assert!(mr.is_pr); + assert_eq!(mr.title, "fix crash"); +} + +#[tokio::test] +async fn a_non_2xx_response_maps_to_an_api_error_with_the_body_message() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v4/projects/acme%2Fwidgets/issues")) + .respond_with( + ResponseTemplate::new(500) + .set_body_string(json!({ "message": "500 Internal Server Error" }).to_string()), + ) + .mount(&server) + .await; + + let err = forge(&server) + .poll(&RepoRef::new("acme", "widgets"), None) + .await + .expect_err("500 is an error"); + match err { + ForgeError::Api { status, body } => { + assert_eq!(status, 500); + assert!(body.contains("Internal Server Error"), "got: {body}"); + } + other => panic!("expected Api error, got: {other:?}"), + } +} + +#[tokio::test] +async fn a_transport_failure_maps_to_network() { + // An unroutable address: nothing is listening, so the connect fails. + let forge = GitLabForge::new("tok").with_base_url("http://127.0.0.1:1"); + let err = forge + .poll(&RepoRef::new("acme", "widgets"), None) + .await + .expect_err("connection refused is an error"); + assert!(matches!(err, ForgeError::Network(_)), "got: {err:?}"); +} + +#[tokio::test] +async fn todos_poll_maps_actions_targets_and_projects() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v4/todos")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { + "id": 501, + "action_name": "review_requested", + "target_type": "MergeRequest", + "target_url": "https://gitlab.com/acme/widgets/-/merge_requests/12", + "updated_at": "2026-10-05T12:00:00Z", + "project": { "path_with_namespace": "group/acme/widgets" }, + "target": { + "iid": 12, + "title": "Add the flange", + "author": { "username": "alice" } + } + }, + { + "id": 502, + "action_name": "assigned", + "target_type": "Issue", + "target_url": "https://gitlab.com/acme/widgets/-/issues/3", + "updated_at": "2026-10-04T08:00:00Z", + "project": { "path_with_namespace": "acme/widgets" }, + "target": { + "iid": 3, + "title": "Ship the barn door", + "author": { "username": "bob" } + } + } + ]))) + .mount(&server) + .await; + + let page = forge(&server) + .notifications(None) + .await + .expect("poll succeeds"); + + assert_eq!(page.notifications.len(), 2); + let assignment = &page.notifications[0]; + assert_eq!( + assignment.id, + "gitlab:notification:502@2026-10-04T08:00:00Z" + ); + assert_eq!(assignment.reason, NotificationReason::Assigned); + assert_eq!(assignment.repo, RepoRef::new("acme", "widgets")); + assert!(!assignment.is_pr); + assert_eq!(assignment.actor.as_deref(), Some("bob")); + let review = &page.notifications[1]; + assert_eq!(review.reason, NotificationReason::ReviewRequested); + // The namespace path is the owner, the project slug the name. + assert_eq!(review.repo, RepoRef::new("group/acme", "widgets")); + assert!(review.is_pr); + assert_eq!(review.number, 12); + assert_eq!( + review.url.as_deref(), + Some("https://gitlab.com/acme/widgets/-/merge_requests/12") + ); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..6422209 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "1.98.1" diff --git a/web/claw/.gitignore b/web/claw/.gitignore new file mode 100644 index 0000000..dacea97 --- /dev/null +++ b/web/claw/.gitignore @@ -0,0 +1,6 @@ +node_modules +.nuxt +.output +dist +.data +*.log diff --git a/web/claw/README.md b/web/claw/README.md new file mode 100644 index 0000000..a2aa0b1 --- /dev/null +++ b/web/claw/README.md @@ -0,0 +1,161 @@ +# rhema claw web + +A small, hand-rolled web client for the claw (`rhema-claw`): a live +transcript of one session, the session inventory, and a settings area +(model providers, about). Nuxt 4 + Vue 3 + TypeScript, no component +library, no store. It is strictly **one client** of the claw's public +`/v1` HTTP surface. + +## Run it against a locally running claw + +```sh +rhema-claw # the daemon, on 127.0.0.1:7799 +cd web/claw +npm install +npm run dev # http://localhost:3000 +``` + +In dev, the `server/middleware/claw-proxy.ts` middleware forwards +`/v1` and `/healthz` to `http://127.0.0.1:7799` (`NUXT_CLAW_UPSTREAM` +overrides), so the browser sees a same-origin API and CORS never enters +the picture. If the claw is not running, the page still loads: the +down/401/stream banners tell you what is wrong. + +### Token + +When the claw config sets `token:`, every `/v1` route wants +`Authorization: Bearer `. Give the UI a token either way: + +- the environment: `NUXT_PUBLIC_CLAW_TOKEN=... npm run dev` (runtime + config `clawToken`), or +- the inline field in the 401 banner; it is stored in `localStorage`. + +`/healthz` is open either way, which is how the header dot stays honest +when the token is wrong. + +## Build + +```sh +npm run build # nuxt generate -> .output/public, fully static +npm run typecheck # vue-tsc over the whole app +``` + +`npm run build` produces a static SPA in `.output/public`, the artifact +an ingress serves. The ingress owns routing: same-origin `/v1` and +`/healthz` to the claw. `runtimeConfig.public.clawBase` can point the +client at another base, but the claw sends no CORS headers, so +cross-origin use needs a proxy, not a different origin. + +## The one-consumer principle + +The claw's `/v1` API is the contract (`crates/rhema-claw/src/http.rs`): +sessions, messages, events, providers. This client uses only public +routes, speaks the wire shapes the Rust side serializes, and adds +nothing server-side. UI needs never justify private endpoints; they +justify claw API changes, argued in the Rust tree. + +Two consequences worth knowing before changing this code: + +- **The daemon owns the conversation.** `POST .../messages` is + fire-and-forget (202); the reply streams back on + `GET /v1/sessions/{key}/events`. The transcript here is a mirror of + the stream, and the stream opens with the claw's replay of the + session's recorded turns before the live feed — a reload and every + reconnect reset the mirror and rebuild from the replay, so the + transcript survives the browser, not just the claw. +- **SSE goes over fetch, not `EventSource`.** The claw's auth reads + only the `Authorization: Bearer` header (`http.rs` `require_auth`); it + has no query-param fallback, and `EventSource` cannot set headers. So + `useClaw().subscribeEvents` holds a `GET` with the auth header, reads + the body as a `ReadableStream`, and parses `data:` frames by hand + (`composables/useClaw.ts`). Drops retry with backoff; the hub's + `lagged` comments surface as notices. + +## What the transcript does with the stream + +- **Long pastes travel as chips.** A paste over three lines (the TUI's + `PASTE_INLINE_LINES`) never lands in the composer; it stages as a + droppable chip (`▣1 42L`) and embeds into the sent text as + U+FFFC-payload-U+FFFC — the same record `rhema-core`'s + `paste::embed` keeps — so the transcript renders each embed back as + a chip in place of the payload. An unpaired mark is literal text. +- **The agent can ask.** A `consent_request` event renders as an + inline card: kind `secret` gets a password input plus submit, kind + `phone` gets Approve/Deny, and both carry a deny link + (`DELETE /v1/consents/{id}`). The card flips when the answer lands + and again on the stream's `consent_resolved` echo; a reconnect's + replay rebuilds the cards, so nothing persists client-side. +- **Attachments render inline.** An `attachment` event becomes a card + — media glyph, file name, B/KB/MB size, caption. Images fetch the + bearer-guarded bytes (`GET /v1/attachments/{id}`, blob → object URL, + like the identity images) and render capped at 240px; code and file + cards download on click. Object URLs cache per id, so a replay never + refetches. +- **Sessions are created in place.** The sidebar's `+` form posts + `POST /v1/sessions` with a client-validated name + (`[A-Za-z0-9._-]{1,64}`), switches the chat to the new key, and + lets 400/409 land in the error banner. The phone client's status + keys (`phone:…`) stay out of the inventory; the full list stays on + the wire. + +## Layout + +The shell is an icon rail plus two views, switched by a tiny hash +router (`#/chat`, `#/settings/agent/general`, `#/settings/agent/ +accounts`, `#/settings/providers`, `#/settings/about`), no +vue-router. The chat view is the session inventory plus the live +transcript; the settings view is a searchable nav plus pages. The +agent's identity (name, mark, avatar) loads +once and renders everywhere — rail, sidebar, chat, window title — +through `AgentAvatar`, which owns the precedence: avatar image, then +mark or initials. Settings pages stage edits into a local draft and +apply them with an explicit Save (`PUT /v1/providers/active` answers +the new pair only; the client carries the provider list forward). +The providers page also manages the roster itself: the add form renders +from the `addable` kinds the claw serves; each kind's `fields` carry +`{name, required}`, `required` marking what the daemon refuses the login +without (llama.cpp needs neither of its fields, it serves keyless on a +default url); the form demands only those, marks the rest optional, and +takes an optional instance name; unnamed, the id is the kind. A login +is `PUT /v1/providers/{id}/credentials`: apply immediately, +credentials persist; roster rows are backend instances (`id`, the kind +unless the login named it, plus the credential's `source` +badge, env-backed kinds return on restart after a removal), and the +remove button (`DELETE /v1/providers/{id}`) refuses the last backend +claw-side. + +``` +app/ + app.vue the shell: rail, banners, view switch + assets/css/main.css the Jadeite tokens (matches the TUI's + palette.rs DARK) + card/badge primitives + components/ + AppRail.vue the icon rail: views, claw pulse + AppBadge.vue the status/tag pill + AppCallout.vue the toned inline note (info/ok/warn/error) + AppMarkdown.vue agent replies as markdown (markdown-it, + raw HTML escaped, styles in main.css .md) + ClawBanner.vue the one error/notice banner surface + ChatPanel.vue transcript: sent lines (paste chips), + streamed turns as bash shell lines and + glyph rows, consent + attachment + cards; the growing composer; agent + replies render through AppMarkdown + SessionsSidebar.vue conversable dispatch keys (phone status + keys hidden), interactive first; the + + form creates a session + PageHeader.vue title + subtitle + page actions + SettingCard.vue one titled card of rows + SettingRow.vue name + description + control + SettingsNav.vue searchable settings nav + StatusDot.vue one honest state dot + settings/ + ProvidersPage.vue the backend switch: draft → Save/Discard + AboutPage.vue identity, stack, the routes spoken + composables/ + useClaw.ts the typed API client + fetch-based SSE + useTranscript.ts folds TurnEvents into renderable turn + blocks; owns the paste-embed text form +server/ + middleware/claw-proxy.ts dev-only same-origin proxy to a running claw +``` diff --git a/web/claw/app/app.vue b/web/claw/app/app.vue new file mode 100644 index 0000000..ee17380 --- /dev/null +++ b/web/claw/app/app.vue @@ -0,0 +1,701 @@ + + + + + diff --git a/web/claw/app/assets/css/main.css b/web/claw/app/assets/css/main.css new file mode 100644 index 0000000..65170c9 --- /dev/null +++ b/web/claw/app/assets/css/main.css @@ -0,0 +1,395 @@ +/* + * The Jadeite tokens, dark theme — lifted token for token from + * crates/rhema-code/src/palette.rs `DARK` so the web claw reads as the + * same tool as the TUI. The ground is deliberately jade-tinted, not + * slate; saturated color stays on the accent and the status set. + */ +:root { + --ground: #0f1413; + --surface: #171d1b; + --elevated: #1f2624; + --sunken: #0a0f0e; + --border: #28312e; + --border-strong: #3d4844; + --text: #eef3f1; + --text2: #b9c6c1; + --text3: #87948f; + --accent: #35a882; + --accent-text: #64c9a3; + --on-accent: #07110d; + --running: #64c9a3; + --ok: #4fbf94; + --warn: #d9a643; + --error: #e4687a; + --info: #8fa6b8; + + --sans: ui-sans-serif, system-ui, 'Segoe UI', Roboto, 'Helvetica Neue', + Arial, sans-serif; + --mono: ui-monospace, 'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, + Consolas, monospace; +} + +* { + box-sizing: border-box; +} + +html, +body, +#__nuxt { + height: 100%; +} + +body { + margin: 0; + background: var(--ground); + color: var(--text); + font-family: var(--sans); + font-size: 13.5px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +/* Values, keys, and anything the claw itself says render mono; the UI + chrome around them renders sans. */ +code, +pre, +.mono { + font-family: var(--mono); + font-size: 0.93em; +} + +/* Section labels: the one repeated typographic voice across panels. */ +.label { + font-family: var(--sans); + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.13em; + text-transform: uppercase; + color: var(--text3); +} + +/* --- controls ------------------------------------------------------- */ + +button { + font: inherit; + color: var(--text); + background: var(--elevated); + border: 1px solid var(--border-strong); + border-radius: 6px; + padding: 5px 14px; + cursor: pointer; +} + +button:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent-text); +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +button.primary { + background: var(--accent); + border-color: var(--accent); + color: var(--on-accent); + font-weight: 600; +} + +button.primary:hover:not(:disabled) { + background: var(--accent-text); + border-color: var(--accent-text); + color: var(--on-accent); +} + +button.ghost { + background: none; + border-color: transparent; + color: var(--text3); +} + +button.ghost:hover:not(:disabled) { + color: var(--accent-text); +} + +input, +select, +textarea { + font: inherit; + color: var(--text); + background: var(--sunken); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 10px; +} + +input:hover, +select:hover, +textarea:hover { + border-color: var(--border-strong); +} + +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +button:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 0; +} + +input::placeholder, +textarea::placeholder { + color: var(--text3); +} + +/* --- surfaces ------------------------------------------------------- */ + +/* The one card: a titled container of setting rows or static facts. */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} + +.card > .card-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.card > .card-head .title { + font-weight: 700; + font-size: 14px; +} + +.card > .card-head .meta { + color: var(--text3); + font-size: 12px; + min-width: 0; + text-align: right; +} + +.card > .card-body { + padding: 0; +} + +/* Rows inside a card body separate by hairline, not by gaps. */ +.card > .card-body > * + * { + border-top: 1px solid var(--border); +} + +.card > .card-body.flush { + padding: 8px 16px; +} + +.card > .card-body.flush > * + * { + border-top: none; +} + +/* --- badges ---------------------------------------------------------- */ + +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 1px 8px; + border-radius: 999px; + border: 1px solid var(--border-strong); + color: var(--text3); + font-size: 11px; + font-family: var(--mono); + white-space: nowrap; +} + +.badge.accent { + border-color: color-mix(in srgb, var(--accent) 55%, transparent); + color: var(--accent-text); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +.badge.ok { + border-color: color-mix(in srgb, var(--ok) 55%, transparent); + color: var(--ok); +} + +.badge.warn { + border-color: color-mix(in srgb, var(--warn) 55%, transparent); + color: var(--warn); +} + +.badge.error { + border-color: color-mix(in srgb, var(--error) 55%, transparent); + color: var(--error); +} + +/* --- banners --------------------------------------------------------- */ + +/* Banners: the single inline error/notice surface. Never toasts, never + console-only — claw down, 401, and stream drops all land here. */ +.banner { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border: 1px solid var(--border-strong); + border-left: 3px solid var(--info); + border-radius: 6px; + background: var(--surface); + color: var(--text2); +} + +.banner.error { + border-left-color: var(--error); + color: var(--text); +} + +.banner.warn { + border-left-color: var(--warn); +} + +.banner .banner-text { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; +} + +/* --- markdown -------------------------------------------------------- */ + +/* Rendered agent replies (AppMarkdown): markdown-it output mounted with + v-html, so only generated tags show up here. Blocks separate by small + margins; headings stay close to body size, a chat reply not a page. */ +.md > :first-child { + margin-top: 0; +} + +.md > :last-child { + margin-bottom: 0; +} + +.md p, +.md ul, +.md ol, +.md pre, +.md blockquote, +.md table { + margin: 0 0 8px; +} + +.md ul, +.md ol { + padding-left: 20px; +} + +.md li { + margin: 2px 0; +} + +.md li > ul, +.md li > ol { + margin-bottom: 0; +} + +.md h1, +.md h2, +.md h3, +.md h4, +.md h5, +.md h6 { + margin: 14px 0 6px; + font-weight: 700; + line-height: 1.3; +} + +.md h1 { + font-size: 16px; +} + +.md h2 { + font-size: 15px; +} + +.md h3 { + font-size: 14px; +} + +.md h4, +.md h5, +.md h6 { + font-size: 13.5px; +} + +.md a { + color: var(--accent-text); +} + +.md code { + background: var(--sunken); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 5px; +} + +.md pre { + padding: 10px 12px; + background: var(--sunken); + border: 1px solid var(--border); + border-radius: 6px; + overflow-x: auto; +} + +/* The global code rule shrinks em-relative twice inside a pre; undo it. */ +.md pre code { + background: none; + border: none; + padding: 0; + font-size: inherit; +} + +.md blockquote { + padding: 2px 12px; + border-left: 3px solid var(--border-strong); + color: var(--text2); +} + +.md hr { + border: none; + border-top: 1px solid var(--border); +} + +.md table { + border-collapse: collapse; +} + +.md th, +.md td { + border: 1px solid var(--border-strong); + padding: 3px 10px; + text-align: left; +} + +.md th { + background: var(--surface); +} + +.md img { + max-width: 100%; +} + +/* While the turn streams, the cursor rides the end of the last block. */ +.md.streaming > :last-child::after { + content: '▊'; + margin-left: 1px; + color: var(--accent-text); + animation: md-blink 1.1s steps(1) infinite; +} + +@keyframes md-blink { + 50% { + opacity: 0; + } +} diff --git a/web/claw/app/components/AgentAvatar.vue b/web/claw/app/components/AgentAvatar.vue new file mode 100644 index 0000000..a1973eb --- /dev/null +++ b/web/claw/app/components/AgentAvatar.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/web/claw/app/components/AppBadge.vue b/web/claw/app/components/AppBadge.vue new file mode 100644 index 0000000..4e2b778 --- /dev/null +++ b/web/claw/app/components/AppBadge.vue @@ -0,0 +1,11 @@ + + + diff --git a/web/claw/app/components/AppCallout.vue b/web/claw/app/components/AppCallout.vue new file mode 100644 index 0000000..8e53eae --- /dev/null +++ b/web/claw/app/components/AppCallout.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/web/claw/app/components/AppMarkdown.vue b/web/claw/app/components/AppMarkdown.vue new file mode 100644 index 0000000..f98af6b --- /dev/null +++ b/web/claw/app/components/AppMarkdown.vue @@ -0,0 +1,36 @@ + + + diff --git a/web/claw/app/components/AppModal.vue b/web/claw/app/components/AppModal.vue new file mode 100644 index 0000000..0432d62 --- /dev/null +++ b/web/claw/app/components/AppModal.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/web/claw/app/components/AppRail.vue b/web/claw/app/components/AppRail.vue new file mode 100644 index 0000000..daffa8f --- /dev/null +++ b/web/claw/app/components/AppRail.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/web/claw/app/components/ChatPanel.vue b/web/claw/app/components/ChatPanel.vue new file mode 100644 index 0000000..d982097 --- /dev/null +++ b/web/claw/app/components/ChatPanel.vue @@ -0,0 +1,685 @@ + + +