feat(rhema-code): add the colors setting with four palettes
The stored `colors` key names a palette — rhema (the default, Jadeite), aquamarine, black-and-white, or dracula — and the Appearance page of /settings cycles it live, like the theme and icon toggles beside it. Each palette carries a dark and a light appearance under the existing `theme` key; dracula's light side is derived, the official palette ships only dark. The appearance decision moves off token comparison: Theme now answers is_dark() from the ground/text luminance, so palettes beyond the original two get the right per-profile fallback tones.
This commit is contained in:
commit
81da26b955
159 files changed
+50628
No files matched your search
@@ -0,0 +1,82 @@
|
||||
[package]
|
||||
name = "rhema-claw"
|
||||
description = "The resident agent daemon: forge watches and the chat HTTP surface, the first consumer of rhema-autonomous-agent"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
# The resident tier is still indev: `cargo publish --workspace` skips
|
||||
# it until the claw ships.
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "rhema_claw"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "rhema-claw"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
# The endless canned provider for `--mock` debug runs; nothing ships in
|
||||
# a real build.
|
||||
mock = ["rhema-core/mock"]
|
||||
|
||||
[dependencies]
|
||||
# The shared toolkit: the resident agent assembles its agent from the
|
||||
# shell, the approval seam, paste expansion, and revive-on-resume
|
||||
rhema-agent-tools = { path = "../rhema-agent-tools" }
|
||||
rhema-autonomous-agent = { path = "../rhema-autonomous-agent" }
|
||||
rhema-coding-agent = { path = "../rhema-coding-agent" }
|
||||
rhema-config = { path = "../rhema-config" }
|
||||
rhema-core = { path = "../rhema-core" }
|
||||
rhema-calendar-integration = { path = "../../integrations/rhema-calendar-integration" }
|
||||
rhema-forge-integration = { path = "../../integrations/rhema-forge-integration" }
|
||||
# The accounts layer: identities hold the forge tokens the watches poll
|
||||
# with, and the identity tools manage them over chat
|
||||
rhema-identity = { path = "../rhema-identity" }
|
||||
rhema-provider-anthropic = { path = "../rhema-provider-anthropic" }
|
||||
rhema-provider-copilot = { path = "../rhema-provider-copilot" }
|
||||
rhema-provider-google = { path = "../rhema-provider-google" }
|
||||
rhema-provider-llama-cpp = { path = "../rhema-provider-llama-cpp" }
|
||||
rhema-provider-openai-compatible = { path = "../rhema-provider-openai-compatible" }
|
||||
rhema-provider-openrouter = { path = "../rhema-provider-openrouter" }
|
||||
rhema-rsd = { path = "../rhema-rsd" }
|
||||
# The resident tier's durable memory: its tools join the resolver chain
|
||||
rhema-memory = { path = "../rhema-memory" }
|
||||
# The shared ModelCard the backend registry's listings return
|
||||
rhema-provider = { path = "../rhema-provider" }
|
||||
# Args for the two-flag binary surface
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
# Typed public errors
|
||||
thiserror = { workspace = true }
|
||||
# A daemon: the runtime, the poll sleep, the ctrl-c signal; the driver's
|
||||
# concurrent handler runs share it. `net` is the HTTP listener.
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "time", "sync", "net"] }
|
||||
# The chat surface: native routes plus SSE, and the OpenAI shim
|
||||
axum = { workspace = true }
|
||||
# Broadcast receivers as SSE bodies
|
||||
tokio-stream = { workspace = true }
|
||||
# The watch table: a second connection to the same SQLite file as the
|
||||
# dispatcher's store — the two-connections pattern rhema-rsd's tests use
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
# RFC3339 parsing for schedule_task's `at`
|
||||
time = { version = "0.3", default-features = false, features = ["parsing"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
# In-process HTTP mocks for the offline e2e: zero network
|
||||
wiremock = "0.6"
|
||||
# The HTTP client for the surface tests, SSE bodies included
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
# Naming the SSE chunks' type in the surface tests
|
||||
bytes = "1"
|
||||
# The scripted provider for turn-level tests; mirrors rhema-code's mock
|
||||
# feature without a feature of our own — tests are the only consumer
|
||||
rhema-core = { path = "../rhema-core", features = ["mock"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||
@@ -0,0 +1,16 @@
|
||||
## Setup
|
||||
|
||||
This is a first conversation: SOUL.md or USER.md is still unwritten.
|
||||
Offer a short setup — who they are, what this machine is for, who you
|
||||
should be — and let them decline; never interrogate. Ask at most a few
|
||||
questions, spread across turns.
|
||||
|
||||
Write SOUL.md (your name, tone, purpose) and USER.md (who the user
|
||||
is) with the bootstrap_save tool as facts arrive; incrementally is
|
||||
fine. Set what you learn about your own presentation with the
|
||||
identity_set tool as well — your name, a mark or initials, a tagline.
|
||||
An avatar only if the user hands you an image file; they can also
|
||||
upload one in the web interface's settings, and saying so beats
|
||||
hunting one down. Never invent facts the user did not give.
|
||||
|
||||
When both files exist, say setup is done and stop offering.
|
||||
@@ -0,0 +1,275 @@
|
||||
# Rhema Claw
|
||||
|
||||
Still Work-In-Progress.
|
||||
|
||||
Rhema Claw is an autonomous personal agent ("claw"), built on the Rhema agent framework.
|
||||
|
||||
## Features
|
||||
|
||||
## The claw: `rhema-claw`
|
||||
|
||||
The resident agent. One daemon owns every session in one SQLite store:
|
||||
yours (`interactive:<name>`, keyed per chat surface) and every watched
|
||||
forge thread's. Talk to it over HTTP; it watches repos, answers forge
|
||||
threads with coding-agent turns, and configures its own watches when
|
||||
asked in chat (`watch_repo`/`unwatch_repo`/`list_watches` are tools the
|
||||
model calls like any other).
|
||||
|
||||
```sh
|
||||
rhema-claw # serve on 127.0.0.1:7799 (config: listen)
|
||||
rhema-claw --once # one detect + one dispatch pass, then exit
|
||||
```
|
||||
|
||||
### Coding jobs: the `coding_agent` tool
|
||||
|
||||
The claw is not the only agent it runs. The `coding_agent` tool drives
|
||||
a full rhema-coding-agent session — its own shell, skills, and step
|
||||
budget — in a named workspace, with one tool and an `action` argument:
|
||||
|
||||
- `add` pins a name to an existing directory (`rhema` → `~/rhema`).
|
||||
The instance list is the audit surface for which checkouts the model
|
||||
can drive a coding agent in; `drop` removes one, refused while a job
|
||||
is live.
|
||||
- `run` enqueues a job. Async by design: a coding run is minutes of
|
||||
tool rounds, so the call answers with the job id in one model round
|
||||
and the turn runs off the chat — its own dispatcher, `job:` keys
|
||||
with forge-style leases, the same commit-before-execute discipline.
|
||||
The instance's coding session carries to the next run unless
|
||||
`fresh`; the final text lands in the job row and the user's
|
||||
notifications.
|
||||
- `list` names the instances, each with its live job at a glance;
|
||||
`status` reads jobs — every recent one, one instance's, or one
|
||||
job's result.
|
||||
- One live job per instance: two coding agents in one workspace is
|
||||
two agents editing the same files. Across instances, jobs run in
|
||||
parallel.
|
||||
|
||||
For the debug loop, daemon plus UI against throwaway state and zero
|
||||
credentials: `scripts/dev.sh --mock` (canned streamed replies; the UI
|
||||
is at `localhost:3000`, the claw at `127.0.0.1:7799`). Drop `--mock`
|
||||
and set the provider's key env var (`CLAW_DEV_PROVIDER` picks the
|
||||
provider) to debug against a real model.
|
||||
|
||||
```yaml
|
||||
# ~/.config/rhema/claw.yaml
|
||||
provider: anthropic
|
||||
model: claude-sonnet-4-5
|
||||
listen: 127.0.0.1:7799
|
||||
token: change-me # optional; when set, /v1/* requires it
|
||||
poll_interval_secs: 120
|
||||
max_turn_steps: 2048 # tool rounds one chat turn may run
|
||||
watches: # a seed: imported while the table is empty
|
||||
- forge: github
|
||||
repo: acme/widgets
|
||||
calendars: # feeds the claw reminds about
|
||||
- name: home # the reminder session is calendar:home
|
||||
kind: ics # an ics/webcal feed (Google's secret iCal
|
||||
url: https://cloud.example.net/remote.php/dav/calendars/personal.ics
|
||||
lead_minutes: [60, 15] # default [15]; horizon_hours default 24
|
||||
- name: cloud
|
||||
kind: caldav # a CalDAV collection (REPORT; Nextcloud,
|
||||
url: https://cloud.example.net/remote.php/dav/calendars/me/personal/
|
||||
username: me # Radicale); password falls back to
|
||||
password: app-pass # CALDAV_PASSWORD; token: = bearer instead
|
||||
- name: work
|
||||
kind: google # the Google Calendar API
|
||||
calendar_id: primary
|
||||
api_key: pub-key # or token: (OAuth; env fallback CALENDAR_TOKEN)
|
||||
```
|
||||
|
||||
`max_turn_steps` caps one turn's tool rounds (the coding driver's own
|
||||
budget shape, and the same default, 2048): the claw is meant to work
|
||||
unattended for a long time, so a cap this deep is a runaway guard, not
|
||||
a work timer — a turn that hits it ends with a failed outcome and the
|
||||
conversation intact; a follow-up message keeps going.
|
||||
|
||||
The surface refuses to start on a non-loopback `listen` with no
|
||||
`token`: anything beyond loopback can reach the port, and the bearer
|
||||
token is what stands between it and the agent's tools. Set a token, or
|
||||
bind a loopback address. Resident sessions run the coding agent with
|
||||
its approval seam auto-approved: tool calls, the shell included,
|
||||
execute without a prompt. The daemon says so at startup, and the
|
||||
bearer-gated surface plus the watch list are the trust boundary.
|
||||
|
||||
The native surface:
|
||||
|
||||
```
|
||||
GET /healthz GET /v1/sessions
|
||||
POST /v1/sessions {"name"} — create an empty interactive session
|
||||
POST /v1/sessions/{key}/messages {"text": "..."} -> 202
|
||||
GET /v1/sessions/{key}/events SSE turn events (see below)
|
||||
GET /v1/watches POST /v1/watches DELETE /v1/watches/{forge}/{repo}
|
||||
GET /v1/bootstrap {"soul": bool, "user": bool}
|
||||
POST /v1/ingest {"source","key","payload"} -> 202
|
||||
GET /v1/notifications?device=&since= POST /v1/notifications/{id}/ack
|
||||
GET /v1/consents the asks a turn is waiting on
|
||||
POST /v1/consents/{id} {"secret"} or {"grant": bool}
|
||||
DELETE /v1/consents/{id} deny a pending ask
|
||||
GET /v1/attachments/{id} an attached file's bytes
|
||||
GET /v1/phone/commands?device= the device's queued, unconfirmed commands
|
||||
POST /v1/phone/commands/{id}/result {"ok", "detail"?} — the device's report
|
||||
GET /v1/providers the backend instances + the active pair
|
||||
PUT /v1/providers/active {"provider","model"?} — switch, persisted
|
||||
PUT /v1/providers/{id}/credentials add or replace an instance, persisted
|
||||
DELETE /v1/providers/{id} remove one (the last is refused)
|
||||
GET /v1/providers/{id}/models the live model listing
|
||||
```
|
||||
|
||||
### Identity and first-run
|
||||
|
||||
The claw's prompt is assembled per turn from an in-binary base plus
|
||||
three identity files under the state dir's `bootstrap/` (config:
|
||||
`bootstrap_root`): **SOUL.md**, who the claw is, tone and purpose,
|
||||
written with you; **USER.md**, the always-visible summary of who you
|
||||
are; **ENVIRONMENT.md**, host, shell, and tool inventory, generated
|
||||
deterministically at every boot (detection is code, not conversation;
|
||||
hand edits are overwritten). The base renders the dispatch's own tool
|
||||
roster too — every tool's name, description, and call schema, straight
|
||||
from the resolver chain, so what the prompt lists is what the request
|
||||
advertises. On first run the claw *offers* a short
|
||||
setup in chat: who you are, what the machine is for, who it should be.
|
||||
It writes the first two files itself through the `bootstrap_save`
|
||||
tool as you answer, and — through the `identity_set` tool — sets its
|
||||
own presentation the same way: its name, a mark or tagline, an avatar
|
||||
from an image you hand it. The same tool is yours to invoke any time
|
||||
("call yourself something else"); it is the same store the settings
|
||||
pages write, so what it sets is what every client renders. Decline
|
||||
and it simply keeps offering, never
|
||||
interrogating. Both files stay hand-editable forever; `/v1/bootstrap`
|
||||
lets a UI badge setup state.
|
||||
|
||||
SSE frames are JSON, `data: {..}`: `user_message`, `thinking_delta`,
|
||||
`text_delta`, `tool_start`, `tool_result`, `turn_done`. A subscription
|
||||
opens with the session's recorded turns replayed first and the live feed
|
||||
after, cut atomically daemon-side — every streamed event is persisted as
|
||||
it streams, so both a reload and a daemon restart rebuild the whole
|
||||
transcript, not a recent window; a session that runs for weeks scrolls
|
||||
back to its first message. A chat UI subscribes once,
|
||||
POSTs messages, and resets its mirror whenever the stream (re)opens,
|
||||
letting the replay rebuild it; the daemon owns the conversation, so the
|
||||
client mirrors the stream rather than managing history. The OpenAI shim
|
||||
and the message POST read the live feed only — replay never reaches a
|
||||
turn that is starting.
|
||||
|
||||
Every provider whose credentials resolve (env, the claw's own stored
|
||||
logins, or the stored rhema config) is constructed at boot as a backend
|
||||
*instance* named by an id, the kind unless a login named it, so two
|
||||
llama.cpp servers (or two keys of one cloud) coexist under two names.
|
||||
One is active, and switching with `PUT /v1/providers/active` is runtime
|
||||
state persisted in SQLite: the
|
||||
config's `provider`/`model` pair is only the seed, and when the config
|
||||
names no provider the first credential discovery actually finds (a
|
||||
stored login, an env key, or the stored rhema config) is active; the
|
||||
keyless llama.cpp fallback never wins a seed, so the chart pins
|
||||
nothing and a switch never needs a restart.
|
||||
|
||||
The claw registers RSD sources four ways: watches (forge), calendar
|
||||
reminders (`calendars:` in the config; each tick polls every feed for
|
||||
the occurrences starting within its horizon, and an upcoming event
|
||||
warns once per `lead_minutes` on its persistent `calendar:<name>`
|
||||
session, a turn whose final text auto-notifies the phone; recurring
|
||||
series arrive expanded, overrides and cancellations applied, all-day
|
||||
events stay silent), scheduled
|
||||
tasks the agent creates in chat (`schedule_task` with `at` or
|
||||
`every_secs`; a due task dispatches through the resident agent — into
|
||||
the session the tool named, its own `task:<id>` slot by default — and
|
||||
a reply on a chat-less key auto-notifies the phone; a task whose
|
||||
`session` names a chat conversation wakes *that* session with its full
|
||||
context, so "pick this back up at 10 am" or "before my meeting,
|
||||
summarize X" is one tool call, with `notify` reaching the user away
|
||||
from the chat), and a generic door,
|
||||
`POST /v1/ingest`, that any external emitter addresses
|
||||
(`phone:<device>:<topic>`
|
||||
keys from the Android app, for example). The resident agent owns
|
||||
`interactive:`, `task:`, `phone:`, and `calendar:` keys; forge threads
|
||||
own the rest. Its `notify` tool queues a notification for a device;
|
||||
the device polls and acks.
|
||||
|
||||
When a turn needs something only a human can give, it asks and waits
|
||||
inside the tool call: `ask_secret` raises a consent request — a card in
|
||||
the chat stream (`consent_request` over SSE, plus `GET /v1/consents`
|
||||
for a UI catching up after a reload) — and the human answers through
|
||||
`POST /v1/consents/{id}` (`{"secret": ...}`) or denies with `DELETE`.
|
||||
The secret travels once, from the answer into the agent accounts; the
|
||||
tool wakes with the account id, never the secret, and a denial or a
|
||||
15-minute silence comes back in-band so the turn can say so and move
|
||||
on. Acting with a stored account is `identity_exec`: it runs one shell
|
||||
command in the session workspace with the secret injected —
|
||||
`ACCOUNT_SECRET` plus the service's own `GITHUB_TOKEN`/`GITEA_TOKEN`/
|
||||
`GITLAB_TOKEN`, and a git credential helper — so `git clone` and API
|
||||
calls work while the credential itself never reaches the transcript. A turn can also hand files over: `attach_file` copies a workspace
|
||||
file (25 MiB cap, workspace-relative paths only) into the attachment
|
||||
store beside the database and puts an `attachment` card on the stream;
|
||||
the bytes are fetchable at `GET /v1/attachments/{id}` with the content
|
||||
type the file's name implies, so a screenshot, a diff, or a report
|
||||
reaches the chat as something to view or download rather than a
|
||||
truncated snippet.
|
||||
|
||||
The phone is reachable too. Its status reports (`/v1/ingest`, source
|
||||
`phone`, keys `phone:<device>:<topic>`) are conversation *and* state:
|
||||
the payload lands in the phone tables, so the `phone` tool's
|
||||
`status`/`devices` actions read the latest battery, charging, network,
|
||||
and DND observations without asking anyone. Acting on the phone —
|
||||
`dnd_on`, `dnd_off`, `alert` — is consent-gated end to end: the tool
|
||||
raises a consent card, the human approves or denies, and only then does
|
||||
the command join `GET /v1/phone/commands?device=`, the poll list the
|
||||
device executes from and reports on via `POST
|
||||
/v1/phone/commands/{id}/result`. The device confirms on its own clock
|
||||
(the 15-minute WorkManager floor), so an answer may honestly be
|
||||
"queued, not yet confirmed".
|
||||
|
||||
Stock OpenAI-speaking UIs point at the shim instead:
|
||||
|
||||
```sh
|
||||
curl http://127.0.0.1:7799/v1/chat/completions -H 'Content-Type: application/json' -d '{
|
||||
"model": "claw", "user": "web", "stream": true,
|
||||
"messages": [{"role": "user", "content": "what are you watching?"}]
|
||||
}'
|
||||
```
|
||||
|
||||
The shim reads only the last user message (`user` picks the session,
|
||||
default `web`), maps thinking deltas to `reasoning_content`, and
|
||||
streams standard chat-completions chunks. There is one notion of
|
||||
session, the daemon's, behind a wire format every web UI already speaks.
|
||||
|
||||
## Deploying the claw
|
||||
|
||||
```sh
|
||||
docker build -f deploy/docker/claw.Dockerfile -t rhema-claw .
|
||||
helm install claw deploy/helm/rhema-claw \
|
||||
--set token=$(openssl rand -hex 24) \
|
||||
--set secret.stringData.ANTHROPIC_API_KEY=... \
|
||||
--set secret.stringData.GITHUB_TOKEN=... \
|
||||
--set config.provider=anthropic --set config.model=claude-sonnet-4-5
|
||||
```
|
||||
|
||||
Expose it with the chart's optional ingress (`ingress.enabled` +
|
||||
`ingress.host`, TLS strongly recommended, bearer tokens cross it) or
|
||||
keep it cluster-private behind Tailscale; the claw is bearer-gated
|
||||
either way. With the chart's UI on (`ui.enabled`, the default), the
|
||||
same host serves both: the browser gets the web UI on `/`, the daemon
|
||||
answers `/v1` and `/healthz`; one address for the browser and the
|
||||
phone app, no CORS anywhere.
|
||||
|
||||
The image is the daemon plus `bash` and `git` (the agent's shell is
|
||||
load-bearing); the chart runs exactly one replica, the claw is
|
||||
single-node by design: one SQLite store, one dispatcher. Single-node
|
||||
is what makes restarts instant: at boot the daemon releases every
|
||||
lease the previous process left — outright, not at the dead lease's
|
||||
TTL — so a turn an update cut mid-flight answers its next message
|
||||
instead of stalling behind a ghost (the committed tool call revives
|
||||
at the top of that next dispatch). The
|
||||
store and workspaces live on a PVC, `claw.yaml` in a ConfigMap (db and
|
||||
workspace paths are pinned to the mounted volume), credentials in a
|
||||
Secret, and the bearer token is the `token` value, required since the
|
||||
daemon binds all interfaces in-cluster. `persistence.enabled=false`
|
||||
trades restart survival for disposability.
|
||||
|
||||
The UI in `web/claw` is one client of the same surface: `npm run dev`
|
||||
proxies to a local claw, `npm run build` emits the static bundle an
|
||||
ingress serves next to `/v1`. The phone is another. `android/` is the
|
||||
Kotlin/Compose client: chat over the same SSE stream, phone status
|
||||
(battery, charging, network, DND transitions) posted to `/v1/ingest`
|
||||
as `phone:<device>:<topic>` events, and agent notifications polled
|
||||
onto the lock screen (no push infrastructure; a 15-minute WorkManager
|
||||
floor, live SSE while the app is open). See `android/README.md`.
|
||||
@@ -0,0 +1,49 @@
|
||||
You are the resident agent on this machine. Your SOUL defines who you
|
||||
are; until it is written you are rhema-claw, freshly installed. This
|
||||
session's key is {{ session }}. You
|
||||
answer chat here; your shell runs in the workspace at {{ workspace }}:
|
||||
read code, run tests, make edits there. Keep replies terse and
|
||||
concrete; do the work with tools instead of narrating it. Your
|
||||
final message, the one you end the turn on with no tool calls after
|
||||
it, is the reply the user sees.
|
||||
|
||||
{{ soul }}
|
||||
|
||||
{{ user }}
|
||||
|
||||
{{ environment }}
|
||||
|
||||
{% if tools %}
|
||||
You act through these tools (name | description | arguments). The
|
||||
arguments are the JSON schema a call must satisfy.
|
||||
|
||||
{% for tool in tools %}
|
||||
- {{ tool.name }} | {{ tool.description }} | {{ tool.parameters | tojson }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
You manage your own watch list with the watch_repo, unwatch_repo, and
|
||||
list_watches tools: a watched repository's activity (issues, pull
|
||||
requests, comments) arrives as its own dispatch.
|
||||
|
||||
You can reach the user away from this chat with the notify tool: it
|
||||
queues a notification (title, body, device; the phone by default)
|
||||
for their device to pick up. You can also schedule your own future
|
||||
turns with the schedule_task, list_tasks, and cancel_task tools: a
|
||||
task's prompt runs as one of your turns at a given time (at, an
|
||||
RFC3339 timestamp or epoch milliseconds) or on an interval
|
||||
(every_secs); set exactly one. A task with no session runs cold and
|
||||
its final message reaches the user as a notification; give session
|
||||
(this session is {{ session }}) to wake that conversation instead, with
|
||||
all of its context — pair it with notify when the user must see the
|
||||
result away from the chat.
|
||||
|
||||
You keep persistent memory across sessions. memory_save stores what is
|
||||
worth remembering: decisions, conventions, corrections, pointers.
|
||||
memory_search recalls it; search before asking the user for something
|
||||
they have likely said before. memory_forget removes a record on
|
||||
request. Never save secrets into memory.
|
||||
|
||||
{% if onboarding %}
|
||||
{{ onboarding }}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,862 @@
|
||||
//! The identity tools: `identity_save`, `identity_list`,
|
||||
//! `identity_forget`, and `identity_exec`, the model's surface over the
|
||||
//! [`IdentityStore`].
|
||||
//!
|
||||
//! These are the one place the use-never-read contract could break, so
|
||||
//! the answers are built to keep it: a save answers with the redacted
|
||||
//! [`Account`] and the watch verdict — never the secret, which nothing
|
||||
//! here re-renders; a list answers with the store's own redacted
|
||||
//! records; a forget answers with what went. The store's `secret()`
|
||||
//! has exactly one tool-side consumer, [`IDENTITY_EXEC`]: the credential
|
||||
//! is injected into one child process's environment — the sanctioned
|
||||
//! *destination* — and only that command's output returns, so the value
|
||||
//! reaches its work without ever entering the conversation.
|
||||
//!
|
||||
//! These tools join the interactive chain only. A forge dispatch — whose
|
||||
//! prompt is text a stranger wrote — never resolves them, so an injected
|
||||
//! comment cannot ask its way to the accounts or act with their
|
||||
//! credentials.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||
use rhema_core::{ToolCall, ToolContext, ToolResolver, ToolResult, ToolSpec};
|
||||
use rhema_identity::{Account, AccountDraft, IdentityStore, Secret, SecretKind};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::config::ForgeKind;
|
||||
use crate::watch_store::ClawStore;
|
||||
|
||||
/// `identity_save`: store an account (label, service, url, username, and
|
||||
/// the secret — required) and optionally register its forge watch.
|
||||
struct IdentitySave {
|
||||
accounts: Arc<dyn IdentityStore>,
|
||||
watches: Arc<ClawStore>,
|
||||
}
|
||||
|
||||
/// The save's arguments. `secret_kind` defaults to a token; a password
|
||||
/// is stored but is never model-accessible or pollable in v1.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SaveArgs {
|
||||
label: String,
|
||||
service: String,
|
||||
#[serde(default)]
|
||||
url: Option<String>,
|
||||
#[serde(default)]
|
||||
username: Option<String>,
|
||||
secret: String,
|
||||
#[serde(default)]
|
||||
secret_kind: Option<String>,
|
||||
#[serde(default)]
|
||||
watch: bool,
|
||||
}
|
||||
|
||||
/// What saving an account earns: the redacted account, whether the
|
||||
/// service resolved to a forge the daemon watches, and whether the watch
|
||||
/// got registered.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct SavedAccount {
|
||||
/// The stored account, redacted.
|
||||
pub account: Account,
|
||||
/// Whether `service`/`url` resolved to a watchable forge.
|
||||
pub forge_known: bool,
|
||||
/// Whether the forge watch got registered (asked for, forge known,
|
||||
/// and not already watched).
|
||||
pub watch_added: bool,
|
||||
}
|
||||
|
||||
/// The forge an account's `service` (then its `url` host) names, when it
|
||||
/// is one the daemon watches. A self-hosted instance is only recognizable
|
||||
/// through an explicit service name — a URL alone cannot distinguish a
|
||||
/// Gitea from a GitLab.
|
||||
pub fn forge_of(service: &str, url: Option<&str>) -> Option<ForgeKind> {
|
||||
// Case-insensitive on the service: a user says "GitHub", the store
|
||||
// normalizes to "github", and both should name the forge.
|
||||
if let Some(kind) = ForgeKind::parse(service.trim().to_lowercase().as_str()) {
|
||||
return Some(kind);
|
||||
}
|
||||
let url = url?;
|
||||
let host = url
|
||||
.split_once("://")
|
||||
.map(|(_, rest)| rest)
|
||||
.unwrap_or(url)
|
||||
.split('/')
|
||||
.next()?;
|
||||
match host.trim_end_matches('/') {
|
||||
"github.com" => Some(ForgeKind::GitHub),
|
||||
"gitlab.com" => Some(ForgeKind::GitLab),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the optional `secret_kind` spelling; `None` (absent) means a
|
||||
/// token. An unknown spelling is an in-band error. Shared with the HTTP
|
||||
/// route, which validates the same shape.
|
||||
pub fn parse_kind(kind: Option<&str>) -> Result<SecretKind, String> {
|
||||
match kind.map(str::trim).filter(|name| !name.is_empty()) {
|
||||
None => Ok(SecretKind::Token),
|
||||
Some("token") => Ok(SecretKind::Token),
|
||||
Some("password") => Ok(SecretKind::Password),
|
||||
Some(other) => Err(format!(
|
||||
"unknown secret_kind {other:?}: a kind is 'token' or 'password'"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores an account and, when asked and the service names a watchable
|
||||
/// forge, registers its account watch — the explicit-at-creation sync:
|
||||
/// no background reconciliation, so this is the one moment a watch
|
||||
/// appears. The store's error and the watch verdict both land in-band.
|
||||
pub fn save_account(
|
||||
accounts: &dyn IdentityStore,
|
||||
watches: &ClawStore,
|
||||
draft: AccountDraft,
|
||||
watch: bool,
|
||||
) -> Result<SavedAccount, String> {
|
||||
let account = accounts.put(draft).map_err(|err| err.to_string())?;
|
||||
let forge = forge_of(&account.service, account.url.as_deref());
|
||||
let mut saved = SavedAccount {
|
||||
account,
|
||||
forge_known: forge.is_some(),
|
||||
watch_added: false,
|
||||
};
|
||||
if watch {
|
||||
if let Some(kind) = forge {
|
||||
let host = saved.account.url.as_deref().filter(|url| !url.is_empty());
|
||||
saved.watch_added = watches
|
||||
.add_account_watch(kind.name(), host, &saved.account.id)
|
||||
.map_err(|err| err.to_string())?;
|
||||
}
|
||||
// Asked to watch a service the daemon cannot watch: the save
|
||||
// stands, the watch verdict says why not.
|
||||
}
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
/// Forgets an account and cascades its derived account watches — the
|
||||
/// other half of the explicit sync. Returns whether the account existed
|
||||
/// and how many watches went with it.
|
||||
pub fn forget_account(
|
||||
accounts: &dyn IdentityStore,
|
||||
watches: &ClawStore,
|
||||
id: &str,
|
||||
) -> Result<(bool, usize), String> {
|
||||
let forgot = accounts.forget(id).map_err(|err| err.to_string())?;
|
||||
let removed = if forgot {
|
||||
watches
|
||||
.remove_account_watches_for(id)
|
||||
.map_err(|err| err.to_string())?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok((forgot, removed))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for IdentitySave {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
"identity_save",
|
||||
"Store an account the daemon holds on a service: a label, the service's name (e.g. \
|
||||
'github'), optionally its URL and the username, and the secret (an API token, or a \
|
||||
password). Act with a stored account through identity_exec — the credential \
|
||||
itself is never shown to you. With watch=true and a forge the daemon watches, \
|
||||
the account's notifications are followed.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": { "type": "string",
|
||||
"description": "Display name, e.g. 'acme releases'." },
|
||||
"service": { "type": "string",
|
||||
"description": "The service, lowercase: github, gitlab, gitea, or any name." },
|
||||
"url": { "type": "string",
|
||||
"description": "Base URL, for self-hosted instances." },
|
||||
"username": { "type": "string",
|
||||
"description": "The login name on the service." },
|
||||
"secret": { "type": "string",
|
||||
"description": "The credential to store. Never echoed back." },
|
||||
"secret_kind": { "type": "string",
|
||||
"enum": ["token", "password"],
|
||||
"description": "What the secret is; default token." },
|
||||
"watch": { "type": "boolean",
|
||||
"description": "Follow this forge identity's notifications; default false." }
|
||||
},
|
||||
"required": ["label", "service", "secret"]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||
let args: SaveArgs =
|
||||
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||
let kind = parse_kind(args.secret_kind.as_deref())?;
|
||||
let mut draft = AccountDraft::new(args.label, args.service, Secret::new(kind, args.secret));
|
||||
if let Some(url) = args.url {
|
||||
draft = draft.with_url(url);
|
||||
}
|
||||
if let Some(username) = args.username {
|
||||
draft = draft.with_username(username);
|
||||
}
|
||||
let saved = save_account(
|
||||
self.accounts.as_ref(),
|
||||
self.watches.as_ref(),
|
||||
draft,
|
||||
args.watch,
|
||||
)?;
|
||||
Ok(json!({
|
||||
"saved": true,
|
||||
"id": saved.account.id,
|
||||
"label": saved.account.label,
|
||||
"service": saved.account.service,
|
||||
"secret_kind": saved.account.secret_kind,
|
||||
"watched": saved.watch_added,
|
||||
"watchable_forge": saved.forge_known,
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// `identity_list`: every account, redacted — the store's own records,
|
||||
/// which have no secret field to leak.
|
||||
struct IdentityList {
|
||||
accounts: Arc<dyn IdentityStore>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for IdentityList {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
"identity_list",
|
||||
"List the accounts the daemon holds. Secrets are never shown — only each account's \
|
||||
label, service, url, username, and what kind of secret it keeps.",
|
||||
json!({ "type": "object", "properties": {} }),
|
||||
)
|
||||
}
|
||||
|
||||
async fn invoke(&self, _args: Value) -> Result<String, String> {
|
||||
let accounts = self.accounts.list().map_err(|err| err.to_string())?;
|
||||
Ok(json!({ "accounts": accounts }).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// `identity_forget`: remove an account, its secret with it, and the
|
||||
/// account watches derived from it.
|
||||
struct IdentityForget {
|
||||
accounts: Arc<dyn IdentityStore>,
|
||||
watches: Arc<ClawStore>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ForgetArgs {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for IdentityForget {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
"identity_forget",
|
||||
"Forget a stored account by id: the secret goes with it, and any forge watch that \
|
||||
followed this identity is removed too.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "The account id (identity_list's ids)." }
|
||||
},
|
||||
"required": ["id"]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||
let args: ForgetArgs =
|
||||
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||
let (forgot, watches_removed) =
|
||||
forget_account(self.accounts.as_ref(), self.watches.as_ref(), &args.id)?;
|
||||
Ok(json!({
|
||||
"forgot": forgot,
|
||||
"watches_removed": watches_removed,
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// The identity tool set over the account store and the watch table.
|
||||
pub fn registry(accounts: Arc<dyn IdentityStore>, watches: Arc<ClawStore>) -> ToolRegistry {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(IdentitySave {
|
||||
accounts: accounts.clone(),
|
||||
watches: watches.clone(),
|
||||
}));
|
||||
registry.register(Arc::new(IdentityList {
|
||||
accounts: accounts.clone(),
|
||||
}));
|
||||
registry.register(Arc::new(IdentityForget { accounts, watches }));
|
||||
registry
|
||||
}
|
||||
|
||||
/// The tool's name on the wire.
|
||||
pub const IDENTITY_EXEC: &str = "identity_exec";
|
||||
|
||||
/// How long one command may run before it is killed.
|
||||
const EXEC_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
/// The longest a caller may stretch a command's budget.
|
||||
const EXEC_TIMEOUT_MAX: Duration = Duration::from_secs(600);
|
||||
|
||||
/// The output tail a result carries, mirroring the bash tool's caps:
|
||||
/// past either the byte or the line limit the head is elided and the
|
||||
/// tail kept — a failure's summary lives at the end.
|
||||
fn render_truncated(bytes: &[u8]) -> String {
|
||||
const LIMIT: usize = 50 * 1024;
|
||||
const LINES: usize = 2000;
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let mut lines: Vec<&str> = text.lines().collect();
|
||||
if bytes.len() <= LIMIT && lines.len() <= LINES {
|
||||
return text.into_owned();
|
||||
}
|
||||
if lines.len() > LINES {
|
||||
lines = lines[lines.len() - LINES..].to_vec();
|
||||
}
|
||||
let mut kept = String::new();
|
||||
for line in lines {
|
||||
if kept.len() + line.len() + 1 > LIMIT {
|
||||
break;
|
||||
}
|
||||
kept.push_str(line);
|
||||
kept.push('\n');
|
||||
}
|
||||
let elided = bytes.len() - kept.len();
|
||||
format!("[output truncated — {elided} bytes elided]\n{kept}")
|
||||
}
|
||||
|
||||
/// The service's own env names a token answers as, beyond the generic
|
||||
/// `ACCOUNT_*` set: the names its CLI and API clients read.
|
||||
fn service_env_names(service: &str) -> &'static [&'static str] {
|
||||
match service.trim().to_ascii_lowercase().as_str() {
|
||||
"github" => &["GITHUB_TOKEN", "GH_TOKEN"],
|
||||
"gitea" => &["GITEA_TOKEN"],
|
||||
"gitlab" => &["GITLAB_TOKEN"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The git credential helper the command runs with: a shell snippet
|
||||
/// that answers git's https prompts from the injected environment. The
|
||||
/// secret lives only in the child process's env — never in the value
|
||||
/// itself, never in the output.
|
||||
const GIT_CREDENTIAL_HELPER: &str = "!f() { \
|
||||
printf 'username=%s\\n' \"$ACCOUNT_USERNAME\"; \
|
||||
printf 'password=%s\\n' \"$ACCOUNT_SECRET\"; }; f";
|
||||
|
||||
/// `identity_exec`: act with a stored account. The one tool-side
|
||||
/// consumer of the store's `secret()` — the credential is injected into
|
||||
/// one child process's environment and a git credential helper, the
|
||||
/// command runs, and only its output returns. The value reaches its
|
||||
/// destination; it never reaches the conversation.
|
||||
pub struct IdentityExec {
|
||||
accounts: Arc<dyn IdentityStore>,
|
||||
workspace: PathBuf,
|
||||
}
|
||||
|
||||
impl IdentityExec {
|
||||
/// The executor over `accounts`, running commands in `workspace`.
|
||||
pub fn new(accounts: Arc<dyn IdentityStore>, workspace: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
accounts,
|
||||
workspace: workspace.into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn exec(
|
||||
&self,
|
||||
call: ToolCall,
|
||||
progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
|
||||
) -> ToolResult {
|
||||
let id = match call.args.get("id").and_then(Value::as_str) {
|
||||
Some(id) => id.trim().to_owned(),
|
||||
None => return ToolResult::failure(call.id, "missing required argument: id"),
|
||||
};
|
||||
let command = match call.args.get("command").and_then(Value::as_str) {
|
||||
Some(command) => command,
|
||||
None => return ToolResult::failure(call.id, "missing required argument: command"),
|
||||
};
|
||||
let timeout = match call.args.get("timeout_secs") {
|
||||
None | Some(Value::Null) => EXEC_TIMEOUT,
|
||||
Some(value) => {
|
||||
let Some(secs) = value.as_u64() else {
|
||||
return ToolResult::failure(call.id, "timeout_secs must be a number");
|
||||
};
|
||||
Duration::from_secs(secs.clamp(1, EXEC_TIMEOUT_MAX.as_secs()))
|
||||
}
|
||||
};
|
||||
let Some(account) = self
|
||||
.accounts
|
||||
.get(&id)
|
||||
.map_err(|err| err.to_string())
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
return ToolResult::failure(
|
||||
call.id,
|
||||
format!("no account {id:?} — identity_list names them"),
|
||||
);
|
||||
};
|
||||
let Some(secret) = self
|
||||
.accounts
|
||||
.secret(&id)
|
||||
.map_err(|err| err.to_string())
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
return ToolResult::failure(call.id, format!("account {id:?} has no secret"));
|
||||
};
|
||||
|
||||
let mut cmd = tokio::process::Command::new("bash");
|
||||
cmd.arg("-c")
|
||||
.arg(command)
|
||||
.current_dir(&self.workspace)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.env("ACCOUNT_ID", &account.id)
|
||||
.env("ACCOUNT_SERVICE", &account.service)
|
||||
.env(
|
||||
"ACCOUNT_USERNAME",
|
||||
account.username.as_deref().unwrap_or(""),
|
||||
)
|
||||
.env("ACCOUNT_SECRET", &secret.value)
|
||||
// git asks https credentials from the helper, which reads
|
||||
// the injected env; a failed auth must fail, not hang.
|
||||
.env("GIT_CONFIG_COUNT", "1")
|
||||
.env("GIT_CONFIG_KEY_0", "credential.helper")
|
||||
.env("GIT_CONFIG_VALUE_0", GIT_CREDENTIAL_HELPER)
|
||||
.env("GIT_TERMINAL_PROMPT", "0");
|
||||
if secret.kind == SecretKind::Token {
|
||||
for name in service_env_names(&account.service) {
|
||||
cmd.env(name, &secret.value);
|
||||
}
|
||||
}
|
||||
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(err) => {
|
||||
return ToolResult::failure(call.id, format!("failed to spawn bash: {err}"));
|
||||
}
|
||||
};
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
pump(stdout, false, tx.clone());
|
||||
}
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
pump(stderr, true, tx.clone());
|
||||
}
|
||||
drop(tx);
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
let mut stdout: Vec<u8> = Vec::new();
|
||||
let mut stderr: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
let _ = child.start_kill();
|
||||
let _ = child.wait().await;
|
||||
return ToolResult::failure(
|
||||
call.id,
|
||||
format!(
|
||||
"command timed out after {}s and was killed",
|
||||
timeout.as_secs()
|
||||
),
|
||||
);
|
||||
}
|
||||
line = rx.recv() => match line {
|
||||
Some((is_stderr, line)) => {
|
||||
let sink = if is_stderr { &mut stderr } else { &mut stdout };
|
||||
sink.extend_from_slice(line.as_bytes());
|
||||
sink.push(b'\n');
|
||||
progress(&format!("{line}\n"));
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
let status = match tokio::time::timeout(timeout, child.wait()).await {
|
||||
Ok(Ok(status)) => status,
|
||||
_ => {
|
||||
return ToolResult::failure(
|
||||
call.id,
|
||||
format!(
|
||||
"command timed out after {}s and was killed",
|
||||
timeout.as_secs()
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
let content = format!(
|
||||
"exit code {}\n--- stdout ---\n{}\n--- stderr ---\n{}",
|
||||
status
|
||||
.code()
|
||||
.map(|code| code.to_string())
|
||||
.unwrap_or_else(|| "signal".to_owned()),
|
||||
render_truncated(&stdout),
|
||||
render_truncated(&stderr),
|
||||
);
|
||||
let result = if status.success() {
|
||||
ToolResult::success(call.id, content)
|
||||
} else {
|
||||
ToolResult::failure(call.id, content)
|
||||
};
|
||||
match status.code() {
|
||||
Some(code) => result.with_code(code),
|
||||
None => result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `stream` line by line into `tx`, tagged with its stream, until
|
||||
/// EOF — the live side of the command's output.
|
||||
fn pump<R>(stream: R, is_stderr: bool, tx: tokio::sync::mpsc::UnboundedSender<(bool, String)>)
|
||||
where
|
||||
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let mut lines = tokio::io::BufReader::new(stream).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if tx.send((is_stderr, line)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolResolver for IdentityExec {
|
||||
fn specs(&self) -> Vec<ToolSpec> {
|
||||
vec![ToolSpec::new(
|
||||
IDENTITY_EXEC,
|
||||
"Act with a stored account's credential without ever seeing \
|
||||
it: run one shell command in this session's workspace with \
|
||||
the secret injected as environment variables — ACCOUNT_SECRET, \
|
||||
the service's own (GITHUB_TOKEN/GH_TOKEN, GITEA_TOKEN, \
|
||||
GITLAB_TOKEN) — and a git credential helper preconfigured, \
|
||||
so plain `git clone`/`git push` over https and API calls \
|
||||
(curl, gh) just work. Only the command's output comes back; \
|
||||
the secret itself never enters the conversation. Pick `id` \
|
||||
from identity_list.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string",
|
||||
"description": "The account's id, as identity_list \
|
||||
reports it." },
|
||||
"command": { "type": "string",
|
||||
"description": "The shell command to run." },
|
||||
"timeout_secs": { "type": "integer",
|
||||
"description": "Kill the command after this many \
|
||||
seconds; 180 by default, 600 at most." }
|
||||
},
|
||||
"required": ["id", "command"]
|
||||
}),
|
||||
)]
|
||||
}
|
||||
|
||||
async fn resolve(&self, call: ToolCall, _ctx: &ToolContext) -> ToolResult {
|
||||
self.exec(call, &mut |_| {}).await
|
||||
}
|
||||
|
||||
async fn resolve_streaming(
|
||||
&self,
|
||||
call: ToolCall,
|
||||
ctx: &ToolContext,
|
||||
progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
|
||||
) -> ToolResult {
|
||||
if ctx.cancelled.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return ToolResult::failure(call.id, "cancelled");
|
||||
}
|
||||
self.exec(call, progress).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parts() -> (Arc<dyn IdentityStore>, Arc<ClawStore>) {
|
||||
(
|
||||
Arc::new(rhema_identity::VolatileIdentityStore::new()),
|
||||
Arc::new(ClawStore::in_memory().expect("store opens")),
|
||||
)
|
||||
}
|
||||
|
||||
fn call(tool: &dyn Tool, args: Value) -> Result<String, String> {
|
||||
futures_block_on(tool.invoke(args))
|
||||
}
|
||||
|
||||
/// The tools are async over a sync store: one block-on harness for
|
||||
/// the tests.
|
||||
fn futures_block_on<F: std::future::Future>(future: F) -> F::Output {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("runtime builds")
|
||||
.block_on(future)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_answers_without_the_secret_and_watch_lands_for_a_known_forge() {
|
||||
let (accounts, watches) = parts();
|
||||
let tool = IdentitySave {
|
||||
accounts: accounts.clone(),
|
||||
watches: watches.clone(),
|
||||
};
|
||||
let answer = call(
|
||||
&tool,
|
||||
json!({
|
||||
"label": "acme bot",
|
||||
"service": "github",
|
||||
"secret": "sekrit-token",
|
||||
"watch": true
|
||||
}),
|
||||
)
|
||||
.expect("saves");
|
||||
assert!(!answer.contains("sekrit-token"), "{answer}");
|
||||
assert!(answer.contains("\"watched\":true"), "{answer}");
|
||||
|
||||
let listed = call(
|
||||
&IdentityList {
|
||||
accounts: accounts.clone(),
|
||||
},
|
||||
json!({}),
|
||||
)
|
||||
.expect("lists");
|
||||
assert!(!listed.contains("sekrit-token"), "{listed}");
|
||||
assert!(listed.contains("acme bot"), "{listed}");
|
||||
|
||||
let watched = watches.list_account_watches().expect("watches read");
|
||||
assert_eq!(watched.len(), 1);
|
||||
assert_eq!(watched[0].forge, "github");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forget_cascades_the_derived_watch() {
|
||||
let (accounts, watches) = parts();
|
||||
let saved = save_account(
|
||||
accounts.as_ref(),
|
||||
watches.as_ref(),
|
||||
AccountDraft::new("acme", "github", Secret::new(SecretKind::Token, "t")),
|
||||
true,
|
||||
)
|
||||
.expect("saves");
|
||||
assert!(saved.watch_added);
|
||||
|
||||
let tool = IdentityForget {
|
||||
accounts: accounts.clone(),
|
||||
watches: watches.clone(),
|
||||
};
|
||||
let answer = call(&tool, json!({ "id": saved.account.id })).expect("forgets");
|
||||
assert!(answer.contains("\"forgot\":true"), "{answer}");
|
||||
assert!(answer.contains("\"watches_removed\":1"), "{answer}");
|
||||
assert!(watches.list_account_watches().expect("reads").is_empty());
|
||||
assert!(!accounts.secret(&saved.account.id).expect("reads").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_watch_request_on_an_unknown_service_saves_without_watching() {
|
||||
let (accounts, watches) = parts();
|
||||
let saved = save_account(
|
||||
accounts.as_ref(),
|
||||
watches.as_ref(),
|
||||
AccountDraft::new("mail", "smtp", Secret::new(SecretKind::Password, "p"))
|
||||
.with_url("https://mail.example.net"),
|
||||
true,
|
||||
)
|
||||
.expect("saves");
|
||||
assert!(!saved.watch_added);
|
||||
assert!(!saved.forge_known);
|
||||
assert!(watches.list_account_watches().expect("reads").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forge_of_matches_services_and_production_hosts_only() {
|
||||
assert_eq!(forge_of("GitHub", None), Some(ForgeKind::GitHub));
|
||||
assert_eq!(
|
||||
forge_of("whatever", Some("https://github.com/acme")),
|
||||
Some(ForgeKind::GitHub)
|
||||
);
|
||||
assert_eq!(
|
||||
forge_of("whatever", Some("https://gitlab.com/acme/w")),
|
||||
Some(ForgeKind::GitLab)
|
||||
);
|
||||
assert_eq!(
|
||||
forge_of("whatever", Some("https://git.example.net/acme/w")),
|
||||
None,
|
||||
"a self-hosted host is unrecognizable by URL alone"
|
||||
);
|
||||
assert_eq!(
|
||||
forge_of("gitea", Some("https://git.example.net")),
|
||||
Some(ForgeKind::Gitea)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_secret_kind_is_rejected_in_band() {
|
||||
let (accounts, watches) = parts();
|
||||
let tool = IdentitySave { accounts, watches };
|
||||
let err = call(
|
||||
&tool,
|
||||
json!({
|
||||
"label": "x",
|
||||
"service": "github",
|
||||
"secret": "t",
|
||||
"secret_kind": "pin"
|
||||
}),
|
||||
)
|
||||
.expect_err("rejected");
|
||||
assert!(err.contains("unknown secret_kind"), "{err}");
|
||||
}
|
||||
|
||||
/// The use half of the accounts: the command runs with the
|
||||
/// service's env injected, the git helper configured, and none of
|
||||
/// the secret in the result.
|
||||
#[tokio::test]
|
||||
async fn identity_exec_runs_with_the_credential_in_its_environment() {
|
||||
let accounts = Arc::new(rhema_identity::VolatileIdentityStore::new());
|
||||
let stored = accounts
|
||||
.put(
|
||||
AccountDraft::new(
|
||||
"github releases",
|
||||
"github",
|
||||
Secret::new(SecretKind::Token, "tok-1234567890"),
|
||||
)
|
||||
.with_username("kosmos-ai-bot"),
|
||||
)
|
||||
.expect("the account stores");
|
||||
let workspace = tempfile::tempdir().expect("tempdir");
|
||||
let exec = IdentityExec::new(accounts, workspace.path());
|
||||
let id = stored.id.clone();
|
||||
|
||||
async fn run(exec: &IdentityExec, id: &str, command: &str) -> rhema_core::ToolResult {
|
||||
exec.resolve(
|
||||
ToolCall {
|
||||
id: "c1".into(),
|
||||
name: IDENTITY_EXEC.into(),
|
||||
args: json!({ "id": id, "command": command }),
|
||||
provider_data: Value::Null,
|
||||
},
|
||||
&rhema_core::ToolContext::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// The service's token and the generic set are all the same
|
||||
// value, and the username rode along.
|
||||
let result = run(
|
||||
&exec,
|
||||
&id,
|
||||
"[ -n \"$GITHUB_TOKEN\" ] && [ \"$GH_TOKEN\" = \"$ACCOUNT_SECRET\" ] \
|
||||
&& [ \"$ACCOUNT_USERNAME\" = \"kosmos-ai-bot\" ] \
|
||||
&& [ \"$GIT_CONFIG_COUNT\" = \"1\" ] && echo ok",
|
||||
)
|
||||
.await;
|
||||
assert!(!result.is_error, "{}", result.content);
|
||||
assert!(result.content.contains("ok"), "{}", result.content);
|
||||
assert!(
|
||||
!result.content.contains("tok-1234567890"),
|
||||
"the secret never rides the answer: {}",
|
||||
result.content
|
||||
);
|
||||
|
||||
// The helper answers git's prompt shape from the env.
|
||||
let result = run(
|
||||
&exec,
|
||||
&id,
|
||||
"printf 'username=%s password=%s' \"$ACCOUNT_USERNAME\" \"$ACCOUNT_SECRET\" \
|
||||
| grep -c kosmos-ai-bot",
|
||||
)
|
||||
.await;
|
||||
assert!(!result.is_error, "{}", result.content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identity_exec_reports_exit_codes_and_unknown_accounts() {
|
||||
let accounts = Arc::new(rhema_identity::VolatileIdentityStore::new());
|
||||
let stored = accounts
|
||||
.put(AccountDraft::new(
|
||||
"gitea",
|
||||
"gitea",
|
||||
Secret::new(SecretKind::Token, "gt-1"),
|
||||
))
|
||||
.expect("the account stores");
|
||||
let workspace = tempfile::tempdir().expect("tempdir");
|
||||
let exec = IdentityExec::new(accounts.clone(), workspace.path());
|
||||
let id = stored.id.clone();
|
||||
|
||||
let call = |id: &str, args: Value| ToolCall {
|
||||
id: "c1".into(),
|
||||
name: IDENTITY_EXEC.into(),
|
||||
args: {
|
||||
let mut args = args;
|
||||
args["id"] = json!(id);
|
||||
args
|
||||
},
|
||||
provider_data: Value::Null,
|
||||
};
|
||||
let failure = exec
|
||||
.resolve(
|
||||
call(&id, json!({ "command": "exit 3" })),
|
||||
&rhema_core::ToolContext::default(),
|
||||
)
|
||||
.await;
|
||||
assert!(failure.is_error);
|
||||
assert_eq!(failure.code, Some(3));
|
||||
assert!(failure.content.contains("exit code 3"));
|
||||
|
||||
// A gitea token answers as GITEA_TOKEN; a github account it is not.
|
||||
let check = exec
|
||||
.resolve(
|
||||
call(
|
||||
&id,
|
||||
json!({
|
||||
"command": "[ -n \"$GITEA_TOKEN\" ] && [ -z \"$GITHUB_TOKEN\" ]"
|
||||
}),
|
||||
),
|
||||
&rhema_core::ToolContext::default(),
|
||||
)
|
||||
.await;
|
||||
assert!(!check.is_error, "{}", check.content);
|
||||
|
||||
let missing = exec
|
||||
.resolve(
|
||||
call("99", json!({ "command": "echo hi" })),
|
||||
&rhema_core::ToolContext::default(),
|
||||
)
|
||||
.await;
|
||||
assert!(missing.is_error);
|
||||
assert!(
|
||||
missing.content.contains("no account"),
|
||||
"{}",
|
||||
missing.content
|
||||
);
|
||||
|
||||
// The command runs in the session's workspace.
|
||||
let pwd = exec
|
||||
.resolve(
|
||||
call(&id, json!({ "command": "pwd" })),
|
||||
&rhema_core::ToolContext::default(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
pwd.content
|
||||
.contains(workspace.path().to_str().expect("utf8")),
|
||||
"{}",
|
||||
pwd.content
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
//! The attachment store and the `attach_file` tool: how a turn hands
|
||||
//! the human a file from its workspace — a screenshot it just took, a
|
||||
//! diff, a log the transcript's snippets cut.
|
||||
//!
|
||||
//! A stored attachment is a copy, not a reference: the bytes move into
|
||||
//! the attachments directory beside the database under an id, so the
|
||||
//! turn's later work (or a workspace cleanup) cannot break a link the
|
||||
//! transcript already served. The transcript carries the metadata —
|
||||
//! name, size, media kind, caption — and a client fetches bytes
|
||||
//! through `GET /v1/attachments/{id}`. A size cap keeps one tool call
|
||||
//! from turning the store into a workspace mirror; a file past the cap
|
||||
//! is an in-band error, and the model can cut it down first.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||
use rhema_core::ToolSpec;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::hub::{EventHub, TurnEvent};
|
||||
use rhema_rsd::ResourceKey;
|
||||
|
||||
/// How large one attachment may be. Generous for screenshots and
|
||||
/// text; a whole dataset does not belong in a chat.
|
||||
pub const MAX_ATTACHMENT_BYTES: usize = 25 * 1024 * 1024;
|
||||
|
||||
/// Why an attachment operation failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AttachmentError {
|
||||
/// The backend failed to read or write.
|
||||
#[error("attachment store failed: {0}")]
|
||||
Backend(#[source] std::io::Error),
|
||||
/// A stored attachment's file went missing.
|
||||
#[error("attachment {0} is gone from the store")]
|
||||
Missing(String),
|
||||
}
|
||||
|
||||
/// One stored attachment: the metadata the transcript carries.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct Stored {
|
||||
/// The attachment's id (`att-…`), the fetch path's address.
|
||||
pub id: String,
|
||||
/// The original file's name, as the tool call named it.
|
||||
pub name: String,
|
||||
/// The byte size.
|
||||
pub size: u64,
|
||||
/// The media kind the name implies: `image`, `code`, or `file`.
|
||||
pub media: &'static str,
|
||||
}
|
||||
|
||||
/// The attachments directory. Shareable behind an `Arc`; every
|
||||
/// operation takes `&self`.
|
||||
pub struct AttachmentStore {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl AttachmentStore {
|
||||
/// Opens (creating if needed) the attachments directory at `root` —
|
||||
/// beside the database, the same state volume the transcript lives
|
||||
/// on, so a served id never outlives its bytes.
|
||||
pub fn open(root: impl Into<PathBuf>) -> Result<Self, AttachmentError> {
|
||||
let root = root.into();
|
||||
std::fs::create_dir_all(&root).map_err(AttachmentError::Backend)?;
|
||||
Ok(Self { root })
|
||||
}
|
||||
|
||||
/// Stores `bytes` under `name`. The name is sanitized to its file
|
||||
/// name and rides the stored file name only for content-type
|
||||
/// guesses and human downloads; the id is the address.
|
||||
pub fn put(&self, name: &str, bytes: &[u8]) -> Result<Stored, AttachmentError> {
|
||||
static ATTACHMENT_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
let id = format!(
|
||||
"att-{}-{:04x}",
|
||||
now,
|
||||
ATTACHMENT_SEQ.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
let name = sanitize_name(name);
|
||||
let media = media_of(&name);
|
||||
std::fs::write(self.root.join(&id), bytes).map_err(AttachmentError::Backend)?;
|
||||
// The name rides a sidecar: the transcript carries the original
|
||||
// spelling, and the fetch route needs the file name back for
|
||||
// its content type and Content-Disposition.
|
||||
std::fs::write(self.root.join(format!("{id}.name")), &name)
|
||||
.map_err(AttachmentError::Backend)?;
|
||||
Ok(Stored {
|
||||
id,
|
||||
name,
|
||||
size: bytes.len() as u64,
|
||||
media,
|
||||
})
|
||||
}
|
||||
|
||||
/// The stored attachment's bytes, when it exists.
|
||||
pub fn get(&self, id: &str) -> Result<Option<(Stored, Vec<u8>)>, AttachmentError> {
|
||||
if !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = self.root.join(id);
|
||||
let metadata = match std::fs::metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => return Err(AttachmentError::Backend(err)),
|
||||
};
|
||||
let bytes = std::fs::read(&path).map_err(AttachmentError::Backend)?;
|
||||
let name = std::fs::read_to_string(self.root.join(format!("{id}.name")))
|
||||
.ok()
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or_else(|| id.to_owned());
|
||||
let stored = Stored {
|
||||
id: id.to_owned(),
|
||||
name,
|
||||
size: metadata.len(),
|
||||
media: "file",
|
||||
};
|
||||
Ok(Some((stored, bytes)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Reduces a caller-supplied name to a safe single file name: its last
|
||||
/// path component, path separators and control characters folded to
|
||||
/// `_`, never empty. The original spelling travels in the transcript's
|
||||
/// metadata; this form only guards the store's content-type guesses.
|
||||
fn sanitize_name(name: &str) -> String {
|
||||
let cleaned: String = name
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(name)
|
||||
.chars()
|
||||
.map(|c| if c.is_control() { '_' } else { c })
|
||||
.collect();
|
||||
let trimmed = cleaned.trim();
|
||||
if trimmed.is_empty() {
|
||||
"attachment".to_owned()
|
||||
} else {
|
||||
trimmed.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// The media kind a file name implies, the shape the wire's
|
||||
/// `attachment` events carry: images render inline where a client can,
|
||||
/// source files read as code, everything else downloads.
|
||||
pub fn media_of(name: &str) -> &'static str {
|
||||
let extension = Path::new(name)
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.map(|extension| extension.to_ascii_lowercase());
|
||||
match extension.as_deref() {
|
||||
Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "ico" | "avif") => "image",
|
||||
Some(
|
||||
"rs" | "go" | "c" | "h" | "cpp" | "hpp" | "py" | "rb" | "java" | "kt" | "swift" | "js"
|
||||
| "mjs" | "cjs" | "ts" | "tsx" | "jsx" | "vue" | "svelte" | "sh" | "bash" | "zsh"
|
||||
| "fish" | "sql" | "toml" | "yaml" | "yml" | "json" | "jsonc" | "xml" | "html" | "css"
|
||||
| "scss" | "md" | "markdown" | "txt" | "diff" | "patch" | "lua" | "pl" | "php" | "cs"
|
||||
| "scala" | "hs" | "ex" | "exs" | "zig" | "gradle" | "tf",
|
||||
) => "code",
|
||||
_ => "file",
|
||||
}
|
||||
}
|
||||
|
||||
/// The content type a file name implies, for the fetch route's header.
|
||||
pub fn content_type(name: &str) -> &'static str {
|
||||
let extension = Path::new(name)
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.map(|extension| extension.to_ascii_lowercase());
|
||||
match extension.as_deref() {
|
||||
Some("png") => "image/png",
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("gif") => "image/gif",
|
||||
Some("webp") => "image/webp",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("bmp") => "image/bmp",
|
||||
Some("ico") => "image/x-icon",
|
||||
Some("avif") => "image/avif",
|
||||
Some("json") => "application/json",
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("html") => "text/html; charset=utf-8",
|
||||
Some("css") => "text/css; charset=utf-8",
|
||||
Some("md" | "markdown" | "txt" | "log" | "diff" | "patch") => "text/plain; charset=utf-8",
|
||||
Some("xml") => "application/xml",
|
||||
Some("wasm") => "application/wasm",
|
||||
Some("zip") => "application/zip",
|
||||
Some("tar") => "application/x-tar",
|
||||
Some("gz") => "application/gzip",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// The tool's name on the wire.
|
||||
pub const ATTACH_FILE: &str = "attach_file";
|
||||
|
||||
/// `attach_file`: copy a workspace file into the attachment store and
|
||||
/// put its card on the stream. Built per dispatch — the event must
|
||||
/// land on the asking session.
|
||||
struct AttachFile {
|
||||
store: Arc<AttachmentStore>,
|
||||
workspace: PathBuf,
|
||||
hub: Arc<EventHub>,
|
||||
key: ResourceKey,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AttachArgs {
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
caption: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for AttachFile {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
ATTACH_FILE,
|
||||
"Attach a file from this session's workspace to the chat: the \
|
||||
human gets a card with the file's name and can view or \
|
||||
download it. Use it to hand over artifacts a reply snippet \
|
||||
cannot carry — a screenshot, a diff, a generated report, a \
|
||||
log. The path is workspace-relative. Large files are \
|
||||
refused; attach the relevant cut instead.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string",
|
||||
"description": "The workspace-relative file path, \
|
||||
e.g. 'screenshots/failure.png'." },
|
||||
"caption": { "type": "string",
|
||||
"description": "One line about what the file is \
|
||||
and why it matters." }
|
||||
},
|
||||
"required": ["path"]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||
let args: AttachArgs =
|
||||
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||
let relative = Path::new(&args.path);
|
||||
if relative.is_absolute()
|
||||
|| relative
|
||||
.components()
|
||||
.any(|c| c == std::path::Component::ParentDir)
|
||||
{
|
||||
return Err(format!(
|
||||
"path must be relative to the workspace, without '..': {:?}",
|
||||
args.path
|
||||
));
|
||||
}
|
||||
let path = self.workspace.join(relative);
|
||||
let metadata = std::fs::metadata(&path)
|
||||
.map_err(|err| format!("cannot attach {:?}: {err}", args.path))?;
|
||||
if metadata.len() > MAX_ATTACHMENT_BYTES as u64 {
|
||||
return Err(format!(
|
||||
"cannot attach {:?}: {} bytes exceeds the {}-byte limit; attach a smaller cut",
|
||||
args.path,
|
||||
metadata.len(),
|
||||
MAX_ATTACHMENT_BYTES
|
||||
));
|
||||
}
|
||||
let bytes =
|
||||
std::fs::read(&path).map_err(|err| format!("cannot attach {:?}: {err}", args.path))?;
|
||||
let stored = self
|
||||
.store
|
||||
.put(&args.path, &bytes)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let caption = args
|
||||
.caption
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|caption| !caption.is_empty())
|
||||
.map(str::to_owned);
|
||||
self.hub.emit(
|
||||
&self.key,
|
||||
TurnEvent::Attachment {
|
||||
id: stored.id.clone(),
|
||||
name: stored.name.clone(),
|
||||
media: stored.media.to_owned(),
|
||||
size: stored.size,
|
||||
caption: caption.clone(),
|
||||
},
|
||||
);
|
||||
Ok(json!({
|
||||
"attached": true,
|
||||
"id": stored.id,
|
||||
"name": stored.name,
|
||||
"size": stored.size,
|
||||
"media": stored.media,
|
||||
"caption": caption,
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// The attachment tool set over `store`: `attach_file`.
|
||||
pub fn registry(
|
||||
store: Arc<AttachmentStore>,
|
||||
workspace: impl Into<PathBuf>,
|
||||
hub: Arc<EventHub>,
|
||||
key: ResourceKey,
|
||||
) -> ToolRegistry {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(AttachFile {
|
||||
store,
|
||||
workspace: workspace.into(),
|
||||
hub,
|
||||
key,
|
||||
}));
|
||||
registry
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store(dir: &Path) -> Arc<AttachmentStore> {
|
||||
Arc::new(AttachmentStore::open(dir.join("attachments")).expect("store opens"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_attachment_round_trips_through_its_id() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let store = store(dir.path());
|
||||
let stored = store
|
||||
.put("screenshots/failure.png", b"\x89PNG")
|
||||
.expect("puts");
|
||||
assert_eq!(
|
||||
stored.name, "failure.png",
|
||||
"the name folds to its file name"
|
||||
);
|
||||
assert_eq!(stored.media, "image");
|
||||
assert_eq!(stored.size, 4);
|
||||
let (fetched, bytes) = store.get(&stored.id).expect("reads").expect("exists");
|
||||
assert_eq!(bytes, b"\x89PNG");
|
||||
assert_eq!(fetched.id, stored.id);
|
||||
assert_eq!(
|
||||
store.get("../escape").expect("reads"),
|
||||
None,
|
||||
"a path is not an id"
|
||||
);
|
||||
assert_eq!(store.get("att-with$igns").expect("reads"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_kinds_follow_the_extension() {
|
||||
assert_eq!(media_of("a.PNG"), "image");
|
||||
assert_eq!(media_of("src/main.rs"), "code");
|
||||
assert_eq!(media_of("notes.md"), "code");
|
||||
assert_eq!(media_of("archive.tar.gz"), "file");
|
||||
assert_eq!(media_of("no-extension"), "file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_types_follow_the_extension() {
|
||||
assert_eq!(content_type("a.png"), "image/png");
|
||||
assert_eq!(content_type("run.log"), "text/plain; charset=utf-8");
|
||||
assert_eq!(content_type("blob.bin"), "application/octet-stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_that_sanitizes_to_nothing_becomes_a_placeholder() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let store = store(dir.path());
|
||||
let stored = store.put("/", b"x").expect("puts");
|
||||
assert_eq!(stored.name, "attachment");
|
||||
}
|
||||
|
||||
/// The tool, end to end: a workspace file in, a stored attachment
|
||||
/// and a stream card out, with the caption riding.
|
||||
#[tokio::test]
|
||||
async fn attach_file_copies_a_workspace_file_and_emits_the_card() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let workspace = dir.path().join("interactive");
|
||||
std::fs::create_dir_all(workspace.join("out")).expect("workspace");
|
||||
std::fs::write(workspace.join("out/report.md"), "# report").expect("file");
|
||||
|
||||
let store = store(dir.path());
|
||||
let hub = Arc::new(EventHub::default());
|
||||
let key = ResourceKey::from("interactive:web");
|
||||
let tool = AttachFile {
|
||||
store: store.clone(),
|
||||
workspace: workspace.clone(),
|
||||
hub: hub.clone(),
|
||||
key: key.clone(),
|
||||
};
|
||||
let answer = tool
|
||||
.invoke(json!({"path": "out/report.md", "caption": "the draft"}))
|
||||
.await
|
||||
.expect("in-band ok");
|
||||
let answer: Value = serde_json::from_str(&answer).expect("the answer parses");
|
||||
assert_eq!(answer["attached"], true);
|
||||
assert_eq!(answer["name"], "report.md");
|
||||
assert_eq!(answer["media"], "code");
|
||||
let id = answer["id"].as_str().expect("an id").to_owned();
|
||||
assert!(
|
||||
store.get(&id).expect("reads").is_some(),
|
||||
"the bytes are stored"
|
||||
);
|
||||
|
||||
let (history, _) = hub.subscribe_with_history(&key);
|
||||
assert_eq!(
|
||||
history.last(),
|
||||
Some(&TurnEvent::Attachment {
|
||||
id,
|
||||
name: "report.md".into(),
|
||||
media: "code".into(),
|
||||
size: 8,
|
||||
caption: Some("the draft".into()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn escapes_oversized_files_and_missing_paths_fail_in_band() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let workspace = dir.path().join("interactive");
|
||||
std::fs::create_dir_all(&workspace).expect("workspace");
|
||||
let tool = AttachFile {
|
||||
store: store(dir.path()),
|
||||
workspace: workspace.clone(),
|
||||
hub: Arc::new(EventHub::default()),
|
||||
key: ResourceKey::from("interactive:web"),
|
||||
};
|
||||
for bad in [
|
||||
json!({"path": "../outside.txt"}),
|
||||
json!({"path": "/etc/hostname"}),
|
||||
json!({"path": "missing.txt"}),
|
||||
] {
|
||||
assert!(
|
||||
tool.invoke(bad).await.is_err(),
|
||||
"an escape or a miss is an in-band error"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,982 @@
|
||||
//! The backend registry: every provider the deployment can speak to, one
|
||||
//! active at a time, switchable at runtime.
|
||||
//!
|
||||
//! At boot the registry constructs every provider whose credentials
|
||||
//! resolve — env vars win, the claw's own stored credentials serve
|
||||
//! next, the stored rhema config after that, and the keyless
|
||||
//! llama-cpp backend is always constructible, so a daemon can always
|
||||
//! boot. The same construction runs over HTTP: credentials saved
|
||||
//! through the surface insert (or replace) a backend in the live
|
||||
//! registry and persist in the settings store. A backend is one
|
||||
//! *instance*, named by its `id` — the kind unless a login named it,
|
||||
//! so two llama.cpp servers coexist under two ids; ids of instances
|
||||
//! discovered from env or the config are always their kind. One
|
||||
//! backend is *active*; handlers read the registry per
|
||||
//! dispatch, so a switch takes effect on the next turn without a
|
||||
//! restart. The active pair is deployment state: seeded from the config
|
||||
//! file, persisted in the settings store, changeable over `/v1/providers`
|
||||
//! — the chart pins nothing.
|
||||
//!
|
||||
//! The per-kind default models are fallbacks only, in one place here:
|
||||
//! openrouter's `openrouter/auto` deliberately replaces the old
|
||||
//! copy-pasted google default — a router deserves its own router id.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, PoisonError, RwLock, RwLockWriteGuard};
|
||||
|
||||
use rhema_autonomous_agent::async_trait;
|
||||
use rhema_core::{EventSink, ModelOutput, ModelProvider, ModelRequest, ProviderError};
|
||||
use rhema_provider_anthropic::{AnthropicProvider, ZAI_BASE_URL};
|
||||
use rhema_provider_copilot::CopilotProvider;
|
||||
use rhema_provider_google::GoogleProvider;
|
||||
use rhema_provider_llama_cpp::LlamaCppProvider;
|
||||
use rhema_provider_openai_compatible::OpenAiCompatibleProvider;
|
||||
use rhema_provider_openrouter::OpenRouterProvider;
|
||||
|
||||
use rhema_provider::ModelCard;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The default model per backend kind, when neither the operator nor the
|
||||
/// settings store names one.
|
||||
pub fn default_model_for(kind: &str) -> &'static str {
|
||||
match kind {
|
||||
"llama-cpp" => "local",
|
||||
"anthropic" => "claude-sonnet-4-5",
|
||||
"openai" => "gpt-5",
|
||||
"openrouter" => "openrouter/auto",
|
||||
"deepseek" => "deepseek-chat",
|
||||
// The Coding Plan's flagship, as Z.ai's own client spells it.
|
||||
"zai" => "GLM-5.3",
|
||||
"copilot" => "gpt-4.1",
|
||||
_ => "gemini-3.8-flash",
|
||||
}
|
||||
}
|
||||
|
||||
/// The listing of a card-backed backend, boxed for the registry; a
|
||||
/// backend without one (the mock, llama's raw id list) carries `None`.
|
||||
pub type ModelListing = Pin<Box<dyn Future<Output = Result<Vec<ModelCard>, ProviderError>> + Send>>;
|
||||
|
||||
/// One constructible backend instance: the provider seam, the id the
|
||||
/// registry keys it under, its kind, its model fallback, and how to
|
||||
/// list its models.
|
||||
pub struct Backend {
|
||||
/// The instance id: the kind unless the login named it. The
|
||||
/// registry's key, the credentials row's suffix, and the active
|
||||
/// pair's first half are all this string.
|
||||
pub id: String,
|
||||
/// The backend kind: `"google"`, `"llama-cpp"`, `"openrouter"`,
|
||||
/// `"anthropic"`, `"openai"`, `"mock"`.
|
||||
pub kind: String,
|
||||
/// The provider behind the daemon's `ModelProvider` seam.
|
||||
pub provider: Arc<dyn ModelProvider>,
|
||||
/// The model used when none is set for this kind.
|
||||
pub default_model: String,
|
||||
/// The live model listing, when the backend has one worth calling.
|
||||
list_models: Option<Arc<dyn Fn() -> ModelListing + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
/// A backend without a live listing (the mock). The id doubles as
|
||||
/// the kind: the registry-injected kinds are singletons.
|
||||
pub fn headless(kind: impl Into<String>, provider: Arc<dyn ModelProvider>) -> Self {
|
||||
let kind = kind.into();
|
||||
Self {
|
||||
id: kind.clone(),
|
||||
kind,
|
||||
provider,
|
||||
default_model: "mock".to_owned(),
|
||||
list_models: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The currently active backend instance: id plus the model turns run on.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Active {
|
||||
id: String,
|
||||
model: String,
|
||||
}
|
||||
|
||||
/// One row of the registry listing: the instance id, its kind, and the
|
||||
/// model fallback.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackendRow {
|
||||
/// The instance id.
|
||||
pub id: String,
|
||||
/// The backend kind.
|
||||
pub kind: String,
|
||||
/// The model used when none is set for this instance.
|
||||
pub default_model: String,
|
||||
}
|
||||
|
||||
/// The deployment's providers and the active pair, shared by every
|
||||
/// handler so a switch takes effect on the next dispatch. The map is
|
||||
/// keyed by instance id, not kind — two instances of one kind are two
|
||||
/// rows.
|
||||
pub struct BackendRegistry {
|
||||
backends: RwLock<BTreeMap<String, Arc<Backend>>>,
|
||||
active: RwLock<Active>,
|
||||
}
|
||||
|
||||
impl BackendRegistry {
|
||||
/// A registry over `backends`. The active pair is `seed` when that
|
||||
/// id exists, else the first backend's id and default model. An
|
||||
/// empty list is a construction error: the registry exists so
|
||||
/// handlers never face an absent backend.
|
||||
pub fn new(
|
||||
backends: Vec<Backend>,
|
||||
seed: Option<(String, Option<String>)>,
|
||||
) -> Result<Self, String> {
|
||||
if backends.is_empty() {
|
||||
return Err(
|
||||
"no provider backend is constructible: no key resolved for any provider".to_owned(),
|
||||
);
|
||||
}
|
||||
let map: BTreeMap<String, Arc<Backend>> = backends
|
||||
.into_iter()
|
||||
.map(|backend| (backend.id.clone(), Arc::new(backend)))
|
||||
.collect();
|
||||
let active = match seed {
|
||||
Some((id, model)) if map.contains_key(&id) => {
|
||||
let default = map[&id].default_model.clone();
|
||||
Active {
|
||||
id,
|
||||
model: model.unwrap_or(default),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let first = map.values().next().expect("non-empty above");
|
||||
Active {
|
||||
id: first.id.clone(),
|
||||
model: first.default_model.clone(),
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
backends: RwLock::new(map),
|
||||
active: RwLock::new(active),
|
||||
})
|
||||
}
|
||||
|
||||
/// Inserts (or replaces) `backend` under its id in the live
|
||||
/// registry: the write behind the credentials routes. The active
|
||||
/// pair is untouched — a replaced backend keeps serving under its
|
||||
/// id.
|
||||
pub fn insert(&self, backend: Backend) {
|
||||
let mut backends = self.backends_lock();
|
||||
backends.insert(backend.id.clone(), Arc::new(backend));
|
||||
}
|
||||
|
||||
/// The backend registered under `id`, when one is.
|
||||
pub fn get(&self, id: &str) -> Option<Arc<Backend>> {
|
||||
self.backends_lock().get(id).cloned()
|
||||
}
|
||||
|
||||
/// Removes the instance `id` from the live registry. Removing the
|
||||
/// last backend is refused: handlers never face an absent backend.
|
||||
/// When the removed instance was active, the first remaining
|
||||
/// backend (ascending) takes over with its default model — the
|
||||
/// caller re-persists the pair it reads back from [`Self::active`].
|
||||
pub fn remove(&self, id: &str) -> Result<(), String> {
|
||||
let mut backends = self.backends_lock();
|
||||
if backends.len() == 1 && backends.contains_key(id) {
|
||||
return Err("cannot remove the last provider backend".to_owned());
|
||||
}
|
||||
let removed_was_active = self.active_lock().id == id;
|
||||
if backends.remove(id).is_none() {
|
||||
return Err(format!("unknown provider backend {id:?}"));
|
||||
}
|
||||
if removed_was_active {
|
||||
let first = backends.values().next().expect("a backend remains");
|
||||
*self.active_lock() = Active {
|
||||
id: first.id.clone(),
|
||||
model: first.default_model.clone(),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The provider and model the next dispatch runs on.
|
||||
pub fn current(&self) -> (Arc<dyn ModelProvider>, String) {
|
||||
let active = self.active_lock();
|
||||
let backend = self.backends_lock()[&active.id].clone();
|
||||
(backend.provider.clone(), active.model.clone())
|
||||
}
|
||||
|
||||
/// The active pair as data: instance id and model.
|
||||
pub fn active(&self) -> (String, String) {
|
||||
let active = self.active_lock();
|
||||
(active.id.clone(), active.model.clone())
|
||||
}
|
||||
|
||||
/// Switches the active pair. An unknown instance id is an error; a
|
||||
/// model left unset falls back to that instance's default.
|
||||
pub fn set_active(&self, id: &str, model: Option<&str>) -> Result<(), String> {
|
||||
let default = {
|
||||
let backends = self.backends_lock();
|
||||
let backend = backends
|
||||
.get(id)
|
||||
.ok_or_else(|| format!("unknown provider backend {id:?}"))?;
|
||||
backend.default_model.clone()
|
||||
};
|
||||
let model = model.map(str::to_owned).unwrap_or_else(|| default);
|
||||
*self.active_lock() = Active {
|
||||
id: id.to_owned(),
|
||||
model,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every constructible backend as listing rows, ascending by id.
|
||||
pub fn list(&self) -> Vec<BackendRow> {
|
||||
self.backends_lock()
|
||||
.values()
|
||||
.map(|backend| BackendRow {
|
||||
id: backend.id.clone(),
|
||||
kind: backend.kind.clone(),
|
||||
default_model: backend.default_model.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The live model listing of the instance `id`, when the backend
|
||||
/// carries one.
|
||||
pub fn list_models(&self, id: &str) -> Option<ModelListing> {
|
||||
let backend = self.backends_lock().get(id)?.clone();
|
||||
backend.list_models.as_ref().map(|list| list())
|
||||
}
|
||||
|
||||
fn active_lock(&self) -> RwLockWriteGuard<'_, Active> {
|
||||
self.active.write().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn backends_lock(&self) -> RwLockWriteGuard<'_, BTreeMap<String, Arc<Backend>>> {
|
||||
self.backends
|
||||
.write()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
}
|
||||
|
||||
/// The credential-backed seed for the active pair when the config
|
||||
/// names no provider: the first discovered backend that resolved an
|
||||
/// actual credential — a stored row, an env key, or the stored rhema
|
||||
/// profile. The keyless llama.cpp fallback is skipped: it exists so
|
||||
/// the registry is never empty, not because anyone chose it, and with
|
||||
/// nothing keyed the registry serves it while the UI's add-a-provider
|
||||
/// state stays the honest answer. The model stays unset — the
|
||||
/// registry fills each kind's default.
|
||||
pub fn credential_seed(backends: &[Backend]) -> Option<(String, Option<String>)> {
|
||||
backends
|
||||
.iter()
|
||||
.find(|backend| backend.kind != "llama-cpp")
|
||||
.map(|backend| (backend.id.clone(), None))
|
||||
}
|
||||
|
||||
/// The concrete providers behind [`Backend`]: an enum because the live
|
||||
/// model listings are inherent methods, and the registry stores the
|
||||
/// provider behind the object-safe seam. Shared by `Arc`, never cloned.
|
||||
pub enum AnyProvider {
|
||||
/// The Gemini adapter.
|
||||
Google(GoogleProvider),
|
||||
/// The llama.cpp server adapter.
|
||||
LlamaCpp(LlamaCppProvider),
|
||||
/// The OpenRouter adapter.
|
||||
OpenRouter(OpenRouterProvider),
|
||||
/// The Anthropic Messages adapter.
|
||||
Anthropic(AnthropicProvider),
|
||||
/// The generic OpenAI-compatible adapter.
|
||||
OpenAiCompatible(OpenAiCompatibleProvider),
|
||||
/// DeepSeek through the OpenAI-compatible adapter at its own base.
|
||||
DeepSeek(OpenAiCompatibleProvider),
|
||||
/// Z.ai's GLM Coding Plan through the Anthropic-compatible route.
|
||||
Zai(AnthropicProvider),
|
||||
/// GitHub Copilot through the Copilot adapter.
|
||||
Copilot(CopilotProvider),
|
||||
}
|
||||
|
||||
impl AnyProvider {
|
||||
/// The kind's live model listing. Llama servers list their own
|
||||
/// `LlamaModel` rows, which carry ids only: mapped into the shared
|
||||
/// card shape with nothing but the id filled.
|
||||
pub async fn list_models(&self) -> Result<Vec<ModelCard>, ProviderError> {
|
||||
match self {
|
||||
AnyProvider::Google(provider) => provider.list_models().await,
|
||||
AnyProvider::Anthropic(provider) => provider.list_models().await,
|
||||
AnyProvider::OpenRouter(provider) => provider.list_models().await,
|
||||
AnyProvider::OpenAiCompatible(provider) => provider.list_models().await,
|
||||
AnyProvider::DeepSeek(provider) => provider.list_models().await,
|
||||
AnyProvider::Zai(provider) => provider.list_models().await,
|
||||
AnyProvider::Copilot(provider) => provider.list_models().await,
|
||||
AnyProvider::LlamaCpp(provider) => {
|
||||
let models = provider.list_models().await?;
|
||||
Ok(models
|
||||
.into_iter()
|
||||
.map(|model| ModelCard {
|
||||
display_name: model.id.clone(),
|
||||
id: model.id,
|
||||
description: String::new(),
|
||||
input_token_limit: model.context_length,
|
||||
output_token_limit: None,
|
||||
generation_methods: vec!["generateContent".to_owned()],
|
||||
pricing: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The boxed listing a [`Backend`] carries.
|
||||
pub fn boxed_listing(provider: Arc<Self>) -> Arc<dyn Fn() -> ModelListing + Send + Sync> {
|
||||
Arc::new(move || {
|
||||
let provider = provider.clone();
|
||||
Box::pin(async move { provider.list_models().await })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelProvider for AnyProvider {
|
||||
async fn complete(
|
||||
&self,
|
||||
request: ModelRequest,
|
||||
sink: &mut dyn EventSink,
|
||||
) -> Result<ModelOutput, ProviderError> {
|
||||
match self {
|
||||
AnyProvider::Google(provider) => provider.complete(request, sink).await,
|
||||
AnyProvider::LlamaCpp(provider) => provider.complete(request, sink).await,
|
||||
AnyProvider::OpenRouter(provider) => provider.complete(request, sink).await,
|
||||
AnyProvider::Anthropic(provider) => provider.complete(request, sink).await,
|
||||
AnyProvider::OpenAiCompatible(provider) => provider.complete(request, sink).await,
|
||||
AnyProvider::DeepSeek(provider) => provider.complete(request, sink).await,
|
||||
AnyProvider::Zai(provider) => provider.complete(request, sink).await,
|
||||
AnyProvider::Copilot(provider) => provider.complete(request, sink).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The env variable each keyed backend reads, in precedence order
|
||||
/// (checked before the stored rhema config's value, mirroring the
|
||||
/// frontend's env-wins rule).
|
||||
/// The env variable names a kind's credential reads, in precedence
|
||||
/// order — the list route's `source` computation needs the names, the
|
||||
/// discovery the values.
|
||||
pub fn env_key_names(kind: &str) -> &'static [&'static str] {
|
||||
env_vars_for(kind)
|
||||
}
|
||||
|
||||
fn env_vars_for(kind: &str) -> &'static [&'static str] {
|
||||
match kind {
|
||||
"google" => &["GOOGLE_API_KEY", "GEMINI_API_KEY"],
|
||||
"anthropic" => &["ANTHROPIC_API_KEY"],
|
||||
"openai" => &["OPENAI_API_KEY"],
|
||||
"openrouter" => &["OPENROUTER_API_KEY"],
|
||||
"deepseek" => &["DEEPSEEK_API_KEY"],
|
||||
"zai" => &["ZAI_API_KEY", "Z_AI_API_KEY"],
|
||||
"copilot" => &["GITHUB_COPILOT_TOKEN"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// DeepSeek speaks the OpenAI-compatible dialect at its own base.
|
||||
const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1";
|
||||
|
||||
/// The settings-store key prefix under which a backend instance's
|
||||
/// credentials persist: `provider.<id>` holds a [`ProviderParams`]
|
||||
/// JSON. Legacy rows stored `provider.<kind>` — the id doubling as the
|
||||
/// kind, which is what ids default to.
|
||||
pub const PROVIDER_KEY_PREFIX: &str = "provider.";
|
||||
|
||||
/// The settings key for one backend instance's stored credentials.
|
||||
pub fn provider_settings_key(id: &str) -> String {
|
||||
format!("{PROVIDER_KEY_PREFIX}{id}")
|
||||
}
|
||||
|
||||
/// Whether `id` may name a backend instance: trimmed already by the
|
||||
/// caller, non-empty, no whitespace, no `/`, at most 64 chars.
|
||||
/// Comparisons are exact.
|
||||
pub fn valid_instance_id(id: &str) -> bool {
|
||||
!id.is_empty() && id.len() <= 64 && !id.chars().any(|c| c.is_whitespace() || c == '/')
|
||||
}
|
||||
|
||||
/// The credentials a backend instance constructs from, as the settings
|
||||
/// store persists them: the kind the params construct, an API key, and
|
||||
/// for server-addressed kinds the URL. `api_key` never logs and never
|
||||
/// round-trips through the list route — the list says only where a
|
||||
/// credential came from.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct ProviderParams {
|
||||
/// The kind these params construct.
|
||||
pub kind: String,
|
||||
/// The API key (or GitHub token for copilot).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
/// The server address (llama.cpp).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
/// Every kind a runtime login can construct, ascending: the addable
|
||||
/// list the HTTP surface serves and the UI renders forms from.
|
||||
pub fn addable_kinds() -> &'static [&'static str] {
|
||||
&[
|
||||
"anthropic",
|
||||
"copilot",
|
||||
"deepseek",
|
||||
"google",
|
||||
"llama-cpp",
|
||||
"openai",
|
||||
"openrouter",
|
||||
"zai",
|
||||
]
|
||||
}
|
||||
|
||||
/// One field of a kind's login form: the name the credentials body
|
||||
/// spells it with, and whether the daemon refuses the login without it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct ParamField {
|
||||
/// The field name as the credentials route reads it.
|
||||
pub name: &'static str,
|
||||
/// Whether constructing without the field fails.
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
/// The parameter fields a kind's login takes, in form order — the wire
|
||||
/// shape under `addable` the UI builds its inputs from. `required`
|
||||
/// states what the daemon demands, not what the form shows: llama.cpp
|
||||
/// serves keyless on a default url, so neither of its fields gates the
|
||||
/// login; the six keyed kinds cannot construct without an `api_key`.
|
||||
pub fn param_fields_for(kind: &str) -> &'static [ParamField] {
|
||||
match kind {
|
||||
"llama-cpp" => &[
|
||||
ParamField {
|
||||
name: "url",
|
||||
required: false,
|
||||
},
|
||||
ParamField {
|
||||
name: "api_key",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
_ => &[ParamField {
|
||||
name: "api_key",
|
||||
required: true,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs one backend from `params`, its id defaulting to the
|
||||
/// kind. The per-kind requirements: keyed kinds want an `api_key`;
|
||||
/// llama.cpp wants a `url` (the keyless default still applies when
|
||||
/// neither is given). Unknown kinds are an error — "mock" is
|
||||
/// registry-injected, never constructed.
|
||||
pub fn construct(kind: &str, params: &ProviderParams) -> Result<Backend, String> {
|
||||
let provider = match kind {
|
||||
"google" => AnyProvider::Google(GoogleProvider::new(require_key(kind, params)?)),
|
||||
"anthropic" => AnyProvider::Anthropic(AnthropicProvider::new(require_key(kind, params)?)),
|
||||
"openai" => {
|
||||
AnyProvider::OpenAiCompatible(OpenAiCompatibleProvider::new(require_key(kind, params)?))
|
||||
}
|
||||
"openrouter" => {
|
||||
AnyProvider::OpenRouter(OpenRouterProvider::new(require_key(kind, params)?))
|
||||
}
|
||||
"deepseek" => AnyProvider::DeepSeek(
|
||||
OpenAiCompatibleProvider::new(require_key(kind, params)?)
|
||||
.with_base_url(DEEPSEEK_BASE_URL),
|
||||
),
|
||||
// Bearer auth: the scheme the Coding Plan route documents.
|
||||
"zai" => AnyProvider::Zai(
|
||||
AnthropicProvider::new(require_key(kind, params)?)
|
||||
.with_base_url(ZAI_BASE_URL)
|
||||
.with_bearer_auth(),
|
||||
),
|
||||
"copilot" => AnyProvider::Copilot(CopilotProvider::new(require_key(kind, params)?)),
|
||||
"llama-cpp" => {
|
||||
let mut provider = LlamaCppProvider::new(
|
||||
params
|
||||
.url
|
||||
.clone()
|
||||
.unwrap_or_else(|| rhema_config::Profile::default().llama_cpp_url()),
|
||||
);
|
||||
if let Some(key) = ¶ms.api_key {
|
||||
provider = provider.with_api_key(key);
|
||||
}
|
||||
AnyProvider::LlamaCpp(provider)
|
||||
}
|
||||
_ => return Err(format!("unknown provider kind {kind:?}")),
|
||||
};
|
||||
let default_model = default_model_for(kind).to_owned();
|
||||
let provider = Arc::new(provider);
|
||||
Ok(Backend {
|
||||
id: kind.to_owned(),
|
||||
kind: kind.to_owned(),
|
||||
// Turns ride the retry, so a transient failure costs a pause
|
||||
// instead of the turn; the listing stays on the raw adapter —
|
||||
// `list_models` is an inherent method the wrapper has no.
|
||||
provider: Arc::new(rhema_provider::Retrying::new(provider.clone())),
|
||||
default_model,
|
||||
list_models: Some(AnyProvider::boxed_listing(provider)),
|
||||
})
|
||||
}
|
||||
|
||||
/// The kind's API key, or the error the credentials route answers with.
|
||||
fn require_key(kind: &str, params: &ProviderParams) -> Result<String, String> {
|
||||
params
|
||||
.api_key
|
||||
.clone()
|
||||
.filter(|key| !key.trim().is_empty())
|
||||
.ok_or_else(|| format!("{kind} needs an api_key"))
|
||||
}
|
||||
|
||||
/// Discovers every constructible backend instance from the claw's own
|
||||
/// stored credentials, the environment, and the stored rhema config.
|
||||
/// Stored instances — the `provider.<id>` rows the HTTP surface wrote —
|
||||
/// boot under the id they were saved with, the row's inner `kind`
|
||||
/// naming what to construct (a legacy row without one keeps its id
|
||||
/// doubling as the kind). For each kind, the env credential outranks
|
||||
/// everything: it constructs the kind's own id, pushing a stored twin
|
||||
/// of the same id aside while the row stays persisted for a
|
||||
/// post-restart return. A stored instance of a kind outranks the
|
||||
/// profile fallback, which only constructs when neither env nor a
|
||||
/// stored row resolved. The keyless llama.cpp backend always
|
||||
/// constructs while no stored row of its kind exists, so the registry
|
||||
/// is never empty. The config's `provider`/`model` pair is the
|
||||
/// caller's seed for the active backend — deployment defaults, not a
|
||||
/// constraint. `stored_rows` reads the daemon's settings store, every
|
||||
/// `provider.*` row as `(id, params)`; a row that no longer constructs
|
||||
/// never blocks the boot.
|
||||
pub fn discover(
|
||||
config: &crate::config::ClawConfig,
|
||||
stored: &rhema_config::Profile,
|
||||
env: &dyn Fn(&str) -> Option<String>,
|
||||
stored_rows: &dyn Fn() -> Vec<(String, ProviderParams)>,
|
||||
secrets: Option<&dyn rhema_config::secrets::SecretStore>,
|
||||
) -> Vec<Backend> {
|
||||
let rows = stored_rows();
|
||||
let mut backends = Vec::new();
|
||||
|
||||
// The env credential each kind resolves from, if any.
|
||||
let env_key = |kind: &str| {
|
||||
env_vars_for(kind)
|
||||
.iter()
|
||||
.find_map(|name| env(name))
|
||||
.filter(|key| !key.trim().is_empty())
|
||||
};
|
||||
|
||||
// Stored instances first, under the id their row names. An
|
||||
// env-backed kind takes the id back from a stored twin of the same
|
||||
// id below — env wins the per-kind slot, the row stays persisted.
|
||||
for (id, params) in &rows {
|
||||
if id == ¶ms.kind && env_key(¶ms.kind).is_some() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(mut backend) = construct(¶ms.kind, params) {
|
||||
backend.id = id.clone();
|
||||
seed_llama_model(&mut backend, config);
|
||||
backends.push(backend);
|
||||
}
|
||||
}
|
||||
|
||||
for kind in [
|
||||
"llama-cpp",
|
||||
"google",
|
||||
"anthropic",
|
||||
"openai",
|
||||
"openrouter",
|
||||
"deepseek",
|
||||
"zai",
|
||||
"copilot",
|
||||
] {
|
||||
let stored_of_kind = rows.iter().any(|(_, params)| params.kind == kind);
|
||||
let params = if kind == "llama-cpp" {
|
||||
if stored_of_kind {
|
||||
// The stored rows own the kind now; the keyless default
|
||||
// would undo a removal of the kind's own id.
|
||||
continue;
|
||||
}
|
||||
// The keyless server backend always constructs, so the
|
||||
// registry is never empty.
|
||||
ProviderParams {
|
||||
kind: kind.to_owned(),
|
||||
api_key: None,
|
||||
url: Some(stored.llama_cpp_url()),
|
||||
}
|
||||
} else if let Some(api_key) = env_key(kind) {
|
||||
ProviderParams {
|
||||
kind: kind.to_owned(),
|
||||
api_key: Some(api_key),
|
||||
url: None,
|
||||
}
|
||||
} else if stored_of_kind {
|
||||
// The stored instance of the kind wins over the profile
|
||||
// fallback — the surface's login is the newer decision.
|
||||
continue;
|
||||
} else {
|
||||
// The stored rhema profile's key for the kind: the matching
|
||||
// named instance's. The keyless llama.cpp backend never
|
||||
// takes a key from the profile.
|
||||
let api_key = match kind {
|
||||
"llama-cpp" => None,
|
||||
_ => stored.stored_provider_key(kind, secrets),
|
||||
};
|
||||
ProviderParams {
|
||||
kind: kind.to_owned(),
|
||||
api_key,
|
||||
url: None,
|
||||
}
|
||||
};
|
||||
let has_creds = match kind {
|
||||
// The keyless server backend always constructs.
|
||||
"llama-cpp" => true,
|
||||
_ => params.api_key.is_some(),
|
||||
};
|
||||
if !has_creds {
|
||||
continue;
|
||||
}
|
||||
match construct(kind, ¶ms) {
|
||||
Ok(mut backend) => {
|
||||
seed_llama_model(&mut backend, config);
|
||||
backends.push(backend);
|
||||
}
|
||||
// An unconstructible kind (a blank key) never blocks the boot.
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
backends
|
||||
}
|
||||
|
||||
/// The config file's model seeds llama's fallback, as discovery always
|
||||
/// has: it names the deployment's local model when it names one at all.
|
||||
fn seed_llama_model(backend: &mut Backend, config: &crate::config::ClawConfig) {
|
||||
if backend.kind == "llama-cpp" {
|
||||
if let Some(model) = &config.model {
|
||||
backend.default_model = model.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rhema_config::secrets::SecretStore as _;
|
||||
use rhema_core::{Message, Role, StopReason, Usage};
|
||||
|
||||
/// A minimal scripted stand-in: registry tests never complete a turn.
|
||||
struct Stub;
|
||||
|
||||
#[async_trait]
|
||||
impl ModelProvider for Stub {
|
||||
async fn complete(
|
||||
&self,
|
||||
_req: ModelRequest,
|
||||
_sink: &mut dyn EventSink,
|
||||
) -> Result<ModelOutput, ProviderError> {
|
||||
Ok(ModelOutput {
|
||||
message: Message::text(Role::Assistant, "stub"),
|
||||
stop_reason: StopReason::EndTurn,
|
||||
usage: Usage::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn backend(kind: &str) -> Backend {
|
||||
// The real per-kind default, not headless's mock placeholder —
|
||||
// the fallback assertions depend on it.
|
||||
Backend {
|
||||
id: kind.to_owned(),
|
||||
kind: kind.to_owned(),
|
||||
provider: Arc::new(Stub),
|
||||
default_model: default_model_for(kind).to_owned(),
|
||||
list_models: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_credential_seed_is_the_first_keyed_backend_in_discovery_order() {
|
||||
// The keyless llama.cpp fallback never seeds: it exists so the
|
||||
// registry is never empty, not because anyone chose it.
|
||||
assert_eq!(
|
||||
credential_seed(&[backend("llama-cpp")]),
|
||||
None,
|
||||
"nothing keyed: no seed, the keyless server serves"
|
||||
);
|
||||
// The first keyed backend wins, in discovery order.
|
||||
assert_eq!(
|
||||
credential_seed(&[
|
||||
backend("llama-cpp"),
|
||||
backend("google"),
|
||||
backend("anthropic")
|
||||
]),
|
||||
Some(("google".to_owned(), None))
|
||||
);
|
||||
// A stored named row comes first in discovery order, whatever
|
||||
// its kind sorts to in the registry.
|
||||
let mut stored = backend("anthropic");
|
||||
stored.id = "work-key".to_owned();
|
||||
assert_eq!(
|
||||
credential_seed(&[stored, backend("google")]),
|
||||
Some(("work-key".to_owned(), None))
|
||||
);
|
||||
// The model stays unset: the registry fills the kind default.
|
||||
let (id, model) = credential_seed(&[backend("openai")]).unwrap();
|
||||
assert_eq!((id.as_str(), model), ("openai", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_seed_wins_when_the_kind_exists() {
|
||||
let registry = BackendRegistry::new(
|
||||
vec![backend("anthropic"), backend("google")],
|
||||
Some(("google".to_owned(), Some("gemini-3.8-flash".to_owned()))),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
registry.active(),
|
||||
("google".to_owned(), "gemini-3.8-flash".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seed_for_an_absent_kind_falls_back_to_the_first_backend() {
|
||||
let registry = BackendRegistry::new(
|
||||
vec![backend("anthropic"), backend("google")],
|
||||
Some(("openai".to_owned(), None)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(registry.active().0, "anthropic", "ascending first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_switch_falls_back_to_the_kind_default_without_a_model() {
|
||||
let registry =
|
||||
BackendRegistry::new(vec![backend("anthropic"), backend("google")], None).unwrap();
|
||||
registry.set_active("google", None).unwrap();
|
||||
assert_eq!(registry.active().1, "gemini-3.8-flash");
|
||||
registry
|
||||
.set_active("anthropic", Some("claude-opus-4-1"))
|
||||
.unwrap();
|
||||
assert_eq!(registry.active().1, "claude-opus-4-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_kind_is_rejected() {
|
||||
let registry = BackendRegistry::new(vec![backend("google")], None).unwrap();
|
||||
assert!(registry.set_active("openai", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_registry_is_a_construction_error() {
|
||||
assert!(BackendRegistry::new(vec![], None).is_err());
|
||||
}
|
||||
|
||||
fn params(kind: &str, api_key: Option<&str>, url: Option<&str>) -> ProviderParams {
|
||||
ProviderParams {
|
||||
kind: kind.to_owned(),
|
||||
api_key: api_key.map(str::to_owned),
|
||||
url: url.map(str::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_ids_exclude_whitespace_slashes_and_excess_length() {
|
||||
assert!(valid_instance_id("local-llama"));
|
||||
assert!(valid_instance_id(&"x".repeat(64)));
|
||||
assert!(!valid_instance_id(""));
|
||||
assert!(!valid_instance_id("a b"));
|
||||
assert!(!valid_instance_id("a/b"));
|
||||
assert!(!valid_instance_id(&"x".repeat(65)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_instances_of_a_kind_coexist_under_their_ids() {
|
||||
let mut local_a = backend("llama-cpp");
|
||||
local_a.id = "local-a".to_owned();
|
||||
let mut local_b = backend("llama-cpp");
|
||||
local_b.id = "local-b".to_owned();
|
||||
let registry = BackendRegistry::new(vec![local_a, local_b], None).unwrap();
|
||||
assert_eq!(registry.list().len(), 2);
|
||||
registry.set_active("local-b", None).unwrap();
|
||||
assert_eq!(registry.active().0, "local-b");
|
||||
// Removing the active id hands the pair to the remaining one.
|
||||
registry.remove("local-b").unwrap();
|
||||
assert_eq!(registry.active().0, "local-a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construct_demands_what_each_kind_needs() {
|
||||
// Keyed kinds want a non-blank key.
|
||||
assert!(construct("google", ¶ms("google", None, None)).is_err());
|
||||
assert!(construct("google", ¶ms("google", Some(" "), None)).is_err());
|
||||
assert!(construct("google", ¶ms("google", Some("k"), None)).is_ok());
|
||||
assert!(construct("deepseek", ¶ms("deepseek", Some("k"), None)).is_ok());
|
||||
assert!(construct("zai", ¶ms("zai", Some("k"), None)).is_ok());
|
||||
assert!(construct("copilot", ¶ms("copilot", Some("t"), None)).is_ok());
|
||||
assert!(construct("nope", ¶ms("nope", Some("k"), None)).is_err());
|
||||
// llama.cpp serves keyless on its default url.
|
||||
assert!(construct("llama-cpp", ¶ms("llama-cpp", None, None)).is_ok());
|
||||
assert!(construct("llama-cpp", ¶ms("llama-cpp", None, Some("http://h:1"))).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_builds_exactly_the_kinds_with_a_source() {
|
||||
let config = test_config();
|
||||
let profile = profile_with_google();
|
||||
let env = |_name: &str| None;
|
||||
let stored_rows = || vec![("deepseek".to_owned(), params("deepseek", Some("cd"), None))];
|
||||
let backends = discover(&config, &profile, &env, &stored_rows, None);
|
||||
let mut kinds: Vec<&str> = backends.iter().map(|b| b.kind.as_str()).collect();
|
||||
kinds.sort_unstable();
|
||||
assert_eq!(kinds, vec!["deepseek", "google", "llama-cpp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_boots_the_zai_kind_from_its_env_key() {
|
||||
let config = test_config();
|
||||
let profile = rhema_config::Profile::default();
|
||||
let env = |name: &str| (name == "ZAI_API_KEY").then(|| "zk".to_owned());
|
||||
let backends = discover(&config, &profile, &env, &|| Vec::new(), None);
|
||||
let kinds: Vec<&str> = backends.iter().map(|b| b.kind.as_str()).collect();
|
||||
assert!(
|
||||
kinds.contains(&"zai"),
|
||||
"the env key boots the zai backend: {kinds:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_boots_llama_even_when_nobody_stored_anything() {
|
||||
let config = test_config();
|
||||
let profile = rhema_config::Profile::default();
|
||||
let env = |_name: &str| None;
|
||||
let backends = discover(&config, &profile, &env, &|| Vec::new(), None);
|
||||
assert_eq!(backends.len(), 1);
|
||||
assert_eq!(backends[0].kind, "llama-cpp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keychain_ref_serves_the_profile_fallback_without_a_literal() {
|
||||
let config = test_config();
|
||||
// The stored instance logs in through the chain: no literal, a
|
||||
// live ref. Discovery resolves it through the store tier.
|
||||
let mut profile = rhema_config::Profile::default();
|
||||
profile.upsert_instance(
|
||||
"google",
|
||||
rhema_config::ProviderInstance {
|
||||
provider: "google".into(),
|
||||
api_key: None,
|
||||
secret_ref: Some("ref-1".into()),
|
||||
base_url: None,
|
||||
},
|
||||
);
|
||||
let store = rhema_config::secrets::VolatileSecretStore::new();
|
||||
store.set("ref-1", "chain-key").unwrap();
|
||||
let env = |_name: &str| None;
|
||||
let backends = discover(&config, &profile, &env, &|| Vec::new(), Some(&store));
|
||||
assert!(
|
||||
backends.iter().any(|b| b.kind == "google"),
|
||||
"the keychain key builds the backend"
|
||||
);
|
||||
// Without the store the ref is unreadable and the kind is not
|
||||
// constructible — the honest logged-out shape, not a guess.
|
||||
let backends = discover(&config, &profile, &env, &|| Vec::new(), None);
|
||||
assert!(!backends.iter().any(|b| b.kind == "google"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_row_boots_under_its_id_and_keeps_the_default_slot_empty() {
|
||||
let config = test_config();
|
||||
let profile = profile_with_google();
|
||||
let env = |_name: &str| None;
|
||||
let stored_rows = || {
|
||||
vec![(
|
||||
"local-llama".to_owned(),
|
||||
params("llama-cpp", None, Some("http://h:1")),
|
||||
)]
|
||||
};
|
||||
let backends = discover(&config, &profile, &env, &stored_rows, None);
|
||||
let mut ids: Vec<&str> = backends.iter().map(|b| b.id.as_str()).collect();
|
||||
ids.sort_unstable();
|
||||
assert_eq!(ids, vec!["google", "local-llama"]);
|
||||
// The stored row owns the kind: the keyless default id does not
|
||||
// resurrect beside it.
|
||||
assert!(!ids.contains(&"llama-cpp"));
|
||||
let llama = backends.iter().find(|b| b.id == "local-llama").unwrap();
|
||||
assert_eq!(llama.kind, "llama-cpp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_takes_the_kind_id_back_from_a_stored_twin() {
|
||||
let config = test_config();
|
||||
let profile = rhema_config::Profile::default();
|
||||
let env = |name: &str| (name == "GOOGLE_API_KEY").then(|| "from-env".to_owned());
|
||||
let stored_rows = || vec![("google".to_owned(), params("google", Some("stored"), None))];
|
||||
let backends = discover(&config, &profile, &env, &stored_rows, None);
|
||||
let google: Vec<&Backend> = backends.iter().filter(|b| b.kind == "google").collect();
|
||||
assert_eq!(google.len(), 1, "env wins the id, no twin survives");
|
||||
assert_eq!(google[0].id, "google");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_stored_under_its_kind_id_boots_as_its_kind() {
|
||||
let config = test_config();
|
||||
let profile = rhema_config::Profile::default();
|
||||
let env = |_name: &str| None;
|
||||
let stored_rows = || vec![("anthropic".to_owned(), params("anthropic", Some("k"), None))];
|
||||
let backends = discover(&config, &profile, &env, &stored_rows, None);
|
||||
let anthropic = backends
|
||||
.iter()
|
||||
.find(|b| b.id == "anthropic")
|
||||
.expect("the stored row boots");
|
||||
assert_eq!(anthropic.kind, "anthropic");
|
||||
assert_eq!(anthropic.default_model, "claude-sonnet-4-5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_replaces_and_remove_moves_the_active_pair() {
|
||||
let registry = BackendRegistry::new(
|
||||
vec![backend("anthropic"), backend("google")],
|
||||
Some(("google".to_owned(), None)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// A replace: the kind count holds, the default may change.
|
||||
registry.insert(backend("google"));
|
||||
assert_eq!(registry.list().len(), 2);
|
||||
|
||||
// An addition joins the list.
|
||||
registry.insert(backend("openai"));
|
||||
assert_eq!(registry.list().len(), 3);
|
||||
|
||||
// Removing the active kind hands the pair to the first
|
||||
// remaining backend, ascending.
|
||||
registry.remove("google").unwrap();
|
||||
assert_eq!(registry.active().0, "anthropic");
|
||||
|
||||
// The last backend refuses to go.
|
||||
registry.remove("openai").unwrap();
|
||||
assert!(registry.remove("anthropic").is_err());
|
||||
assert_eq!(registry.list().len(), 1);
|
||||
}
|
||||
|
||||
fn test_config() -> crate::config::ClawConfig {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"poll_interval_secs": 60,
|
||||
"watches": []
|
||||
}))
|
||||
.expect("a minimal claw config")
|
||||
}
|
||||
|
||||
fn profile_with_google() -> rhema_config::Profile {
|
||||
let mut profile = rhema_config::Profile::default();
|
||||
profile.upsert_instance(
|
||||
"google",
|
||||
rhema_config::ProviderInstance {
|
||||
provider: "google".into(),
|
||||
api_key: Some("pg".into()),
|
||||
secret_ref: None,
|
||||
base_url: None,
|
||||
},
|
||||
);
|
||||
profile
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
//! The bootstrap layer: the resident agent's user-editable identity
|
||||
//! files, the system prompt rendered from them per dispatch, and the
|
||||
//! one tool the model writes them with.
|
||||
//!
|
||||
//! Context is earned (framework spec §9): one small always-injected
|
||||
//! bootstrap doc, and nothing else force-injected — memory stays in
|
||||
//! `rhema-memory`, pulled via tools. SOUL.md and USER.md are the earned
|
||||
//! exceptions: tiny, stable, high-signal, and written only through
|
||||
//! [`BootstrapSave`], so the model never free-writes config paths.
|
||||
//! ENVIRONMENT.md lives in the same store but is generated at boot by
|
||||
//! the environment module; the save path refuses it. The store's root
|
||||
//! is handed over through [`Bootstrap::root`] for exactly that writer.
|
||||
//!
|
||||
//! The template ([`DEFAULT_SYSTEM_MD`]) is data next to the code, the
|
||||
//! coding agent's house pattern, and it renders through the agent
|
||||
//! toolkit's Jinja renderer — one template language for both agents:
|
||||
//! `{{ workspace }}`, the three file sections from the live files, a
|
||||
//! `{% for %}` over `tools` from the dispatch's own resolver chain, so
|
||||
//! the roster the prompt shows is the roster the model gets. A present
|
||||
//! file replaces its placeholder verbatim — never appended to, never
|
||||
//! re-parsed; a missing one renders its stub. The `{% if onboarding %}`
|
||||
//! section renders only while either file is unwritten, so the offer
|
||||
//! self-terminates.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use rhema_agent_tools::render_template;
|
||||
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||
use rhema_core::ToolSpec;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The built-in system prompt, kept as data next to the code and read
|
||||
/// in whole — placeholders filled per dispatch by [`render_system`].
|
||||
pub const DEFAULT_SYSTEM_MD: &str = include_str!("../SYSTEM.md");
|
||||
|
||||
/// The cap on one bootstrap file: these documents enter every request,
|
||||
/// so a bigger write is a misuse of the channel, refused loudly.
|
||||
pub const MAX_CONTENT_BYTES: usize = 16 * 1024;
|
||||
|
||||
/// The save tool's name — allowlists reference it.
|
||||
pub const BOOTSTRAP_SAVE: &str = "bootstrap_save";
|
||||
|
||||
/// One of the three bootstrap files. SOUL.md and USER.md are the
|
||||
/// model-written pair; ENVIRONMENT.md is the boot-generated one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum BootstrapKind {
|
||||
/// SOUL.md — who the claw is: name, tone, purpose.
|
||||
Soul,
|
||||
/// USER.md — who the user is.
|
||||
User,
|
||||
/// ENVIRONMENT.md — the machine, written by the environment
|
||||
/// module's boot pass; never through [`Bootstrap::save`].
|
||||
Environment,
|
||||
}
|
||||
|
||||
impl BootstrapKind {
|
||||
/// The file name under the store's root — uppercase, the house
|
||||
/// spelling.
|
||||
pub fn file_name(self) -> &'static str {
|
||||
match self {
|
||||
BootstrapKind::Soul => "SOUL.md",
|
||||
BootstrapKind::User => "USER.md",
|
||||
BootstrapKind::Environment => "ENVIRONMENT.md",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the spelling the tool schema uses. `None` for anything
|
||||
/// else.
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
match name.trim() {
|
||||
"soul" => Some(BootstrapKind::Soul),
|
||||
"user" => Some(BootstrapKind::User),
|
||||
"environment" => Some(BootstrapKind::Environment),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a bootstrap operation failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BootstrapError {
|
||||
/// The store's directory could not be created, or a file could not
|
||||
/// be read or written.
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
/// ENVIRONMENT.md is boot-generated; the save path refuses it.
|
||||
#[error("ENVIRONMENT.md is generated at boot; it is not saveable through bootstrap_save")]
|
||||
EnvironmentNotSaveable,
|
||||
/// The content is over [`MAX_CONTENT_BYTES`]: a bootstrap file
|
||||
/// enters every request, so a bigger one is refused, not trimmed.
|
||||
#[error("bootstrap content is {size} bytes; the cap is {MAX_CONTENT_BYTES} bytes")]
|
||||
TooLarge {
|
||||
/// The rejected content's size, in bytes.
|
||||
size: usize,
|
||||
},
|
||||
/// The system-prompt template failed to render — bad syntax, unknown
|
||||
/// filter — with the template position when known. The dispatch
|
||||
/// refuses to run on a prompt it cannot fully render.
|
||||
#[error("system prompt template: {0}")]
|
||||
Template(String),
|
||||
}
|
||||
|
||||
/// Which of the onboarding files exist — the badge the HTTP surface
|
||||
/// serves and the onboarding section's off switch.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct BootstrapStatus {
|
||||
/// SOUL.md exists.
|
||||
pub soul: bool,
|
||||
/// USER.md exists.
|
||||
pub user: bool,
|
||||
}
|
||||
|
||||
/// The identity-file store: three markdown files under one root —
|
||||
/// SOUL.md, USER.md, ENVIRONMENT.md. Its only writers are
|
||||
/// [`BootstrapSave`] (the model's path, SOUL/USER only) and the
|
||||
/// onboarding flow through it; the environment module writes
|
||||
/// ENVIRONMENT.md directly at [`Bootstrap::root`].
|
||||
pub struct Bootstrap {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl Bootstrap {
|
||||
/// Opens the store at `root`, creating the directory: a fresh
|
||||
/// install has no files, which is the pending-onboarding state, not
|
||||
/// an error.
|
||||
pub fn new(root: impl Into<PathBuf>) -> Result<Self, BootstrapError> {
|
||||
let root = root.into();
|
||||
std::fs::create_dir_all(&root)?;
|
||||
Ok(Self { root })
|
||||
}
|
||||
|
||||
/// The store's root — the documented seam the daemon hands to the
|
||||
/// environment module, whose boot pass writes ENVIRONMENT.md here.
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// The file's text, `None` while it is unwritten.
|
||||
pub fn load(&self, kind: BootstrapKind) -> Result<Option<String>, BootstrapError> {
|
||||
match std::fs::read_to_string(self.root.join(kind.file_name())) {
|
||||
Ok(text) => Ok(Some(text)),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Overwrites the file with `content`, verbatim. ENVIRONMENT.md is
|
||||
/// refused — it is generated at boot, never model-written.
|
||||
pub fn save(&self, kind: BootstrapKind, content: &str) -> Result<(), BootstrapError> {
|
||||
if kind == BootstrapKind::Environment {
|
||||
return Err(BootstrapError::EnvironmentNotSaveable);
|
||||
}
|
||||
let size = content.len();
|
||||
if size > MAX_CONTENT_BYTES {
|
||||
return Err(BootstrapError::TooLarge { size });
|
||||
}
|
||||
std::fs::write(self.root.join(kind.file_name()), content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Which of the onboarding files exist, read live.
|
||||
pub fn status(&self) -> Result<BootstrapStatus, BootstrapError> {
|
||||
Ok(BootstrapStatus {
|
||||
soul: self.load(BootstrapKind::Soul)?.is_some(),
|
||||
user: self.load(BootstrapKind::User)?.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the onboarding flow still applies: either file is
|
||||
/// unwritten. An unreadable store counts as pending — the worst
|
||||
/// case is an offered setup, never a silently skipped one.
|
||||
pub fn needs_onboarding(&self) -> bool {
|
||||
!self.status().is_ok_and(|status| status.soul && status.user)
|
||||
}
|
||||
}
|
||||
|
||||
/// The stub a missing SOUL.md or USER.md renders.
|
||||
const PENDING: &str = "(pending onboarding)";
|
||||
|
||||
/// The stub a missing ENVIRONMENT.md renders; the environment module's
|
||||
/// boot pass writes the real file.
|
||||
const GENERATED_AT_BOOT: &str = "(generated at boot)";
|
||||
|
||||
/// The offered onboarding, rendered while either file is unwritten. It
|
||||
/// self-terminates: the per-dispatch status check stops rendering it
|
||||
/// the moment both files exist. The offer is made in chat and may be
|
||||
/// declined — never an unprompted interrogation. Compiled in like the
|
||||
/// base template (`../ONBOARDING.md`), for the same reason: the prompt
|
||||
/// is code-reviewed text, not runtime state.
|
||||
const ONBOARDING_MD: &str = include_str!("../ONBOARDING.md");
|
||||
|
||||
/// One rendered markdown section: header, blank line, the file's text
|
||||
/// — or the stub while it is unwritten.
|
||||
fn section(header: &str, content: Option<&str>, stub: &str) -> String {
|
||||
format!("{header}\n\n{}", content.unwrap_or(stub))
|
||||
}
|
||||
|
||||
/// Renders the system prompt for one dispatch through the agent
|
||||
/// toolkit's Jinja renderer: `{{ workspace }}` and the three file
|
||||
/// sections from the live store, `tools` from the dispatch's own
|
||||
/// resolver specs, `{{ session }}` — the dispatching key, the address
|
||||
/// the model schedules its own wakes to — and `onboarding`, the offer
|
||||
/// the template's `{% if %}` shows only while a file is unwritten.
|
||||
/// Fallible on purpose — a store that cannot be read surfaces loudly
|
||||
/// instead of rendering a prompt without it, and a template that
|
||||
/// cannot render fails the dispatch before any model call.
|
||||
pub fn render_system(
|
||||
template: &str,
|
||||
session: &str,
|
||||
workspace: &Path,
|
||||
bootstrap: &Bootstrap,
|
||||
tools: &[ToolSpec],
|
||||
) -> Result<String, BootstrapError> {
|
||||
let soul = bootstrap.load(BootstrapKind::Soul)?;
|
||||
let user = bootstrap.load(BootstrapKind::User)?;
|
||||
let environment = bootstrap.load(BootstrapKind::Environment)?;
|
||||
let onboarding = if soul.is_none() || user.is_none() {
|
||||
ONBOARDING_MD
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let context = json!({
|
||||
"session": session,
|
||||
"workspace": workspace.display().to_string(),
|
||||
"soul": section("## SOUL", soul.as_deref(), PENDING),
|
||||
"user": section("## USER", user.as_deref(), PENDING),
|
||||
"environment": section("## ENVIRONMENT", environment.as_deref(), GENERATED_AT_BOOT),
|
||||
"onboarding": onboarding,
|
||||
"tools": tools,
|
||||
});
|
||||
render_template(template, &context).map_err(|err| {
|
||||
let line = err
|
||||
.line()
|
||||
.map(|line| format!(", line {line}"))
|
||||
.unwrap_or_default();
|
||||
BootstrapError::Template(format!("{err}{line}"))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SaveArgs {
|
||||
kind: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
/// Writes SOUL.md or USER.md through the [`Bootstrap`] store — the only
|
||||
/// writer the model gets, deliberately allowlistable and deterministic.
|
||||
pub struct BootstrapSave {
|
||||
bootstrap: Arc<Bootstrap>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for BootstrapSave {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
BOOTSTRAP_SAVE,
|
||||
"Write one of your identity files. `kind` is `soul` (who you \
|
||||
are: name, tone, purpose) or `user` (who the user is). \
|
||||
Overwrites the file, so send the whole document every time. \
|
||||
Both files enter every request: keep each one small, \
|
||||
stable, and high-signal.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "enum": ["soul", "user"],
|
||||
"description": "Which file to write: soul or user." },
|
||||
"content": { "type": "string",
|
||||
"description": "The file's full markdown contents." }
|
||||
},
|
||||
"required": ["kind", "content"]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||
let args: SaveArgs =
|
||||
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||
let kind = BootstrapKind::parse(&args.kind)
|
||||
.ok_or_else(|| format!("unknown kind {:?}: soul or user", args.kind))?;
|
||||
self.bootstrap
|
||||
.save(kind, &args.content)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(format!("saved {}", kind.file_name()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The bootstrap tool set over `store`: `bootstrap_save`, the shape the
|
||||
/// interactive resolver chain composes.
|
||||
pub fn registry(bootstrap: Arc<Bootstrap>) -> ToolRegistry {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(BootstrapSave { bootstrap }));
|
||||
registry
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store() -> (tempfile::TempDir, Bootstrap) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let bootstrap = Bootstrap::new(dir.path().join("bootstrap")).expect("store opens");
|
||||
(dir, bootstrap)
|
||||
}
|
||||
|
||||
/// ENVIRONMENT.md is written directly — the environment module's
|
||||
/// boot pass is its only writer, never [`Bootstrap::save`].
|
||||
fn write_environment(bootstrap: &Bootstrap, content: &str) {
|
||||
std::fs::write(bootstrap.root().join("ENVIRONMENT.md"), content).expect("write");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_creates_the_store_directory() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let nested = dir.path().join("a").join("bootstrap");
|
||||
let bootstrap = Bootstrap::new(&nested).expect("store opens");
|
||||
assert!(nested.is_dir());
|
||||
assert_eq!(bootstrap.root(), nested.as_path());
|
||||
assert!(bootstrap.needs_onboarding(), "a fresh store is pending");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_parse_and_name_their_files() {
|
||||
assert_eq!(BootstrapKind::parse("soul"), Some(BootstrapKind::Soul));
|
||||
assert_eq!(BootstrapKind::parse(" user "), Some(BootstrapKind::User));
|
||||
assert_eq!(
|
||||
BootstrapKind::parse("environment"),
|
||||
Some(BootstrapKind::Environment)
|
||||
);
|
||||
assert_eq!(BootstrapKind::parse("soul.md"), None);
|
||||
assert_eq!(BootstrapKind::parse(""), None);
|
||||
for kind in [
|
||||
BootstrapKind::Soul,
|
||||
BootstrapKind::User,
|
||||
BootstrapKind::Environment,
|
||||
] {
|
||||
let name = kind.file_name().to_lowercase();
|
||||
assert_eq!(
|
||||
BootstrapKind::parse(name.trim_end_matches(".md")),
|
||||
Some(kind),
|
||||
"{}",
|
||||
kind.file_name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_load_round_trips_and_overwrites() {
|
||||
let (_dir, bootstrap) = store();
|
||||
assert_eq!(bootstrap.load(BootstrapKind::Soul).expect("reads"), None);
|
||||
bootstrap
|
||||
.save(BootstrapKind::Soul, "I am claw.\n")
|
||||
.expect("saves");
|
||||
assert_eq!(
|
||||
bootstrap.load(BootstrapKind::Soul).expect("reads"),
|
||||
Some("I am claw.\n".to_owned()),
|
||||
"saved verbatim"
|
||||
);
|
||||
bootstrap
|
||||
.save(BootstrapKind::Soul, "I am claw, v2.")
|
||||
.expect("overwrites");
|
||||
assert_eq!(
|
||||
bootstrap.load(BootstrapKind::Soul).expect("reads"),
|
||||
Some("I am claw, v2.".to_owned())
|
||||
);
|
||||
let status = bootstrap.status().expect("status reads");
|
||||
assert!(status.soul);
|
||||
assert!(!status.user);
|
||||
assert!(bootstrap.needs_onboarding(), "user is still unwritten");
|
||||
bootstrap
|
||||
.save(BootstrapKind::User, "the user is Ana.")
|
||||
.expect("saves");
|
||||
assert!(!bootstrap.needs_onboarding(), "both files exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_size_cap_is_loud_not_trimmed() {
|
||||
let (_dir, bootstrap) = store();
|
||||
let too_big = "x".repeat(MAX_CONTENT_BYTES + 1);
|
||||
let err = bootstrap
|
||||
.save(BootstrapKind::Soul, &too_big)
|
||||
.expect_err("over the cap is loud");
|
||||
assert!(err.to_string().contains("bytes"), "{err}");
|
||||
assert!(
|
||||
bootstrap
|
||||
.load(BootstrapKind::Soul)
|
||||
.expect("reads")
|
||||
.is_none(),
|
||||
"nothing was written"
|
||||
);
|
||||
let at_cap = "x".repeat(MAX_CONTENT_BYTES);
|
||||
bootstrap
|
||||
.save(BootstrapKind::User, &at_cap)
|
||||
.expect("exactly the cap saves");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_refuses_save_everywhere() {
|
||||
let (_dir, bootstrap) = store();
|
||||
let err = bootstrap
|
||||
.save(BootstrapKind::Environment, "machine facts")
|
||||
.expect_err("environment is not saveable");
|
||||
assert!(
|
||||
err.to_string().contains("generated at boot"),
|
||||
"the refusal names the reason: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_house_renders_stubs_and_the_onboarding_section() {
|
||||
let (_dir, bootstrap) = store();
|
||||
let tools = registry(Arc::new(Bootstrap {
|
||||
root: bootstrap.root().to_path_buf(),
|
||||
}))
|
||||
.resolver_for(&rhema_autonomous_agent::Allowlist::permissive())
|
||||
.specs();
|
||||
let prompt = render_system(
|
||||
DEFAULT_SYSTEM_MD,
|
||||
"interactive:web",
|
||||
Path::new("/ws"),
|
||||
&bootstrap,
|
||||
&tools,
|
||||
)
|
||||
.expect("renders");
|
||||
assert!(prompt.contains("## SOUL"), "{prompt}");
|
||||
assert!(prompt.contains("## USER"), "{prompt}");
|
||||
assert!(prompt.contains("## ENVIRONMENT"), "{prompt}");
|
||||
assert_eq!(prompt.matches(PENDING).count(), 2, "{prompt}");
|
||||
assert!(prompt.contains(GENERATED_AT_BOOT), "{prompt}");
|
||||
assert!(prompt.contains("## Setup"), "{prompt}");
|
||||
assert!(prompt.contains("/ws"), "the workspace named: {prompt}");
|
||||
assert!(prompt.contains("bootstrap_save"), "{prompt}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_full_house_renders_each_file_and_drops_the_onboarding_section() {
|
||||
let (_dir, bootstrap) = store();
|
||||
bootstrap
|
||||
.save(BootstrapKind::Soul, "I am claw: terse, warm, relentless.")
|
||||
.expect("saves");
|
||||
bootstrap
|
||||
.save(BootstrapKind::User, "The user is Ana; she ships compilers.")
|
||||
.expect("saves");
|
||||
write_environment(&bootstrap, "Linux, 32 cores, no GPU.");
|
||||
let prompt = render_system(
|
||||
DEFAULT_SYSTEM_MD,
|
||||
"interactive:web",
|
||||
Path::new("/ws"),
|
||||
&bootstrap,
|
||||
&[],
|
||||
)
|
||||
.expect("renders");
|
||||
assert!(
|
||||
prompt.contains("I am claw: terse, warm, relentless."),
|
||||
"{prompt}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("The user is Ana; she ships compilers."),
|
||||
"{prompt}"
|
||||
);
|
||||
assert!(prompt.contains("Linux, 32 cores, no GPU."), "{prompt}");
|
||||
assert!(!prompt.contains(PENDING), "{prompt}");
|
||||
assert!(!prompt.contains(GENERATED_AT_BOOT), "{prompt}");
|
||||
assert!(!prompt.contains("## Setup"), "{prompt}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_user_file_replaces_its_placeholder_verbatim() {
|
||||
let (_dir, bootstrap) = store();
|
||||
let content = "the user is Ana.\n\n- braces stay: {{ keep }} and {soul}\n";
|
||||
bootstrap.save(BootstrapKind::User, content).expect("saves");
|
||||
let prompt = render_system(
|
||||
DEFAULT_SYSTEM_MD,
|
||||
"interactive:web",
|
||||
Path::new("/ws"),
|
||||
&bootstrap,
|
||||
&[],
|
||||
)
|
||||
.expect("renders");
|
||||
assert!(prompt.contains(content), "{prompt}");
|
||||
assert!(
|
||||
prompt.contains("{soul}"),
|
||||
"a file's own placeholder-looking text is never substituted: {prompt}"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("## USER\n\n\n"),
|
||||
"the file rides under its header, not appended: {prompt}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tool_specs_render_with_their_schemas() {
|
||||
let (_dir, bootstrap) = store();
|
||||
let tools = vec![ToolSpec::new(
|
||||
"my_tool",
|
||||
"its purpose",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": { "kind": { "type": "string" } },
|
||||
"required": ["kind"]
|
||||
}),
|
||||
)];
|
||||
let prompt = render_system(
|
||||
DEFAULT_SYSTEM_MD,
|
||||
"interactive:web",
|
||||
Path::new("/ws"),
|
||||
&bootstrap,
|
||||
&tools,
|
||||
)
|
||||
.expect("renders");
|
||||
assert!(prompt.contains("- my_tool | its purpose | "), "{prompt}");
|
||||
assert!(
|
||||
prompt.contains(r#""required": ["kind"]"#),
|
||||
"the JSON schema rendered: {prompt}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_tool_writes_through_the_store_and_answers_one_line() {
|
||||
use rhema_autonomous_agent::Allowlist;
|
||||
use rhema_core::{ToolCall, ToolContext};
|
||||
|
||||
let (_dir, bootstrap) = store();
|
||||
let resolver = registry(Arc::new(Bootstrap {
|
||||
root: bootstrap.root().to_path_buf(),
|
||||
}))
|
||||
.resolver_for(&Allowlist::permissive());
|
||||
let ctx = ToolContext::default();
|
||||
let call = |id: &str, args: Value| {
|
||||
resolver.resolve(
|
||||
ToolCall {
|
||||
id: id.into(),
|
||||
name: BOOTSTRAP_SAVE.into(),
|
||||
args,
|
||||
provider_data: Value::Null,
|
||||
},
|
||||
&ctx,
|
||||
)
|
||||
};
|
||||
|
||||
let saved = call(
|
||||
"c1",
|
||||
json!({"kind": "soul", "content": "I am claw: terse."}),
|
||||
)
|
||||
.await;
|
||||
assert!(!saved.is_error, "{}", saved.content);
|
||||
assert_eq!(saved.content, "saved SOUL.md");
|
||||
assert_eq!(
|
||||
bootstrap.load(BootstrapKind::Soul).expect("reads"),
|
||||
Some("I am claw: terse.".to_owned())
|
||||
);
|
||||
|
||||
// A kind the schema does not offer still cannot reach the file:
|
||||
// the refusal comes back in band, with the reason.
|
||||
let environment = call(
|
||||
"c2",
|
||||
json!({"kind": "environment", "content": "machine facts"}),
|
||||
)
|
||||
.await;
|
||||
assert!(environment.is_error);
|
||||
assert!(
|
||||
environment.content.contains("generated at boot"),
|
||||
"{}",
|
||||
environment.content
|
||||
);
|
||||
|
||||
let unknown = call("c3", json!({"kind": "persona", "content": "x"})).await;
|
||||
assert!(unknown.is_error);
|
||||
assert!(
|
||||
unknown.content.contains("unknown kind"),
|
||||
"{}",
|
||||
unknown.content
|
||||
);
|
||||
|
||||
let malformed = call("c4", json!({"kind": "soul"})).await;
|
||||
assert!(malformed.is_error);
|
||||
assert!(
|
||||
malformed.content.contains("bad arguments"),
|
||||
"{}",
|
||||
malformed.content
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
//! The calendar source: deterministic detection of due reminders, the
|
||||
//! task scheduler's shape over a calendar feed.
|
||||
//!
|
||||
//! The [`CalendarSource`] is a [`Source`] named `"calendars"` on the
|
||||
//! resident dispatcher: its `detect` pass polls every configured
|
||||
//! calendar for the occurrences starting within its horizon and
|
||||
//! ingests one event per due reminder slot, a (occurrence, lead time)
|
||||
//! pair whose moment has come. The dedup id carries all three facts
|
||||
//! (`{calendar}:{occurrence id}:{lead}`), so a slot feeds once and its
|
||||
//! repeats dedup away in the dispatcher, and the next lead's slot is a
|
||||
//! different id firing later. A slot whose start has passed is never
|
||||
//! fed: a reminder never fires after the fact. All-day occurrences
|
||||
//! never fire: a lead in minutes against a midnight start is noise,
|
||||
//! and a date-shaped nudge is what the task tools are for.
|
||||
//!
|
||||
//! The model is never reached here; the turn happens because the
|
||||
//! dispatcher admitted the `calendar:<name>` key, exactly as for a
|
||||
//! task firing, and the resident handler's delivery rule auto-notifies
|
||||
//! the final text to the user's devices. One session per calendar
|
||||
//! persists across its reminders, so the claw remembers what it said
|
||||
//! about the last one. A dead feed is logged and skipped, never fatal:
|
||||
//! an outage must not eat the other calendars' reminders on the same
|
||||
//! pass. Cadence belongs to the daemon's loop.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rhema_autonomous_agent::{Source, SourceError, async_trait};
|
||||
use rhema_calendar_integration::{
|
||||
CaldavCalendar, Calendar, CalendarEvent, GoogleCalendar, IcsCalendar, Window,
|
||||
};
|
||||
use rhema_rsd::{Clock, Dispatcher, Event, KeyFn};
|
||||
use serde_json::json;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use crate::config::{CalendarKind, CalendarWatch};
|
||||
use crate::interactive::passthrough_key_fn;
|
||||
|
||||
/// The source name the calendar reminders ingest under; registered on
|
||||
/// the resident dispatcher, so allowlists and `/v1/ingest` can name it.
|
||||
pub const CALENDARS_SOURCE: &str = "calendars";
|
||||
|
||||
/// The env variable a calendar's token falls back to.
|
||||
pub const TOKEN_ENV: &str = "CALENDAR_TOKEN";
|
||||
|
||||
/// The env variable a CalDAV password falls back to.
|
||||
pub const CALDAV_PASSWORD_ENV: &str = "CALDAV_PASSWORD";
|
||||
|
||||
/// One calendar's reminder key: `calendar:<name>` — the session the
|
||||
/// reminder turns run in, persistent across the calendar's reminders.
|
||||
pub fn calendar_key(name: &str) -> String {
|
||||
format!("calendar:{name}")
|
||||
}
|
||||
|
||||
/// One configured calendar, resolved to a client the source polls.
|
||||
pub(crate) struct Feed {
|
||||
name: String,
|
||||
client: Arc<dyn Calendar>,
|
||||
leads: Vec<u64>,
|
||||
horizon_hours: u32,
|
||||
}
|
||||
|
||||
/// The client one calendar watch polls through. Credentials resolve
|
||||
/// before construction: the api key wins for Google, else the token
|
||||
/// (config first, then [`TOKEN_ENV`]), and a feed takes its token or
|
||||
/// polls anonymous, which is how most published feed URLs work.
|
||||
fn client_for(calendar: &CalendarWatch) -> Result<Arc<dyn Calendar>, String> {
|
||||
match calendar.kind {
|
||||
CalendarKind::Google => {
|
||||
let id = calendar
|
||||
.calendar_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.ok_or_else(|| "google needs calendar_id".to_owned())?;
|
||||
if let Some(key) = calendar.resolved_api_key() {
|
||||
return Ok(Arc::new(GoogleCalendar::with_api_key(key, id)));
|
||||
}
|
||||
let token = calendar
|
||||
.resolved_token()
|
||||
.ok_or_else(|| "google needs api_key or token".to_owned())?;
|
||||
Ok(Arc::new(GoogleCalendar::new(token, id)))
|
||||
}
|
||||
CalendarKind::Ics => {
|
||||
let url = calendar
|
||||
.url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|url| !url.is_empty())
|
||||
.ok_or_else(|| "ics needs url".to_owned())?;
|
||||
let mut feed = IcsCalendar::new(url);
|
||||
if let Some(token) = calendar.resolved_token() {
|
||||
feed = feed.with_token(token);
|
||||
}
|
||||
Ok(Arc::new(feed))
|
||||
}
|
||||
CalendarKind::Caldav => {
|
||||
let url = calendar
|
||||
.url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|url| !url.is_empty())
|
||||
.ok_or_else(|| "caldav needs url".to_owned())?;
|
||||
let mut dav = CaldavCalendar::new(url);
|
||||
// A username means Basic auth, the self-hosted norm, and
|
||||
// the password must have resolved at validation; a token
|
||||
// without one is the bearer spelling; neither polls
|
||||
// anonymous, which is what open servers take.
|
||||
if let Some(user) = calendar
|
||||
.username
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|user| !user.is_empty())
|
||||
{
|
||||
let password = calendar.resolved_password().ok_or_else(|| {
|
||||
"caldav needs a password: set password or CALDAV_PASSWORD".to_owned()
|
||||
})?;
|
||||
dav = dav.with_basic(user, password);
|
||||
} else if let Some(token) = calendar.resolved_token() {
|
||||
dav = dav.with_token(token);
|
||||
}
|
||||
Ok(Arc::new(dav))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The reminder slots of one poll's events: (event, lead) pairs that
|
||||
/// are due, in feed order, leads in configured order. Due means the
|
||||
/// lead's warning moment has arrived and the event has not started;
|
||||
/// all-day events and started events contribute nothing.
|
||||
pub(crate) fn due_slots<'a>(
|
||||
events: &'a [CalendarEvent],
|
||||
leads: &[u64],
|
||||
now: OffsetDateTime,
|
||||
) -> Vec<(&'a CalendarEvent, u64)> {
|
||||
let now_secs = now.unix_timestamp();
|
||||
let mut slots = Vec::new();
|
||||
for event in events {
|
||||
if event.all_day {
|
||||
continue;
|
||||
}
|
||||
let start = event.start.unix_timestamp();
|
||||
if start <= now_secs {
|
||||
continue;
|
||||
}
|
||||
for lead in leads {
|
||||
if start - i64::try_from(*lead).unwrap_or(i64::MAX) * 60 <= now_secs {
|
||||
slots.push((event, *lead));
|
||||
}
|
||||
}
|
||||
}
|
||||
slots
|
||||
}
|
||||
|
||||
/// The event one due slot becomes: keyed to the calendar's session,
|
||||
/// its dedup id naming the calendar, the occurrence, and the lead, so
|
||||
/// a refired slot dedups and the next lead does not.
|
||||
pub(crate) fn reminder_event(
|
||||
calendar: &str,
|
||||
event: &CalendarEvent,
|
||||
lead: u64,
|
||||
now: OffsetDateTime,
|
||||
received_at: u64,
|
||||
) -> Event {
|
||||
// The start is strictly future at this point, so the count is at
|
||||
// least one.
|
||||
let minutes = ((event.start - now).whole_seconds() + 59) / 60;
|
||||
let title = match event.title.trim() {
|
||||
"" => "(untitled)",
|
||||
trimmed => trimmed,
|
||||
};
|
||||
let start = event.start.format(&Rfc3339).unwrap_or_default();
|
||||
let mut lines = vec![
|
||||
format!(
|
||||
"Calendar reminder from the daemon (source `{CALENDARS_SOURCE}`), not a user message."
|
||||
),
|
||||
format!("Calendar {calendar:?}: {title:?} starts in {minutes} minutes at {start}."),
|
||||
];
|
||||
if let Some(location) = nonempty(event.location.as_deref()) {
|
||||
lines.push(format!("Where: {location}"));
|
||||
}
|
||||
if let Some(notes) = nonempty(event.description.as_deref()) {
|
||||
lines.push(format!("Notes: {notes}"));
|
||||
}
|
||||
if let Some(url) = nonempty(event.url.as_deref()) {
|
||||
lines.push(format!("Link: {url}"));
|
||||
}
|
||||
if !event.attendees.is_empty() {
|
||||
lines.push(format!("Attendees: {}", event.attendees.join(", ")));
|
||||
}
|
||||
if let Some(organizer) = nonempty(event.organizer.as_deref()) {
|
||||
lines.push(format!("Organizer: {organizer}"));
|
||||
}
|
||||
lines.push(
|
||||
"Reply with one short line telling the user; this session's reply \
|
||||
auto-notifies their devices."
|
||||
.to_owned(),
|
||||
);
|
||||
|
||||
Event {
|
||||
id: format!("{calendar}:{}:{lead}", event.id),
|
||||
source: CALENDARS_SOURCE.to_owned(),
|
||||
payload: json!({ "key": calendar_key(calendar), "prompt": lines.join("\n") }),
|
||||
received_at,
|
||||
}
|
||||
}
|
||||
|
||||
/// A trimmed string, or `None` when it is all whitespace.
|
||||
fn nonempty(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// The detectable stream of calendar reminders: every configured
|
||||
/// calendar, polled on the daemon's tick, feeding the resident
|
||||
/// dispatcher.
|
||||
pub struct CalendarSource {
|
||||
dispatcher: Arc<Dispatcher>,
|
||||
clock: Arc<dyn Clock>,
|
||||
feeds: Vec<Feed>,
|
||||
}
|
||||
|
||||
impl CalendarSource {
|
||||
/// Registers the `calendars` source's key function on `dispatcher`
|
||||
/// and resolves `calendars` into clients. Config validation ran at
|
||||
/// load; a calendar that still fails here names itself in the
|
||||
/// error, and the daemon refuses to start.
|
||||
pub fn new(
|
||||
dispatcher: &Arc<Dispatcher>,
|
||||
clock: Arc<dyn Clock>,
|
||||
calendars: &[CalendarWatch],
|
||||
) -> Result<Self, String> {
|
||||
let mut feeds = Vec::with_capacity(calendars.len());
|
||||
for calendar in calendars {
|
||||
feeds.push(Feed {
|
||||
name: calendar.name.trim().to_owned(),
|
||||
client: client_for(calendar)?,
|
||||
leads: calendar.leads(),
|
||||
horizon_hours: calendar.horizon(),
|
||||
});
|
||||
}
|
||||
dispatcher.register_source(CALENDARS_SOURCE, passthrough_key_fn());
|
||||
Ok(Self {
|
||||
dispatcher: dispatcher.clone(),
|
||||
clock,
|
||||
feeds,
|
||||
})
|
||||
}
|
||||
|
||||
/// A source over ready-made feeds. Tests use this; production
|
||||
/// builds from the config with [`CalendarSource::new`].
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_feeds(
|
||||
dispatcher: &Arc<Dispatcher>,
|
||||
clock: Arc<dyn Clock>,
|
||||
feeds: Vec<Feed>,
|
||||
) -> Self {
|
||||
dispatcher.register_source(CALENDARS_SOURCE, passthrough_key_fn());
|
||||
Self {
|
||||
dispatcher: dispatcher.clone(),
|
||||
clock,
|
||||
feeds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Source for CalendarSource {
|
||||
fn name(&self) -> &str {
|
||||
CALENDARS_SOURCE
|
||||
}
|
||||
|
||||
fn key_fn(&self) -> Arc<KeyFn> {
|
||||
passthrough_key_fn()
|
||||
}
|
||||
|
||||
async fn detect(&self) -> Result<usize, SourceError> {
|
||||
let now_ms = self.clock.now_millis() as i64;
|
||||
let now = OffsetDateTime::from_unix_timestamp_nanos(now_ms as i128 * 1_000_000)
|
||||
.map_err(|err| SourceError::Backend(format!("clock: {err}")))?;
|
||||
let mut fed = 0;
|
||||
for feed in &self.feeds {
|
||||
let window = Window::new(
|
||||
now,
|
||||
now + time::Duration::hours(i64::from(feed.horizon_hours)),
|
||||
);
|
||||
let events = match feed.client.events(&window).await {
|
||||
Ok(events) => events,
|
||||
// A dead feed is this tick's loss, not the daemon's:
|
||||
// the next pass polls it again, and the other
|
||||
// calendars' reminders never wait behind it.
|
||||
Err(err) => {
|
||||
eprintln!("[claw] warning: calendar {} failed: {err}", feed.name);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for (event, lead) in due_slots(&events, &feed.leads, now) {
|
||||
self.dispatcher.ingest(reminder_event(
|
||||
&feed.name,
|
||||
event,
|
||||
lead,
|
||||
now,
|
||||
now_ms as u64,
|
||||
))?;
|
||||
fed += 1;
|
||||
}
|
||||
}
|
||||
Ok(fed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rhema_rsd::{DispatcherConfig, FixedClock, ResourceKey, SystemClock, VolatileStore};
|
||||
|
||||
/// 2026-10-20T00:00:00Z, in milliseconds.
|
||||
const NOW_MS: i64 = 1_792_444_800_000;
|
||||
|
||||
fn dispatcher() -> Arc<Dispatcher> {
|
||||
Arc::new(Dispatcher::new(
|
||||
DispatcherConfig {
|
||||
debounce_window_millis: 0,
|
||||
..DispatcherConfig::default()
|
||||
},
|
||||
Arc::new(SystemClock),
|
||||
Arc::new(VolatileStore::new()),
|
||||
))
|
||||
}
|
||||
|
||||
/// A feed that answers with fixed events, whatever the window.
|
||||
struct Fixed(Vec<CalendarEvent>);
|
||||
|
||||
#[async_trait]
|
||||
impl Calendar for Fixed {
|
||||
async fn events(
|
||||
&self,
|
||||
_window: &Window,
|
||||
) -> Result<Vec<CalendarEvent>, rhema_calendar_integration::CalendarError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn event(id: &str, title: &str, minutes_from_now: i64) -> CalendarEvent {
|
||||
let start = OffsetDateTime::from_unix_timestamp(NOW_MS / 1000 + minutes_from_now * 60)
|
||||
.expect("timestamp");
|
||||
CalendarEvent {
|
||||
id: format!("ics:{id}@{}", start.unix_timestamp()),
|
||||
title: title.to_owned(),
|
||||
start,
|
||||
end: Some(start + time::Duration::minutes(30)),
|
||||
all_day: false,
|
||||
location: None,
|
||||
description: None,
|
||||
attendees: Vec::new(),
|
||||
organizer: None,
|
||||
url: None,
|
||||
raw: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
fn source(
|
||||
dispatcher: &Arc<Dispatcher>,
|
||||
clock: Arc<dyn Clock>,
|
||||
events: Vec<CalendarEvent>,
|
||||
) -> CalendarSource {
|
||||
CalendarSource::from_feeds(
|
||||
dispatcher,
|
||||
clock,
|
||||
vec![Feed {
|
||||
name: "home".to_owned(),
|
||||
client: Arc::new(Fixed(events)),
|
||||
leads: vec![60, 15],
|
||||
horizon_hours: 24,
|
||||
}],
|
||||
)
|
||||
}
|
||||
|
||||
fn inbox_depth(dispatcher: &Dispatcher) -> usize {
|
||||
dispatcher
|
||||
.status(&ResourceKey::from(calendar_key("home")))
|
||||
.expect("status reads")
|
||||
.inbox_depth
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_due_slot_fires_once_and_stays_deduped() {
|
||||
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
|
||||
let dispatcher = dispatcher();
|
||||
let source = source(
|
||||
&dispatcher,
|
||||
clock.clone(),
|
||||
vec![event("standup", "standup", 90)],
|
||||
);
|
||||
|
||||
// Ninety minutes out, neither lead is due yet.
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 0);
|
||||
|
||||
// Sixty minutes out the first slot is due: one event on the
|
||||
// calendar's key.
|
||||
clock.advance(30 * 60 * 1000);
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||
assert_eq!(inbox_depth(&dispatcher), 1);
|
||||
|
||||
// The next pass replays the same dedup id: fed, deduped away.
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||
assert_eq!(inbox_depth(&dispatcher), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_lead_fires_once_at_its_own_moment() {
|
||||
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
|
||||
let dispatcher = dispatcher();
|
||||
// Starts in 90 minutes: the 60 lead is not due yet.
|
||||
let source = source(&dispatcher, clock.clone(), vec![event("demo", "demo", 90)]);
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 0);
|
||||
|
||||
// At 60 minutes out the first slot fires.
|
||||
clock.advance(30 * 60 * 1000);
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||
|
||||
// At 15 minutes out the second slot fires under its own id.
|
||||
// The count includes the first slot's replay, which dedups:
|
||||
// the inbox holds one event per lead, never more.
|
||||
clock.advance(45 * 60 * 1000);
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 2);
|
||||
assert_eq!(inbox_depth(&dispatcher), 2);
|
||||
|
||||
// Past the start: nothing more, ever.
|
||||
clock.advance(20 * 60 * 1000);
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 0);
|
||||
assert_eq!(inbox_depth(&dispatcher), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_slot_first_seen_late_fires_late_once() {
|
||||
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
|
||||
let dispatcher = dispatcher();
|
||||
// The daemon only meets this event 15 minutes before it
|
||||
// starts: the 60 slot's moment came and went unseen, the 15
|
||||
// slot is due. Both are fed on that first pass, then dedup.
|
||||
let source = source(&dispatcher, clock.clone(), vec![event("late", "late", 15)]);
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 2);
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 2);
|
||||
assert_eq!(inbox_depth(&dispatcher), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_started_or_all_day_event_never_fires() {
|
||||
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
|
||||
let dispatcher = dispatcher();
|
||||
let mut all_day = event("offsite", "offsite", 10);
|
||||
all_day.all_day = true;
|
||||
let started = event("past", "already going", -5);
|
||||
let source = source(&dispatcher, clock.clone(), vec![all_day, started]);
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 0);
|
||||
assert_eq!(inbox_depth(&dispatcher), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_dead_feed_is_skipped_not_fatal() {
|
||||
struct Dead;
|
||||
|
||||
#[async_trait]
|
||||
impl Calendar for Dead {
|
||||
async fn events(
|
||||
&self,
|
||||
_window: &Window,
|
||||
) -> Result<Vec<CalendarEvent>, rhema_calendar_integration::CalendarError> {
|
||||
Err(rhema_calendar_integration::CalendarError::Network(
|
||||
"down".to_owned(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
|
||||
let dispatcher = dispatcher();
|
||||
let source = CalendarSource::from_feeds(
|
||||
&dispatcher,
|
||||
clock.clone(),
|
||||
vec![
|
||||
Feed {
|
||||
name: "dead".to_owned(),
|
||||
client: Arc::new(Dead),
|
||||
leads: vec![15],
|
||||
horizon_hours: 24,
|
||||
},
|
||||
Feed {
|
||||
name: "alive".to_owned(),
|
||||
client: Arc::new(Fixed(vec![event("alive-event", "alive", 10)])),
|
||||
leads: vec![15],
|
||||
horizon_hours: 24,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||
assert_eq!(
|
||||
dispatcher
|
||||
.status(&ResourceKey::from(calendar_key("alive")))
|
||||
.expect("status reads")
|
||||
.inbox_depth,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prompt_names_everything_a_reply_needs() {
|
||||
let now = OffsetDateTime::from_unix_timestamp(NOW_MS / 1000).expect("timestamp");
|
||||
let mut event = event("dentist", "dentist", 15);
|
||||
event.location = Some("rue de la Paix".to_owned());
|
||||
event.description = Some("cleaning".to_owned());
|
||||
event.attendees = vec!["dr@example.net".to_owned()];
|
||||
event.organizer = Some("dr@example.net".to_owned());
|
||||
event.url = Some("https://meet.example.net/x".to_owned());
|
||||
|
||||
let slot = reminder_event("home", &event, 15, now, NOW_MS as u64);
|
||||
assert_eq!(slot.source, CALENDARS_SOURCE);
|
||||
assert_eq!(
|
||||
slot.id,
|
||||
format!("home:{}:15", event.id),
|
||||
"the dedup id names calendar, occurrence, and lead"
|
||||
);
|
||||
assert_eq!(slot.payload["key"], json!(calendar_key("home")));
|
||||
let prompt = slot.payload["prompt"].as_str().expect("prompt");
|
||||
assert!(prompt.contains("\"dentist\""), "got: {prompt}");
|
||||
assert!(prompt.contains("in 15 minutes"), "got: {prompt}");
|
||||
assert!(prompt.contains("home"), "got: {prompt}");
|
||||
assert!(prompt.contains("rue de la Paix"), "got: {prompt}");
|
||||
assert!(prompt.contains("cleaning"), "got: {prompt}");
|
||||
assert!(prompt.contains("dr@example.net"), "got: {prompt}");
|
||||
assert!(
|
||||
prompt.contains("https://meet.example.net/x"),
|
||||
"got: {prompt}"
|
||||
);
|
||||
assert!(prompt.contains("auto-notifies"), "got: {prompt}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untitled_event_still_reads_as_a_reminder() {
|
||||
let now = OffsetDateTime::from_unix_timestamp(NOW_MS / 1000).expect("timestamp");
|
||||
let event = event("bare", " ", 3);
|
||||
let slot = reminder_event("home", &event, 15, now, NOW_MS as u64);
|
||||
let prompt = slot.payload["prompt"].as_str().expect("prompt");
|
||||
assert!(prompt.contains("(untitled)"), "got: {prompt}");
|
||||
assert!(prompt.contains("in 3 minutes"), "got: {prompt}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,751 @@
|
||||
//! The consent round-trip: how a running turn asks the human for
|
||||
//! something only a human can give — a credential to store, permission
|
||||
//! to act — and waits, inside the tool call, for the answer.
|
||||
//!
|
||||
//! The shape is one table plus one bridge. [`ConsentStore`] keeps the
|
||||
//! request rows: what was asked, of which session, and how it
|
||||
//! resolved — never the secret itself, which travels once, from the
|
||||
//! answering client through the HTTP route into the identity store,
|
||||
//! and is gone. [`WaitBridge`] is how the HTTP route wakes the tool
|
||||
//! that awaits its row's resolution; the tool re-reads the row around
|
||||
//! every wake, so a resolution that raced the waiter's registration is
|
||||
//! caught by the store, not lost.
|
||||
//!
|
||||
//! Unanswered requests expire by age: [`CONSENT_WAIT`] bounds both the
|
||||
//! tool's patience and the `pending` list a UI polls, so a daemon that
|
||||
//! restarted under a waiting tool (the waiter is gone) stops offering
|
||||
//! a stale card. The tool answers a denial or an expiry in-band — a
|
||||
//! turn that asked and was refused is a turn that can say so.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||
use rhema_core::ToolSpec;
|
||||
use rusqlite::params;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use crate::hub::{EventHub, TurnEvent};
|
||||
use rhema_rsd::ResourceKey;
|
||||
|
||||
/// How long a consent request stays answerable — the tool's patience
|
||||
/// and the pending list's horizon, one number so neither lies about
|
||||
/// the other.
|
||||
pub const CONSENT_WAIT: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
fn now_millis() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
/// What kind of answer a request waits for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConsentKind {
|
||||
/// A credential: the human pastes a secret, it lands in the
|
||||
/// identity store, the tool learns only the account id.
|
||||
Secret,
|
||||
/// Permission: the human approves or denies an action.
|
||||
Phone,
|
||||
}
|
||||
|
||||
impl ConsentKind {
|
||||
/// The wire spelling, in consent rows and on the SSE stream.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ConsentKind::Secret => "secret",
|
||||
ConsentKind::Phone => "phone",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the stored spelling; `None` for anything else.
|
||||
pub fn parse(kind: &str) -> Option<Self> {
|
||||
match kind {
|
||||
"secret" => Some(ConsentKind::Secret),
|
||||
"phone" => Some(ConsentKind::Phone),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One consent request: asked, and maybe answered.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct Consent {
|
||||
/// The request's id (`con-…`).
|
||||
pub id: String,
|
||||
/// What kind of answer it waits for.
|
||||
pub kind: ConsentKind,
|
||||
/// The one-line ask, as the UI shows it.
|
||||
pub summary: String,
|
||||
/// The ask's context — the model's own reason.
|
||||
pub detail: String,
|
||||
/// The session that asked.
|
||||
pub session_key: String,
|
||||
/// For a secret request: the account fields to store the answer
|
||||
/// under (label, service, url, username, secret_kind) — the draft
|
||||
/// without its secret. `None` for permission requests.
|
||||
pub draft: Option<Value>,
|
||||
/// When the request was made, epoch millis.
|
||||
pub created_at: u64,
|
||||
/// When it resolved, epoch millis, if it did.
|
||||
pub resolved_at: Option<u64>,
|
||||
/// Whether it was granted, if it resolved.
|
||||
pub granted: Option<bool>,
|
||||
/// For a resolved secret request: the account the secret landed in.
|
||||
pub account_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Why a consent-store operation failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConsentStoreError {
|
||||
/// The backend failed to read or write.
|
||||
#[error("consent store failed: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
/// The consent table over the claw's shared database.
|
||||
pub struct ConsentStore {
|
||||
conn: Mutex<rusqlite::Connection>,
|
||||
}
|
||||
|
||||
impl ConsentStore {
|
||||
/// Opens (creating if needed) the consent table on the database at
|
||||
/// `path`.
|
||||
pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, ConsentStoreError> {
|
||||
let conn = rusqlite::Connection::open(path)
|
||||
.map_err(|err| ConsentStoreError::Backend(err.to_string()))?;
|
||||
Self::init(conn)
|
||||
}
|
||||
|
||||
/// Opens the consent table over an in-memory database.
|
||||
pub fn in_memory() -> Result<Self, ConsentStoreError> {
|
||||
let conn = rusqlite::Connection::open_in_memory()
|
||||
.map_err(|err| ConsentStoreError::Backend(err.to_string()))?;
|
||||
Self::init(conn)
|
||||
}
|
||||
|
||||
fn init(conn: rusqlite::Connection) -> Result<Self, ConsentStoreError> {
|
||||
let conn = conn;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))
|
||||
.map_err(|err| ConsentStoreError::Backend(err.to_string()))?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS consents (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
detail TEXT NOT NULL,
|
||||
session_key TEXT NOT NULL,
|
||||
draft TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
resolved_at INTEGER,
|
||||
granted INTEGER,
|
||||
account_id TEXT
|
||||
);",
|
||||
)
|
||||
.map_err(|err| ConsentStoreError::Backend(err.to_string()))?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// Records a request, durable before it returns. The id is unique
|
||||
/// per request (a wall-clock millisecond plus a process counter).
|
||||
pub fn create(
|
||||
&self,
|
||||
kind: ConsentKind,
|
||||
summary: &str,
|
||||
detail: &str,
|
||||
session_key: &str,
|
||||
draft: Option<Value>,
|
||||
) -> Result<Consent, ConsentStoreError> {
|
||||
static CONSENT_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let now = now_millis();
|
||||
let id = format!(
|
||||
"con-{}-{:04x}",
|
||||
now,
|
||||
CONSENT_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
);
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
conn.execute(
|
||||
"INSERT INTO consents (id, kind, summary, detail, session_key, draft, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
id,
|
||||
kind.as_str(),
|
||||
summary,
|
||||
detail,
|
||||
session_key,
|
||||
draft.as_ref().map(Value::to_string),
|
||||
now as i64,
|
||||
],
|
||||
)
|
||||
.map_err(|err| ConsentStoreError::Backend(err.to_string()))?;
|
||||
Ok(Consent {
|
||||
id,
|
||||
kind,
|
||||
summary: summary.to_owned(),
|
||||
detail: detail.to_owned(),
|
||||
session_key: session_key.to_owned(),
|
||||
draft,
|
||||
created_at: now,
|
||||
resolved_at: None,
|
||||
granted: None,
|
||||
account_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The request with this id, when it exists.
|
||||
pub fn get(&self, id: &str) -> Result<Option<Consent>, ConsentStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, kind, summary, detail, session_key, draft, created_at,
|
||||
resolved_at, granted, account_id
|
||||
FROM consents WHERE id = ?1",
|
||||
)
|
||||
.map_err(backend)?;
|
||||
let mut rows = stmt.query_map(params![id], row_consent).map_err(backend)?;
|
||||
rows.next().transpose().map_err(backend)
|
||||
}
|
||||
|
||||
/// Every unanswered, unexpired request, oldest first — the list a
|
||||
/// UI polls after a reload, so a card is never lost to one.
|
||||
pub fn pending(&self) -> Result<Vec<Consent>, ConsentStoreError> {
|
||||
let horizon = (now_millis().saturating_sub(CONSENT_WAIT.as_millis() as u64)) as i64;
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, kind, summary, detail, session_key, draft, created_at,
|
||||
resolved_at, granted, account_id
|
||||
FROM consents
|
||||
WHERE resolved_at IS NULL AND created_at >= ?1
|
||||
ORDER BY created_at, id",
|
||||
)
|
||||
.map_err(backend)?;
|
||||
let rows = stmt
|
||||
.query_map(params![horizon], row_consent)
|
||||
.map_err(backend)?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(backend)
|
||||
}
|
||||
|
||||
/// Marks a secret request resolved and names the account the secret
|
||||
/// landed in. `false` when the id is unknown or already resolved —
|
||||
/// the answer is consumed exactly once.
|
||||
pub fn resolve_secret(&self, id: &str, account_id: &str) -> Result<bool, ConsentStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let updated = conn
|
||||
.execute(
|
||||
"UPDATE consents SET resolved_at = ?2, granted = 1, account_id = ?3
|
||||
WHERE id = ?1 AND resolved_at IS NULL",
|
||||
params![id, now_millis() as i64, account_id],
|
||||
)
|
||||
.map_err(backend)?;
|
||||
Ok(updated > 0)
|
||||
}
|
||||
|
||||
/// Marks a permission request granted or denied. `false` when the id
|
||||
/// is unknown or already resolved.
|
||||
pub fn resolve_decision(&self, id: &str, granted: bool) -> Result<bool, ConsentStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let updated = conn
|
||||
.execute(
|
||||
"UPDATE consents SET resolved_at = ?2, granted = ?3
|
||||
WHERE id = ?1 AND resolved_at IS NULL",
|
||||
params![id, now_millis() as i64, granted],
|
||||
)
|
||||
.map_err(backend)?;
|
||||
Ok(updated > 0)
|
||||
}
|
||||
}
|
||||
|
||||
fn backend(err: rusqlite::Error) -> ConsentStoreError {
|
||||
ConsentStoreError::Backend(err.to_string())
|
||||
}
|
||||
|
||||
fn row_consent(row: &rusqlite::Row<'_>) -> rusqlite::Result<Consent> {
|
||||
let kind: String = row.get(1)?;
|
||||
Ok(Consent {
|
||||
id: row.get(0)?,
|
||||
kind: ConsentKind::parse(&kind).unwrap_or(ConsentKind::Secret),
|
||||
summary: row.get(2)?,
|
||||
detail: row.get(3)?,
|
||||
session_key: row.get(4)?,
|
||||
draft: row
|
||||
.get::<_, Option<String>>(5)?
|
||||
.map(|json| serde_json::from_str(&json).unwrap_or(Value::Null)),
|
||||
created_at: row.get::<_, i64>(6)?.max(0) as u64,
|
||||
resolved_at: row
|
||||
.get::<_, Option<i64>>(7)?
|
||||
.map(|millis| millis.max(0) as u64),
|
||||
granted: row.get::<_, Option<i64>>(8)?.map(|flag| flag != 0),
|
||||
account_id: row.get(9)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wakes tools waiting on a row's resolution. A wake for an id nobody
|
||||
/// waits on yet is not an error and not lost: the waiter re-reads its
|
||||
/// row around every wait, so the store, not the signal, is the truth.
|
||||
#[derive(Default)]
|
||||
pub struct WaitBridge {
|
||||
waiters: Mutex<HashMap<String, Arc<Notify>>>,
|
||||
}
|
||||
|
||||
impl WaitBridge {
|
||||
/// An empty bridge.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The notify handle for `id`, creating it on first use. A waiter
|
||||
/// holds this handle across its wait; the resolver and the phone
|
||||
/// command wait both wait through it.
|
||||
pub(crate) fn waiter(&self, id: &str) -> Arc<Notify> {
|
||||
self.waiters
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.entry(id.to_owned())
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Wakes whoever waits on `id`, if anyone.
|
||||
pub fn wake(&self, id: &str) {
|
||||
let waiter = self
|
||||
.waiters
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.get(id)
|
||||
.cloned();
|
||||
if let Some(waiter) = waiter {
|
||||
waiter.notify_one();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a wait came back with.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WaitOutcome {
|
||||
/// Resolved in favor: for a secret request, the account id the
|
||||
/// answer was stored under.
|
||||
Granted(Option<String>),
|
||||
/// Resolved against.
|
||||
Denied,
|
||||
/// [`CONSENT_WAIT`] elapsed with no answer.
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// Waits until `id`'s row resolves or the budget runs out. The row —
|
||||
/// not the wake — is what the answer reads, so a resolution that raced
|
||||
/// the waiter is still seen.
|
||||
pub async fn wait_for_resolution(
|
||||
store: &ConsentStore,
|
||||
bridge: &WaitBridge,
|
||||
id: &str,
|
||||
budget: Duration,
|
||||
) -> WaitOutcome {
|
||||
let deadline = tokio::time::Instant::now() + budget;
|
||||
loop {
|
||||
if let Ok(Some(consent)) = store.get(id) {
|
||||
if let Some(granted) = consent.granted {
|
||||
return if granted {
|
||||
WaitOutcome::Granted(consent.account_id)
|
||||
} else {
|
||||
WaitOutcome::Denied
|
||||
};
|
||||
}
|
||||
}
|
||||
let waiter = bridge.waiter(id);
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
match tokio::time::timeout(remaining, waiter.notified()).await {
|
||||
Ok(_) => continue,
|
||||
Err(_) => return WaitOutcome::TimedOut,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tool's name on the wire.
|
||||
pub const ASK_SECRET: &str = "ask_secret";
|
||||
|
||||
/// `ask_secret`: store an account through the human. The tool asks, the
|
||||
/// chat surface shows a card, the human pastes the secret, the HTTP
|
||||
/// route stores it into the identity store, and the tool wakes with the
|
||||
/// account id — never the secret, which nothing here re-renders.
|
||||
struct AskSecret {
|
||||
consents: Arc<ConsentStore>,
|
||||
bridge: Arc<WaitBridge>,
|
||||
hub: Arc<EventHub>,
|
||||
key: ResourceKey,
|
||||
/// The tool's patience; [`CONSENT_WAIT`] in production, a sliver in
|
||||
/// tests.
|
||||
wait: Duration,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AskSecretArgs {
|
||||
service: String,
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
#[serde(default)]
|
||||
username: Option<String>,
|
||||
#[serde(default)]
|
||||
url: Option<String>,
|
||||
#[serde(default)]
|
||||
secret_kind: Option<String>,
|
||||
#[serde(default)]
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for AskSecret {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
ASK_SECRET,
|
||||
"Ask the human for a credential and store it in the agent \
|
||||
accounts: a card appears in their chat where they paste the \
|
||||
secret. Use it when a task needs an account (a forge token, \
|
||||
an API key) that identity_list does not show yet — never ask \
|
||||
for something already stored. The answer carries no secret, \
|
||||
only whether it was stored and the account's id. The human \
|
||||
may decline, or answer never; both come back in-band. Act \
|
||||
with the stored account afterwards through identity_exec.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"service": { "type": "string",
|
||||
"description": "The service the credential is for, \
|
||||
e.g. 'github' or an internal tool's name." },
|
||||
"label": { "type": "string",
|
||||
"description": "Display name for the account; \
|
||||
defaults to the service name." },
|
||||
"username": { "type": "string",
|
||||
"description": "The login name on the service." },
|
||||
"url": { "type": "string",
|
||||
"description": "Base URL, for self-hosted instances." },
|
||||
"secret_kind": { "type": "string",
|
||||
"enum": ["token", "password"],
|
||||
"description": "What the secret is; default token." },
|
||||
"reason": { "type": "string",
|
||||
"description": "Why you need it — shown on the card." }
|
||||
},
|
||||
"required": ["service"]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||
let args: AskSecretArgs =
|
||||
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||
let service = args.service.trim();
|
||||
if service.is_empty() {
|
||||
return Err("service must not be empty".to_owned());
|
||||
}
|
||||
// Validate the kind spelling before the card exists: a typo'd
|
||||
// enum must fail the call, not the human's answer.
|
||||
let secret_kind = crate::accounts_tools::parse_kind(args.secret_kind.as_deref())?;
|
||||
let label = args
|
||||
.label
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|label| !label.is_empty())
|
||||
.unwrap_or(service)
|
||||
.to_owned();
|
||||
let summary = match args
|
||||
.username
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|u| !u.is_empty())
|
||||
{
|
||||
Some(username) => format!("secret for {service} ({username})"),
|
||||
None => format!("secret for {service}"),
|
||||
};
|
||||
let detail = args
|
||||
.reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|reason| !reason.is_empty())
|
||||
.unwrap_or("no reason given")
|
||||
.to_owned();
|
||||
let draft = json!({
|
||||
"label": label,
|
||||
"service": service,
|
||||
"url": args.url,
|
||||
"username": args.username,
|
||||
"secret_kind": match secret_kind {
|
||||
rhema_identity::SecretKind::Token => "token",
|
||||
rhema_identity::SecretKind::Password => "password",
|
||||
},
|
||||
});
|
||||
let consent = self
|
||||
.consents
|
||||
.create(
|
||||
ConsentKind::Secret,
|
||||
&summary,
|
||||
&detail,
|
||||
self.key.as_str(),
|
||||
Some(draft),
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
self.hub.emit(
|
||||
&self.key,
|
||||
TurnEvent::ConsentRequest {
|
||||
id: consent.id.clone(),
|
||||
kind: consent.kind.as_str().to_owned(),
|
||||
summary: consent.summary.clone(),
|
||||
detail: consent.detail.clone(),
|
||||
created_at: consent.created_at,
|
||||
},
|
||||
);
|
||||
match wait_for_resolution(&self.consents, &self.bridge, &consent.id, self.wait).await {
|
||||
WaitOutcome::Granted(account_id) => Ok(json!({
|
||||
"stored": true,
|
||||
"account_id": account_id,
|
||||
"service": service,
|
||||
})
|
||||
.to_string()),
|
||||
WaitOutcome::Denied => Ok(json!({"granted": false}).to_string()),
|
||||
WaitOutcome::TimedOut => Ok(json!({"timed_out": true}).to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The consent tool set over `store`: `ask_secret`. Built per dispatch,
|
||||
/// so the request names the session that asked.
|
||||
pub fn registry(
|
||||
consents: Arc<ConsentStore>,
|
||||
bridge: Arc<WaitBridge>,
|
||||
hub: Arc<EventHub>,
|
||||
key: ResourceKey,
|
||||
) -> ToolRegistry {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(AskSecret {
|
||||
consents,
|
||||
bridge,
|
||||
hub,
|
||||
key,
|
||||
wait: CONSENT_WAIT,
|
||||
}));
|
||||
registry
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store() -> Arc<ConsentStore> {
|
||||
Arc::new(ConsentStore::in_memory().expect("store opens"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_created_request_round_trips_and_starts_pending() {
|
||||
let store = store();
|
||||
let consent = store
|
||||
.create(
|
||||
ConsentKind::Secret,
|
||||
"secret for github",
|
||||
"to post",
|
||||
"interactive:web",
|
||||
Some(json!({"label": "github"})),
|
||||
)
|
||||
.expect("creates");
|
||||
assert_eq!(consent.kind, ConsentKind::Secret);
|
||||
assert_eq!(consent.granted, None);
|
||||
assert_eq!(consent.session_key, "interactive:web");
|
||||
let fetched = store.get(&consent.id).expect("reads").expect("exists");
|
||||
assert_eq!(fetched, consent);
|
||||
assert_eq!(store.pending().expect("reads"), vec![consent]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_resolution_names_the_account_exactly_once() {
|
||||
let store = store();
|
||||
let consent = store
|
||||
.create(ConsentKind::Secret, "s", "d", "interactive:web", None)
|
||||
.expect("creates");
|
||||
assert!(
|
||||
store
|
||||
.resolve_secret(&consent.id, "acc-1")
|
||||
.expect("resolves"),
|
||||
"the first resolution lands"
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.resolve_secret(&consent.id, "acc-2")
|
||||
.expect("resolves"),
|
||||
"the answer is consumed once"
|
||||
);
|
||||
let resolved = store.get(&consent.id).expect("reads").expect("exists");
|
||||
assert_eq!(resolved.account_id.as_deref(), Some("acc-1"));
|
||||
assert_eq!(resolved.granted, Some(true));
|
||||
assert!(
|
||||
store.pending().expect("reads").is_empty(),
|
||||
"resolved rows leave the pending list"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_decision_resolves_once_with_its_verdict() {
|
||||
let store = store();
|
||||
let consent = store
|
||||
.create(
|
||||
ConsentKind::Phone,
|
||||
"phone dnd_on",
|
||||
"quiet",
|
||||
"interactive:web",
|
||||
None,
|
||||
)
|
||||
.expect("creates");
|
||||
assert!(
|
||||
store
|
||||
.resolve_decision(&consent.id, false)
|
||||
.expect("resolves"),
|
||||
"a denial lands"
|
||||
);
|
||||
let resolved = store.get(&consent.id).expect("reads").expect("exists");
|
||||
assert_eq!(resolved.granted, Some(false));
|
||||
assert_eq!(resolved.account_id, None);
|
||||
assert!(
|
||||
!store.resolve_decision(&consent.id, true).expect("resolves"),
|
||||
"a denial cannot be flipped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expired_request_leaves_the_pending_list() {
|
||||
let store = store();
|
||||
{
|
||||
let conn = store.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
conn.execute(
|
||||
"INSERT INTO consents (id, kind, summary, detail, session_key, created_at)
|
||||
VALUES ('con-old', 'secret', 's', 'd', 'interactive:web', 1)",
|
||||
[],
|
||||
)
|
||||
.expect("writes raw");
|
||||
}
|
||||
let fresh = store
|
||||
.create(ConsentKind::Secret, "s", "d", "interactive:web", None)
|
||||
.expect("creates");
|
||||
let pending = store.pending().expect("reads");
|
||||
assert_eq!(pending, vec![fresh], "only the unexpired request answers");
|
||||
assert!(
|
||||
store.get("con-old").expect("reads").is_some(),
|
||||
"the expired row still reads"
|
||||
);
|
||||
}
|
||||
|
||||
/// The full round-trip, tool-side: the ask creates the row and the
|
||||
/// stream event, the human's answer (the route's half: resolve,
|
||||
/// wake) ends the wait, and the answer names the account — the
|
||||
/// secret never passes through the tool.
|
||||
#[tokio::test]
|
||||
async fn an_ask_stored_through_a_consent_answers_with_the_account_id() {
|
||||
use crate::hub::EventHub;
|
||||
|
||||
let store = store();
|
||||
let bridge = Arc::new(WaitBridge::new());
|
||||
let hub = Arc::new(EventHub::default());
|
||||
let key = ResourceKey::from("interactive:web");
|
||||
let tool = AskSecret {
|
||||
consents: store.clone(),
|
||||
bridge: bridge.clone(),
|
||||
hub: hub.clone(),
|
||||
key: key.clone(),
|
||||
wait: std::time::Duration::from_secs(10),
|
||||
};
|
||||
let mut receiver = hub.subscribe(&key);
|
||||
let task = tokio::spawn(async move {
|
||||
tool.invoke(json!({
|
||||
"service": "github",
|
||||
"username": "octocat",
|
||||
"reason": "to file the issue you asked for",
|
||||
}))
|
||||
.await
|
||||
});
|
||||
// The ask lands: one pending row, one stream event.
|
||||
let pending = loop {
|
||||
let pending = store.pending().expect("reads");
|
||||
if !pending.is_empty() {
|
||||
break pending;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
};
|
||||
assert_eq!(pending[0].summary, "secret for github (octocat)");
|
||||
assert!(matches!(
|
||||
receiver.try_recv(),
|
||||
Ok(TurnEvent::ConsentRequest { .. })
|
||||
));
|
||||
// The route's half: store the secret elsewhere, resolve, wake.
|
||||
store
|
||||
.resolve_secret(&pending[0].id, "acc-9")
|
||||
.expect("resolves");
|
||||
bridge.wake(&pending[0].id);
|
||||
let answer = task.await.expect("the tool returns").expect("in-band ok");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&answer).unwrap(),
|
||||
json!({"stored": true, "account_id": "acc-9", "service": "github"}),
|
||||
"{answer}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_denied_ask_answers_in_band_and_not_as_an_error() {
|
||||
let store = store();
|
||||
let bridge = Arc::new(WaitBridge::new());
|
||||
let hub = Arc::new(EventHub::default());
|
||||
let key = ResourceKey::from("interactive:web");
|
||||
let tool = AskSecret {
|
||||
consents: store.clone(),
|
||||
bridge: bridge.clone(),
|
||||
hub,
|
||||
key,
|
||||
wait: std::time::Duration::from_secs(10),
|
||||
};
|
||||
let task = tokio::spawn(async move { tool.invoke(json!({"service": "gitlab"})).await });
|
||||
let pending = loop {
|
||||
let pending = store.pending().expect("reads");
|
||||
if !pending.is_empty() {
|
||||
break pending;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
};
|
||||
store
|
||||
.resolve_decision(&pending[0].id, false)
|
||||
.expect("resolves");
|
||||
bridge.wake(&pending[0].id);
|
||||
let answer = task.await.expect("the tool returns").expect("in-band ok");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&answer).unwrap(),
|
||||
json!({"granted": false}),
|
||||
"{answer}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unanswered_ask_times_out_in_band() {
|
||||
let store = store();
|
||||
let bridge = Arc::new(WaitBridge::new());
|
||||
let hub = Arc::new(EventHub::default());
|
||||
let key = ResourceKey::from("interactive:web");
|
||||
let tool = AskSecret {
|
||||
consents: store,
|
||||
bridge,
|
||||
hub,
|
||||
key,
|
||||
wait: std::time::Duration::from_millis(50),
|
||||
};
|
||||
let answer = tool
|
||||
.invoke(json!({"service": "github"}))
|
||||
.await
|
||||
.expect("in-band ok");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&answer).unwrap(),
|
||||
json!({"timed_out": true}),
|
||||
"{answer}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
//! The daemon: sources, drivers, the HTTP surface, and the cadence
|
||||
//! assembled into one resident process.
|
||||
//!
|
||||
//! Two dispatchers share one SQLite store: the forge dispatcher (watches
|
||||
//! polled by the sources, answered by [`ClawHandler`]) and the
|
||||
//! interactive dispatcher — the resident dispatcher — whose keys carry
|
||||
//! the `interactive:`/`task:`/`phone:` prefixes and whose inputs are the
|
||||
//! chat routes' messages, the `/v1/ingest` door, and the task
|
||||
//! scheduler's due firings, all answered by the interactive handler.
|
||||
//! Sharing the store is what makes the whole thing one resident agent
|
||||
//! with one inventory — and it is safe because the store's whole-record
|
||||
//! revision compare-and-swap serializes across every instance holding
|
||||
//! the file (rhema-rsd's cross-connection CAS tests pin that).
|
||||
//!
|
||||
//! Every timer here drives deterministic detection and admission only —
|
||||
//! the model is reached exclusively inside handler runs (spec §7's
|
||||
//! invariant: a resident process is not a process that burns tokens on a
|
||||
//! schedule). The `--once` shape is the same assembly with a zero
|
||||
//! debounce window — a cron pass acts on what it sees now — and one
|
||||
//! detect-plus-driver pass instead of a loop, with no HTTP surface: cron
|
||||
//! machines have no one to chat with.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use rhema_autonomous_agent::{Driver, Source, SourceError};
|
||||
use rhema_rsd::{
|
||||
DispatchError, Dispatcher, DispatcherConfig, Outcome, ResourceKey, SqliteStore, StoreError,
|
||||
SystemClock,
|
||||
};
|
||||
|
||||
use crate::calendar::CalendarSource;
|
||||
use crate::config::{ClawConfig, ConfigError, ForgeKind};
|
||||
use crate::forge::ForgeSource;
|
||||
use crate::handler::ClawHandler;
|
||||
use crate::http::ClawState;
|
||||
use crate::notifications::NotificationStore;
|
||||
use crate::task_source::TaskScheduler;
|
||||
use crate::task_store::TaskStore;
|
||||
use rhema_memory::FileMemoryStore;
|
||||
|
||||
use crate::backends::BackendRegistry;
|
||||
use crate::hub::EventHub;
|
||||
use crate::interactive::InteractiveHandler;
|
||||
use crate::watch_store::ClawStore;
|
||||
|
||||
/// Why the daemon could not run.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ClawError {
|
||||
/// The config file is missing, malformed, or fails validation.
|
||||
#[error(transparent)]
|
||||
Config(#[from] ConfigError),
|
||||
/// A directory or path the daemon needs could not be created, or the
|
||||
/// HTTP listener could not bind.
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
/// The SQLite store could not be opened.
|
||||
#[error(transparent)]
|
||||
Store(#[from] StoreError),
|
||||
/// The watch table could not be opened or read.
|
||||
#[error(transparent)]
|
||||
Watches(#[from] crate::watch_store::WatchStoreError),
|
||||
/// The runtime settings table could not be opened or read.
|
||||
#[error(transparent)]
|
||||
Settings(#[from] crate::settings::SettingsError),
|
||||
/// The notification table could not be opened or read.
|
||||
#[error(transparent)]
|
||||
Notifications(#[from] crate::notifications::NotificationStoreError),
|
||||
/// The task table could not be opened or read.
|
||||
#[error(transparent)]
|
||||
Tasks(#[from] crate::task_store::TaskStoreError),
|
||||
/// The coding-job tables could not be opened or read.
|
||||
#[error(transparent)]
|
||||
Jobs(#[from] crate::job_store::JobStoreError),
|
||||
/// The turn-history table could not be opened or read.
|
||||
#[error(transparent)]
|
||||
TurnHistory(#[from] crate::turn_store::TurnStoreError),
|
||||
/// The consent table could not be opened or read.
|
||||
#[error(transparent)]
|
||||
Consents(#[from] crate::consents::ConsentStoreError),
|
||||
/// The attachments directory could not be opened.
|
||||
#[error(transparent)]
|
||||
Attachments(#[from] crate::attachments::AttachmentError),
|
||||
/// The phone tables could not be opened or read.
|
||||
#[error(transparent)]
|
||||
Phone(#[from] crate::phone::PhoneStoreError),
|
||||
/// The backend registry could not be built: no provider constructed,
|
||||
/// or the configured seed named an unknown kind.
|
||||
#[error("backend registry failed: {0}")]
|
||||
Backend(String),
|
||||
/// The bootstrap store could not be opened.
|
||||
#[error(transparent)]
|
||||
Bootstrap(#[from] crate::bootstrap::BootstrapError),
|
||||
/// A source's detect pass failed.
|
||||
#[error(transparent)]
|
||||
Source(#[from] SourceError),
|
||||
/// The dispatcher rejected a step.
|
||||
#[error(transparent)]
|
||||
Dispatch(#[from] DispatchError),
|
||||
}
|
||||
|
||||
/// One daemon pass: what detection fed, and what the runs reported.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TickReport {
|
||||
/// Events fed by detection across all sources — replayed dedup ids
|
||||
/// included; the dispatcher dedups.
|
||||
pub detected: usize,
|
||||
/// One outcome per forge key that became a run; a key that was
|
||||
/// parked or raced is simply absent.
|
||||
pub outcomes: Vec<Outcome>,
|
||||
}
|
||||
|
||||
/// The interactive dispatcher's timing: messages dispatch immediately —
|
||||
/// an open surface has nothing to coalesce (spec §6.2) — leases run an
|
||||
/// hour and renew at the handler's poll boundaries because turns can run
|
||||
/// long, and the caps are generous for the same reason.
|
||||
fn interactive_dispatch() -> DispatcherConfig {
|
||||
DispatcherConfig {
|
||||
max_concurrent_leases: 64,
|
||||
max_concurrent_per_source: BTreeMap::new(),
|
||||
debounce_window_millis: 0,
|
||||
lease_ttl_millis: 3_600_000,
|
||||
admission_ttl_millis: 60_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// The resident daemon: one source per forge kind, the calendar
|
||||
/// reminders, the task scheduler, the coding-job source, the three
|
||||
/// drivers, and the HTTP surface.
|
||||
pub struct Daemon {
|
||||
sources: Vec<ForgeSource>,
|
||||
calendar_source: CalendarSource,
|
||||
scheduler: TaskScheduler,
|
||||
job_source: crate::jobs::JobsSource,
|
||||
jobs: Arc<crate::job_store::CodingJobStore>,
|
||||
forge_dispatcher: Arc<Dispatcher>,
|
||||
interactive_dispatcher: Arc<Dispatcher>,
|
||||
jobs_dispatcher: Arc<Dispatcher>,
|
||||
forge_driver: Arc<Driver>,
|
||||
interactive_driver: Arc<Driver>,
|
||||
jobs_driver: Arc<Driver>,
|
||||
poll_interval: Duration,
|
||||
listen: String,
|
||||
http: ClawState,
|
||||
/// The registry the drivers' handlers read per dispatch; kept for
|
||||
/// introspection (the active pair after a bootstrap).
|
||||
registry: Arc<BackendRegistry>,
|
||||
}
|
||||
|
||||
impl Daemon {
|
||||
/// Assembles the daemon over `config`: the shared SQLite store, one
|
||||
/// source per forge kind reading the watch table, the interactive
|
||||
/// dispatcher, and both handlers running `provider`. `dispatch` is
|
||||
/// the forge dispatcher's timing — the resident loop uses the
|
||||
/// defaults, `--once` zeroes the debounce.
|
||||
pub fn build(
|
||||
config: &ClawConfig,
|
||||
backends: Arc<BackendRegistry>,
|
||||
dispatch: DispatcherConfig,
|
||||
) -> Result<Self, ClawError> {
|
||||
config.validate()?;
|
||||
let db = config.db_path()?;
|
||||
if let Some(parent) = db.parent() {
|
||||
// The store holds provider keys and forge tokens: `0700` on
|
||||
// the directory, `0600` on the file (below), both
|
||||
// re-asserted on every boot.
|
||||
rhema_config::privacy::private_dir(parent)?;
|
||||
}
|
||||
let workspace_root = config.workspace_root()?;
|
||||
let interactive_workspace = workspace_root.join("interactive");
|
||||
std::fs::create_dir_all(&interactive_workspace)?;
|
||||
|
||||
// One store file, several connections: the dispatcher's records,
|
||||
// the watch table, the notification table, the task table, and
|
||||
// the turn-history table live side by side.
|
||||
let store = Arc::new(SqliteStore::open(&db)?);
|
||||
// SQLite created the file under the umask; whatever it left
|
||||
// wide, tighten. Best-effort: a filesystem that refuses mode
|
||||
// changes must not stop the boot.
|
||||
rhema_config::privacy::tighten_file(&db);
|
||||
let watches = Arc::new(ClawStore::open(&db)?);
|
||||
let notifications = Arc::new(NotificationStore::open(&db)?);
|
||||
let tasks = Arc::new(TaskStore::open(&db)?);
|
||||
let turns = Arc::new(crate::turn_store::TurnStore::open(&db)?);
|
||||
// The coding-job tables: instances the `coding_agent` tool
|
||||
// drives, jobs the jobs dispatcher answers.
|
||||
let jobs = Arc::new(crate::job_store::CodingJobStore::open(&db)?);
|
||||
|
||||
// The YAML watch list is the one-time seed; the table rules
|
||||
// after that.
|
||||
let seeded = watches.seed_from_config(&config.watches)?;
|
||||
if seeded > 0 {
|
||||
eprintln!("[claw] seeded {seeded} watch(es) from the config");
|
||||
}
|
||||
|
||||
let forge_dispatcher = Arc::new(Dispatcher::new(
|
||||
dispatch,
|
||||
Arc::new(SystemClock),
|
||||
store.clone(),
|
||||
));
|
||||
let interactive_dispatcher = Arc::new(Dispatcher::new(
|
||||
interactive_dispatch(),
|
||||
Arc::new(SystemClock),
|
||||
store.clone(),
|
||||
));
|
||||
// The jobs dispatcher answers `job:<instance>#<id>`: a coding
|
||||
// run's turn can take minutes, so it leases on the resident
|
||||
// timing and runs in its own driver, never blocking a chat.
|
||||
let jobs_dispatcher = Arc::new(Dispatcher::new(
|
||||
interactive_dispatch(),
|
||||
Arc::new(SystemClock),
|
||||
store.clone(),
|
||||
));
|
||||
|
||||
// The account store the forge sources poll through: an account
|
||||
// watch authenticates as its identity, and the secret reaches
|
||||
// the forge client's constructor and nowhere else.
|
||||
let accounts: Arc<dyn rhema_identity::IdentityStore> = Arc::new(
|
||||
rhema_identity::SqliteIdentityStore::open(&db)
|
||||
.map_err(|err| ClawError::Backend(err.to_string()))?,
|
||||
);
|
||||
|
||||
// One source per known forge kind, whatever the table currently
|
||||
// holds: a watch added later (chat tool, HTTP route) of a kind
|
||||
// nobody watched at startup still has its key function
|
||||
// registered. A source over zero watches detects nothing.
|
||||
let sources = [ForgeKind::GitHub, ForgeKind::Gitea, ForgeKind::GitLab]
|
||||
.into_iter()
|
||||
.map(|kind| {
|
||||
ForgeSource::new(
|
||||
&forge_dispatcher,
|
||||
kind.name(),
|
||||
watches.clone(),
|
||||
accounts.clone(),
|
||||
notifications.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// The forge handler resolves its watches through the table at
|
||||
// dispatch time, so a watch added mid-run is answerable without a
|
||||
// restart.
|
||||
let forge_handler = ClawHandler::new(backends.clone(), workspace_root, watches.clone());
|
||||
// Three drivers over one store admit from the same table, so
|
||||
// each owns its slice of the key space: a forge pass must never
|
||||
// pick up a resident or job event (it would fail it, and
|
||||
// consume it), and the others likewise.
|
||||
let forge_driver = Arc::new(
|
||||
Driver::new(forge_dispatcher.clone(), Arc::new(forge_handler))
|
||||
.with_holder("rhema-claw")
|
||||
.owning(Arc::new(|key: &ResourceKey| {
|
||||
!crate::interactive::is_resident_key(key.as_str())
|
||||
&& !crate::jobs::is_job_key(key.as_str())
|
||||
})),
|
||||
);
|
||||
|
||||
// The hub replays what past turns streamed: every event appends
|
||||
// to the turn-history table as it streams, so a restart's replay
|
||||
// reads the table back — a restart continues the transcript,
|
||||
// whole, with no boot-time seed pass.
|
||||
let hub = Arc::new(EventHub::with_record_store(turns.clone()));
|
||||
// The consent table and its wake bridge: the `ask_secret` tool
|
||||
// asks through them, the consent routes answer through them.
|
||||
let consents = Arc::new(crate::consents::ConsentStore::open(&db)?);
|
||||
let bridge = Arc::new(crate::consents::WaitBridge::new());
|
||||
// The attachments live beside the database, the same state
|
||||
// volume the transcript is durable on: a served id must not
|
||||
// outlive its bytes.
|
||||
let attachments = Arc::new(crate::attachments::AttachmentStore::open(
|
||||
db.parent()
|
||||
.ok_or_else(|| {
|
||||
ClawError::Backend("the database has no parent directory".to_owned())
|
||||
})?
|
||||
.join("attachments"),
|
||||
)?);
|
||||
// The phone tables: status the ingest door feeds, commands the
|
||||
// `phone` tool queues behind a consent.
|
||||
let phone = Arc::new(crate::phone::PhoneStore::open(&db)?);
|
||||
let memory = Arc::new(FileMemoryStore::new(config.memory_path()?)?);
|
||||
let bootstrap = Arc::new(crate::bootstrap::Bootstrap::new(config.bootstrap_dir()?)?);
|
||||
let settings = Arc::new(crate::settings::SettingsStore::open(&db)?);
|
||||
// The identity store shares the settings table and keeps its
|
||||
// images beside the database.
|
||||
let identity = Arc::new(crate::identity::IdentityStore::new(
|
||||
settings.clone(),
|
||||
db.parent().map(|dir| dir.join("identity")).ok_or_else(|| {
|
||||
ClawError::Backend("the database has no parent directory".to_owned())
|
||||
})?,
|
||||
));
|
||||
// The persisted pair wins over the config seed — it is what a
|
||||
// runtime switch wrote. A persisted id the registry no longer
|
||||
// knows (a backend removed from the deployment) falls back to
|
||||
// the seed silently: the daemon still boots.
|
||||
let persisted_provider = settings.get(crate::settings::ACTIVE_PROVIDER_KEY)?;
|
||||
let persisted_model = settings.get(crate::settings::ACTIVE_MODEL_KEY)?;
|
||||
if let Some(id) = persisted_provider {
|
||||
let _ = backends.set_active(&id, persisted_model.as_deref());
|
||||
}
|
||||
// The environment report is detection, not conversation: generated
|
||||
// at every boot by deterministic discovery, never by the model,
|
||||
// and regenerated over whatever the last boot wrote.
|
||||
crate::environment::write_report(config.bootstrap_dir()?.join("ENVIRONMENT.md"))?;
|
||||
let interactive_handler = InteractiveHandler::new(
|
||||
backends.clone(),
|
||||
interactive_workspace,
|
||||
watches.clone(),
|
||||
memory,
|
||||
bootstrap.clone(),
|
||||
hub.clone(),
|
||||
notifications.clone(),
|
||||
tasks.clone(),
|
||||
jobs.clone(),
|
||||
accounts.clone(),
|
||||
config.max_turn_steps,
|
||||
consents.clone(),
|
||||
bridge.clone(),
|
||||
attachments.clone(),
|
||||
phone.clone(),
|
||||
identity.clone(),
|
||||
);
|
||||
let interactive_driver = Arc::new(
|
||||
Driver::new(
|
||||
interactive_dispatcher.clone(),
|
||||
Arc::new(interactive_handler),
|
||||
)
|
||||
.with_holder("rhema-claw-interactive")
|
||||
.owning(Arc::new(|key: &ResourceKey| {
|
||||
crate::interactive::is_resident_key(key.as_str())
|
||||
})),
|
||||
);
|
||||
// The coding jobs: one handler, one driver, one source. The
|
||||
// final text of a job's turn notifies the user's device, so the
|
||||
// handler needs the notification table too.
|
||||
let jobs_handler = crate::jobs::CodingJobHandler::new(
|
||||
backends.clone(),
|
||||
jobs.clone(),
|
||||
notifications.clone(),
|
||||
config.max_turn_steps,
|
||||
);
|
||||
let jobs_driver = Arc::new(
|
||||
Driver::new(jobs_dispatcher.clone(), Arc::new(jobs_handler))
|
||||
.with_holder("rhema-claw-jobs")
|
||||
.owning(Arc::new(|key: &ResourceKey| {
|
||||
crate::jobs::is_job_key(key.as_str())
|
||||
})),
|
||||
);
|
||||
let job_source = crate::jobs::JobsSource::new(&jobs_dispatcher, jobs.clone());
|
||||
crate::interactive::register_user(&interactive_dispatcher);
|
||||
// The scheduler is the resident dispatcher's second own source:
|
||||
// its detect pass (the daemon's tick drives it) is what turns a
|
||||
// due task row into an event, never a timer near the model.
|
||||
let scheduler = TaskScheduler::new(
|
||||
&interactive_dispatcher,
|
||||
Arc::new(SystemClock),
|
||||
tasks.clone(),
|
||||
);
|
||||
// The calendar reminders join them: a due reminder is an
|
||||
// event, never a timer near the model.
|
||||
let calendar_source = CalendarSource::new(
|
||||
&interactive_dispatcher,
|
||||
Arc::new(SystemClock),
|
||||
&config.calendars,
|
||||
)
|
||||
.map_err(ClawError::Backend)?;
|
||||
|
||||
let http = ClawState::new(
|
||||
interactive_dispatcher.clone(),
|
||||
interactive_driver.clone(),
|
||||
store,
|
||||
watches,
|
||||
notifications,
|
||||
bootstrap,
|
||||
hub,
|
||||
backends.clone(),
|
||||
settings.clone(),
|
||||
identity,
|
||||
accounts,
|
||||
consents,
|
||||
bridge,
|
||||
attachments,
|
||||
phone,
|
||||
config.token.clone(),
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
sources,
|
||||
calendar_source,
|
||||
scheduler,
|
||||
job_source,
|
||||
jobs,
|
||||
forge_dispatcher,
|
||||
interactive_dispatcher,
|
||||
jobs_dispatcher,
|
||||
forge_driver,
|
||||
interactive_driver,
|
||||
jobs_driver,
|
||||
// A zero poll interval would spin detect passes against the
|
||||
// forge API; one second is the floor.
|
||||
poll_interval: Duration::from_secs(config.poll_interval_secs.max(1)),
|
||||
listen: config.listen_addr().to_owned(),
|
||||
http,
|
||||
registry: backends,
|
||||
})
|
||||
}
|
||||
|
||||
/// The HTTP router over the daemon's state — the exact app `run`
|
||||
/// serves, exposed so a test can bind it anywhere.
|
||||
pub fn router(&self) -> axum::Router {
|
||||
crate::http::router(self.http.clone())
|
||||
}
|
||||
|
||||
/// The HTTP surface's configured address.
|
||||
pub fn listen_addr(&self) -> &str {
|
||||
&self.listen
|
||||
}
|
||||
|
||||
/// The deployment's backend registry: providers and the active pair.
|
||||
pub fn backends(&self) -> &BackendRegistry {
|
||||
&self.registry
|
||||
}
|
||||
|
||||
/// Startup recovery: releases the leases the previous process left
|
||||
/// behind — all of them, now, not at the dead lease's TTL (an hour
|
||||
/// on the resident dispatcher), because the claw is single-node:
|
||||
/// the only lease holder over this file is the process that just
|
||||
/// died, and a key it held mid-turn must answer its next message
|
||||
/// instead of stalling behind a ghost. A turn the restart cut
|
||||
/// resumes then: the committed-but-unanswered call revives at the
|
||||
/// top of the next dispatch (safe tools re-run, the rest report
|
||||
/// interrupted). Idempotent; called before the first pass.
|
||||
pub async fn bootstrap(&self) -> Result<(), ClawError> {
|
||||
// The job rows first: a turn the restart cut is failed
|
||||
// interrupted before anything re-runs, and a still-queued row
|
||||
// is re-armed with a fresh dedup id.
|
||||
let touched = self.jobs.recover()?;
|
||||
if touched > 0 {
|
||||
eprintln!("[claw] jobs: {touched} row(s) recovered at boot");
|
||||
}
|
||||
self.forge_dispatcher.recover_all()?;
|
||||
self.jobs_dispatcher.recover_all()?;
|
||||
self.interactive_dispatcher.recover_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One pass: every source detects (due tasks first — a reminder is
|
||||
/// the user-facing clock), the forge driver runs every key the
|
||||
/// debounce released, and the interactive driver drains as a
|
||||
/// backstop — a message delivered live into a running turn waits at
|
||||
/// the key for a pass like this one.
|
||||
pub async fn tick(&self) -> Result<TickReport, ClawError> {
|
||||
let mut detected = self.scheduler.detect().await?;
|
||||
detected += self.calendar_source.detect().await?;
|
||||
detected += self.job_source.detect().await?;
|
||||
for source in &self.sources {
|
||||
detected += source.detect().await?;
|
||||
}
|
||||
// A queued coding job starts before the forge pass: it is the
|
||||
// thing someone just asked for.
|
||||
if let Err(err) = self.jobs_driver.run_once().await {
|
||||
eprintln!("[claw] warning: jobs pass failed: {err}");
|
||||
}
|
||||
let outcomes = self.forge_driver.run_once().await?;
|
||||
if !outcomes.is_empty() {
|
||||
let handled = outcomes
|
||||
.iter()
|
||||
.filter(|outcome| matches!(outcome, Outcome::Handled))
|
||||
.count();
|
||||
let failed = outcomes
|
||||
.iter()
|
||||
.filter(|outcome| matches!(outcome, Outcome::Failed(_)))
|
||||
.count();
|
||||
eprintln!(
|
||||
"[claw] pass: {detected} event(s) detected, {handled} handled, {failed} failed"
|
||||
);
|
||||
}
|
||||
match self.interactive_driver.run_once().await {
|
||||
Ok(runs) if !runs.is_empty() => {
|
||||
eprintln!("[claw] interactive: {} queued run(s) drained", runs.len());
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => eprintln!("[claw] warning: interactive pass failed: {err}"),
|
||||
}
|
||||
Ok(TickReport { detected, outcomes })
|
||||
}
|
||||
|
||||
/// The resident loop: the HTTP surface comes up first (a resident is
|
||||
/// talked to), then detect, run, sleep, until ctrl-c. A failed pass
|
||||
/// is logged, not fatal — a forge outage or a dead backend must not
|
||||
/// end a daemon whose next pass may simply succeed.
|
||||
pub async fn run(&self) -> Result<(), ClawError> {
|
||||
self.bootstrap().await?;
|
||||
let listener = tokio::net::TcpListener::bind(&self.listen).await?;
|
||||
let addr = listener.local_addr()?;
|
||||
let app = self.router();
|
||||
let server = tokio::spawn(async move {
|
||||
if let Err(err) = axum::serve(listener, app).await {
|
||||
eprintln!("[claw] http server stopped: {err}");
|
||||
}
|
||||
});
|
||||
eprintln!("[claw] http listening on {addr}");
|
||||
loop {
|
||||
if let Err(err) = self.tick().await {
|
||||
eprintln!("[claw] warning: pass failed: {err}");
|
||||
}
|
||||
tokio::select! {
|
||||
() = tokio::time::sleep(self.poll_interval) => {}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
server.abort();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `--once` shape: assemble over a zero debounce window, recover, run
|
||||
/// one detect-plus-driver pass, and return. The cron-friendly form for
|
||||
/// ephemeral machines, and the shape the tests drive. No HTTP: a cron
|
||||
/// pass has no one to chat with.
|
||||
pub async fn run_once(
|
||||
config: &ClawConfig,
|
||||
backends: Arc<BackendRegistry>,
|
||||
) -> Result<TickReport, ClawError> {
|
||||
let dispatch = DispatcherConfig {
|
||||
debounce_window_millis: 0,
|
||||
..DispatcherConfig::default()
|
||||
};
|
||||
let daemon = Daemon::build(config, backends, dispatch)?;
|
||||
daemon.bootstrap().await?;
|
||||
daemon.tick().await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_interactive_dispatcher_dispatches_now_and_leases_long() {
|
||||
let dispatch = interactive_dispatch();
|
||||
assert_eq!(dispatch.debounce_window_millis, 0, "chat is immediate");
|
||||
assert_eq!(dispatch.lease_ttl_millis, 3_600_000, "an hour");
|
||||
assert!(
|
||||
dispatch.max_concurrent_leases >= 16,
|
||||
"generous: turns are long"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
//! The boot-time environment report (`ENVIRONMENT.md`): what the machine
|
||||
//! already knows about itself, written before the first model call so no
|
||||
//! turn is spent rediscovering it (framework spec §1 — detection is
|
||||
//! deterministic code; §9 — context is earned).
|
||||
//!
|
||||
//! Everything here is synchronous, offline, and best-effort. Host facts
|
||||
//! come from `std::env::consts` plus a couple of tiny probes; every
|
||||
//! external command runs on a fixed argv (no shell, nothing
|
||||
//! interpolated) under a short budget whose expiry — like any other
|
||||
//! probe failure — is a line in the report, never a failed boot. The
|
||||
//! caller owns the path (the bootstrap store passes it in; the default
|
||||
//! shape is `<state-dir>/bootstrap/ENVIRONMENT.md`) and rewrites the file
|
||||
//! on every daemon start.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rhema_memory::record::format_utc;
|
||||
|
||||
/// The tools the report inventories, in report order. The list is what
|
||||
/// v1's resident workflow reaches for; absence of any entry is ordinary.
|
||||
const TOOLS: &[&str] = &["git", "cargo", "rustc", "docker", "node", "npm", "python3"];
|
||||
|
||||
/// The per-command budget: long enough for a cold `--version`, short
|
||||
/// enough that a hung probe cannot stall a boot past it.
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// How often a running probe is polled for exit.
|
||||
const POLL_STEP: Duration = Duration::from_millis(10);
|
||||
|
||||
/// The report line stands in for absent facts that are not tools.
|
||||
const UNKNOWN: &str = "unknown";
|
||||
|
||||
/// Generates the report and writes it to `path`, creating parent
|
||||
/// directories as needed. Returns the generated text so the caller can
|
||||
/// log or inject it without re-reading the file. The only failures are
|
||||
/// the write's own I/O errors: generation itself cannot fail, because a
|
||||
/// boot that cannot probe its machine must still start.
|
||||
pub fn write_report(path: impl AsRef<Path>) -> Result<String, std::io::Error> {
|
||||
let report = generate_report();
|
||||
if let Some(parent) = path.as_ref().parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, &report)?;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// The document: host, user, tools, and the generation stamp. Ran at
|
||||
/// daemon startup only, so its cost is bounded by the probe budgets, not
|
||||
/// by anything the model or the network does.
|
||||
fn generate_report() -> String {
|
||||
let mut out = String::from(
|
||||
"# Environment\n\n\
|
||||
Generated at daemon boot; regenerated on every start; hand \
|
||||
edits are overwritten.\n\n\
|
||||
## Host\n\n",
|
||||
);
|
||||
out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
|
||||
out.push_str(&format!("- Arch: {}\n", std::env::consts::ARCH));
|
||||
out.push_str(&format!("- Hostname: {}\n\n", hostname()));
|
||||
out.push_str("## User\n\n");
|
||||
out.push_str(&format!("- User: {}\n", user_name()));
|
||||
out.push_str(&format!("- Shell: {}\n\n", shell()));
|
||||
out.push_str("## Tools\n\n");
|
||||
for line in inventory() {
|
||||
out.push_str(&line);
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&format!("\nGenerated at {}.\n", now_utc()));
|
||||
out
|
||||
}
|
||||
|
||||
/// The tool inventory, one line per probed tool in [`TOOLS`] order.
|
||||
/// Probes run concurrently — one thread per tool — so a slow probe costs
|
||||
/// its own budget, not the sum of the budgets.
|
||||
fn inventory() -> Vec<String> {
|
||||
let handles: Vec<_> = TOOLS
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let name = *name;
|
||||
thread::spawn(move || tool_line(name))
|
||||
})
|
||||
.collect();
|
||||
TOOLS
|
||||
.iter()
|
||||
.zip(handles)
|
||||
.map(|(name, handle)| {
|
||||
handle
|
||||
.join()
|
||||
.unwrap_or_else(|_| format!("- {name}: present but unreadable (probe failed)"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One markdown inventory line for `name`: where it sits and what
|
||||
/// version it reports, or the hard `absent` verdict when no probe can
|
||||
/// place it.
|
||||
fn tool_line(name: &str) -> String {
|
||||
// `which` only names the location; the direct `--version` spawn
|
||||
// below decides presence, so hosts without a `which` binary still
|
||||
// report their tools.
|
||||
let located = match run_within("which", &[name], COMMAND_TIMEOUT) {
|
||||
RunOutcome::Output(path) => {
|
||||
let path = bound(&path);
|
||||
(!path.is_empty()).then_some(path)
|
||||
}
|
||||
RunOutcome::NotFound | RunOutcome::Failed(_) => None,
|
||||
};
|
||||
match run_within(name, &["--version"], COMMAND_TIMEOUT) {
|
||||
RunOutcome::Output(version) => {
|
||||
let version = bound(&version);
|
||||
match (located.as_deref(), version.is_empty()) {
|
||||
(Some(path), false) => format!("- {name}: {path} — {version}"),
|
||||
(Some(path), true) => format!("- {name}: {path}"),
|
||||
(None, false) => format!("- {name}: {version}"),
|
||||
(None, true) => format!("- {name}: present but unreadable (no output)"),
|
||||
}
|
||||
}
|
||||
RunOutcome::NotFound => format!("- {name}: absent"),
|
||||
RunOutcome::Failed(reason) => match located {
|
||||
Some(path) => {
|
||||
let reason = bound(&reason);
|
||||
format!("- {name}: {path} — present but unreadable ({reason})")
|
||||
}
|
||||
None => format!("- {name}: absent"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// What one external command produced. Every variant renders as a report
|
||||
/// line; none reaches the caller as an error.
|
||||
enum RunOutcome {
|
||||
/// Exited within the budget; captured output, untrimmed.
|
||||
Output(String),
|
||||
/// The executable does not exist.
|
||||
NotFound,
|
||||
/// Exited non-zero or by signal, or outlived the budget.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Runs `program` with the fixed `args`, allowed `budget` to exit. The
|
||||
/// argv is built only from literals and a tool name — never untrusted
|
||||
/// input, never a shell. Stdin is null so a probe that wrongly reads it
|
||||
/// sees EOF instead of hanging the boot. Pipes are read after the exit:
|
||||
/// these targets are version printers whose output fits any pipe buffer,
|
||||
/// so a post-exit read cannot block on them; anything still running at
|
||||
/// the deadline is killed and reaped.
|
||||
fn run_within(program: &str, args: &[&str], budget: Duration) -> RunOutcome {
|
||||
let mut child = match Command::new(program)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return RunOutcome::NotFound;
|
||||
}
|
||||
Err(err) => return RunOutcome::Failed(format!("cannot spawn: {err}")),
|
||||
};
|
||||
let deadline = Instant::now() + budget;
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break Some(status),
|
||||
Ok(None) if Instant::now() >= deadline => break None,
|
||||
Ok(None) => thread::sleep(POLL_STEP),
|
||||
Err(err) => return RunOutcome::Failed(format!("cannot wait: {err}")),
|
||||
}
|
||||
};
|
||||
let Some(status) = status else {
|
||||
// Over budget: kill and reap so no child outlives the report.
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return RunOutcome::Failed(format!("no exit within {budget:?}"));
|
||||
};
|
||||
if !status.success() {
|
||||
return RunOutcome::Failed(format!("exit: {status}"));
|
||||
}
|
||||
let stdout = read_capped(child.stdout.take());
|
||||
let stderr = read_capped(child.stderr.take());
|
||||
// Version banners sometimes land on stderr (older python3), so the
|
||||
// empty-stdout case falls back to it.
|
||||
let raw = if stdout.trim().is_empty() {
|
||||
stderr
|
||||
} else {
|
||||
stdout
|
||||
};
|
||||
RunOutcome::Output(raw)
|
||||
}
|
||||
|
||||
/// Reads a child pipe up to 16 KiB, lossily UTF-8. The cap bounds the
|
||||
/// read, not the report — [`bound`] cuts again at render time.
|
||||
fn read_capped(pipe: Option<impl Read>) -> String {
|
||||
let Some(mut pipe) = pipe else {
|
||||
return String::new();
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
let _ = (&mut pipe).take(16 * 1024).read_to_end(&mut bytes);
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
/// The report-side bound on captured output: first line only, cut to 200
|
||||
/// characters — chars, not bytes, so the cut never splits a code point.
|
||||
fn bound(raw: &str) -> String {
|
||||
let line = raw.trim().lines().next().unwrap_or("");
|
||||
line.chars().take(200).collect()
|
||||
}
|
||||
|
||||
/// The hostname: `/etc/hostname`'s first line, else the `hostname`
|
||||
/// command, else a stand-in — the file is not portable and the command
|
||||
/// is not guaranteed, so absence is a report line.
|
||||
fn hostname() -> String {
|
||||
if let Ok(raw) = std::fs::read_to_string("/etc/hostname") {
|
||||
let name = bound(&raw);
|
||||
if !name.is_empty() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
match run_within("hostname", &[], COMMAND_TIMEOUT) {
|
||||
RunOutcome::Output(name) => {
|
||||
let name = bound(&name);
|
||||
if name.is_empty() {
|
||||
UNKNOWN.to_owned()
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
RunOutcome::NotFound | RunOutcome::Failed(_) => UNKNOWN.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The account the daemon runs as: `whoami`, falling back to `$USER`,
|
||||
/// else a stand-in — a daemon may run with neither available.
|
||||
fn user_name() -> String {
|
||||
if let RunOutcome::Output(name) = run_within("whoami", &[], COMMAND_TIMEOUT) {
|
||||
let name = bound(&name);
|
||||
if !name.is_empty() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
std::env::var("USER")
|
||||
.ok()
|
||||
.map(|user| bound(&user))
|
||||
.filter(|user| !user.is_empty())
|
||||
.unwrap_or_else(|| UNKNOWN.to_owned())
|
||||
}
|
||||
|
||||
/// The login shell from `$SHELL`; "unset" when the daemon runs without
|
||||
/// one (a service unit, for instance).
|
||||
fn shell() -> String {
|
||||
std::env::var("SHELL")
|
||||
.ok()
|
||||
.map(|shell| bound(&shell))
|
||||
.filter(|shell| !shell.is_empty())
|
||||
.unwrap_or_else(|| "unset".to_owned())
|
||||
}
|
||||
|
||||
/// The wall-clock UTC stamp, to the second, rhema-memory's rendering —
|
||||
/// one timestamp shape across the resident tier.
|
||||
fn now_utc() -> String {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
format_utc(millis)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The report lands as UTF-8 markdown at the given path (parents
|
||||
/// created), with the fixed sections and one line per inventoried
|
||||
/// tool — whatever is or is not installed on this host — and closes
|
||||
/// on a to-the-second UTC stamp.
|
||||
#[test]
|
||||
fn the_report_writes_back_as_markdown_with_every_section() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("bootstrap").join("ENVIRONMENT.md");
|
||||
let report = write_report(&path).expect("writes");
|
||||
let on_disk = String::from_utf8(std::fs::read(&path).expect("reads back"))
|
||||
.expect("the file is utf-8");
|
||||
assert_eq!(report, on_disk, "the returned text is what landed");
|
||||
assert!(report.starts_with("# Environment\n"), "{report}");
|
||||
assert!(report.contains("regenerated on every start"), "{report}");
|
||||
assert!(report.contains("hand edits are overwritten"), "{report}");
|
||||
for section in ["## Host", "## User", "## Tools"] {
|
||||
assert!(report.contains(section), "missing {section}: {report}");
|
||||
}
|
||||
for tool in TOOLS {
|
||||
assert!(
|
||||
report.contains(&format!("- {tool}: ")),
|
||||
"no line for {tool}: {report}"
|
||||
);
|
||||
}
|
||||
let footer = report.lines().last().expect("non-empty report");
|
||||
assert_eq!(
|
||||
footer.len(),
|
||||
"Generated at 1970-01-01T00:00:00Z.".len(),
|
||||
"a UTC stamp to the second: {footer}"
|
||||
);
|
||||
assert!(footer.ends_with("Z."), "{footer}");
|
||||
}
|
||||
|
||||
/// A tool that exists nowhere still gets its line, through the same
|
||||
/// runner the real inventory uses: the hard `absent` verdict, never
|
||||
/// an error.
|
||||
#[test]
|
||||
fn a_missing_tool_gets_the_hard_absent_line() {
|
||||
assert_eq!(
|
||||
tool_line("rhema-claw-definitely-absent-probe"),
|
||||
"- rhema-claw-definitely-absent-probe: absent"
|
||||
);
|
||||
}
|
||||
|
||||
/// Captured output cannot bloat the report: first line only, cut at
|
||||
/// the cap, multibyte-safe.
|
||||
#[test]
|
||||
fn probe_output_is_bounded() {
|
||||
let huge = format!("{}\n{}", "x".repeat(10 * 200), "second line");
|
||||
let cut = bound(&huge);
|
||||
assert_eq!(cut.chars().count(), 200);
|
||||
assert!(!cut.contains('\n'), "{cut}");
|
||||
let multibyte = "é".repeat(3 * 200);
|
||||
assert_eq!(bound(&multibyte).chars().count(), 200);
|
||||
}
|
||||
|
||||
/// A probe that never exits is killed at its budget and reported as
|
||||
/// failed; the report never waits past a budget. Tolerates hosts
|
||||
/// without `sleep`.
|
||||
#[test]
|
||||
fn a_hanging_probe_is_cut_at_its_budget() {
|
||||
match run_within("sleep", &["30"], Duration::from_millis(150)) {
|
||||
RunOutcome::NotFound => {}
|
||||
RunOutcome::Failed(_) => {}
|
||||
RunOutcome::Output(_) => panic!("a 30s sleep outlived a 150ms budget"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
//! The sources: deterministic detection, one [`ForgeSource`] per forge
|
||||
//! kind.
|
||||
//!
|
||||
//! A source owns one forge's watch set: its `detect` pass reads the
|
||||
//! watch tables fresh — [`ClawStore::list`] and [`ClawStore::
|
||||
//! list_account_watches`] — and loops the kind's repos through a
|
||||
//! [`Watcher`] (poll, ingest, advance the per-repo cursor), then the
|
||||
//! kind's account watches through the identity store's credentials:
|
||||
//! poll the account's notifications, [`crate::triage::classify`] each
|
||||
//! one, and either ingest it as a role-tagged thread event, surface it
|
||||
//! to the account's human, or drop it. Neither pass touches the model
|
||||
//! (spec §1). Reading the tables every pass is what makes a watch added
|
||||
//! mid-flight (chat tool, HTTP route) take effect without a restart.
|
||||
//! Repo-watch clients are constructed on first use and cached per
|
||||
//! `(forge, host, token)`; account clients are built fresh every pass,
|
||||
//! so a rotated identity secret is live on the next tick. Cadence
|
||||
//! belongs to the daemon's loop; the source is one pass, nothing more.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use rhema_autonomous_agent::{Source, SourceError, async_trait};
|
||||
use rhema_forge_integration::forge_key_fn;
|
||||
use rhema_forge_integration::{
|
||||
Forge, GitHubForge, GitLabForge, GiteaForge, Notification, Watcher, register_forge,
|
||||
};
|
||||
use rhema_identity::{IdentityStore, SecretKind};
|
||||
use rhema_rsd::{Clock, Dispatcher, Event, KeyFn, SystemClock};
|
||||
|
||||
use crate::config::{ForgeKind, resolve_token};
|
||||
use crate::triage::{self, Triage};
|
||||
use crate::watch_store::{AccountWatchRecord, ClawStore, WatchRecord};
|
||||
|
||||
/// The cache key of a forge client: everything that changes the client
|
||||
/// is the forge kind, its host, and the token it authenticates with.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct ClientKey {
|
||||
forge: String,
|
||||
host: Option<String>,
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// The forge client one watch RECORD polls through: the kind's
|
||||
/// production base unless the record names a `host`. A blank stored
|
||||
/// token falls back to the forge's environment variable — the same
|
||||
/// resolution a config watch gets — and a blank everywhere constructs a
|
||||
/// client that polls anonymous; the poll fails loudly at the forge, not
|
||||
/// silently here.
|
||||
pub fn record_client(kind: ForgeKind, host: Option<&str>, token: &str) -> Arc<dyn Forge> {
|
||||
let resolved = resolve_token(|name| std::env::var(name), kind, Some(token)).unwrap_or_default();
|
||||
match kind {
|
||||
ForgeKind::GitHub => {
|
||||
let mut forge = GitHubForge::new(resolved);
|
||||
if let Some(host) = host {
|
||||
forge = forge.with_base_url(host.to_owned());
|
||||
}
|
||||
Arc::new(forge)
|
||||
}
|
||||
ForgeKind::Gitea => Arc::new(GiteaForge::new(
|
||||
host.unwrap_or_default().to_owned(),
|
||||
resolved,
|
||||
)),
|
||||
ForgeKind::GitLab => {
|
||||
let mut forge = GitLabForge::new(resolved);
|
||||
if let Some(host) = host {
|
||||
forge = forge.with_base_url(host.to_owned());
|
||||
}
|
||||
Arc::new(forge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One forge kind's detectable input stream: every repo watch and every
|
||||
/// account watch of that kind in the tables, polled into the dispatcher.
|
||||
pub struct ForgeSource {
|
||||
name: String,
|
||||
watcher: Watcher,
|
||||
dispatcher: Arc<Dispatcher>,
|
||||
store: Arc<ClawStore>,
|
||||
/// The account store the account watches authenticate through. A
|
||||
/// secret stays inside this crate's code paths: it reaches the forge
|
||||
/// client's constructor, never a log line or a tool answer.
|
||||
accounts: Arc<dyn IdentityStore>,
|
||||
/// The device-notification queue a [`Triage::Surface`] lands in.
|
||||
notifications: Arc<crate::notifications::NotificationStore>,
|
||||
clients: Mutex<HashMap<ClientKey, Arc<dyn Forge>>>,
|
||||
/// One notifications cursor per account id.
|
||||
account_cursors: Mutex<HashMap<String, rhema_forge_integration::Timestamp>>,
|
||||
}
|
||||
|
||||
impl ForgeSource {
|
||||
/// Registers `name`'s key function on the dispatcher and returns the
|
||||
/// source polling the watch table's `name` rows — repo watches and
|
||||
/// account watches — under it. One source per kind, registered once,
|
||||
/// before the first detect.
|
||||
pub fn new(
|
||||
dispatcher: &Arc<Dispatcher>,
|
||||
name: impl Into<String>,
|
||||
store: Arc<ClawStore>,
|
||||
accounts: Arc<dyn IdentityStore>,
|
||||
notifications: Arc<crate::notifications::NotificationStore>,
|
||||
) -> Self {
|
||||
let name = name.into();
|
||||
register_forge(dispatcher, &name);
|
||||
Self {
|
||||
watcher: Watcher::new(dispatcher.clone(), name.clone()),
|
||||
dispatcher: dispatcher.clone(),
|
||||
name,
|
||||
store,
|
||||
accounts,
|
||||
notifications,
|
||||
clients: Mutex::new(HashMap::new()),
|
||||
account_cursors: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The client for `record`: from the cache when a watch with the
|
||||
/// same `(forge, host, token)` polled before, constructed otherwise.
|
||||
fn client_for(&self, record: &WatchRecord) -> Arc<dyn Forge> {
|
||||
let key = ClientKey {
|
||||
forge: record.forge.clone(),
|
||||
host: record.host.clone(),
|
||||
token: record.token.clone(),
|
||||
};
|
||||
let mut clients = self.clients.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
clients
|
||||
.entry(key)
|
||||
.or_insert_with(|| {
|
||||
let Some(kind) = ForgeKind::parse(&record.forge) else {
|
||||
// Unreachable through the daemon's assembly, which
|
||||
// only creates sources for known kinds; an unknown
|
||||
// kind in the table would be skipped in `detect`
|
||||
// before reaching here.
|
||||
unreachable!("detect filters unknown forge kinds before client lookup")
|
||||
};
|
||||
record_client(kind, record.host.as_deref(), &record.token)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Source for ForgeSource {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn key_fn(&self) -> Arc<KeyFn> {
|
||||
forge_key_fn(&self.name)
|
||||
}
|
||||
|
||||
async fn detect(&self) -> Result<usize, SourceError> {
|
||||
let records = self
|
||||
.store
|
||||
.list()
|
||||
.map_err(|err| SourceError::Backend(format!("watch store: {err}")))?;
|
||||
let mut fed = 0;
|
||||
for record in records {
|
||||
if record.forge != self.name {
|
||||
continue;
|
||||
}
|
||||
let repo = match record.repo_ref() {
|
||||
Ok(repo) => repo,
|
||||
Err(reason) => {
|
||||
// A malformed row must not starve the kind's other
|
||||
// watches; say so and move on. New rows cannot be
|
||||
// malformed (every entry validates), so this guards
|
||||
// only hand-edited tables.
|
||||
eprintln!(
|
||||
"[claw] warning: skipping watch {} {}: {reason}",
|
||||
record.forge, record.repo
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let client = self.client_for(&record);
|
||||
match self.watcher.poll(client.as_ref(), &repo).await {
|
||||
Ok(count) => fed += count,
|
||||
// One dead watch (a lapsed token, a forge outage) must
|
||||
// not starve the kind's other watches either.
|
||||
Err(err) => eprintln!(
|
||||
"[claw] warning: watch {} {}: poll failed: {err}",
|
||||
record.forge, record.repo
|
||||
),
|
||||
}
|
||||
}
|
||||
// The account watches: the same table-rules-every-pass doctrine,
|
||||
// with the identity store supplying each poll's credential.
|
||||
let accounts = self
|
||||
.store
|
||||
.list_account_watches()
|
||||
.map_err(|err| SourceError::Backend(format!("watch store: {err}")))?;
|
||||
for watch in accounts {
|
||||
if watch.forge != self.name {
|
||||
continue;
|
||||
}
|
||||
match self.poll_account(&watch).await {
|
||||
Ok(count) => fed += count,
|
||||
// One dead account (a revoked token, a forge outage)
|
||||
// must not starve the kind's other watches either.
|
||||
Err(err) => eprintln!(
|
||||
"[claw] warning: account watch {} {}: poll failed: {err}",
|
||||
watch.forge, watch.account_id
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(fed)
|
||||
}
|
||||
}
|
||||
|
||||
impl ForgeSource {
|
||||
/// One account-watch poll: fetch the account's notifications as the
|
||||
/// account, triage each, and land the verdicts — agent dispatches
|
||||
/// ingest as role-tagged thread events, surfaces hit the
|
||||
/// device-notification queue, ignores drop. Returns how many events
|
||||
/// were fed to the dispatcher.
|
||||
async fn poll_account(&self, watch: &AccountWatchRecord) -> Result<usize, SourceError> {
|
||||
let Some(kind) = ForgeKind::parse(&watch.forge) else {
|
||||
// Unreachable through the daemon's assembly, which only
|
||||
// creates sources for known kinds; an unknown kind in the
|
||||
// table would be skipped in `detect` before reaching here.
|
||||
unreachable!("detect filters unknown forge kinds before account polls")
|
||||
};
|
||||
let secret = self
|
||||
.accounts
|
||||
.secret(&watch.account_id)
|
||||
.map_err(|err| SourceError::Backend(format!("account store: {err}")))?;
|
||||
let Some(secret) = secret else {
|
||||
// The account is gone; its watch's cascade should have gone
|
||||
// with it. Skip quietly — `identity_forget` removes both,
|
||||
// and a hand-edited pair self-heals on the next forget.
|
||||
return Ok(0);
|
||||
};
|
||||
if secret.kind != SecretKind::Token {
|
||||
// A stored password is not a poll credential: nothing to
|
||||
// authenticate the notifications call with. Say so and move
|
||||
// on — the watch stays, a token rotation (or a future login
|
||||
// action) revives it.
|
||||
eprintln!(
|
||||
"[claw] warning: account watch {} {}: the account holds a password, not a token; skipping the poll",
|
||||
watch.forge, watch.account_id
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
// Fresh client every pass: a rotated secret is live on the next
|
||||
// tick, and one client build per account per pass is nothing
|
||||
// next to the poll itself.
|
||||
let client = record_client(kind, watch.host.as_deref(), &secret.value);
|
||||
let since = self
|
||||
.account_cursors
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.get(&watch.account_id)
|
||||
.cloned();
|
||||
let page = client
|
||||
.notifications(since.as_ref())
|
||||
.await
|
||||
.map_err(|err| SourceError::Backend(format!("notifications: {err}")))?;
|
||||
let now = SystemClock.now_millis();
|
||||
let mut fed = 0;
|
||||
for notification in &page.notifications {
|
||||
match triage::classify(notification) {
|
||||
Triage::Ignore => {}
|
||||
Triage::Surface => {
|
||||
if let Err(err) = self.notifications.push(
|
||||
crate::notifications::DEFAULT_DEVICE,
|
||||
&format!(
|
||||
"{}: {}#{}",
|
||||
watch.forge,
|
||||
notification.repo.path(),
|
||||
notification.number
|
||||
),
|
||||
&surface_body(notification),
|
||||
) {
|
||||
eprintln!(
|
||||
"[claw] warning: surface of {} failed: {err}",
|
||||
notification.id
|
||||
);
|
||||
}
|
||||
}
|
||||
Triage::Agent(role) => {
|
||||
// The key function reads `repo` (as the
|
||||
// `{owner, name}` row RepoRef serializes to) and
|
||||
// `number` off the payload's top level, so the
|
||||
// thread key is the same `forge:owner/repo#n` shape
|
||||
// the repo watches use — one session per thread,
|
||||
// whatever fed it.
|
||||
let payload = serde_json::json!({
|
||||
"repo": notification.repo,
|
||||
"number": notification.number,
|
||||
"role": role.as_str(),
|
||||
"notification": notification,
|
||||
});
|
||||
self.dispatcher
|
||||
.ingest(Event {
|
||||
id: notification.id.clone(),
|
||||
source: self.name.clone(),
|
||||
payload,
|
||||
received_at: now,
|
||||
})
|
||||
.map_err(SourceError::Dispatch)?;
|
||||
fed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(cursor) = page.cursor {
|
||||
self.account_cursors
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.insert(watch.account_id.clone(), cursor);
|
||||
}
|
||||
Ok(fed)
|
||||
}
|
||||
}
|
||||
|
||||
/// The device-notification text a [`Triage::Surface`] lands: the reason
|
||||
/// and the thread title, so the human can judge without opening the
|
||||
/// forge.
|
||||
fn surface_body(notification: &Notification) -> String {
|
||||
let title = notification.title.as_deref().unwrap_or("(untitled)");
|
||||
format!(
|
||||
"{} on {}#{}: {}",
|
||||
notification.reason.as_str(),
|
||||
notification.repo.path(),
|
||||
notification.number,
|
||||
title
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The parts a test source polls through, all in memory.
|
||||
fn test_parts(
|
||||
store: Arc<ClawStore>,
|
||||
) -> (
|
||||
Arc<Dispatcher>,
|
||||
Arc<dyn IdentityStore>,
|
||||
Arc<crate::notifications::NotificationStore>,
|
||||
) {
|
||||
let _ = store;
|
||||
let dispatcher = Arc::new(Dispatcher::new(
|
||||
rhema_rsd::DispatcherConfig::default(),
|
||||
Arc::new(rhema_rsd::SystemClock),
|
||||
Arc::new(rhema_rsd::VolatileStore::new()),
|
||||
));
|
||||
let accounts: Arc<dyn IdentityStore> =
|
||||
Arc::new(rhema_identity::VolatileIdentityStore::new());
|
||||
let notifications = Arc::new(
|
||||
crate::notifications::NotificationStore::in_memory().expect("notification store opens"),
|
||||
);
|
||||
(dispatcher, accounts, notifications)
|
||||
}
|
||||
|
||||
/// The client cache pays off when two watches share their
|
||||
/// coordinates: one client, not two.
|
||||
#[tokio::test]
|
||||
async fn same_coordinates_share_one_cached_client() {
|
||||
let store = Arc::new(ClawStore::in_memory().expect("store opens"));
|
||||
store
|
||||
.add("github", "acme/widgets", None, "tok")
|
||||
.expect("add");
|
||||
store
|
||||
.add("github", "acme/gadgets", None, "tok")
|
||||
.expect("add");
|
||||
store
|
||||
.add("github", "acme/private", None, "other")
|
||||
.expect("add");
|
||||
let (dispatcher, accounts, notifications) = test_parts(store.clone());
|
||||
let source = ForgeSource::new(&dispatcher, "github", store, accounts, notifications);
|
||||
|
||||
let records = source.store.list().expect("list reads");
|
||||
let a = source.client_for(&records[0]);
|
||||
let b = source.client_for(&records[1]);
|
||||
let c = source.client_for(&records[2]);
|
||||
assert!(Arc::ptr_eq(&a, &b), "same (forge, host, token): one client");
|
||||
assert!(
|
||||
!Arc::ptr_eq(&a, &c),
|
||||
"a different token is a different client"
|
||||
);
|
||||
assert_eq!(source.clients.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// One triaged notification ingest: the payload carries `repo` and
|
||||
/// `number` at the top level, so the registered key function folds
|
||||
/// it onto the same thread key a repo-watch event takes.
|
||||
#[tokio::test]
|
||||
async fn a_triaged_notification_lands_on_the_thread_key() {
|
||||
use rhema_forge_integration::{NotificationReason, RepoRef, Timestamp};
|
||||
|
||||
let store = Arc::new(ClawStore::in_memory().expect("store opens"));
|
||||
let (_dispatcher, accounts, _notifications) = test_parts(store.clone());
|
||||
let account = rhema_identity::AccountDraft::new(
|
||||
"acme bot",
|
||||
"github",
|
||||
rhema_identity::Secret::new(rhema_identity::SecretKind::Token, "tok"),
|
||||
);
|
||||
let account = accounts.put(account).expect("account lands");
|
||||
store
|
||||
.add_account_watch("github", None, &account.id)
|
||||
.expect("watch lands");
|
||||
|
||||
// The key function must fold the triaged payload onto the thread
|
||||
// key: build a payload the way `poll_account` does and run it
|
||||
// through the registered `forge_key_fn`.
|
||||
let notification = Notification {
|
||||
id: "github:notification:7@2026-10-08T00:00:00Z".to_owned(),
|
||||
reason: NotificationReason::ReviewRequested,
|
||||
repo: RepoRef::new("acme", "widgets"),
|
||||
number: 142,
|
||||
is_pr: true,
|
||||
actor: None,
|
||||
title: Some("Add the flange".to_owned()),
|
||||
url: None,
|
||||
timestamp: Timestamp::parse("2026-10-08T00:00:00Z").expect("parses"),
|
||||
raw: serde_json::json!({}),
|
||||
};
|
||||
let payload = serde_json::json!({
|
||||
"repo": notification.repo,
|
||||
"number": notification.number,
|
||||
"role": "reviewer",
|
||||
"notification": notification,
|
||||
});
|
||||
let key_fn = forge_key_fn("github");
|
||||
let key = key_fn(&Event {
|
||||
id: notification.id.clone(),
|
||||
source: "github".to_owned(),
|
||||
payload,
|
||||
received_at: 0,
|
||||
});
|
||||
assert_eq!(key.as_str(), "github:acme/widgets#142");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,805 @@
|
||||
//! The handler: one dispatch of a forge thread becomes one coding-agent
|
||||
//! turn, and the turn's final text becomes the thread's comment reply.
|
||||
//!
|
||||
//! The deterministic part stays ahead of the model (spec §1, §8): the
|
||||
//! watch's forge client is looked up by key, the snapshot is fetched, the
|
||||
//! inbox is drained and decoded, the allowlist is resolved — v1 is
|
||||
//! permissive, the spec's starting point, pending a real
|
||||
//! profile-to-permissions mapping — and only then does the agent run.
|
||||
//! One turn per dispatch; the final assistant text posts verbatim.
|
||||
//!
|
||||
//! Session continuity rides the dispatcher, not the filesystem: the
|
||||
//! agent's session JSON is stashed into `ctx.session.core.driver_state`
|
||||
//! before the run returns and restored with [`CodingAgent::from_session`]
|
||||
//! on the next dispatch of the same key, so a resumed thread keeps its
|
||||
//! conversation. Commit-before-execute rides it too: the run installs the
|
||||
//! context's commit hook, so the intent is in the store before the first
|
||||
//! tool runs, and a crash-left dangling call revives at turn entry.
|
||||
//! Delivery is at-least-once: a re-delivered drained event
|
||||
//! re-dispatches, and this handler treats a dispatch as work to do — the
|
||||
//! dedup boundary is the dispatcher's, not re-derived here.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use crate::config::ForgeKind;
|
||||
use crate::forge::record_client;
|
||||
use crate::triage::Role;
|
||||
use crate::watch_store::ClawStore;
|
||||
use rhema_agent_tools::AllowAll;
|
||||
use rhema_autonomous_agent::{Handler, SessionContext, async_trait};
|
||||
use rhema_coding_agent::{CodingAgent, CodingAgentConfig};
|
||||
use rhema_core::{Content, Event as ModelEvent, EventSink, Message, Session};
|
||||
use rhema_forge_integration::{
|
||||
EventKind, Forge, ForgeError, ForgeEvent, Notification, RepoRef, Snapshot, ThreadState, decode,
|
||||
parse_key,
|
||||
};
|
||||
use rhema_rsd::{InboxItem, Outcome, ResourceKey};
|
||||
|
||||
use crate::backends::BackendRegistry;
|
||||
|
||||
/// The handler's tool permissions, resolved deterministically before the
|
||||
/// agent's first step (spec §8) — never by the model, never mid-run. V1
|
||||
/// is permissive, the spec's starting point; a profile-to-permissions
|
||||
/// mapping lands here as a per-deployment policy — the triage's
|
||||
/// [`Role`] is the seam it hangs on, a reviewer dispatch deserving a
|
||||
/// narrower policy than an implementer's — and a policy that refuses the
|
||||
/// shell refuses the run (see [`ClawHandler::dispatch`]).
|
||||
fn allowlist() -> rhema_autonomous_agent::Allowlist {
|
||||
rhema_autonomous_agent::Allowlist::permissive()
|
||||
}
|
||||
|
||||
/// The coding agent over one watch: a provider, a model, a workspace
|
||||
/// root, and the watch table the dispatch keys resolve through — live,
|
||||
/// so a watch added at runtime is answerable without a restart.
|
||||
pub struct ClawHandler {
|
||||
/// The deployment's providers and the active pair, read per dispatch.
|
||||
backends: Arc<BackendRegistry>,
|
||||
workspace_root: PathBuf,
|
||||
watches: Arc<ClawStore>,
|
||||
clients: Mutex<HashMap<String, Arc<dyn Forge>>>,
|
||||
}
|
||||
|
||||
impl ClawHandler {
|
||||
/// A handler whose per-thread workspaces live under `workspace_root`
|
||||
/// and whose dispatch keys resolve through `watches` at dispatch time.
|
||||
pub fn new(
|
||||
backends: Arc<BackendRegistry>,
|
||||
workspace_root: impl Into<PathBuf>,
|
||||
watches: Arc<ClawStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
backends,
|
||||
workspace_root: workspace_root.into(),
|
||||
watches,
|
||||
clients: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The forge client a dispatch key answers to: `"github:acme/widgets#7"`
|
||||
/// resolves its watch record from the table at dispatch time — the
|
||||
/// table is the source of truth, not a boot-time snapshot — and the
|
||||
/// client is cached per `(forge, host, token)` so one poll pool serves
|
||||
/// every thread of a watch.
|
||||
pub fn watch_forge(&self, key: &ResourceKey) -> Result<Arc<dyn Forge>, String> {
|
||||
let watch = watch_key(key)
|
||||
.ok_or_else(|| format!("resource key {key} is not a forge thread key"))?;
|
||||
let (kind, repo) = watch
|
||||
.split_once(':')
|
||||
.ok_or_else(|| format!("watch entry {watch} has no forge kind"))?;
|
||||
let record = self
|
||||
.watches
|
||||
.list()
|
||||
.map_err(|err| format!("watch lookup for {key} failed: {err}"))?
|
||||
.into_iter()
|
||||
.find(|record| record.forge == kind && record.repo == repo)
|
||||
.ok_or_else(|| format!("no watch configured for {key}"))?;
|
||||
let kind = ForgeKind::parse(&record.forge)
|
||||
.ok_or_else(|| format!("watch entry {watch} names an unknown forge"))?;
|
||||
let cache_key = format!("{}|{:?}|{}", record.forge, record.host, record.token);
|
||||
let mut clients = self.clients.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
Ok(clients
|
||||
.entry(cache_key)
|
||||
.or_insert_with(|| record_client(kind, record.host.as_deref(), &record.token))
|
||||
.clone())
|
||||
}
|
||||
|
||||
/// One dispatch, failed with a reason on any step. The session stash
|
||||
/// lands before the comment posts, so a failed post still leaves the
|
||||
/// conversation resumable.
|
||||
async fn dispatch(&self, ctx: &mut SessionContext) -> Result<Outcome, String> {
|
||||
// Owned: the key names the run through every step, while `ctx`
|
||||
// itself goes mutable for the inbox drain.
|
||||
let key = ctx.key.as_str().to_owned();
|
||||
let (repo, number) = parse_key(&ctx.key)
|
||||
.ok_or_else(|| format!("resource key {key} is not a forge thread key"))?;
|
||||
let forge = self.watch_forge(&ctx.key)?;
|
||||
|
||||
let snapshot = forge
|
||||
.snapshot(&repo, number)
|
||||
.await
|
||||
.map_err(|err| format!("snapshot of {key} failed: {err}"))?;
|
||||
let news = self.drain_news(ctx, &key).await?;
|
||||
let role = dominant_role(&news);
|
||||
|
||||
// The permission check is load-bearing: this handler's one tool
|
||||
// is the agent's shell, so a policy without it is a run that
|
||||
// could only flail — fail it loudly instead.
|
||||
let permissions = allowlist();
|
||||
if !permissions.allows("bash") {
|
||||
return Err(format!(
|
||||
"tool policy for {key} does not permit the shell; nothing to run"
|
||||
));
|
||||
}
|
||||
|
||||
let workspace = self.workspace_for(&key, &repo, number)?;
|
||||
let mut agent = self.resume(ctx, &workspace, &key)?;
|
||||
// Commit-before-execute: the intent is in the store before the
|
||||
// first tool runs; a lease that cannot be committed under fails
|
||||
// the step before any side effect.
|
||||
agent.set_commit_hook(Some(ctx.commit_hook()));
|
||||
|
||||
let prompt = build_prompt(&key, &snapshot, &news, workspace.as_path(), role);
|
||||
let mut sink = TurnSink;
|
||||
let output = agent
|
||||
.turn(prompt, &mut sink, Arc::new(AllowAll))
|
||||
.await
|
||||
.map_err(|err| format!("turn for {key} failed: {err}"))?;
|
||||
|
||||
// Stash before posting: the turn's work survives a failed comment.
|
||||
ctx.session.core.driver_state = serde_json::to_value(agent.session())
|
||||
.map_err(|err| format!("session for {key} does not serialize: {err}"))?;
|
||||
|
||||
let reply = final_text(&output.message)
|
||||
.ok_or_else(|| format!("the model returned no text to post for {key}"))?;
|
||||
forge
|
||||
.comment(&repo, number, &reply)
|
||||
.await
|
||||
.map_err(|err| format!("comment on {key} failed: {err}"))?;
|
||||
Ok(Outcome::Handled)
|
||||
}
|
||||
|
||||
/// Renews the lease and drains the inbox, decoding every item into
|
||||
/// the news it carries: a repo-watch forge event, or a triaged
|
||||
/// account notification with its role. A payload that decodes to
|
||||
/// neither is loud: a shape break must surface, not silently shrink
|
||||
/// the prompt.
|
||||
async fn drain_news(
|
||||
&self,
|
||||
ctx: &mut SessionContext,
|
||||
key: &str,
|
||||
) -> Result<Vec<InboxNews>, String> {
|
||||
let items = ctx
|
||||
.poll_inbox()
|
||||
.await
|
||||
.map_err(|err| format!("inbox poll for {key} failed: {err}"))?;
|
||||
items
|
||||
.iter()
|
||||
.map(decode_news)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|err| format!("an inbox item for {key} does not decode: {err}"))
|
||||
}
|
||||
|
||||
/// The agent for this dispatch: the stashed session resumed when the
|
||||
/// key has history, a fresh one otherwise. A stash that does not parse
|
||||
/// fails the run loudly — silently resetting would re-orient the agent
|
||||
/// mid-conversation without anyone knowing.
|
||||
fn resume(
|
||||
&self,
|
||||
ctx: &SessionContext,
|
||||
workspace: &Path,
|
||||
key: &str,
|
||||
) -> Result<CodingAgent, String> {
|
||||
let (provider, model) = self.backends.current();
|
||||
let config = CodingAgentConfig::new(model, workspace);
|
||||
let agent = match &ctx.session.core.driver_state {
|
||||
// A never-dispatched key carries no stash: start clean.
|
||||
serde_json::Value::Null => CodingAgent::new(provider, config),
|
||||
stashed => {
|
||||
let session: Session = serde_json::from_value(stashed.clone())
|
||||
.map_err(|err| format!("stashed session for {key} does not parse: {err}"))?;
|
||||
CodingAgent::from_session(provider, config, session)
|
||||
}
|
||||
};
|
||||
agent.map_err(|err| format!("cannot start the coding agent for {key}: {err}"))
|
||||
}
|
||||
|
||||
/// The per-thread workspace: one directory under the root, created on
|
||||
/// demand. The name mapping is not injective (separators collapse);
|
||||
/// thread identity lives in the dispatch key — the directory is only
|
||||
/// scratch space for the agent's tools.
|
||||
fn workspace_for(&self, key: &str, repo: &RepoRef, number: u64) -> Result<PathBuf, String> {
|
||||
let dir = self.workspace_root.join(workspace_name(key, repo, number));
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|err| format!("cannot create the workspace {dir:?} for {key}: {err}"))?;
|
||||
Ok(dir)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Handler for ClawHandler {
|
||||
async fn handle(&self, ctx: &mut SessionContext) -> Outcome {
|
||||
match self.dispatch(ctx).await {
|
||||
Ok(outcome) => outcome,
|
||||
Err(reason) => Outcome::Failed(reason),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The watch-table key of a resource key: `"github:acme/widgets#7"` builds
|
||||
/// `"github:acme/widgets"`. `None` for a key that is not a forge thread
|
||||
/// key at all.
|
||||
pub fn watch_key(key: &ResourceKey) -> Option<String> {
|
||||
let (head, _) = key.as_str().rsplit_once('#')?;
|
||||
head.contains(':').then(|| head.to_owned())
|
||||
}
|
||||
|
||||
/// The watch-table entry for a repo: `{kind}:{owner}/{name}` — the value
|
||||
/// [`watch_key`] parses back out of a dispatch key. The pair is the
|
||||
/// key-parse-to-watch-lookup seam.
|
||||
pub fn watch_entry(kind: &str, repo: &RepoRef) -> String {
|
||||
format!("{kind}:{}", repo.path())
|
||||
}
|
||||
|
||||
/// The workspace directory name for a thread: kind, repo path, and number
|
||||
/// flattened to filesystem-safe characters.
|
||||
pub fn workspace_name(key: &str, repo: &RepoRef, number: u64) -> String {
|
||||
let kind = key.split_once(':').map(|(kind, _)| kind).unwrap_or("forge");
|
||||
format!("{kind}-{}-{number}", repo.path())
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One drained inbox item: a plain repo-watch forge event, or a triaged
|
||||
/// account notification carrying the role the triage assigned it.
|
||||
#[derive(Debug, Clone)]
|
||||
enum InboxNews {
|
||||
Event(ForgeEvent),
|
||||
Notified {
|
||||
role: Role,
|
||||
notification: Notification,
|
||||
},
|
||||
}
|
||||
|
||||
/// The triaged payload [`crate::forge::ForgeSource`] ingests: `repo` and
|
||||
/// `number` ride at the top level for the key function, the rest is the
|
||||
/// notification and the role its triage earned.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TriagedPayload {
|
||||
role: Role,
|
||||
notification: Notification,
|
||||
}
|
||||
|
||||
/// Decodes one inbox item into its news. A triaged notification is tried
|
||||
/// first — a plain forge event has none of its fields — and the plain
|
||||
/// event second; anything else is a loud parse error.
|
||||
fn decode_news(item: &InboxItem) -> Result<InboxNews, ForgeError> {
|
||||
if let Ok(triaged) = serde_json::from_value::<TriagedPayload>(item.event.payload.clone()) {
|
||||
return Ok(InboxNews::Notified {
|
||||
role: triaged.role,
|
||||
notification: triaged.notification,
|
||||
});
|
||||
}
|
||||
decode(item).map(InboxNews::Event)
|
||||
}
|
||||
|
||||
/// The batch's dominant role, for the prompt's persona framing:
|
||||
/// implementing subsumes reviewing — a batch that asks for both is a
|
||||
/// dispatch where the work is the point. Plain events imply no persona:
|
||||
/// the default framing runs.
|
||||
fn dominant_role(news: &[InboxNews]) -> Option<Role> {
|
||||
if news.iter().any(|news| {
|
||||
matches!(
|
||||
news,
|
||||
InboxNews::Notified {
|
||||
role: Role::Implementer,
|
||||
..
|
||||
}
|
||||
)
|
||||
}) {
|
||||
Some(Role::Implementer)
|
||||
} else if news.iter().any(|news| {
|
||||
matches!(
|
||||
news,
|
||||
InboxNews::Notified {
|
||||
role: Role::Reviewer,
|
||||
..
|
||||
}
|
||||
)
|
||||
}) {
|
||||
Some(Role::Reviewer)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The persona framing a dispatched role opens the prompt with. The key
|
||||
/// stays the thread, so a role change across dispatches is the session's
|
||||
/// continuity, not a new conversation.
|
||||
fn persona_text(role: Role) -> &'static str {
|
||||
match role {
|
||||
Role::Reviewer => {
|
||||
"You are acting as this pull request's reviewer. Check the change \
|
||||
out in the workspace when you can, read the diff, and judge it: correctness, \
|
||||
tests, edge cases, fit with the surrounding code. Your final reply is the \
|
||||
review — findings first, ordered by severity, concrete and actionable; if \
|
||||
there is nothing to fix, say so plainly and say what you checked."
|
||||
}
|
||||
Role::Implementer => {
|
||||
"You are acting as this thread's implementer. Do the work the new \
|
||||
activity asks for: address the review feedback or the assigned issue by \
|
||||
changing the code in the workspace — build and test what you change. Your \
|
||||
final reply is a short report of what you changed and why, or, when the \
|
||||
whole change does not fit one turn, what landed now and the concrete plan \
|
||||
for the rest."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The agent's prompt for one dispatch: the persona framing (when the
|
||||
/// triage dispatched a role), the thread's standing state, the new news,
|
||||
/// and the posting contract. The final assistant message is the comment
|
||||
/// — everything else is orientation for the investigation.
|
||||
fn build_prompt(
|
||||
key: &str,
|
||||
snapshot: &Snapshot,
|
||||
news: &[InboxNews],
|
||||
workspace: &Path,
|
||||
role: Option<Role>,
|
||||
) -> String {
|
||||
let mut prompt = String::new();
|
||||
if let Some(role) = role {
|
||||
prompt.push_str(persona_text(role));
|
||||
prompt.push_str("\n\n");
|
||||
}
|
||||
prompt.push_str(&format!(
|
||||
"You are responding to new activity on forge thread {key}.\n\n\
|
||||
Thread: {}\nKind: {}; state: {}\nOpened by: {}\nLabels: {}\nOpening body:\n{}\n\n",
|
||||
snapshot.title,
|
||||
if snapshot.is_pr {
|
||||
"pull request"
|
||||
} else {
|
||||
"issue"
|
||||
},
|
||||
state_text(snapshot.state),
|
||||
snapshot.author.as_deref().unwrap_or("unknown"),
|
||||
if snapshot.labels.is_empty() {
|
||||
"none".to_owned()
|
||||
} else {
|
||||
snapshot.labels.join(", ")
|
||||
},
|
||||
snapshot
|
||||
.body
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|body| !body.is_empty())
|
||||
.unwrap_or("(none)"),
|
||||
));
|
||||
prompt.push_str("New activity to respond to:\n");
|
||||
for news in news {
|
||||
match news {
|
||||
InboxNews::Event(event) => prompt.push_str(&format!(
|
||||
"- {} by {}: {}\n",
|
||||
kind_text(event.kind),
|
||||
event.actor.as_deref().unwrap_or("unknown"),
|
||||
event
|
||||
.body
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|body| !body.is_empty())
|
||||
.unwrap_or("(no text)"),
|
||||
)),
|
||||
InboxNews::Notified { notification, .. } => prompt.push_str(&format!(
|
||||
"- notification ({}) on {} #{} by {}: {}\n",
|
||||
notification.reason.as_str(),
|
||||
thread_kind(notification.is_pr),
|
||||
notification.number,
|
||||
notification.actor.as_deref().unwrap_or("unknown"),
|
||||
notification
|
||||
.title
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|title| !title.is_empty())
|
||||
.unwrap_or("(no title)"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
prompt.push_str(&format!(
|
||||
"\nThe workspace is at {}: investigate there with your shell tools (read\n\
|
||||
code, run tests, reproduce reports) whenever the text alone does not\n\
|
||||
settle it. Your FINAL reply — the message you end the turn on, with\n\
|
||||
no tool calls after it — is posted to the thread verbatim: make it\n\
|
||||
the exact comment text for the thread's participants, with no\n\
|
||||
meta-commentary about this prompt.\n",
|
||||
workspace.display(),
|
||||
));
|
||||
prompt
|
||||
}
|
||||
|
||||
/// "pull request" or "issue", for the notification lines.
|
||||
fn thread_kind(is_pr: bool) -> &'static str {
|
||||
if is_pr { "pull request" } else { "issue" }
|
||||
}
|
||||
|
||||
/// The thread state, in words.
|
||||
fn state_text(state: ThreadState) -> &'static str {
|
||||
match state {
|
||||
ThreadState::Open => "open",
|
||||
ThreadState::Closed => "closed",
|
||||
ThreadState::Merged => "merged",
|
||||
}
|
||||
}
|
||||
|
||||
/// The event kind, in words.
|
||||
fn kind_text(kind: EventKind) -> &'static str {
|
||||
match kind {
|
||||
EventKind::Opened => "opened",
|
||||
EventKind::Reopened => "reopened",
|
||||
EventKind::Closed => "closed",
|
||||
EventKind::Merged => "merged",
|
||||
EventKind::Comment => "comment",
|
||||
EventKind::Review => "review",
|
||||
}
|
||||
}
|
||||
|
||||
/// The text a turn ends on: the assistant message's text parts, joined
|
||||
/// and trimmed. `None` when the model stopped on tool calls and never
|
||||
/// wrote a reply.
|
||||
pub(crate) fn final_text(message: &Message) -> Option<String> {
|
||||
let joined = message
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|content| match content {
|
||||
Content::Text(text) => Some(text.as_str()),
|
||||
Content::ToolCall(_) | Content::ToolResult(_) => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let trimmed = joined.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_owned())
|
||||
}
|
||||
|
||||
/// The turn's stderr log: tool traffic only. The reply is not streamed —
|
||||
/// it is posted to the thread, and a daemon log line per tool call is the
|
||||
/// observability a resident run gets.
|
||||
struct TurnSink;
|
||||
|
||||
impl EventSink for TurnSink {
|
||||
fn emit(&mut self, event: ModelEvent<'_>) {
|
||||
match event {
|
||||
ModelEvent::ToolCallStart { name, .. } => eprintln!("[claw] tool call: {name}"),
|
||||
ModelEvent::ToolResult {
|
||||
content, is_error, ..
|
||||
} => {
|
||||
let mark = if is_error { "failed" } else { "ok" };
|
||||
eprintln!("[claw] tool {mark}: {}", first_line(content));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first line of a tool result, cut for the stderr stream.
|
||||
fn first_line(content: &str) -> String {
|
||||
let line = content.lines().next().unwrap_or("");
|
||||
let mut cut = line.chars().take(72).collect::<String>();
|
||||
if line.chars().count() > 72 {
|
||||
cut.push('…');
|
||||
}
|
||||
cut
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rhema_core::ModelProvider;
|
||||
use rhema_forge_integration::NotificationReason;
|
||||
|
||||
fn snapshot() -> Snapshot {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"title": "crash on save",
|
||||
"body": "steps to reproduce…",
|
||||
"state": "open",
|
||||
"is_pr": false,
|
||||
"author": "alice",
|
||||
"labels": ["bug", "cli"],
|
||||
"url": "https://github.com/acme/widgets/issues/7",
|
||||
"raw": {},
|
||||
}))
|
||||
.expect("snapshot fixture parses")
|
||||
}
|
||||
|
||||
fn event(kind: EventKind, actor: &str, body: Option<&str>) -> ForgeEvent {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": "github:comment:8001",
|
||||
"kind": kind,
|
||||
"repo": { "owner": "acme", "name": "widgets" },
|
||||
"number": 7,
|
||||
"is_pr": false,
|
||||
"actor": actor,
|
||||
"body": body,
|
||||
"url": null,
|
||||
"timestamp": "2026-10-03T09:00:00Z",
|
||||
"raw": {},
|
||||
}))
|
||||
.expect("event fixture parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prompt_carries_the_thread_the_events_and_the_contract() {
|
||||
let prompt = build_prompt(
|
||||
"github:acme/widgets#7",
|
||||
&snapshot(),
|
||||
&[InboxNews::Event(event(
|
||||
EventKind::Comment,
|
||||
"dave",
|
||||
Some("any progress?"),
|
||||
))],
|
||||
Path::new("/tmp/workspaces/github-acme-widgets-7"),
|
||||
None,
|
||||
);
|
||||
assert!(prompt.contains("github:acme/widgets#7"), "key: {prompt}");
|
||||
assert!(prompt.contains("crash on save"), "title: {prompt}");
|
||||
assert!(prompt.contains("state: open"), "state: {prompt}");
|
||||
assert!(prompt.contains("Labels: bug, cli"), "labels: {prompt}");
|
||||
assert!(
|
||||
prompt.contains("- comment by dave: any progress?"),
|
||||
"events: {prompt}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("posted to the thread verbatim"),
|
||||
"contract: {prompt}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_level_events_carry_no_body_and_the_prompt_says_so() {
|
||||
let prompt = build_prompt(
|
||||
"github:acme/widgets#7",
|
||||
&snapshot(),
|
||||
&[InboxNews::Event(event(EventKind::Opened, "alice", None))],
|
||||
Path::new("/tmp/w"),
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("- opened by alice: (no text)"),
|
||||
"got: {prompt}"
|
||||
);
|
||||
}
|
||||
|
||||
fn notification(reason: NotificationReason) -> Notification {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": "github:notification:42188@2026-10-03T09:00:00Z",
|
||||
"reason": reason,
|
||||
"repo": { "owner": "acme", "name": "widgets" },
|
||||
"number": 142,
|
||||
"is_pr": true,
|
||||
"actor": "alice",
|
||||
"title": "Add the widget flange",
|
||||
"url": null,
|
||||
"timestamp": "2026-10-03T09:00:00Z",
|
||||
"raw": {},
|
||||
}))
|
||||
.expect("notification fixture parses")
|
||||
}
|
||||
|
||||
fn triaged_item(role: Role, reason: NotificationReason) -> InboxItem {
|
||||
InboxItem {
|
||||
event: rhema_rsd::Event {
|
||||
id: "github:notification:42188@2026-10-03T09:00:00Z".to_owned(),
|
||||
source: "github".to_owned(),
|
||||
payload: serde_json::json!({
|
||||
"repo": "acme/widgets",
|
||||
"number": 142,
|
||||
"role": role,
|
||||
"notification": notification(reason),
|
||||
}),
|
||||
received_at: 0,
|
||||
},
|
||||
delivered_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_triaged_item_decodes_to_its_role_and_a_plain_event_to_its_event() {
|
||||
let news = decode_news(&triaged_item(
|
||||
Role::Reviewer,
|
||||
NotificationReason::ReviewRequested,
|
||||
))
|
||||
.expect("decodes");
|
||||
assert!(matches!(
|
||||
news,
|
||||
InboxNews::Notified {
|
||||
role: Role::Reviewer,
|
||||
..
|
||||
}
|
||||
));
|
||||
let plain = InboxItem {
|
||||
event: rhema_rsd::Event {
|
||||
id: "github:comment:8001".to_owned(),
|
||||
source: "github".to_owned(),
|
||||
payload: serde_json::to_value(event(EventKind::Comment, "dave", None))
|
||||
.expect("serializes"),
|
||||
received_at: 0,
|
||||
},
|
||||
delivered_at: 0,
|
||||
};
|
||||
assert!(matches!(decode_news(&plain), Ok(InboxNews::Event(_))));
|
||||
let garbage = InboxItem {
|
||||
event: rhema_rsd::Event {
|
||||
id: "x".to_owned(),
|
||||
source: "github".to_owned(),
|
||||
payload: serde_json::json!({"neither": true}),
|
||||
received_at: 0,
|
||||
},
|
||||
delivered_at: 0,
|
||||
};
|
||||
assert!(decode_news(&garbage).is_err(), "an unknown shape is loud");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_batchs_dominant_role_subordinates_implementing_to_nothing() {
|
||||
assert_eq!(dominant_role(&[]), None);
|
||||
assert_eq!(
|
||||
dominant_role(&[InboxNews::Event(event(EventKind::Comment, "dave", None))]),
|
||||
None,
|
||||
"plain events imply no persona"
|
||||
);
|
||||
let review = InboxNews::Notified {
|
||||
role: Role::Reviewer,
|
||||
notification: notification(NotificationReason::ReviewRequested),
|
||||
};
|
||||
let work = InboxNews::Notified {
|
||||
role: Role::Implementer,
|
||||
notification: notification(NotificationReason::Assigned),
|
||||
};
|
||||
assert_eq!(
|
||||
dominant_role(std::slice::from_ref(&review)),
|
||||
Some(Role::Reviewer)
|
||||
);
|
||||
assert_eq!(
|
||||
dominant_role(std::slice::from_ref(&work)),
|
||||
Some(Role::Implementer)
|
||||
);
|
||||
assert_eq!(
|
||||
dominant_role(&[review, work]),
|
||||
Some(Role::Implementer),
|
||||
"doing the work subsumes judging it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reviewer_dispatch_frames_the_prompt_as_a_review() {
|
||||
let prompt = build_prompt(
|
||||
"github:acme/widgets#142",
|
||||
&snapshot(),
|
||||
&[InboxNews::Notified {
|
||||
role: Role::Reviewer,
|
||||
notification: notification(NotificationReason::ReviewRequested),
|
||||
}],
|
||||
Path::new("/tmp/w"),
|
||||
Some(Role::Reviewer),
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("acting as this pull request's reviewer"),
|
||||
"persona: {prompt}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("notification (review_requested) on pull request #142"),
|
||||
"news line: {prompt}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_final_text_joins_and_trims_but_not_tool_calls() {
|
||||
let message = Message {
|
||||
role: rhema_core::Role::Assistant,
|
||||
content: vec![
|
||||
Content::Text("on it".into()),
|
||||
Content::Text(" — closing.".into()),
|
||||
],
|
||||
};
|
||||
assert_eq!(final_text(&message).as_deref(), Some("on it — closing."));
|
||||
let tool_only = Message {
|
||||
role: rhema_core::Role::Assistant,
|
||||
content: vec![Content::ToolCall(rhema_core::ToolCall {
|
||||
id: "call_1".into(),
|
||||
name: "bash".into(),
|
||||
args: serde_json::json!({}),
|
||||
provider_data: serde_json::Value::Null,
|
||||
})],
|
||||
};
|
||||
assert_eq!(final_text(&tool_only), None, "no text, no reply");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_resolves_to_its_watch_entry_live_from_the_table() {
|
||||
let watches = Arc::new(ClawStore::in_memory().expect("store opens"));
|
||||
watches
|
||||
.add("github", "acme/widgets", None, "tok")
|
||||
.expect("watch added");
|
||||
let registry = Arc::new(
|
||||
crate::backends::BackendRegistry::new(
|
||||
vec![crate::backends::Backend::headless(
|
||||
"mock",
|
||||
Arc::new(NoopProvider),
|
||||
)],
|
||||
None,
|
||||
)
|
||||
.expect("constructs"),
|
||||
);
|
||||
let handler = ClawHandler::new(registry, "/tmp/w", watches);
|
||||
|
||||
// A watch added to the table AFTER the handler was built resolves:
|
||||
// the table is the source of truth, not a boot-time snapshot.
|
||||
let key = ResourceKey::from("github:acme/widgets#7");
|
||||
assert_eq!(watch_key(&key).as_deref(), Some("github:acme/widgets"));
|
||||
assert!(handler.watch_forge(&key).is_ok(), "the watch resolves");
|
||||
assert!(
|
||||
handler
|
||||
.watch_forge(&ResourceKey::from("gitea:acme/widgets#7"))
|
||||
.is_err(),
|
||||
"another forge's key does not"
|
||||
);
|
||||
assert!(
|
||||
handler
|
||||
.watch_forge(&ResourceKey::from("github:nonsense"))
|
||||
.is_err(),
|
||||
"a non-thread key does not"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_workspace_name_flattens_to_filesystem_safe_characters() {
|
||||
assert_eq!(
|
||||
workspace_name("github:acme/widgets#7", &RepoRef::new("acme", "widgets"), 7),
|
||||
"github-acme-widgets-7"
|
||||
);
|
||||
assert_eq!(
|
||||
workspace_name(
|
||||
"gitlab:group/subgroup/widgets#7",
|
||||
&RepoRef::new("group/subgroup", "widgets"),
|
||||
7
|
||||
),
|
||||
"gitlab-group-subgroup-widgets-7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_table_entry_round_trips_through_the_dispatch_key() {
|
||||
let repo = RepoRef::new("acme", "widgets");
|
||||
let entry = watch_entry("github", &repo);
|
||||
let key = ResourceKey::from(format!("{entry}#7"));
|
||||
assert_eq!(watch_key(&key).as_deref(), Some(entry.as_str()));
|
||||
}
|
||||
|
||||
/// Stands in for the provider in pure-lookup tests; nothing calls it.
|
||||
struct NoopProvider;
|
||||
#[rhema_core::async_trait]
|
||||
impl ModelProvider for NoopProvider {
|
||||
async fn complete(
|
||||
&self,
|
||||
_req: rhema_core::ModelRequest,
|
||||
_sink: &mut dyn EventSink,
|
||||
) -> Result<rhema_core::ModelOutput, rhema_core::ProviderError> {
|
||||
Err(rhema_core::ProviderError::Api("never called".into()))
|
||||
}
|
||||
}
|
||||
|
||||
// `allowlist()` pins the v1 policy: permissive. The assertion keeps
|
||||
// the policy a decision someone wrote down, not an accident.
|
||||
#[test]
|
||||
fn the_v1_allowlist_is_permissive() {
|
||||
let allowlist = allowlist();
|
||||
assert!(allowlist.allows("bash"));
|
||||
assert!(allowlist.allows("anything_at_all"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,558 @@
|
||||
//! The event hub: per-key broadcast of live turn events, and the
|
||||
//! per-key record a late subscriber replays.
|
||||
//!
|
||||
//! HTTP subscribers stream one key's turns through it: a chat UI holds
|
||||
//! one `GET /v1/sessions/{key}/events` connection open and watches every
|
||||
//! turn of that session go by — whoever triggered the turn. The hub is
|
||||
//! a [`tokio::sync::broadcast`] channel per key behind a mutex'd map.
|
||||
//! `emit` records the event and broadcasts in one lock; a subscriber
|
||||
//! that asks for history gets the record and a live receiver cut under
|
||||
//! that same lock — every event lands in exactly one of the two, so a
|
||||
//! replay has no gap and no overlap with the live stream, ever.
|
||||
//!
|
||||
//! Where the record lives depends on the assembly. With a turn store
|
||||
//! attached (the daemon), every event is appended there as it streams
|
||||
//! and a replay reads the store back: the transcript is unbounded and
|
||||
//! survives restarts, so a session that runs for weeks replays whole —
|
||||
//! the appends serialize under the hub's lock, which is what keeps the
|
||||
//! table's order the stream's order. Without one (tests, store-less
|
||||
//! assemblies) the record is a bounded in-memory window
|
||||
//! ([`RECORD_CAPACITY`] events per key, the oldest falling off), good
|
||||
//! enough for a process that lives as long as its transcript needs to.
|
||||
//!
|
||||
//! Live delivery itself stays best-effort: a subscriber that falls more
|
||||
//! than [`EventHub::capacity`] events behind drops the oldest ones
|
||||
//! (`broadcast`'s contract) and the receiver surfaces the skip as a
|
||||
//! `Lagged` error so clients can say so.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use rhema_rsd::ResourceKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::turn_store::TurnStore;
|
||||
|
||||
/// How many events a subscriber may trail by before the oldest drop.
|
||||
/// Generous: a turn's deltas arrive faster than any viewer, but rarely
|
||||
/// past four digits.
|
||||
pub const TURN_CHANNEL_CAPACITY: usize = 1024;
|
||||
|
||||
/// How many events one key's in-memory record holds when no turn store
|
||||
/// is attached. Wide enough that a reload rebuilds whole recent turns —
|
||||
/// thinking traces and tool rows are deltas, and they add up.
|
||||
pub const RECORD_CAPACITY: usize = 8192;
|
||||
|
||||
/// One event of a turn, as the wire sees it. Serialized with an internal
|
||||
/// `type` tag, snake-cased: `{"type":"text_delta","text":"..."}`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum TurnEvent {
|
||||
/// The turn's input: the text (a batch folds into one) the turn
|
||||
/// answers, recorded by the handler when the dispatch starts. It
|
||||
/// exists for the replay — a chat surface shows its own messages
|
||||
/// as it sends them, and skips the live echo.
|
||||
UserMessage {
|
||||
/// The input text, verbatim.
|
||||
text: String,
|
||||
},
|
||||
/// A piece of the model's reasoning trace, in stream order.
|
||||
ThinkingDelta {
|
||||
/// The text fragment.
|
||||
text: String,
|
||||
},
|
||||
/// A piece of the model's reply, in stream order.
|
||||
TextDelta {
|
||||
/// The text fragment.
|
||||
text: String,
|
||||
},
|
||||
/// A tool call begins.
|
||||
ToolStart {
|
||||
/// The tool's name.
|
||||
name: String,
|
||||
},
|
||||
/// A tool result landed.
|
||||
ToolResult {
|
||||
/// The tool's name.
|
||||
name: String,
|
||||
/// Whether the call failed.
|
||||
is_error: bool,
|
||||
/// A bounded snippet of the result content.
|
||||
summary: String,
|
||||
/// The process exit code of command-like tools; `None` otherwise.
|
||||
code: Option<i32>,
|
||||
/// The command line a shell tool ran, parsed from the call's
|
||||
/// streamed arguments; `None` for every other tool.
|
||||
command: Option<String>,
|
||||
},
|
||||
/// The turn is over — the clean end marker subscribers wait on,
|
||||
/// emitted by the handler itself, so it arrives whoever triggered
|
||||
/// the turn.
|
||||
TurnDone {
|
||||
/// The turn's final assistant text, when it produced one.
|
||||
reply: Option<String>,
|
||||
/// `handled` or `failed`.
|
||||
outcome: String,
|
||||
},
|
||||
/// The agent asked the human something only they can answer: a
|
||||
/// credential to store, permission to act. A chat surface renders
|
||||
/// the ask as a card and answers through the consent routes.
|
||||
ConsentRequest {
|
||||
/// The request's id — the answer addresses it.
|
||||
id: String,
|
||||
/// What kind of answer: `secret` or `phone`.
|
||||
kind: String,
|
||||
/// The one-line ask.
|
||||
summary: String,
|
||||
/// The ask's context, the model's own reason.
|
||||
detail: String,
|
||||
/// When the request was made, epoch millis — a card that has
|
||||
/// outlived the answer window greys out against it.
|
||||
created_at: u64,
|
||||
},
|
||||
/// A consent request settled. The tool's answer rides the turn like
|
||||
/// any tool result; this is how the card learns it can stop waiting.
|
||||
ConsentResolved {
|
||||
/// The request's id.
|
||||
id: String,
|
||||
/// Whether it was granted.
|
||||
granted: bool,
|
||||
},
|
||||
/// The agent attached a file from its workspace: the metadata a
|
||||
/// card needs, the bytes behind `GET /v1/attachments/{id}`.
|
||||
Attachment {
|
||||
/// The attachment's id.
|
||||
id: String,
|
||||
/// The file's name, as the tool call named it.
|
||||
name: String,
|
||||
/// What the name implies: `image`, `code`, or `file`.
|
||||
media: String,
|
||||
/// The byte size.
|
||||
size: u64,
|
||||
/// One line about the file, when the tool call carried one.
|
||||
caption: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl TurnEvent {
|
||||
/// The event's kind, spelled the way the wire's `type` tag spells
|
||||
/// it — for logs that name what they dropped.
|
||||
pub fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::UserMessage { .. } => "user_message",
|
||||
Self::ThinkingDelta { .. } => "thinking_delta",
|
||||
Self::TextDelta { .. } => "text_delta",
|
||||
Self::ToolStart { .. } => "tool_start",
|
||||
Self::ToolResult { .. } => "tool_result",
|
||||
Self::TurnDone { .. } => "turn_done",
|
||||
Self::ConsentRequest { .. } => "consent_request",
|
||||
Self::ConsentResolved { .. } => "consent_resolved",
|
||||
Self::Attachment { .. } => "attachment",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One key's entry: the live channel, plus the in-memory record a
|
||||
/// replay reads when no turn store is attached. Both live behind one
|
||||
/// mutex, which is what makes a history snapshot + subscription atomic.
|
||||
struct KeyEntry {
|
||||
history: VecDeque<TurnEvent>,
|
||||
sender: broadcast::Sender<TurnEvent>,
|
||||
}
|
||||
|
||||
/// The per-key record and broadcast of [`TurnEvent`]s.
|
||||
pub struct EventHub {
|
||||
entries: Mutex<HashMap<ResourceKey, KeyEntry>>,
|
||||
capacity: usize,
|
||||
/// Where events append as they stream; `None` keeps the record
|
||||
/// memory-only (tests, and any assembly without a database).
|
||||
record_store: Option<Arc<TurnStore>>,
|
||||
}
|
||||
|
||||
impl Default for EventHub {
|
||||
fn default() -> Self {
|
||||
Self::with_capacity(TURN_CHANNEL_CAPACITY)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventHub {
|
||||
/// A hub whose per-key channels hold `capacity` events.
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
capacity,
|
||||
record_store: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A hub that appends every event to `store` as it streams — the
|
||||
/// replay a restart serves reads back exactly what streamed, with no
|
||||
/// window and no end-marker boundary.
|
||||
pub fn with_record_store(store: Arc<TurnStore>) -> Self {
|
||||
Self {
|
||||
record_store: Some(store),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribes to `key`'s turns, creating the channel on first use.
|
||||
/// Subscribe BEFORE triggering work: a turn that starts before the
|
||||
/// subscriber exists is simply not streamed to it. The chat
|
||||
/// surface's route uses [`Self::subscribe_with_history`] instead;
|
||||
/// this live-only shape is for callers that already know where the
|
||||
/// turn starts (the shim, the messages route).
|
||||
pub fn subscribe(&self, key: &ResourceKey) -> broadcast::Receiver<TurnEvent> {
|
||||
let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
Self::entry(&mut entries, key, self.capacity)
|
||||
.sender
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
/// Subscribes to `key`'s turns and hands back the record cut at the
|
||||
/// same instant: everything emitted before this call is in
|
||||
/// `history`, everything after arrives on the receiver. The pair is
|
||||
/// cut under one lock, so no event is in both and none is in
|
||||
/// neither — a client folds `history` first and the stream after,
|
||||
/// and the transcript is whole.
|
||||
pub fn subscribe_with_history(
|
||||
&self,
|
||||
key: &ResourceKey,
|
||||
) -> (Vec<TurnEvent>, broadcast::Receiver<TurnEvent>) {
|
||||
Self::subscribe_with_history_from(self, key, 0)
|
||||
}
|
||||
|
||||
/// [`Self::subscribe_with_history`] with a cursor: `skip` names how
|
||||
/// many of the record's oldest events the caller already holds (the
|
||||
/// count a client tracks for its local transcript cache), and the
|
||||
/// replay drops them, so a reconnecting client that kept its
|
||||
/// transcript receives only what is new. A `skip` past the record's
|
||||
/// end clamps to it — the client cannot skip into events that do not
|
||||
/// exist, and the live feed after the cut is unaffected either way.
|
||||
pub fn subscribe_with_history_from(
|
||||
&self,
|
||||
key: &ResourceKey,
|
||||
skip: usize,
|
||||
) -> (Vec<TurnEvent>, broadcast::Receiver<TurnEvent>) {
|
||||
let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let entry = Self::entry(&mut entries, key, self.capacity);
|
||||
let mut history = match self.record_store.as_ref() {
|
||||
Some(store) => store.events(key.as_str()).unwrap_or_else(|err| {
|
||||
eprintln!("[claw] warning: replay for {key} failed: {err}");
|
||||
Vec::new()
|
||||
}),
|
||||
None => entry.history.iter().cloned().collect(),
|
||||
};
|
||||
history.drain(..skip.min(history.len()));
|
||||
(history, entry.sender.subscribe())
|
||||
}
|
||||
|
||||
/// Publishes one event to `key`: the record takes it, and every
|
||||
/// live subscriber gets a copy. A key with nobody listening is not
|
||||
/// an error — turns run whether or not anyone watches, and the
|
||||
/// record keeps the turn replayable. With a store attached the
|
||||
/// append happens under the same lock as the broadcast, so the
|
||||
/// table's order is the stream's order no matter how events race in
|
||||
/// across keys.
|
||||
pub fn emit(&self, key: &ResourceKey, event: TurnEvent) {
|
||||
let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let entry = Self::entry(&mut entries, key, self.capacity);
|
||||
match self.record_store.as_ref() {
|
||||
Some(store) => {
|
||||
if let Err(err) = store.append(key.as_str(), std::slice::from_ref(&event)) {
|
||||
eprintln!("[claw] warning: turn-history append for {key} failed: {err}");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
entry.history.push_back(event.clone());
|
||||
if entry.history.len() > RECORD_CAPACITY {
|
||||
entry.history.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = entry.sender.send(event);
|
||||
}
|
||||
|
||||
/// The map entry for `key`, creating it (and its channel) on first
|
||||
/// use. Entries live for the process lifetime once created — the
|
||||
/// live channel is the point; with a store attached the record
|
||||
/// outlives the process, without one it stays bounded by
|
||||
/// `RECORD_CAPACITY`.
|
||||
fn entry<'a>(
|
||||
entries: &'a mut HashMap<ResourceKey, KeyEntry>,
|
||||
key: &ResourceKey,
|
||||
capacity: usize,
|
||||
) -> &'a mut KeyEntry {
|
||||
entries.entry(key.clone()).or_insert_with(|| KeyEntry {
|
||||
history: VecDeque::new(),
|
||||
sender: broadcast::channel(capacity).0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(name: &str) -> ResourceKey {
|
||||
ResourceKey::from(format!("interactive:{name}"))
|
||||
}
|
||||
|
||||
fn text(text: &str) -> TurnEvent {
|
||||
TurnEvent::TextDelta { text: text.into() }
|
||||
}
|
||||
|
||||
fn done(reply: &str) -> TurnEvent {
|
||||
TurnEvent::TurnDone {
|
||||
reply: Some(reply.into()),
|
||||
outcome: "handled".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_serialize_with_a_type_tag_and_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TurnEvent::TextDelta { text: "hi".into() }).unwrap(),
|
||||
r#"{"type":"text_delta","text":"hi"}"#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TurnEvent::UserMessage { text: "hi".into() }).unwrap(),
|
||||
r#"{"type":"user_message","text":"hi"}"#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TurnEvent::ToolResult {
|
||||
name: "bash".into(),
|
||||
is_error: true,
|
||||
summary: "exit 1".into(),
|
||||
code: Some(1),
|
||||
command: Some("false".into()),
|
||||
})
|
||||
.unwrap(),
|
||||
r#"{"type":"tool_result","name":"bash","is_error":true,"summary":"exit 1","code":1,"command":"false"}"#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TurnEvent::TurnDone {
|
||||
reply: Some("done".into()),
|
||||
outcome: "handled".into()
|
||||
})
|
||||
.unwrap(),
|
||||
r#"{"type":"turn_done","reply":"done","outcome":"handled"}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kind_is_the_wire_type_tag() {
|
||||
assert_eq!(
|
||||
TurnEvent::TextDelta { text: "x".into() }.kind(),
|
||||
"text_delta"
|
||||
);
|
||||
assert_eq!(
|
||||
TurnEvent::UserMessage { text: "x".into() }.kind(),
|
||||
"user_message"
|
||||
);
|
||||
assert_eq!(
|
||||
TurnEvent::TurnDone {
|
||||
reply: None,
|
||||
outcome: "handled".into()
|
||||
}
|
||||
.kind(),
|
||||
"turn_done"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subscriber_receives_what_is_emitted_after_it_subscribed() {
|
||||
let hub = EventHub::default();
|
||||
let key = key("web");
|
||||
let mut receiver = hub.subscribe(&key);
|
||||
hub.emit(
|
||||
&key,
|
||||
TurnEvent::ToolStart {
|
||||
name: "bash".into(),
|
||||
},
|
||||
);
|
||||
hub.emit(&key, done("done"));
|
||||
assert_eq!(
|
||||
receiver.try_recv().unwrap(),
|
||||
TurnEvent::ToolStart {
|
||||
name: "bash".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(receiver.try_recv().unwrap(), done("done"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_history_subscription_replays_what_preceded_it_and_splits_exactly_once() {
|
||||
let hub = EventHub::default();
|
||||
let key = key("web");
|
||||
hub.emit(
|
||||
&key,
|
||||
TurnEvent::UserMessage {
|
||||
text: "hello".into(),
|
||||
},
|
||||
);
|
||||
hub.emit(&key, text("hi"));
|
||||
|
||||
let (history, mut receiver) = hub.subscribe_with_history(&key);
|
||||
assert_eq!(
|
||||
history,
|
||||
vec![
|
||||
TurnEvent::UserMessage {
|
||||
text: "hello".into()
|
||||
},
|
||||
text("hi"),
|
||||
],
|
||||
"everything before the cut is in the record"
|
||||
);
|
||||
hub.emit(&key, done("hi back"));
|
||||
assert_eq!(
|
||||
receiver.try_recv().unwrap(),
|
||||
done("hi back"),
|
||||
"everything after the cut is on the stream"
|
||||
);
|
||||
|
||||
// A second cut contains the whole run — the live events were
|
||||
// recorded too, and none of them twice.
|
||||
let (history, _) = hub.subscribe_with_history(&key);
|
||||
assert_eq!(history.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emitting_to_nobody_listening_is_quiet_and_still_recorded() {
|
||||
let hub = EventHub::default();
|
||||
let key = key("alone");
|
||||
// No subscriber ever: quiet — and the record still accrues, so
|
||||
// a later viewer replays the turn nobody watched.
|
||||
hub.emit(&key, text("x"));
|
||||
assert!(
|
||||
hub.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&key)
|
||||
.expect("the entry exists")
|
||||
.sender
|
||||
.receiver_count()
|
||||
== 0
|
||||
);
|
||||
let (history, _) = hub.subscribe_with_history(&key);
|
||||
assert_eq!(history, vec![text("x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_do_not_hear_each_other() {
|
||||
let hub = EventHub::default();
|
||||
let mut mine = hub.subscribe(&key("mine"));
|
||||
hub.subscribe(&key("theirs"));
|
||||
hub.emit(&key("theirs"), text("x"));
|
||||
assert!(mine.try_recv().is_err(), "no cross-talk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lagging_subscriber_drops_the_oldest_and_is_told_so() {
|
||||
let hub = EventHub::with_capacity(2);
|
||||
let key = key("slow");
|
||||
let mut receiver = hub.subscribe(&key);
|
||||
for n in 1..=4 {
|
||||
hub.emit(&key, text(&n.to_string()));
|
||||
}
|
||||
assert!(
|
||||
matches!(
|
||||
receiver.try_recv(),
|
||||
Err(broadcast::error::TryRecvError::Lagged(2))
|
||||
),
|
||||
"the skip is surfaced, not silent"
|
||||
);
|
||||
assert_eq!(
|
||||
receiver.try_recv().unwrap(),
|
||||
text("3"),
|
||||
"the newest capacity events survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_memory_record_is_capped_at_the_newest_events() {
|
||||
let hub = EventHub::default();
|
||||
let key = key("long");
|
||||
for n in 0..RECORD_CAPACITY + 10 {
|
||||
hub.emit(&key, text(&n.to_string()));
|
||||
}
|
||||
let (history, _) = hub.subscribe_with_history(&key);
|
||||
assert_eq!(history.len(), RECORD_CAPACITY);
|
||||
assert_eq!(
|
||||
history.first(),
|
||||
Some(&text("10")),
|
||||
"the oldest fell off, not the newest"
|
||||
);
|
||||
}
|
||||
|
||||
/// The restart story: a store-backed hub's record outlives the
|
||||
/// process, whole — a fresh hub over the same store replays
|
||||
/// everything, and past the in-memory cap, too.
|
||||
#[test]
|
||||
fn a_stored_record_replays_whole_across_hubs_and_past_any_cap() {
|
||||
let store = Arc::new(TurnStore::in_memory().expect("store opens"));
|
||||
let hub = EventHub::with_record_store(store.clone());
|
||||
let key = key("long");
|
||||
for n in 0..RECORD_CAPACITY + 10 {
|
||||
hub.emit(&key, text(&n.to_string()));
|
||||
}
|
||||
hub.emit(&key, done("hi back"));
|
||||
|
||||
let restarted = EventHub::with_record_store(store);
|
||||
let (history, mut receiver) = restarted.subscribe_with_history(&key);
|
||||
assert_eq!(
|
||||
history.len(),
|
||||
RECORD_CAPACITY + 11,
|
||||
"nothing fell off: the table, not a window, is the record"
|
||||
);
|
||||
assert_eq!(history.first(), Some(&text("0")));
|
||||
assert_eq!(history.last(), Some(&done("hi back")));
|
||||
restarted.emit(&key, text("in this life"));
|
||||
assert_eq!(
|
||||
receiver.try_recv().unwrap(),
|
||||
text("in this life"),
|
||||
"the live cut is exact: replayed events never re-deliver"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_memory_record_replays_and_keeps_taking_new_events() {
|
||||
let hub = EventHub::default();
|
||||
let key = key("booted");
|
||||
hub.emit(
|
||||
&key,
|
||||
TurnEvent::UserMessage {
|
||||
text: "earlier".into(),
|
||||
},
|
||||
);
|
||||
let (history, mut receiver) = hub.subscribe_with_history(&key);
|
||||
assert_eq!(history.len(), 1);
|
||||
hub.emit(&key, text("in this life"));
|
||||
assert_eq!(receiver.try_recv().unwrap(), text("in this life"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cursor_skips_exactly_the_events_the_caller_already_holds() {
|
||||
let hub = EventHub::default();
|
||||
let key = key("cached");
|
||||
for n in 0..4 {
|
||||
hub.emit(&key, text(&n.to_string()));
|
||||
}
|
||||
|
||||
// A client that cached the first three receives only the fourth;
|
||||
// the live cut is untouched by the skip.
|
||||
let (history, mut receiver) = hub.subscribe_with_history_from(&key, 3);
|
||||
assert_eq!(history, vec![text("3")], "only the unseen part replays");
|
||||
hub.emit(&key, done("hi back"));
|
||||
assert_eq!(receiver.try_recv().unwrap(), done("hi back"));
|
||||
|
||||
// A cursor past the record's end clamps to it: nothing replays,
|
||||
// and nothing the record still holds is skipped twice.
|
||||
let (history, _) = hub.subscribe_with_history_from(&key, 100);
|
||||
assert!(history.is_empty());
|
||||
|
||||
// Zero behaves as the plain replay.
|
||||
let (history, _) = hub.subscribe_with_history_from(&key, 0);
|
||||
assert_eq!(history.len(), 5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
//! The agent's presentation identity: the name, mark, tagline, and
|
||||
//! images the interface renders everywhere — the rail, the chat, the
|
||||
//! window title, the notifications' author. Stored claw-side so every
|
||||
//! client reads the same identity over `GET /v1/agent`.
|
||||
//!
|
||||
//! Two kinds of facts live here. The text fields are keys in the
|
||||
//! settings store; the images are files next to the daemon's database
|
||||
//! with their content type in the store, because the bearer-guarded
|
||||
//! route serves them back byte for byte. Behaviour — how the agent
|
||||
//! talks, what it is like — is deliberately not here: that is SOUL.md,
|
||||
//! the bootstrap store's domain, edited with the agent over onboarding.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||
use rhema_core::ToolSpec;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::settings::SettingsStore;
|
||||
|
||||
/// Why an identity operation failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum IdentityError {
|
||||
/// The settings store or the image directory failed.
|
||||
#[error("identity store failed: {0}")]
|
||||
Backend(String),
|
||||
/// An image exceeded [`MAX_IMAGE_BYTES`].
|
||||
#[error("image too large: {0} bytes, the limit is {1}")]
|
||||
TooLarge(usize, usize),
|
||||
/// An upload carried a non-image content type.
|
||||
#[error("not an image: {0:?}")]
|
||||
NotAnImage(String),
|
||||
}
|
||||
|
||||
/// The cap on an uploaded avatar or logo: enough for a crisp square at
|
||||
/// every size the UI renders, small enough that the route stays cheap.
|
||||
pub const MAX_IMAGE_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// The agent's identity as the wire sees it: the optional text fields
|
||||
/// (each `null` when unset) and whether the images exist — the images
|
||||
/// themselves live behind their own routes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AgentIdentity {
|
||||
/// The agent's display name: message author, window title,
|
||||
/// notifications.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// The glyph or emoji in the rail, the favicon, the empty states.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mark: Option<String>,
|
||||
/// Used when there is no mark and no avatar.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub initials: Option<String>,
|
||||
/// One line under the name on the agent pane.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tagline: Option<String>,
|
||||
/// Appended to messages the agent sends outside the interface.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub signoff: Option<String>,
|
||||
/// Whether an avatar image is set (served at `/v1/agent/avatar`).
|
||||
pub avatar: bool,
|
||||
/// Whether a logo image is set (served at `/v1/agent/logo`).
|
||||
pub logo: bool,
|
||||
}
|
||||
|
||||
/// The five text fields, for a whole-identity save.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct IdentityTexts {
|
||||
pub name: Option<String>,
|
||||
pub mark: Option<String>,
|
||||
pub initials: Option<String>,
|
||||
pub tagline: Option<String>,
|
||||
pub signoff: Option<String>,
|
||||
}
|
||||
|
||||
/// One of the two identity images.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ImageKind {
|
||||
/// The square face the chat and rail render.
|
||||
Avatar,
|
||||
/// The mark the empty states and headers render.
|
||||
Logo,
|
||||
}
|
||||
|
||||
impl ImageKind {
|
||||
/// The wire path segment and file stem.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ImageKind::Avatar => "avatar",
|
||||
ImageKind::Logo => "logo",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text field's settings key.
|
||||
fn text_key(field: &str) -> String {
|
||||
format!("identity.{field}")
|
||||
}
|
||||
|
||||
/// The identity store: the text fields in the settings table, the
|
||||
/// images in `dir` with their content types alongside in the store.
|
||||
pub struct IdentityStore {
|
||||
settings: Arc<SettingsStore>,
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl IdentityStore {
|
||||
/// Opens the store over `settings`, keeping images in `dir`
|
||||
/// (created on first write).
|
||||
pub fn new(settings: Arc<SettingsStore>, dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
settings,
|
||||
dir: dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The current identity; unset fields read as `None`.
|
||||
pub fn get(&self) -> Result<AgentIdentity, IdentityError> {
|
||||
Ok(AgentIdentity {
|
||||
name: self.text("name")?,
|
||||
mark: self.text("mark")?,
|
||||
initials: self.text("initials")?,
|
||||
tagline: self.text("tagline")?,
|
||||
signoff: self.text("signoff")?,
|
||||
avatar: self.image_meta(ImageKind::Avatar)?.is_some(),
|
||||
logo: self.image_meta(ImageKind::Logo)?.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Saves the text fields whole: a `None` clears the field, a value
|
||||
/// is trimmed and length-checked (the UI's inputs cap them, the
|
||||
/// store enforces it).
|
||||
pub fn set_texts(&self, texts: &IdentityTexts) -> Result<AgentIdentity, IdentityError> {
|
||||
let fields = [
|
||||
("name", &texts.name, 64usize),
|
||||
("mark", &texts.mark, 8),
|
||||
("initials", &texts.initials, 4),
|
||||
("tagline", &texts.tagline, 120),
|
||||
("signoff", &texts.signoff, 120),
|
||||
];
|
||||
for (field, value, cap) in fields {
|
||||
let cleaned = value.as_deref().map(str::trim).filter(|v| !v.is_empty());
|
||||
if let Some(value) = cleaned {
|
||||
if value.chars().count() > cap {
|
||||
return Err(IdentityError::Backend(format!(
|
||||
"{field} is over {cap} characters"
|
||||
)));
|
||||
}
|
||||
}
|
||||
self.settings
|
||||
.set(
|
||||
&text_key(field),
|
||||
&serde_json::to_string(&cleaned).expect("an option serializes"),
|
||||
)
|
||||
.map_err(|err| IdentityError::Backend(err.to_string()))?;
|
||||
}
|
||||
self.get()
|
||||
}
|
||||
|
||||
/// Stores an image: the bytes in `dir`, the content type in the
|
||||
/// settings store. Content types outside `image/` are refused, as
|
||||
/// are bodies over [`MAX_IMAGE_BYTES`].
|
||||
pub fn set_image(
|
||||
&self,
|
||||
kind: ImageKind,
|
||||
bytes: &[u8],
|
||||
content_type: &str,
|
||||
) -> Result<AgentIdentity, IdentityError> {
|
||||
let content_type = content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if !content_type.starts_with("image/") || content_type == "image/svg+xml" {
|
||||
// SVG is refused deliberately: it is the one image format
|
||||
// that can carry scripts, and the route serves these bytes
|
||||
// back to a browser.
|
||||
return Err(IdentityError::NotAnImage(content_type));
|
||||
}
|
||||
if bytes.len() > MAX_IMAGE_BYTES {
|
||||
return Err(IdentityError::TooLarge(bytes.len(), MAX_IMAGE_BYTES));
|
||||
}
|
||||
std::fs::create_dir_all(&self.dir)
|
||||
.map_err(|err| IdentityError::Backend(err.to_string()))?;
|
||||
let file = self.image_file(kind);
|
||||
std::fs::write(&file, bytes).map_err(|err| IdentityError::Backend(err.to_string()))?;
|
||||
self.settings
|
||||
.set(&format!("identity.{}.type", kind.name()), &content_type)
|
||||
.map_err(|err| IdentityError::Backend(err.to_string()))?;
|
||||
self.get()
|
||||
}
|
||||
|
||||
/// The image bytes and their content type, when one is set.
|
||||
pub fn image(&self, kind: ImageKind) -> Result<Option<(Vec<u8>, String)>, IdentityError> {
|
||||
self.image_meta(kind)?
|
||||
.map(|content_type| {
|
||||
std::fs::read(self.image_file(kind))
|
||||
.map(|bytes| (bytes, content_type))
|
||||
.map_err(|err| IdentityError::Backend(err.to_string()))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Drops the image and its content type; a no-op when unset.
|
||||
pub fn clear_image(&self, kind: ImageKind) -> Result<AgentIdentity, IdentityError> {
|
||||
match std::fs::remove_file(self.image_file(kind)) {
|
||||
Ok(()) => {}
|
||||
// Absent is the cleared state, not a failure.
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(IdentityError::Backend(err.to_string())),
|
||||
}
|
||||
self.settings
|
||||
.delete(&format!("identity.{}.type", kind.name()))
|
||||
.map_err(|err| IdentityError::Backend(err.to_string()))?;
|
||||
self.get()
|
||||
}
|
||||
|
||||
/// The stored content type, `None` when no image is set.
|
||||
fn image_meta(&self, kind: ImageKind) -> Result<Option<String>, IdentityError> {
|
||||
self.settings
|
||||
.get(&format!("identity.{}.type", kind.name()))
|
||||
.map_err(|err| IdentityError::Backend(err.to_string()))
|
||||
}
|
||||
|
||||
fn image_file(&self, kind: ImageKind) -> PathBuf {
|
||||
self.dir.join(format!("{}.bin", kind.name()))
|
||||
}
|
||||
|
||||
fn text(&self, field: &str) -> Result<Option<String>, IdentityError> {
|
||||
Ok(self
|
||||
.settings
|
||||
.get(&text_key(field))
|
||||
.map_err(|err| IdentityError::Backend(err.to_string()))?
|
||||
.and_then(|json| serde_json::from_str::<Option<String>>(&json).ok())
|
||||
.flatten())
|
||||
}
|
||||
}
|
||||
|
||||
/// The tool's name on the wire.
|
||||
pub const IDENTITY_SET: &str = "identity_set";
|
||||
|
||||
/// `identity_set`: the model's surface over its own presentation —
|
||||
/// the same store the settings pages write, so what it sets here is
|
||||
/// what every client renders. Personality stays out of reach: that is
|
||||
/// SOUL.md, and the tool has no path to it.
|
||||
struct IdentitySet {
|
||||
store: Arc<IdentityStore>,
|
||||
workspace: PathBuf,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for IdentitySet {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
IDENTITY_SET,
|
||||
"Set your own presentation identity: the name, mark, \
|
||||
initials, tagline, and signoff the interfaces render, and \
|
||||
your avatar or logo images. This is how you look, not who \
|
||||
you are — personality lives in SOUL.md. Leave a field out \
|
||||
to keep what is set; an empty string clears it. The avatar \
|
||||
and logo take a workspace-relative path to an image file \
|
||||
(png, jpeg, gif, or webp, under 2 MiB — svg is refused).",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string",
|
||||
"description": "Display name: message author, \
|
||||
window title, notifications. Up to 64 chars." },
|
||||
"mark": { "type": "string",
|
||||
"description": "Glyph or emoji for the rail and \
|
||||
favicon. Up to 8 chars." },
|
||||
"initials": { "type": "string",
|
||||
"description": "Shown when there is no mark and \
|
||||
no avatar. Up to 4 chars." },
|
||||
"tagline": { "type": "string",
|
||||
"description": "One line under the name. Up to \
|
||||
120 chars." },
|
||||
"signoff": { "type": "string",
|
||||
"description": "Appended to messages sent \
|
||||
outside the interface. Up to 120 chars." },
|
||||
"avatar": { "type": "string",
|
||||
"description": "Workspace-relative path to the \
|
||||
avatar image." },
|
||||
"logo": { "type": "string",
|
||||
"description": "Workspace-relative path to the \
|
||||
logo image." },
|
||||
"clear": { "type": "array",
|
||||
"items": { "type": "string",
|
||||
"enum": ["avatar", "logo"] },
|
||||
"description": "Images to remove." }
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||
if !args.is_object() {
|
||||
return Err("bad arguments: expected an object".to_owned());
|
||||
}
|
||||
// The texts save whole, so an argument the call omits must carry
|
||||
// the stored value forward — the same absent-means-keep rule the
|
||||
// HTTP route applies. An empty string means clear.
|
||||
let current = self.store.get().map_err(|err| err.to_string())?;
|
||||
let field = |arg: Option<&str>, stored: Option<&str>| {
|
||||
arg.map(str::to_owned).or_else(|| stored.map(str::to_owned))
|
||||
};
|
||||
let texts = IdentityTexts {
|
||||
name: field(args["name"].as_str(), current.name.as_deref()),
|
||||
mark: field(args["mark"].as_str(), current.mark.as_deref()),
|
||||
initials: field(args["initials"].as_str(), current.initials.as_deref()),
|
||||
tagline: field(args["tagline"].as_str(), current.tagline.as_deref()),
|
||||
signoff: field(args["signoff"].as_str(), current.signoff.as_deref()),
|
||||
};
|
||||
// Every image is read and validated before anything is written,
|
||||
// so a bad path fails the call without half a new identity.
|
||||
let mut images = Vec::new();
|
||||
for (field, kind) in [("avatar", ImageKind::Avatar), ("logo", ImageKind::Logo)] {
|
||||
if let Some(path) = args[field].as_str() {
|
||||
let file = workspace_image(&self.workspace, path)?;
|
||||
let bytes =
|
||||
std::fs::read(&file).map_err(|err| format!("cannot read {path:?}: {err}"))?;
|
||||
images.push((kind, bytes, crate::attachments::content_type(path)));
|
||||
}
|
||||
}
|
||||
let mut clears = Vec::new();
|
||||
if let Some(list) = args["clear"].as_array() {
|
||||
for name in list {
|
||||
let name = name.as_str().ok_or("clear must name avatar or logo")?;
|
||||
clears.push(match name {
|
||||
"avatar" => ImageKind::Avatar,
|
||||
"logo" => ImageKind::Logo,
|
||||
other => return Err(format!("unknown image {other:?}")),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut identity = self
|
||||
.store
|
||||
.set_texts(&texts)
|
||||
.map_err(|err| err.to_string())?;
|
||||
for (kind, bytes, content_type) in images {
|
||||
identity = self
|
||||
.store
|
||||
.set_image(kind, &bytes, content_type)
|
||||
.map_err(|err| err.to_string())?;
|
||||
}
|
||||
for kind in clears {
|
||||
identity = self
|
||||
.store
|
||||
.clear_image(kind)
|
||||
.map_err(|err| err.to_string())?;
|
||||
}
|
||||
Ok(json!({ "saved": true, "identity": identity }).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a workspace-relative image path — absolute paths and `..`
|
||||
/// escapes are refused, the same discipline the attachment tool keeps.
|
||||
fn workspace_image(workspace: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
let relative = Path::new(path);
|
||||
if relative.is_absolute()
|
||||
|| relative
|
||||
.components()
|
||||
.any(|component| component == std::path::Component::ParentDir)
|
||||
{
|
||||
return Err(format!(
|
||||
"path must be relative to the workspace, without '..': {path:?}"
|
||||
));
|
||||
}
|
||||
Ok(workspace.join(relative))
|
||||
}
|
||||
|
||||
/// The identity tool set over `store`: `identity_set`.
|
||||
pub fn registry(store: Arc<IdentityStore>, workspace: impl Into<PathBuf>) -> ToolRegistry {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(IdentitySet {
|
||||
store,
|
||||
workspace: workspace.into(),
|
||||
}));
|
||||
registry
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The store over a tempdir, with the guard returned so the
|
||||
/// directory outlives the calls under test.
|
||||
fn store() -> (IdentityStore, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let settings = Arc::new(SettingsStore::in_memory().expect("the store opens"));
|
||||
(
|
||||
IdentityStore::new(settings, dir.path().join("identity")),
|
||||
dir,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_fields_round_trip_and_clear() {
|
||||
let (store, _dir) = store();
|
||||
let saved = store
|
||||
.set_texts(&IdentityTexts {
|
||||
name: Some(" Kosmos ".to_owned()),
|
||||
mark: Some("✦".to_owned()),
|
||||
initials: None,
|
||||
tagline: Some("order".to_owned()),
|
||||
signoff: Some(String::new()),
|
||||
})
|
||||
.expect("the texts save");
|
||||
assert_eq!(saved.name.as_deref(), Some("Kosmos"), "trimmed");
|
||||
assert_eq!(saved.mark.as_deref(), Some("✦"));
|
||||
assert_eq!(saved.initials, None);
|
||||
assert_eq!(saved.signoff, None, "an empty string clears");
|
||||
assert!(!saved.avatar);
|
||||
|
||||
let cleared = store
|
||||
.set_texts(&IdentityTexts::default())
|
||||
.expect("the clear saves");
|
||||
assert_eq!(cleared.name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlong_field_is_refused() {
|
||||
let (store, _dir) = store();
|
||||
let long = "x".repeat(65);
|
||||
let err = store
|
||||
.set_texts(&IdentityTexts {
|
||||
name: Some(long),
|
||||
..IdentityTexts::default()
|
||||
})
|
||||
.expect_err("over the cap");
|
||||
assert!(err.to_string().contains("over 64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_round_trip_and_clear() {
|
||||
let (store, _dir) = store();
|
||||
let saved = store
|
||||
.set_image(ImageKind::Avatar, &[1, 2, 3], "image/png; charset=binary")
|
||||
.expect("the avatar saves");
|
||||
assert!(saved.avatar);
|
||||
let (bytes, content_type) = store
|
||||
.image(ImageKind::Avatar)
|
||||
.expect("the image reads")
|
||||
.expect("an image is set");
|
||||
assert_eq!(bytes, vec![1, 2, 3]);
|
||||
assert_eq!(content_type, "image/png", "parameters stripped");
|
||||
|
||||
let cleared = store
|
||||
.clear_image(ImageKind::Avatar)
|
||||
.expect("the clear saves");
|
||||
assert!(!cleared.avatar);
|
||||
assert!(store.image(ImageKind::Avatar).expect("reads").is_none());
|
||||
// Clearing an unset image is a no-op, not an error.
|
||||
store
|
||||
.clear_image(ImageKind::Avatar)
|
||||
.expect("the second clear is a no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_images_and_oversized_bodies_are_refused() {
|
||||
let (store, _dir) = store();
|
||||
assert!(
|
||||
store
|
||||
.set_image(ImageKind::Logo, b"<svg/>", "image/svg+xml")
|
||||
.is_err(),
|
||||
"svg carries scripts"
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.set_image(ImageKind::Logo, b"plain", "text/plain")
|
||||
.is_err()
|
||||
);
|
||||
let big = vec![0u8; MAX_IMAGE_BYTES + 1];
|
||||
assert!(store.set_image(ImageKind::Logo, &big, "image/png").is_err());
|
||||
}
|
||||
|
||||
/// The tool, over a real workspace file: the texts save, the image
|
||||
/// lands with its extension's content type, and the answer carries
|
||||
/// the resulting identity.
|
||||
#[tokio::test]
|
||||
async fn identity_set_saves_texts_and_a_workspace_avatar() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let workspace = dir.path().join("interactive");
|
||||
std::fs::create_dir_all(&workspace).expect("workspace");
|
||||
std::fs::write(workspace.join("face.png"), b"png-bytes").expect("the image writes");
|
||||
let settings = Arc::new(SettingsStore::in_memory().expect("the store opens"));
|
||||
let store = Arc::new(IdentityStore::new(settings, dir.path().join("identity")));
|
||||
let tool = IdentitySet {
|
||||
store: store.clone(),
|
||||
workspace: workspace.clone(),
|
||||
};
|
||||
let answer = tool
|
||||
.invoke(json!({
|
||||
"name": "Kosmos",
|
||||
"mark": "✦",
|
||||
"avatar": "face.png",
|
||||
}))
|
||||
.await
|
||||
.expect("in-band ok");
|
||||
let answer: Value = serde_json::from_str(&answer).expect("parses");
|
||||
assert_eq!(answer["saved"], true);
|
||||
assert_eq!(answer["identity"]["name"], "Kosmos");
|
||||
assert_eq!(answer["identity"]["avatar"], true);
|
||||
let (bytes, content_type) = store
|
||||
.image(ImageKind::Avatar)
|
||||
.expect("reads")
|
||||
.expect("the avatar is set");
|
||||
assert_eq!(bytes, b"png-bytes");
|
||||
assert_eq!(content_type, "image/png");
|
||||
// An absent field carried the stored value forward.
|
||||
tool.invoke(json!({ "tagline": "order" }))
|
||||
.await
|
||||
.expect("saves");
|
||||
let identity = store.get().expect("reads");
|
||||
assert_eq!(identity.name.as_deref(), Some("Kosmos"), "kept");
|
||||
assert_eq!(identity.tagline.as_deref(), Some("order"), "added");
|
||||
assert!(identity.avatar, "untouched by a text-only save");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identity_set_clears_fields_and_images_and_refuses_escapes() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let workspace = dir.path().join("interactive");
|
||||
std::fs::create_dir_all(&workspace).expect("workspace");
|
||||
let settings = Arc::new(SettingsStore::in_memory().expect("the store opens"));
|
||||
let store = Arc::new(IdentityStore::new(settings, dir.path().join("identity")));
|
||||
store
|
||||
.set_image(ImageKind::Logo, b"bytes", "image/png")
|
||||
.expect("the logo saves");
|
||||
let tool = IdentitySet {
|
||||
store: store.clone(),
|
||||
workspace: workspace.clone(),
|
||||
};
|
||||
let answer = tool
|
||||
.invoke(json!({
|
||||
"name": "",
|
||||
"clear": ["logo"],
|
||||
}))
|
||||
.await
|
||||
.expect("in-band ok");
|
||||
let answer: Value = serde_json::from_str(&answer).expect("parses");
|
||||
assert_eq!(
|
||||
answer["identity"]["name"],
|
||||
Value::Null,
|
||||
"an empty string clears"
|
||||
);
|
||||
assert_eq!(answer["identity"]["logo"], false);
|
||||
|
||||
for bad in [
|
||||
json!({ "avatar": "../outside.png" }),
|
||||
json!({ "avatar": "/etc/hostname" }),
|
||||
json!({ "avatar": "missing.png" }),
|
||||
json!({ "clear": ["soul"] }),
|
||||
json!({ "name": "x".repeat(65) }),
|
||||
] {
|
||||
assert!(
|
||||
tool.invoke(bad.clone()).await.is_err(),
|
||||
"refused in band: {bad}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,604 @@
|
||||
//! The coding-job store: the instances the resident may drive a coding
|
||||
//! agent in, and the jobs queued against them.
|
||||
//!
|
||||
//! An **instance** is a named workspace: the audit unit for "which
|
||||
//! checkouts can the model drive a coding agent in". A **job** is one
|
||||
//! `CodingAgent` turn over an instance's workspace: prompt in, final
|
||||
//! text out, run off the chat turn by the jobs dispatcher (see
|
||||
//! [`crate::jobs`]). The conversation carries across a job boundary in
|
||||
//! the instance row's session blob — the same stash the forge handler
|
||||
//! keeps in the dispatcher's `driver_state`, keyed by instance instead
|
||||
//! of by thread, so "run the tests" after "fix the lint" continues the
|
||||
//! same coding session.
|
||||
//!
|
||||
//! Two invariants the schema itself holds: at most one **live** job
|
||||
//! (queued or running) per instance — a partial unique index refuses
|
||||
//! the second enqueue, because two coding agents in one workspace is
|
||||
//! two agents editing the same files — and [`CodingJobStore::recover`]
|
||||
//! is the boot pass that makes a crash honest: a row left `running` by
|
||||
//! a dead process is failed as interrupted, and a row still `queued`
|
||||
//! gets its timestamp bumped so its re-ingest carries a fresh dedup id
|
||||
//! (the old one may already sit in the dispatcher's consumed set).
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
/// Why a store operation failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum JobStoreError {
|
||||
/// The SQLite layer failed.
|
||||
#[error(transparent)]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
/// The instance name is empty or carries a character the dispatch
|
||||
/// key cannot hold (`#`, `/`, `:`) or whitespace.
|
||||
#[error("invalid instance name {name:?}: use letters, digits, dash, dot, underscore")]
|
||||
InvalidName {
|
||||
/// The rejected name.
|
||||
name: String,
|
||||
},
|
||||
/// An instance with this name already exists.
|
||||
#[error("instance {name:?} already exists")]
|
||||
Duplicate {
|
||||
/// The refused name.
|
||||
name: String,
|
||||
},
|
||||
/// The instance is not in the table.
|
||||
#[error("no instance named {name:?}")]
|
||||
UnknownInstance {
|
||||
/// The name that resolved to nothing.
|
||||
name: String,
|
||||
},
|
||||
/// The instance has a live job; the operation (a second enqueue, a
|
||||
/// drop) is refused until it finishes.
|
||||
#[error("instance {name:?} has a job in flight; check its status or wait")]
|
||||
Busy {
|
||||
/// The busy instance.
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// How far a job has got.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum JobStatus {
|
||||
/// Enqueued; the jobs source will ingest it.
|
||||
Queued,
|
||||
/// A coding-agent turn is running for it.
|
||||
Running,
|
||||
/// The turn's final text landed in `result`.
|
||||
Done,
|
||||
/// The turn failed; `result` carries the reason.
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl JobStatus {
|
||||
/// The spelling `status` answers carry.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
JobStatus::Queued => "queued",
|
||||
JobStatus::Running => "running",
|
||||
JobStatus::Done => "done",
|
||||
JobStatus::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(raw: &str) -> Self {
|
||||
match raw {
|
||||
"running" => JobStatus::Running,
|
||||
"done" => JobStatus::Done,
|
||||
"failed" => JobStatus::Failed,
|
||||
_ => JobStatus::Queued,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the job still has a turn ahead of it.
|
||||
pub fn is_live(self) -> bool {
|
||||
matches!(self, JobStatus::Queued | JobStatus::Running)
|
||||
}
|
||||
}
|
||||
|
||||
/// One named workspace the resident may drive a coding agent in.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InstanceRecord {
|
||||
/// The name the tools address it by.
|
||||
pub name: String,
|
||||
/// The workspace directory the coding agent's shell runs in.
|
||||
pub path: String,
|
||||
/// The coding session the next job resumes, as JSON; `None` until
|
||||
/// the first job's turn returns.
|
||||
pub session: Option<String>,
|
||||
/// When the instance was added.
|
||||
pub created_at_millis: i64,
|
||||
}
|
||||
|
||||
/// One queued or finished coding run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JobRecord {
|
||||
/// The row id; the dispatch key and the `status` answer carry it.
|
||||
pub id: i64,
|
||||
/// The instance the job runs against.
|
||||
pub instance: String,
|
||||
/// The prompt the coding agent turns on.
|
||||
pub prompt: String,
|
||||
/// How far the job has got.
|
||||
pub status: JobStatus,
|
||||
/// The final text on done, the reason on failed.
|
||||
pub result: Option<String>,
|
||||
/// When the job was enqueued.
|
||||
pub created_at_millis: i64,
|
||||
/// The last transition; also the dedup id's half, so a re-queued
|
||||
/// row re-ingests after a restart.
|
||||
pub updated_at_millis: i64,
|
||||
}
|
||||
|
||||
/// The instances and jobs, in one SQLite file beside the daemon's other
|
||||
/// tables.
|
||||
pub struct CodingJobStore {
|
||||
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
impl CodingJobStore {
|
||||
/// Opens the store at `path`, creating the tables.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, JobStoreError> {
|
||||
let conn = rusqlite::Connection::open(path)?;
|
||||
Self::init(conn)
|
||||
}
|
||||
|
||||
/// A memory-backed store; the tests' shape.
|
||||
pub fn in_memory() -> Result<Self, JobStoreError> {
|
||||
Self::init(rusqlite::Connection::open_in_memory()?)
|
||||
}
|
||||
|
||||
fn init(conn: rusqlite::Connection) -> Result<Self, JobStoreError> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS coding_instances (
|
||||
name TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
session TEXT,
|
||||
created_at_millis INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS coding_jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
instance TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
result TEXT,
|
||||
created_at_millis INTEGER NOT NULL,
|
||||
updated_at_millis INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS coding_jobs_one_live_per_instance
|
||||
ON coding_jobs(instance) WHERE status IN ('queued', 'running');",
|
||||
)?;
|
||||
Ok(Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Adds an instance. The name is validated here — the dispatch key
|
||||
/// is `job:<name>#<id>`, so a `#` in the name would break the parse
|
||||
/// the jobs handler depends on.
|
||||
pub fn add_instance(&self, name: &str, path: &str) -> Result<InstanceRecord, JobStoreError> {
|
||||
if name.is_empty()
|
||||
|| name
|
||||
.chars()
|
||||
.any(|c| c.is_whitespace() || matches!(c, '#' | '/' | ':'))
|
||||
{
|
||||
return Err(JobStoreError::InvalidName { name: name.into() });
|
||||
}
|
||||
let created_at = now_millis();
|
||||
let conn = self.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO coding_instances (name, path, session, created_at_millis)
|
||||
VALUES (?1, ?2, NULL, ?3)",
|
||||
params![name, path, created_at],
|
||||
)
|
||||
.map_err(|err| match err {
|
||||
rusqlite::Error::SqliteFailure(_, Some(text)) if text.contains("UNIQUE") => {
|
||||
JobStoreError::Duplicate { name: name.into() }
|
||||
}
|
||||
other => other.into(),
|
||||
})?;
|
||||
Ok(InstanceRecord {
|
||||
name: name.to_owned(),
|
||||
path: path.to_owned(),
|
||||
session: None,
|
||||
created_at_millis: created_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Removes an instance. Refused while a job is live on it: a drop
|
||||
/// under a running turn would orphan the run's store writes.
|
||||
pub fn drop_instance(&self, name: &str) -> Result<bool, JobStoreError> {
|
||||
let conn = self.lock();
|
||||
if Self::live_locked(&conn, name)?.is_some() {
|
||||
return Err(JobStoreError::Busy { name: name.into() });
|
||||
}
|
||||
let dropped = conn.execute(
|
||||
"DELETE FROM coding_instances WHERE name = ?1",
|
||||
params![name],
|
||||
)?;
|
||||
Ok(dropped > 0)
|
||||
}
|
||||
|
||||
/// Every instance, oldest first.
|
||||
pub fn instances(&self) -> Result<Vec<InstanceRecord>, JobStoreError> {
|
||||
let conn = self.lock();
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT name, path, session, created_at_millis
|
||||
FROM coding_instances ORDER BY created_at_millis, name",
|
||||
)?;
|
||||
let rows = statement.query_map([], row_instance)?;
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(JobStoreError::from)
|
||||
}
|
||||
|
||||
/// One instance by name.
|
||||
pub fn instance(&self, name: &str) -> Result<Option<InstanceRecord>, JobStoreError> {
|
||||
let conn = self.lock();
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT name, path, session, created_at_millis
|
||||
FROM coding_instances WHERE name = ?1",
|
||||
)?;
|
||||
let mut rows = statement.query_map(params![name], row_instance)?;
|
||||
rows.next().transpose().map_err(JobStoreError::from)
|
||||
}
|
||||
|
||||
/// The instance's session blob, `None` until the first job's turn
|
||||
/// returns or after a `fresh` run cleared it.
|
||||
pub fn session(&self, name: &str) -> Result<Option<String>, JobStoreError> {
|
||||
self.instance(name)?
|
||||
.map(|instance| instance.session)
|
||||
.ok_or_else(|| JobStoreError::UnknownInstance { name: name.into() })
|
||||
}
|
||||
|
||||
/// Replaces the instance's session blob — the coding conversation
|
||||
/// the next job resumes.
|
||||
pub fn save_session(&self, name: &str, session: &str) -> Result<(), JobStoreError> {
|
||||
let conn = self.lock();
|
||||
let updated = conn.execute(
|
||||
"UPDATE coding_instances SET session = ?2 WHERE name = ?1",
|
||||
params![name, session],
|
||||
)?;
|
||||
(updated > 0)
|
||||
.then_some(())
|
||||
.ok_or_else(|| JobStoreError::UnknownInstance { name: name.into() })
|
||||
}
|
||||
|
||||
/// Drops the session blob: the next `run` starts a fresh coding
|
||||
/// conversation.
|
||||
pub fn clear_session(&self, name: &str) -> Result<(), JobStoreError> {
|
||||
self.save_session_name(name, None)
|
||||
}
|
||||
|
||||
fn save_session_name(&self, name: &str, session: Option<&str>) -> Result<(), JobStoreError> {
|
||||
let conn = self.lock();
|
||||
let updated = conn.execute(
|
||||
"UPDATE coding_instances SET session = ?2 WHERE name = ?1",
|
||||
params![name, session],
|
||||
)?;
|
||||
(updated > 0)
|
||||
.then_some(())
|
||||
.ok_or_else(|| JobStoreError::UnknownInstance { name: name.into() })
|
||||
}
|
||||
|
||||
/// Enqueues one job. Refused when the instance is unknown or
|
||||
/// already has a live job.
|
||||
pub fn enqueue(&self, instance: &str, prompt: &str) -> Result<JobRecord, JobStoreError> {
|
||||
let conn = self.lock();
|
||||
if conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM coding_instances WHERE name = ?1",
|
||||
params![instance],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return Err(JobStoreError::UnknownInstance {
|
||||
name: instance.into(),
|
||||
});
|
||||
}
|
||||
let now = now_millis();
|
||||
conn.execute(
|
||||
"INSERT INTO coding_jobs (instance, prompt, status, created_at_millis, updated_at_millis)
|
||||
VALUES (?1, ?2, 'queued', ?3, ?3)",
|
||||
params![instance, prompt, now],
|
||||
)
|
||||
.map_err(|err| match err {
|
||||
rusqlite::Error::SqliteFailure(_, Some(text))
|
||||
if text.contains("UNIQUE") || text.contains("constraint") =>
|
||||
{
|
||||
JobStoreError::Busy { name: instance.into() }
|
||||
}
|
||||
other => other.into(),
|
||||
})?;
|
||||
let id = conn.last_insert_rowid();
|
||||
Ok(JobRecord {
|
||||
id,
|
||||
instance: instance.to_owned(),
|
||||
prompt: prompt.to_owned(),
|
||||
status: JobStatus::Queued,
|
||||
result: None,
|
||||
created_at_millis: now,
|
||||
updated_at_millis: now,
|
||||
})
|
||||
}
|
||||
|
||||
/// One job by id.
|
||||
pub fn job(&self, id: i64) -> Result<Option<JobRecord>, JobStoreError> {
|
||||
let conn = self.lock();
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT id, instance, prompt, status, result, created_at_millis, updated_at_millis
|
||||
FROM coding_jobs WHERE id = ?1",
|
||||
)?;
|
||||
let mut rows = statement.query_map(params![id], row_job)?;
|
||||
rows.next().transpose().map_err(JobStoreError::from)
|
||||
}
|
||||
|
||||
/// The instance's jobs, newest first, at most `limit`.
|
||||
pub fn jobs_for(&self, instance: &str, limit: u32) -> Result<Vec<JobRecord>, JobStoreError> {
|
||||
let conn = self.lock();
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT id, instance, prompt, status, result, created_at_millis, updated_at_millis
|
||||
FROM coding_jobs WHERE instance = ?1
|
||||
ORDER BY id DESC LIMIT ?2",
|
||||
)?;
|
||||
let rows = statement.query_map(params![instance, limit], row_job)?;
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(JobStoreError::from)
|
||||
}
|
||||
|
||||
/// The most recent jobs across every instance, newest first — the
|
||||
/// no-argument `status` answer.
|
||||
pub fn recent(&self, limit: u32) -> Result<Vec<JobRecord>, JobStoreError> {
|
||||
let conn = self.lock();
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT id, instance, prompt, status, result, created_at_millis, updated_at_millis
|
||||
FROM coding_jobs ORDER BY id DESC LIMIT ?1",
|
||||
)?;
|
||||
let rows = statement.query_map(params![limit], row_job)?;
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(JobStoreError::from)
|
||||
}
|
||||
|
||||
/// The queued rows, oldest first — the jobs source's detect pass.
|
||||
pub fn queued(&self) -> Result<Vec<JobRecord>, JobStoreError> {
|
||||
let conn = self.lock();
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT id, instance, prompt, status, result, created_at_millis, updated_at_millis
|
||||
FROM coding_jobs WHERE status = 'queued' ORDER BY id",
|
||||
)?;
|
||||
let rows = statement.query_map([], row_job)?;
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(JobStoreError::from)
|
||||
}
|
||||
|
||||
/// The instance's live job, if any — the busy check the tools and
|
||||
/// the drop path share.
|
||||
pub fn live_job(&self, instance: &str) -> Result<Option<JobRecord>, JobStoreError> {
|
||||
let conn = self.lock();
|
||||
Self::live_locked(&conn, instance)
|
||||
}
|
||||
|
||||
/// Marks the job running; the turn has begun.
|
||||
pub fn mark_running(&self, id: i64) -> Result<(), JobStoreError> {
|
||||
let conn = self.lock();
|
||||
conn.execute(
|
||||
"UPDATE coding_jobs SET status = 'running', updated_at_millis = ?2
|
||||
WHERE id = ?1",
|
||||
params![id, now_millis()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finishes the job with the turn's final text.
|
||||
pub fn finish(&self, id: i64, result: &str) -> Result<(), JobStoreError> {
|
||||
self.transition(id, "done", Some(result))
|
||||
}
|
||||
|
||||
/// Fails the job with the reason; the model can read it via status.
|
||||
pub fn fail(&self, id: i64, reason: &str) -> Result<(), JobStoreError> {
|
||||
self.transition(id, "failed", Some(reason))
|
||||
}
|
||||
|
||||
fn transition(&self, id: i64, status: &str, result: Option<&str>) -> Result<(), JobStoreError> {
|
||||
let conn = self.lock();
|
||||
conn.execute(
|
||||
"UPDATE coding_jobs SET status = ?2, result = ?3, updated_at_millis = ?4
|
||||
WHERE id = ?1",
|
||||
params![id, status, result, now_millis()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The boot pass: a `running` row belonged to the process that just
|
||||
/// died and is failed as interrupted; a `queued` row gets its
|
||||
/// timestamp bumped, so the source's re-ingest carries a dedup id
|
||||
/// the previous process's consumed set cannot swallow. Returns the
|
||||
/// number of rows it touched.
|
||||
pub fn recover(&self) -> Result<usize, JobStoreError> {
|
||||
let conn = self.lock();
|
||||
let now = now_millis();
|
||||
let interrupted = conn.execute(
|
||||
"UPDATE coding_jobs
|
||||
SET status = 'failed',
|
||||
result = 'interrupted: the daemon restarted mid-job; run it again',
|
||||
updated_at_millis = ?1
|
||||
WHERE status = 'running'",
|
||||
params![now],
|
||||
)?;
|
||||
let requeued = conn.execute(
|
||||
"UPDATE coding_jobs SET updated_at_millis = ?1 WHERE status = 'queued'",
|
||||
params![now],
|
||||
)?;
|
||||
Ok(interrupted + requeued)
|
||||
}
|
||||
|
||||
fn live_locked(
|
||||
conn: &rusqlite::Connection,
|
||||
instance: &str,
|
||||
) -> Result<Option<JobRecord>, JobStoreError> {
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT id, instance, prompt, status, result, created_at_millis, updated_at_millis
|
||||
FROM coding_jobs WHERE instance = ?1 AND status IN ('queued', 'running')
|
||||
ORDER BY id LIMIT 1",
|
||||
)?;
|
||||
let mut rows = statement.query_map(params![instance], row_job)?;
|
||||
rows.next().transpose().map_err(JobStoreError::from)
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, rusqlite::Connection> {
|
||||
self.conn.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
}
|
||||
|
||||
fn row_instance(row: &rusqlite::Row<'_>) -> rusqlite::Result<InstanceRecord> {
|
||||
Ok(InstanceRecord {
|
||||
name: row.get(0)?,
|
||||
path: row.get(1)?,
|
||||
session: row.get(2)?,
|
||||
created_at_millis: row.get(3)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_job(row: &rusqlite::Row<'_>) -> rusqlite::Result<JobRecord> {
|
||||
Ok(JobRecord {
|
||||
id: row.get(0)?,
|
||||
instance: row.get(1)?,
|
||||
prompt: row.get(2)?,
|
||||
status: JobStatus::parse(&row.get::<_, String>(3)?),
|
||||
result: row.get(4)?,
|
||||
created_at_millis: row.get(5)?,
|
||||
updated_at_millis: row.get(6)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Milliseconds since the Unix epoch, the table's clock everywhere.
|
||||
fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn add_lists_and_drops_instances() {
|
||||
let store = CodingJobStore::in_memory().unwrap();
|
||||
store.add_instance("rhema", "/home/me/rhema").unwrap();
|
||||
store.add_instance("widgets", "/home/me/widgets").unwrap();
|
||||
let instances = store.instances().unwrap();
|
||||
assert_eq!(instances.len(), 2);
|
||||
assert_eq!(instances[0].name, "rhema");
|
||||
assert_eq!(instances[0].session, None);
|
||||
assert!(store.drop_instance("widgets").unwrap());
|
||||
assert_eq!(store.instances().unwrap().len(), 1);
|
||||
assert!(!store.drop_instance("widgets").unwrap(), "already gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_the_key_cannot_hold_are_refused() {
|
||||
let store = CodingJobStore::in_memory().unwrap();
|
||||
for bad in ["", "a#b", "a/b", "a:b", "a b"] {
|
||||
let err = store.add_instance(bad, "/tmp").unwrap_err();
|
||||
assert!(matches!(err, JobStoreError::InvalidName { .. }), "{bad}");
|
||||
}
|
||||
store.add_instance("ok-name_1", "/tmp").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_duplicate_add_names_the_collision() {
|
||||
let store = CodingJobStore::in_memory().unwrap();
|
||||
store.add_instance("rhema", "/tmp/rhema").unwrap();
|
||||
let err = store.add_instance("rhema", "/tmp/other").unwrap_err();
|
||||
assert!(matches!(err, JobStoreError::Duplicate { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_live_job_per_instance() {
|
||||
let store = CodingJobStore::in_memory().unwrap();
|
||||
store.add_instance("rhema", "/tmp/rhema").unwrap();
|
||||
let first = store.enqueue("rhema", "fix the lint").unwrap();
|
||||
assert_eq!(first.status, JobStatus::Queued);
|
||||
let err = store.enqueue("rhema", "also this").unwrap_err();
|
||||
assert!(matches!(err, JobStoreError::Busy { .. }));
|
||||
store.finish(first.id, "lint fixed").unwrap();
|
||||
// Done unblocks the next enqueue.
|
||||
store.enqueue("rhema", "run the tests").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finishing_frees_but_a_second_instance_runs_parallel() {
|
||||
let store = CodingJobStore::in_memory().unwrap();
|
||||
store.add_instance("a", "/tmp/a").unwrap();
|
||||
store.add_instance("b", "/tmp/b").unwrap();
|
||||
store.enqueue("a", "one").unwrap();
|
||||
store.enqueue("b", "two").unwrap();
|
||||
assert_eq!(store.queued().unwrap().len(), 2);
|
||||
assert!(store.live_job("a").unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_requires_the_instance_and_the_status_tool_finds_the_job() {
|
||||
let store = CodingJobStore::in_memory().unwrap();
|
||||
let err = store.enqueue("ghost", "x").unwrap_err();
|
||||
assert!(matches!(err, JobStoreError::UnknownInstance { .. }));
|
||||
store.add_instance("rhema", "/tmp/rhema").unwrap();
|
||||
let job = store.enqueue("rhema", "do it").unwrap();
|
||||
let read = store.job(job.id).unwrap().unwrap();
|
||||
assert_eq!(read.prompt, "do it");
|
||||
assert_eq!(store.jobs_for("rhema", 10).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drop_is_refused_while_a_job_is_live() {
|
||||
let store = CodingJobStore::in_memory().unwrap();
|
||||
store.add_instance("rhema", "/tmp/rhema").unwrap();
|
||||
let job = store.enqueue("rhema", "long work").unwrap();
|
||||
store.mark_running(job.id).unwrap();
|
||||
let err = store.drop_instance("rhema").unwrap_err();
|
||||
assert!(matches!(err, JobStoreError::Busy { .. }));
|
||||
store.fail(job.id, "cancelled").unwrap();
|
||||
assert!(store.drop_instance("rhema").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_session_ride_transitions_marking_and_recover() {
|
||||
let store = CodingJobStore::in_memory().unwrap();
|
||||
store.add_instance("rhema", "/tmp/rhema").unwrap();
|
||||
store.save_session("rhema", r#"{"history":[]}"#).unwrap();
|
||||
assert_eq!(
|
||||
store.instance("rhema").unwrap().unwrap().session,
|
||||
Some(r#"{"history":[]}"#.to_owned())
|
||||
);
|
||||
store.clear_session("rhema").unwrap();
|
||||
assert_eq!(store.instance("rhema").unwrap().unwrap().session, None);
|
||||
store.save_session("ghost", "x").unwrap_err();
|
||||
|
||||
let job = store.enqueue("rhema", "work").unwrap();
|
||||
store.mark_running(job.id).unwrap();
|
||||
assert_eq!(
|
||||
store.job(job.id).unwrap().unwrap().status,
|
||||
JobStatus::Running
|
||||
);
|
||||
// The boot pass: the running row is failed as interrupted; the
|
||||
// other instance's queued row only has its timestamp moved, so
|
||||
// its re-ingest after the restart carries a fresh dedup id.
|
||||
store.add_instance("other", "/tmp/other").unwrap();
|
||||
let queued = store.enqueue("other", "queued work").unwrap();
|
||||
let touched = store.recover().unwrap();
|
||||
assert_eq!(touched, 2);
|
||||
let after = store.job(job.id).unwrap().unwrap();
|
||||
assert_eq!(after.status, JobStatus::Failed);
|
||||
assert!(after.result.unwrap().contains("interrupted"));
|
||||
assert!(!after.status.is_live());
|
||||
assert_eq!(
|
||||
store.job(queued.id).unwrap().unwrap().status,
|
||||
JobStatus::Queued,
|
||||
"still queued, only touched"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! The `coding_agent` tool: how the resident drives a coding agent —
|
||||
//! named instances over real workspaces, and async jobs against them.
|
||||
//!
|
||||
//! One tool, an `action` argument, the [`crate::phone`] tool's shape:
|
||||
//! `add` pins a name to a workspace directory, `drop` removes it,
|
||||
//! `run` enqueues a job (a `CodingAgent` turn over the instance's
|
||||
//! workspace, resumed from the instance's last coding session unless
|
||||
//! `fresh`), `list` names the instances, and `status` reports jobs.
|
||||
//! The run is
|
||||
//! **async by design**: a coding job is minutes of tool rounds, so the
|
||||
//! call returns the job id in one model round, the jobs dispatcher
|
||||
//! runs the turn off the chat turn ([`crate::jobs`]), and the final
|
||||
//! text lands in the job row and the user's notifications. The model
|
||||
//! answers "it's running" honestly and reads the result later with
|
||||
//! `status`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||
use rhema_core::ToolSpec;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::job_store::{CodingJobStore, JobStoreError};
|
||||
|
||||
/// The coding-agent tool set over `store`: one tool, `coding_agent`.
|
||||
pub fn registry(store: Arc<CodingJobStore>) -> ToolRegistry {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(CodingAgent { store }));
|
||||
registry
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Args {
|
||||
action: String,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
path: Option<String>,
|
||||
#[serde(default)]
|
||||
instance: Option<String>,
|
||||
#[serde(default)]
|
||||
prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
fresh: Option<bool>,
|
||||
#[serde(default)]
|
||||
job: Option<i64>,
|
||||
}
|
||||
|
||||
/// The tool: one `coding_agent` call, `action` picks the operation.
|
||||
struct CodingAgent {
|
||||
store: Arc<CodingJobStore>,
|
||||
}
|
||||
|
||||
impl CodingAgent {
|
||||
fn add(&self, name: &str, path: &str) -> Result<String, String> {
|
||||
if !std::path::Path::new(path).is_dir() {
|
||||
return Err(format!(
|
||||
"no directory {path:?}: the workspace must exist; create or clone it first"
|
||||
));
|
||||
}
|
||||
self.store
|
||||
.add_instance(name, path)
|
||||
.map(|_| json!({"added": name, "path": path}).to_string())
|
||||
.map_err(in_band)
|
||||
}
|
||||
|
||||
fn drop(&self, name: &str) -> Result<String, String> {
|
||||
let dropped = self.store.drop_instance(name).map_err(in_band)?;
|
||||
if dropped {
|
||||
Ok(json!({"dropped": name}).to_string())
|
||||
} else {
|
||||
Err(format!("no instance named {name:?}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&self, instance: &str, prompt: &str, fresh: bool) -> Result<String, String> {
|
||||
if prompt.trim().is_empty() {
|
||||
return Err("`prompt` is empty; the coding agent needs a task".to_owned());
|
||||
}
|
||||
if fresh {
|
||||
// The conversation reset lands before the enqueue: a fresh
|
||||
// flag on a refused run must not wipe a session it did not
|
||||
// replace.
|
||||
self.store.clear_session(instance).map_err(in_band)?;
|
||||
}
|
||||
let job = self.store.enqueue(instance, prompt).map_err(in_band)?;
|
||||
Ok(json!({
|
||||
"job": job.id,
|
||||
"instance": instance,
|
||||
"status": "queued",
|
||||
"note": "the job runs off this chat; its final text lands in \
|
||||
coding_agent status and the user's notifications"
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// The instances: the audit surface for which checkouts a coding
|
||||
/// agent may drive, each with its live job at a glance.
|
||||
fn list(&self) -> Result<String, String> {
|
||||
let instances = self.store.instances().map_err(in_band)?;
|
||||
let listed = instances
|
||||
.iter()
|
||||
.map(|instance| {
|
||||
let live = self
|
||||
.store
|
||||
.live_job(&instance.name)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|job| json!({"id": job.id, "status": job.status.as_str()}));
|
||||
json!({
|
||||
"name": instance.name,
|
||||
"path": instance.path,
|
||||
"has_session": instance.session.is_some(),
|
||||
"live_job": live,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({ "instances": listed }).to_string())
|
||||
}
|
||||
|
||||
/// The jobs: every recent one, one instance's, or one job's result.
|
||||
fn status(&self, instance: Option<&str>, job: Option<i64>) -> Result<String, String> {
|
||||
if let Some(id) = job {
|
||||
let record = self
|
||||
.store
|
||||
.job(id)
|
||||
.map_err(in_band)?
|
||||
.ok_or_else(|| format!("no job {id}"))?;
|
||||
return Ok(job_json(&record).to_string());
|
||||
}
|
||||
match instance {
|
||||
Some(name) => {
|
||||
if self.store.instance(name).map_err(in_band)?.is_none() {
|
||||
return Err(format!("no instance named {name:?}"));
|
||||
}
|
||||
let jobs = self.store.jobs_for(name, 20).map_err(in_band)?;
|
||||
Ok(json!({
|
||||
"instance": name,
|
||||
"jobs": jobs.iter().map(job_json).collect::<Vec<_>>(),
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
None => {
|
||||
let jobs = self.store.recent(20).map_err(in_band)?;
|
||||
Ok(json!({
|
||||
"jobs": jobs.iter().map(job_json).collect::<Vec<_>>(),
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn job_json(job: &crate::job_store::JobRecord) -> Value {
|
||||
json!({
|
||||
"job": job.id,
|
||||
"instance": job.instance,
|
||||
"status": job.status.as_str(),
|
||||
"result": job.result,
|
||||
"prompt": if job.status.is_live() { Some(job.prompt.as_str()) } else { None },
|
||||
})
|
||||
}
|
||||
|
||||
/// The store's typed refusals are the model's in-band answers.
|
||||
fn in_band(err: JobStoreError) -> String {
|
||||
err.to_string()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for CodingAgent {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
"coding_agent",
|
||||
"Drive a coding agent — a full rhema-coding-agent session with \
|
||||
its own shell — in a named workspace, off this chat. `add` \
|
||||
pins a name to an existing directory; `drop` removes it; \
|
||||
`list` names the instances (the workspaces a coding agent \
|
||||
may drive); `run` enqueues a job whose turn runs in the \
|
||||
background (minutes, not seconds): the answer is the job \
|
||||
id, the final text reaches `status` and the user's \
|
||||
notifications, and the instance's coding session carries \
|
||||
over to the next run unless `fresh`. `status` reads jobs — \
|
||||
every recent one, one instance's, or one job's result. One \
|
||||
live job per instance.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string",
|
||||
"enum": ["add", "drop", "run", "list", "status"],
|
||||
"description": "Which operation." },
|
||||
"name": { "type": "string",
|
||||
"description": "add/drop: the instance's name." },
|
||||
"path": { "type": "string",
|
||||
"description": "add: the workspace directory; it \
|
||||
must already exist." },
|
||||
"instance": { "type": "string",
|
||||
"description": "run/status: the instance to act on." },
|
||||
"prompt": { "type": "string",
|
||||
"description": "run: what the coding agent should do." },
|
||||
"fresh": { "type": "boolean",
|
||||
"description": "run: start a fresh coding session \
|
||||
instead of resuming the instance's last one." },
|
||||
"job": { "type": "integer",
|
||||
"description": "status: one job's id, for its result." }
|
||||
},
|
||||
"required": ["action"]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||
let args: Args =
|
||||
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||
match args.action.as_str() {
|
||||
"add" => self.add(
|
||||
required(&args.name, "add needs `name`")?,
|
||||
required(&args.path, "add needs `path`")?,
|
||||
),
|
||||
"drop" => self.drop(required(&args.name, "drop needs `name`")?),
|
||||
"run" => self.run(
|
||||
required(&args.instance, "run needs `instance`")?,
|
||||
required(&args.prompt, "run needs `prompt`")?,
|
||||
args.fresh.unwrap_or(false),
|
||||
),
|
||||
"list" => self.list(),
|
||||
"status" => self.status(args.instance.as_deref(), args.job),
|
||||
other => Err(format!(
|
||||
"unknown action {other:?}; one of add, drop, run, list, status"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn required<'a>(value: &'a Option<String>, complaint: &str) -> Result<&'a str, String> {
|
||||
value
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| complaint.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rhema_autonomous_agent::Allowlist;
|
||||
use rhema_core::{ToolCall, ToolContext, ToolResolver};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc as StdArc;
|
||||
|
||||
fn resolver(store: StdArc<CodingJobStore>) -> StdArc<dyn ToolResolver> {
|
||||
registry(store).resolver_for(&Allowlist::permissive())
|
||||
}
|
||||
|
||||
fn tempdir() -> std::path::PathBuf {
|
||||
let dir = tempfile::tempdir().unwrap().keep();
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
async fn call(resolver: &StdArc<dyn ToolResolver>, args: Value) -> Result<String, String> {
|
||||
let result = resolver
|
||||
.resolve(
|
||||
ToolCall {
|
||||
id: "call_1".into(),
|
||||
name: "coding_agent".into(),
|
||||
args,
|
||||
provider_data: Value::Null,
|
||||
},
|
||||
&ToolContext::default(),
|
||||
)
|
||||
.await;
|
||||
if result.is_error {
|
||||
Err(result.content)
|
||||
} else {
|
||||
Ok(result.content)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_run_and_status_round_trip() {
|
||||
let store = StdArc::new(CodingJobStore::in_memory().unwrap());
|
||||
let resolver = resolver(store.clone());
|
||||
let workspace = tempdir();
|
||||
let added = call(
|
||||
&resolver,
|
||||
json!({"action": "add", "name": "rhema",
|
||||
"path": workspace.to_str().unwrap()}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(added.contains("\"added\":\"rhema\""), "{added}");
|
||||
|
||||
let run = call(
|
||||
&resolver,
|
||||
json!({"action": "run", "instance": "rhema", "prompt": "fix the lint"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(run.contains("\"status\":\"queued\""), "{run}");
|
||||
|
||||
let listed = call(&resolver, json!({"action": "list"})).await.unwrap();
|
||||
assert!(listed.contains("rhema"), "{listed}");
|
||||
assert!(listed.contains("live_job"), "{listed}");
|
||||
|
||||
let recent = call(&resolver, json!({"action": "status"})).await.unwrap();
|
||||
assert!(recent.contains("fix the lint"), "{recent}");
|
||||
|
||||
let status = call(&resolver, json!({"action": "status", "instance": "rhema"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(status.contains("fix the lint"), "{status}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_refusals_are_in_band_and_named() {
|
||||
let store = StdArc::new(CodingJobStore::in_memory().unwrap());
|
||||
let resolver = resolver(store.clone());
|
||||
let workspace = tempdir();
|
||||
|
||||
let err = call(&resolver, json!({"action": "explode"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("unknown action"), "{err}");
|
||||
let err = call(&resolver, json!({"action": "add", "name": "x"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("add needs `path`"), "{err}");
|
||||
let err = call(
|
||||
&resolver,
|
||||
json!({"action": "add", "name": "x", "path": "/nowhere-at-all"}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("no directory"), "{err}");
|
||||
|
||||
call(
|
||||
&resolver,
|
||||
json!({"action": "add", "name": "rhema", "path": workspace.to_str().unwrap()}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = call(
|
||||
&resolver,
|
||||
json!({"action": "run", "instance": "ghost", "prompt": "x"}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("no instance named"), "{err}");
|
||||
|
||||
call(
|
||||
&resolver,
|
||||
json!({"action": "run", "instance": "rhema", "prompt": "long work"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = call(
|
||||
&resolver,
|
||||
json!({"action": "run", "instance": "rhema", "prompt": "more"}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("job in flight"), "{err}");
|
||||
let err = call(&resolver, json!({"action": "drop", "name": "rhema"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("job in flight"), "{err}");
|
||||
|
||||
let err = call(&resolver, json!({"action": "status", "job": 999}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("no job 999"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_clears_the_session_only_on_a_valid_run() {
|
||||
let store = StdArc::new(CodingJobStore::in_memory().unwrap());
|
||||
store.add_instance("rhema", "/tmp/rhema").unwrap();
|
||||
store.save_session("rhema", r#"{"history":[1]}"#).unwrap();
|
||||
let resolver = resolver(store.clone());
|
||||
// The refused run wipes nothing.
|
||||
let err = call(
|
||||
&resolver,
|
||||
json!({"action": "run", "instance": "ghost", "prompt": "x", "fresh": true}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("no instance named"), "{err}");
|
||||
assert!(store.instance("rhema").unwrap().unwrap().session.is_some());
|
||||
// The accepted run clears before enqueueing.
|
||||
call(
|
||||
&resolver,
|
||||
json!({"action": "run", "instance": "rhema", "prompt": "x", "fresh": true}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.instance("rhema").unwrap().unwrap().session.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
//! The coding jobs path: the source that ingests queued rows, and the
|
||||
//! handler that turns one into a coding-agent turn off the chat.
|
||||
//!
|
||||
//! **The key:** `job:<instance>#<id>` — per job, like a forge thread is
|
||||
//! per thread, so the dispatcher's dedup and lease machinery applies
|
||||
//! per job while the *conversation* carries in the instance row's
|
||||
//! session blob (`crate::job_store`), keyed by instance. A run the
|
||||
//! restart cut is failed interrupted by the store's recover pass; its
|
||||
//! next `run` resumes from the last saved session.
|
||||
//!
|
||||
//! **The handler** is the forge handler's sibling with a different
|
||||
//! driver: the deterministic part stays ahead of the model — the
|
||||
//! instance and job resolve through the store, the workspace is the
|
||||
//! instance's path, the session blob (or a fresh agent) is loaded, the
|
||||
//! allowlist is permissive exactly as the forge path's — and then one
|
||||
//! `CodingAgent` turn runs. The final text lands in the job row and
|
||||
//! the user's notifications; the turn itself streams nowhere, its
|
||||
//! observability is the stderr tool log.
|
||||
//!
|
||||
//! The permission check is the forge handler's own argument: this
|
||||
//! handler's one tool is the agent's shell, so a policy without it
|
||||
//! fails before any model call. The trust boundary is unchanged from
|
||||
//! the forge path — the bearer-gated surface and the instance list.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rhema_autonomous_agent::{Handler, SessionContext, Source, SourceError, async_trait};
|
||||
use rhema_coding_agent::{AllowAll, CodingAgent, CodingAgentConfig};
|
||||
use rhema_core::{Event as ModelEvent, EventSink};
|
||||
use rhema_rsd::{Dispatcher, Event, KeyFn, Outcome, ResourceKey};
|
||||
|
||||
use crate::backends::BackendRegistry;
|
||||
use crate::handler::final_text;
|
||||
use crate::interactive::passthrough_key_fn;
|
||||
use crate::job_store::{CodingJobStore, JobStatus};
|
||||
use crate::notifications::{DEFAULT_DEVICE, NotificationStore};
|
||||
|
||||
/// The source name the coding jobs ingest under.
|
||||
pub const JOBS_SOURCE: &str = "jobs";
|
||||
|
||||
/// One job's dispatch key: `job:<instance>#<id>`.
|
||||
pub fn job_key(instance: &str, id: i64) -> String {
|
||||
format!("job:{instance}#{id}")
|
||||
}
|
||||
|
||||
/// Whether `key` belongs to the coding-jobs context — the check the
|
||||
/// drivers' ownership split keys on, alongside the resident prefixes
|
||||
/// and the forge keys.
|
||||
pub fn is_job_key(key: &str) -> bool {
|
||||
key.starts_with("job:")
|
||||
}
|
||||
|
||||
/// Parses the dispatch key back into instance and job id.
|
||||
pub fn parse_job_key(key: &ResourceKey) -> Option<(String, i64)> {
|
||||
let raw = key.as_str().strip_prefix("job:")?;
|
||||
let (instance, id) = raw.rsplit_once('#')?;
|
||||
if instance.is_empty() || id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((instance.to_owned(), id.parse().ok()?))
|
||||
}
|
||||
|
||||
/// The detectable stream of queued coding jobs: the job table feeding
|
||||
/// the jobs dispatcher — the task scheduler's shape, one source,
|
||||
/// registered once, before the first detect.
|
||||
pub struct JobsSource {
|
||||
store: Arc<CodingJobStore>,
|
||||
dispatcher: Arc<Dispatcher>,
|
||||
key_fn: Arc<KeyFn>,
|
||||
}
|
||||
|
||||
impl JobsSource {
|
||||
/// Registers the `jobs` source's key function on `dispatcher` and
|
||||
/// returns the source feeding it.
|
||||
pub fn new(dispatcher: &Arc<Dispatcher>, store: Arc<CodingJobStore>) -> Self {
|
||||
let key_fn = passthrough_key_fn();
|
||||
dispatcher.register_source(JOBS_SOURCE, key_fn.clone());
|
||||
Self {
|
||||
store,
|
||||
dispatcher: dispatcher.clone(),
|
||||
key_fn,
|
||||
}
|
||||
}
|
||||
|
||||
/// The event one queued row becomes. The dedup id carries the
|
||||
/// row's `updated_at_millis`, so a row the recover pass re-queued
|
||||
/// after a restart ingests fresh instead of drowning in the
|
||||
/// previous process's consumed set.
|
||||
fn event(job: &crate::job_store::JobRecord, now: u64) -> Event {
|
||||
Event {
|
||||
id: format!("job-{}@{}", job.id, job.updated_at_millis),
|
||||
source: JOBS_SOURCE.to_owned(),
|
||||
payload: json_key(&job.instance, job.id),
|
||||
received_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_key(instance: &str, id: i64) -> serde_json::Value {
|
||||
serde_json::json!({ "key": job_key(instance, id) })
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Source for JobsSource {
|
||||
fn name(&self) -> &str {
|
||||
JOBS_SOURCE
|
||||
}
|
||||
|
||||
fn key_fn(&self) -> Arc<KeyFn> {
|
||||
self.key_fn.clone()
|
||||
}
|
||||
|
||||
async fn detect(&self) -> Result<usize, SourceError> {
|
||||
let queued = self
|
||||
.store
|
||||
.queued()
|
||||
.map_err(|err| SourceError::Backend(format!("job store: {err}")))?;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
let mut fed = 0;
|
||||
for job in queued {
|
||||
self.dispatcher
|
||||
.ingest(Self::event(&job, now))
|
||||
.map_err(|err| SourceError::Backend(format!("job ingest: {err}")))?;
|
||||
fed += 1;
|
||||
}
|
||||
Ok(fed)
|
||||
}
|
||||
}
|
||||
|
||||
/// The coding-agent jobs handler: one dispatch of `job:<instance>#<id>`
|
||||
/// is one coding-agent turn, and the turn's final text is the job's
|
||||
/// result and the user's notification.
|
||||
pub struct CodingJobHandler {
|
||||
backends: Arc<BackendRegistry>,
|
||||
store: Arc<CodingJobStore>,
|
||||
notifications: Arc<NotificationStore>,
|
||||
max_turn_steps: u32,
|
||||
}
|
||||
|
||||
impl CodingJobHandler {
|
||||
/// A handler whose turns run in the instance's workspace, read per
|
||||
/// dispatch, with the deployment's active provider pair and the
|
||||
/// resident turn's step budget.
|
||||
pub fn new(
|
||||
backends: Arc<BackendRegistry>,
|
||||
store: Arc<CodingJobStore>,
|
||||
notifications: Arc<NotificationStore>,
|
||||
max_turn_steps: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
backends,
|
||||
store,
|
||||
notifications,
|
||||
max_turn_steps,
|
||||
}
|
||||
}
|
||||
|
||||
/// One dispatch, failed with a reason on any step; every failure
|
||||
/// lands in the job row before it lands in the outcome, so the
|
||||
/// model's `status` read and the user's notification agree.
|
||||
async fn dispatch(&self, ctx: &mut SessionContext) -> Result<Outcome, String> {
|
||||
let (instance_name, job_id) = parse_job_key(&ctx.key)
|
||||
.ok_or_else(|| format!("resource key {} is not a job key", ctx.key))?;
|
||||
let job = self
|
||||
.store
|
||||
.job(job_id)
|
||||
.map_err(|err| format!("job lookup for {job_id} failed: {err}"))?
|
||||
.ok_or_else(|| format!("job {job_id} is not in the table"))?;
|
||||
if job.status != JobStatus::Queued {
|
||||
// A re-delivered event over a row already running or done:
|
||||
// the dedup boundary is the dispatcher's; this handler
|
||||
// treats the row as the truth.
|
||||
return Ok(Outcome::Handled);
|
||||
}
|
||||
let instance = self
|
||||
.store
|
||||
.instance(&instance_name)
|
||||
.map_err(|err| format!("instance lookup for {instance_name} failed: {err}"))?
|
||||
.ok_or_else(|| format!("instance {instance_name:?} is gone; the job cannot run"))?;
|
||||
let workspace = std::path::PathBuf::from(&instance.path);
|
||||
if !workspace.is_dir() {
|
||||
let reason = format!(
|
||||
"the workspace {workspace:?} does not exist; add the instance again with a live path"
|
||||
);
|
||||
self.fail(job_id, &instance_name, &reason);
|
||||
return Ok(Outcome::Failed(reason));
|
||||
}
|
||||
self.store
|
||||
.mark_running(job_id)
|
||||
.map_err(|err| format!("job {job_id} could not start: {err}"))?;
|
||||
|
||||
let (provider, model) = self.backends.current();
|
||||
let mut config = CodingAgentConfig::new(model, &workspace);
|
||||
config.max_turn_steps = self.max_turn_steps;
|
||||
let stashed = self
|
||||
.store
|
||||
.session(&instance_name)
|
||||
.map_err(|err| format!("the session lookup for {instance_name:?} failed: {err}"))?;
|
||||
let mut agent = match stashed {
|
||||
Some(blob) => {
|
||||
let session: rhema_core::Session = serde_json::from_str(&blob).map_err(|err| {
|
||||
let reason = format!(
|
||||
"the stashed coding session for {instance_name:?} does not parse: {err}"
|
||||
);
|
||||
self.fail(job_id, &instance_name, &reason);
|
||||
reason
|
||||
})?;
|
||||
CodingAgent::from_session(provider, config, session)
|
||||
}
|
||||
None => CodingAgent::new(provider, config),
|
||||
}
|
||||
.map_err(|err| {
|
||||
let reason = format!("cannot start the coding agent for {instance_name:?}: {err}");
|
||||
self.fail(job_id, &instance_name, &reason);
|
||||
reason
|
||||
})?;
|
||||
// Commit-before-execute rides the dispatcher exactly as the
|
||||
// forge handler's does: the intent is in the store before the
|
||||
// first tool runs, and a crash-left dangling call revives at
|
||||
// turn entry (CodingAgent::turn's own revive, over the resumed
|
||||
// session).
|
||||
agent.set_commit_hook(Some(ctx.commit_hook()));
|
||||
|
||||
let mut sink = JobSink;
|
||||
let output = match agent.turn(&job.prompt, &mut sink, Arc::new(AllowAll)).await {
|
||||
Ok(output) => output,
|
||||
Err(err) => {
|
||||
let reason = format!("the coding turn for {instance_name:?} failed: {err}");
|
||||
// The conversation up to the failure is still the
|
||||
// instance's: saved, so a follow-up run continues it.
|
||||
self.save_session(&instance_name, &agent);
|
||||
self.fail(job_id, &instance_name, &reason);
|
||||
return Ok(Outcome::Failed(reason));
|
||||
}
|
||||
};
|
||||
self.save_session(&instance_name, &agent);
|
||||
let reply = final_text(&output.message).ok_or_else(|| {
|
||||
let reason = format!("the model returned no text to record for job {job_id}");
|
||||
self.fail(job_id, &instance_name, &reason);
|
||||
reason
|
||||
})?;
|
||||
self.store
|
||||
.finish(job_id, &reply)
|
||||
.map_err(|err| format!("job {job_id} could not be finished: {err}"))?;
|
||||
// The implicit channel, the task keys' rule: the reply reaches
|
||||
// the user away from this chat; `status` is the model's way to
|
||||
// read it again.
|
||||
self.notify(&instance_name, &reply);
|
||||
Ok(Outcome::Handled)
|
||||
}
|
||||
|
||||
fn save_session(&self, instance: &str, agent: &CodingAgent) {
|
||||
match serde_json::to_string(agent.session()) {
|
||||
Ok(blob) => {
|
||||
let _ = self.store.save_session(instance, &blob);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"[claw] jobs: the coding session for {instance:?} does not serialize: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(&self, job_id: i64, instance: &str, reason: &str) {
|
||||
if let Err(err) = self.store.fail(job_id, reason) {
|
||||
eprintln!("[claw] jobs: job {job_id} could not be failed: {err}");
|
||||
}
|
||||
self.notify(instance, &format!("coding job failed: {reason}"));
|
||||
}
|
||||
|
||||
fn notify(&self, instance: &str, body: &str) {
|
||||
if let Err(err) = self.notifications.push(DEFAULT_DEVICE, instance, body) {
|
||||
eprintln!("[claw] jobs: the notification for {instance:?} failed: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Handler for CodingJobHandler {
|
||||
async fn handle(&self, ctx: &mut SessionContext) -> Outcome {
|
||||
match self.dispatch(ctx).await {
|
||||
Ok(outcome) => outcome,
|
||||
Err(reason) => Outcome::Failed(reason),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The job's stderr log: tool traffic only. Nothing streams to a chat
|
||||
/// surface — the result is the row and the notification.
|
||||
struct JobSink;
|
||||
|
||||
impl EventSink for JobSink {
|
||||
fn emit(&mut self, event: ModelEvent<'_>) {
|
||||
match event {
|
||||
ModelEvent::ToolCallStart { name, .. } => eprintln!("[claw] job tool call: {name}"),
|
||||
ModelEvent::ToolResult {
|
||||
content, is_error, ..
|
||||
} => {
|
||||
let mark = if is_error { "failed" } else { "ok" };
|
||||
let line = content.lines().next().unwrap_or("");
|
||||
let mut cut = line.chars().take(72).collect::<String>();
|
||||
if line.chars().count() > 72 {
|
||||
cut.push('…');
|
||||
}
|
||||
eprintln!("[claw] job tool {mark}: {cut}");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backends::Backend;
|
||||
use rhema_autonomous_agent::Driver;
|
||||
use rhema_core::{Message as CoreMessage, MockProvider, ModelOutput, Role, StopReason, Usage};
|
||||
use rhema_rsd::{DispatcherConfig, SqliteStore, SystemClock};
|
||||
|
||||
/// A registry whose only backend is the scripted mock.
|
||||
fn mock_handler(
|
||||
replies: Vec<ModelOutput>,
|
||||
store: Arc<CodingJobStore>,
|
||||
notifications: Arc<NotificationStore>,
|
||||
) -> CodingJobHandler {
|
||||
let provider: Arc<dyn rhema_core::ModelProvider> = Arc::new(MockProvider::new(replies));
|
||||
let backends = Arc::new(
|
||||
BackendRegistry::new(vec![Backend::headless("mock", provider)], None)
|
||||
.expect("a single mock backend always constructs"),
|
||||
);
|
||||
CodingJobHandler::new(backends, store, notifications, 16)
|
||||
}
|
||||
|
||||
fn reply(text: &str) -> ModelOutput {
|
||||
ModelOutput {
|
||||
message: CoreMessage::text(Role::Assistant, text),
|
||||
stop_reason: StopReason::EndTurn,
|
||||
usage: Usage::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tempdir() -> std::path::PathBuf {
|
||||
let dir = tempfile::tempdir().unwrap().keep();
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_queued_job_runs_to_done_and_notifies() {
|
||||
let workspace = tempdir();
|
||||
let store = Arc::new(CodingJobStore::in_memory().unwrap());
|
||||
store
|
||||
.add_instance("rhema", workspace.to_str().unwrap())
|
||||
.unwrap();
|
||||
let job = store.enqueue("rhema", "fix the lint").unwrap();
|
||||
let notifications = Arc::new(NotificationStore::in_memory().unwrap());
|
||||
let handler = mock_handler(
|
||||
vec![reply("lint fixed, all green")],
|
||||
store.clone(),
|
||||
notifications.clone(),
|
||||
);
|
||||
|
||||
// The whole path: source detects the queued row, the driver
|
||||
// runs the handler over its own dispatcher.
|
||||
let (source, driver) = harness(store.clone(), handler);
|
||||
|
||||
assert_eq!(source.detect().await.unwrap(), 1);
|
||||
let outcomes = driver.run_once().await.unwrap();
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
assert!(matches!(outcomes[0], Outcome::Handled));
|
||||
|
||||
let done = store.job(job.id).unwrap().unwrap();
|
||||
assert_eq!(done.status, JobStatus::Done);
|
||||
assert_eq!(done.result.as_deref(), Some("lint fixed, all green"));
|
||||
let instance = store.instance("rhema").unwrap().unwrap();
|
||||
assert!(instance.session.is_some(), "the session is stashed");
|
||||
let pending = notifications.pending(DEFAULT_DEVICE, 0).unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].body, "lint fixed, all green");
|
||||
// The source finds nothing more to feed.
|
||||
assert_eq!(source.detect().await.unwrap(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_second_job_resumes_the_first_session() {
|
||||
let workspace = tempdir();
|
||||
let store = Arc::new(CodingJobStore::in_memory().unwrap());
|
||||
store
|
||||
.add_instance("rhema", workspace.to_str().unwrap())
|
||||
.unwrap();
|
||||
let notifications = Arc::new(NotificationStore::in_memory().unwrap());
|
||||
let handler = mock_handler(
|
||||
vec![reply("first done"), reply("second done, history intact")],
|
||||
store.clone(),
|
||||
notifications.clone(),
|
||||
);
|
||||
let first = store.enqueue("rhema", "step one").unwrap();
|
||||
let second = store.enqueue("rhema", "step two").unwrap_err();
|
||||
assert!(
|
||||
second.to_string().contains("job in flight"),
|
||||
"one live job per instance: {second}"
|
||||
);
|
||||
let (source, driver) = harness(store.clone(), handler);
|
||||
|
||||
assert_eq!(source.detect().await.unwrap(), 1);
|
||||
driver.run_once().await.unwrap();
|
||||
assert_eq!(
|
||||
store.job(first.id).unwrap().unwrap().status,
|
||||
JobStatus::Done
|
||||
);
|
||||
|
||||
// With the first job done, the follow-up enqueues and runs on
|
||||
// the resumed session: the mock's second reply lands, and the
|
||||
// stash the second turn loaded carried the first turn's rounds.
|
||||
let follow = store.enqueue("rhema", "step two").unwrap();
|
||||
assert_eq!(source.detect().await.unwrap(), 1);
|
||||
driver.run_once().await.unwrap();
|
||||
let done = store.job(follow.id).unwrap().unwrap();
|
||||
assert_eq!(done.result.as_deref(), Some("second done, history intact"));
|
||||
let session = store.instance("rhema").unwrap().unwrap().session.unwrap();
|
||||
let session: rhema_core::Session = serde_json::from_str(&session).unwrap();
|
||||
// Two turns: two user inputs, two assistant replies.
|
||||
assert!(session.history.len() >= 4, "{:?}", session.history.len());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_missing_workspace_fails_the_job_in_band() {
|
||||
let store = Arc::new(CodingJobStore::in_memory().unwrap());
|
||||
store.add_instance("gone", "/nowhere-at-all").unwrap();
|
||||
let job = store.enqueue("gone", "work").unwrap();
|
||||
let notifications = Arc::new(NotificationStore::in_memory().unwrap());
|
||||
let handler = mock_handler(
|
||||
vec![reply("never reached")],
|
||||
store.clone(),
|
||||
notifications.clone(),
|
||||
);
|
||||
let (source, driver) = harness(store.clone(), handler);
|
||||
|
||||
assert_eq!(source.detect().await.unwrap(), 1);
|
||||
let outcomes = driver.run_once().await.unwrap();
|
||||
assert!(
|
||||
matches!(&outcomes[0], Outcome::Failed(reason) if reason.contains("does not exist")),
|
||||
"{outcomes:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
store.job(job.id).unwrap().unwrap().status,
|
||||
JobStatus::Failed
|
||||
);
|
||||
let pending = notifications.pending(DEFAULT_DEVICE, 0).unwrap();
|
||||
assert!(pending[0].body.contains("coding job failed"), "{pending:?}");
|
||||
}
|
||||
|
||||
/// The source and driver over one in-memory dispatcher, the shape
|
||||
/// the daemon wires and these tests drive.
|
||||
fn harness(store: Arc<CodingJobStore>, handler: CodingJobHandler) -> (JobsSource, Driver) {
|
||||
let rsd_store = Arc::new(SqliteStore::open(tempdir().join("rsd.db")).unwrap());
|
||||
// A zero debounce: the queued row is admitted on this pass,
|
||||
// the shape the daemon's jobs dispatcher runs under.
|
||||
let dispatcher = Arc::new(Dispatcher::new(
|
||||
DispatcherConfig {
|
||||
debounce_window_millis: 0,
|
||||
..DispatcherConfig::default()
|
||||
},
|
||||
Arc::new(SystemClock),
|
||||
rsd_store,
|
||||
));
|
||||
let source = JobsSource::new(&dispatcher, store);
|
||||
let driver = Driver::new(dispatcher, Arc::new(handler))
|
||||
.with_holder("test-jobs")
|
||||
.owning(Arc::new(|key: &ResourceKey| is_job_key(key.as_str())));
|
||||
(source, driver)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! `rhema-claw` — the resident agent daemon: forge watches plus the chat
|
||||
//! HTTP surface, the first consumer of `rhema-autonomous-agent`.
|
||||
//!
|
||||
//! The spec invariants this crate exists to keep (specs/
|
||||
//! rhema-framework-v1.md): detection is deterministic code, never the
|
||||
//! model (§1) — every timer here drives a detect pass and an admission
|
||||
//! step, and the model is reached only inside a handler run; there is no
|
||||
//! periodic, unconditional model-invoking timer (§7) — a scheduled task
|
||||
//! is an event the [`TaskScheduler`] detects, not a heartbeat; and the
|
||||
//! open interactive surface never resolves keys (§6.2) — one resident
|
||||
//! key is one owned session, forever. Two dispatchers share one SQLite
|
||||
//! store: the forge dispatcher turns watch activity into [`ClawHandler`]
|
||||
//! runs (one turn per dispatch, the final text posted back as the
|
||||
//! thread's comment); the resident dispatcher — keys prefixed
|
||||
//! `interactive:`, `task:`, `phone:`, or `calendar:`, the contract
|
||||
//! [`interactive`] documents — turns chat messages, `/v1/ingest`
|
||||
//! events, due tasks, and due calendar reminders into
|
||||
//! [`InteractiveHandler`] runs (one turn per dispatch, streamed to a
|
||||
//! chat subscriber or auto-notified to a device).
|
||||
//!
|
||||
//! Layout:
|
||||
//! - [`config`] — the YAML config: the watch seed, paths, HTTP surface,
|
||||
//! provider and model
|
||||
//! - [`bootstrap`] — the SOUL/USER/ENVIRONMENT store, the system prompt
|
||||
//! rendered from it per dispatch with the dispatch's tool roster, and
|
||||
//! the `bootstrap_save` tool
|
||||
//! - [`watch_store`] — the persistent watch table (SQLite, shared file)
|
||||
//! - [`notifications`] — the notification table: the outbound queue the
|
||||
//! devices poll and ack
|
||||
//! - [`task_store`] — the task table: scheduled prompts, inert until the
|
||||
//! scheduler detects them due
|
||||
//! - [`turn_store`] — the turn-history table: what each key's stream
|
||||
//! streamed, appended per event, replayed whole after a restart
|
||||
//! - [`hub`] — the event hub: per-key record and broadcast of turn events
|
||||
//! - [`identity`] — the agent's presentation identity: name, mark,
|
||||
//! images, served over `/v1/agent` and settable by the agent itself
|
||||
//! through the `identity_set` tool
|
||||
//! - [`consents`] — the consent table and wake bridge: a running turn
|
||||
//! asks the human (a secret to store, permission to act) and waits;
|
||||
//! the chat surface answers through the consent routes
|
||||
//! - [`attachments`] — the attachment store and the `attach_file` tool:
|
||||
//! workspace files handed to the human as fetchable cards
|
||||
//! - [`phone`] — the phone state and command queue: reported status to
|
||||
//! read, consent-gated control actions the device executes
|
||||
//! - [`watch_tools`] — the agent's watch-management tool set
|
||||
//! - [`notify_tools`] — the `notify` tool: the queue a device polls
|
||||
//! - [`task_tools`] — `schedule_task`, `list_tasks`, `cancel_task`
|
||||
//! - [`forge`] — the sources: one detectable stream per forge kind
|
||||
//! - [`calendar`] — the [`CalendarSource`]: due calendar reminders in,
|
||||
//! events out
|
||||
//! - [`task_source`] — the [`TaskScheduler`]: due tasks in, events out
|
||||
//! - [`handler`] — the forge dispatch: snapshot, inbox, one turn, one
|
||||
//! comment
|
||||
//! - [`interactive`] — the resident dispatch: the key-prefix contract,
|
||||
//! inbox, one turn, one streamed reply, auto-notify
|
||||
//! - [`http`] — the routes: chat, sessions, watches, ingest,
|
||||
//! notifications, the event stream
|
||||
//! - [`openai`] — the `/v1/chat/completions` shim for stock web UIs
|
||||
//! - [`daemon`] — the assembly and the loop (`run` with HTTP, and the
|
||||
//! headless `--once` shape [`run_once`])
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(missing_docs)]
|
||||
|
||||
pub mod accounts_tools;
|
||||
pub mod attachments;
|
||||
pub mod backends;
|
||||
pub mod bootstrap;
|
||||
pub mod calendar;
|
||||
pub mod config;
|
||||
pub mod consents;
|
||||
pub mod daemon;
|
||||
pub mod environment;
|
||||
pub mod forge;
|
||||
pub mod handler;
|
||||
pub mod http;
|
||||
pub mod hub;
|
||||
mod identity;
|
||||
pub mod interactive;
|
||||
pub mod job_store;
|
||||
pub mod job_tools;
|
||||
pub mod jobs;
|
||||
#[cfg(feature = "mock")]
|
||||
pub mod mock;
|
||||
pub mod notifications;
|
||||
pub mod notify_tools;
|
||||
pub mod openai;
|
||||
pub mod phone;
|
||||
pub mod settings;
|
||||
pub mod task_source;
|
||||
pub mod task_store;
|
||||
pub mod task_tools;
|
||||
pub mod triage;
|
||||
pub mod turn_store;
|
||||
pub mod watch_store;
|
||||
pub mod watch_tools;
|
||||
|
||||
pub use backends::{Backend, BackendRegistry};
|
||||
pub use bootstrap::{
|
||||
BOOTSTRAP_SAVE, Bootstrap, BootstrapError, BootstrapKind, BootstrapStatus, DEFAULT_SYSTEM_MD,
|
||||
render_system,
|
||||
};
|
||||
pub use calendar::CalendarSource;
|
||||
pub use config::{ClawConfig, ConfigError, ForgeKind, Watch};
|
||||
pub use daemon::{ClawError, Daemon, TickReport, run_once};
|
||||
pub use environment::write_report;
|
||||
pub use forge::ForgeSource;
|
||||
pub use handler::ClawHandler;
|
||||
pub use hub::{EventHub, TurnEvent};
|
||||
pub use identity::{AgentIdentity, IdentityError, IdentityStore, ImageKind};
|
||||
pub use interactive::InteractiveHandler;
|
||||
#[cfg(feature = "mock")]
|
||||
pub use mock::MockLoop;
|
||||
pub use notifications::{NotificationRecord, NotificationStore, NotificationStoreError};
|
||||
pub use settings::SettingsStore;
|
||||
pub use task_source::{TASKS_SOURCE, TaskScheduler};
|
||||
pub use task_store::{TaskKind, TaskRecord, TaskStore, TaskStoreError};
|
||||
pub use turn_store::{TurnStore, TurnStoreError};
|
||||
pub use watch_store::{ClawStore, WatchRecord, WatchStoreError};
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Binary wiring only: parse the two args, resolve the model backend from
|
||||
//! the stored rhema credentials, run the daemon. The loop and its
|
||||
//! assembly live in `rhema_claw::daemon`.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use clap::Parser;
|
||||
use rhema_claw::{ClawConfig, ClawError, Daemon, run_once};
|
||||
use rhema_rsd::{DispatcherConfig, Outcome};
|
||||
|
||||
/// `rhema-claw` — the resident forge-watching agent daemon.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "rhema-claw",
|
||||
version,
|
||||
about = "Resident forge-watching agent daemon"
|
||||
)]
|
||||
struct Args {
|
||||
/// Path to the claw config. Defaults to ~/.config/rhema/claw.yaml.
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
/// Run one detect pass and one driver pass, then exit — the
|
||||
/// cron-friendly shape for ephemeral machines.
|
||||
#[arg(long)]
|
||||
once: bool,
|
||||
|
||||
/// Answer every turn with canned streamed text: the zero-credential
|
||||
/// debug backend. Requires the `mock` feature.
|
||||
#[arg(long)]
|
||||
mock: bool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
if let Err(err) = run(Args::parse()).await {
|
||||
eprintln!("error: {err}");
|
||||
let mut source: &dyn std::error::Error = &err;
|
||||
while let Some(cause) = source.source() {
|
||||
eprintln!(" caused by: {cause}");
|
||||
source = cause;
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(args: Args) -> Result<(), ClawError> {
|
||||
let path = match args.config {
|
||||
Some(path) => path,
|
||||
None => ClawConfig::default_path()?,
|
||||
};
|
||||
let config = ClawConfig::load_from(&path)?;
|
||||
let backends = if args.mock {
|
||||
mock_backends()
|
||||
} else {
|
||||
resolve_backends(&config)?
|
||||
};
|
||||
eprintln!(
|
||||
"[claw] {} seeded watch(es), active {}/{}, db {}, http {}",
|
||||
config.watches.len(),
|
||||
backends.active().0,
|
||||
backends.active().1,
|
||||
config
|
||||
.db_path()
|
||||
.map(|db| db.display().to_string())
|
||||
.unwrap_or_default(),
|
||||
config.listen_addr(),
|
||||
);
|
||||
|
||||
if args.once {
|
||||
// The approval posture is deliberate (spec §8): say it once at
|
||||
// startup so the operator reads it next to the config that
|
||||
// turns it on, not in a README three weeks in.
|
||||
if !config.watches.is_empty() {
|
||||
eprintln!("[claw] note: forge dispatches run tool calls without approval prompts");
|
||||
}
|
||||
let report = run_once(&config, backends.clone()).await?;
|
||||
// A cron pass reports its failures through the exit code.
|
||||
if report
|
||||
.outcomes
|
||||
.iter()
|
||||
.any(|outcome| matches!(outcome, Outcome::Failed(_)))
|
||||
{
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
// The resident daemon always serves HTTP: the chat surface is
|
||||
// how it is talked to. Only `--once` runs headless. The
|
||||
// surface's posture is checked before anything binds: a wide
|
||||
// listen with no token never comes up. The approval posture is
|
||||
// deliberate (spec §8): say it once at startup so the operator
|
||||
// reads it next to the config that turns it on.
|
||||
config.validate_surface()?;
|
||||
eprintln!("[claw] note: resident sessions run tool calls without approval prompts");
|
||||
let daemon = Daemon::build(&config, backends, DispatcherConfig::default())?;
|
||||
daemon.run().await
|
||||
}
|
||||
}
|
||||
|
||||
/// The `--mock` backend: an endless canned provider behind the `mock`
|
||||
/// feature; without it the flag is a loud startup failure naming the fix.
|
||||
#[cfg(feature = "mock")]
|
||||
fn mock_backends() -> Arc<rhema_claw::BackendRegistry> {
|
||||
eprintln!("[claw] mock backend: every turn answers with canned streamed text");
|
||||
Arc::new(
|
||||
rhema_claw::BackendRegistry::new(
|
||||
vec![rhema_claw::Backend::headless(
|
||||
"mock",
|
||||
rhema_claw::mock::MockLoop::provider(),
|
||||
)],
|
||||
None,
|
||||
)
|
||||
.expect("a single mock backend always constructs"),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mock"))]
|
||||
fn mock_backends() -> Arc<rhema_claw::BackendRegistry> {
|
||||
eprintln!("error: --mock needs a mock build: cargo build -p rhema-claw --features mock");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
/// The deployment's backends: every provider whose credentials resolve
|
||||
/// (env wins, the stored rhema config serves), with the config's
|
||||
/// provider/model pair as the active seed. A daemon always boots —
|
||||
/// the keyless llama-cpp backend is always constructible — and a
|
||||
/// dispatch against an unreachable provider fails loudly per turn.
|
||||
fn resolve_backends(config: &ClawConfig) -> Result<Arc<rhema_claw::BackendRegistry>, ClawError> {
|
||||
let stored = match rhema_config::Config::load() {
|
||||
Ok(stored) => stored,
|
||||
Err(err) => {
|
||||
eprintln!("warning: ignoring unreadable rhema config: {err}");
|
||||
rhema_config::Config::default()
|
||||
}
|
||||
};
|
||||
let profile = stored.effective(None);
|
||||
// A second connection to the shared database, read-only in spirit:
|
||||
// discovery reads the credentials the HTTP surface persisted. An
|
||||
// unopenable store boots the daemon without them rather than not at
|
||||
// all — the same grace the rhema config gets above.
|
||||
let claw_creds = rhema_claw::SettingsStore::open(config.db_path()?).ok();
|
||||
let claw_rows = move || {
|
||||
claw_creds
|
||||
.as_ref()
|
||||
.and_then(|store| {
|
||||
store
|
||||
.get_prefix(rhema_claw::backends::PROVIDER_KEY_PREFIX)
|
||||
.ok()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|(key, json)| {
|
||||
let id = key.strip_prefix(rhema_claw::backends::PROVIDER_KEY_PREFIX)?;
|
||||
serde_json::from_str(&json)
|
||||
.ok()
|
||||
.map(|params| (id.to_owned(), params))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let env = |name: &str| std::env::var(name).ok();
|
||||
// The daemon only reads the stored rhema config, keychain tier
|
||||
// included; it never writes it, so no migration runs here — the
|
||||
// file's logins belong to the frontends. In a container the
|
||||
// keychain is unreachable and the literal tier serves.
|
||||
let secrets = rhema_config::secrets::KeyringSecretStore::new();
|
||||
let backends =
|
||||
rhema_claw::backends::discover(config, &profile, &env, &claw_rows, Some(&secrets));
|
||||
// The config's provider/model pair seeds the active backend when it
|
||||
// names one. When it does not, the seed is the first credential
|
||||
// discovery actually found — never a hardcoded default kind, and
|
||||
// never the keyless llama.cpp fallback; with nothing keyed at all,
|
||||
// the registry serves the keyless server and the UI's
|
||||
// add-a-provider state is the honest answer.
|
||||
let seed = config
|
||||
.provider
|
||||
.as_deref()
|
||||
.map(|kind| (kind.to_owned(), config.model.clone()))
|
||||
.or_else(|| rhema_claw::backends::credential_seed(&backends));
|
||||
Ok(Arc::new(
|
||||
rhema_claw::BackendRegistry::new(backends, seed).map_err(ClawError::Backend)?,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! The debug provider (`mock` feature): an endless scripted stand-in so
|
||||
//! the daemon and a UI can run with zero credentials.
|
||||
//!
|
||||
//! [`MockProvider`](rhema_core::MockProvider) replays a fixed script and
|
||||
//! errors once it runs dry — right for tests, wrong for a daemon a
|
||||
//! developer chats with all afternoon. This one never dries up: every
|
||||
//! turn answers with a numbered canned reply, streamed as deltas so a UI
|
||||
//! exercises the same rendering path a real turn takes.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use rhema_core::{
|
||||
Content, Event, EventSink, Message, ModelOutput, ModelProvider, ModelRequest, ProviderError,
|
||||
Role, StopReason, Usage, async_trait,
|
||||
};
|
||||
|
||||
/// Answers every request with a canned, streamed reply.
|
||||
#[derive(Default)]
|
||||
pub struct MockLoop {
|
||||
turns: AtomicU64,
|
||||
}
|
||||
|
||||
impl MockLoop {
|
||||
/// The provider, shared as the daemon's backend seam. (Named for what
|
||||
/// it returns: a `new` returning `Arc<dyn ModelProvider>` would lie
|
||||
/// about constructing `Self`.)
|
||||
pub fn provider() -> Arc<dyn ModelProvider> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelProvider for MockLoop {
|
||||
async fn complete(
|
||||
&self,
|
||||
_req: ModelRequest,
|
||||
sink: &mut dyn EventSink,
|
||||
) -> Result<ModelOutput, ProviderError> {
|
||||
let turn = self.turns.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let text = format!(
|
||||
"Mock claw, turn {turn}. No model is attached - every reply \
|
||||
is this canned text, streamed in deltas like the real thing."
|
||||
);
|
||||
// Three char-safe deltas: the UI's streaming path gets exercised,
|
||||
// not just the single-frame one.
|
||||
let chars: Vec<&str> = {
|
||||
let total = text.chars().count();
|
||||
let boundary = |n: usize| -> usize {
|
||||
text.char_indices()
|
||||
.nth(n)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(text.len())
|
||||
};
|
||||
vec![
|
||||
&text[..boundary(total / 3)],
|
||||
&text[boundary(total / 3)..boundary(2 * total / 3)],
|
||||
&text[boundary(2 * total / 3)..],
|
||||
]
|
||||
};
|
||||
for piece in chars {
|
||||
sink.emit(Event::TextDelta(piece));
|
||||
}
|
||||
Ok(ModelOutput {
|
||||
message: Message {
|
||||
role: Role::Assistant,
|
||||
content: vec![Content::Text(text)],
|
||||
},
|
||||
stop_reason: StopReason::EndTurn,
|
||||
usage: Usage::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! The notification table: outbound claw-to-device messages in SQLite,
|
||||
//! the same database file the dispatcher's
|
||||
//! [`SqliteStore`](rhema_rsd::SqliteStore) and the watch table use.
|
||||
//!
|
||||
//! Notifications are how the resident reaches a device that is not the
|
||||
//! live chat surface: the `notify` agent tool enqueues, a task or phone
|
||||
//! turn's reply auto-notifies, and the device polls
|
||||
//! `GET /v1/notifications` and acks what it delivered. Delivery is
|
||||
//! at-least-once until the ack — an unacked row keeps answering the
|
||||
//! poll, a crash between delivery and ack re-delivers.
|
||||
//!
|
||||
//! Two connections, one file: this store opens its own rusqlite
|
||||
//! connection to the same database as the dispatcher's store — WAL and a
|
||||
//! busy timeout, the pattern [`ClawStore`](crate::watch_store::ClawStore)
|
||||
//! established — and the two interleave through SQLite's own locking.
|
||||
//! Writes are single INSERT / UPDATE statements, so there is no
|
||||
//! read-modify-write window to guard.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rusqlite::params;
|
||||
use serde::Serialize;
|
||||
|
||||
/// The device a notification reaches when its producer names none: the
|
||||
/// tool's default, the auto-notify rule's fallback, and the poll
|
||||
/// route's default — one phone, one user (spec §7).
|
||||
pub const DEFAULT_DEVICE: &str = "phone";
|
||||
|
||||
/// The notification table.
|
||||
const SCHEMA: &str = "
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INTEGER PRIMARY KEY,
|
||||
device TEXT NOT NULL DEFAULT 'phone',
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
delivered_at INTEGER NULL
|
||||
);
|
||||
";
|
||||
|
||||
/// Why a notification-table operation failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum NotificationStoreError {
|
||||
/// The SQLite backend failed.
|
||||
#[error("notification store backend: {0}")]
|
||||
Backend(#[from] rusqlite::Error),
|
||||
}
|
||||
|
||||
/// One outbound notification, as stored. Serializes whole: nothing here
|
||||
/// is secret — this is the shape the poll route serves.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct NotificationRecord {
|
||||
/// The row id; the ack route addresses it, `since` filters by it.
|
||||
pub id: i64,
|
||||
/// The device it is bound for (`phone` unless told otherwise).
|
||||
pub device: String,
|
||||
/// The one-line headline.
|
||||
pub title: String,
|
||||
/// The message body.
|
||||
pub body: String,
|
||||
/// Creation time, milliseconds since the Unix epoch.
|
||||
pub created_at: i64,
|
||||
/// When the device acked delivery; `None` while outstanding.
|
||||
pub delivered_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// The persistent notification table over its own connection to the
|
||||
/// daemon's SQLite file. Shareable behind an `Arc`; every operation
|
||||
/// takes `&self`.
|
||||
pub struct NotificationStore {
|
||||
conn: Mutex<rusqlite::Connection>,
|
||||
}
|
||||
|
||||
impl NotificationStore {
|
||||
/// Opens (creating if needed) the notification table in the database
|
||||
/// at `path` — the same file the dispatcher's store holds. WAL keeps
|
||||
/// the connections from blocking each other's readers; the busy
|
||||
/// timeout rides out the other's write locks.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, NotificationStoreError> {
|
||||
let conn = rusqlite::Connection::open(path).map_err(NotificationStoreError::from)?;
|
||||
Self::init(conn, true)
|
||||
}
|
||||
|
||||
/// Opens a private in-memory table: the shape without the file, for
|
||||
/// tests that need no sharing.
|
||||
pub fn in_memory() -> Result<Self, NotificationStoreError> {
|
||||
let conn = rusqlite::Connection::open_in_memory().map_err(NotificationStoreError::from)?;
|
||||
Self::init(conn, false)
|
||||
}
|
||||
|
||||
fn init(conn: rusqlite::Connection, durable: bool) -> Result<Self, NotificationStoreError> {
|
||||
if durable {
|
||||
// Same pragmas as the dispatcher's store on the same file:
|
||||
// SQLite stores the journal mode per database, and both
|
||||
// connections must agree that it is WAL.
|
||||
let _mode: String = conn
|
||||
.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
|
||||
.map_err(NotificationStoreError::from)?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))
|
||||
.map_err(NotificationStoreError::from)?;
|
||||
}
|
||||
conn.execute_batch(SCHEMA)
|
||||
.map_err(NotificationStoreError::from)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// Enqueues one notification and returns it with its row id. The
|
||||
/// row is outstanding (`delivered_at` unset) until
|
||||
/// [`Self::ack`].
|
||||
pub fn push(
|
||||
&self,
|
||||
device: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
) -> Result<NotificationRecord, NotificationStoreError> {
|
||||
let created_at = now_millis();
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
conn.execute(
|
||||
"INSERT INTO notifications (device, title, body, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![device, title, body, created_at],
|
||||
)?;
|
||||
Ok(NotificationRecord {
|
||||
id: conn.last_insert_rowid(),
|
||||
device: device.to_owned(),
|
||||
title: title.to_owned(),
|
||||
body: body.to_owned(),
|
||||
created_at,
|
||||
delivered_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The device's outstanding notifications with an id over `since`,
|
||||
/// oldest first — exactly what a device that acks what it delivers
|
||||
/// polls for. Delivered rows stop answering the poll; an unacked row
|
||||
/// keeps coming back (at-least-once until the ack).
|
||||
pub fn pending(
|
||||
&self,
|
||||
device: &str,
|
||||
since: i64,
|
||||
) -> Result<Vec<NotificationRecord>, NotificationStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, device, title, body, created_at, delivered_at
|
||||
FROM notifications
|
||||
WHERE device = ?1 AND delivered_at IS NULL AND id > ?2
|
||||
ORDER BY id ASC",
|
||||
)
|
||||
.map_err(NotificationStoreError::from)?;
|
||||
let rows = stmt
|
||||
.query_map(params![device, since], |row| {
|
||||
Ok(NotificationRecord {
|
||||
id: row.get(0)?,
|
||||
device: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
body: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
delivered_at: row.get(5)?,
|
||||
})
|
||||
})
|
||||
.map_err(NotificationStoreError::from)?
|
||||
.collect::<Result<Vec<_>, rusqlite::Error>>()
|
||||
.map_err(NotificationStoreError::from)?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Marks the notification delivered. Idempotent: `false` when the id
|
||||
/// is unknown or already acked, never an error.
|
||||
pub fn ack(&self, id: i64) -> Result<bool, NotificationStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let acked = conn.execute(
|
||||
"UPDATE notifications SET delivered_at = ?2
|
||||
WHERE id = ?1 AND delivered_at IS NULL",
|
||||
params![id, now_millis()],
|
||||
)?;
|
||||
Ok(acked == 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Milliseconds since the Unix epoch; stamps `created_at` and
|
||||
/// `delivered_at`.
|
||||
fn now_millis() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_notification_polls_until_acked() {
|
||||
let store = NotificationStore::in_memory().expect("store opens");
|
||||
let first = store
|
||||
.push(DEFAULT_DEVICE, "claw", "first")
|
||||
.expect("push lands");
|
||||
let second = store
|
||||
.push(DEFAULT_DEVICE, "claw", "second")
|
||||
.expect("push lands");
|
||||
assert!(first.id < second.id, "ids ascend");
|
||||
assert_eq!(first.delivered_at, None, "outstanding until acked");
|
||||
|
||||
// The poll answers outstanding rows oldest first.
|
||||
let pending = store.pending(DEFAULT_DEVICE, 0).expect("poll reads");
|
||||
assert_eq!(
|
||||
pending
|
||||
.iter()
|
||||
.map(|record| record.body.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["first", "second"],
|
||||
);
|
||||
|
||||
// `since` skips what the device already saw.
|
||||
assert_eq!(
|
||||
store
|
||||
.pending(DEFAULT_DEVICE, first.id)
|
||||
.expect("poll reads")
|
||||
.len(),
|
||||
1,
|
||||
);
|
||||
|
||||
// Acking delivers; the delivered row stops answering, and a
|
||||
// re-ack is a quiet false, not an error.
|
||||
assert!(store.ack(first.id).expect("ack lands"));
|
||||
let pending = store.pending(DEFAULT_DEVICE, 0).expect("poll reads");
|
||||
assert_eq!(
|
||||
pending.iter().map(|record| record.id).collect::<Vec<_>>(),
|
||||
[second.id],
|
||||
);
|
||||
assert!(!store.ack(first.id).expect("re-ack is quiet"));
|
||||
assert!(!store.ack(10_000).expect("unknown id is quiet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn devices_do_not_hear_each_other() {
|
||||
let store = NotificationStore::in_memory().expect("store opens");
|
||||
store.push(DEFAULT_DEVICE, "claw", "phone").expect("push");
|
||||
store.push("desk", "claw", "desk").expect("push");
|
||||
let phones = store.pending(DEFAULT_DEVICE, 0).expect("poll reads");
|
||||
assert_eq!(phones.len(), 1);
|
||||
assert_eq!(phones[0].device, DEFAULT_DEVICE);
|
||||
assert_eq!(phones[0].body, "phone");
|
||||
assert_eq!(store.pending("desk", 0).expect("poll reads").len(), 1);
|
||||
assert_eq!(store.pending("watch", 0).expect("poll reads").len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_connections_share_one_file() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("dispatch.db");
|
||||
let writer = NotificationStore::open(&path).expect("first connection");
|
||||
let reader = NotificationStore::open(&path).expect("second connection");
|
||||
writer.push(DEFAULT_DEVICE, "claw", "hello").expect("push");
|
||||
let pending = reader.pending(DEFAULT_DEVICE, 0).expect("poll reads");
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].body, "hello");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! The agent-side notify tool: how the resident reaches a device that is
|
||||
//! not the live chat surface — "remind me when CI lands" becomes a tool
|
||||
//! call, the row lands in the [`NotificationStore`], and the device's
|
||||
//! next poll carries it. No restart, no second mechanism.
|
||||
//!
|
||||
//! This is a [`Tool`] implementation for a [`ToolRegistry`], so the
|
||||
//! interactive handler offers it through the same allowlist-filtered
|
||||
//! seam as everything else the model may call (spec §8). Answers are
|
||||
//! short JSON; failures are in-band strings the model can react to.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||
use rhema_core::ToolSpec;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::notifications::{DEFAULT_DEVICE, NotificationStore};
|
||||
|
||||
/// The notification tool set over `store`: `notify`.
|
||||
pub fn registry(store: Arc<NotificationStore>) -> ToolRegistry {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(Notify { store }));
|
||||
registry
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NotifyArgs {
|
||||
title: String,
|
||||
body: String,
|
||||
#[serde(default)]
|
||||
device: Option<String>,
|
||||
}
|
||||
|
||||
/// Sends one notification: one row in the table, delivered at the
|
||||
/// device's next poll-and-ack.
|
||||
struct Notify {
|
||||
store: Arc<NotificationStore>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for Notify {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
"notify",
|
||||
"Send a notification to one of the user's devices: the phone \
|
||||
by default. Use it to reach the user away from this chat — \
|
||||
a reminder they asked to be passed on, finished background \
|
||||
work, anything that must not wait for their next message.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": { "type": "string",
|
||||
"description": "The one-line headline." },
|
||||
"body": { "type": "string",
|
||||
"description": "The message body." },
|
||||
"device": { "type": "string",
|
||||
"description": "The target device; the phone \
|
||||
when omitted." }
|
||||
},
|
||||
"required": ["title", "body"]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||
let args: NotifyArgs =
|
||||
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||
let title = args.title.trim();
|
||||
let body = args.body.trim();
|
||||
if title.is_empty() {
|
||||
return Err("title must not be empty".to_owned());
|
||||
}
|
||||
if body.is_empty() {
|
||||
return Err("body must not be empty".to_owned());
|
||||
}
|
||||
let device = args
|
||||
.device
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|device| !device.is_empty())
|
||||
.unwrap_or(DEFAULT_DEVICE);
|
||||
let record = self
|
||||
.store
|
||||
.push(device, title, body)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(json!({
|
||||
"queued": true,
|
||||
"id": record.id,
|
||||
"device": record.device,
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store() -> Arc<NotificationStore> {
|
||||
Arc::new(NotificationStore::in_memory().expect("store opens"))
|
||||
}
|
||||
|
||||
async fn call(store: &Arc<NotificationStore>, args: Value) -> rhema_core::ToolResult {
|
||||
let resolver =
|
||||
registry(store.clone()).resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
|
||||
resolver
|
||||
.resolve(
|
||||
rhema_core::ToolCall {
|
||||
id: "c1".into(),
|
||||
name: "notify".into(),
|
||||
args,
|
||||
provider_data: Value::Null,
|
||||
},
|
||||
&rhema_core::ToolContext::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notify_enqueues_a_row_and_answers_one_line() {
|
||||
let store = store();
|
||||
let result = call(
|
||||
&store,
|
||||
json!({"title": "CI green", "body": "all checks pass"}),
|
||||
)
|
||||
.await;
|
||||
assert!(!result.is_error, "{}", result.content);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&result.content).unwrap(),
|
||||
json!({"queued": true, "id": 1, "device": "phone"}),
|
||||
"{}",
|
||||
result.content
|
||||
);
|
||||
let pending = store.pending(DEFAULT_DEVICE, 0).expect("poll reads");
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].title, "CI green");
|
||||
assert_eq!(pending[0].body, "all checks pass");
|
||||
assert_eq!(pending[0].device, "phone", "the default device");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_named_device_lands_on_its_own_queue() {
|
||||
let store = store();
|
||||
let result = call(
|
||||
&store,
|
||||
json!({"title": "done", "body": "shipped", "device": " desk "}),
|
||||
)
|
||||
.await;
|
||||
assert!(!result.is_error, "{}", result.content);
|
||||
assert!(
|
||||
store.pending("desk", 0).expect("poll reads").len() == 1
|
||||
&& store
|
||||
.pending(DEFAULT_DEVICE, 0)
|
||||
.expect("poll reads")
|
||||
.is_empty(),
|
||||
"the trimmed device owns the row"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_fields_and_bad_arguments_fail_in_band() {
|
||||
let store = store();
|
||||
for args in [
|
||||
json!({"title": " ", "body": "x"}),
|
||||
json!({"title": "x", "body": ""}),
|
||||
json!({"title": "x"}),
|
||||
] {
|
||||
let result = call(&store, args).await;
|
||||
assert!(result.is_error, "{}", result.content);
|
||||
}
|
||||
assert!(
|
||||
store
|
||||
.pending(DEFAULT_DEVICE, 0)
|
||||
.expect("poll reads")
|
||||
.is_empty(),
|
||||
"nothing landed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
//! The OpenAI-compatible shim: `POST /v1/chat/completions`, so a stock
|
||||
//! web UI plugs into the resident agent without knowing the claw's own
|
||||
//! routes.
|
||||
//!
|
||||
//! **The incoming history is ignored — deliberately, and this is the
|
||||
//! load-bearing caveat of the whole shim.** The daemon owns the session:
|
||||
//! one key, one persistent conversation, restored and stashed by the
|
||||
//! interactive handler across every turn. A chat UI over this endpoint
|
||||
//! MIRRORS the replies (streamed as deltas or returned whole); it does
|
||||
//! not own history and cannot rewrite it. Sending a full transcript
|
||||
//! changes nothing but which message triggers the turn: only the LAST
|
||||
//! user message is read. The session key is `interactive:<user>`,
|
||||
//! defaulting to `interactive:web`.
|
||||
//!
|
||||
//! The wire is the familiar subset: `stream: true` answers SSE
|
||||
//! chat-completion chunks (`delta.reasoning_content` for thinking,
|
||||
//! `delta.content` for text, a final chunk with `finish_reason: "stop"`,
|
||||
//! then `data: [DONE]`); `stream: false` answers one completion object
|
||||
//! once the turn's end marker arrives.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use rhema_rsd::{KeyState, ResourceKey};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
use crate::hub::TurnEvent;
|
||||
use crate::interactive::KEY_PREFIX;
|
||||
|
||||
/// How long the non-stream path waits for the turn's end marker. Turns
|
||||
/// can legitimately run minutes of shell work; a web UI's default
|
||||
/// timeout is the caller's problem, this one is generous.
|
||||
pub const TURN_WAIT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// The session key when the request names no `user`.
|
||||
pub const DEFAULT_USER: &str = "web";
|
||||
|
||||
/// The request body. `messages` is accepted in the OpenAI shape and
|
||||
/// reduced to the last user message; everything else is ignored.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChatRequest {
|
||||
/// Echoed back when present; the daemon's configured model
|
||||
/// otherwise.
|
||||
pub model: Option<String>,
|
||||
/// The transcript-shaped input. Only the last `user` entry matters.
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// Stream chunks (SSE) or wait for the whole reply.
|
||||
#[serde(default)]
|
||||
pub stream: bool,
|
||||
/// The session name: key `interactive:<user>`, `web` when unset.
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
/// One transcript entry; `content` is a string or an array of parts.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
/// `user`, `assistant`, `system`, `tool` — only `user` is read.
|
||||
pub role: String,
|
||||
/// String content, or OpenAI's array-of-parts.
|
||||
pub content: ChatContent,
|
||||
}
|
||||
|
||||
/// OpenAI's two content shapes.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ChatContent {
|
||||
/// A plain string.
|
||||
Text(String),
|
||||
/// An array of typed parts; only the text parts are read.
|
||||
Parts(Vec<ChatPart>),
|
||||
}
|
||||
|
||||
impl ChatContent {
|
||||
/// The message's text: the string, or the text parts joined. A part
|
||||
/// array with no text parts (images only, say) reads as empty.
|
||||
pub fn text(&self) -> String {
|
||||
match self {
|
||||
ChatContent::Text(text) => text.clone(),
|
||||
ChatContent::Parts(parts) => parts
|
||||
.iter()
|
||||
.filter_map(|part| part.text.as_deref())
|
||||
.collect::<Vec<_>>()
|
||||
.join(""),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One content part; unknown parts carry no readable text.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChatPart {
|
||||
/// The part's text, for `{"type": "text"}` parts.
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
/// The last `user` message's text — the only input a turn gets.
|
||||
pub fn last_user_text(messages: &[ChatMessage]) -> Option<String> {
|
||||
messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|message| message.role == "user")
|
||||
.map(|message| message.content.text())
|
||||
}
|
||||
|
||||
/// The completion/chunk id: `chatcmpl-` plus a process-unique suffix.
|
||||
fn completion_id() -> String {
|
||||
static SHIM_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"chatcmpl-{}-{:04x}",
|
||||
now.as_millis(),
|
||||
SHIM_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
)
|
||||
}
|
||||
|
||||
/// Seconds since the Unix epoch, the shim's `created`.
|
||||
fn unix_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
/// The non-stream completion object. `content` is `null` when the turn
|
||||
/// ended without a reply — OpenAI's shape allows it, and the session
|
||||
/// records why.
|
||||
fn completion_body(id: &str, created: u64, model: &str, reply: Option<String>) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"object": "chat.completion",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": reply},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/// One streaming chunk.
|
||||
fn chunk_body(id: &str, created: u64, model: &str, delta: Value, finish: Option<&str>) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": delta,
|
||||
"finish_reason": finish,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/// The shim endpoint. Errors are OpenAI-shaped: `{"error": {"message":
|
||||
/// ...}}` with a sensible status.
|
||||
pub async fn chat_completions(
|
||||
State(state): State<crate::http::ClawState>,
|
||||
Json(request): Json<ChatRequest>,
|
||||
) -> Response {
|
||||
let Some(text) = last_user_text(&request.messages).filter(|t| !t.trim().is_empty()) else {
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"no usable user message: the shim reads only the last `user` entry",
|
||||
);
|
||||
};
|
||||
let user = request
|
||||
.user
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|user| !user.is_empty())
|
||||
.unwrap_or(DEFAULT_USER);
|
||||
let key = ResourceKey::from(format!("{KEY_PREFIX}{user}"));
|
||||
let model = request.model.clone().unwrap_or_else(|| {
|
||||
let (_, active_model) = state.backends().active();
|
||||
active_model
|
||||
});
|
||||
let id = completion_id();
|
||||
let created = unix_secs();
|
||||
|
||||
// Subscribe first, ingest second, drain third — the same contract
|
||||
// the messages route keeps.
|
||||
let mut receiver = state.hub().subscribe(&key);
|
||||
if let Err(err) = state
|
||||
.dispatcher()
|
||||
.ingest(crate::interactive::user_event(key.as_str(), &text))
|
||||
{
|
||||
return error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string());
|
||||
}
|
||||
if let Err(err) = crate::http::drain(state.driver(), state.dispatcher(), &key).await {
|
||||
return error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string());
|
||||
}
|
||||
|
||||
if request.stream {
|
||||
return stream_response(receiver, &id, created, &model);
|
||||
}
|
||||
|
||||
// The drain ran the turn; its end marker is queued. A queued
|
||||
// follow-up behind a foreign turn delays ours — keep waiting through
|
||||
// drains until the key settles, bounded by TURN_WAIT.
|
||||
let deadline = tokio::time::Instant::now() + TURN_WAIT;
|
||||
let reply = loop {
|
||||
let done = match tokio::time::timeout_at(deadline, receiver.recv()).await {
|
||||
Ok(Ok(TurnEvent::TurnDone { reply, .. })) => reply,
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(RecvError::Lagged(_))) => continue,
|
||||
Ok(Err(RecvError::Closed)) => {
|
||||
return error_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"the event hub closed before the turn finished",
|
||||
);
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
return error_response(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"the turn did not finish in time; its reply may still arrive on the event stream",
|
||||
);
|
||||
}
|
||||
};
|
||||
let settled = state
|
||||
.dispatcher()
|
||||
.status(&key)
|
||||
.map(|status| status.inbox_depth == 0 && status.state != KeyState::Reacquire);
|
||||
match settled {
|
||||
Ok(true) => break done,
|
||||
Ok(false) => {
|
||||
// More work is queued behind what just finished — ours,
|
||||
// most likely: drain and wait for its end marker.
|
||||
if let Err(err) = crate::http::drain(state.driver(), state.dispatcher(), &key).await
|
||||
{
|
||||
return error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string());
|
||||
}
|
||||
}
|
||||
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()),
|
||||
}
|
||||
};
|
||||
Json(completion_body(&id, created, &model, reply)).into_response()
|
||||
}
|
||||
|
||||
/// The `stream: true` answer: an SSE body of completion chunks pumped
|
||||
/// from the hub. Thinking becomes `delta.reasoning_content`, text
|
||||
/// becomes `delta.content`, and the turn's end marker becomes the final
|
||||
/// `finish_reason: "stop"` chunk plus `data: [DONE]` — then the stream
|
||||
/// closes. Tool traffic is not surfaced: it is the agent's plumbing.
|
||||
fn stream_response(
|
||||
mut receiver: tokio::sync::broadcast::Receiver<TurnEvent>,
|
||||
id: &str,
|
||||
created: u64,
|
||||
model: &str,
|
||||
) -> Response {
|
||||
let id = id.to_owned();
|
||||
let model = model.to_owned();
|
||||
let (sender, body) =
|
||||
tokio::sync::mpsc::channel::<Result<SseEvent, std::convert::Infallible>>(64);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let frame = match receiver.recv().await {
|
||||
Ok(TurnEvent::ThinkingDelta { text }) => Ok(sse_chunk(
|
||||
&id,
|
||||
created,
|
||||
&model,
|
||||
json!({"reasoning_content": text}),
|
||||
)),
|
||||
Ok(TurnEvent::TextDelta { text }) => {
|
||||
Ok(sse_chunk(&id, created, &model, json!({"content": text})))
|
||||
}
|
||||
Ok(TurnEvent::TurnDone { .. }) => {
|
||||
// The turn is over regardless of who triggered it or
|
||||
// how it ended: the client gets a clean stop.
|
||||
let final_frame = Ok(sse_chunk_finish(
|
||||
&id,
|
||||
created,
|
||||
&model,
|
||||
json!({}),
|
||||
Some("stop"),
|
||||
));
|
||||
let done = Ok(SseEvent::default().data("[DONE]"));
|
||||
let _ = sender.send(final_frame).await;
|
||||
let _ = sender.send(done).await;
|
||||
break;
|
||||
}
|
||||
// The claw's own clients get the tool rows, the
|
||||
// consent cards, and the attachments; an OpenAI caller
|
||||
// gets text, reasoning, and the end marker.
|
||||
Ok(
|
||||
TurnEvent::ToolStart { .. }
|
||||
| TurnEvent::ToolResult { .. }
|
||||
| TurnEvent::ConsentRequest { .. }
|
||||
| TurnEvent::ConsentResolved { .. }
|
||||
| TurnEvent::Attachment { .. },
|
||||
) => continue,
|
||||
// The turn's input rides the stream for the claw's own
|
||||
// clients; an OpenAI caller named it in the request.
|
||||
Ok(TurnEvent::UserMessage { .. }) => continue,
|
||||
Err(RecvError::Lagged(dropped)) => Ok(SseEvent::default()
|
||||
.comment(format!("lagged; {dropped} earlier event(s) dropped"))),
|
||||
Err(RecvError::Closed) => break,
|
||||
};
|
||||
if sender.send(frame).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
Sse::new(ReceiverStream::new(body))
|
||||
.keep_alive(
|
||||
KeepAlive::default()
|
||||
.interval(crate::http::KEEP_ALIVE_INTERVAL)
|
||||
.text("keep-alive"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// One delta (or the final) chunk as an SSE frame.
|
||||
fn sse_chunk(id: &str, created: u64, model: &str, delta: Value) -> SseEvent {
|
||||
sse_chunk_finish(id, created, model, delta, None)
|
||||
}
|
||||
|
||||
fn sse_chunk_finish(
|
||||
id: &str,
|
||||
created: u64,
|
||||
model: &str,
|
||||
delta: Value,
|
||||
finish: Option<&str>,
|
||||
) -> SseEvent {
|
||||
SseEvent::default().data(chunk_body(id, created, model, delta, finish).to_string())
|
||||
}
|
||||
|
||||
/// An OpenAI-shaped error: `{"error": {"message": ...}}`.
|
||||
fn error_response(status: StatusCode, message: &str) -> Response {
|
||||
(status, Json(json!({"error": {"message": message}}))).into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn message(role: &str, content: ChatContent) -> ChatMessage {
|
||||
ChatMessage {
|
||||
role: role.to_owned(),
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_last_user_message_is_read() {
|
||||
let messages = vec![
|
||||
message("system", ChatContent::Text("be brief".into())),
|
||||
message("user", ChatContent::Text("first".into())),
|
||||
message("assistant", ChatContent::Text("no".into())),
|
||||
message("user", ChatContent::Text("second".into())),
|
||||
];
|
||||
assert_eq!(last_user_text(&messages).as_deref(), Some("second"));
|
||||
assert_eq!(last_user_text(&messages[0..1]).as_deref(), None);
|
||||
assert_eq!(last_user_text(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn part_arrays_join_their_text_parts() {
|
||||
let parts = ChatContent::Parts(vec![
|
||||
ChatPart {
|
||||
text: Some("look ".into()),
|
||||
},
|
||||
ChatPart {
|
||||
text: None, // an image part, say
|
||||
},
|
||||
ChatPart {
|
||||
text: Some("here".into()),
|
||||
},
|
||||
]);
|
||||
assert_eq!(parts.text(), "look here");
|
||||
assert_eq!(ChatContent::Text("plain".into()).text(), "plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_completion_shape_is_the_familiar_one() {
|
||||
let body = completion_body("chatcmpl-1", 42, "m", Some("hi".into()));
|
||||
assert_eq!(body["object"], "chat.completion");
|
||||
assert_eq!(body["choices"][0]["message"]["content"], "hi");
|
||||
assert_eq!(body["choices"][0]["finish_reason"], "stop");
|
||||
let null = completion_body("chatcmpl-1", 42, "m", None);
|
||||
assert!(null["choices"][0]["message"]["content"].is_null());
|
||||
|
||||
let chunk = chunk_body("chatcmpl-1", 42, "m", json!({"content": "x"}), None);
|
||||
assert_eq!(chunk["object"], "chat.completion.chunk");
|
||||
assert_eq!(chunk["choices"][0]["delta"]["content"], "x");
|
||||
assert!(chunk["choices"][0]["finish_reason"].is_null());
|
||||
let stop = chunk_body("chatcmpl-1", 42, "m", json!({}), Some("stop"));
|
||||
assert_eq!(stop["choices"][0]["finish_reason"], "stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_unique_and_shaped() {
|
||||
let a = completion_id();
|
||||
let b = completion_id();
|
||||
assert_ne!(a, b);
|
||||
assert!(a.starts_with("chatcmpl-"), "{a}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,115 @@
|
||||
//! Runtime settings the daemon persists: the key-value table behind the
|
||||
//! deployment state that outlives a restart.
|
||||
//!
|
||||
//! The active provider/model pair lives here, not in `claw.yaml` — the
|
||||
//! config file seeds it, the API changes it, and this store keeps it.
|
||||
//! Same store shape as the crate's other tables: a second connection to
|
||||
//! the shared SQLite file, WAL, busy timeout.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
use rusqlite::OptionalExtension;
|
||||
use rusqlite::params;
|
||||
|
||||
/// Why a settings operation failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SettingsError {
|
||||
/// The backend failed to read or write.
|
||||
#[error("settings store failed: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
/// The `settings` key-value table over the claw's shared database.
|
||||
pub struct SettingsStore {
|
||||
conn: Mutex<rusqlite::Connection>,
|
||||
}
|
||||
|
||||
impl SettingsStore {
|
||||
/// Opens (creating if needed) the settings table on the database at
|
||||
/// `path`.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, SettingsError> {
|
||||
let conn = rusqlite::Connection::open(path)
|
||||
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||
Self::init(conn)
|
||||
}
|
||||
|
||||
/// Opens the settings table over an in-memory database.
|
||||
pub fn in_memory() -> Result<Self, SettingsError> {
|
||||
let conn = rusqlite::Connection::open_in_memory()
|
||||
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||
Self::init(conn)
|
||||
}
|
||||
|
||||
fn init(conn: rusqlite::Connection) -> Result<Self, SettingsError> {
|
||||
let conn = conn;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))
|
||||
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// The value stored for `key`, when one was set.
|
||||
pub fn get(&self, key: &str) -> Result<Option<String>, SettingsError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let value: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = ?1",
|
||||
params![key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Every `(key, value)` whose key starts with `prefix`, ascending
|
||||
/// by key — discovery's view of the stored provider rows.
|
||||
pub fn get_prefix(&self, prefix: &str) -> Result<Vec<(String, String)>, SettingsError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut statement = conn
|
||||
.prepare("SELECT key, value FROM settings WHERE key LIKE (?1 || '%') ORDER BY key")
|
||||
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||
let rows = statement
|
||||
.query_map(params![prefix], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map_err(|err| SettingsError::Backend(err.to_string()))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Sets `key` to `value`, replacing any previous value.
|
||||
pub fn set(&self, key: &str, value: &str) -> Result<(), SettingsError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value],
|
||||
)
|
||||
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes `key` from the store; a no-op when it was absent.
|
||||
pub fn delete(&self, key: &str) -> Result<(), SettingsError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
|
||||
.map_err(|err| SettingsError::Backend(err.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted settings keys: the active backend pair.
|
||||
pub const ACTIVE_PROVIDER_KEY: &str = "active_provider";
|
||||
/// The persisted active model.
|
||||
pub const ACTIVE_MODEL_KEY: &str = "active_model";
|
||||
@@ -0,0 +1,291 @@
|
||||
//! The task source: deterministic detection of due scheduled tasks, the
|
||||
//! spec §1-compliant cron.
|
||||
//!
|
||||
//! The [`TaskScheduler`] is a [`Source`] named `"tasks"`: its `detect`
|
||||
//! pass reads the task table against an injected
|
||||
//! [`Clock`](rhema_rsd::Clock), ingests one event per due task into the
|
||||
//! resident dispatcher, then advances the row — the one-shot is
|
||||
//! deleted, the recurring task re-arms. The model is never reached here;
|
||||
//! the turn happens because the dispatcher admitted the key, exactly as
|
||||
//! for a chat message. Cadence belongs to the daemon's loop, which runs
|
||||
//! this source's detect each tick alongside the forge sources.
|
||||
//!
|
||||
//! Due/re-arm semantics (the loop-fire guards):
|
||||
//!
|
||||
//! - A row fires at most once per detect pass: every acted-on row is
|
||||
//! advanced before the pass returns, and `due` reads the table once.
|
||||
//! - A recurring task re-arms to `next_due + interval`; when that is
|
||||
//! still past (the process was down, the clock jumped), it re-arms to
|
||||
//! `now + interval` — a missed task catches up with one firing, never
|
||||
//! one per elapsed interval.
|
||||
//! - A recurring row without a usable interval is deleted, not skipped:
|
||||
//! a skipped due row would fire again on every pass forever.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rhema_autonomous_agent::{Source, SourceError, async_trait};
|
||||
use rhema_rsd::{Clock, Dispatcher, Event, KeyFn};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::interactive::passthrough_key_fn;
|
||||
use crate::task_store::{TaskKind, TaskStore};
|
||||
|
||||
/// The source name the scheduler ingests under; registered on the
|
||||
/// resident dispatcher, so allowlists and `/v1/ingest` can name it.
|
||||
pub const TASKS_SOURCE: &str = "tasks";
|
||||
|
||||
/// One scheduled task's dispatch key: `task:<id>` — the session the
|
||||
/// prompt's turn runs in, persistent across the task's firings.
|
||||
pub fn task_key(id: i64) -> String {
|
||||
format!("task:{id}")
|
||||
}
|
||||
|
||||
/// The event one due firing becomes. The key is the task's delivery
|
||||
/// target — its own `task:<id>` slot, or the session the scheduler was
|
||||
/// asked to wake (a chat conversation with its context, a phone key
|
||||
/// whose reply auto-notifies). The dedup id carries the firing's
|
||||
/// `next_due`, so a recurring task's next firing never dedups against
|
||||
/// the last one still pending in the key's inbox.
|
||||
pub fn task_event(task: &crate::task_store::TaskRecord, now: u64) -> Event {
|
||||
let default_key = task_key(task.id);
|
||||
let key = task
|
||||
.deliver_to
|
||||
.as_deref()
|
||||
.filter(|key| !key.is_empty())
|
||||
.unwrap_or(default_key.as_str());
|
||||
Event {
|
||||
id: format!("task-{}@{}", task.id, task.next_due_millis),
|
||||
source: TASKS_SOURCE.to_owned(),
|
||||
payload: json!({ "key": key, "prompt": task.prompt }),
|
||||
received_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// The detectable stream of due tasks: the task table against a clock,
|
||||
/// feeding the resident dispatcher.
|
||||
pub struct TaskScheduler {
|
||||
clock: Arc<dyn Clock>,
|
||||
store: Arc<TaskStore>,
|
||||
dispatcher: Arc<Dispatcher>,
|
||||
key_fn: Arc<KeyFn>,
|
||||
}
|
||||
|
||||
impl TaskScheduler {
|
||||
/// Registers the `tasks` source's key function on `dispatcher` and
|
||||
/// returns the scheduler feeding it — the ForgeSource shape: one
|
||||
/// source, registered once, before the first detect.
|
||||
pub fn new(dispatcher: &Arc<Dispatcher>, clock: Arc<dyn Clock>, store: Arc<TaskStore>) -> Self {
|
||||
let key_fn = passthrough_key_fn();
|
||||
dispatcher.register_source(TASKS_SOURCE, key_fn.clone());
|
||||
Self {
|
||||
clock,
|
||||
store,
|
||||
dispatcher: dispatcher.clone(),
|
||||
key_fn,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Source for TaskScheduler {
|
||||
fn name(&self) -> &str {
|
||||
TASKS_SOURCE
|
||||
}
|
||||
|
||||
fn key_fn(&self) -> Arc<KeyFn> {
|
||||
self.key_fn.clone()
|
||||
}
|
||||
|
||||
async fn detect(&self) -> Result<usize, SourceError> {
|
||||
let now = self.clock.now_millis() as i64;
|
||||
let due = self
|
||||
.store
|
||||
.due(now)
|
||||
.map_err(|err| SourceError::Backend(format!("task store: {err}")))?;
|
||||
let mut fed = 0;
|
||||
for task in due {
|
||||
self.dispatcher.ingest(task_event(&task, now as u64))?;
|
||||
fed += 1;
|
||||
match task.kind {
|
||||
TaskKind::At => {
|
||||
// Fired once; the row's work is done.
|
||||
self.store
|
||||
.remove(task.id)
|
||||
.map_err(|err| SourceError::Backend(format!("task store: {err}")))?;
|
||||
}
|
||||
TaskKind::Every => match task.interval_millis {
|
||||
Some(interval) if interval > 0 => {
|
||||
let mut next = task.next_due_millis.saturating_add(interval);
|
||||
if next <= now {
|
||||
// Catch-up: the scheduled next is already past
|
||||
// (downtime, a clock jump). One firing settles
|
||||
// the debt; the rest never happens.
|
||||
next = now.saturating_add(interval);
|
||||
}
|
||||
self.store
|
||||
.rearm(task.id, next)
|
||||
.map_err(|err| SourceError::Backend(format!("task store: {err}")))?;
|
||||
}
|
||||
// A recurring task that cannot re-arm must not sit due
|
||||
// forever, re-firing every pass: retire it, loudly.
|
||||
interval => {
|
||||
eprintln!(
|
||||
"[claw] warning: task {} has no usable interval \
|
||||
({interval:?}); removing it",
|
||||
task.id
|
||||
);
|
||||
self.store
|
||||
.remove(task.id)
|
||||
.map_err(|err| SourceError::Backend(format!("task store: {err}")))?;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(fed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rhema_rsd::{DispatcherConfig, FixedClock, SystemClock, VolatileStore};
|
||||
|
||||
fn dispatcher() -> Arc<Dispatcher> {
|
||||
Arc::new(Dispatcher::new(
|
||||
DispatcherConfig {
|
||||
debounce_window_millis: 0,
|
||||
..DispatcherConfig::default()
|
||||
},
|
||||
Arc::new(SystemClock),
|
||||
Arc::new(VolatileStore::new()),
|
||||
))
|
||||
}
|
||||
|
||||
/// The dispatcher status of one task's key: how many events wait on
|
||||
/// it. A key never ingested reports the missing view — depth 0.
|
||||
fn inbox_depth(dispatcher: &Dispatcher, id: i64) -> usize {
|
||||
dispatcher
|
||||
.status(&rhema_rsd::ResourceKey::from(task_key(id)))
|
||||
.expect("status reads")
|
||||
.inbox_depth
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_due_one_shot_fires_once_and_is_removed() {
|
||||
let clock = Arc::new(FixedClock::new(1_000));
|
||||
let store = Arc::new(TaskStore::in_memory().expect("store opens"));
|
||||
let dispatcher = dispatcher();
|
||||
let source = TaskScheduler::new(&dispatcher, clock.clone(), store.clone());
|
||||
store
|
||||
.schedule_at("remind the user: bread", 1_500, 1_000, None)
|
||||
.expect("scheduled");
|
||||
|
||||
// Not yet due: skipped, nothing fed, no record.
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 0);
|
||||
assert_eq!(inbox_depth(&dispatcher, 1), 0, "nothing fired");
|
||||
|
||||
// Due: exactly one event, on the task's key.
|
||||
clock.advance(500);
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||
assert_eq!(inbox_depth(&dispatcher, 1), 1);
|
||||
|
||||
// The row is gone; a second pass re-fires nothing.
|
||||
assert!(store.list().expect("list reads").is_empty());
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 0);
|
||||
assert_eq!(inbox_depth(&dispatcher, 1), 1, "the inbox keeps it");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_recurring_task_rearms_and_never_loop_fires() {
|
||||
let clock = Arc::new(FixedClock::new(1_000));
|
||||
let store = Arc::new(TaskStore::in_memory().expect("store opens"));
|
||||
let dispatcher = dispatcher();
|
||||
let source = TaskScheduler::new(&dispatcher, clock.clone(), store.clone());
|
||||
store
|
||||
.schedule_every("stand up", 1_000, 1_000, None)
|
||||
.expect("scheduled");
|
||||
|
||||
// Due at 2000: one firing, re-armed to due + interval.
|
||||
clock.advance(1_500); // now = 2500
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||
let task = &store.list().expect("list reads")[0];
|
||||
assert_eq!(task.next_due_millis, 2_000 + 1_000, "next_due + interval");
|
||||
assert_eq!(inbox_depth(&dispatcher, 1), 1);
|
||||
|
||||
// Still past due after re-arm (the firing lagged): the next pass
|
||||
// fires again — but one detect never fires the same task twice,
|
||||
// and the catch-up rule re-arms from now, not from the backlog.
|
||||
clock.advance(10_000); // now = 12500
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||
let task = &store.list().expect("list reads")[0];
|
||||
assert_eq!(
|
||||
task.next_due_millis, 13_500,
|
||||
"catch-up re-arms to now + interval, never loop-fires"
|
||||
);
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 0);
|
||||
assert_eq!(
|
||||
inbox_depth(&dispatcher, 1),
|
||||
2,
|
||||
"one firing per pass: two passes, two events"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_delivery_target_routes_the_firing_to_that_session() {
|
||||
let clock = Arc::new(FixedClock::new(1_000));
|
||||
let store = Arc::new(TaskStore::in_memory().expect("store opens"));
|
||||
let dispatcher = dispatcher();
|
||||
let source = TaskScheduler::new(&dispatcher, clock.clone(), store.clone());
|
||||
store
|
||||
.schedule_at(
|
||||
"summarize this evening's work",
|
||||
1_500,
|
||||
1_000,
|
||||
Some("interactive:web"),
|
||||
)
|
||||
.expect("scheduled");
|
||||
|
||||
clock.advance(500);
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||
// The firing woke the named session, not the task's own slot.
|
||||
let depth = |key: &str| {
|
||||
dispatcher
|
||||
.status(&rhema_rsd::ResourceKey::from(key))
|
||||
.expect("status reads")
|
||||
.inbox_depth
|
||||
};
|
||||
assert_eq!(depth("interactive:web"), 1);
|
||||
assert_eq!(depth(&task_key(1)), 0, "the default slot stays cold");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unusable_recurring_row_is_retired_not_loop_fired() {
|
||||
let clock = Arc::new(FixedClock::new(1_000));
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("dispatch.db");
|
||||
// The table exists before the hand edit, so the row is the only
|
||||
// thing in it.
|
||||
drop(TaskStore::open(&path).expect("store opens"));
|
||||
{
|
||||
// Simulate the hand-edited table the store's API cannot
|
||||
// produce: an `every` row without an interval, due now. The
|
||||
// CHECK constraint only pins the kind's spelling.
|
||||
let conn = rusqlite::Connection::open(&path).expect("raw connection");
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (prompt, kind, at_millis, interval_millis, next_due_millis, created_at)
|
||||
VALUES ('tick', 'every', NULL, NULL, 0, 0)",
|
||||
[],
|
||||
)
|
||||
.expect("the malformed row lands");
|
||||
}
|
||||
let store = Arc::new(TaskStore::open(&path).expect("store reopens"));
|
||||
let dispatcher = dispatcher();
|
||||
let source = TaskScheduler::new(&dispatcher, clock.clone(), store.clone());
|
||||
|
||||
// The bad row fires once (its prompt is real), then is retired:
|
||||
// a skip would leave it due on every pass forever.
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 1);
|
||||
assert!(store.list().expect("list reads").is_empty());
|
||||
assert_eq!(source.detect().await.expect("detect runs"), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
//! The task table: user-scheduled prompts in SQLite, the same database
|
||||
//! file the dispatcher's [`SqliteStore`](rhema_rsd::SqliteStore), the
|
||||
//! watch table, and the notification table use.
|
||||
//!
|
||||
//! A task is the spec §1-compliant "cron": the row is inert data until
|
||||
//! the [`TaskScheduler`](crate::task_source::TaskScheduler) — a
|
||||
//! deterministic source, never the model — reads `next_due_millis`
|
||||
//! against the clock and ingests the prompt as one event. No timer
|
||||
//! invokes the model; a due task is an event, not a heartbeat.
|
||||
//!
|
||||
//! Two connections, one file: this store opens its own rusqlite
|
||||
//! connection to the same database as the dispatcher's store — WAL and a
|
||||
//! busy timeout, the pattern [`ClawStore`](crate::watch_store::ClawStore)
|
||||
//! established — and the two interleave through SQLite's own locking.
|
||||
//! Writes are single INSERT / UPDATE / DELETE statements, so there is no
|
||||
//! read-modify-write window to guard.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
use rusqlite::params;
|
||||
use serde::Serialize;
|
||||
|
||||
/// The task table. The CHECK pins the two kinds' column pairing to what
|
||||
/// the code writes: `at` rows carry `at_millis`, `every` rows carry
|
||||
/// `interval_millis`. `deliver_to` names the session a firing wakes —
|
||||
/// `NULL` meaning the task's own `task:<id>` slot.
|
||||
const SCHEMA: &str = "
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
prompt TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('at','every')),
|
||||
at_millis INTEGER NULL,
|
||||
interval_millis INTEGER NULL,
|
||||
next_due_millis INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
deliver_to TEXT NULL
|
||||
);
|
||||
";
|
||||
|
||||
/// Adds `deliver_to` to a table created before it existed. The column
|
||||
/// is the only schema the first release lacked, and `ALTER TABLE ADD
|
||||
/// COLUMN` with a `NULL` default is the one shape SQLite can add
|
||||
/// in place.
|
||||
fn migrate_add_deliver_to(conn: &rusqlite::Connection) -> Result<(), TaskStoreError> {
|
||||
let present: bool = conn
|
||||
.prepare("PRAGMA table_info(tasks)")?
|
||||
.query_map([], |row| row.get::<_, String>(1))?
|
||||
.filter_map(Result::ok)
|
||||
.any(|name| name == "deliver_to");
|
||||
if !present {
|
||||
conn.execute("ALTER TABLE tasks ADD COLUMN deliver_to TEXT NULL", [])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Why a task-table operation failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TaskStoreError {
|
||||
/// The SQLite backend failed.
|
||||
#[error("task store backend: {0}")]
|
||||
Backend(#[from] rusqlite::Error),
|
||||
}
|
||||
|
||||
/// What kind of task a row is: fire once at a time (`at`) or repeat on
|
||||
/// an interval (`every`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TaskKind {
|
||||
/// Fire once, at `at_millis`; the row leaves the table after
|
||||
/// firing.
|
||||
At,
|
||||
/// Fire on every `interval_millis` tick; the row re-arms after each
|
||||
/// firing.
|
||||
Every,
|
||||
}
|
||||
|
||||
impl TaskKind {
|
||||
/// The name the table's `kind` column stores.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
TaskKind::At => "at",
|
||||
TaskKind::Every => "every",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the column spelling. Private: rows only enter through
|
||||
/// [`TaskStore::schedule_at`] / [`TaskStore::schedule_every`], and
|
||||
/// this guards only hand-edited tables.
|
||||
fn parse(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"at" => Some(TaskKind::At),
|
||||
"every" => Some(TaskKind::Every),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One scheduled prompt, as stored. Serializes whole: this is the shape
|
||||
/// `list_tasks` serves the model.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct TaskRecord {
|
||||
/// The row id; the dispatch key (`task:<id>`) and `cancel_task`
|
||||
/// address it.
|
||||
pub id: i64,
|
||||
/// The prompt the scheduled turn runs.
|
||||
pub prompt: String,
|
||||
/// Fire once or repeat.
|
||||
pub kind: TaskKind,
|
||||
/// The one-shot's firing time, milliseconds since the Unix epoch.
|
||||
pub at_millis: Option<i64>,
|
||||
/// The recurring task's interval, in milliseconds.
|
||||
pub interval_millis: Option<i64>,
|
||||
/// When the task next fires, milliseconds since the Unix epoch.
|
||||
pub next_due_millis: i64,
|
||||
/// Creation time, milliseconds since the Unix epoch.
|
||||
pub created_at: i64,
|
||||
/// The session a firing wakes; `None` meaning the task's own
|
||||
/// `task:<id>` slot.
|
||||
pub deliver_to: Option<String>,
|
||||
}
|
||||
|
||||
/// The persistent task table over its own connection to the daemon's
|
||||
/// SQLite file. Shareable behind an `Arc`; every operation takes
|
||||
/// `&self`.
|
||||
pub struct TaskStore {
|
||||
conn: Mutex<rusqlite::Connection>,
|
||||
}
|
||||
|
||||
impl TaskStore {
|
||||
/// Opens (creating if needed) the task table in the database at
|
||||
/// `path` — the same file the dispatcher's store holds. WAL keeps
|
||||
/// the connections from blocking each other's readers; the busy
|
||||
/// timeout rides out the other's write locks.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, TaskStoreError> {
|
||||
let conn = rusqlite::Connection::open(path).map_err(TaskStoreError::from)?;
|
||||
Self::init(conn, true)
|
||||
}
|
||||
|
||||
/// Opens a private in-memory table: the shape without the file, for
|
||||
/// tests that need no sharing.
|
||||
pub fn in_memory() -> Result<Self, TaskStoreError> {
|
||||
let conn = rusqlite::Connection::open_in_memory().map_err(TaskStoreError::from)?;
|
||||
Self::init(conn, false)
|
||||
}
|
||||
|
||||
fn init(conn: rusqlite::Connection, durable: bool) -> Result<Self, TaskStoreError> {
|
||||
if durable {
|
||||
// Same pragmas as the dispatcher's store on the same file:
|
||||
// SQLite stores the journal mode per database, and both
|
||||
// connections must agree that it is WAL.
|
||||
let _mode: String = conn
|
||||
.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
|
||||
.map_err(TaskStoreError::from)?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))
|
||||
.map_err(TaskStoreError::from)?;
|
||||
}
|
||||
conn.execute_batch(SCHEMA).map_err(TaskStoreError::from)?;
|
||||
migrate_add_deliver_to(&conn)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// Schedules a one-shot firing at `at_millis`, waking `deliver_to`
|
||||
/// (the task's own `task:<id>` slot when `None`). A time already
|
||||
/// past is accepted: the next detect pass fires it immediately — a
|
||||
/// task scheduled for "15:00" at 15:01 still runs.
|
||||
pub fn schedule_at(
|
||||
&self,
|
||||
prompt: &str,
|
||||
at_millis: i64,
|
||||
now: i64,
|
||||
deliver_to: Option<&str>,
|
||||
) -> Result<TaskRecord, TaskStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (prompt, kind, at_millis, interval_millis, next_due_millis, created_at, deliver_to)
|
||||
VALUES (?1, 'at', ?2, NULL, ?2, ?3, ?4)",
|
||||
params![prompt, at_millis, now, deliver_to],
|
||||
)?;
|
||||
Ok(TaskRecord {
|
||||
id: conn.last_insert_rowid(),
|
||||
prompt: prompt.to_owned(),
|
||||
kind: TaskKind::At,
|
||||
at_millis: Some(at_millis),
|
||||
interval_millis: None,
|
||||
next_due_millis: at_millis,
|
||||
created_at: now,
|
||||
deliver_to: deliver_to.map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
/// Schedules a recurring firing every `interval_millis`, the first
|
||||
/// one `interval_millis` from `now`, waking `deliver_to`.
|
||||
pub fn schedule_every(
|
||||
&self,
|
||||
prompt: &str,
|
||||
interval_millis: i64,
|
||||
now: i64,
|
||||
deliver_to: Option<&str>,
|
||||
) -> Result<TaskRecord, TaskStoreError> {
|
||||
let next_due = now + interval_millis;
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (prompt, kind, at_millis, interval_millis, next_due_millis, created_at, deliver_to)
|
||||
VALUES (?1, 'every', NULL, ?2, ?3, ?4, ?5)",
|
||||
params![prompt, interval_millis, next_due, now, deliver_to],
|
||||
)?;
|
||||
Ok(TaskRecord {
|
||||
id: conn.last_insert_rowid(),
|
||||
prompt: prompt.to_owned(),
|
||||
kind: TaskKind::Every,
|
||||
at_millis: None,
|
||||
interval_millis: Some(interval_millis),
|
||||
next_due_millis: next_due,
|
||||
created_at: now,
|
||||
deliver_to: deliver_to.map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
/// Every task, oldest first — the `list_tasks` view.
|
||||
pub fn list(&self) -> Result<Vec<TaskRecord>, TaskStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, prompt, kind, at_millis, interval_millis, next_due_millis, created_at, deliver_to
|
||||
FROM tasks ORDER BY id ASC",
|
||||
)
|
||||
.map_err(TaskStoreError::from)?;
|
||||
let rows = stmt
|
||||
.query_map([], row_task)
|
||||
.map_err(TaskStoreError::from)?
|
||||
.collect::<Result<Vec<_>, rusqlite::Error>>()
|
||||
.map_err(TaskStoreError::from)?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The due tasks at `now`, in firing order: `next_due_millis`
|
||||
/// elapsed, oldest due first. One detect pass reads this once and
|
||||
/// advances every row it acts on, so a task fires at most once per
|
||||
/// pass.
|
||||
pub fn due(&self, now: i64) -> Result<Vec<TaskRecord>, TaskStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, prompt, kind, at_millis, interval_millis, next_due_millis, created_at, deliver_to
|
||||
FROM tasks WHERE next_due_millis <= ?1
|
||||
ORDER BY next_due_millis ASC, id ASC",
|
||||
)
|
||||
.map_err(TaskStoreError::from)?;
|
||||
let rows = stmt
|
||||
.query_map(params![now], row_task)
|
||||
.map_err(TaskStoreError::from)?
|
||||
.collect::<Result<Vec<_>, rusqlite::Error>>()
|
||||
.map_err(TaskStoreError::from)?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Removes a task. `false` when there was none — the one-shot
|
||||
/// completion path and `cancel_task` share it, and both treat "already
|
||||
/// gone" as success.
|
||||
pub fn remove(&self, id: i64) -> Result<bool, TaskStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let removed = conn.execute("DELETE FROM tasks WHERE id = ?1", params![id])?;
|
||||
Ok(removed == 1)
|
||||
}
|
||||
|
||||
/// Moves a recurring task's next firing to `next_due_millis`. The
|
||||
/// scheduler computes the value — never `now + interval` inside a
|
||||
/// loop — so a task missed while the process was down catches up
|
||||
/// with one firing, not one per elapsed interval.
|
||||
pub fn rearm(&self, id: i64, next_due_millis: i64) -> Result<(), TaskStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
conn.execute(
|
||||
"UPDATE tasks SET next_due_millis = ?2 WHERE id = ?1",
|
||||
params![id, next_due_millis],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps one row; the shared tail of [`TaskStore::list`] and
|
||||
/// [`TaskStore::due`].
|
||||
fn row_task(row: &rusqlite::Row<'_>) -> Result<TaskRecord, rusqlite::Error> {
|
||||
let kind: String = row.get(2)?;
|
||||
Ok(TaskRecord {
|
||||
id: row.get(0)?,
|
||||
prompt: row.get(1)?,
|
||||
kind: TaskKind::parse(&kind)
|
||||
.unwrap_or_else(|| unreachable!("the CHECK constraint only admits 'at' and 'every'")),
|
||||
at_millis: row.get(3)?,
|
||||
interval_millis: row.get(4)?,
|
||||
next_due_millis: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
deliver_to: row.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tasks_round_trip_through_the_table() {
|
||||
let store = TaskStore::in_memory().expect("store opens");
|
||||
let at = store
|
||||
.schedule_at("take the bread out", 1_500, 1_000, None)
|
||||
.expect("at lands");
|
||||
let every = store
|
||||
.schedule_every("stand up", 1_800_000, 1_000, None)
|
||||
.expect("every lands");
|
||||
assert_ne!(at.id, every.id);
|
||||
assert_eq!(at.deliver_to, None, "the default slot is the task's own");
|
||||
|
||||
let listed = store.list().expect("list reads");
|
||||
assert_eq!(listed, vec![at.clone(), every.clone()]);
|
||||
assert_eq!(at.kind, TaskKind::At);
|
||||
assert_eq!(at.at_millis, Some(1_500));
|
||||
assert_eq!(at.next_due_millis, 1_500, "a one-shot is due at its at");
|
||||
assert_eq!(every.kind, TaskKind::Every);
|
||||
assert_eq!(every.interval_millis, Some(1_800_000));
|
||||
assert_eq!(every.next_due_millis, 1_801_000, "now + interval");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn due_selects_only_elapsed_tasks_in_firing_order() {
|
||||
let store = TaskStore::in_memory().expect("store opens");
|
||||
let late = store.schedule_at("late", 500, 0, None).expect("at lands");
|
||||
let edge = store.schedule_at("edge", 1_000, 0, None).expect("at lands");
|
||||
store
|
||||
.schedule_at("future", 5_000, 0, None)
|
||||
.expect("at lands");
|
||||
|
||||
assert!(
|
||||
store.due(499).expect("due reads").is_empty(),
|
||||
"nothing is due before the earliest"
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.due(1_000)
|
||||
.expect("due reads")
|
||||
.iter()
|
||||
.map(|task| task.id)
|
||||
.collect::<Vec<_>>(),
|
||||
[late.id, edge.id],
|
||||
"the boundary is inclusive, in next-due order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_and_rearm_advance_rows() {
|
||||
let store = TaskStore::in_memory().expect("store opens");
|
||||
let task = store.schedule_every("tick", 1_000, 0, None).expect("lands");
|
||||
store.rearm(task.id, 9_999).expect("rearm lands");
|
||||
let listed = store.list().expect("list reads");
|
||||
assert_eq!(listed[0].next_due_millis, 9_999);
|
||||
assert!(store.remove(task.id).expect("remove lands"));
|
||||
assert!(!store.remove(task.id).expect("re-remove is quiet"));
|
||||
assert!(store.list().expect("list reads").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deliver_to_session_round_trips_and_defaults_absent() {
|
||||
let store = TaskStore::in_memory().expect("store opens");
|
||||
store
|
||||
.schedule_at(
|
||||
"summarize tonight's work",
|
||||
1_500,
|
||||
1_000,
|
||||
Some("interactive:web"),
|
||||
)
|
||||
.expect("at lands");
|
||||
let listed = store.list().expect("list reads");
|
||||
assert_eq!(listed[0].deliver_to.as_deref(), Some("interactive:web"));
|
||||
}
|
||||
|
||||
/// A table from before `deliver_to` opens into the new shape; its
|
||||
/// rows read as defaulting to the task's own slot.
|
||||
#[test]
|
||||
fn a_pre_deliver_to_table_migrates_at_open() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("dispatch.db");
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&path).expect("opens");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE tasks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
prompt TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('at','every')),
|
||||
at_millis INTEGER NULL,
|
||||
interval_millis INTEGER NULL,
|
||||
next_due_millis INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO tasks (prompt, kind, at_millis, next_due_millis, created_at)
|
||||
VALUES ('old row', 'at', 42, 42, 0);",
|
||||
)
|
||||
.expect("legacy table");
|
||||
}
|
||||
let store = TaskStore::open(&path).expect("opens and migrates");
|
||||
let listed = store.list().expect("list reads");
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].deliver_to, None);
|
||||
// The migrated row still schedules with a delivery target.
|
||||
store
|
||||
.schedule_at("new row", 50, 40, Some("task:9"))
|
||||
.expect("schedules");
|
||||
assert_eq!(store.list().expect("reads").len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_connections_share_one_file() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("dispatch.db");
|
||||
let writer = TaskStore::open(&path).expect("first connection");
|
||||
let reader = TaskStore::open(&path).expect("second connection");
|
||||
writer.schedule_at("hello", 42, 0, None).expect("schedule");
|
||||
let listed = reader.list().expect("the other connection sees it");
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].prompt, "hello");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
//! The agent-side task tools: how the resident schedules its own future
|
||||
//! turns — "remind me at 15:00" becomes a `schedule_task` call, the row
|
||||
//! lands in the [`TaskStore`], and the next detect pass of the
|
||||
//! [`TaskScheduler`](crate::task_source::TaskScheduler) picks it up. No
|
||||
//! restart, no second mechanism, and no timer anywhere: a due task is
|
||||
//! an event (spec §1), never a heartbeat.
|
||||
//!
|
||||
//! These are [`Tool`] implementations for a [`ToolRegistry`], so the
|
||||
//! interactive handler offers them through the same allowlist-filtered
|
||||
//! seam as everything else the model may call (spec §8). Answers are
|
||||
//! short JSON; failures are in-band strings the model can react to.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||
use rhema_core::ToolSpec;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use crate::interactive::is_resident_key;
|
||||
use crate::task_store::TaskStore;
|
||||
|
||||
/// The task tool set over `store`: `schedule_task`, `list_tasks`,
|
||||
/// `cancel_task`.
|
||||
pub fn registry(store: Arc<TaskStore>) -> ToolRegistry {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(ScheduleTask {
|
||||
store: store.clone(),
|
||||
}));
|
||||
registry.register(Arc::new(ListTasks {
|
||||
store: store.clone(),
|
||||
}));
|
||||
registry.register(Arc::new(CancelTask { store }));
|
||||
registry
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ScheduleArgs {
|
||||
prompt: String,
|
||||
#[serde(default)]
|
||||
at: Option<Value>,
|
||||
#[serde(default)]
|
||||
every_secs: Option<u64>,
|
||||
#[serde(default)]
|
||||
session: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CancelArgs {
|
||||
id: i64,
|
||||
}
|
||||
|
||||
/// Milliseconds since the Unix epoch, the table's clock everywhere.
|
||||
fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
/// Parses the `at` argument: epoch milliseconds as a JSON number, or a
|
||||
/// string holding either epoch milliseconds or an RFC3339 timestamp.
|
||||
fn parse_at_millis(value: Value) -> Result<i64, String> {
|
||||
match value {
|
||||
Value::Number(number) => number
|
||||
.as_i64()
|
||||
.ok_or_else(|| "`at` millis are out of range".to_owned()),
|
||||
Value::String(raw) => {
|
||||
let trimmed = raw.trim();
|
||||
if !trimmed.is_empty() && trimmed.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
trimmed
|
||||
.parse::<i64>()
|
||||
.map_err(|_| "`at` millis are out of range".to_owned())
|
||||
} else {
|
||||
let at = OffsetDateTime::parse(trimmed, &Rfc3339)
|
||||
.map_err(|err| format!("`at` is not an RFC3339 timestamp: {err}"))?;
|
||||
Ok(at.unix_timestamp() * 1000 + i64::from(at.millisecond()))
|
||||
}
|
||||
}
|
||||
_ => Err("`at` is an RFC3339 timestamp or epoch milliseconds".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules one prompt: fire once at `at` (an RFC3339 timestamp or
|
||||
/// epoch milliseconds) or repeatedly every `every_secs` seconds —
|
||||
/// exactly one of the two.
|
||||
struct ScheduleTask {
|
||||
store: Arc<TaskStore>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ScheduleTask {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
"schedule_task",
|
||||
"Schedule a future turn of yours. `prompt` runs as this \
|
||||
agent's own turn — once at `at`, or repeatedly every \
|
||||
`every_secs` seconds; set exactly one of the two. The \
|
||||
scheduled turn has no chat surface: its reply reaches the \
|
||||
user as a notification, so write prompts that produce one. \
|
||||
Use list_tasks and cancel_task to manage what is scheduled.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": { "type": "string",
|
||||
"description": "What the scheduled turn should do." },
|
||||
"at": { "type": ["string", "number"],
|
||||
"description": "When to fire once: an RFC3339 \
|
||||
timestamp (\"2026-10-10T15:00:00Z\") or epoch \
|
||||
milliseconds." },
|
||||
"every_secs": { "type": "integer",
|
||||
"description": "Repeat every this many seconds." }
|
||||
},
|
||||
"required": ["prompt"]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||
let args: ScheduleArgs =
|
||||
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||
let prompt = args.prompt.trim();
|
||||
if prompt.is_empty() {
|
||||
return Err("prompt must not be empty".to_owned());
|
||||
}
|
||||
// A blank session reads as unset, and only a resident key can be
|
||||
// woken: the delivery target must be a session the resident
|
||||
// dispatcher answers.
|
||||
let deliver_to = args
|
||||
.session
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|session| !session.is_empty());
|
||||
if let Some(session) = deliver_to {
|
||||
if !is_resident_key(session) {
|
||||
return Err(format!(
|
||||
"session must be a resident key (interactive:, task:, or phone:), got {session:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
let record = match (args.at, args.every_secs) {
|
||||
(Some(at), None) => {
|
||||
let at_millis = parse_at_millis(at)?;
|
||||
self.store
|
||||
.schedule_at(prompt, at_millis, now_millis(), deliver_to)
|
||||
.map_err(|err| err.to_string())?
|
||||
}
|
||||
(None, Some(every_secs)) => {
|
||||
if every_secs == 0 {
|
||||
return Err("every_secs must be at least 1".to_owned());
|
||||
}
|
||||
let interval = every_secs
|
||||
.checked_mul(1000)
|
||||
.filter(|millis| *millis <= i64::MAX as u64)
|
||||
.ok_or_else(|| "`every_secs` is out of range".to_owned())?;
|
||||
self.store
|
||||
.schedule_every(prompt, interval as i64, now_millis(), deliver_to)
|
||||
.map_err(|err| err.to_string())?
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
return Err("set exactly one of `at` or `every_secs`, not both".to_owned());
|
||||
}
|
||||
(None, None) => {
|
||||
return Err("set one of `at` or `every_secs`".to_owned());
|
||||
}
|
||||
};
|
||||
Ok(json!({
|
||||
"scheduled": true,
|
||||
"id": record.id,
|
||||
"kind": record.kind.name(),
|
||||
"next_due_millis": record.next_due_millis,
|
||||
"session": record.deliver_to,
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists what is scheduled.
|
||||
struct ListTasks {
|
||||
store: Arc<TaskStore>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ListTasks {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
"list_tasks",
|
||||
"List the scheduled tasks: id, prompt, kind (at or every), \
|
||||
and next_due_millis — when each one fires next.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn invoke(&self, _args: Value) -> Result<String, String> {
|
||||
let tasks = self.store.list().map_err(|err| err.to_string())?;
|
||||
Ok(json!({ "tasks": tasks }).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels one task by id.
|
||||
struct CancelTask {
|
||||
store: Arc<TaskStore>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for CancelTask {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
"cancel_task",
|
||||
"Cancel a scheduled task by id (as list_tasks reports it). \
|
||||
Already-fired one-shots report cancelled: false.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer",
|
||||
"description": "The task's id." }
|
||||
},
|
||||
"required": ["id"]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||
let args: CancelArgs =
|
||||
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||
let removed = self.store.remove(args.id).map_err(|err| err.to_string())?;
|
||||
Ok(json!({ "cancelled": removed, "id": args.id }).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use crate::task_store::TaskKind;
|
||||
|
||||
fn store() -> Arc<TaskStore> {
|
||||
Arc::new(TaskStore::in_memory().expect("store opens"))
|
||||
}
|
||||
|
||||
async fn call(store: &Arc<TaskStore>, name: &str, args: Value) -> rhema_core::ToolResult {
|
||||
let resolver =
|
||||
registry(store.clone()).resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
|
||||
resolver
|
||||
.resolve(
|
||||
rhema_core::ToolCall {
|
||||
id: "c1".into(),
|
||||
name: name.into(),
|
||||
args,
|
||||
provider_data: Value::Null,
|
||||
},
|
||||
&rhema_core::ToolContext::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_at_lands_a_one_shot_row() {
|
||||
let store = store();
|
||||
let result = call(
|
||||
&store,
|
||||
"schedule_task",
|
||||
json!({"prompt": "remind the user: bread", "at": 2_000_000_000_000_i64}),
|
||||
)
|
||||
.await;
|
||||
assert!(!result.is_error, "{}", result.content);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&result.content).unwrap(),
|
||||
json!({"scheduled": true, "id": 1, "kind": "at", "next_due_millis": 2_000_000_000_000_i64, "session": null}),
|
||||
"{}",
|
||||
result.content
|
||||
);
|
||||
let listed = store.list().expect("list reads");
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].kind, TaskKind::At);
|
||||
assert_eq!(listed[0].prompt, "remind the user: bread");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_rfc3339_at_parses_to_millis() {
|
||||
let store = store();
|
||||
let result = call(
|
||||
&store,
|
||||
"schedule_task",
|
||||
json!({"prompt": "standup", "at": "2026-10-10T15:00:00Z"}),
|
||||
)
|
||||
.await;
|
||||
assert!(!result.is_error, "{}", result.content);
|
||||
let listed = store.list().expect("list reads");
|
||||
assert_eq!(listed[0].kind, TaskKind::At);
|
||||
assert_eq!(listed[0].next_due_millis, 1_791_644_400_000, "15:00Z");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_every_lands_a_recurring_row() {
|
||||
let store = store();
|
||||
let result = call(
|
||||
&store,
|
||||
"schedule_task",
|
||||
json!({"prompt": "stretch", "every_secs": 1800}),
|
||||
)
|
||||
.await;
|
||||
assert!(!result.is_error, "{}", result.content);
|
||||
let listed = store.list().expect("list reads");
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].kind, TaskKind::Every);
|
||||
assert_eq!(listed[0].interval_millis, Some(1_800_000));
|
||||
assert!(
|
||||
listed[0].next_due_millis > now_millis(),
|
||||
"the first firing is in the future"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bad_shapes_fail_in_band_and_land_nothing() {
|
||||
let store = store();
|
||||
for (args, reason) in [
|
||||
(
|
||||
json!({"prompt": "x", "at": 1, "every_secs": 1}),
|
||||
"exactly one",
|
||||
),
|
||||
(json!({"prompt": "x"}), "one of"),
|
||||
(json!({"prompt": " ", "at": 1}), "empty"),
|
||||
(json!({"prompt": "x", "at": "not a time"}), "RFC3339"),
|
||||
(json!({"prompt": "x", "at": [1]}), "epoch milliseconds"),
|
||||
(json!({"prompt": "x", "every_secs": 0}), "at least 1"),
|
||||
] {
|
||||
let result = call(&store, "schedule_task", args).await;
|
||||
assert!(result.is_error, "{}", result.content);
|
||||
assert!(
|
||||
result.content.contains(reason),
|
||||
"{reason} in {}",
|
||||
result.content
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
store.list().expect("list reads").is_empty(),
|
||||
"nothing landed"
|
||||
);
|
||||
}
|
||||
|
||||
/// The wake-itself shape: a session-named task lands with its
|
||||
/// delivery target, and a non-resident target is refused in band.
|
||||
#[tokio::test]
|
||||
async fn a_session_target_lands_and_a_foreign_one_is_refused() {
|
||||
let store = store();
|
||||
let result = call(
|
||||
&store,
|
||||
"schedule_task",
|
||||
json!({
|
||||
"prompt": "summarize this evening's work",
|
||||
"at": "2026-10-15T08:00:00Z",
|
||||
"session": "interactive:web",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert!(!result.is_error, "{}", result.content);
|
||||
let answer: Value = serde_json::from_str(&result.content).unwrap();
|
||||
assert_eq!(answer["session"], "interactive:web");
|
||||
assert_eq!(
|
||||
store.list().expect("reads")[0].deliver_to.as_deref(),
|
||||
Some("interactive:web")
|
||||
);
|
||||
|
||||
// A blank session reads as unset — the default task slot.
|
||||
for blank in ["", " "] {
|
||||
let accepted = call(
|
||||
&store,
|
||||
"schedule_task",
|
||||
json!({"prompt": "x", "at": 1, "session": blank}),
|
||||
)
|
||||
.await;
|
||||
assert!(!accepted.is_error, "{blank:?} reads as unset");
|
||||
assert_eq!(
|
||||
store
|
||||
.list()
|
||||
.expect("reads")
|
||||
.last()
|
||||
.expect("lands")
|
||||
.deliver_to,
|
||||
None,
|
||||
"{blank:?} schedules into the task's own slot"
|
||||
);
|
||||
}
|
||||
// A non-resident target, or a bare name, cannot be woken.
|
||||
for foreign in ["github:acme/w#7", "web"] {
|
||||
let refused = call(
|
||||
&store,
|
||||
"schedule_task",
|
||||
json!({"prompt": "x", "at": 1, "session": foreign}),
|
||||
)
|
||||
.await;
|
||||
assert!(refused.is_error, "{foreign:?} must be refused");
|
||||
}
|
||||
assert_eq!(
|
||||
store
|
||||
.list()
|
||||
.expect("reads")
|
||||
.iter()
|
||||
.filter(|task| task.deliver_to.is_some())
|
||||
.count(),
|
||||
1,
|
||||
"the named session is the only targeted row"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_and_cancel_round_trip_through_the_table() {
|
||||
let store = store();
|
||||
store.schedule_at("one", 42, 0, None).expect("seed");
|
||||
store.schedule_every("two", 1_000, 0, None).expect("seed");
|
||||
|
||||
let listed = call(&store, "list_tasks", json!({})).await;
|
||||
assert!(!listed.is_error, "{}", listed.content);
|
||||
let value: Value = serde_json::from_str(&listed.content).unwrap();
|
||||
assert_eq!(value["tasks"].as_array().map(Vec::len), Some(2));
|
||||
assert_eq!(value["tasks"][0]["kind"], "at");
|
||||
assert_eq!(value["tasks"][1]["kind"], "every");
|
||||
|
||||
let cancelled = call(&store, "cancel_task", json!({"id": 1})).await;
|
||||
assert!(!cancelled.is_error, "{}", cancelled.content);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&cancelled.content).unwrap(),
|
||||
json!({"cancelled": true, "id": 1})
|
||||
);
|
||||
let already_gone = call(&store, "cancel_task", json!({"id": 1})).await;
|
||||
assert!(!already_gone.is_error, "{}", already_gone.content);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&already_gone.content).unwrap(),
|
||||
json!({"cancelled": false, "id": 1})
|
||||
);
|
||||
assert_eq!(store.list().expect("list reads").len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! The triage: what a notification earns, decided deterministically
|
||||
//! before any model call (spec §1 — the model is never the detector).
|
||||
//!
|
||||
//! A [`Notification`] carries the forge's own reason for bothering the
|
||||
//! account; [`classify`] reduces it to one of three verdicts. An
|
||||
//! [`Triage::Agent`] dispatch becomes an RSD event whose payload names
|
||||
//! the [`Role`] — the persona the handler's prompt opens with, so one
|
||||
//! thread can move from review-requested to implement-feedback across
|
||||
//! dispatches with the session carrying the continuity. A
|
||||
//! [`Triage::Surface`] never dispatches an agent: a mention is not a
|
||||
//! assignment, and the account's human hears about it through the
|
||||
//! device-notification queue instead. [`Triage::Ignore`] drops the noise
|
||||
//! (CI churn) on the floor.
|
||||
//!
|
||||
//! The mapping is a pure function and this module's tests pin it: the
|
||||
//! forges' vocabularies are external data, and the policy must not drift
|
||||
//! with them.
|
||||
|
||||
use rhema_forge_integration::{Notification, NotificationReason};
|
||||
|
||||
/// The agent persona a dispatched notification runs as. The key stays
|
||||
/// the thread — one session, one voice per thread, ever — and the role
|
||||
/// rides the dispatch: it selects the prompt's opening framing and is
|
||||
/// the seam where per-role tool policies land later.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
/// Asked to review: judge the pull request and post the review.
|
||||
Reviewer,
|
||||
/// Asked to do the work: implement the issue or answer the feedback.
|
||||
Implementer,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
/// The payload and prompt spelling.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Role::Reviewer => "reviewer",
|
||||
Role::Implementer => "implementer",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What one notification earns.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Triage {
|
||||
/// Dispatch the thread's agent as this role.
|
||||
Agent(Role),
|
||||
/// No agent: surface the notification to the account's human through
|
||||
/// the device-notification queue.
|
||||
Surface,
|
||||
/// Drop it: noise the account should not hear about.
|
||||
Ignore,
|
||||
}
|
||||
|
||||
/// Classifies one notification. A forge reason the vocabulary does not
|
||||
/// know ([`NotificationReason::Other`]) surfaces rather than acts: an
|
||||
/// unknown shape is the user's to judge, never the agent's to guess at.
|
||||
pub fn classify(notification: &Notification) -> Triage {
|
||||
match ¬ification.reason {
|
||||
NotificationReason::ReviewRequested => Triage::Agent(Role::Reviewer),
|
||||
NotificationReason::Assigned => Triage::Agent(Role::Implementer),
|
||||
NotificationReason::ReviewFeedback => Triage::Agent(Role::Implementer),
|
||||
NotificationReason::Author => Triage::Agent(Role::Implementer),
|
||||
NotificationReason::Mention => Triage::Surface,
|
||||
NotificationReason::CiActivity => Triage::Ignore,
|
||||
NotificationReason::Other(_) => Triage::Surface,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rhema_forge_integration::{RepoRef, Timestamp};
|
||||
|
||||
fn notification(reason: NotificationReason) -> Notification {
|
||||
Notification {
|
||||
id: format!("github:notification:1@{}", reason.as_str()),
|
||||
reason,
|
||||
repo: RepoRef::new("acme", "widgets"),
|
||||
number: 7,
|
||||
is_pr: true,
|
||||
actor: None,
|
||||
title: None,
|
||||
url: None,
|
||||
timestamp: Timestamp::parse("2026-10-08T00:00:00Z").expect("parses"),
|
||||
raw: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asks_become_agent_dispatches_of_the_matching_role() {
|
||||
assert_eq!(
|
||||
classify(¬ification(NotificationReason::ReviewRequested)),
|
||||
Triage::Agent(Role::Reviewer)
|
||||
);
|
||||
for reason in [
|
||||
NotificationReason::Assigned,
|
||||
NotificationReason::ReviewFeedback,
|
||||
NotificationReason::Author,
|
||||
] {
|
||||
let triage = classify(¬ification(reason.clone()));
|
||||
assert_eq!(
|
||||
triage,
|
||||
Triage::Agent(Role::Implementer),
|
||||
"{} implements",
|
||||
reason.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mentions_surface_ci_ignores_and_unknowns_surface() {
|
||||
assert_eq!(
|
||||
classify(¬ification(NotificationReason::Mention)),
|
||||
Triage::Surface
|
||||
);
|
||||
assert_eq!(
|
||||
classify(¬ification(NotificationReason::CiActivity)),
|
||||
Triage::Ignore
|
||||
);
|
||||
assert_eq!(
|
||||
classify(¬ification(NotificationReason::Other(
|
||||
"subscription".to_owned()
|
||||
))),
|
||||
Triage::Surface
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roles_round_trip_through_serde() {
|
||||
for role in [Role::Reviewer, Role::Implementer] {
|
||||
let encoded = serde_json::to_value(role).expect("serializes");
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Role>(encoded).expect("deserializes"),
|
||||
role
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! The turn-history table: one durable row per streamed event, in the
|
||||
//! same database file the dispatcher's
|
||||
//! [`SqliteStore`](rhema_rsd::SqliteStore) and the other claw tables
|
||||
//! use.
|
||||
//!
|
||||
//! The table is append-only and unbounded, on purpose: it keeps the
|
||||
//! *transcript* — what a viewer saw, tool rows and thinking traces
|
||||
//! included — and a resident agent's sessions run for weeks, so a
|
||||
//! rolling window would eat history no client could ever get back. The
|
||||
//! event hub appends every event here as it streams and replays from
|
||||
//! here on subscribe; the session stash keeps the *conversation* (what
|
||||
//! the model sees). A pre-window table (`turn_history`, one JSON blob
|
||||
//! per key rewritten whole) migrates once, at open, into this shape.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
use rusqlite::params;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::hub::TurnEvent;
|
||||
|
||||
/// The turn-history table.
|
||||
const SCHEMA: &str = "
|
||||
CREATE TABLE IF NOT EXISTS turn_events (
|
||||
key TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
PRIMARY KEY (key, seq)
|
||||
);
|
||||
";
|
||||
|
||||
/// Why a turn-history operation failed. A row whose JSON does not parse
|
||||
/// is its own variant: a transcript that cannot replay must say so, not
|
||||
/// silently read as "no history".
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TurnStoreError {
|
||||
/// The SQLite backend failed.
|
||||
#[error("turn history backend: {0}")]
|
||||
Backend(#[from] rusqlite::Error),
|
||||
/// A stored event is not a turn event.
|
||||
#[error("turn history for {key} does not parse: {source}")]
|
||||
Corrupt {
|
||||
/// The key whose row failed.
|
||||
key: String,
|
||||
/// The parse failure.
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// The persistent turn-history table over its own connection to the
|
||||
/// daemon's SQLite file. Shareable behind an `Arc`; every operation
|
||||
/// takes `&self`.
|
||||
pub struct TurnStore {
|
||||
conn: Mutex<rusqlite::Connection>,
|
||||
}
|
||||
|
||||
impl TurnStore {
|
||||
/// Opens (creating if needed) the turn-history table in the database
|
||||
/// at `path`, migrating a legacy whole-blob table if one is present.
|
||||
/// WAL keeps the connections from blocking each other's readers; the
|
||||
/// busy timeout rides out the other's write locks.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, TurnStoreError> {
|
||||
let conn = rusqlite::Connection::open(path).map_err(TurnStoreError::from)?;
|
||||
Self::init(conn, true)
|
||||
}
|
||||
|
||||
/// Opens a private in-memory table: the shape without the file, for
|
||||
/// tests that need no sharing.
|
||||
pub fn in_memory() -> Result<Self, TurnStoreError> {
|
||||
let conn = rusqlite::Connection::open_in_memory().map_err(TurnStoreError::from)?;
|
||||
Self::init(conn, false)
|
||||
}
|
||||
|
||||
fn init(conn: rusqlite::Connection, durable: bool) -> Result<Self, TurnStoreError> {
|
||||
if durable {
|
||||
// Same pragmas as the dispatcher's store on the same file:
|
||||
// SQLite stores the journal mode per database, and both
|
||||
// connections must agree that it is WAL.
|
||||
let _mode: String = conn
|
||||
.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
|
||||
.map_err(TurnStoreError::from)?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))
|
||||
.map_err(TurnStoreError::from)?;
|
||||
}
|
||||
conn.execute_batch(SCHEMA).map_err(TurnStoreError::from)?;
|
||||
migrate_legacy_blob_table(&conn)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// Appends `events` to `key`'s stream, in order, durable before it
|
||||
/// returns. The stream's sequence is the table's, so appends extend
|
||||
/// and never rewrite what a replay already read.
|
||||
pub fn append(&self, key: &str, events: &[TurnEvent]) -> Result<(), TurnStoreError> {
|
||||
if events.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let tx = conn.transaction().map_err(TurnStoreError::from)?;
|
||||
let next: i64 = tx
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(seq) + 1, 0) FROM turn_events WHERE key = ?1",
|
||||
params![key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(TurnStoreError::from)?;
|
||||
for (offset, event) in events.iter().enumerate() {
|
||||
let json = serde_json::to_string(event).map_err(|err| TurnStoreError::Corrupt {
|
||||
key: key.to_owned(),
|
||||
source: err,
|
||||
})?;
|
||||
tx.execute(
|
||||
"INSERT INTO turn_events (key, seq, event) VALUES (?1, ?2, ?3)",
|
||||
params![key, next + offset as i64, json],
|
||||
)
|
||||
.map_err(TurnStoreError::from)?;
|
||||
}
|
||||
tx.commit().map_err(TurnStoreError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `key`'s whole recorded stream, oldest first — the replay a fresh
|
||||
/// subscriber folds before the live feed.
|
||||
pub fn events(&self, key: &str) -> Result<Vec<TurnEvent>, TurnStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut stmt = conn.prepare("SELECT event FROM turn_events WHERE key = ?1 ORDER BY seq")?;
|
||||
let rows = stmt.query_map(params![key], |row| row.get::<_, String>(0))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let json = row.map_err(TurnStoreError::from)?;
|
||||
out.push(parse(key, &json)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves a legacy `turn_history` table (one JSON-array blob per key,
|
||||
/// rewritten whole) into per-event rows, then drops it. A blob that no
|
||||
/// longer parses is skipped with a warning: half a migration beats a
|
||||
/// boot that cannot open its history at all. No-op when the table is
|
||||
/// absent (every database this code created fresh).
|
||||
fn migrate_legacy_blob_table(conn: &rusqlite::Connection) -> Result<(), TurnStoreError> {
|
||||
let legacy: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'turn_history'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.map(|count| count > 0)
|
||||
.map_err(TurnStoreError::from)?;
|
||||
if !legacy {
|
||||
return Ok(());
|
||||
}
|
||||
let mut stmt = conn.prepare("SELECT key, events FROM turn_history")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?;
|
||||
let mut migrated = 0usize;
|
||||
for row in rows {
|
||||
let (key, blob) = row.map_err(TurnStoreError::from)?;
|
||||
let events: Vec<Value> = match serde_json::from_str(&blob) {
|
||||
Ok(events) => events,
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"[claw] warning: legacy transcript for {key} is unreadable, dropped: {err}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for (seq, event) in events.into_iter().enumerate() {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO turn_events (key, seq, event) VALUES (?1, ?2, ?3)",
|
||||
params![key, seq as i64, event.to_string()],
|
||||
)
|
||||
.map_err(TurnStoreError::from)?;
|
||||
}
|
||||
migrated += 1;
|
||||
}
|
||||
drop(stmt);
|
||||
conn.execute("DROP TABLE turn_history", [])
|
||||
.map_err(TurnStoreError::from)?;
|
||||
if migrated > 0 {
|
||||
eprintln!("[claw] migrated {migrated} legacy transcript(s) to the append-only table");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parses one stored event. The JSON value step keeps the error message
|
||||
/// about *what* the row holds, not just that serde choked.
|
||||
fn parse(key: &str, json: &str) -> Result<TurnEvent, TurnStoreError> {
|
||||
let value: Value = serde_json::from_str(json).map_err(|source| TurnStoreError::Corrupt {
|
||||
key: key.to_owned(),
|
||||
source,
|
||||
})?;
|
||||
serde_json::from_value(value).map_err(|source| TurnStoreError::Corrupt {
|
||||
key: key.to_owned(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn text_delta(text: &str) -> TurnEvent {
|
||||
TurnEvent::TextDelta {
|
||||
text: text.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_appended_history_round_trips_through_events() {
|
||||
let store = TurnStore::in_memory().expect("store opens");
|
||||
assert_eq!(store.events("interactive:web").expect("reads"), vec![]);
|
||||
|
||||
store
|
||||
.append(
|
||||
"interactive:web",
|
||||
&[
|
||||
TurnEvent::UserMessage {
|
||||
text: "hello".into(),
|
||||
},
|
||||
text_delta("hi"),
|
||||
],
|
||||
)
|
||||
.expect("appends");
|
||||
store
|
||||
.append(
|
||||
"interactive:web",
|
||||
&[TurnEvent::TurnDone {
|
||||
reply: Some("hi".into()),
|
||||
outcome: "handled".into(),
|
||||
}],
|
||||
)
|
||||
.expect("appends again");
|
||||
assert_eq!(
|
||||
store.events("interactive:web").expect("reads"),
|
||||
vec![
|
||||
TurnEvent::UserMessage {
|
||||
text: "hello".into()
|
||||
},
|
||||
text_delta("hi"),
|
||||
TurnEvent::TurnDone {
|
||||
reply: Some("hi".into()),
|
||||
outcome: "handled".into(),
|
||||
},
|
||||
],
|
||||
"appends extend the stream in order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_append_is_quiet() {
|
||||
let store = TurnStore::in_memory().expect("store opens");
|
||||
store
|
||||
.append("interactive:web", &[])
|
||||
.expect("an empty append is a no-op");
|
||||
assert_eq!(store.events("interactive:web").expect("reads"), vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_separate_streams() {
|
||||
let store = TurnStore::in_memory().expect("store opens");
|
||||
store.append("task:2", &[text_delta("b")]).expect("appends");
|
||||
store
|
||||
.append("interactive:web", &[text_delta("a")])
|
||||
.expect("appends");
|
||||
assert_eq!(
|
||||
store.events("task:2").expect("reads"),
|
||||
vec![text_delta("b")],
|
||||
"one key's stream never leaks into another's"
|
||||
);
|
||||
assert_eq!(store.events("interactive:web").expect("reads").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_event_row_is_loud_not_empty() {
|
||||
let store = TurnStore::in_memory().expect("store opens");
|
||||
{
|
||||
let conn = store.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
conn.execute(
|
||||
"INSERT INTO turn_events (key, seq, event) VALUES ('interactive:web', 0, 'garbage')",
|
||||
[],
|
||||
)
|
||||
.expect("writes raw");
|
||||
}
|
||||
let err = store
|
||||
.events("interactive:web")
|
||||
.expect_err("corruption is loud");
|
||||
assert!(err.to_string().contains("interactive:web"), "{err}");
|
||||
}
|
||||
|
||||
/// The one-time migration: a pre-window blob table opens into the
|
||||
/// append-only shape, its rows gone.
|
||||
#[test]
|
||||
fn a_legacy_blob_table_migrates_into_event_rows() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("dispatch.db");
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&path).expect("opens");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE turn_history (
|
||||
key TEXT PRIMARY KEY,
|
||||
events TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO turn_history VALUES ('interactive:web',
|
||||
'[{\"type\":\"text_delta\",\"text\":\"old days\"}]', 1);
|
||||
INSERT INTO turn_history VALUES ('interactive:broke', 'not json', 2);",
|
||||
)
|
||||
.expect("legacy table");
|
||||
}
|
||||
let store = TurnStore::open(&path).expect("opens and migrates");
|
||||
assert_eq!(
|
||||
store.events("interactive:web").expect("reads"),
|
||||
vec![text_delta("old days")],
|
||||
"the readable blob migrated"
|
||||
);
|
||||
assert_eq!(store.events("interactive:broke").expect("reads"), vec![]);
|
||||
{
|
||||
let conn = store.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let legacy: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE name = 'turn_history'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("reads");
|
||||
assert_eq!(legacy, 0, "the legacy table is gone");
|
||||
}
|
||||
// The migrated rows keep appending after the migration.
|
||||
store
|
||||
.append("interactive:web", &[text_delta("new days")])
|
||||
.expect("appends");
|
||||
assert_eq!(store.events("interactive:web").expect("reads").len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
//! The watch table: agent-managed watches in SQLite, the same database
|
||||
//! file the dispatcher's [`SqliteStore`](rhema_rsd::SqliteStore) uses.
|
||||
//!
|
||||
//! Watches are data the daemon reconfigures at runtime — the chat
|
||||
//! surface's [`watch_repo`](crate::watch_tools) tool and the HTTP
|
||||
//! `POST /v1/watches` route both land here — so the table is read fresh
|
||||
//! on every detect pass instead of living only in the YAML config. The
|
||||
//! config's `watches:` list is a one-time seed ([`ClawStore::
|
||||
//! seed_from_config`]): imported when the table is still empty, ignored
|
||||
//! once the table has rows.
|
||||
//!
|
||||
//! Two connections, one file: this store opens its own rusqlite
|
||||
//! connection to the same database as the dispatcher's store — WAL and a
|
||||
//! busy timeout, the pattern rhema-rsd's SQLite tests use — and the two
|
||||
//! interleave through SQLite's own locking. Writes are single INSERT /
|
||||
//! DELETE statements, so there is no read-modify-write window to guard.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rhema_forge_integration::RepoRef;
|
||||
use rusqlite::params;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::config::{ForgeKind, Watch};
|
||||
|
||||
/// The watch table, and the account-watch table beside it: one row per
|
||||
/// identity whose forge notifications the daemon polls. An account watch
|
||||
/// is derived state — it names the [`rhema_identity`] account the
|
||||
/// notifications belong to, and its token is resolved through the
|
||||
/// identity store at poll time, never stored here.
|
||||
const SCHEMA: &str = "
|
||||
CREATE TABLE IF NOT EXISTS watches (
|
||||
id INTEGER PRIMARY KEY,
|
||||
forge TEXT NOT NULL,
|
||||
repo TEXT NOT NULL,
|
||||
host TEXT,
|
||||
token TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(forge, repo)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS account_watches (
|
||||
id INTEGER PRIMARY KEY,
|
||||
forge TEXT NOT NULL,
|
||||
host TEXT,
|
||||
account_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(forge, account_id)
|
||||
);
|
||||
";
|
||||
|
||||
/// Why a watch-table operation failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WatchStoreError {
|
||||
/// The SQLite backend failed.
|
||||
#[error("watch store backend: {0}")]
|
||||
Backend(#[from] rusqlite::Error),
|
||||
}
|
||||
|
||||
/// One watched repository, as stored. `token` never serializes: it
|
||||
/// reaches the forge clients, not the wire or the model.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct WatchRecord {
|
||||
/// The row id.
|
||||
pub id: i64,
|
||||
/// The forge's source name: `github`, `gitea`, or `gitlab`.
|
||||
pub forge: String,
|
||||
/// The repository, `owner/name` (GitLab: `namespace/project`).
|
||||
pub repo: String,
|
||||
/// The instance base URL; `None` for the forges with production
|
||||
/// bases. Required for gitea, which is self-hosted only.
|
||||
pub host: Option<String>,
|
||||
/// The forge token; blank means the forge's environment variable is
|
||||
/// consulted at poll time. Kept out of every serialized form.
|
||||
#[serde(skip_serializing)]
|
||||
pub token: String,
|
||||
/// Creation time, milliseconds since the Unix epoch.
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl WatchRecord {
|
||||
/// The repository coordinates, splitting `owner/name` — the same
|
||||
/// shape check a config watch passes. GitLab's owner keeps its full
|
||||
/// namespace path, which is why the split is lazy.
|
||||
pub fn repo_ref(&self) -> Result<RepoRef, String> {
|
||||
let (owner, name) = self
|
||||
.repo
|
||||
.split_once('/')
|
||||
.ok_or_else(|| format!("repo must be owner/name, got {:?}", self.repo))?;
|
||||
if owner.is_empty() || name.is_empty() {
|
||||
return Err(format!("repo must be owner/name, got {:?}", self.repo));
|
||||
}
|
||||
Ok(RepoRef::new(owner, name))
|
||||
}
|
||||
}
|
||||
|
||||
/// The persistent watch table over its own connection to the daemon's
|
||||
/// SQLite file. Shareable behind an `Arc`; every operation takes `&self`.
|
||||
pub struct ClawStore {
|
||||
conn: Mutex<rusqlite::Connection>,
|
||||
}
|
||||
|
||||
/// One watched identity: the daemon polls this forge's notifications as
|
||||
/// the named account. `account_id` is the [`rhema_identity`] store's id —
|
||||
/// the credential stays there, never in this table.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct AccountWatchRecord {
|
||||
/// The row id.
|
||||
pub id: i64,
|
||||
/// The forge's source name: `github`, `gitea`, or `gitlab`.
|
||||
pub forge: String,
|
||||
/// The instance base URL; `None` for the forges with production
|
||||
/// bases. Required for gitea, which is self-hosted only.
|
||||
pub host: Option<String>,
|
||||
/// The identity store's account id the watch polls as.
|
||||
pub account_id: String,
|
||||
/// Creation time, milliseconds since the Unix epoch.
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl ClawStore {
|
||||
/// Opens (creating if needed) the watch table in the database at
|
||||
/// `path` — the same file the dispatcher's store holds. WAL keeps the
|
||||
/// two connections from blocking each other's readers; the busy
|
||||
/// timeout rides out the other's write locks.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, WatchStoreError> {
|
||||
let conn = rusqlite::Connection::open(path).map_err(WatchStoreError::from)?;
|
||||
Self::init(conn, true)
|
||||
}
|
||||
|
||||
/// Opens a private in-memory table: the shape without the file, for
|
||||
/// tests that need no sharing.
|
||||
pub fn in_memory() -> Result<Self, WatchStoreError> {
|
||||
let conn = rusqlite::Connection::open_in_memory().map_err(WatchStoreError::from)?;
|
||||
Self::init(conn, false)
|
||||
}
|
||||
|
||||
fn init(conn: rusqlite::Connection, durable: bool) -> Result<Self, WatchStoreError> {
|
||||
if durable {
|
||||
// Same pragmas as the dispatcher's store on the same file:
|
||||
// SQLite stores the journal mode per database, and both
|
||||
// connections must agree that it is WAL.
|
||||
let _mode: String = conn
|
||||
.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
|
||||
.map_err(WatchStoreError::from)?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))
|
||||
.map_err(WatchStoreError::from)?;
|
||||
}
|
||||
conn.execute_batch(SCHEMA).map_err(WatchStoreError::from)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// Adds a watch. `false` when `(forge, repo)` is already watched —
|
||||
/// the unique index turns a re-add into a no-op, so callers can be
|
||||
/// liberal.
|
||||
pub fn add(
|
||||
&self,
|
||||
forge: &str,
|
||||
repo: &str,
|
||||
host: Option<&str>,
|
||||
token: &str,
|
||||
) -> Result<bool, WatchStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let inserted = conn.execute(
|
||||
"INSERT INTO watches (forge, repo, host, token, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(forge, repo) DO NOTHING",
|
||||
params![
|
||||
forge.trim(),
|
||||
repo.trim(),
|
||||
host.map(str::trim),
|
||||
token.trim(),
|
||||
now_millis()
|
||||
],
|
||||
)?;
|
||||
Ok(inserted == 1)
|
||||
}
|
||||
|
||||
/// Removes the watch for `(forge, repo)`; `false` when there was
|
||||
/// none.
|
||||
pub fn remove(&self, forge: &str, repo: &str) -> Result<bool, WatchStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let removed = conn.execute(
|
||||
"DELETE FROM watches WHERE forge = ?1 AND repo = ?2",
|
||||
params![forge.trim(), repo.trim()],
|
||||
)?;
|
||||
Ok(removed == 1)
|
||||
}
|
||||
|
||||
/// Every watch, oldest first. Read fresh by every detect pass, so a
|
||||
/// watch added at runtime takes effect without a restart.
|
||||
pub fn list(&self) -> Result<Vec<WatchRecord>, WatchStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, forge, repo, host, token, created_at FROM watches ORDER BY id ASC")
|
||||
.map_err(WatchStoreError::from)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(WatchRecord {
|
||||
id: row.get(0)?,
|
||||
forge: row.get(1)?,
|
||||
repo: row.get(2)?,
|
||||
host: row.get(3)?,
|
||||
token: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
})
|
||||
.map_err(WatchStoreError::from)?
|
||||
.collect::<Result<Vec<_>, rusqlite::Error>>()
|
||||
.map_err(WatchStoreError::from)?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The config seed: imports `watches` when the table is empty — the
|
||||
/// one-time bootstrap of a fresh deployment — and reports how many
|
||||
/// landed. A populated table is never rewritten by the seed: once
|
||||
/// any watch exists, the table rules and the config list is inert.
|
||||
/// Only the config's own token is stored; the forge's environment
|
||||
/// variable still serves at poll time when the stored one is blank.
|
||||
pub fn seed_from_config(&self, watches: &[Watch]) -> Result<usize, WatchStoreError> {
|
||||
if !self.list()?.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut seeded = 0;
|
||||
for watch in watches {
|
||||
let token = watch.token.as_deref().map(str::trim).unwrap_or("");
|
||||
if self.add(
|
||||
watch.forge.name(),
|
||||
&watch.repo,
|
||||
watch.host.as_deref(),
|
||||
token,
|
||||
)? {
|
||||
seeded += 1;
|
||||
}
|
||||
}
|
||||
Ok(seeded)
|
||||
}
|
||||
|
||||
/// Registers the account watch: the daemon polls `forge`'s
|
||||
/// notifications as the account. `false` when the account is already
|
||||
/// watched on that forge. The identity's own secret is *not* passed
|
||||
/// here — the watch names the account, and the token is resolved
|
||||
/// through the identity store at poll time.
|
||||
pub fn add_account_watch(
|
||||
&self,
|
||||
forge: &str,
|
||||
host: Option<&str>,
|
||||
account_id: &str,
|
||||
) -> Result<bool, WatchStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let inserted = conn.execute(
|
||||
"INSERT INTO account_watches (forge, host, account_id, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(forge, account_id) DO NOTHING",
|
||||
params![
|
||||
forge.trim(),
|
||||
host.map(str::trim),
|
||||
account_id.trim(),
|
||||
now_millis()
|
||||
],
|
||||
)?;
|
||||
Ok(inserted == 1)
|
||||
}
|
||||
|
||||
/// Removes the account watch for `(forge, account_id)`; `false` when
|
||||
/// there was none.
|
||||
pub fn remove_account_watch(
|
||||
&self,
|
||||
forge: &str,
|
||||
account_id: &str,
|
||||
) -> Result<bool, WatchStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let removed = conn.execute(
|
||||
"DELETE FROM account_watches WHERE forge = ?1 AND account_id = ?2",
|
||||
params![forge.trim(), account_id.trim()],
|
||||
)?;
|
||||
Ok(removed == 1)
|
||||
}
|
||||
|
||||
/// Removes every account watch of one account — the cascade its
|
||||
/// `identity_forget` runs. Returns how many rows went.
|
||||
pub fn remove_account_watches_for(&self, account_id: &str) -> Result<usize, WatchStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let removed = conn.execute(
|
||||
"DELETE FROM account_watches WHERE account_id = ?1",
|
||||
params![account_id.trim()],
|
||||
)?;
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Every account watch, oldest first. Read fresh by every detect
|
||||
/// pass, like the repo watches.
|
||||
pub fn list_account_watches(&self) -> Result<Vec<AccountWatchRecord>, WatchStoreError> {
|
||||
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, forge, host, account_id, created_at
|
||||
FROM account_watches ORDER BY id ASC",
|
||||
)
|
||||
.map_err(WatchStoreError::from)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(AccountWatchRecord {
|
||||
id: row.get(0)?,
|
||||
forge: row.get(1)?,
|
||||
host: row.get(2)?,
|
||||
account_id: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
})
|
||||
})
|
||||
.map_err(WatchStoreError::from)?
|
||||
.collect::<Result<Vec<_>, rusqlite::Error>>()
|
||||
.map_err(WatchStoreError::from)?;
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
|
||||
/// Milliseconds since the Unix epoch; stamps `created_at`.
|
||||
fn now_millis() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
/// The shape checks every watch must pass, wherever it enters (the
|
||||
/// config seed, the HTTP route, the agent tool): a parseable forge, an
|
||||
/// `owner/name` repo, and — gitea being self-hosted only — a host for
|
||||
/// gitea. Tokens are not required here: a blank one falls back to the
|
||||
/// forge's environment variable at poll time.
|
||||
pub fn validate_watch(forge: ForgeKind, repo: &str, host: Option<&str>) -> Result<(), String> {
|
||||
let watch = Watch {
|
||||
forge,
|
||||
repo: repo.trim().to_owned(),
|
||||
host: host.map(str::trim).map(str::to_owned),
|
||||
token: None,
|
||||
};
|
||||
watch.repo_ref()?;
|
||||
if forge == ForgeKind::Gitea && watch.host.as_deref().filter(|h| !h.is_empty()).is_none() {
|
||||
return Err("gitea is self-hosted: set host to the instance base URL".to_owned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_watch_round_trips_through_the_table() {
|
||||
let store = ClawStore::in_memory().expect("store opens");
|
||||
assert!(
|
||||
store
|
||||
.add("github", "acme/widgets", None, "tok")
|
||||
.expect("add lands")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.add("gitea", "acme/barn", Some("https://git.example.net"), "")
|
||||
.expect("add lands")
|
||||
);
|
||||
|
||||
let watches = store.list().expect("list reads");
|
||||
assert_eq!(watches.len(), 2);
|
||||
assert_eq!(watches[0].forge, "github");
|
||||
assert_eq!(watches[0].repo, "acme/widgets");
|
||||
assert_eq!(watches[0].host, None);
|
||||
assert_eq!(watches[0].token, "tok");
|
||||
assert_eq!(watches[1].host.as_deref(), Some("https://git.example.net"));
|
||||
assert_eq!(watches[1].token, "", "a missing token stores blank");
|
||||
|
||||
assert!(store.remove("github", "acme/widgets").expect("remove"));
|
||||
assert!(!store.remove("github", "acme/widgets").expect("re-remove"));
|
||||
assert_eq!(store.list().expect("list reads").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_re_add_is_a_no_op_not_an_error() {
|
||||
let store = ClawStore::in_memory().expect("store opens");
|
||||
assert!(
|
||||
store
|
||||
.add("gitlab", "group/sub/prop", None, "")
|
||||
.expect("first")
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.add("gitlab", "group/sub/prop", None, "")
|
||||
.expect("second")
|
||||
);
|
||||
assert_eq!(store.list().expect("list reads").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_repo_ref_splits_owner_from_name_lazily() {
|
||||
let store = ClawStore::in_memory().expect("store opens");
|
||||
store
|
||||
.add("gitlab", "group/subgroup/widgets", None, "")
|
||||
.expect("add");
|
||||
let record = &store.list().expect("list reads")[0];
|
||||
assert_eq!(
|
||||
record.repo_ref().expect("parses").path(),
|
||||
"group/subgroup/widgets"
|
||||
);
|
||||
let bad = WatchRecord {
|
||||
id: 0,
|
||||
forge: "github".into(),
|
||||
repo: "ownerless".into(),
|
||||
host: None,
|
||||
token: String::new(),
|
||||
created_at: 0,
|
||||
};
|
||||
assert!(bad.repo_ref().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_seed_imports_only_an_empty_table() {
|
||||
let store = ClawStore::in_memory().expect("store opens");
|
||||
let watches = vec![Watch {
|
||||
forge: ForgeKind::GitHub,
|
||||
repo: "acme/widgets".to_owned(),
|
||||
host: None,
|
||||
token: Some(" ".to_owned()),
|
||||
}];
|
||||
assert_eq!(
|
||||
store.seed_from_config(&watches).expect("seeds"),
|
||||
1,
|
||||
"the empty table takes the seed"
|
||||
);
|
||||
assert_eq!(
|
||||
store.list().expect("list reads")[0].token,
|
||||
"",
|
||||
"blank stays blank"
|
||||
);
|
||||
assert_eq!(
|
||||
store.seed_from_config(&watches).expect("skips"),
|
||||
0,
|
||||
"a populated table ignores the seed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_checks_shape_and_giteas_host() {
|
||||
assert!(validate_watch(ForgeKind::GitHub, "acme/widgets", None).is_ok());
|
||||
assert!(validate_watch(ForgeKind::GitHub, "ownerless", None).is_err());
|
||||
assert!(validate_watch(ForgeKind::GitHub, "acme/", None).is_err());
|
||||
assert!(validate_watch(ForgeKind::Gitea, "acme/barn", None).is_err());
|
||||
assert!(validate_watch(ForgeKind::Gitea, "acme/barn", Some(" ")).is_err());
|
||||
assert!(
|
||||
validate_watch(
|
||||
ForgeKind::Gitea,
|
||||
"acme/barn",
|
||||
Some("https://git.example.net")
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
validate_watch(ForgeKind::GitLab, "group/subgroup/widgets", None).is_ok(),
|
||||
"gitlab has a production base"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_never_serializes() {
|
||||
let store = ClawStore::in_memory().expect("store opens");
|
||||
store
|
||||
.add("github", "acme/widgets", None, "sekrit")
|
||||
.expect("add");
|
||||
let json =
|
||||
serde_json::to_string(&store.list().expect("list reads")[0]).expect("serializes");
|
||||
assert!(!json.contains("sekrit"), "token leaked: {json}");
|
||||
assert!(json.contains("acme/widgets"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_connections_share_one_file() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("dispatch.db");
|
||||
let writer = ClawStore::open(&path).expect("first connection");
|
||||
let reader = ClawStore::open(&path).expect("second connection");
|
||||
writer.add("github", "acme/widgets", None, "").expect("add");
|
||||
let watches = reader.list().expect("the other connection sees it");
|
||||
assert_eq!(watches.len(), 1);
|
||||
assert_eq!(watches[0].repo, "acme/widgets");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
//! The agent-side watch tools: how the resident agent reconfigures its
|
||||
//! own watch list mid-conversation — "watch this repo too" becomes a
|
||||
//! tool call, the row lands in the [`ClawStore`], and the next detect
|
||||
//! pass picks it up. No restart, no second mechanism.
|
||||
//!
|
||||
//! These are [`Tool`] implementations for a [`ToolRegistry`], so the
|
||||
//! interactive handler offers them through the same allowlist-filtered
|
||||
//! seam as everything else the model may call (spec §8: the allowlist is
|
||||
//! fixed before the first step, never decided mid-run). Answers are
|
||||
//! short JSON; failures are in-band strings the model can react to.
|
||||
//! Tokens never appear in an answer.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
|
||||
use rhema_core::ToolSpec;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::config::ForgeKind;
|
||||
use crate::watch_store::{ClawStore, validate_watch};
|
||||
|
||||
/// The watch-management tool set over `store`: `watch_repo`,
|
||||
/// `unwatch_repo`, `list_watches`.
|
||||
pub fn registry(store: Arc<ClawStore>) -> ToolRegistry {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(WatchRepo {
|
||||
store: store.clone(),
|
||||
}));
|
||||
registry.register(Arc::new(UnwatchRepo {
|
||||
store: store.clone(),
|
||||
}));
|
||||
registry.register(Arc::new(ListWatches { store }));
|
||||
registry
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WatchArgs {
|
||||
forge: String,
|
||||
repo: String,
|
||||
#[serde(default)]
|
||||
host: Option<String>,
|
||||
#[serde(default)]
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UnwatchArgs {
|
||||
forge: String,
|
||||
repo: String,
|
||||
}
|
||||
|
||||
/// Starts watching: one row in the table.
|
||||
struct WatchRepo {
|
||||
store: Arc<ClawStore>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WatchRepo {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
"watch_repo",
|
||||
"Start watching a repository: new activity (issues, pull \
|
||||
requests, comments, reviews) arrives as its own dispatch. \
|
||||
`forge` is `github`, `gitea`, or `gitlab`; `repo` is \
|
||||
`owner/name` (GitLab: `namespace/project`); gitea also needs \
|
||||
`host`, its instance base URL. `token` is optional and falls \
|
||||
back to the forge's environment variable.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"forge": { "type": "string",
|
||||
"description": "github, gitea, or gitlab." },
|
||||
"repo": { "type": "string",
|
||||
"description": "The repository as owner/name." },
|
||||
"host": { "type": "string",
|
||||
"description": "Instance base URL; required for gitea." },
|
||||
"token": { "type": "string",
|
||||
"description": "Forge token; optional, env fallback." }
|
||||
},
|
||||
"required": ["forge", "repo"]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||
let args: WatchArgs =
|
||||
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||
let kind = ForgeKind::parse(&args.forge)
|
||||
.ok_or_else(|| format!("unknown forge {:?}: github, gitea, or gitlab", args.forge))?;
|
||||
let host = args.host.as_deref().map(str::trim);
|
||||
validate_watch(kind, &args.repo, host)?;
|
||||
let added = self
|
||||
.store
|
||||
.add(
|
||||
kind.name(),
|
||||
args.repo.trim(),
|
||||
host,
|
||||
args.token.as_deref().map(str::trim).unwrap_or(""),
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(json!({
|
||||
"watched": true,
|
||||
"added": added,
|
||||
"forge": kind.name(),
|
||||
"repo": args.repo.trim(),
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops watching: the row leaves the table.
|
||||
struct UnwatchRepo {
|
||||
store: Arc<ClawStore>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for UnwatchRepo {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
"unwatch_repo",
|
||||
"Stop watching a repository: no more activity arrives from \
|
||||
it. Same `forge` and `repo` spelling as `watch_repo`.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"forge": { "type": "string",
|
||||
"description": "github, gitea, or gitlab." },
|
||||
"repo": { "type": "string",
|
||||
"description": "The repository as owner/name." }
|
||||
},
|
||||
"required": ["forge", "repo"]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn invoke(&self, args: Value) -> Result<String, String> {
|
||||
let args: UnwatchArgs =
|
||||
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
|
||||
let kind = ForgeKind::parse(&args.forge)
|
||||
.ok_or_else(|| format!("unknown forge {:?}: github, gitea, or gitlab", args.forge))?;
|
||||
let removed = self
|
||||
.store
|
||||
.remove(kind.name(), args.repo.trim())
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(json!({
|
||||
"watched": false,
|
||||
"removed": removed,
|
||||
"forge": kind.name(),
|
||||
"repo": args.repo.trim(),
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists what is watched.
|
||||
struct ListWatches {
|
||||
store: Arc<ClawStore>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ListWatches {
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec::new(
|
||||
"list_watches",
|
||||
"List the watched repositories: forge, repo, and host for \
|
||||
each.",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn invoke(&self, _args: Value) -> Result<String, String> {
|
||||
let watches = self.store.list().map_err(|err| err.to_string())?;
|
||||
let rows: Vec<Value> = watches
|
||||
.iter()
|
||||
.map(|watch| {
|
||||
json!({
|
||||
"forge": watch.forge,
|
||||
"repo": watch.repo,
|
||||
"host": watch.host,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "watches": rows }).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store() -> Arc<ClawStore> {
|
||||
Arc::new(ClawStore::in_memory().expect("store opens"))
|
||||
}
|
||||
|
||||
fn names(registry: &ToolRegistry) -> Vec<String> {
|
||||
registry
|
||||
.resolver_for(&rhema_autonomous_agent::Allowlist::permissive())
|
||||
.specs()
|
||||
.into_iter()
|
||||
.map(|spec| spec.name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_registry_offers_the_three_tools() {
|
||||
let tools = registry(store());
|
||||
assert_eq!(
|
||||
names(&tools),
|
||||
["list_watches", "unwatch_repo", "watch_repo"].map(str::to_owned),
|
||||
"BTreeMap order: the registry sorts by name"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watch_repo_lands_a_row_and_answers_json() {
|
||||
let store = store();
|
||||
let tools = registry(store.clone());
|
||||
let resolver = tools.resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
|
||||
let ctx = rhema_core::ToolContext::default();
|
||||
let result = resolver
|
||||
.resolve(
|
||||
rhema_core::ToolCall {
|
||||
id: "c1".into(),
|
||||
name: "watch_repo".into(),
|
||||
args: json!({
|
||||
"forge": "github",
|
||||
"repo": "acme/widgets",
|
||||
"token": "sekrit",
|
||||
}),
|
||||
provider_data: Value::Null,
|
||||
},
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
assert!(!result.is_error, "{}", result.content);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&result.content).unwrap(),
|
||||
json!({"watched": true, "added": true, "forge": "github", "repo": "acme/widgets"}),
|
||||
"{}",
|
||||
result.content
|
||||
);
|
||||
let watches = store.list().expect("list reads");
|
||||
assert_eq!(watches[0].repo, "acme/widgets");
|
||||
assert_eq!(
|
||||
watches[0].token, "sekrit",
|
||||
"the token is stored, not echoed"
|
||||
);
|
||||
assert!(
|
||||
!result.content.contains("sekrit"),
|
||||
"answers never carry tokens"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_bad_watch_fails_in_band_with_the_reason() {
|
||||
let store = store();
|
||||
let tools = registry(store.clone());
|
||||
let resolver = tools.resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
|
||||
let ctx = rhema_core::ToolContext::default();
|
||||
let call = |name: &str, args: Value| {
|
||||
resolver.resolve(
|
||||
rhema_core::ToolCall {
|
||||
id: "c1".into(),
|
||||
name: name.into(),
|
||||
args,
|
||||
provider_data: Value::Null,
|
||||
},
|
||||
&ctx,
|
||||
)
|
||||
};
|
||||
let hostless = call("watch_repo", json!({"forge": "gitea", "repo": "acme/barn"})).await;
|
||||
assert!(hostless.is_error);
|
||||
assert!(
|
||||
hostless.content.contains("self-hosted"),
|
||||
"{}",
|
||||
hostless.content
|
||||
);
|
||||
|
||||
let unknown = call(
|
||||
"watch_repo",
|
||||
json!({"forge": "sourcehut", "repo": "acme/barn"}),
|
||||
)
|
||||
.await;
|
||||
assert!(unknown.is_error);
|
||||
assert!(
|
||||
unknown.content.contains("unknown forge"),
|
||||
"{}",
|
||||
unknown.content
|
||||
);
|
||||
|
||||
let malformed = call("watch_repo", json!({"forge": "github"})).await;
|
||||
assert!(malformed.is_error);
|
||||
assert!(
|
||||
malformed.content.contains("bad arguments"),
|
||||
"{}",
|
||||
malformed.content
|
||||
);
|
||||
|
||||
assert!(
|
||||
store.list().expect("list reads").is_empty(),
|
||||
"nothing landed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unwatch_and_list_round_trip_through_the_same_table() {
|
||||
let store = store();
|
||||
store.add("github", "acme/widgets", None, "").expect("seed");
|
||||
store.add("gitlab", "group/prop", None, "").expect("seed");
|
||||
let tools = registry(store.clone());
|
||||
let resolver = tools.resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
|
||||
let ctx = rhema_core::ToolContext::default();
|
||||
let call = |name: &str, args: Value| {
|
||||
resolver.resolve(
|
||||
rhema_core::ToolCall {
|
||||
id: "c1".into(),
|
||||
name: name.into(),
|
||||
args,
|
||||
provider_data: Value::Null,
|
||||
},
|
||||
&ctx,
|
||||
)
|
||||
};
|
||||
|
||||
let listed = call("list_watches", json!({})).await;
|
||||
assert!(!listed.is_error);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&listed.content).unwrap(),
|
||||
json!({"watches": [
|
||||
{"forge": "github", "repo": "acme/widgets", "host": null},
|
||||
{"forge": "gitlab", "repo": "group/prop", "host": null},
|
||||
]}),
|
||||
"{}",
|
||||
listed.content
|
||||
);
|
||||
|
||||
let removed = call(
|
||||
"unwatch_repo",
|
||||
json!({"forge": "github", "repo": "acme/widgets"}),
|
||||
)
|
||||
.await;
|
||||
assert!(!removed.is_error, "{}", removed.content);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&removed.content).unwrap(),
|
||||
json!({"watched": false, "removed": true, "forge": "github", "repo": "acme/widgets"}),
|
||||
);
|
||||
assert_eq!(store.list().expect("list reads").len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Session continuity across processes: two `--once` passes over the same
|
||||
//! SQLite db and the same thread. The first pass's conversation is
|
||||
//! stashed into the dispatcher's `driver_state`; the second pass's agent
|
||||
//! resumes from it — the MockProvider cannot record its requests, so the
|
||||
//! stashed state itself is the proof: the restored history carries both
|
||||
//! exchanges.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rhema_claw::{ClawConfig, ForgeKind, Watch, run_once};
|
||||
use rhema_core::{Message, MockProvider, ModelOutput, Role, Session, StopReason, Usage};
|
||||
use rhema_rsd::{KeyState, Outcome, ResourceKey, SqliteStore, Store};
|
||||
use serde_json::{Value, json};
|
||||
use wiremock::matchers::{body_partial_json, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// An open issue row, created and updated in one instant: its dedup id
|
||||
/// `github:issue:1007@…` never changes, so every pass's poll re-feeds it
|
||||
/// and delivery stays at-least-once — the second pass sees the replay
|
||||
/// plus the new comment.
|
||||
fn issue_row() -> Value {
|
||||
json!({
|
||||
"id": 1007,
|
||||
"number": 7,
|
||||
"state": "open",
|
||||
"title": "crash on save",
|
||||
"user": { "login": "alice" },
|
||||
"labels": [{ "name": "bug" }],
|
||||
"body": "steps to reproduce…",
|
||||
"created_at": "2026-10-02T10:00:00Z",
|
||||
"updated_at": "2026-10-02T10:00:00Z",
|
||||
"closed_at": null,
|
||||
"html_url": "https://github.com/acme/widgets/issues/7"
|
||||
})
|
||||
}
|
||||
|
||||
/// A comment row on thread 7.
|
||||
fn comment_row(body: &str) -> Value {
|
||||
json!({
|
||||
"id": 8001,
|
||||
"user": { "login": "dave" },
|
||||
"body": body,
|
||||
"created_at": "2026-10-03T09:00:00Z",
|
||||
"updated_at": "2026-10-03T09:00:00Z",
|
||||
"issue_url": "https://api.github.com/repos/acme/widgets/issues/7",
|
||||
"html_url": "https://github.com/acme/widgets/issues/7#issuecomment-8001"
|
||||
})
|
||||
}
|
||||
|
||||
fn reply(text: &str) -> ModelOutput {
|
||||
ModelOutput {
|
||||
message: Message::text(Role::Assistant, text),
|
||||
stop_reason: StopReason::EndTurn,
|
||||
usage: Usage::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig {
|
||||
ClawConfig {
|
||||
db: Some(dir.path().join("dispatch.db")),
|
||||
workspace_root: Some(dir.path().join("workspaces")),
|
||||
memory_root: None,
|
||||
bootstrap_root: None,
|
||||
poll_interval_secs: 60,
|
||||
max_turn_steps: rhema_claw::config::DEFAULT_MAX_TURN_STEPS,
|
||||
provider: Some("google".to_owned()),
|
||||
model: Some("test-model".to_owned()),
|
||||
listen: None,
|
||||
token: None,
|
||||
watches: vec![Watch {
|
||||
forge: ForgeKind::GitHub,
|
||||
repo: "acme/widgets".to_owned(),
|
||||
host: Some(server.uri()),
|
||||
token: Some("tok".to_owned()),
|
||||
}],
|
||||
calendars: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The thread's standing state: open, unchanged across both passes.
|
||||
async fn mount_snapshot(server: &MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/issues/7"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(issue_row()))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_second_dispatch_resumes_the_first_conversation() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/issues"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([issue_row()])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
// The comments feed answers per pass: quiet first, then dave's
|
||||
// comment. Same matchers, mounted in order — the first serves the
|
||||
// first request only, then falls through.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/issues/comments"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
|
||||
.up_to_n_times(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/issues/comments"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(json!([comment_row("any progress?")])),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
mount_snapshot(&server).await;
|
||||
let first_post = Mock::given(method("POST"))
|
||||
.and(path("/repos/acme/widgets/issues/7/comments"))
|
||||
.and(body_partial_json(json!({ "body": "on it" })))
|
||||
.respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 43 })))
|
||||
.expect(1)
|
||||
.mount_as_scoped(&server)
|
||||
.await;
|
||||
let second_post = Mock::given(method("POST"))
|
||||
.and(path("/repos/acme/widgets/issues/7/comments"))
|
||||
.and(body_partial_json(json!({ "body": "still on it" })))
|
||||
.respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 44 })))
|
||||
.expect(1)
|
||||
.mount_as_scoped(&server)
|
||||
.await;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let config = config(&server, &dir);
|
||||
let key = ResourceKey::from("github:acme/widgets#7");
|
||||
|
||||
// Pass one: the opened event alone. The cron shape — a fresh process,
|
||||
// a fresh watcher, the same db.
|
||||
let first = run_once(
|
||||
&config,
|
||||
mock_registry(Arc::new(MockProvider::new([reply("on it")]))),
|
||||
)
|
||||
.await
|
||||
.expect("the first pass runs");
|
||||
assert_eq!(first.detected, 1);
|
||||
assert_eq!(first.outcomes, vec![Outcome::Handled]);
|
||||
drop(first_post);
|
||||
|
||||
// Pass two: the replayed opened event plus dave's comment, both
|
||||
// decoded into one prompt for one resumed run.
|
||||
let second = run_once(
|
||||
&config,
|
||||
mock_registry(Arc::new(MockProvider::new([reply("still on it")]))),
|
||||
)
|
||||
.await
|
||||
.expect("the second pass runs");
|
||||
assert_eq!(second.detected, 2, "the replay re-delivers, at-least-once");
|
||||
assert_eq!(second.outcomes, vec![Outcome::Handled]);
|
||||
drop(second_post);
|
||||
|
||||
// The proof: the second run's stashed session carries the first
|
||||
// conversation — a fresh agent would hold only its own exchange.
|
||||
let store = SqliteStore::open(dir.path().join("dispatch.db")).expect("db reopens");
|
||||
let record = store
|
||||
.load(&key)
|
||||
.expect("the record loads")
|
||||
.expect("the key has a record");
|
||||
assert_eq!(record.state, KeyState::Idle);
|
||||
let stashed: Session = serde_json::from_value(
|
||||
record
|
||||
.session
|
||||
.expect("the key has a session")
|
||||
.core
|
||||
.driver_state,
|
||||
)
|
||||
.expect("the stash parses back into a session");
|
||||
let user_prompts: Vec<&str> = stashed
|
||||
.history
|
||||
.iter()
|
||||
.filter_map(|message| match &message.content[0] {
|
||||
rhema_core::Content::Text(text) if message.role == Role::User => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
user_prompts.len(),
|
||||
2,
|
||||
"both dispatches prompted: {user_prompts:?}"
|
||||
);
|
||||
assert!(
|
||||
user_prompts[0].contains("opened by alice"),
|
||||
"pass one's prompt carried the opened event"
|
||||
);
|
||||
assert!(
|
||||
user_prompts[1].contains("any progress?"),
|
||||
"pass two's prompt carried the new comment"
|
||||
);
|
||||
let replies: Vec<&str> = stashed
|
||||
.history
|
||||
.iter()
|
||||
.filter_map(|message| match &message.content[0] {
|
||||
rhema_core::Content::Text(text) if message.role == Role::Assistant => {
|
||||
Some(text.as_str())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
replies,
|
||||
["on it", "still on it"],
|
||||
"the conversation resumed"
|
||||
);
|
||||
}
|
||||
|
||||
/// A registry whose only backend is the scripted mock — the shape every
|
||||
/// integration test runs on.
|
||||
fn mock_registry(provider: Arc<dyn rhema_core::ModelProvider>) -> Arc<rhema_claw::BackendRegistry> {
|
||||
Arc::new(
|
||||
rhema_claw::BackendRegistry::new(
|
||||
vec![rhema_claw::Backend::headless("mock", provider)],
|
||||
None,
|
||||
)
|
||||
.expect("a single mock backend always constructs"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Offline end-to-end: one `--once` pass over a wiremock GitHub — the
|
||||
//! feed is polled, the MockProvider's reply lands as the thread's
|
||||
//! comment, and the dispatch state persists in the SQLite db. Zero
|
||||
//! network.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rhema_claw::{ClawConfig, ForgeKind, Watch, run_once};
|
||||
use rhema_core::{Message, MockProvider, ModelOutput, Role, StopReason, Usage};
|
||||
use rhema_rsd::{
|
||||
Dispatcher, DispatcherConfig, KeyState, Outcome, ResourceKey, SqliteStore, SystemClock,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use wiremock::matchers::{body_partial_json, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// A closed issue row, updated at `updated_at`: the feed's event source.
|
||||
fn issue_row(updated_at: &str) -> Value {
|
||||
json!({
|
||||
"id": 1007,
|
||||
"number": 7,
|
||||
"state": "closed",
|
||||
"title": "crash on save",
|
||||
"user": { "login": "alice" },
|
||||
"labels": [{ "name": "bug" }],
|
||||
"body": "steps to reproduce…",
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": updated_at,
|
||||
"closed_at": "2026-10-02T10:00:00Z",
|
||||
"html_url": "https://github.com/acme/widgets/issues/7"
|
||||
})
|
||||
}
|
||||
|
||||
/// The scripted assistant turn: one text reply, nothing else.
|
||||
fn reply(text: &str) -> ModelOutput {
|
||||
ModelOutput {
|
||||
message: Message::text(Role::Assistant, text),
|
||||
stop_reason: StopReason::EndTurn,
|
||||
usage: Usage::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The test's claw config: everything under one tempdir, the watch aimed
|
||||
/// at the mock server. The provider never resolves through it — the
|
||||
/// daemon library takes the built provider, and the test builds a mock.
|
||||
fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig {
|
||||
ClawConfig {
|
||||
db: Some(dir.path().join("dispatch.db")),
|
||||
workspace_root: Some(dir.path().join("workspaces")),
|
||||
memory_root: None,
|
||||
bootstrap_root: None,
|
||||
poll_interval_secs: 60,
|
||||
max_turn_steps: rhema_claw::config::DEFAULT_MAX_TURN_STEPS,
|
||||
provider: Some("google".to_owned()),
|
||||
model: Some("test-model".to_owned()),
|
||||
listen: None,
|
||||
token: None,
|
||||
watches: vec![Watch {
|
||||
forge: ForgeKind::GitHub,
|
||||
repo: "acme/widgets".to_owned(),
|
||||
host: Some(server.uri()),
|
||||
token: Some("tok".to_owned()),
|
||||
}],
|
||||
calendars: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_pass_polls_reasons_and_posts() {
|
||||
let server = MockServer::start().await;
|
||||
// The feeds a first poll reads: the issues list (one closed issue)
|
||||
// and the quiet comments feed.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/issues"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(json!([issue_row("2026-10-02T10:00:00Z")])),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/issues/comments"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
// The handler's orientation call: the thread snapshot.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/issues/7"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(issue_row("2026-10-02T10:00:00Z")))
|
||||
.mount(&server)
|
||||
.await;
|
||||
// The reply, counted: the assertion is that it posts exactly once
|
||||
// with the mock's text.
|
||||
let comment = Mock::given(method("POST"))
|
||||
.and(path("/repos/acme/widgets/issues/7/comments"))
|
||||
.and(body_partial_json(json!({ "body": "on it" })))
|
||||
.respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 43 })))
|
||||
.expect(1)
|
||||
.mount_as_scoped(&server)
|
||||
.await;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let config = config(&server, &dir);
|
||||
let provider = Arc::new(MockProvider::new([reply("on it")]));
|
||||
let report = run_once(&config, mock_registry(provider))
|
||||
.await
|
||||
.expect("the pass runs");
|
||||
assert_eq!(report.detected, 1, "the feed's one event was fed");
|
||||
assert_eq!(report.outcomes, vec![Outcome::Handled]);
|
||||
drop(comment);
|
||||
|
||||
// Reopen the db cold: the dispatch state persisted — the key is idle,
|
||||
// drained, and carries its outcome.
|
||||
let store = SqliteStore::open(dir.path().join("dispatch.db")).expect("db reopens");
|
||||
let dispatcher = Dispatcher::new(
|
||||
DispatcherConfig::default(),
|
||||
Arc::new(SystemClock),
|
||||
Arc::new(store),
|
||||
);
|
||||
let key = ResourceKey::from("github:acme/widgets#7");
|
||||
let status = dispatcher.status(&key).expect("status reads");
|
||||
assert_eq!(status.state, KeyState::Idle);
|
||||
assert_eq!(status.inbox_depth, 0);
|
||||
assert_eq!(status.last_outcome, Some(Outcome::Handled));
|
||||
}
|
||||
|
||||
/// A registry whose only backend is the scripted mock — the shape every
|
||||
/// integration test runs on.
|
||||
fn mock_registry(provider: Arc<dyn rhema_core::ModelProvider>) -> Arc<rhema_claw::BackendRegistry> {
|
||||
Arc::new(
|
||||
rhema_claw::BackendRegistry::new(
|
||||
vec![rhema_claw::Backend::headless("mock", provider)],
|
||||
None,
|
||||
)
|
||||
.expect("a single mock backend always constructs"),
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user