feat(rhema-code): add the colors setting with four palettes

The stored `colors` key names a palette — rhema (the default,
Jadeite), aquamarine, black-and-white, or dracula — and the
Appearance page of /settings cycles it live, like the theme and
icon toggles beside it. Each palette carries a dark and a light
appearance under the existing `theme` key; dracula's light side is
derived, the official palette ships only dark.

The appearance decision moves off token comparison: Theme now
answers is_dark() from the ground/text luminance, so palettes
beyond the original two get the right per-profile fallback tones.
This commit is contained in:
vhaudiquet committed 2026-10-17 04:28:31 +08:00
commit 81da26b955
159 files changed
+50628

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
[package]
name = "rhema-claw"
description = "The resident agent daemon: forge watches and the chat HTTP surface, the first consumer of rhema-autonomous-agent"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
# The resident tier is still indev: `cargo publish --workspace` skips
# it until the claw ships.
publish = false
[lib]
name = "rhema_claw"
path = "src/lib.rs"
[[bin]]
name = "rhema-claw"
path = "src/main.rs"
[features]
# The endless canned provider for `--mock` debug runs; nothing ships in
# a real build.
mock = ["rhema-core/mock"]
[dependencies]
# The shared toolkit: the resident agent assembles its agent from the
# shell, the approval seam, paste expansion, and revive-on-resume
rhema-agent-tools = { path = "../rhema-agent-tools" }
rhema-autonomous-agent = { path = "../rhema-autonomous-agent" }
rhema-coding-agent = { path = "../rhema-coding-agent" }
rhema-config = { path = "../rhema-config" }
rhema-core = { path = "../rhema-core" }
rhema-calendar-integration = { path = "../../integrations/rhema-calendar-integration" }
rhema-forge-integration = { path = "../../integrations/rhema-forge-integration" }
# The accounts layer: identities hold the forge tokens the watches poll
# with, and the identity tools manage them over chat
rhema-identity = { path = "../rhema-identity" }
rhema-provider-anthropic = { path = "../rhema-provider-anthropic" }
rhema-provider-copilot = { path = "../rhema-provider-copilot" }
rhema-provider-google = { path = "../rhema-provider-google" }
rhema-provider-llama-cpp = { path = "../rhema-provider-llama-cpp" }
rhema-provider-openai-compatible = { path = "../rhema-provider-openai-compatible" }
rhema-provider-openrouter = { path = "../rhema-provider-openrouter" }
rhema-rsd = { path = "../rhema-rsd" }
# The resident tier's durable memory: its tools join the resolver chain
rhema-memory = { path = "../rhema-memory" }
# The shared ModelCard the backend registry's listings return
rhema-provider = { path = "../rhema-provider" }
# Args for the two-flag binary surface
clap = { version = "4.5", features = ["derive"] }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
# Typed public errors
thiserror = { workspace = true }
# A daemon: the runtime, the poll sleep, the ctrl-c signal; the driver's
# concurrent handler runs share it. `net` is the HTTP listener.
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "time", "sync", "net"] }
# The chat surface: native routes plus SSE, and the OpenAI shim
axum = { workspace = true }
# Broadcast receivers as SSE bodies
tokio-stream = { workspace = true }
# The watch table: a second connection to the same SQLite file as the
# dispatcher's store — the two-connections pattern rhema-rsd's tests use
rusqlite = { version = "0.40", features = ["bundled"] }
# RFC3339 parsing for schedule_task's `at`
time = { version = "0.3", default-features = false, features = ["parsing"] }
[dev-dependencies]
tempfile = "3"
# In-process HTTP mocks for the offline e2e: zero network
wiremock = "0.6"
# The HTTP client for the surface tests, SSE bodies included
reqwest = { version = "0.12", features = ["json", "stream"] }
# Naming the SSE chunks' type in the surface tests
bytes = "1"
# The scripted provider for turn-level tests; mirrors rhema-code's mock
# feature without a feature of our own — tests are the only consumer
rhema-core = { path = "../rhema-core", features = ["mock"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] }
+16
View File
@@ -0,0 +1,16 @@
## Setup
This is a first conversation: SOUL.md or USER.md is still unwritten.
Offer a short setup — who they are, what this machine is for, who you
should be — and let them decline; never interrogate. Ask at most a few
questions, spread across turns.
Write SOUL.md (your name, tone, purpose) and USER.md (who the user
is) with the bootstrap_save tool as facts arrive; incrementally is
fine. Set what you learn about your own presentation with the
identity_set tool as well — your name, a mark or initials, a tagline.
An avatar only if the user hands you an image file; they can also
upload one in the web interface's settings, and saying so beats
hunting one down. Never invent facts the user did not give.
When both files exist, say setup is done and stop offering.
+275
View File
@@ -0,0 +1,275 @@
# Rhema Claw
Still Work-In-Progress.
Rhema Claw is an autonomous personal agent ("claw"), built on the Rhema agent framework.
## Features
## The claw: `rhema-claw`
The resident agent. One daemon owns every session in one SQLite store:
yours (`interactive:<name>`, keyed per chat surface) and every watched
forge thread's. Talk to it over HTTP; it watches repos, answers forge
threads with coding-agent turns, and configures its own watches when
asked in chat (`watch_repo`/`unwatch_repo`/`list_watches` are tools the
model calls like any other).
```sh
rhema-claw # serve on 127.0.0.1:7799 (config: listen)
rhema-claw --once # one detect + one dispatch pass, then exit
```
### Coding jobs: the `coding_agent` tool
The claw is not the only agent it runs. The `coding_agent` tool drives
a full rhema-coding-agent session — its own shell, skills, and step
budget — in a named workspace, with one tool and an `action` argument:
- `add` pins a name to an existing directory (`rhema` → `~/rhema`).
The instance list is the audit surface for which checkouts the model
can drive a coding agent in; `drop` removes one, refused while a job
is live.
- `run` enqueues a job. Async by design: a coding run is minutes of
tool rounds, so the call answers with the job id in one model round
and the turn runs off the chat — its own dispatcher, `job:` keys
with forge-style leases, the same commit-before-execute discipline.
The instance's coding session carries to the next run unless
`fresh`; the final text lands in the job row and the user's
notifications.
- `list` names the instances, each with its live job at a glance;
`status` reads jobs — every recent one, one instance's, or one
job's result.
- One live job per instance: two coding agents in one workspace is
two agents editing the same files. Across instances, jobs run in
parallel.
For the debug loop, daemon plus UI against throwaway state and zero
credentials: `scripts/dev.sh --mock` (canned streamed replies; the UI
is at `localhost:3000`, the claw at `127.0.0.1:7799`). Drop `--mock`
and set the provider's key env var (`CLAW_DEV_PROVIDER` picks the
provider) to debug against a real model.
```yaml
# ~/.config/rhema/claw.yaml
provider: anthropic
model: claude-sonnet-4-5
listen: 127.0.0.1:7799
token: change-me # optional; when set, /v1/* requires it
poll_interval_secs: 120
max_turn_steps: 2048 # tool rounds one chat turn may run
watches: # a seed: imported while the table is empty
- forge: github
repo: acme/widgets
calendars: # feeds the claw reminds about
- name: home # the reminder session is calendar:home
kind: ics # an ics/webcal feed (Google's secret iCal
url: https://cloud.example.net/remote.php/dav/calendars/personal.ics
lead_minutes: [60, 15] # default [15]; horizon_hours default 24
- name: cloud
kind: caldav # a CalDAV collection (REPORT; Nextcloud,
url: https://cloud.example.net/remote.php/dav/calendars/me/personal/
username: me # Radicale); password falls back to
password: app-pass # CALDAV_PASSWORD; token: = bearer instead
- name: work
kind: google # the Google Calendar API
calendar_id: primary
api_key: pub-key # or token: (OAuth; env fallback CALENDAR_TOKEN)
```
`max_turn_steps` caps one turn's tool rounds (the coding driver's own
budget shape, and the same default, 2048): the claw is meant to work
unattended for a long time, so a cap this deep is a runaway guard, not
a work timer — a turn that hits it ends with a failed outcome and the
conversation intact; a follow-up message keeps going.
The surface refuses to start on a non-loopback `listen` with no
`token`: anything beyond loopback can reach the port, and the bearer
token is what stands between it and the agent's tools. Set a token, or
bind a loopback address. Resident sessions run the coding agent with
its approval seam auto-approved: tool calls, the shell included,
execute without a prompt. The daemon says so at startup, and the
bearer-gated surface plus the watch list are the trust boundary.
The native surface:
```
GET /healthz GET /v1/sessions
POST /v1/sessions {"name"} — create an empty interactive session
POST /v1/sessions/{key}/messages {"text": "..."} -> 202
GET /v1/sessions/{key}/events SSE turn events (see below)
GET /v1/watches POST /v1/watches DELETE /v1/watches/{forge}/{repo}
GET /v1/bootstrap {"soul": bool, "user": bool}
POST /v1/ingest {"source","key","payload"} -> 202
GET /v1/notifications?device=&since= POST /v1/notifications/{id}/ack
GET /v1/consents the asks a turn is waiting on
POST /v1/consents/{id} {"secret"} or {"grant": bool}
DELETE /v1/consents/{id} deny a pending ask
GET /v1/attachments/{id} an attached file's bytes
GET /v1/phone/commands?device= the device's queued, unconfirmed commands
POST /v1/phone/commands/{id}/result {"ok", "detail"?} — the device's report
GET /v1/providers the backend instances + the active pair
PUT /v1/providers/active {"provider","model"?} — switch, persisted
PUT /v1/providers/{id}/credentials add or replace an instance, persisted
DELETE /v1/providers/{id} remove one (the last is refused)
GET /v1/providers/{id}/models the live model listing
```
### Identity and first-run
The claw's prompt is assembled per turn from an in-binary base plus
three identity files under the state dir's `bootstrap/` (config:
`bootstrap_root`): **SOUL.md**, who the claw is, tone and purpose,
written with you; **USER.md**, the always-visible summary of who you
are; **ENVIRONMENT.md**, host, shell, and tool inventory, generated
deterministically at every boot (detection is code, not conversation;
hand edits are overwritten). The base renders the dispatch's own tool
roster too — every tool's name, description, and call schema, straight
from the resolver chain, so what the prompt lists is what the request
advertises. On first run the claw *offers* a short
setup in chat: who you are, what the machine is for, who it should be.
It writes the first two files itself through the `bootstrap_save`
tool as you answer, and — through the `identity_set` tool — sets its
own presentation the same way: its name, a mark or tagline, an avatar
from an image you hand it. The same tool is yours to invoke any time
("call yourself something else"); it is the same store the settings
pages write, so what it sets is what every client renders. Decline
and it simply keeps offering, never
interrogating. Both files stay hand-editable forever; `/v1/bootstrap`
lets a UI badge setup state.
SSE frames are JSON, `data: {..}`: `user_message`, `thinking_delta`,
`text_delta`, `tool_start`, `tool_result`, `turn_done`. A subscription
opens with the session's recorded turns replayed first and the live feed
after, cut atomically daemon-side — every streamed event is persisted as
it streams, so both a reload and a daemon restart rebuild the whole
transcript, not a recent window; a session that runs for weeks scrolls
back to its first message. A chat UI subscribes once,
POSTs messages, and resets its mirror whenever the stream (re)opens,
letting the replay rebuild it; the daemon owns the conversation, so the
client mirrors the stream rather than managing history. The OpenAI shim
and the message POST read the live feed only — replay never reaches a
turn that is starting.
Every provider whose credentials resolve (env, the claw's own stored
logins, or the stored rhema config) is constructed at boot as a backend
*instance* named by an id, the kind unless a login named it, so two
llama.cpp servers (or two keys of one cloud) coexist under two names.
One is active, and switching with `PUT /v1/providers/active` is runtime
state persisted in SQLite: the
config's `provider`/`model` pair is only the seed, and when the config
names no provider the first credential discovery actually finds (a
stored login, an env key, or the stored rhema config) is active; the
keyless llama.cpp fallback never wins a seed, so the chart pins
nothing and a switch never needs a restart.
The claw registers RSD sources four ways: watches (forge), calendar
reminders (`calendars:` in the config; each tick polls every feed for
the occurrences starting within its horizon, and an upcoming event
warns once per `lead_minutes` on its persistent `calendar:<name>`
session, a turn whose final text auto-notifies the phone; recurring
series arrive expanded, overrides and cancellations applied, all-day
events stay silent), scheduled
tasks the agent creates in chat (`schedule_task` with `at` or
`every_secs`; a due task dispatches through the resident agent — into
the session the tool named, its own `task:<id>` slot by default — and
a reply on a chat-less key auto-notifies the phone; a task whose
`session` names a chat conversation wakes *that* session with its full
context, so "pick this back up at 10 am" or "before my meeting,
summarize X" is one tool call, with `notify` reaching the user away
from the chat), and a generic door,
`POST /v1/ingest`, that any external emitter addresses
(`phone:<device>:<topic>`
keys from the Android app, for example). The resident agent owns
`interactive:`, `task:`, `phone:`, and `calendar:` keys; forge threads
own the rest. Its `notify` tool queues a notification for a device;
the device polls and acks.
When a turn needs something only a human can give, it asks and waits
inside the tool call: `ask_secret` raises a consent request — a card in
the chat stream (`consent_request` over SSE, plus `GET /v1/consents`
for a UI catching up after a reload) — and the human answers through
`POST /v1/consents/{id}` (`{"secret": ...}`) or denies with `DELETE`.
The secret travels once, from the answer into the agent accounts; the
tool wakes with the account id, never the secret, and a denial or a
15-minute silence comes back in-band so the turn can say so and move
on. Acting with a stored account is `identity_exec`: it runs one shell
command in the session workspace with the secret injected —
`ACCOUNT_SECRET` plus the service's own `GITHUB_TOKEN`/`GITEA_TOKEN`/
`GITLAB_TOKEN`, and a git credential helper — so `git clone` and API
calls work while the credential itself never reaches the transcript. A turn can also hand files over: `attach_file` copies a workspace
file (25 MiB cap, workspace-relative paths only) into the attachment
store beside the database and puts an `attachment` card on the stream;
the bytes are fetchable at `GET /v1/attachments/{id}` with the content
type the file's name implies, so a screenshot, a diff, or a report
reaches the chat as something to view or download rather than a
truncated snippet.
The phone is reachable too. Its status reports (`/v1/ingest`, source
`phone`, keys `phone:<device>:<topic>`) are conversation *and* state:
the payload lands in the phone tables, so the `phone` tool's
`status`/`devices` actions read the latest battery, charging, network,
and DND observations without asking anyone. Acting on the phone —
`dnd_on`, `dnd_off`, `alert` — is consent-gated end to end: the tool
raises a consent card, the human approves or denies, and only then does
the command join `GET /v1/phone/commands?device=`, the poll list the
device executes from and reports on via `POST
/v1/phone/commands/{id}/result`. The device confirms on its own clock
(the 15-minute WorkManager floor), so an answer may honestly be
"queued, not yet confirmed".
Stock OpenAI-speaking UIs point at the shim instead:
```sh
curl http://127.0.0.1:7799/v1/chat/completions -H 'Content-Type: application/json' -d '{
"model": "claw", "user": "web", "stream": true,
"messages": [{"role": "user", "content": "what are you watching?"}]
}'
```
The shim reads only the last user message (`user` picks the session,
default `web`), maps thinking deltas to `reasoning_content`, and
streams standard chat-completions chunks. There is one notion of
session, the daemon's, behind a wire format every web UI already speaks.
## Deploying the claw
```sh
docker build -f deploy/docker/claw.Dockerfile -t rhema-claw .
helm install claw deploy/helm/rhema-claw \
--set token=$(openssl rand -hex 24) \
--set secret.stringData.ANTHROPIC_API_KEY=... \
--set secret.stringData.GITHUB_TOKEN=... \
--set config.provider=anthropic --set config.model=claude-sonnet-4-5
```
Expose it with the chart's optional ingress (`ingress.enabled` +
`ingress.host`, TLS strongly recommended, bearer tokens cross it) or
keep it cluster-private behind Tailscale; the claw is bearer-gated
either way. With the chart's UI on (`ui.enabled`, the default), the
same host serves both: the browser gets the web UI on `/`, the daemon
answers `/v1` and `/healthz`; one address for the browser and the
phone app, no CORS anywhere.
The image is the daemon plus `bash` and `git` (the agent's shell is
load-bearing); the chart runs exactly one replica, the claw is
single-node by design: one SQLite store, one dispatcher. Single-node
is what makes restarts instant: at boot the daemon releases every
lease the previous process left — outright, not at the dead lease's
TTL — so a turn an update cut mid-flight answers its next message
instead of stalling behind a ghost (the committed tool call revives
at the top of that next dispatch). The
store and workspaces live on a PVC, `claw.yaml` in a ConfigMap (db and
workspace paths are pinned to the mounted volume), credentials in a
Secret, and the bearer token is the `token` value, required since the
daemon binds all interfaces in-cluster. `persistence.enabled=false`
trades restart survival for disposability.
The UI in `web/claw` is one client of the same surface: `npm run dev`
proxies to a local claw, `npm run build` emits the static bundle an
ingress serves next to `/v1`. The phone is another. `android/` is the
Kotlin/Compose client: chat over the same SSE stream, phone status
(battery, charging, network, DND transitions) posted to `/v1/ingest`
as `phone:<device>:<topic>` events, and agent notifications polled
onto the lock screen (no push infrastructure; a 15-minute WorkManager
floor, live SSE while the app is open). See `android/README.md`.
+49
View File
@@ -0,0 +1,49 @@
You are the resident agent on this machine. Your SOUL defines who you
are; until it is written you are rhema-claw, freshly installed. This
session's key is {{ session }}. You
answer chat here; your shell runs in the workspace at {{ workspace }}:
read code, run tests, make edits there. Keep replies terse and
concrete; do the work with tools instead of narrating it. Your
final message, the one you end the turn on with no tool calls after
it, is the reply the user sees.
{{ soul }}
{{ user }}
{{ environment }}
{% if tools %}
You act through these tools (name | description | arguments). The
arguments are the JSON schema a call must satisfy.
{% for tool in tools %}
- {{ tool.name }} | {{ tool.description }} | {{ tool.parameters | tojson }}
{% endfor %}
{% endif %}
You manage your own watch list with the watch_repo, unwatch_repo, and
list_watches tools: a watched repository's activity (issues, pull
requests, comments) arrives as its own dispatch.
You can reach the user away from this chat with the notify tool: it
queues a notification (title, body, device; the phone by default)
for their device to pick up. You can also schedule your own future
turns with the schedule_task, list_tasks, and cancel_task tools: a
task's prompt runs as one of your turns at a given time (at, an
RFC3339 timestamp or epoch milliseconds) or on an interval
(every_secs); set exactly one. A task with no session runs cold and
its final message reaches the user as a notification; give session
(this session is {{ session }}) to wake that conversation instead, with
all of its context — pair it with notify when the user must see the
result away from the chat.
You keep persistent memory across sessions. memory_save stores what is
worth remembering: decisions, conventions, corrections, pointers.
memory_search recalls it; search before asking the user for something
they have likely said before. memory_forget removes a record on
request. Never save secrets into memory.
{% if onboarding %}
{{ onboarding }}
{% endif %}
+862
View File
@@ -0,0 +1,862 @@
//! The identity tools: `identity_save`, `identity_list`,
//! `identity_forget`, and `identity_exec`, the model's surface over the
//! [`IdentityStore`].
//!
//! These are the one place the use-never-read contract could break, so
//! the answers are built to keep it: a save answers with the redacted
//! [`Account`] and the watch verdict — never the secret, which nothing
//! here re-renders; a list answers with the store's own redacted
//! records; a forget answers with what went. The store's `secret()`
//! has exactly one tool-side consumer, [`IDENTITY_EXEC`]: the credential
//! is injected into one child process's environment — the sanctioned
//! *destination* — and only that command's output returns, so the value
//! reaches its work without ever entering the conversation.
//!
//! These tools join the interactive chain only. A forge dispatch — whose
//! prompt is text a stranger wrote — never resolves them, so an injected
//! comment cannot ask its way to the accounts or act with their
//! credentials.
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
use rhema_core::{ToolCall, ToolContext, ToolResolver, ToolResult, ToolSpec};
use rhema_identity::{Account, AccountDraft, IdentityStore, Secret, SecretKind};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::config::ForgeKind;
use crate::watch_store::ClawStore;
/// `identity_save`: store an account (label, service, url, username, and
/// the secret — required) and optionally register its forge watch.
struct IdentitySave {
accounts: Arc<dyn IdentityStore>,
watches: Arc<ClawStore>,
}
/// The save's arguments. `secret_kind` defaults to a token; a password
/// is stored but is never model-accessible or pollable in v1.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SaveArgs {
label: String,
service: String,
#[serde(default)]
url: Option<String>,
#[serde(default)]
username: Option<String>,
secret: String,
#[serde(default)]
secret_kind: Option<String>,
#[serde(default)]
watch: bool,
}
/// What saving an account earns: the redacted account, whether the
/// service resolved to a forge the daemon watches, and whether the watch
/// got registered.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct SavedAccount {
/// The stored account, redacted.
pub account: Account,
/// Whether `service`/`url` resolved to a watchable forge.
pub forge_known: bool,
/// Whether the forge watch got registered (asked for, forge known,
/// and not already watched).
pub watch_added: bool,
}
/// The forge an account's `service` (then its `url` host) names, when it
/// is one the daemon watches. A self-hosted instance is only recognizable
/// through an explicit service name — a URL alone cannot distinguish a
/// Gitea from a GitLab.
pub fn forge_of(service: &str, url: Option<&str>) -> Option<ForgeKind> {
// Case-insensitive on the service: a user says "GitHub", the store
// normalizes to "github", and both should name the forge.
if let Some(kind) = ForgeKind::parse(service.trim().to_lowercase().as_str()) {
return Some(kind);
}
let url = url?;
let host = url
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(url)
.split('/')
.next()?;
match host.trim_end_matches('/') {
"github.com" => Some(ForgeKind::GitHub),
"gitlab.com" => Some(ForgeKind::GitLab),
_ => None,
}
}
/// Parses the optional `secret_kind` spelling; `None` (absent) means a
/// token. An unknown spelling is an in-band error. Shared with the HTTP
/// route, which validates the same shape.
pub fn parse_kind(kind: Option<&str>) -> Result<SecretKind, String> {
match kind.map(str::trim).filter(|name| !name.is_empty()) {
None => Ok(SecretKind::Token),
Some("token") => Ok(SecretKind::Token),
Some("password") => Ok(SecretKind::Password),
Some(other) => Err(format!(
"unknown secret_kind {other:?}: a kind is 'token' or 'password'"
)),
}
}
/// Stores an account and, when asked and the service names a watchable
/// forge, registers its account watch — the explicit-at-creation sync:
/// no background reconciliation, so this is the one moment a watch
/// appears. The store's error and the watch verdict both land in-band.
pub fn save_account(
accounts: &dyn IdentityStore,
watches: &ClawStore,
draft: AccountDraft,
watch: bool,
) -> Result<SavedAccount, String> {
let account = accounts.put(draft).map_err(|err| err.to_string())?;
let forge = forge_of(&account.service, account.url.as_deref());
let mut saved = SavedAccount {
account,
forge_known: forge.is_some(),
watch_added: false,
};
if watch {
if let Some(kind) = forge {
let host = saved.account.url.as_deref().filter(|url| !url.is_empty());
saved.watch_added = watches
.add_account_watch(kind.name(), host, &saved.account.id)
.map_err(|err| err.to_string())?;
}
// Asked to watch a service the daemon cannot watch: the save
// stands, the watch verdict says why not.
}
Ok(saved)
}
/// Forgets an account and cascades its derived account watches — the
/// other half of the explicit sync. Returns whether the account existed
/// and how many watches went with it.
pub fn forget_account(
accounts: &dyn IdentityStore,
watches: &ClawStore,
id: &str,
) -> Result<(bool, usize), String> {
let forgot = accounts.forget(id).map_err(|err| err.to_string())?;
let removed = if forgot {
watches
.remove_account_watches_for(id)
.map_err(|err| err.to_string())?
} else {
0
};
Ok((forgot, removed))
}
#[async_trait]
impl Tool for IdentitySave {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
"identity_save",
"Store an account the daemon holds on a service: a label, the service's name (e.g. \
'github'), optionally its URL and the username, and the secret (an API token, or a \
password). Act with a stored account through identity_exec — the credential \
itself is never shown to you. With watch=true and a forge the daemon watches, \
the account's notifications are followed.",
json!({
"type": "object",
"properties": {
"label": { "type": "string",
"description": "Display name, e.g. 'acme releases'." },
"service": { "type": "string",
"description": "The service, lowercase: github, gitlab, gitea, or any name." },
"url": { "type": "string",
"description": "Base URL, for self-hosted instances." },
"username": { "type": "string",
"description": "The login name on the service." },
"secret": { "type": "string",
"description": "The credential to store. Never echoed back." },
"secret_kind": { "type": "string",
"enum": ["token", "password"],
"description": "What the secret is; default token." },
"watch": { "type": "boolean",
"description": "Follow this forge identity's notifications; default false." }
},
"required": ["label", "service", "secret"]
}),
)
}
async fn invoke(&self, args: Value) -> Result<String, String> {
let args: SaveArgs =
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
let kind = parse_kind(args.secret_kind.as_deref())?;
let mut draft = AccountDraft::new(args.label, args.service, Secret::new(kind, args.secret));
if let Some(url) = args.url {
draft = draft.with_url(url);
}
if let Some(username) = args.username {
draft = draft.with_username(username);
}
let saved = save_account(
self.accounts.as_ref(),
self.watches.as_ref(),
draft,
args.watch,
)?;
Ok(json!({
"saved": true,
"id": saved.account.id,
"label": saved.account.label,
"service": saved.account.service,
"secret_kind": saved.account.secret_kind,
"watched": saved.watch_added,
"watchable_forge": saved.forge_known,
})
.to_string())
}
}
/// `identity_list`: every account, redacted — the store's own records,
/// which have no secret field to leak.
struct IdentityList {
accounts: Arc<dyn IdentityStore>,
}
#[async_trait]
impl Tool for IdentityList {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
"identity_list",
"List the accounts the daemon holds. Secrets are never shown — only each account's \
label, service, url, username, and what kind of secret it keeps.",
json!({ "type": "object", "properties": {} }),
)
}
async fn invoke(&self, _args: Value) -> Result<String, String> {
let accounts = self.accounts.list().map_err(|err| err.to_string())?;
Ok(json!({ "accounts": accounts }).to_string())
}
}
/// `identity_forget`: remove an account, its secret with it, and the
/// account watches derived from it.
struct IdentityForget {
accounts: Arc<dyn IdentityStore>,
watches: Arc<ClawStore>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ForgetArgs {
id: String,
}
#[async_trait]
impl Tool for IdentityForget {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
"identity_forget",
"Forget a stored account by id: the secret goes with it, and any forge watch that \
followed this identity is removed too.",
json!({
"type": "object",
"properties": {
"id": { "type": "string", "description": "The account id (identity_list's ids)." }
},
"required": ["id"]
}),
)
}
async fn invoke(&self, args: Value) -> Result<String, String> {
let args: ForgetArgs =
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
let (forgot, watches_removed) =
forget_account(self.accounts.as_ref(), self.watches.as_ref(), &args.id)?;
Ok(json!({
"forgot": forgot,
"watches_removed": watches_removed,
})
.to_string())
}
}
/// The identity tool set over the account store and the watch table.
pub fn registry(accounts: Arc<dyn IdentityStore>, watches: Arc<ClawStore>) -> ToolRegistry {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(IdentitySave {
accounts: accounts.clone(),
watches: watches.clone(),
}));
registry.register(Arc::new(IdentityList {
accounts: accounts.clone(),
}));
registry.register(Arc::new(IdentityForget { accounts, watches }));
registry
}
/// The tool's name on the wire.
pub const IDENTITY_EXEC: &str = "identity_exec";
/// How long one command may run before it is killed.
const EXEC_TIMEOUT: Duration = Duration::from_secs(180);
/// The longest a caller may stretch a command's budget.
const EXEC_TIMEOUT_MAX: Duration = Duration::from_secs(600);
/// The output tail a result carries, mirroring the bash tool's caps:
/// past either the byte or the line limit the head is elided and the
/// tail kept — a failure's summary lives at the end.
fn render_truncated(bytes: &[u8]) -> String {
const LIMIT: usize = 50 * 1024;
const LINES: usize = 2000;
let text = String::from_utf8_lossy(bytes);
let mut lines: Vec<&str> = text.lines().collect();
if bytes.len() <= LIMIT && lines.len() <= LINES {
return text.into_owned();
}
if lines.len() > LINES {
lines = lines[lines.len() - LINES..].to_vec();
}
let mut kept = String::new();
for line in lines {
if kept.len() + line.len() + 1 > LIMIT {
break;
}
kept.push_str(line);
kept.push('\n');
}
let elided = bytes.len() - kept.len();
format!("[output truncated — {elided} bytes elided]\n{kept}")
}
/// The service's own env names a token answers as, beyond the generic
/// `ACCOUNT_*` set: the names its CLI and API clients read.
fn service_env_names(service: &str) -> &'static [&'static str] {
match service.trim().to_ascii_lowercase().as_str() {
"github" => &["GITHUB_TOKEN", "GH_TOKEN"],
"gitea" => &["GITEA_TOKEN"],
"gitlab" => &["GITLAB_TOKEN"],
_ => &[],
}
}
/// The git credential helper the command runs with: a shell snippet
/// that answers git's https prompts from the injected environment. The
/// secret lives only in the child process's env — never in the value
/// itself, never in the output.
const GIT_CREDENTIAL_HELPER: &str = "!f() { \
printf 'username=%s\\n' \"$ACCOUNT_USERNAME\"; \
printf 'password=%s\\n' \"$ACCOUNT_SECRET\"; }; f";
/// `identity_exec`: act with a stored account. The one tool-side
/// consumer of the store's `secret()` — the credential is injected into
/// one child process's environment and a git credential helper, the
/// command runs, and only its output returns. The value reaches its
/// destination; it never reaches the conversation.
pub struct IdentityExec {
accounts: Arc<dyn IdentityStore>,
workspace: PathBuf,
}
impl IdentityExec {
/// The executor over `accounts`, running commands in `workspace`.
pub fn new(accounts: Arc<dyn IdentityStore>, workspace: impl Into<PathBuf>) -> Self {
Self {
accounts,
workspace: workspace.into(),
}
}
async fn exec(
&self,
call: ToolCall,
progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
) -> ToolResult {
let id = match call.args.get("id").and_then(Value::as_str) {
Some(id) => id.trim().to_owned(),
None => return ToolResult::failure(call.id, "missing required argument: id"),
};
let command = match call.args.get("command").and_then(Value::as_str) {
Some(command) => command,
None => return ToolResult::failure(call.id, "missing required argument: command"),
};
let timeout = match call.args.get("timeout_secs") {
None | Some(Value::Null) => EXEC_TIMEOUT,
Some(value) => {
let Some(secs) = value.as_u64() else {
return ToolResult::failure(call.id, "timeout_secs must be a number");
};
Duration::from_secs(secs.clamp(1, EXEC_TIMEOUT_MAX.as_secs()))
}
};
let Some(account) = self
.accounts
.get(&id)
.map_err(|err| err.to_string())
.ok()
.flatten()
else {
return ToolResult::failure(
call.id,
format!("no account {id:?} — identity_list names them"),
);
};
let Some(secret) = self
.accounts
.secret(&id)
.map_err(|err| err.to_string())
.ok()
.flatten()
else {
return ToolResult::failure(call.id, format!("account {id:?} has no secret"));
};
let mut cmd = tokio::process::Command::new("bash");
cmd.arg("-c")
.arg(command)
.current_dir(&self.workspace)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.env("ACCOUNT_ID", &account.id)
.env("ACCOUNT_SERVICE", &account.service)
.env(
"ACCOUNT_USERNAME",
account.username.as_deref().unwrap_or(""),
)
.env("ACCOUNT_SECRET", &secret.value)
// git asks https credentials from the helper, which reads
// the injected env; a failed auth must fail, not hang.
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "credential.helper")
.env("GIT_CONFIG_VALUE_0", GIT_CREDENTIAL_HELPER)
.env("GIT_TERMINAL_PROMPT", "0");
if secret.kind == SecretKind::Token {
for name in service_env_names(&account.service) {
cmd.env(name, &secret.value);
}
}
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(err) => {
return ToolResult::failure(call.id, format!("failed to spawn bash: {err}"));
}
};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
if let Some(stdout) = child.stdout.take() {
pump(stdout, false, tx.clone());
}
if let Some(stderr) = child.stderr.take() {
pump(stderr, true, tx.clone());
}
drop(tx);
let deadline = tokio::time::Instant::now() + timeout;
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
loop {
tokio::select! {
_ = tokio::time::sleep_until(deadline) => {
let _ = child.start_kill();
let _ = child.wait().await;
return ToolResult::failure(
call.id,
format!(
"command timed out after {}s and was killed",
timeout.as_secs()
),
);
}
line = rx.recv() => match line {
Some((is_stderr, line)) => {
let sink = if is_stderr { &mut stderr } else { &mut stdout };
sink.extend_from_slice(line.as_bytes());
sink.push(b'\n');
progress(&format!("{line}\n"));
}
None => break,
}
}
}
let status = match tokio::time::timeout(timeout, child.wait()).await {
Ok(Ok(status)) => status,
_ => {
return ToolResult::failure(
call.id,
format!(
"command timed out after {}s and was killed",
timeout.as_secs()
),
);
}
};
let content = format!(
"exit code {}\n--- stdout ---\n{}\n--- stderr ---\n{}",
status
.code()
.map(|code| code.to_string())
.unwrap_or_else(|| "signal".to_owned()),
render_truncated(&stdout),
render_truncated(&stderr),
);
let result = if status.success() {
ToolResult::success(call.id, content)
} else {
ToolResult::failure(call.id, content)
};
match status.code() {
Some(code) => result.with_code(code),
None => result,
}
}
}
/// Reads `stream` line by line into `tx`, tagged with its stream, until
/// EOF — the live side of the command's output.
fn pump<R>(stream: R, is_stderr: bool, tx: tokio::sync::mpsc::UnboundedSender<(bool, String)>)
where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
{
tokio::spawn(async move {
use tokio::io::AsyncBufReadExt;
let mut lines = tokio::io::BufReader::new(stream).lines();
while let Ok(Some(line)) = lines.next_line().await {
if tx.send((is_stderr, line)).is_err() {
break;
}
}
});
}
#[async_trait]
impl ToolResolver for IdentityExec {
fn specs(&self) -> Vec<ToolSpec> {
vec![ToolSpec::new(
IDENTITY_EXEC,
"Act with a stored account's credential without ever seeing \
it: run one shell command in this session's workspace with \
the secret injected as environment variables — ACCOUNT_SECRET, \
the service's own (GITHUB_TOKEN/GH_TOKEN, GITEA_TOKEN, \
GITLAB_TOKEN) — and a git credential helper preconfigured, \
so plain `git clone`/`git push` over https and API calls \
(curl, gh) just work. Only the command's output comes back; \
the secret itself never enters the conversation. Pick `id` \
from identity_list.",
json!({
"type": "object",
"properties": {
"id": { "type": "string",
"description": "The account's id, as identity_list \
reports it." },
"command": { "type": "string",
"description": "The shell command to run." },
"timeout_secs": { "type": "integer",
"description": "Kill the command after this many \
seconds; 180 by default, 600 at most." }
},
"required": ["id", "command"]
}),
)]
}
async fn resolve(&self, call: ToolCall, _ctx: &ToolContext) -> ToolResult {
self.exec(call, &mut |_| {}).await
}
async fn resolve_streaming(
&self,
call: ToolCall,
ctx: &ToolContext,
progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
) -> ToolResult {
if ctx.cancelled.load(std::sync::atomic::Ordering::Relaxed) {
return ToolResult::failure(call.id, "cancelled");
}
self.exec(call, progress).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parts() -> (Arc<dyn IdentityStore>, Arc<ClawStore>) {
(
Arc::new(rhema_identity::VolatileIdentityStore::new()),
Arc::new(ClawStore::in_memory().expect("store opens")),
)
}
fn call(tool: &dyn Tool, args: Value) -> Result<String, String> {
futures_block_on(tool.invoke(args))
}
/// The tools are async over a sync store: one block-on harness for
/// the tests.
fn futures_block_on<F: std::future::Future>(future: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime builds")
.block_on(future)
}
#[test]
fn save_answers_without_the_secret_and_watch_lands_for_a_known_forge() {
let (accounts, watches) = parts();
let tool = IdentitySave {
accounts: accounts.clone(),
watches: watches.clone(),
};
let answer = call(
&tool,
json!({
"label": "acme bot",
"service": "github",
"secret": "sekrit-token",
"watch": true
}),
)
.expect("saves");
assert!(!answer.contains("sekrit-token"), "{answer}");
assert!(answer.contains("\"watched\":true"), "{answer}");
let listed = call(
&IdentityList {
accounts: accounts.clone(),
},
json!({}),
)
.expect("lists");
assert!(!listed.contains("sekrit-token"), "{listed}");
assert!(listed.contains("acme bot"), "{listed}");
let watched = watches.list_account_watches().expect("watches read");
assert_eq!(watched.len(), 1);
assert_eq!(watched[0].forge, "github");
}
#[test]
fn forget_cascades_the_derived_watch() {
let (accounts, watches) = parts();
let saved = save_account(
accounts.as_ref(),
watches.as_ref(),
AccountDraft::new("acme", "github", Secret::new(SecretKind::Token, "t")),
true,
)
.expect("saves");
assert!(saved.watch_added);
let tool = IdentityForget {
accounts: accounts.clone(),
watches: watches.clone(),
};
let answer = call(&tool, json!({ "id": saved.account.id })).expect("forgets");
assert!(answer.contains("\"forgot\":true"), "{answer}");
assert!(answer.contains("\"watches_removed\":1"), "{answer}");
assert!(watches.list_account_watches().expect("reads").is_empty());
assert!(!accounts.secret(&saved.account.id).expect("reads").is_some());
}
#[test]
fn a_watch_request_on_an_unknown_service_saves_without_watching() {
let (accounts, watches) = parts();
let saved = save_account(
accounts.as_ref(),
watches.as_ref(),
AccountDraft::new("mail", "smtp", Secret::new(SecretKind::Password, "p"))
.with_url("https://mail.example.net"),
true,
)
.expect("saves");
assert!(!saved.watch_added);
assert!(!saved.forge_known);
assert!(watches.list_account_watches().expect("reads").is_empty());
}
#[test]
fn forge_of_matches_services_and_production_hosts_only() {
assert_eq!(forge_of("GitHub", None), Some(ForgeKind::GitHub));
assert_eq!(
forge_of("whatever", Some("https://github.com/acme")),
Some(ForgeKind::GitHub)
);
assert_eq!(
forge_of("whatever", Some("https://gitlab.com/acme/w")),
Some(ForgeKind::GitLab)
);
assert_eq!(
forge_of("whatever", Some("https://git.example.net/acme/w")),
None,
"a self-hosted host is unrecognizable by URL alone"
);
assert_eq!(
forge_of("gitea", Some("https://git.example.net")),
Some(ForgeKind::Gitea)
);
}
#[test]
fn an_unknown_secret_kind_is_rejected_in_band() {
let (accounts, watches) = parts();
let tool = IdentitySave { accounts, watches };
let err = call(
&tool,
json!({
"label": "x",
"service": "github",
"secret": "t",
"secret_kind": "pin"
}),
)
.expect_err("rejected");
assert!(err.contains("unknown secret_kind"), "{err}");
}
/// The use half of the accounts: the command runs with the
/// service's env injected, the git helper configured, and none of
/// the secret in the result.
#[tokio::test]
async fn identity_exec_runs_with_the_credential_in_its_environment() {
let accounts = Arc::new(rhema_identity::VolatileIdentityStore::new());
let stored = accounts
.put(
AccountDraft::new(
"github releases",
"github",
Secret::new(SecretKind::Token, "tok-1234567890"),
)
.with_username("kosmos-ai-bot"),
)
.expect("the account stores");
let workspace = tempfile::tempdir().expect("tempdir");
let exec = IdentityExec::new(accounts, workspace.path());
let id = stored.id.clone();
async fn run(exec: &IdentityExec, id: &str, command: &str) -> rhema_core::ToolResult {
exec.resolve(
ToolCall {
id: "c1".into(),
name: IDENTITY_EXEC.into(),
args: json!({ "id": id, "command": command }),
provider_data: Value::Null,
},
&rhema_core::ToolContext::default(),
)
.await
}
// The service's token and the generic set are all the same
// value, and the username rode along.
let result = run(
&exec,
&id,
"[ -n \"$GITHUB_TOKEN\" ] && [ \"$GH_TOKEN\" = \"$ACCOUNT_SECRET\" ] \
&& [ \"$ACCOUNT_USERNAME\" = \"kosmos-ai-bot\" ] \
&& [ \"$GIT_CONFIG_COUNT\" = \"1\" ] && echo ok",
)
.await;
assert!(!result.is_error, "{}", result.content);
assert!(result.content.contains("ok"), "{}", result.content);
assert!(
!result.content.contains("tok-1234567890"),
"the secret never rides the answer: {}",
result.content
);
// The helper answers git's prompt shape from the env.
let result = run(
&exec,
&id,
"printf 'username=%s password=%s' \"$ACCOUNT_USERNAME\" \"$ACCOUNT_SECRET\" \
| grep -c kosmos-ai-bot",
)
.await;
assert!(!result.is_error, "{}", result.content);
}
#[tokio::test]
async fn identity_exec_reports_exit_codes_and_unknown_accounts() {
let accounts = Arc::new(rhema_identity::VolatileIdentityStore::new());
let stored = accounts
.put(AccountDraft::new(
"gitea",
"gitea",
Secret::new(SecretKind::Token, "gt-1"),
))
.expect("the account stores");
let workspace = tempfile::tempdir().expect("tempdir");
let exec = IdentityExec::new(accounts.clone(), workspace.path());
let id = stored.id.clone();
let call = |id: &str, args: Value| ToolCall {
id: "c1".into(),
name: IDENTITY_EXEC.into(),
args: {
let mut args = args;
args["id"] = json!(id);
args
},
provider_data: Value::Null,
};
let failure = exec
.resolve(
call(&id, json!({ "command": "exit 3" })),
&rhema_core::ToolContext::default(),
)
.await;
assert!(failure.is_error);
assert_eq!(failure.code, Some(3));
assert!(failure.content.contains("exit code 3"));
// A gitea token answers as GITEA_TOKEN; a github account it is not.
let check = exec
.resolve(
call(
&id,
json!({
"command": "[ -n \"$GITEA_TOKEN\" ] && [ -z \"$GITHUB_TOKEN\" ]"
}),
),
&rhema_core::ToolContext::default(),
)
.await;
assert!(!check.is_error, "{}", check.content);
let missing = exec
.resolve(
call("99", json!({ "command": "echo hi" })),
&rhema_core::ToolContext::default(),
)
.await;
assert!(missing.is_error);
assert!(
missing.content.contains("no account"),
"{}",
missing.content
);
// The command runs in the session's workspace.
let pwd = exec
.resolve(
call(&id, json!({ "command": "pwd" })),
&rhema_core::ToolContext::default(),
)
.await;
assert!(
pwd.content
.contains(workspace.path().to_str().expect("utf8")),
"{}",
pwd.content
);
}
}
+443
View File
@@ -0,0 +1,443 @@
//! The attachment store and the `attach_file` tool: how a turn hands
//! the human a file from its workspace — a screenshot it just took, a
//! diff, a log the transcript's snippets cut.
//!
//! A stored attachment is a copy, not a reference: the bytes move into
//! the attachments directory beside the database under an id, so the
//! turn's later work (or a workspace cleanup) cannot break a link the
//! transcript already served. The transcript carries the metadata —
//! name, size, media kind, caption — and a client fetches bytes
//! through `GET /v1/attachments/{id}`. A size cap keeps one tool call
//! from turning the store into a workspace mirror; a file past the cap
//! is an in-band error, and the model can cut it down first.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
use rhema_core::ToolSpec;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::hub::{EventHub, TurnEvent};
use rhema_rsd::ResourceKey;
/// How large one attachment may be. Generous for screenshots and
/// text; a whole dataset does not belong in a chat.
pub const MAX_ATTACHMENT_BYTES: usize = 25 * 1024 * 1024;
/// Why an attachment operation failed.
#[derive(Debug, thiserror::Error)]
pub enum AttachmentError {
/// The backend failed to read or write.
#[error("attachment store failed: {0}")]
Backend(#[source] std::io::Error),
/// A stored attachment's file went missing.
#[error("attachment {0} is gone from the store")]
Missing(String),
}
/// One stored attachment: the metadata the transcript carries.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Stored {
/// The attachment's id (`att-…`), the fetch path's address.
pub id: String,
/// The original file's name, as the tool call named it.
pub name: String,
/// The byte size.
pub size: u64,
/// The media kind the name implies: `image`, `code`, or `file`.
pub media: &'static str,
}
/// The attachments directory. Shareable behind an `Arc`; every
/// operation takes `&self`.
pub struct AttachmentStore {
root: PathBuf,
}
impl AttachmentStore {
/// Opens (creating if needed) the attachments directory at `root` —
/// beside the database, the same state volume the transcript lives
/// on, so a served id never outlives its bytes.
pub fn open(root: impl Into<PathBuf>) -> Result<Self, AttachmentError> {
let root = root.into();
std::fs::create_dir_all(&root).map_err(AttachmentError::Backend)?;
Ok(Self { root })
}
/// Stores `bytes` under `name`. The name is sanitized to its file
/// name and rides the stored file name only for content-type
/// guesses and human downloads; the id is the address.
pub fn put(&self, name: &str, bytes: &[u8]) -> Result<Stored, AttachmentError> {
static ATTACHMENT_SEQ: AtomicU64 = AtomicU64::new(0);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let id = format!(
"att-{}-{:04x}",
now,
ATTACHMENT_SEQ.fetch_add(1, Ordering::Relaxed)
);
let name = sanitize_name(name);
let media = media_of(&name);
std::fs::write(self.root.join(&id), bytes).map_err(AttachmentError::Backend)?;
// The name rides a sidecar: the transcript carries the original
// spelling, and the fetch route needs the file name back for
// its content type and Content-Disposition.
std::fs::write(self.root.join(format!("{id}.name")), &name)
.map_err(AttachmentError::Backend)?;
Ok(Stored {
id,
name,
size: bytes.len() as u64,
media,
})
}
/// The stored attachment's bytes, when it exists.
pub fn get(&self, id: &str) -> Result<Option<(Stored, Vec<u8>)>, AttachmentError> {
if !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return Ok(None);
}
let path = self.root.join(id);
let metadata = match std::fs::metadata(&path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(AttachmentError::Backend(err)),
};
let bytes = std::fs::read(&path).map_err(AttachmentError::Backend)?;
let name = std::fs::read_to_string(self.root.join(format!("{id}.name")))
.ok()
.filter(|name| !name.is_empty())
.unwrap_or_else(|| id.to_owned());
let stored = Stored {
id: id.to_owned(),
name,
size: metadata.len(),
media: "file",
};
Ok(Some((stored, bytes)))
}
}
/// Reduces a caller-supplied name to a safe single file name: its last
/// path component, path separators and control characters folded to
/// `_`, never empty. The original spelling travels in the transcript's
/// metadata; this form only guards the store's content-type guesses.
fn sanitize_name(name: &str) -> String {
let cleaned: String = name
.rsplit(['/', '\\'])
.next()
.unwrap_or(name)
.chars()
.map(|c| if c.is_control() { '_' } else { c })
.collect();
let trimmed = cleaned.trim();
if trimmed.is_empty() {
"attachment".to_owned()
} else {
trimmed.to_owned()
}
}
/// The media kind a file name implies, the shape the wire's
/// `attachment` events carry: images render inline where a client can,
/// source files read as code, everything else downloads.
pub fn media_of(name: &str) -> &'static str {
let extension = Path::new(name)
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase());
match extension.as_deref() {
Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "ico" | "avif") => "image",
Some(
"rs" | "go" | "c" | "h" | "cpp" | "hpp" | "py" | "rb" | "java" | "kt" | "swift" | "js"
| "mjs" | "cjs" | "ts" | "tsx" | "jsx" | "vue" | "svelte" | "sh" | "bash" | "zsh"
| "fish" | "sql" | "toml" | "yaml" | "yml" | "json" | "jsonc" | "xml" | "html" | "css"
| "scss" | "md" | "markdown" | "txt" | "diff" | "patch" | "lua" | "pl" | "php" | "cs"
| "scala" | "hs" | "ex" | "exs" | "zig" | "gradle" | "tf",
) => "code",
_ => "file",
}
}
/// The content type a file name implies, for the fetch route's header.
pub fn content_type(name: &str) -> &'static str {
let extension = Path::new(name)
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase());
match extension.as_deref() {
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("svg") => "image/svg+xml",
Some("bmp") => "image/bmp",
Some("ico") => "image/x-icon",
Some("avif") => "image/avif",
Some("json") => "application/json",
Some("pdf") => "application/pdf",
Some("html") => "text/html; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("md" | "markdown" | "txt" | "log" | "diff" | "patch") => "text/plain; charset=utf-8",
Some("xml") => "application/xml",
Some("wasm") => "application/wasm",
Some("zip") => "application/zip",
Some("tar") => "application/x-tar",
Some("gz") => "application/gzip",
_ => "application/octet-stream",
}
}
/// The tool's name on the wire.
pub const ATTACH_FILE: &str = "attach_file";
/// `attach_file`: copy a workspace file into the attachment store and
/// put its card on the stream. Built per dispatch — the event must
/// land on the asking session.
struct AttachFile {
store: Arc<AttachmentStore>,
workspace: PathBuf,
hub: Arc<EventHub>,
key: ResourceKey,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AttachArgs {
path: String,
#[serde(default)]
caption: Option<String>,
}
#[async_trait]
impl Tool for AttachFile {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
ATTACH_FILE,
"Attach a file from this session's workspace to the chat: the \
human gets a card with the file's name and can view or \
download it. Use it to hand over artifacts a reply snippet \
cannot carry — a screenshot, a diff, a generated report, a \
log. The path is workspace-relative. Large files are \
refused; attach the relevant cut instead.",
json!({
"type": "object",
"properties": {
"path": { "type": "string",
"description": "The workspace-relative file path, \
e.g. 'screenshots/failure.png'." },
"caption": { "type": "string",
"description": "One line about what the file is \
and why it matters." }
},
"required": ["path"]
}),
)
}
async fn invoke(&self, args: Value) -> Result<String, String> {
let args: AttachArgs =
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
let relative = Path::new(&args.path);
if relative.is_absolute()
|| relative
.components()
.any(|c| c == std::path::Component::ParentDir)
{
return Err(format!(
"path must be relative to the workspace, without '..': {:?}",
args.path
));
}
let path = self.workspace.join(relative);
let metadata = std::fs::metadata(&path)
.map_err(|err| format!("cannot attach {:?}: {err}", args.path))?;
if metadata.len() > MAX_ATTACHMENT_BYTES as u64 {
return Err(format!(
"cannot attach {:?}: {} bytes exceeds the {}-byte limit; attach a smaller cut",
args.path,
metadata.len(),
MAX_ATTACHMENT_BYTES
));
}
let bytes =
std::fs::read(&path).map_err(|err| format!("cannot attach {:?}: {err}", args.path))?;
let stored = self
.store
.put(&args.path, &bytes)
.map_err(|err| err.to_string())?;
let caption = args
.caption
.as_deref()
.map(str::trim)
.filter(|caption| !caption.is_empty())
.map(str::to_owned);
self.hub.emit(
&self.key,
TurnEvent::Attachment {
id: stored.id.clone(),
name: stored.name.clone(),
media: stored.media.to_owned(),
size: stored.size,
caption: caption.clone(),
},
);
Ok(json!({
"attached": true,
"id": stored.id,
"name": stored.name,
"size": stored.size,
"media": stored.media,
"caption": caption,
})
.to_string())
}
}
/// The attachment tool set over `store`: `attach_file`.
pub fn registry(
store: Arc<AttachmentStore>,
workspace: impl Into<PathBuf>,
hub: Arc<EventHub>,
key: ResourceKey,
) -> ToolRegistry {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(AttachFile {
store,
workspace: workspace.into(),
hub,
key,
}));
registry
}
#[cfg(test)]
mod tests {
use super::*;
fn store(dir: &Path) -> Arc<AttachmentStore> {
Arc::new(AttachmentStore::open(dir.join("attachments")).expect("store opens"))
}
#[test]
fn a_stored_attachment_round_trips_through_its_id() {
let dir = tempfile::tempdir().expect("tempdir");
let store = store(dir.path());
let stored = store
.put("screenshots/failure.png", b"\x89PNG")
.expect("puts");
assert_eq!(
stored.name, "failure.png",
"the name folds to its file name"
);
assert_eq!(stored.media, "image");
assert_eq!(stored.size, 4);
let (fetched, bytes) = store.get(&stored.id).expect("reads").expect("exists");
assert_eq!(bytes, b"\x89PNG");
assert_eq!(fetched.id, stored.id);
assert_eq!(
store.get("../escape").expect("reads"),
None,
"a path is not an id"
);
assert_eq!(store.get("att-with$igns").expect("reads"), None);
}
#[test]
fn media_kinds_follow_the_extension() {
assert_eq!(media_of("a.PNG"), "image");
assert_eq!(media_of("src/main.rs"), "code");
assert_eq!(media_of("notes.md"), "code");
assert_eq!(media_of("archive.tar.gz"), "file");
assert_eq!(media_of("no-extension"), "file");
}
#[test]
fn content_types_follow_the_extension() {
assert_eq!(content_type("a.png"), "image/png");
assert_eq!(content_type("run.log"), "text/plain; charset=utf-8");
assert_eq!(content_type("blob.bin"), "application/octet-stream");
}
#[test]
fn a_name_that_sanitizes_to_nothing_becomes_a_placeholder() {
let dir = tempfile::tempdir().expect("tempdir");
let store = store(dir.path());
let stored = store.put("/", b"x").expect("puts");
assert_eq!(stored.name, "attachment");
}
/// The tool, end to end: a workspace file in, a stored attachment
/// and a stream card out, with the caption riding.
#[tokio::test]
async fn attach_file_copies_a_workspace_file_and_emits_the_card() {
let dir = tempfile::tempdir().expect("tempdir");
let workspace = dir.path().join("interactive");
std::fs::create_dir_all(workspace.join("out")).expect("workspace");
std::fs::write(workspace.join("out/report.md"), "# report").expect("file");
let store = store(dir.path());
let hub = Arc::new(EventHub::default());
let key = ResourceKey::from("interactive:web");
let tool = AttachFile {
store: store.clone(),
workspace: workspace.clone(),
hub: hub.clone(),
key: key.clone(),
};
let answer = tool
.invoke(json!({"path": "out/report.md", "caption": "the draft"}))
.await
.expect("in-band ok");
let answer: Value = serde_json::from_str(&answer).expect("the answer parses");
assert_eq!(answer["attached"], true);
assert_eq!(answer["name"], "report.md");
assert_eq!(answer["media"], "code");
let id = answer["id"].as_str().expect("an id").to_owned();
assert!(
store.get(&id).expect("reads").is_some(),
"the bytes are stored"
);
let (history, _) = hub.subscribe_with_history(&key);
assert_eq!(
history.last(),
Some(&TurnEvent::Attachment {
id,
name: "report.md".into(),
media: "code".into(),
size: 8,
caption: Some("the draft".into()),
})
);
}
#[tokio::test]
async fn escapes_oversized_files_and_missing_paths_fail_in_band() {
let dir = tempfile::tempdir().expect("tempdir");
let workspace = dir.path().join("interactive");
std::fs::create_dir_all(&workspace).expect("workspace");
let tool = AttachFile {
store: store(dir.path()),
workspace: workspace.clone(),
hub: Arc::new(EventHub::default()),
key: ResourceKey::from("interactive:web"),
};
for bad in [
json!({"path": "../outside.txt"}),
json!({"path": "/etc/hostname"}),
json!({"path": "missing.txt"}),
] {
assert!(
tool.invoke(bad).await.is_err(),
"an escape or a miss is an in-band error"
);
}
}
}
+982
View File
@@ -0,0 +1,982 @@
//! The backend registry: every provider the deployment can speak to, one
//! active at a time, switchable at runtime.
//!
//! At boot the registry constructs every provider whose credentials
//! resolve — env vars win, the claw's own stored credentials serve
//! next, the stored rhema config after that, and the keyless
//! llama-cpp backend is always constructible, so a daemon can always
//! boot. The same construction runs over HTTP: credentials saved
//! through the surface insert (or replace) a backend in the live
//! registry and persist in the settings store. A backend is one
//! *instance*, named by its `id` — the kind unless a login named it,
//! so two llama.cpp servers coexist under two ids; ids of instances
//! discovered from env or the config are always their kind. One
//! backend is *active*; handlers read the registry per
//! dispatch, so a switch takes effect on the next turn without a
//! restart. The active pair is deployment state: seeded from the config
//! file, persisted in the settings store, changeable over `/v1/providers`
//! — the chart pins nothing.
//!
//! The per-kind default models are fallbacks only, in one place here:
//! openrouter's `openrouter/auto` deliberately replaces the old
//! copy-pasted google default — a router deserves its own router id.
use std::collections::BTreeMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, PoisonError, RwLock, RwLockWriteGuard};
use rhema_autonomous_agent::async_trait;
use rhema_core::{EventSink, ModelOutput, ModelProvider, ModelRequest, ProviderError};
use rhema_provider_anthropic::{AnthropicProvider, ZAI_BASE_URL};
use rhema_provider_copilot::CopilotProvider;
use rhema_provider_google::GoogleProvider;
use rhema_provider_llama_cpp::LlamaCppProvider;
use rhema_provider_openai_compatible::OpenAiCompatibleProvider;
use rhema_provider_openrouter::OpenRouterProvider;
use rhema_provider::ModelCard;
use serde::{Deserialize, Serialize};
/// The default model per backend kind, when neither the operator nor the
/// settings store names one.
pub fn default_model_for(kind: &str) -> &'static str {
match kind {
"llama-cpp" => "local",
"anthropic" => "claude-sonnet-4-5",
"openai" => "gpt-5",
"openrouter" => "openrouter/auto",
"deepseek" => "deepseek-chat",
// The Coding Plan's flagship, as Z.ai's own client spells it.
"zai" => "GLM-5.3",
"copilot" => "gpt-4.1",
_ => "gemini-3.8-flash",
}
}
/// The listing of a card-backed backend, boxed for the registry; a
/// backend without one (the mock, llama's raw id list) carries `None`.
pub type ModelListing = Pin<Box<dyn Future<Output = Result<Vec<ModelCard>, ProviderError>> + Send>>;
/// One constructible backend instance: the provider seam, the id the
/// registry keys it under, its kind, its model fallback, and how to
/// list its models.
pub struct Backend {
/// The instance id: the kind unless the login named it. The
/// registry's key, the credentials row's suffix, and the active
/// pair's first half are all this string.
pub id: String,
/// The backend kind: `"google"`, `"llama-cpp"`, `"openrouter"`,
/// `"anthropic"`, `"openai"`, `"mock"`.
pub kind: String,
/// The provider behind the daemon's `ModelProvider` seam.
pub provider: Arc<dyn ModelProvider>,
/// The model used when none is set for this kind.
pub default_model: String,
/// The live model listing, when the backend has one worth calling.
list_models: Option<Arc<dyn Fn() -> ModelListing + Send + Sync>>,
}
impl Backend {
/// A backend without a live listing (the mock). The id doubles as
/// the kind: the registry-injected kinds are singletons.
pub fn headless(kind: impl Into<String>, provider: Arc<dyn ModelProvider>) -> Self {
let kind = kind.into();
Self {
id: kind.clone(),
kind,
provider,
default_model: "mock".to_owned(),
list_models: None,
}
}
}
/// The currently active backend instance: id plus the model turns run on.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Active {
id: String,
model: String,
}
/// One row of the registry listing: the instance id, its kind, and the
/// model fallback.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendRow {
/// The instance id.
pub id: String,
/// The backend kind.
pub kind: String,
/// The model used when none is set for this instance.
pub default_model: String,
}
/// The deployment's providers and the active pair, shared by every
/// handler so a switch takes effect on the next dispatch. The map is
/// keyed by instance id, not kind — two instances of one kind are two
/// rows.
pub struct BackendRegistry {
backends: RwLock<BTreeMap<String, Arc<Backend>>>,
active: RwLock<Active>,
}
impl BackendRegistry {
/// A registry over `backends`. The active pair is `seed` when that
/// id exists, else the first backend's id and default model. An
/// empty list is a construction error: the registry exists so
/// handlers never face an absent backend.
pub fn new(
backends: Vec<Backend>,
seed: Option<(String, Option<String>)>,
) -> Result<Self, String> {
if backends.is_empty() {
return Err(
"no provider backend is constructible: no key resolved for any provider".to_owned(),
);
}
let map: BTreeMap<String, Arc<Backend>> = backends
.into_iter()
.map(|backend| (backend.id.clone(), Arc::new(backend)))
.collect();
let active = match seed {
Some((id, model)) if map.contains_key(&id) => {
let default = map[&id].default_model.clone();
Active {
id,
model: model.unwrap_or(default),
}
}
_ => {
let first = map.values().next().expect("non-empty above");
Active {
id: first.id.clone(),
model: first.default_model.clone(),
}
}
};
Ok(Self {
backends: RwLock::new(map),
active: RwLock::new(active),
})
}
/// Inserts (or replaces) `backend` under its id in the live
/// registry: the write behind the credentials routes. The active
/// pair is untouched — a replaced backend keeps serving under its
/// id.
pub fn insert(&self, backend: Backend) {
let mut backends = self.backends_lock();
backends.insert(backend.id.clone(), Arc::new(backend));
}
/// The backend registered under `id`, when one is.
pub fn get(&self, id: &str) -> Option<Arc<Backend>> {
self.backends_lock().get(id).cloned()
}
/// Removes the instance `id` from the live registry. Removing the
/// last backend is refused: handlers never face an absent backend.
/// When the removed instance was active, the first remaining
/// backend (ascending) takes over with its default model — the
/// caller re-persists the pair it reads back from [`Self::active`].
pub fn remove(&self, id: &str) -> Result<(), String> {
let mut backends = self.backends_lock();
if backends.len() == 1 && backends.contains_key(id) {
return Err("cannot remove the last provider backend".to_owned());
}
let removed_was_active = self.active_lock().id == id;
if backends.remove(id).is_none() {
return Err(format!("unknown provider backend {id:?}"));
}
if removed_was_active {
let first = backends.values().next().expect("a backend remains");
*self.active_lock() = Active {
id: first.id.clone(),
model: first.default_model.clone(),
};
}
Ok(())
}
/// The provider and model the next dispatch runs on.
pub fn current(&self) -> (Arc<dyn ModelProvider>, String) {
let active = self.active_lock();
let backend = self.backends_lock()[&active.id].clone();
(backend.provider.clone(), active.model.clone())
}
/// The active pair as data: instance id and model.
pub fn active(&self) -> (String, String) {
let active = self.active_lock();
(active.id.clone(), active.model.clone())
}
/// Switches the active pair. An unknown instance id is an error; a
/// model left unset falls back to that instance's default.
pub fn set_active(&self, id: &str, model: Option<&str>) -> Result<(), String> {
let default = {
let backends = self.backends_lock();
let backend = backends
.get(id)
.ok_or_else(|| format!("unknown provider backend {id:?}"))?;
backend.default_model.clone()
};
let model = model.map(str::to_owned).unwrap_or_else(|| default);
*self.active_lock() = Active {
id: id.to_owned(),
model,
};
Ok(())
}
/// Every constructible backend as listing rows, ascending by id.
pub fn list(&self) -> Vec<BackendRow> {
self.backends_lock()
.values()
.map(|backend| BackendRow {
id: backend.id.clone(),
kind: backend.kind.clone(),
default_model: backend.default_model.clone(),
})
.collect()
}
/// The live model listing of the instance `id`, when the backend
/// carries one.
pub fn list_models(&self, id: &str) -> Option<ModelListing> {
let backend = self.backends_lock().get(id)?.clone();
backend.list_models.as_ref().map(|list| list())
}
fn active_lock(&self) -> RwLockWriteGuard<'_, Active> {
self.active.write().unwrap_or_else(PoisonError::into_inner)
}
fn backends_lock(&self) -> RwLockWriteGuard<'_, BTreeMap<String, Arc<Backend>>> {
self.backends
.write()
.unwrap_or_else(PoisonError::into_inner)
}
}
/// The credential-backed seed for the active pair when the config
/// names no provider: the first discovered backend that resolved an
/// actual credential — a stored row, an env key, or the stored rhema
/// profile. The keyless llama.cpp fallback is skipped: it exists so
/// the registry is never empty, not because anyone chose it, and with
/// nothing keyed the registry serves it while the UI's add-a-provider
/// state stays the honest answer. The model stays unset — the
/// registry fills each kind's default.
pub fn credential_seed(backends: &[Backend]) -> Option<(String, Option<String>)> {
backends
.iter()
.find(|backend| backend.kind != "llama-cpp")
.map(|backend| (backend.id.clone(), None))
}
/// The concrete providers behind [`Backend`]: an enum because the live
/// model listings are inherent methods, and the registry stores the
/// provider behind the object-safe seam. Shared by `Arc`, never cloned.
pub enum AnyProvider {
/// The Gemini adapter.
Google(GoogleProvider),
/// The llama.cpp server adapter.
LlamaCpp(LlamaCppProvider),
/// The OpenRouter adapter.
OpenRouter(OpenRouterProvider),
/// The Anthropic Messages adapter.
Anthropic(AnthropicProvider),
/// The generic OpenAI-compatible adapter.
OpenAiCompatible(OpenAiCompatibleProvider),
/// DeepSeek through the OpenAI-compatible adapter at its own base.
DeepSeek(OpenAiCompatibleProvider),
/// Z.ai's GLM Coding Plan through the Anthropic-compatible route.
Zai(AnthropicProvider),
/// GitHub Copilot through the Copilot adapter.
Copilot(CopilotProvider),
}
impl AnyProvider {
/// The kind's live model listing. Llama servers list their own
/// `LlamaModel` rows, which carry ids only: mapped into the shared
/// card shape with nothing but the id filled.
pub async fn list_models(&self) -> Result<Vec<ModelCard>, ProviderError> {
match self {
AnyProvider::Google(provider) => provider.list_models().await,
AnyProvider::Anthropic(provider) => provider.list_models().await,
AnyProvider::OpenRouter(provider) => provider.list_models().await,
AnyProvider::OpenAiCompatible(provider) => provider.list_models().await,
AnyProvider::DeepSeek(provider) => provider.list_models().await,
AnyProvider::Zai(provider) => provider.list_models().await,
AnyProvider::Copilot(provider) => provider.list_models().await,
AnyProvider::LlamaCpp(provider) => {
let models = provider.list_models().await?;
Ok(models
.into_iter()
.map(|model| ModelCard {
display_name: model.id.clone(),
id: model.id,
description: String::new(),
input_token_limit: model.context_length,
output_token_limit: None,
generation_methods: vec!["generateContent".to_owned()],
pricing: None,
})
.collect())
}
}
}
/// The boxed listing a [`Backend`] carries.
pub fn boxed_listing(provider: Arc<Self>) -> Arc<dyn Fn() -> ModelListing + Send + Sync> {
Arc::new(move || {
let provider = provider.clone();
Box::pin(async move { provider.list_models().await })
})
}
}
#[async_trait]
impl ModelProvider for AnyProvider {
async fn complete(
&self,
request: ModelRequest,
sink: &mut dyn EventSink,
) -> Result<ModelOutput, ProviderError> {
match self {
AnyProvider::Google(provider) => provider.complete(request, sink).await,
AnyProvider::LlamaCpp(provider) => provider.complete(request, sink).await,
AnyProvider::OpenRouter(provider) => provider.complete(request, sink).await,
AnyProvider::Anthropic(provider) => provider.complete(request, sink).await,
AnyProvider::OpenAiCompatible(provider) => provider.complete(request, sink).await,
AnyProvider::DeepSeek(provider) => provider.complete(request, sink).await,
AnyProvider::Zai(provider) => provider.complete(request, sink).await,
AnyProvider::Copilot(provider) => provider.complete(request, sink).await,
}
}
}
/// The env variable each keyed backend reads, in precedence order
/// (checked before the stored rhema config's value, mirroring the
/// frontend's env-wins rule).
/// The env variable names a kind's credential reads, in precedence
/// order — the list route's `source` computation needs the names, the
/// discovery the values.
pub fn env_key_names(kind: &str) -> &'static [&'static str] {
env_vars_for(kind)
}
fn env_vars_for(kind: &str) -> &'static [&'static str] {
match kind {
"google" => &["GOOGLE_API_KEY", "GEMINI_API_KEY"],
"anthropic" => &["ANTHROPIC_API_KEY"],
"openai" => &["OPENAI_API_KEY"],
"openrouter" => &["OPENROUTER_API_KEY"],
"deepseek" => &["DEEPSEEK_API_KEY"],
"zai" => &["ZAI_API_KEY", "Z_AI_API_KEY"],
"copilot" => &["GITHUB_COPILOT_TOKEN"],
_ => &[],
}
}
/// DeepSeek speaks the OpenAI-compatible dialect at its own base.
const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1";
/// The settings-store key prefix under which a backend instance's
/// credentials persist: `provider.<id>` holds a [`ProviderParams`]
/// JSON. Legacy rows stored `provider.<kind>` — the id doubling as the
/// kind, which is what ids default to.
pub const PROVIDER_KEY_PREFIX: &str = "provider.";
/// The settings key for one backend instance's stored credentials.
pub fn provider_settings_key(id: &str) -> String {
format!("{PROVIDER_KEY_PREFIX}{id}")
}
/// Whether `id` may name a backend instance: trimmed already by the
/// caller, non-empty, no whitespace, no `/`, at most 64 chars.
/// Comparisons are exact.
pub fn valid_instance_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 64 && !id.chars().any(|c| c.is_whitespace() || c == '/')
}
/// The credentials a backend instance constructs from, as the settings
/// store persists them: the kind the params construct, an API key, and
/// for server-addressed kinds the URL. `api_key` never logs and never
/// round-trips through the list route — the list says only where a
/// credential came from.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ProviderParams {
/// The kind these params construct.
pub kind: String,
/// The API key (or GitHub token for copilot).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
/// The server address (llama.cpp).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
/// Every kind a runtime login can construct, ascending: the addable
/// list the HTTP surface serves and the UI renders forms from.
pub fn addable_kinds() -> &'static [&'static str] {
&[
"anthropic",
"copilot",
"deepseek",
"google",
"llama-cpp",
"openai",
"openrouter",
"zai",
]
}
/// One field of a kind's login form: the name the credentials body
/// spells it with, and whether the daemon refuses the login without it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct ParamField {
/// The field name as the credentials route reads it.
pub name: &'static str,
/// Whether constructing without the field fails.
pub required: bool,
}
/// The parameter fields a kind's login takes, in form order — the wire
/// shape under `addable` the UI builds its inputs from. `required`
/// states what the daemon demands, not what the form shows: llama.cpp
/// serves keyless on a default url, so neither of its fields gates the
/// login; the six keyed kinds cannot construct without an `api_key`.
pub fn param_fields_for(kind: &str) -> &'static [ParamField] {
match kind {
"llama-cpp" => &[
ParamField {
name: "url",
required: false,
},
ParamField {
name: "api_key",
required: false,
},
],
_ => &[ParamField {
name: "api_key",
required: true,
}],
}
}
/// Constructs one backend from `params`, its id defaulting to the
/// kind. The per-kind requirements: keyed kinds want an `api_key`;
/// llama.cpp wants a `url` (the keyless default still applies when
/// neither is given). Unknown kinds are an error — "mock" is
/// registry-injected, never constructed.
pub fn construct(kind: &str, params: &ProviderParams) -> Result<Backend, String> {
let provider = match kind {
"google" => AnyProvider::Google(GoogleProvider::new(require_key(kind, params)?)),
"anthropic" => AnyProvider::Anthropic(AnthropicProvider::new(require_key(kind, params)?)),
"openai" => {
AnyProvider::OpenAiCompatible(OpenAiCompatibleProvider::new(require_key(kind, params)?))
}
"openrouter" => {
AnyProvider::OpenRouter(OpenRouterProvider::new(require_key(kind, params)?))
}
"deepseek" => AnyProvider::DeepSeek(
OpenAiCompatibleProvider::new(require_key(kind, params)?)
.with_base_url(DEEPSEEK_BASE_URL),
),
// Bearer auth: the scheme the Coding Plan route documents.
"zai" => AnyProvider::Zai(
AnthropicProvider::new(require_key(kind, params)?)
.with_base_url(ZAI_BASE_URL)
.with_bearer_auth(),
),
"copilot" => AnyProvider::Copilot(CopilotProvider::new(require_key(kind, params)?)),
"llama-cpp" => {
let mut provider = LlamaCppProvider::new(
params
.url
.clone()
.unwrap_or_else(|| rhema_config::Profile::default().llama_cpp_url()),
);
if let Some(key) = &params.api_key {
provider = provider.with_api_key(key);
}
AnyProvider::LlamaCpp(provider)
}
_ => return Err(format!("unknown provider kind {kind:?}")),
};
let default_model = default_model_for(kind).to_owned();
let provider = Arc::new(provider);
Ok(Backend {
id: kind.to_owned(),
kind: kind.to_owned(),
// Turns ride the retry, so a transient failure costs a pause
// instead of the turn; the listing stays on the raw adapter —
// `list_models` is an inherent method the wrapper has no.
provider: Arc::new(rhema_provider::Retrying::new(provider.clone())),
default_model,
list_models: Some(AnyProvider::boxed_listing(provider)),
})
}
/// The kind's API key, or the error the credentials route answers with.
fn require_key(kind: &str, params: &ProviderParams) -> Result<String, String> {
params
.api_key
.clone()
.filter(|key| !key.trim().is_empty())
.ok_or_else(|| format!("{kind} needs an api_key"))
}
/// Discovers every constructible backend instance from the claw's own
/// stored credentials, the environment, and the stored rhema config.
/// Stored instances — the `provider.<id>` rows the HTTP surface wrote —
/// boot under the id they were saved with, the row's inner `kind`
/// naming what to construct (a legacy row without one keeps its id
/// doubling as the kind). For each kind, the env credential outranks
/// everything: it constructs the kind's own id, pushing a stored twin
/// of the same id aside while the row stays persisted for a
/// post-restart return. A stored instance of a kind outranks the
/// profile fallback, which only constructs when neither env nor a
/// stored row resolved. The keyless llama.cpp backend always
/// constructs while no stored row of its kind exists, so the registry
/// is never empty. The config's `provider`/`model` pair is the
/// caller's seed for the active backend — deployment defaults, not a
/// constraint. `stored_rows` reads the daemon's settings store, every
/// `provider.*` row as `(id, params)`; a row that no longer constructs
/// never blocks the boot.
pub fn discover(
config: &crate::config::ClawConfig,
stored: &rhema_config::Profile,
env: &dyn Fn(&str) -> Option<String>,
stored_rows: &dyn Fn() -> Vec<(String, ProviderParams)>,
secrets: Option<&dyn rhema_config::secrets::SecretStore>,
) -> Vec<Backend> {
let rows = stored_rows();
let mut backends = Vec::new();
// The env credential each kind resolves from, if any.
let env_key = |kind: &str| {
env_vars_for(kind)
.iter()
.find_map(|name| env(name))
.filter(|key| !key.trim().is_empty())
};
// Stored instances first, under the id their row names. An
// env-backed kind takes the id back from a stored twin of the same
// id below — env wins the per-kind slot, the row stays persisted.
for (id, params) in &rows {
if id == &params.kind && env_key(&params.kind).is_some() {
continue;
}
if let Ok(mut backend) = construct(&params.kind, params) {
backend.id = id.clone();
seed_llama_model(&mut backend, config);
backends.push(backend);
}
}
for kind in [
"llama-cpp",
"google",
"anthropic",
"openai",
"openrouter",
"deepseek",
"zai",
"copilot",
] {
let stored_of_kind = rows.iter().any(|(_, params)| params.kind == kind);
let params = if kind == "llama-cpp" {
if stored_of_kind {
// The stored rows own the kind now; the keyless default
// would undo a removal of the kind's own id.
continue;
}
// The keyless server backend always constructs, so the
// registry is never empty.
ProviderParams {
kind: kind.to_owned(),
api_key: None,
url: Some(stored.llama_cpp_url()),
}
} else if let Some(api_key) = env_key(kind) {
ProviderParams {
kind: kind.to_owned(),
api_key: Some(api_key),
url: None,
}
} else if stored_of_kind {
// The stored instance of the kind wins over the profile
// fallback — the surface's login is the newer decision.
continue;
} else {
// The stored rhema profile's key for the kind: the matching
// named instance's. The keyless llama.cpp backend never
// takes a key from the profile.
let api_key = match kind {
"llama-cpp" => None,
_ => stored.stored_provider_key(kind, secrets),
};
ProviderParams {
kind: kind.to_owned(),
api_key,
url: None,
}
};
let has_creds = match kind {
// The keyless server backend always constructs.
"llama-cpp" => true,
_ => params.api_key.is_some(),
};
if !has_creds {
continue;
}
match construct(kind, &params) {
Ok(mut backend) => {
seed_llama_model(&mut backend, config);
backends.push(backend);
}
// An unconstructible kind (a blank key) never blocks the boot.
Err(_) => continue,
}
}
backends
}
/// The config file's model seeds llama's fallback, as discovery always
/// has: it names the deployment's local model when it names one at all.
fn seed_llama_model(backend: &mut Backend, config: &crate::config::ClawConfig) {
if backend.kind == "llama-cpp" {
if let Some(model) = &config.model {
backend.default_model = model.clone();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rhema_config::secrets::SecretStore as _;
use rhema_core::{Message, Role, StopReason, Usage};
/// A minimal scripted stand-in: registry tests never complete a turn.
struct Stub;
#[async_trait]
impl ModelProvider for Stub {
async fn complete(
&self,
_req: ModelRequest,
_sink: &mut dyn EventSink,
) -> Result<ModelOutput, ProviderError> {
Ok(ModelOutput {
message: Message::text(Role::Assistant, "stub"),
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
})
}
}
fn backend(kind: &str) -> Backend {
// The real per-kind default, not headless's mock placeholder —
// the fallback assertions depend on it.
Backend {
id: kind.to_owned(),
kind: kind.to_owned(),
provider: Arc::new(Stub),
default_model: default_model_for(kind).to_owned(),
list_models: None,
}
}
#[test]
fn the_credential_seed_is_the_first_keyed_backend_in_discovery_order() {
// The keyless llama.cpp fallback never seeds: it exists so the
// registry is never empty, not because anyone chose it.
assert_eq!(
credential_seed(&[backend("llama-cpp")]),
None,
"nothing keyed: no seed, the keyless server serves"
);
// The first keyed backend wins, in discovery order.
assert_eq!(
credential_seed(&[
backend("llama-cpp"),
backend("google"),
backend("anthropic")
]),
Some(("google".to_owned(), None))
);
// A stored named row comes first in discovery order, whatever
// its kind sorts to in the registry.
let mut stored = backend("anthropic");
stored.id = "work-key".to_owned();
assert_eq!(
credential_seed(&[stored, backend("google")]),
Some(("work-key".to_owned(), None))
);
// The model stays unset: the registry fills the kind default.
let (id, model) = credential_seed(&[backend("openai")]).unwrap();
assert_eq!((id.as_str(), model), ("openai", None));
}
#[test]
fn the_seed_wins_when_the_kind_exists() {
let registry = BackendRegistry::new(
vec![backend("anthropic"), backend("google")],
Some(("google".to_owned(), Some("gemini-3.8-flash".to_owned()))),
)
.unwrap();
assert_eq!(
registry.active(),
("google".to_owned(), "gemini-3.8-flash".to_owned())
);
}
#[test]
fn a_seed_for_an_absent_kind_falls_back_to_the_first_backend() {
let registry = BackendRegistry::new(
vec![backend("anthropic"), backend("google")],
Some(("openai".to_owned(), None)),
)
.unwrap();
assert_eq!(registry.active().0, "anthropic", "ascending first");
}
#[test]
fn a_switch_falls_back_to_the_kind_default_without_a_model() {
let registry =
BackendRegistry::new(vec![backend("anthropic"), backend("google")], None).unwrap();
registry.set_active("google", None).unwrap();
assert_eq!(registry.active().1, "gemini-3.8-flash");
registry
.set_active("anthropic", Some("claude-opus-4-1"))
.unwrap();
assert_eq!(registry.active().1, "claude-opus-4-1");
}
#[test]
fn an_unknown_kind_is_rejected() {
let registry = BackendRegistry::new(vec![backend("google")], None).unwrap();
assert!(registry.set_active("openai", None).is_err());
}
#[test]
fn an_empty_registry_is_a_construction_error() {
assert!(BackendRegistry::new(vec![], None).is_err());
}
fn params(kind: &str, api_key: Option<&str>, url: Option<&str>) -> ProviderParams {
ProviderParams {
kind: kind.to_owned(),
api_key: api_key.map(str::to_owned),
url: url.map(str::to_owned),
}
}
#[test]
fn instance_ids_exclude_whitespace_slashes_and_excess_length() {
assert!(valid_instance_id("local-llama"));
assert!(valid_instance_id(&"x".repeat(64)));
assert!(!valid_instance_id(""));
assert!(!valid_instance_id("a b"));
assert!(!valid_instance_id("a/b"));
assert!(!valid_instance_id(&"x".repeat(65)));
}
#[test]
fn two_instances_of_a_kind_coexist_under_their_ids() {
let mut local_a = backend("llama-cpp");
local_a.id = "local-a".to_owned();
let mut local_b = backend("llama-cpp");
local_b.id = "local-b".to_owned();
let registry = BackendRegistry::new(vec![local_a, local_b], None).unwrap();
assert_eq!(registry.list().len(), 2);
registry.set_active("local-b", None).unwrap();
assert_eq!(registry.active().0, "local-b");
// Removing the active id hands the pair to the remaining one.
registry.remove("local-b").unwrap();
assert_eq!(registry.active().0, "local-a");
}
#[test]
fn construct_demands_what_each_kind_needs() {
// Keyed kinds want a non-blank key.
assert!(construct("google", &params("google", None, None)).is_err());
assert!(construct("google", &params("google", Some(" "), None)).is_err());
assert!(construct("google", &params("google", Some("k"), None)).is_ok());
assert!(construct("deepseek", &params("deepseek", Some("k"), None)).is_ok());
assert!(construct("zai", &params("zai", Some("k"), None)).is_ok());
assert!(construct("copilot", &params("copilot", Some("t"), None)).is_ok());
assert!(construct("nope", &params("nope", Some("k"), None)).is_err());
// llama.cpp serves keyless on its default url.
assert!(construct("llama-cpp", &params("llama-cpp", None, None)).is_ok());
assert!(construct("llama-cpp", &params("llama-cpp", None, Some("http://h:1"))).is_ok());
}
#[test]
fn discover_builds_exactly_the_kinds_with_a_source() {
let config = test_config();
let profile = profile_with_google();
let env = |_name: &str| None;
let stored_rows = || vec![("deepseek".to_owned(), params("deepseek", Some("cd"), None))];
let backends = discover(&config, &profile, &env, &stored_rows, None);
let mut kinds: Vec<&str> = backends.iter().map(|b| b.kind.as_str()).collect();
kinds.sort_unstable();
assert_eq!(kinds, vec!["deepseek", "google", "llama-cpp"]);
}
#[test]
fn discover_boots_the_zai_kind_from_its_env_key() {
let config = test_config();
let profile = rhema_config::Profile::default();
let env = |name: &str| (name == "ZAI_API_KEY").then(|| "zk".to_owned());
let backends = discover(&config, &profile, &env, &|| Vec::new(), None);
let kinds: Vec<&str> = backends.iter().map(|b| b.kind.as_str()).collect();
assert!(
kinds.contains(&"zai"),
"the env key boots the zai backend: {kinds:?}"
);
}
#[test]
fn discover_boots_llama_even_when_nobody_stored_anything() {
let config = test_config();
let profile = rhema_config::Profile::default();
let env = |_name: &str| None;
let backends = discover(&config, &profile, &env, &|| Vec::new(), None);
assert_eq!(backends.len(), 1);
assert_eq!(backends[0].kind, "llama-cpp");
}
#[test]
fn a_keychain_ref_serves_the_profile_fallback_without_a_literal() {
let config = test_config();
// The stored instance logs in through the chain: no literal, a
// live ref. Discovery resolves it through the store tier.
let mut profile = rhema_config::Profile::default();
profile.upsert_instance(
"google",
rhema_config::ProviderInstance {
provider: "google".into(),
api_key: None,
secret_ref: Some("ref-1".into()),
base_url: None,
},
);
let store = rhema_config::secrets::VolatileSecretStore::new();
store.set("ref-1", "chain-key").unwrap();
let env = |_name: &str| None;
let backends = discover(&config, &profile, &env, &|| Vec::new(), Some(&store));
assert!(
backends.iter().any(|b| b.kind == "google"),
"the keychain key builds the backend"
);
// Without the store the ref is unreadable and the kind is not
// constructible — the honest logged-out shape, not a guess.
let backends = discover(&config, &profile, &env, &|| Vec::new(), None);
assert!(!backends.iter().any(|b| b.kind == "google"));
}
#[test]
fn a_named_row_boots_under_its_id_and_keeps_the_default_slot_empty() {
let config = test_config();
let profile = profile_with_google();
let env = |_name: &str| None;
let stored_rows = || {
vec![(
"local-llama".to_owned(),
params("llama-cpp", None, Some("http://h:1")),
)]
};
let backends = discover(&config, &profile, &env, &stored_rows, None);
let mut ids: Vec<&str> = backends.iter().map(|b| b.id.as_str()).collect();
ids.sort_unstable();
assert_eq!(ids, vec!["google", "local-llama"]);
// The stored row owns the kind: the keyless default id does not
// resurrect beside it.
assert!(!ids.contains(&"llama-cpp"));
let llama = backends.iter().find(|b| b.id == "local-llama").unwrap();
assert_eq!(llama.kind, "llama-cpp");
}
#[test]
fn env_takes_the_kind_id_back_from_a_stored_twin() {
let config = test_config();
let profile = rhema_config::Profile::default();
let env = |name: &str| (name == "GOOGLE_API_KEY").then(|| "from-env".to_owned());
let stored_rows = || vec![("google".to_owned(), params("google", Some("stored"), None))];
let backends = discover(&config, &profile, &env, &stored_rows, None);
let google: Vec<&Backend> = backends.iter().filter(|b| b.kind == "google").collect();
assert_eq!(google.len(), 1, "env wins the id, no twin survives");
assert_eq!(google[0].id, "google");
}
#[test]
fn a_row_stored_under_its_kind_id_boots_as_its_kind() {
let config = test_config();
let profile = rhema_config::Profile::default();
let env = |_name: &str| None;
let stored_rows = || vec![("anthropic".to_owned(), params("anthropic", Some("k"), None))];
let backends = discover(&config, &profile, &env, &stored_rows, None);
let anthropic = backends
.iter()
.find(|b| b.id == "anthropic")
.expect("the stored row boots");
assert_eq!(anthropic.kind, "anthropic");
assert_eq!(anthropic.default_model, "claude-sonnet-4-5");
}
#[test]
fn insert_replaces_and_remove_moves_the_active_pair() {
let registry = BackendRegistry::new(
vec![backend("anthropic"), backend("google")],
Some(("google".to_owned(), None)),
)
.unwrap();
// A replace: the kind count holds, the default may change.
registry.insert(backend("google"));
assert_eq!(registry.list().len(), 2);
// An addition joins the list.
registry.insert(backend("openai"));
assert_eq!(registry.list().len(), 3);
// Removing the active kind hands the pair to the first
// remaining backend, ascending.
registry.remove("google").unwrap();
assert_eq!(registry.active().0, "anthropic");
// The last backend refuses to go.
registry.remove("openai").unwrap();
assert!(registry.remove("anthropic").is_err());
assert_eq!(registry.list().len(), 1);
}
fn test_config() -> crate::config::ClawConfig {
serde_json::from_value(serde_json::json!({
"poll_interval_secs": 60,
"watches": []
}))
.expect("a minimal claw config")
}
fn profile_with_google() -> rhema_config::Profile {
let mut profile = rhema_config::Profile::default();
profile.upsert_instance(
"google",
rhema_config::ProviderInstance {
provider: "google".into(),
api_key: Some("pg".into()),
secret_ref: None,
base_url: None,
},
);
profile
}
}
+583
View File
@@ -0,0 +1,583 @@
//! The bootstrap layer: the resident agent's user-editable identity
//! files, the system prompt rendered from them per dispatch, and the
//! one tool the model writes them with.
//!
//! Context is earned (framework spec §9): one small always-injected
//! bootstrap doc, and nothing else force-injected — memory stays in
//! `rhema-memory`, pulled via tools. SOUL.md and USER.md are the earned
//! exceptions: tiny, stable, high-signal, and written only through
//! [`BootstrapSave`], so the model never free-writes config paths.
//! ENVIRONMENT.md lives in the same store but is generated at boot by
//! the environment module; the save path refuses it. The store's root
//! is handed over through [`Bootstrap::root`] for exactly that writer.
//!
//! The template ([`DEFAULT_SYSTEM_MD`]) is data next to the code, the
//! coding agent's house pattern, and it renders through the agent
//! toolkit's Jinja renderer — one template language for both agents:
//! `{{ workspace }}`, the three file sections from the live files, a
//! `{% for %}` over `tools` from the dispatch's own resolver chain, so
//! the roster the prompt shows is the roster the model gets. A present
//! file replaces its placeholder verbatim — never appended to, never
//! re-parsed; a missing one renders its stub. The `{% if onboarding %}`
//! section renders only while either file is unwritten, so the offer
//! self-terminates.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use rhema_agent_tools::render_template;
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
use rhema_core::ToolSpec;
use serde_json::{Value, json};
/// The built-in system prompt, kept as data next to the code and read
/// in whole — placeholders filled per dispatch by [`render_system`].
pub const DEFAULT_SYSTEM_MD: &str = include_str!("../SYSTEM.md");
/// The cap on one bootstrap file: these documents enter every request,
/// so a bigger write is a misuse of the channel, refused loudly.
pub const MAX_CONTENT_BYTES: usize = 16 * 1024;
/// The save tool's name — allowlists reference it.
pub const BOOTSTRAP_SAVE: &str = "bootstrap_save";
/// One of the three bootstrap files. SOUL.md and USER.md are the
/// model-written pair; ENVIRONMENT.md is the boot-generated one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BootstrapKind {
/// SOUL.md — who the claw is: name, tone, purpose.
Soul,
/// USER.md — who the user is.
User,
/// ENVIRONMENT.md — the machine, written by the environment
/// module's boot pass; never through [`Bootstrap::save`].
Environment,
}
impl BootstrapKind {
/// The file name under the store's root — uppercase, the house
/// spelling.
pub fn file_name(self) -> &'static str {
match self {
BootstrapKind::Soul => "SOUL.md",
BootstrapKind::User => "USER.md",
BootstrapKind::Environment => "ENVIRONMENT.md",
}
}
/// Parses the spelling the tool schema uses. `None` for anything
/// else.
pub fn parse(name: &str) -> Option<Self> {
match name.trim() {
"soul" => Some(BootstrapKind::Soul),
"user" => Some(BootstrapKind::User),
"environment" => Some(BootstrapKind::Environment),
_ => None,
}
}
}
/// Why a bootstrap operation failed.
#[derive(Debug, thiserror::Error)]
pub enum BootstrapError {
/// The store's directory could not be created, or a file could not
/// be read or written.
#[error(transparent)]
Io(#[from] std::io::Error),
/// ENVIRONMENT.md is boot-generated; the save path refuses it.
#[error("ENVIRONMENT.md is generated at boot; it is not saveable through bootstrap_save")]
EnvironmentNotSaveable,
/// The content is over [`MAX_CONTENT_BYTES`]: a bootstrap file
/// enters every request, so a bigger one is refused, not trimmed.
#[error("bootstrap content is {size} bytes; the cap is {MAX_CONTENT_BYTES} bytes")]
TooLarge {
/// The rejected content's size, in bytes.
size: usize,
},
/// The system-prompt template failed to render — bad syntax, unknown
/// filter — with the template position when known. The dispatch
/// refuses to run on a prompt it cannot fully render.
#[error("system prompt template: {0}")]
Template(String),
}
/// Which of the onboarding files exist — the badge the HTTP surface
/// serves and the onboarding section's off switch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct BootstrapStatus {
/// SOUL.md exists.
pub soul: bool,
/// USER.md exists.
pub user: bool,
}
/// The identity-file store: three markdown files under one root —
/// SOUL.md, USER.md, ENVIRONMENT.md. Its only writers are
/// [`BootstrapSave`] (the model's path, SOUL/USER only) and the
/// onboarding flow through it; the environment module writes
/// ENVIRONMENT.md directly at [`Bootstrap::root`].
pub struct Bootstrap {
root: PathBuf,
}
impl Bootstrap {
/// Opens the store at `root`, creating the directory: a fresh
/// install has no files, which is the pending-onboarding state, not
/// an error.
pub fn new(root: impl Into<PathBuf>) -> Result<Self, BootstrapError> {
let root = root.into();
std::fs::create_dir_all(&root)?;
Ok(Self { root })
}
/// The store's root — the documented seam the daemon hands to the
/// environment module, whose boot pass writes ENVIRONMENT.md here.
pub fn root(&self) -> &Path {
&self.root
}
/// The file's text, `None` while it is unwritten.
pub fn load(&self, kind: BootstrapKind) -> Result<Option<String>, BootstrapError> {
match std::fs::read_to_string(self.root.join(kind.file_name())) {
Ok(text) => Ok(Some(text)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err.into()),
}
}
/// Overwrites the file with `content`, verbatim. ENVIRONMENT.md is
/// refused — it is generated at boot, never model-written.
pub fn save(&self, kind: BootstrapKind, content: &str) -> Result<(), BootstrapError> {
if kind == BootstrapKind::Environment {
return Err(BootstrapError::EnvironmentNotSaveable);
}
let size = content.len();
if size > MAX_CONTENT_BYTES {
return Err(BootstrapError::TooLarge { size });
}
std::fs::write(self.root.join(kind.file_name()), content)?;
Ok(())
}
/// Which of the onboarding files exist, read live.
pub fn status(&self) -> Result<BootstrapStatus, BootstrapError> {
Ok(BootstrapStatus {
soul: self.load(BootstrapKind::Soul)?.is_some(),
user: self.load(BootstrapKind::User)?.is_some(),
})
}
/// Whether the onboarding flow still applies: either file is
/// unwritten. An unreadable store counts as pending — the worst
/// case is an offered setup, never a silently skipped one.
pub fn needs_onboarding(&self) -> bool {
!self.status().is_ok_and(|status| status.soul && status.user)
}
}
/// The stub a missing SOUL.md or USER.md renders.
const PENDING: &str = "(pending onboarding)";
/// The stub a missing ENVIRONMENT.md renders; the environment module's
/// boot pass writes the real file.
const GENERATED_AT_BOOT: &str = "(generated at boot)";
/// The offered onboarding, rendered while either file is unwritten. It
/// self-terminates: the per-dispatch status check stops rendering it
/// the moment both files exist. The offer is made in chat and may be
/// declined — never an unprompted interrogation. Compiled in like the
/// base template (`../ONBOARDING.md`), for the same reason: the prompt
/// is code-reviewed text, not runtime state.
const ONBOARDING_MD: &str = include_str!("../ONBOARDING.md");
/// One rendered markdown section: header, blank line, the file's text
/// — or the stub while it is unwritten.
fn section(header: &str, content: Option<&str>, stub: &str) -> String {
format!("{header}\n\n{}", content.unwrap_or(stub))
}
/// Renders the system prompt for one dispatch through the agent
/// toolkit's Jinja renderer: `{{ workspace }}` and the three file
/// sections from the live store, `tools` from the dispatch's own
/// resolver specs, `{{ session }}` — the dispatching key, the address
/// the model schedules its own wakes to — and `onboarding`, the offer
/// the template's `{% if %}` shows only while a file is unwritten.
/// Fallible on purpose — a store that cannot be read surfaces loudly
/// instead of rendering a prompt without it, and a template that
/// cannot render fails the dispatch before any model call.
pub fn render_system(
template: &str,
session: &str,
workspace: &Path,
bootstrap: &Bootstrap,
tools: &[ToolSpec],
) -> Result<String, BootstrapError> {
let soul = bootstrap.load(BootstrapKind::Soul)?;
let user = bootstrap.load(BootstrapKind::User)?;
let environment = bootstrap.load(BootstrapKind::Environment)?;
let onboarding = if soul.is_none() || user.is_none() {
ONBOARDING_MD
} else {
""
};
let context = json!({
"session": session,
"workspace": workspace.display().to_string(),
"soul": section("## SOUL", soul.as_deref(), PENDING),
"user": section("## USER", user.as_deref(), PENDING),
"environment": section("## ENVIRONMENT", environment.as_deref(), GENERATED_AT_BOOT),
"onboarding": onboarding,
"tools": tools,
});
render_template(template, &context).map_err(|err| {
let line = err
.line()
.map(|line| format!(", line {line}"))
.unwrap_or_default();
BootstrapError::Template(format!("{err}{line}"))
})
}
#[derive(serde::Deserialize)]
struct SaveArgs {
kind: String,
content: String,
}
/// Writes SOUL.md or USER.md through the [`Bootstrap`] store — the only
/// writer the model gets, deliberately allowlistable and deterministic.
pub struct BootstrapSave {
bootstrap: Arc<Bootstrap>,
}
#[async_trait]
impl Tool for BootstrapSave {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
BOOTSTRAP_SAVE,
"Write one of your identity files. `kind` is `soul` (who you \
are: name, tone, purpose) or `user` (who the user is). \
Overwrites the file, so send the whole document every time. \
Both files enter every request: keep each one small, \
stable, and high-signal.",
json!({
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["soul", "user"],
"description": "Which file to write: soul or user." },
"content": { "type": "string",
"description": "The file's full markdown contents." }
},
"required": ["kind", "content"]
}),
)
}
async fn invoke(&self, args: Value) -> Result<String, String> {
let args: SaveArgs =
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
let kind = BootstrapKind::parse(&args.kind)
.ok_or_else(|| format!("unknown kind {:?}: soul or user", args.kind))?;
self.bootstrap
.save(kind, &args.content)
.map_err(|err| err.to_string())?;
Ok(format!("saved {}", kind.file_name()))
}
}
/// The bootstrap tool set over `store`: `bootstrap_save`, the shape the
/// interactive resolver chain composes.
pub fn registry(bootstrap: Arc<Bootstrap>) -> ToolRegistry {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(BootstrapSave { bootstrap }));
registry
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> (tempfile::TempDir, Bootstrap) {
let dir = tempfile::tempdir().expect("tempdir");
let bootstrap = Bootstrap::new(dir.path().join("bootstrap")).expect("store opens");
(dir, bootstrap)
}
/// ENVIRONMENT.md is written directly — the environment module's
/// boot pass is its only writer, never [`Bootstrap::save`].
fn write_environment(bootstrap: &Bootstrap, content: &str) {
std::fs::write(bootstrap.root().join("ENVIRONMENT.md"), content).expect("write");
}
#[test]
fn new_creates_the_store_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let nested = dir.path().join("a").join("bootstrap");
let bootstrap = Bootstrap::new(&nested).expect("store opens");
assert!(nested.is_dir());
assert_eq!(bootstrap.root(), nested.as_path());
assert!(bootstrap.needs_onboarding(), "a fresh store is pending");
}
#[test]
fn kinds_parse_and_name_their_files() {
assert_eq!(BootstrapKind::parse("soul"), Some(BootstrapKind::Soul));
assert_eq!(BootstrapKind::parse(" user "), Some(BootstrapKind::User));
assert_eq!(
BootstrapKind::parse("environment"),
Some(BootstrapKind::Environment)
);
assert_eq!(BootstrapKind::parse("soul.md"), None);
assert_eq!(BootstrapKind::parse(""), None);
for kind in [
BootstrapKind::Soul,
BootstrapKind::User,
BootstrapKind::Environment,
] {
let name = kind.file_name().to_lowercase();
assert_eq!(
BootstrapKind::parse(name.trim_end_matches(".md")),
Some(kind),
"{}",
kind.file_name()
);
}
}
#[test]
fn save_then_load_round_trips_and_overwrites() {
let (_dir, bootstrap) = store();
assert_eq!(bootstrap.load(BootstrapKind::Soul).expect("reads"), None);
bootstrap
.save(BootstrapKind::Soul, "I am claw.\n")
.expect("saves");
assert_eq!(
bootstrap.load(BootstrapKind::Soul).expect("reads"),
Some("I am claw.\n".to_owned()),
"saved verbatim"
);
bootstrap
.save(BootstrapKind::Soul, "I am claw, v2.")
.expect("overwrites");
assert_eq!(
bootstrap.load(BootstrapKind::Soul).expect("reads"),
Some("I am claw, v2.".to_owned())
);
let status = bootstrap.status().expect("status reads");
assert!(status.soul);
assert!(!status.user);
assert!(bootstrap.needs_onboarding(), "user is still unwritten");
bootstrap
.save(BootstrapKind::User, "the user is Ana.")
.expect("saves");
assert!(!bootstrap.needs_onboarding(), "both files exist");
}
#[test]
fn the_size_cap_is_loud_not_trimmed() {
let (_dir, bootstrap) = store();
let too_big = "x".repeat(MAX_CONTENT_BYTES + 1);
let err = bootstrap
.save(BootstrapKind::Soul, &too_big)
.expect_err("over the cap is loud");
assert!(err.to_string().contains("bytes"), "{err}");
assert!(
bootstrap
.load(BootstrapKind::Soul)
.expect("reads")
.is_none(),
"nothing was written"
);
let at_cap = "x".repeat(MAX_CONTENT_BYTES);
bootstrap
.save(BootstrapKind::User, &at_cap)
.expect("exactly the cap saves");
}
#[test]
fn environment_refuses_save_everywhere() {
let (_dir, bootstrap) = store();
let err = bootstrap
.save(BootstrapKind::Environment, "machine facts")
.expect_err("environment is not saveable");
assert!(
err.to_string().contains("generated at boot"),
"the refusal names the reason: {err}"
);
}
#[test]
fn a_fresh_house_renders_stubs_and_the_onboarding_section() {
let (_dir, bootstrap) = store();
let tools = registry(Arc::new(Bootstrap {
root: bootstrap.root().to_path_buf(),
}))
.resolver_for(&rhema_autonomous_agent::Allowlist::permissive())
.specs();
let prompt = render_system(
DEFAULT_SYSTEM_MD,
"interactive:web",
Path::new("/ws"),
&bootstrap,
&tools,
)
.expect("renders");
assert!(prompt.contains("## SOUL"), "{prompt}");
assert!(prompt.contains("## USER"), "{prompt}");
assert!(prompt.contains("## ENVIRONMENT"), "{prompt}");
assert_eq!(prompt.matches(PENDING).count(), 2, "{prompt}");
assert!(prompt.contains(GENERATED_AT_BOOT), "{prompt}");
assert!(prompt.contains("## Setup"), "{prompt}");
assert!(prompt.contains("/ws"), "the workspace named: {prompt}");
assert!(prompt.contains("bootstrap_save"), "{prompt}");
}
#[test]
fn the_full_house_renders_each_file_and_drops_the_onboarding_section() {
let (_dir, bootstrap) = store();
bootstrap
.save(BootstrapKind::Soul, "I am claw: terse, warm, relentless.")
.expect("saves");
bootstrap
.save(BootstrapKind::User, "The user is Ana; she ships compilers.")
.expect("saves");
write_environment(&bootstrap, "Linux, 32 cores, no GPU.");
let prompt = render_system(
DEFAULT_SYSTEM_MD,
"interactive:web",
Path::new("/ws"),
&bootstrap,
&[],
)
.expect("renders");
assert!(
prompt.contains("I am claw: terse, warm, relentless."),
"{prompt}"
);
assert!(
prompt.contains("The user is Ana; she ships compilers."),
"{prompt}"
);
assert!(prompt.contains("Linux, 32 cores, no GPU."), "{prompt}");
assert!(!prompt.contains(PENDING), "{prompt}");
assert!(!prompt.contains(GENERATED_AT_BOOT), "{prompt}");
assert!(!prompt.contains("## Setup"), "{prompt}");
}
#[test]
fn a_user_file_replaces_its_placeholder_verbatim() {
let (_dir, bootstrap) = store();
let content = "the user is Ana.\n\n- braces stay: {{ keep }} and {soul}\n";
bootstrap.save(BootstrapKind::User, content).expect("saves");
let prompt = render_system(
DEFAULT_SYSTEM_MD,
"interactive:web",
Path::new("/ws"),
&bootstrap,
&[],
)
.expect("renders");
assert!(prompt.contains(content), "{prompt}");
assert!(
prompt.contains("{soul}"),
"a file's own placeholder-looking text is never substituted: {prompt}"
);
assert!(
!prompt.contains("## USER\n\n\n"),
"the file rides under its header, not appended: {prompt}"
);
}
#[test]
fn the_tool_specs_render_with_their_schemas() {
let (_dir, bootstrap) = store();
let tools = vec![ToolSpec::new(
"my_tool",
"its purpose",
json!({
"type": "object",
"properties": { "kind": { "type": "string" } },
"required": ["kind"]
}),
)];
let prompt = render_system(
DEFAULT_SYSTEM_MD,
"interactive:web",
Path::new("/ws"),
&bootstrap,
&tools,
)
.expect("renders");
assert!(prompt.contains("- my_tool | its purpose | "), "{prompt}");
assert!(
prompt.contains(r#""required": ["kind"]"#),
"the JSON schema rendered: {prompt}"
);
}
#[tokio::test]
async fn the_tool_writes_through_the_store_and_answers_one_line() {
use rhema_autonomous_agent::Allowlist;
use rhema_core::{ToolCall, ToolContext};
let (_dir, bootstrap) = store();
let resolver = registry(Arc::new(Bootstrap {
root: bootstrap.root().to_path_buf(),
}))
.resolver_for(&Allowlist::permissive());
let ctx = ToolContext::default();
let call = |id: &str, args: Value| {
resolver.resolve(
ToolCall {
id: id.into(),
name: BOOTSTRAP_SAVE.into(),
args,
provider_data: Value::Null,
},
&ctx,
)
};
let saved = call(
"c1",
json!({"kind": "soul", "content": "I am claw: terse."}),
)
.await;
assert!(!saved.is_error, "{}", saved.content);
assert_eq!(saved.content, "saved SOUL.md");
assert_eq!(
bootstrap.load(BootstrapKind::Soul).expect("reads"),
Some("I am claw: terse.".to_owned())
);
// A kind the schema does not offer still cannot reach the file:
// the refusal comes back in band, with the reason.
let environment = call(
"c2",
json!({"kind": "environment", "content": "machine facts"}),
)
.await;
assert!(environment.is_error);
assert!(
environment.content.contains("generated at boot"),
"{}",
environment.content
);
let unknown = call("c3", json!({"kind": "persona", "content": "x"})).await;
assert!(unknown.is_error);
assert!(
unknown.content.contains("unknown kind"),
"{}",
unknown.content
);
let malformed = call("c4", json!({"kind": "soul"})).await;
assert!(malformed.is_error);
assert!(
malformed.content.contains("bad arguments"),
"{}",
malformed.content
);
}
}
+546
View File
@@ -0,0 +1,546 @@
//! The calendar source: deterministic detection of due reminders, the
//! task scheduler's shape over a calendar feed.
//!
//! The [`CalendarSource`] is a [`Source`] named `"calendars"` on the
//! resident dispatcher: its `detect` pass polls every configured
//! calendar for the occurrences starting within its horizon and
//! ingests one event per due reminder slot, a (occurrence, lead time)
//! pair whose moment has come. The dedup id carries all three facts
//! (`{calendar}:{occurrence id}:{lead}`), so a slot feeds once and its
//! repeats dedup away in the dispatcher, and the next lead's slot is a
//! different id firing later. A slot whose start has passed is never
//! fed: a reminder never fires after the fact. All-day occurrences
//! never fire: a lead in minutes against a midnight start is noise,
//! and a date-shaped nudge is what the task tools are for.
//!
//! The model is never reached here; the turn happens because the
//! dispatcher admitted the `calendar:<name>` key, exactly as for a
//! task firing, and the resident handler's delivery rule auto-notifies
//! the final text to the user's devices. One session per calendar
//! persists across its reminders, so the claw remembers what it said
//! about the last one. A dead feed is logged and skipped, never fatal:
//! an outage must not eat the other calendars' reminders on the same
//! pass. Cadence belongs to the daemon's loop.
use std::sync::Arc;
use rhema_autonomous_agent::{Source, SourceError, async_trait};
use rhema_calendar_integration::{
CaldavCalendar, Calendar, CalendarEvent, GoogleCalendar, IcsCalendar, Window,
};
use rhema_rsd::{Clock, Dispatcher, Event, KeyFn};
use serde_json::json;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::config::{CalendarKind, CalendarWatch};
use crate::interactive::passthrough_key_fn;
/// The source name the calendar reminders ingest under; registered on
/// the resident dispatcher, so allowlists and `/v1/ingest` can name it.
pub const CALENDARS_SOURCE: &str = "calendars";
/// The env variable a calendar's token falls back to.
pub const TOKEN_ENV: &str = "CALENDAR_TOKEN";
/// The env variable a CalDAV password falls back to.
pub const CALDAV_PASSWORD_ENV: &str = "CALDAV_PASSWORD";
/// One calendar's reminder key: `calendar:<name>` — the session the
/// reminder turns run in, persistent across the calendar's reminders.
pub fn calendar_key(name: &str) -> String {
format!("calendar:{name}")
}
/// One configured calendar, resolved to a client the source polls.
pub(crate) struct Feed {
name: String,
client: Arc<dyn Calendar>,
leads: Vec<u64>,
horizon_hours: u32,
}
/// The client one calendar watch polls through. Credentials resolve
/// before construction: the api key wins for Google, else the token
/// (config first, then [`TOKEN_ENV`]), and a feed takes its token or
/// polls anonymous, which is how most published feed URLs work.
fn client_for(calendar: &CalendarWatch) -> Result<Arc<dyn Calendar>, String> {
match calendar.kind {
CalendarKind::Google => {
let id = calendar
.calendar_id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
.ok_or_else(|| "google needs calendar_id".to_owned())?;
if let Some(key) = calendar.resolved_api_key() {
return Ok(Arc::new(GoogleCalendar::with_api_key(key, id)));
}
let token = calendar
.resolved_token()
.ok_or_else(|| "google needs api_key or token".to_owned())?;
Ok(Arc::new(GoogleCalendar::new(token, id)))
}
CalendarKind::Ics => {
let url = calendar
.url
.as_deref()
.map(str::trim)
.filter(|url| !url.is_empty())
.ok_or_else(|| "ics needs url".to_owned())?;
let mut feed = IcsCalendar::new(url);
if let Some(token) = calendar.resolved_token() {
feed = feed.with_token(token);
}
Ok(Arc::new(feed))
}
CalendarKind::Caldav => {
let url = calendar
.url
.as_deref()
.map(str::trim)
.filter(|url| !url.is_empty())
.ok_or_else(|| "caldav needs url".to_owned())?;
let mut dav = CaldavCalendar::new(url);
// A username means Basic auth, the self-hosted norm, and
// the password must have resolved at validation; a token
// without one is the bearer spelling; neither polls
// anonymous, which is what open servers take.
if let Some(user) = calendar
.username
.as_deref()
.map(str::trim)
.filter(|user| !user.is_empty())
{
let password = calendar.resolved_password().ok_or_else(|| {
"caldav needs a password: set password or CALDAV_PASSWORD".to_owned()
})?;
dav = dav.with_basic(user, password);
} else if let Some(token) = calendar.resolved_token() {
dav = dav.with_token(token);
}
Ok(Arc::new(dav))
}
}
}
/// The reminder slots of one poll's events: (event, lead) pairs that
/// are due, in feed order, leads in configured order. Due means the
/// lead's warning moment has arrived and the event has not started;
/// all-day events and started events contribute nothing.
pub(crate) fn due_slots<'a>(
events: &'a [CalendarEvent],
leads: &[u64],
now: OffsetDateTime,
) -> Vec<(&'a CalendarEvent, u64)> {
let now_secs = now.unix_timestamp();
let mut slots = Vec::new();
for event in events {
if event.all_day {
continue;
}
let start = event.start.unix_timestamp();
if start <= now_secs {
continue;
}
for lead in leads {
if start - i64::try_from(*lead).unwrap_or(i64::MAX) * 60 <= now_secs {
slots.push((event, *lead));
}
}
}
slots
}
/// The event one due slot becomes: keyed to the calendar's session,
/// its dedup id naming the calendar, the occurrence, and the lead, so
/// a refired slot dedups and the next lead does not.
pub(crate) fn reminder_event(
calendar: &str,
event: &CalendarEvent,
lead: u64,
now: OffsetDateTime,
received_at: u64,
) -> Event {
// The start is strictly future at this point, so the count is at
// least one.
let minutes = ((event.start - now).whole_seconds() + 59) / 60;
let title = match event.title.trim() {
"" => "(untitled)",
trimmed => trimmed,
};
let start = event.start.format(&Rfc3339).unwrap_or_default();
let mut lines = vec![
format!(
"Calendar reminder from the daemon (source `{CALENDARS_SOURCE}`), not a user message."
),
format!("Calendar {calendar:?}: {title:?} starts in {minutes} minutes at {start}."),
];
if let Some(location) = nonempty(event.location.as_deref()) {
lines.push(format!("Where: {location}"));
}
if let Some(notes) = nonempty(event.description.as_deref()) {
lines.push(format!("Notes: {notes}"));
}
if let Some(url) = nonempty(event.url.as_deref()) {
lines.push(format!("Link: {url}"));
}
if !event.attendees.is_empty() {
lines.push(format!("Attendees: {}", event.attendees.join(", ")));
}
if let Some(organizer) = nonempty(event.organizer.as_deref()) {
lines.push(format!("Organizer: {organizer}"));
}
lines.push(
"Reply with one short line telling the user; this session's reply \
auto-notifies their devices."
.to_owned(),
);
Event {
id: format!("{calendar}:{}:{lead}", event.id),
source: CALENDARS_SOURCE.to_owned(),
payload: json!({ "key": calendar_key(calendar), "prompt": lines.join("\n") }),
received_at,
}
}
/// A trimmed string, or `None` when it is all whitespace.
fn nonempty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
/// The detectable stream of calendar reminders: every configured
/// calendar, polled on the daemon's tick, feeding the resident
/// dispatcher.
pub struct CalendarSource {
dispatcher: Arc<Dispatcher>,
clock: Arc<dyn Clock>,
feeds: Vec<Feed>,
}
impl CalendarSource {
/// Registers the `calendars` source's key function on `dispatcher`
/// and resolves `calendars` into clients. Config validation ran at
/// load; a calendar that still fails here names itself in the
/// error, and the daemon refuses to start.
pub fn new(
dispatcher: &Arc<Dispatcher>,
clock: Arc<dyn Clock>,
calendars: &[CalendarWatch],
) -> Result<Self, String> {
let mut feeds = Vec::with_capacity(calendars.len());
for calendar in calendars {
feeds.push(Feed {
name: calendar.name.trim().to_owned(),
client: client_for(calendar)?,
leads: calendar.leads(),
horizon_hours: calendar.horizon(),
});
}
dispatcher.register_source(CALENDARS_SOURCE, passthrough_key_fn());
Ok(Self {
dispatcher: dispatcher.clone(),
clock,
feeds,
})
}
/// A source over ready-made feeds. Tests use this; production
/// builds from the config with [`CalendarSource::new`].
#[cfg(test)]
pub(crate) fn from_feeds(
dispatcher: &Arc<Dispatcher>,
clock: Arc<dyn Clock>,
feeds: Vec<Feed>,
) -> Self {
dispatcher.register_source(CALENDARS_SOURCE, passthrough_key_fn());
Self {
dispatcher: dispatcher.clone(),
clock,
feeds,
}
}
}
#[async_trait]
impl Source for CalendarSource {
fn name(&self) -> &str {
CALENDARS_SOURCE
}
fn key_fn(&self) -> Arc<KeyFn> {
passthrough_key_fn()
}
async fn detect(&self) -> Result<usize, SourceError> {
let now_ms = self.clock.now_millis() as i64;
let now = OffsetDateTime::from_unix_timestamp_nanos(now_ms as i128 * 1_000_000)
.map_err(|err| SourceError::Backend(format!("clock: {err}")))?;
let mut fed = 0;
for feed in &self.feeds {
let window = Window::new(
now,
now + time::Duration::hours(i64::from(feed.horizon_hours)),
);
let events = match feed.client.events(&window).await {
Ok(events) => events,
// A dead feed is this tick's loss, not the daemon's:
// the next pass polls it again, and the other
// calendars' reminders never wait behind it.
Err(err) => {
eprintln!("[claw] warning: calendar {} failed: {err}", feed.name);
continue;
}
};
for (event, lead) in due_slots(&events, &feed.leads, now) {
self.dispatcher.ingest(reminder_event(
&feed.name,
event,
lead,
now,
now_ms as u64,
))?;
fed += 1;
}
}
Ok(fed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rhema_rsd::{DispatcherConfig, FixedClock, ResourceKey, SystemClock, VolatileStore};
/// 2026-10-20T00:00:00Z, in milliseconds.
const NOW_MS: i64 = 1_792_444_800_000;
fn dispatcher() -> Arc<Dispatcher> {
Arc::new(Dispatcher::new(
DispatcherConfig {
debounce_window_millis: 0,
..DispatcherConfig::default()
},
Arc::new(SystemClock),
Arc::new(VolatileStore::new()),
))
}
/// A feed that answers with fixed events, whatever the window.
struct Fixed(Vec<CalendarEvent>);
#[async_trait]
impl Calendar for Fixed {
async fn events(
&self,
_window: &Window,
) -> Result<Vec<CalendarEvent>, rhema_calendar_integration::CalendarError> {
Ok(self.0.clone())
}
}
fn event(id: &str, title: &str, minutes_from_now: i64) -> CalendarEvent {
let start = OffsetDateTime::from_unix_timestamp(NOW_MS / 1000 + minutes_from_now * 60)
.expect("timestamp");
CalendarEvent {
id: format!("ics:{id}@{}", start.unix_timestamp()),
title: title.to_owned(),
start,
end: Some(start + time::Duration::minutes(30)),
all_day: false,
location: None,
description: None,
attendees: Vec::new(),
organizer: None,
url: None,
raw: serde_json::json!({}),
}
}
fn source(
dispatcher: &Arc<Dispatcher>,
clock: Arc<dyn Clock>,
events: Vec<CalendarEvent>,
) -> CalendarSource {
CalendarSource::from_feeds(
dispatcher,
clock,
vec![Feed {
name: "home".to_owned(),
client: Arc::new(Fixed(events)),
leads: vec![60, 15],
horizon_hours: 24,
}],
)
}
fn inbox_depth(dispatcher: &Dispatcher) -> usize {
dispatcher
.status(&ResourceKey::from(calendar_key("home")))
.expect("status reads")
.inbox_depth
}
#[tokio::test]
async fn a_due_slot_fires_once_and_stays_deduped() {
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
let dispatcher = dispatcher();
let source = source(
&dispatcher,
clock.clone(),
vec![event("standup", "standup", 90)],
);
// Ninety minutes out, neither lead is due yet.
assert_eq!(source.detect().await.expect("detect runs"), 0);
// Sixty minutes out the first slot is due: one event on the
// calendar's key.
clock.advance(30 * 60 * 1000);
assert_eq!(source.detect().await.expect("detect runs"), 1);
assert_eq!(inbox_depth(&dispatcher), 1);
// The next pass replays the same dedup id: fed, deduped away.
assert_eq!(source.detect().await.expect("detect runs"), 1);
assert_eq!(inbox_depth(&dispatcher), 1);
}
#[tokio::test]
async fn every_lead_fires_once_at_its_own_moment() {
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
let dispatcher = dispatcher();
// Starts in 90 minutes: the 60 lead is not due yet.
let source = source(&dispatcher, clock.clone(), vec![event("demo", "demo", 90)]);
assert_eq!(source.detect().await.expect("detect runs"), 0);
// At 60 minutes out the first slot fires.
clock.advance(30 * 60 * 1000);
assert_eq!(source.detect().await.expect("detect runs"), 1);
// At 15 minutes out the second slot fires under its own id.
// The count includes the first slot's replay, which dedups:
// the inbox holds one event per lead, never more.
clock.advance(45 * 60 * 1000);
assert_eq!(source.detect().await.expect("detect runs"), 2);
assert_eq!(inbox_depth(&dispatcher), 2);
// Past the start: nothing more, ever.
clock.advance(20 * 60 * 1000);
assert_eq!(source.detect().await.expect("detect runs"), 0);
assert_eq!(inbox_depth(&dispatcher), 2);
}
#[tokio::test]
async fn a_slot_first_seen_late_fires_late_once() {
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
let dispatcher = dispatcher();
// The daemon only meets this event 15 minutes before it
// starts: the 60 slot's moment came and went unseen, the 15
// slot is due. Both are fed on that first pass, then dedup.
let source = source(&dispatcher, clock.clone(), vec![event("late", "late", 15)]);
assert_eq!(source.detect().await.expect("detect runs"), 2);
assert_eq!(source.detect().await.expect("detect runs"), 2);
assert_eq!(inbox_depth(&dispatcher), 2);
}
#[tokio::test]
async fn a_started_or_all_day_event_never_fires() {
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
let dispatcher = dispatcher();
let mut all_day = event("offsite", "offsite", 10);
all_day.all_day = true;
let started = event("past", "already going", -5);
let source = source(&dispatcher, clock.clone(), vec![all_day, started]);
assert_eq!(source.detect().await.expect("detect runs"), 0);
assert_eq!(inbox_depth(&dispatcher), 0);
}
#[tokio::test]
async fn a_dead_feed_is_skipped_not_fatal() {
struct Dead;
#[async_trait]
impl Calendar for Dead {
async fn events(
&self,
_window: &Window,
) -> Result<Vec<CalendarEvent>, rhema_calendar_integration::CalendarError> {
Err(rhema_calendar_integration::CalendarError::Network(
"down".to_owned(),
))
}
}
let clock = Arc::new(FixedClock::new(NOW_MS as u64));
let dispatcher = dispatcher();
let source = CalendarSource::from_feeds(
&dispatcher,
clock.clone(),
vec![
Feed {
name: "dead".to_owned(),
client: Arc::new(Dead),
leads: vec![15],
horizon_hours: 24,
},
Feed {
name: "alive".to_owned(),
client: Arc::new(Fixed(vec![event("alive-event", "alive", 10)])),
leads: vec![15],
horizon_hours: 24,
},
],
);
assert_eq!(source.detect().await.expect("detect runs"), 1);
assert_eq!(
dispatcher
.status(&ResourceKey::from(calendar_key("alive")))
.expect("status reads")
.inbox_depth,
1
);
}
#[test]
fn the_prompt_names_everything_a_reply_needs() {
let now = OffsetDateTime::from_unix_timestamp(NOW_MS / 1000).expect("timestamp");
let mut event = event("dentist", "dentist", 15);
event.location = Some("rue de la Paix".to_owned());
event.description = Some("cleaning".to_owned());
event.attendees = vec!["dr@example.net".to_owned()];
event.organizer = Some("dr@example.net".to_owned());
event.url = Some("https://meet.example.net/x".to_owned());
let slot = reminder_event("home", &event, 15, now, NOW_MS as u64);
assert_eq!(slot.source, CALENDARS_SOURCE);
assert_eq!(
slot.id,
format!("home:{}:15", event.id),
"the dedup id names calendar, occurrence, and lead"
);
assert_eq!(slot.payload["key"], json!(calendar_key("home")));
let prompt = slot.payload["prompt"].as_str().expect("prompt");
assert!(prompt.contains("\"dentist\""), "got: {prompt}");
assert!(prompt.contains("in 15 minutes"), "got: {prompt}");
assert!(prompt.contains("home"), "got: {prompt}");
assert!(prompt.contains("rue de la Paix"), "got: {prompt}");
assert!(prompt.contains("cleaning"), "got: {prompt}");
assert!(prompt.contains("dr@example.net"), "got: {prompt}");
assert!(
prompt.contains("https://meet.example.net/x"),
"got: {prompt}"
);
assert!(prompt.contains("auto-notifies"), "got: {prompt}");
}
#[test]
fn an_untitled_event_still_reads_as_a_reminder() {
let now = OffsetDateTime::from_unix_timestamp(NOW_MS / 1000).expect("timestamp");
let event = event("bare", " ", 3);
let slot = reminder_event("home", &event, 15, now, NOW_MS as u64);
let prompt = slot.payload["prompt"].as_str().expect("prompt");
assert!(prompt.contains("(untitled)"), "got: {prompt}");
assert!(prompt.contains("in 3 minutes"), "got: {prompt}");
}
}
File diff suppressed because it is too large. Load diff
+751
View File
@@ -0,0 +1,751 @@
//! The consent round-trip: how a running turn asks the human for
//! something only a human can give — a credential to store, permission
//! to act — and waits, inside the tool call, for the answer.
//!
//! The shape is one table plus one bridge. [`ConsentStore`] keeps the
//! request rows: what was asked, of which session, and how it
//! resolved — never the secret itself, which travels once, from the
//! answering client through the HTTP route into the identity store,
//! and is gone. [`WaitBridge`] is how the HTTP route wakes the tool
//! that awaits its row's resolution; the tool re-reads the row around
//! every wake, so a resolution that raced the waiter's registration is
//! caught by the store, not lost.
//!
//! Unanswered requests expire by age: [`CONSENT_WAIT`] bounds both the
//! tool's patience and the `pending` list a UI polls, so a daemon that
//! restarted under a waiting tool (the waiter is gone) stops offering
//! a stale card. The tool answers a denial or an expiry in-band — a
//! turn that asked and was refused is a turn that can say so.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
use rhema_core::ToolSpec;
use rusqlite::params;
use serde::Deserialize;
use serde_json::{Value, json};
use tokio::sync::Notify;
use crate::hub::{EventHub, TurnEvent};
use rhema_rsd::ResourceKey;
/// How long a consent request stays answerable — the tool's patience
/// and the pending list's horizon, one number so neither lies about
/// the other.
pub const CONSENT_WAIT: Duration = Duration::from_secs(15 * 60);
fn now_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
/// What kind of answer a request waits for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ConsentKind {
/// A credential: the human pastes a secret, it lands in the
/// identity store, the tool learns only the account id.
Secret,
/// Permission: the human approves or denies an action.
Phone,
}
impl ConsentKind {
/// The wire spelling, in consent rows and on the SSE stream.
pub fn as_str(self) -> &'static str {
match self {
ConsentKind::Secret => "secret",
ConsentKind::Phone => "phone",
}
}
/// Parses the stored spelling; `None` for anything else.
pub fn parse(kind: &str) -> Option<Self> {
match kind {
"secret" => Some(ConsentKind::Secret),
"phone" => Some(ConsentKind::Phone),
_ => None,
}
}
}
/// One consent request: asked, and maybe answered.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Consent {
/// The request's id (`con-…`).
pub id: String,
/// What kind of answer it waits for.
pub kind: ConsentKind,
/// The one-line ask, as the UI shows it.
pub summary: String,
/// The ask's context — the model's own reason.
pub detail: String,
/// The session that asked.
pub session_key: String,
/// For a secret request: the account fields to store the answer
/// under (label, service, url, username, secret_kind) — the draft
/// without its secret. `None` for permission requests.
pub draft: Option<Value>,
/// When the request was made, epoch millis.
pub created_at: u64,
/// When it resolved, epoch millis, if it did.
pub resolved_at: Option<u64>,
/// Whether it was granted, if it resolved.
pub granted: Option<bool>,
/// For a resolved secret request: the account the secret landed in.
pub account_id: Option<String>,
}
/// Why a consent-store operation failed.
#[derive(Debug, thiserror::Error)]
pub enum ConsentStoreError {
/// The backend failed to read or write.
#[error("consent store failed: {0}")]
Backend(String),
}
/// The consent table over the claw's shared database.
pub struct ConsentStore {
conn: Mutex<rusqlite::Connection>,
}
impl ConsentStore {
/// Opens (creating if needed) the consent table on the database at
/// `path`.
pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, ConsentStoreError> {
let conn = rusqlite::Connection::open(path)
.map_err(|err| ConsentStoreError::Backend(err.to_string()))?;
Self::init(conn)
}
/// Opens the consent table over an in-memory database.
pub fn in_memory() -> Result<Self, ConsentStoreError> {
let conn = rusqlite::Connection::open_in_memory()
.map_err(|err| ConsentStoreError::Backend(err.to_string()))?;
Self::init(conn)
}
fn init(conn: rusqlite::Connection) -> Result<Self, ConsentStoreError> {
let conn = conn;
conn.busy_timeout(std::time::Duration::from_secs(5))
.map_err(|err| ConsentStoreError::Backend(err.to_string()))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS consents (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
summary TEXT NOT NULL,
detail TEXT NOT NULL,
session_key TEXT NOT NULL,
draft TEXT,
created_at INTEGER NOT NULL,
resolved_at INTEGER,
granted INTEGER,
account_id TEXT
);",
)
.map_err(|err| ConsentStoreError::Backend(err.to_string()))?;
Ok(Self {
conn: Mutex::new(conn),
})
}
/// Records a request, durable before it returns. The id is unique
/// per request (a wall-clock millisecond plus a process counter).
pub fn create(
&self,
kind: ConsentKind,
summary: &str,
detail: &str,
session_key: &str,
draft: Option<Value>,
) -> Result<Consent, ConsentStoreError> {
static CONSENT_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let now = now_millis();
let id = format!(
"con-{}-{:04x}",
now,
CONSENT_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
);
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
conn.execute(
"INSERT INTO consents (id, kind, summary, detail, session_key, draft, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
id,
kind.as_str(),
summary,
detail,
session_key,
draft.as_ref().map(Value::to_string),
now as i64,
],
)
.map_err(|err| ConsentStoreError::Backend(err.to_string()))?;
Ok(Consent {
id,
kind,
summary: summary.to_owned(),
detail: detail.to_owned(),
session_key: session_key.to_owned(),
draft,
created_at: now,
resolved_at: None,
granted: None,
account_id: None,
})
}
/// The request with this id, when it exists.
pub fn get(&self, id: &str) -> Result<Option<Consent>, ConsentStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let mut stmt = conn
.prepare(
"SELECT id, kind, summary, detail, session_key, draft, created_at,
resolved_at, granted, account_id
FROM consents WHERE id = ?1",
)
.map_err(backend)?;
let mut rows = stmt.query_map(params![id], row_consent).map_err(backend)?;
rows.next().transpose().map_err(backend)
}
/// Every unanswered, unexpired request, oldest first — the list a
/// UI polls after a reload, so a card is never lost to one.
pub fn pending(&self) -> Result<Vec<Consent>, ConsentStoreError> {
let horizon = (now_millis().saturating_sub(CONSENT_WAIT.as_millis() as u64)) as i64;
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let mut stmt = conn
.prepare(
"SELECT id, kind, summary, detail, session_key, draft, created_at,
resolved_at, granted, account_id
FROM consents
WHERE resolved_at IS NULL AND created_at >= ?1
ORDER BY created_at, id",
)
.map_err(backend)?;
let rows = stmt
.query_map(params![horizon], row_consent)
.map_err(backend)?;
rows.collect::<Result<Vec<_>, _>>().map_err(backend)
}
/// Marks a secret request resolved and names the account the secret
/// landed in. `false` when the id is unknown or already resolved —
/// the answer is consumed exactly once.
pub fn resolve_secret(&self, id: &str, account_id: &str) -> Result<bool, ConsentStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let updated = conn
.execute(
"UPDATE consents SET resolved_at = ?2, granted = 1, account_id = ?3
WHERE id = ?1 AND resolved_at IS NULL",
params![id, now_millis() as i64, account_id],
)
.map_err(backend)?;
Ok(updated > 0)
}
/// Marks a permission request granted or denied. `false` when the id
/// is unknown or already resolved.
pub fn resolve_decision(&self, id: &str, granted: bool) -> Result<bool, ConsentStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let updated = conn
.execute(
"UPDATE consents SET resolved_at = ?2, granted = ?3
WHERE id = ?1 AND resolved_at IS NULL",
params![id, now_millis() as i64, granted],
)
.map_err(backend)?;
Ok(updated > 0)
}
}
fn backend(err: rusqlite::Error) -> ConsentStoreError {
ConsentStoreError::Backend(err.to_string())
}
fn row_consent(row: &rusqlite::Row<'_>) -> rusqlite::Result<Consent> {
let kind: String = row.get(1)?;
Ok(Consent {
id: row.get(0)?,
kind: ConsentKind::parse(&kind).unwrap_or(ConsentKind::Secret),
summary: row.get(2)?,
detail: row.get(3)?,
session_key: row.get(4)?,
draft: row
.get::<_, Option<String>>(5)?
.map(|json| serde_json::from_str(&json).unwrap_or(Value::Null)),
created_at: row.get::<_, i64>(6)?.max(0) as u64,
resolved_at: row
.get::<_, Option<i64>>(7)?
.map(|millis| millis.max(0) as u64),
granted: row.get::<_, Option<i64>>(8)?.map(|flag| flag != 0),
account_id: row.get(9)?,
})
}
/// Wakes tools waiting on a row's resolution. A wake for an id nobody
/// waits on yet is not an error and not lost: the waiter re-reads its
/// row around every wait, so the store, not the signal, is the truth.
#[derive(Default)]
pub struct WaitBridge {
waiters: Mutex<HashMap<String, Arc<Notify>>>,
}
impl WaitBridge {
/// An empty bridge.
pub fn new() -> Self {
Self::default()
}
/// The notify handle for `id`, creating it on first use. A waiter
/// holds this handle across its wait; the resolver and the phone
/// command wait both wait through it.
pub(crate) fn waiter(&self, id: &str) -> Arc<Notify> {
self.waiters
.lock()
.unwrap_or_else(PoisonError::into_inner)
.entry(id.to_owned())
.or_default()
.clone()
}
/// Wakes whoever waits on `id`, if anyone.
pub fn wake(&self, id: &str) {
let waiter = self
.waiters
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(id)
.cloned();
if let Some(waiter) = waiter {
waiter.notify_one();
}
}
}
/// What a wait came back with.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WaitOutcome {
/// Resolved in favor: for a secret request, the account id the
/// answer was stored under.
Granted(Option<String>),
/// Resolved against.
Denied,
/// [`CONSENT_WAIT`] elapsed with no answer.
TimedOut,
}
/// Waits until `id`'s row resolves or the budget runs out. The row —
/// not the wake — is what the answer reads, so a resolution that raced
/// the waiter is still seen.
pub async fn wait_for_resolution(
store: &ConsentStore,
bridge: &WaitBridge,
id: &str,
budget: Duration,
) -> WaitOutcome {
let deadline = tokio::time::Instant::now() + budget;
loop {
if let Ok(Some(consent)) = store.get(id) {
if let Some(granted) = consent.granted {
return if granted {
WaitOutcome::Granted(consent.account_id)
} else {
WaitOutcome::Denied
};
}
}
let waiter = bridge.waiter(id);
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
match tokio::time::timeout(remaining, waiter.notified()).await {
Ok(_) => continue,
Err(_) => return WaitOutcome::TimedOut,
}
}
}
/// The tool's name on the wire.
pub const ASK_SECRET: &str = "ask_secret";
/// `ask_secret`: store an account through the human. The tool asks, the
/// chat surface shows a card, the human pastes the secret, the HTTP
/// route stores it into the identity store, and the tool wakes with the
/// account id — never the secret, which nothing here re-renders.
struct AskSecret {
consents: Arc<ConsentStore>,
bridge: Arc<WaitBridge>,
hub: Arc<EventHub>,
key: ResourceKey,
/// The tool's patience; [`CONSENT_WAIT`] in production, a sliver in
/// tests.
wait: Duration,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AskSecretArgs {
service: String,
#[serde(default)]
label: Option<String>,
#[serde(default)]
username: Option<String>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
secret_kind: Option<String>,
#[serde(default)]
reason: Option<String>,
}
#[async_trait]
impl Tool for AskSecret {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
ASK_SECRET,
"Ask the human for a credential and store it in the agent \
accounts: a card appears in their chat where they paste the \
secret. Use it when a task needs an account (a forge token, \
an API key) that identity_list does not show yet — never ask \
for something already stored. The answer carries no secret, \
only whether it was stored and the account's id. The human \
may decline, or answer never; both come back in-band. Act \
with the stored account afterwards through identity_exec.",
json!({
"type": "object",
"properties": {
"service": { "type": "string",
"description": "The service the credential is for, \
e.g. 'github' or an internal tool's name." },
"label": { "type": "string",
"description": "Display name for the account; \
defaults to the service name." },
"username": { "type": "string",
"description": "The login name on the service." },
"url": { "type": "string",
"description": "Base URL, for self-hosted instances." },
"secret_kind": { "type": "string",
"enum": ["token", "password"],
"description": "What the secret is; default token." },
"reason": { "type": "string",
"description": "Why you need it — shown on the card." }
},
"required": ["service"]
}),
)
}
async fn invoke(&self, args: Value) -> Result<String, String> {
let args: AskSecretArgs =
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
let service = args.service.trim();
if service.is_empty() {
return Err("service must not be empty".to_owned());
}
// Validate the kind spelling before the card exists: a typo'd
// enum must fail the call, not the human's answer.
let secret_kind = crate::accounts_tools::parse_kind(args.secret_kind.as_deref())?;
let label = args
.label
.as_deref()
.map(str::trim)
.filter(|label| !label.is_empty())
.unwrap_or(service)
.to_owned();
let summary = match args
.username
.as_deref()
.map(str::trim)
.filter(|u| !u.is_empty())
{
Some(username) => format!("secret for {service} ({username})"),
None => format!("secret for {service}"),
};
let detail = args
.reason
.as_deref()
.map(str::trim)
.filter(|reason| !reason.is_empty())
.unwrap_or("no reason given")
.to_owned();
let draft = json!({
"label": label,
"service": service,
"url": args.url,
"username": args.username,
"secret_kind": match secret_kind {
rhema_identity::SecretKind::Token => "token",
rhema_identity::SecretKind::Password => "password",
},
});
let consent = self
.consents
.create(
ConsentKind::Secret,
&summary,
&detail,
self.key.as_str(),
Some(draft),
)
.map_err(|err| err.to_string())?;
self.hub.emit(
&self.key,
TurnEvent::ConsentRequest {
id: consent.id.clone(),
kind: consent.kind.as_str().to_owned(),
summary: consent.summary.clone(),
detail: consent.detail.clone(),
created_at: consent.created_at,
},
);
match wait_for_resolution(&self.consents, &self.bridge, &consent.id, self.wait).await {
WaitOutcome::Granted(account_id) => Ok(json!({
"stored": true,
"account_id": account_id,
"service": service,
})
.to_string()),
WaitOutcome::Denied => Ok(json!({"granted": false}).to_string()),
WaitOutcome::TimedOut => Ok(json!({"timed_out": true}).to_string()),
}
}
}
/// The consent tool set over `store`: `ask_secret`. Built per dispatch,
/// so the request names the session that asked.
pub fn registry(
consents: Arc<ConsentStore>,
bridge: Arc<WaitBridge>,
hub: Arc<EventHub>,
key: ResourceKey,
) -> ToolRegistry {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(AskSecret {
consents,
bridge,
hub,
key,
wait: CONSENT_WAIT,
}));
registry
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> Arc<ConsentStore> {
Arc::new(ConsentStore::in_memory().expect("store opens"))
}
#[test]
fn a_created_request_round_trips_and_starts_pending() {
let store = store();
let consent = store
.create(
ConsentKind::Secret,
"secret for github",
"to post",
"interactive:web",
Some(json!({"label": "github"})),
)
.expect("creates");
assert_eq!(consent.kind, ConsentKind::Secret);
assert_eq!(consent.granted, None);
assert_eq!(consent.session_key, "interactive:web");
let fetched = store.get(&consent.id).expect("reads").expect("exists");
assert_eq!(fetched, consent);
assert_eq!(store.pending().expect("reads"), vec![consent]);
}
#[test]
fn a_secret_resolution_names_the_account_exactly_once() {
let store = store();
let consent = store
.create(ConsentKind::Secret, "s", "d", "interactive:web", None)
.expect("creates");
assert!(
store
.resolve_secret(&consent.id, "acc-1")
.expect("resolves"),
"the first resolution lands"
);
assert!(
!store
.resolve_secret(&consent.id, "acc-2")
.expect("resolves"),
"the answer is consumed once"
);
let resolved = store.get(&consent.id).expect("reads").expect("exists");
assert_eq!(resolved.account_id.as_deref(), Some("acc-1"));
assert_eq!(resolved.granted, Some(true));
assert!(
store.pending().expect("reads").is_empty(),
"resolved rows leave the pending list"
);
}
#[test]
fn a_decision_resolves_once_with_its_verdict() {
let store = store();
let consent = store
.create(
ConsentKind::Phone,
"phone dnd_on",
"quiet",
"interactive:web",
None,
)
.expect("creates");
assert!(
store
.resolve_decision(&consent.id, false)
.expect("resolves"),
"a denial lands"
);
let resolved = store.get(&consent.id).expect("reads").expect("exists");
assert_eq!(resolved.granted, Some(false));
assert_eq!(resolved.account_id, None);
assert!(
!store.resolve_decision(&consent.id, true).expect("resolves"),
"a denial cannot be flipped"
);
}
#[test]
fn an_expired_request_leaves_the_pending_list() {
let store = store();
{
let conn = store.conn.lock().unwrap_or_else(PoisonError::into_inner);
conn.execute(
"INSERT INTO consents (id, kind, summary, detail, session_key, created_at)
VALUES ('con-old', 'secret', 's', 'd', 'interactive:web', 1)",
[],
)
.expect("writes raw");
}
let fresh = store
.create(ConsentKind::Secret, "s", "d", "interactive:web", None)
.expect("creates");
let pending = store.pending().expect("reads");
assert_eq!(pending, vec![fresh], "only the unexpired request answers");
assert!(
store.get("con-old").expect("reads").is_some(),
"the expired row still reads"
);
}
/// The full round-trip, tool-side: the ask creates the row and the
/// stream event, the human's answer (the route's half: resolve,
/// wake) ends the wait, and the answer names the account — the
/// secret never passes through the tool.
#[tokio::test]
async fn an_ask_stored_through_a_consent_answers_with_the_account_id() {
use crate::hub::EventHub;
let store = store();
let bridge = Arc::new(WaitBridge::new());
let hub = Arc::new(EventHub::default());
let key = ResourceKey::from("interactive:web");
let tool = AskSecret {
consents: store.clone(),
bridge: bridge.clone(),
hub: hub.clone(),
key: key.clone(),
wait: std::time::Duration::from_secs(10),
};
let mut receiver = hub.subscribe(&key);
let task = tokio::spawn(async move {
tool.invoke(json!({
"service": "github",
"username": "octocat",
"reason": "to file the issue you asked for",
}))
.await
});
// The ask lands: one pending row, one stream event.
let pending = loop {
let pending = store.pending().expect("reads");
if !pending.is_empty() {
break pending;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
};
assert_eq!(pending[0].summary, "secret for github (octocat)");
assert!(matches!(
receiver.try_recv(),
Ok(TurnEvent::ConsentRequest { .. })
));
// The route's half: store the secret elsewhere, resolve, wake.
store
.resolve_secret(&pending[0].id, "acc-9")
.expect("resolves");
bridge.wake(&pending[0].id);
let answer = task.await.expect("the tool returns").expect("in-band ok");
assert_eq!(
serde_json::from_str::<Value>(&answer).unwrap(),
json!({"stored": true, "account_id": "acc-9", "service": "github"}),
"{answer}"
);
}
#[tokio::test]
async fn a_denied_ask_answers_in_band_and_not_as_an_error() {
let store = store();
let bridge = Arc::new(WaitBridge::new());
let hub = Arc::new(EventHub::default());
let key = ResourceKey::from("interactive:web");
let tool = AskSecret {
consents: store.clone(),
bridge: bridge.clone(),
hub,
key,
wait: std::time::Duration::from_secs(10),
};
let task = tokio::spawn(async move { tool.invoke(json!({"service": "gitlab"})).await });
let pending = loop {
let pending = store.pending().expect("reads");
if !pending.is_empty() {
break pending;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
};
store
.resolve_decision(&pending[0].id, false)
.expect("resolves");
bridge.wake(&pending[0].id);
let answer = task.await.expect("the tool returns").expect("in-band ok");
assert_eq!(
serde_json::from_str::<Value>(&answer).unwrap(),
json!({"granted": false}),
"{answer}"
);
}
#[tokio::test]
async fn an_unanswered_ask_times_out_in_band() {
let store = store();
let bridge = Arc::new(WaitBridge::new());
let hub = Arc::new(EventHub::default());
let key = ResourceKey::from("interactive:web");
let tool = AskSecret {
consents: store,
bridge,
hub,
key,
wait: std::time::Duration::from_millis(50),
};
let answer = tool
.invoke(json!({"service": "github"}))
.await
.expect("in-band ok");
assert_eq!(
serde_json::from_str::<Value>(&answer).unwrap(),
json!({"timed_out": true}),
"{answer}"
);
}
}
+548
View File
@@ -0,0 +1,548 @@
//! The daemon: sources, drivers, the HTTP surface, and the cadence
//! assembled into one resident process.
//!
//! Two dispatchers share one SQLite store: the forge dispatcher (watches
//! polled by the sources, answered by [`ClawHandler`]) and the
//! interactive dispatcher — the resident dispatcher — whose keys carry
//! the `interactive:`/`task:`/`phone:` prefixes and whose inputs are the
//! chat routes' messages, the `/v1/ingest` door, and the task
//! scheduler's due firings, all answered by the interactive handler.
//! Sharing the store is what makes the whole thing one resident agent
//! with one inventory — and it is safe because the store's whole-record
//! revision compare-and-swap serializes across every instance holding
//! the file (rhema-rsd's cross-connection CAS tests pin that).
//!
//! Every timer here drives deterministic detection and admission only —
//! the model is reached exclusively inside handler runs (spec §7's
//! invariant: a resident process is not a process that burns tokens on a
//! schedule). The `--once` shape is the same assembly with a zero
//! debounce window — a cron pass acts on what it sees now — and one
//! detect-plus-driver pass instead of a loop, with no HTTP surface: cron
//! machines have no one to chat with.
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use rhema_autonomous_agent::{Driver, Source, SourceError};
use rhema_rsd::{
DispatchError, Dispatcher, DispatcherConfig, Outcome, ResourceKey, SqliteStore, StoreError,
SystemClock,
};
use crate::calendar::CalendarSource;
use crate::config::{ClawConfig, ConfigError, ForgeKind};
use crate::forge::ForgeSource;
use crate::handler::ClawHandler;
use crate::http::ClawState;
use crate::notifications::NotificationStore;
use crate::task_source::TaskScheduler;
use crate::task_store::TaskStore;
use rhema_memory::FileMemoryStore;
use crate::backends::BackendRegistry;
use crate::hub::EventHub;
use crate::interactive::InteractiveHandler;
use crate::watch_store::ClawStore;
/// Why the daemon could not run.
#[derive(Debug, thiserror::Error)]
pub enum ClawError {
/// The config file is missing, malformed, or fails validation.
#[error(transparent)]
Config(#[from] ConfigError),
/// A directory or path the daemon needs could not be created, or the
/// HTTP listener could not bind.
#[error(transparent)]
Io(#[from] std::io::Error),
/// The SQLite store could not be opened.
#[error(transparent)]
Store(#[from] StoreError),
/// The watch table could not be opened or read.
#[error(transparent)]
Watches(#[from] crate::watch_store::WatchStoreError),
/// The runtime settings table could not be opened or read.
#[error(transparent)]
Settings(#[from] crate::settings::SettingsError),
/// The notification table could not be opened or read.
#[error(transparent)]
Notifications(#[from] crate::notifications::NotificationStoreError),
/// The task table could not be opened or read.
#[error(transparent)]
Tasks(#[from] crate::task_store::TaskStoreError),
/// The coding-job tables could not be opened or read.
#[error(transparent)]
Jobs(#[from] crate::job_store::JobStoreError),
/// The turn-history table could not be opened or read.
#[error(transparent)]
TurnHistory(#[from] crate::turn_store::TurnStoreError),
/// The consent table could not be opened or read.
#[error(transparent)]
Consents(#[from] crate::consents::ConsentStoreError),
/// The attachments directory could not be opened.
#[error(transparent)]
Attachments(#[from] crate::attachments::AttachmentError),
/// The phone tables could not be opened or read.
#[error(transparent)]
Phone(#[from] crate::phone::PhoneStoreError),
/// The backend registry could not be built: no provider constructed,
/// or the configured seed named an unknown kind.
#[error("backend registry failed: {0}")]
Backend(String),
/// The bootstrap store could not be opened.
#[error(transparent)]
Bootstrap(#[from] crate::bootstrap::BootstrapError),
/// A source's detect pass failed.
#[error(transparent)]
Source(#[from] SourceError),
/// The dispatcher rejected a step.
#[error(transparent)]
Dispatch(#[from] DispatchError),
}
/// One daemon pass: what detection fed, and what the runs reported.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TickReport {
/// Events fed by detection across all sources — replayed dedup ids
/// included; the dispatcher dedups.
pub detected: usize,
/// One outcome per forge key that became a run; a key that was
/// parked or raced is simply absent.
pub outcomes: Vec<Outcome>,
}
/// The interactive dispatcher's timing: messages dispatch immediately —
/// an open surface has nothing to coalesce (spec §6.2) — leases run an
/// hour and renew at the handler's poll boundaries because turns can run
/// long, and the caps are generous for the same reason.
fn interactive_dispatch() -> DispatcherConfig {
DispatcherConfig {
max_concurrent_leases: 64,
max_concurrent_per_source: BTreeMap::new(),
debounce_window_millis: 0,
lease_ttl_millis: 3_600_000,
admission_ttl_millis: 60_000,
}
}
/// The resident daemon: one source per forge kind, the calendar
/// reminders, the task scheduler, the coding-job source, the three
/// drivers, and the HTTP surface.
pub struct Daemon {
sources: Vec<ForgeSource>,
calendar_source: CalendarSource,
scheduler: TaskScheduler,
job_source: crate::jobs::JobsSource,
jobs: Arc<crate::job_store::CodingJobStore>,
forge_dispatcher: Arc<Dispatcher>,
interactive_dispatcher: Arc<Dispatcher>,
jobs_dispatcher: Arc<Dispatcher>,
forge_driver: Arc<Driver>,
interactive_driver: Arc<Driver>,
jobs_driver: Arc<Driver>,
poll_interval: Duration,
listen: String,
http: ClawState,
/// The registry the drivers' handlers read per dispatch; kept for
/// introspection (the active pair after a bootstrap).
registry: Arc<BackendRegistry>,
}
impl Daemon {
/// Assembles the daemon over `config`: the shared SQLite store, one
/// source per forge kind reading the watch table, the interactive
/// dispatcher, and both handlers running `provider`. `dispatch` is
/// the forge dispatcher's timing — the resident loop uses the
/// defaults, `--once` zeroes the debounce.
pub fn build(
config: &ClawConfig,
backends: Arc<BackendRegistry>,
dispatch: DispatcherConfig,
) -> Result<Self, ClawError> {
config.validate()?;
let db = config.db_path()?;
if let Some(parent) = db.parent() {
// The store holds provider keys and forge tokens: `0700` on
// the directory, `0600` on the file (below), both
// re-asserted on every boot.
rhema_config::privacy::private_dir(parent)?;
}
let workspace_root = config.workspace_root()?;
let interactive_workspace = workspace_root.join("interactive");
std::fs::create_dir_all(&interactive_workspace)?;
// One store file, several connections: the dispatcher's records,
// the watch table, the notification table, the task table, and
// the turn-history table live side by side.
let store = Arc::new(SqliteStore::open(&db)?);
// SQLite created the file under the umask; whatever it left
// wide, tighten. Best-effort: a filesystem that refuses mode
// changes must not stop the boot.
rhema_config::privacy::tighten_file(&db);
let watches = Arc::new(ClawStore::open(&db)?);
let notifications = Arc::new(NotificationStore::open(&db)?);
let tasks = Arc::new(TaskStore::open(&db)?);
let turns = Arc::new(crate::turn_store::TurnStore::open(&db)?);
// The coding-job tables: instances the `coding_agent` tool
// drives, jobs the jobs dispatcher answers.
let jobs = Arc::new(crate::job_store::CodingJobStore::open(&db)?);
// The YAML watch list is the one-time seed; the table rules
// after that.
let seeded = watches.seed_from_config(&config.watches)?;
if seeded > 0 {
eprintln!("[claw] seeded {seeded} watch(es) from the config");
}
let forge_dispatcher = Arc::new(Dispatcher::new(
dispatch,
Arc::new(SystemClock),
store.clone(),
));
let interactive_dispatcher = Arc::new(Dispatcher::new(
interactive_dispatch(),
Arc::new(SystemClock),
store.clone(),
));
// The jobs dispatcher answers `job:<instance>#<id>`: a coding
// run's turn can take minutes, so it leases on the resident
// timing and runs in its own driver, never blocking a chat.
let jobs_dispatcher = Arc::new(Dispatcher::new(
interactive_dispatch(),
Arc::new(SystemClock),
store.clone(),
));
// The account store the forge sources poll through: an account
// watch authenticates as its identity, and the secret reaches
// the forge client's constructor and nowhere else.
let accounts: Arc<dyn rhema_identity::IdentityStore> = Arc::new(
rhema_identity::SqliteIdentityStore::open(&db)
.map_err(|err| ClawError::Backend(err.to_string()))?,
);
// One source per known forge kind, whatever the table currently
// holds: a watch added later (chat tool, HTTP route) of a kind
// nobody watched at startup still has its key function
// registered. A source over zero watches detects nothing.
let sources = [ForgeKind::GitHub, ForgeKind::Gitea, ForgeKind::GitLab]
.into_iter()
.map(|kind| {
ForgeSource::new(
&forge_dispatcher,
kind.name(),
watches.clone(),
accounts.clone(),
notifications.clone(),
)
})
.collect();
// The forge handler resolves its watches through the table at
// dispatch time, so a watch added mid-run is answerable without a
// restart.
let forge_handler = ClawHandler::new(backends.clone(), workspace_root, watches.clone());
// Three drivers over one store admit from the same table, so
// each owns its slice of the key space: a forge pass must never
// pick up a resident or job event (it would fail it, and
// consume it), and the others likewise.
let forge_driver = Arc::new(
Driver::new(forge_dispatcher.clone(), Arc::new(forge_handler))
.with_holder("rhema-claw")
.owning(Arc::new(|key: &ResourceKey| {
!crate::interactive::is_resident_key(key.as_str())
&& !crate::jobs::is_job_key(key.as_str())
})),
);
// The hub replays what past turns streamed: every event appends
// to the turn-history table as it streams, so a restart's replay
// reads the table back — a restart continues the transcript,
// whole, with no boot-time seed pass.
let hub = Arc::new(EventHub::with_record_store(turns.clone()));
// The consent table and its wake bridge: the `ask_secret` tool
// asks through them, the consent routes answer through them.
let consents = Arc::new(crate::consents::ConsentStore::open(&db)?);
let bridge = Arc::new(crate::consents::WaitBridge::new());
// The attachments live beside the database, the same state
// volume the transcript is durable on: a served id must not
// outlive its bytes.
let attachments = Arc::new(crate::attachments::AttachmentStore::open(
db.parent()
.ok_or_else(|| {
ClawError::Backend("the database has no parent directory".to_owned())
})?
.join("attachments"),
)?);
// The phone tables: status the ingest door feeds, commands the
// `phone` tool queues behind a consent.
let phone = Arc::new(crate::phone::PhoneStore::open(&db)?);
let memory = Arc::new(FileMemoryStore::new(config.memory_path()?)?);
let bootstrap = Arc::new(crate::bootstrap::Bootstrap::new(config.bootstrap_dir()?)?);
let settings = Arc::new(crate::settings::SettingsStore::open(&db)?);
// The identity store shares the settings table and keeps its
// images beside the database.
let identity = Arc::new(crate::identity::IdentityStore::new(
settings.clone(),
db.parent().map(|dir| dir.join("identity")).ok_or_else(|| {
ClawError::Backend("the database has no parent directory".to_owned())
})?,
));
// The persisted pair wins over the config seed — it is what a
// runtime switch wrote. A persisted id the registry no longer
// knows (a backend removed from the deployment) falls back to
// the seed silently: the daemon still boots.
let persisted_provider = settings.get(crate::settings::ACTIVE_PROVIDER_KEY)?;
let persisted_model = settings.get(crate::settings::ACTIVE_MODEL_KEY)?;
if let Some(id) = persisted_provider {
let _ = backends.set_active(&id, persisted_model.as_deref());
}
// The environment report is detection, not conversation: generated
// at every boot by deterministic discovery, never by the model,
// and regenerated over whatever the last boot wrote.
crate::environment::write_report(config.bootstrap_dir()?.join("ENVIRONMENT.md"))?;
let interactive_handler = InteractiveHandler::new(
backends.clone(),
interactive_workspace,
watches.clone(),
memory,
bootstrap.clone(),
hub.clone(),
notifications.clone(),
tasks.clone(),
jobs.clone(),
accounts.clone(),
config.max_turn_steps,
consents.clone(),
bridge.clone(),
attachments.clone(),
phone.clone(),
identity.clone(),
);
let interactive_driver = Arc::new(
Driver::new(
interactive_dispatcher.clone(),
Arc::new(interactive_handler),
)
.with_holder("rhema-claw-interactive")
.owning(Arc::new(|key: &ResourceKey| {
crate::interactive::is_resident_key(key.as_str())
})),
);
// The coding jobs: one handler, one driver, one source. The
// final text of a job's turn notifies the user's device, so the
// handler needs the notification table too.
let jobs_handler = crate::jobs::CodingJobHandler::new(
backends.clone(),
jobs.clone(),
notifications.clone(),
config.max_turn_steps,
);
let jobs_driver = Arc::new(
Driver::new(jobs_dispatcher.clone(), Arc::new(jobs_handler))
.with_holder("rhema-claw-jobs")
.owning(Arc::new(|key: &ResourceKey| {
crate::jobs::is_job_key(key.as_str())
})),
);
let job_source = crate::jobs::JobsSource::new(&jobs_dispatcher, jobs.clone());
crate::interactive::register_user(&interactive_dispatcher);
// The scheduler is the resident dispatcher's second own source:
// its detect pass (the daemon's tick drives it) is what turns a
// due task row into an event, never a timer near the model.
let scheduler = TaskScheduler::new(
&interactive_dispatcher,
Arc::new(SystemClock),
tasks.clone(),
);
// The calendar reminders join them: a due reminder is an
// event, never a timer near the model.
let calendar_source = CalendarSource::new(
&interactive_dispatcher,
Arc::new(SystemClock),
&config.calendars,
)
.map_err(ClawError::Backend)?;
let http = ClawState::new(
interactive_dispatcher.clone(),
interactive_driver.clone(),
store,
watches,
notifications,
bootstrap,
hub,
backends.clone(),
settings.clone(),
identity,
accounts,
consents,
bridge,
attachments,
phone,
config.token.clone(),
);
Ok(Self {
sources,
calendar_source,
scheduler,
job_source,
jobs,
forge_dispatcher,
interactive_dispatcher,
jobs_dispatcher,
forge_driver,
interactive_driver,
jobs_driver,
// A zero poll interval would spin detect passes against the
// forge API; one second is the floor.
poll_interval: Duration::from_secs(config.poll_interval_secs.max(1)),
listen: config.listen_addr().to_owned(),
http,
registry: backends,
})
}
/// The HTTP router over the daemon's state — the exact app `run`
/// serves, exposed so a test can bind it anywhere.
pub fn router(&self) -> axum::Router {
crate::http::router(self.http.clone())
}
/// The HTTP surface's configured address.
pub fn listen_addr(&self) -> &str {
&self.listen
}
/// The deployment's backend registry: providers and the active pair.
pub fn backends(&self) -> &BackendRegistry {
&self.registry
}
/// Startup recovery: releases the leases the previous process left
/// behind — all of them, now, not at the dead lease's TTL (an hour
/// on the resident dispatcher), because the claw is single-node:
/// the only lease holder over this file is the process that just
/// died, and a key it held mid-turn must answer its next message
/// instead of stalling behind a ghost. A turn the restart cut
/// resumes then: the committed-but-unanswered call revives at the
/// top of the next dispatch (safe tools re-run, the rest report
/// interrupted). Idempotent; called before the first pass.
pub async fn bootstrap(&self) -> Result<(), ClawError> {
// The job rows first: a turn the restart cut is failed
// interrupted before anything re-runs, and a still-queued row
// is re-armed with a fresh dedup id.
let touched = self.jobs.recover()?;
if touched > 0 {
eprintln!("[claw] jobs: {touched} row(s) recovered at boot");
}
self.forge_dispatcher.recover_all()?;
self.jobs_dispatcher.recover_all()?;
self.interactive_dispatcher.recover_all()?;
Ok(())
}
/// One pass: every source detects (due tasks first — a reminder is
/// the user-facing clock), the forge driver runs every key the
/// debounce released, and the interactive driver drains as a
/// backstop — a message delivered live into a running turn waits at
/// the key for a pass like this one.
pub async fn tick(&self) -> Result<TickReport, ClawError> {
let mut detected = self.scheduler.detect().await?;
detected += self.calendar_source.detect().await?;
detected += self.job_source.detect().await?;
for source in &self.sources {
detected += source.detect().await?;
}
// A queued coding job starts before the forge pass: it is the
// thing someone just asked for.
if let Err(err) = self.jobs_driver.run_once().await {
eprintln!("[claw] warning: jobs pass failed: {err}");
}
let outcomes = self.forge_driver.run_once().await?;
if !outcomes.is_empty() {
let handled = outcomes
.iter()
.filter(|outcome| matches!(outcome, Outcome::Handled))
.count();
let failed = outcomes
.iter()
.filter(|outcome| matches!(outcome, Outcome::Failed(_)))
.count();
eprintln!(
"[claw] pass: {detected} event(s) detected, {handled} handled, {failed} failed"
);
}
match self.interactive_driver.run_once().await {
Ok(runs) if !runs.is_empty() => {
eprintln!("[claw] interactive: {} queued run(s) drained", runs.len());
}
Ok(_) => {}
Err(err) => eprintln!("[claw] warning: interactive pass failed: {err}"),
}
Ok(TickReport { detected, outcomes })
}
/// The resident loop: the HTTP surface comes up first (a resident is
/// talked to), then detect, run, sleep, until ctrl-c. A failed pass
/// is logged, not fatal — a forge outage or a dead backend must not
/// end a daemon whose next pass may simply succeed.
pub async fn run(&self) -> Result<(), ClawError> {
self.bootstrap().await?;
let listener = tokio::net::TcpListener::bind(&self.listen).await?;
let addr = listener.local_addr()?;
let app = self.router();
let server = tokio::spawn(async move {
if let Err(err) = axum::serve(listener, app).await {
eprintln!("[claw] http server stopped: {err}");
}
});
eprintln!("[claw] http listening on {addr}");
loop {
if let Err(err) = self.tick().await {
eprintln!("[claw] warning: pass failed: {err}");
}
tokio::select! {
() = tokio::time::sleep(self.poll_interval) => {}
_ = tokio::signal::ctrl_c() => {
server.abort();
return Ok(());
}
}
}
}
}
/// The `--once` shape: assemble over a zero debounce window, recover, run
/// one detect-plus-driver pass, and return. The cron-friendly form for
/// ephemeral machines, and the shape the tests drive. No HTTP: a cron
/// pass has no one to chat with.
pub async fn run_once(
config: &ClawConfig,
backends: Arc<BackendRegistry>,
) -> Result<TickReport, ClawError> {
let dispatch = DispatcherConfig {
debounce_window_millis: 0,
..DispatcherConfig::default()
};
let daemon = Daemon::build(config, backends, dispatch)?;
daemon.bootstrap().await?;
daemon.tick().await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_interactive_dispatcher_dispatches_now_and_leases_long() {
let dispatch = interactive_dispatch();
assert_eq!(dispatch.debounce_window_millis, 0, "chat is immediate");
assert_eq!(dispatch.lease_ttl_millis, 3_600_000, "an hour");
assert!(
dispatch.max_concurrent_leases >= 16,
"generous: turns are long"
);
}
}
+343
View File
@@ -0,0 +1,343 @@
//! The boot-time environment report (`ENVIRONMENT.md`): what the machine
//! already knows about itself, written before the first model call so no
//! turn is spent rediscovering it (framework spec §1 — detection is
//! deterministic code; §9 — context is earned).
//!
//! Everything here is synchronous, offline, and best-effort. Host facts
//! come from `std::env::consts` plus a couple of tiny probes; every
//! external command runs on a fixed argv (no shell, nothing
//! interpolated) under a short budget whose expiry — like any other
//! probe failure — is a line in the report, never a failed boot. The
//! caller owns the path (the bootstrap store passes it in; the default
//! shape is `<state-dir>/bootstrap/ENVIRONMENT.md`) and rewrites the file
//! on every daemon start.
use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use rhema_memory::record::format_utc;
/// The tools the report inventories, in report order. The list is what
/// v1's resident workflow reaches for; absence of any entry is ordinary.
const TOOLS: &[&str] = &["git", "cargo", "rustc", "docker", "node", "npm", "python3"];
/// The per-command budget: long enough for a cold `--version`, short
/// enough that a hung probe cannot stall a boot past it.
const COMMAND_TIMEOUT: Duration = Duration::from_secs(2);
/// How often a running probe is polled for exit.
const POLL_STEP: Duration = Duration::from_millis(10);
/// The report line stands in for absent facts that are not tools.
const UNKNOWN: &str = "unknown";
/// Generates the report and writes it to `path`, creating parent
/// directories as needed. Returns the generated text so the caller can
/// log or inject it without re-reading the file. The only failures are
/// the write's own I/O errors: generation itself cannot fail, because a
/// boot that cannot probe its machine must still start.
pub fn write_report(path: impl AsRef<Path>) -> Result<String, std::io::Error> {
let report = generate_report();
if let Some(parent) = path.as_ref().parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, &report)?;
Ok(report)
}
/// The document: host, user, tools, and the generation stamp. Ran at
/// daemon startup only, so its cost is bounded by the probe budgets, not
/// by anything the model or the network does.
fn generate_report() -> String {
let mut out = String::from(
"# Environment\n\n\
Generated at daemon boot; regenerated on every start; hand \
edits are overwritten.\n\n\
## Host\n\n",
);
out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
out.push_str(&format!("- Arch: {}\n", std::env::consts::ARCH));
out.push_str(&format!("- Hostname: {}\n\n", hostname()));
out.push_str("## User\n\n");
out.push_str(&format!("- User: {}\n", user_name()));
out.push_str(&format!("- Shell: {}\n\n", shell()));
out.push_str("## Tools\n\n");
for line in inventory() {
out.push_str(&line);
out.push('\n');
}
out.push_str(&format!("\nGenerated at {}.\n", now_utc()));
out
}
/// The tool inventory, one line per probed tool in [`TOOLS`] order.
/// Probes run concurrently — one thread per tool — so a slow probe costs
/// its own budget, not the sum of the budgets.
fn inventory() -> Vec<String> {
let handles: Vec<_> = TOOLS
.iter()
.map(|name| {
let name = *name;
thread::spawn(move || tool_line(name))
})
.collect();
TOOLS
.iter()
.zip(handles)
.map(|(name, handle)| {
handle
.join()
.unwrap_or_else(|_| format!("- {name}: present but unreadable (probe failed)"))
})
.collect()
}
/// One markdown inventory line for `name`: where it sits and what
/// version it reports, or the hard `absent` verdict when no probe can
/// place it.
fn tool_line(name: &str) -> String {
// `which` only names the location; the direct `--version` spawn
// below decides presence, so hosts without a `which` binary still
// report their tools.
let located = match run_within("which", &[name], COMMAND_TIMEOUT) {
RunOutcome::Output(path) => {
let path = bound(&path);
(!path.is_empty()).then_some(path)
}
RunOutcome::NotFound | RunOutcome::Failed(_) => None,
};
match run_within(name, &["--version"], COMMAND_TIMEOUT) {
RunOutcome::Output(version) => {
let version = bound(&version);
match (located.as_deref(), version.is_empty()) {
(Some(path), false) => format!("- {name}: {path} — {version}"),
(Some(path), true) => format!("- {name}: {path}"),
(None, false) => format!("- {name}: {version}"),
(None, true) => format!("- {name}: present but unreadable (no output)"),
}
}
RunOutcome::NotFound => format!("- {name}: absent"),
RunOutcome::Failed(reason) => match located {
Some(path) => {
let reason = bound(&reason);
format!("- {name}: {path} — present but unreadable ({reason})")
}
None => format!("- {name}: absent"),
},
}
}
/// What one external command produced. Every variant renders as a report
/// line; none reaches the caller as an error.
enum RunOutcome {
/// Exited within the budget; captured output, untrimmed.
Output(String),
/// The executable does not exist.
NotFound,
/// Exited non-zero or by signal, or outlived the budget.
Failed(String),
}
/// Runs `program` with the fixed `args`, allowed `budget` to exit. The
/// argv is built only from literals and a tool name — never untrusted
/// input, never a shell. Stdin is null so a probe that wrongly reads it
/// sees EOF instead of hanging the boot. Pipes are read after the exit:
/// these targets are version printers whose output fits any pipe buffer,
/// so a post-exit read cannot block on them; anything still running at
/// the deadline is killed and reaped.
fn run_within(program: &str, args: &[&str], budget: Duration) -> RunOutcome {
let mut child = match Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return RunOutcome::NotFound;
}
Err(err) => return RunOutcome::Failed(format!("cannot spawn: {err}")),
};
let deadline = Instant::now() + budget;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) if Instant::now() >= deadline => break None,
Ok(None) => thread::sleep(POLL_STEP),
Err(err) => return RunOutcome::Failed(format!("cannot wait: {err}")),
}
};
let Some(status) = status else {
// Over budget: kill and reap so no child outlives the report.
let _ = child.kill();
let _ = child.wait();
return RunOutcome::Failed(format!("no exit within {budget:?}"));
};
if !status.success() {
return RunOutcome::Failed(format!("exit: {status}"));
}
let stdout = read_capped(child.stdout.take());
let stderr = read_capped(child.stderr.take());
// Version banners sometimes land on stderr (older python3), so the
// empty-stdout case falls back to it.
let raw = if stdout.trim().is_empty() {
stderr
} else {
stdout
};
RunOutcome::Output(raw)
}
/// Reads a child pipe up to 16 KiB, lossily UTF-8. The cap bounds the
/// read, not the report — [`bound`] cuts again at render time.
fn read_capped(pipe: Option<impl Read>) -> String {
let Some(mut pipe) = pipe else {
return String::new();
};
let mut bytes = Vec::new();
let _ = (&mut pipe).take(16 * 1024).read_to_end(&mut bytes);
String::from_utf8_lossy(&bytes).into_owned()
}
/// The report-side bound on captured output: first line only, cut to 200
/// characters — chars, not bytes, so the cut never splits a code point.
fn bound(raw: &str) -> String {
let line = raw.trim().lines().next().unwrap_or("");
line.chars().take(200).collect()
}
/// The hostname: `/etc/hostname`'s first line, else the `hostname`
/// command, else a stand-in — the file is not portable and the command
/// is not guaranteed, so absence is a report line.
fn hostname() -> String {
if let Ok(raw) = std::fs::read_to_string("/etc/hostname") {
let name = bound(&raw);
if !name.is_empty() {
return name;
}
}
match run_within("hostname", &[], COMMAND_TIMEOUT) {
RunOutcome::Output(name) => {
let name = bound(&name);
if name.is_empty() {
UNKNOWN.to_owned()
} else {
name
}
}
RunOutcome::NotFound | RunOutcome::Failed(_) => UNKNOWN.to_owned(),
}
}
/// The account the daemon runs as: `whoami`, falling back to `$USER`,
/// else a stand-in — a daemon may run with neither available.
fn user_name() -> String {
if let RunOutcome::Output(name) = run_within("whoami", &[], COMMAND_TIMEOUT) {
let name = bound(&name);
if !name.is_empty() {
return name;
}
}
std::env::var("USER")
.ok()
.map(|user| bound(&user))
.filter(|user| !user.is_empty())
.unwrap_or_else(|| UNKNOWN.to_owned())
}
/// The login shell from `$SHELL`; "unset" when the daemon runs without
/// one (a service unit, for instance).
fn shell() -> String {
std::env::var("SHELL")
.ok()
.map(|shell| bound(&shell))
.filter(|shell| !shell.is_empty())
.unwrap_or_else(|| "unset".to_owned())
}
/// The wall-clock UTC stamp, to the second, rhema-memory's rendering —
/// one timestamp shape across the resident tier.
fn now_utc() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
format_utc(millis)
}
#[cfg(test)]
mod tests {
use super::*;
/// The report lands as UTF-8 markdown at the given path (parents
/// created), with the fixed sections and one line per inventoried
/// tool — whatever is or is not installed on this host — and closes
/// on a to-the-second UTC stamp.
#[test]
fn the_report_writes_back_as_markdown_with_every_section() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("bootstrap").join("ENVIRONMENT.md");
let report = write_report(&path).expect("writes");
let on_disk = String::from_utf8(std::fs::read(&path).expect("reads back"))
.expect("the file is utf-8");
assert_eq!(report, on_disk, "the returned text is what landed");
assert!(report.starts_with("# Environment\n"), "{report}");
assert!(report.contains("regenerated on every start"), "{report}");
assert!(report.contains("hand edits are overwritten"), "{report}");
for section in ["## Host", "## User", "## Tools"] {
assert!(report.contains(section), "missing {section}: {report}");
}
for tool in TOOLS {
assert!(
report.contains(&format!("- {tool}: ")),
"no line for {tool}: {report}"
);
}
let footer = report.lines().last().expect("non-empty report");
assert_eq!(
footer.len(),
"Generated at 1970-01-01T00:00:00Z.".len(),
"a UTC stamp to the second: {footer}"
);
assert!(footer.ends_with("Z."), "{footer}");
}
/// A tool that exists nowhere still gets its line, through the same
/// runner the real inventory uses: the hard `absent` verdict, never
/// an error.
#[test]
fn a_missing_tool_gets_the_hard_absent_line() {
assert_eq!(
tool_line("rhema-claw-definitely-absent-probe"),
"- rhema-claw-definitely-absent-probe: absent"
);
}
/// Captured output cannot bloat the report: first line only, cut at
/// the cap, multibyte-safe.
#[test]
fn probe_output_is_bounded() {
let huge = format!("{}\n{}", "x".repeat(10 * 200), "second line");
let cut = bound(&huge);
assert_eq!(cut.chars().count(), 200);
assert!(!cut.contains('\n'), "{cut}");
let multibyte = "é".repeat(3 * 200);
assert_eq!(bound(&multibyte).chars().count(), 200);
}
/// A probe that never exits is killed at its budget and reported as
/// failed; the report never waits past a budget. Tolerates hosts
/// without `sleep`.
#[test]
fn a_hanging_probe_is_cut_at_its_budget() {
match run_within("sleep", &["30"], Duration::from_millis(150)) {
RunOutcome::NotFound => {}
RunOutcome::Failed(_) => {}
RunOutcome::Output(_) => panic!("a 30s sleep outlived a 150ms budget"),
}
}
}
+434
View File
@@ -0,0 +1,434 @@
//! The sources: deterministic detection, one [`ForgeSource`] per forge
//! kind.
//!
//! A source owns one forge's watch set: its `detect` pass reads the
//! watch tables fresh — [`ClawStore::list`] and [`ClawStore::
//! list_account_watches`] — and loops the kind's repos through a
//! [`Watcher`] (poll, ingest, advance the per-repo cursor), then the
//! kind's account watches through the identity store's credentials:
//! poll the account's notifications, [`crate::triage::classify`] each
//! one, and either ingest it as a role-tagged thread event, surface it
//! to the account's human, or drop it. Neither pass touches the model
//! (spec §1). Reading the tables every pass is what makes a watch added
//! mid-flight (chat tool, HTTP route) take effect without a restart.
//! Repo-watch clients are constructed on first use and cached per
//! `(forge, host, token)`; account clients are built fresh every pass,
//! so a rotated identity secret is live on the next tick. Cadence
//! belongs to the daemon's loop; the source is one pass, nothing more.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError};
use rhema_autonomous_agent::{Source, SourceError, async_trait};
use rhema_forge_integration::forge_key_fn;
use rhema_forge_integration::{
Forge, GitHubForge, GitLabForge, GiteaForge, Notification, Watcher, register_forge,
};
use rhema_identity::{IdentityStore, SecretKind};
use rhema_rsd::{Clock, Dispatcher, Event, KeyFn, SystemClock};
use crate::config::{ForgeKind, resolve_token};
use crate::triage::{self, Triage};
use crate::watch_store::{AccountWatchRecord, ClawStore, WatchRecord};
/// The cache key of a forge client: everything that changes the client
/// is the forge kind, its host, and the token it authenticates with.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct ClientKey {
forge: String,
host: Option<String>,
token: String,
}
/// The forge client one watch RECORD polls through: the kind's
/// production base unless the record names a `host`. A blank stored
/// token falls back to the forge's environment variable — the same
/// resolution a config watch gets — and a blank everywhere constructs a
/// client that polls anonymous; the poll fails loudly at the forge, not
/// silently here.
pub fn record_client(kind: ForgeKind, host: Option<&str>, token: &str) -> Arc<dyn Forge> {
let resolved = resolve_token(|name| std::env::var(name), kind, Some(token)).unwrap_or_default();
match kind {
ForgeKind::GitHub => {
let mut forge = GitHubForge::new(resolved);
if let Some(host) = host {
forge = forge.with_base_url(host.to_owned());
}
Arc::new(forge)
}
ForgeKind::Gitea => Arc::new(GiteaForge::new(
host.unwrap_or_default().to_owned(),
resolved,
)),
ForgeKind::GitLab => {
let mut forge = GitLabForge::new(resolved);
if let Some(host) = host {
forge = forge.with_base_url(host.to_owned());
}
Arc::new(forge)
}
}
}
/// One forge kind's detectable input stream: every repo watch and every
/// account watch of that kind in the tables, polled into the dispatcher.
pub struct ForgeSource {
name: String,
watcher: Watcher,
dispatcher: Arc<Dispatcher>,
store: Arc<ClawStore>,
/// The account store the account watches authenticate through. A
/// secret stays inside this crate's code paths: it reaches the forge
/// client's constructor, never a log line or a tool answer.
accounts: Arc<dyn IdentityStore>,
/// The device-notification queue a [`Triage::Surface`] lands in.
notifications: Arc<crate::notifications::NotificationStore>,
clients: Mutex<HashMap<ClientKey, Arc<dyn Forge>>>,
/// One notifications cursor per account id.
account_cursors: Mutex<HashMap<String, rhema_forge_integration::Timestamp>>,
}
impl ForgeSource {
/// Registers `name`'s key function on the dispatcher and returns the
/// source polling the watch table's `name` rows — repo watches and
/// account watches — under it. One source per kind, registered once,
/// before the first detect.
pub fn new(
dispatcher: &Arc<Dispatcher>,
name: impl Into<String>,
store: Arc<ClawStore>,
accounts: Arc<dyn IdentityStore>,
notifications: Arc<crate::notifications::NotificationStore>,
) -> Self {
let name = name.into();
register_forge(dispatcher, &name);
Self {
watcher: Watcher::new(dispatcher.clone(), name.clone()),
dispatcher: dispatcher.clone(),
name,
store,
accounts,
notifications,
clients: Mutex::new(HashMap::new()),
account_cursors: Mutex::new(HashMap::new()),
}
}
/// The client for `record`: from the cache when a watch with the
/// same `(forge, host, token)` polled before, constructed otherwise.
fn client_for(&self, record: &WatchRecord) -> Arc<dyn Forge> {
let key = ClientKey {
forge: record.forge.clone(),
host: record.host.clone(),
token: record.token.clone(),
};
let mut clients = self.clients.lock().unwrap_or_else(PoisonError::into_inner);
clients
.entry(key)
.or_insert_with(|| {
let Some(kind) = ForgeKind::parse(&record.forge) else {
// Unreachable through the daemon's assembly, which
// only creates sources for known kinds; an unknown
// kind in the table would be skipped in `detect`
// before reaching here.
unreachable!("detect filters unknown forge kinds before client lookup")
};
record_client(kind, record.host.as_deref(), &record.token)
})
.clone()
}
}
#[async_trait]
impl Source for ForgeSource {
fn name(&self) -> &str {
&self.name
}
fn key_fn(&self) -> Arc<KeyFn> {
forge_key_fn(&self.name)
}
async fn detect(&self) -> Result<usize, SourceError> {
let records = self
.store
.list()
.map_err(|err| SourceError::Backend(format!("watch store: {err}")))?;
let mut fed = 0;
for record in records {
if record.forge != self.name {
continue;
}
let repo = match record.repo_ref() {
Ok(repo) => repo,
Err(reason) => {
// A malformed row must not starve the kind's other
// watches; say so and move on. New rows cannot be
// malformed (every entry validates), so this guards
// only hand-edited tables.
eprintln!(
"[claw] warning: skipping watch {} {}: {reason}",
record.forge, record.repo
);
continue;
}
};
let client = self.client_for(&record);
match self.watcher.poll(client.as_ref(), &repo).await {
Ok(count) => fed += count,
// One dead watch (a lapsed token, a forge outage) must
// not starve the kind's other watches either.
Err(err) => eprintln!(
"[claw] warning: watch {} {}: poll failed: {err}",
record.forge, record.repo
),
}
}
// The account watches: the same table-rules-every-pass doctrine,
// with the identity store supplying each poll's credential.
let accounts = self
.store
.list_account_watches()
.map_err(|err| SourceError::Backend(format!("watch store: {err}")))?;
for watch in accounts {
if watch.forge != self.name {
continue;
}
match self.poll_account(&watch).await {
Ok(count) => fed += count,
// One dead account (a revoked token, a forge outage)
// must not starve the kind's other watches either.
Err(err) => eprintln!(
"[claw] warning: account watch {} {}: poll failed: {err}",
watch.forge, watch.account_id
),
}
}
Ok(fed)
}
}
impl ForgeSource {
/// One account-watch poll: fetch the account's notifications as the
/// account, triage each, and land the verdicts — agent dispatches
/// ingest as role-tagged thread events, surfaces hit the
/// device-notification queue, ignores drop. Returns how many events
/// were fed to the dispatcher.
async fn poll_account(&self, watch: &AccountWatchRecord) -> Result<usize, SourceError> {
let Some(kind) = ForgeKind::parse(&watch.forge) else {
// Unreachable through the daemon's assembly, which only
// creates sources for known kinds; an unknown kind in the
// table would be skipped in `detect` before reaching here.
unreachable!("detect filters unknown forge kinds before account polls")
};
let secret = self
.accounts
.secret(&watch.account_id)
.map_err(|err| SourceError::Backend(format!("account store: {err}")))?;
let Some(secret) = secret else {
// The account is gone; its watch's cascade should have gone
// with it. Skip quietly — `identity_forget` removes both,
// and a hand-edited pair self-heals on the next forget.
return Ok(0);
};
if secret.kind != SecretKind::Token {
// A stored password is not a poll credential: nothing to
// authenticate the notifications call with. Say so and move
// on — the watch stays, a token rotation (or a future login
// action) revives it.
eprintln!(
"[claw] warning: account watch {} {}: the account holds a password, not a token; skipping the poll",
watch.forge, watch.account_id
);
return Ok(0);
}
// Fresh client every pass: a rotated secret is live on the next
// tick, and one client build per account per pass is nothing
// next to the poll itself.
let client = record_client(kind, watch.host.as_deref(), &secret.value);
let since = self
.account_cursors
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(&watch.account_id)
.cloned();
let page = client
.notifications(since.as_ref())
.await
.map_err(|err| SourceError::Backend(format!("notifications: {err}")))?;
let now = SystemClock.now_millis();
let mut fed = 0;
for notification in &page.notifications {
match triage::classify(notification) {
Triage::Ignore => {}
Triage::Surface => {
if let Err(err) = self.notifications.push(
crate::notifications::DEFAULT_DEVICE,
&format!(
"{}: {}#{}",
watch.forge,
notification.repo.path(),
notification.number
),
&surface_body(notification),
) {
eprintln!(
"[claw] warning: surface of {} failed: {err}",
notification.id
);
}
}
Triage::Agent(role) => {
// The key function reads `repo` (as the
// `{owner, name}` row RepoRef serializes to) and
// `number` off the payload's top level, so the
// thread key is the same `forge:owner/repo#n` shape
// the repo watches use — one session per thread,
// whatever fed it.
let payload = serde_json::json!({
"repo": notification.repo,
"number": notification.number,
"role": role.as_str(),
"notification": notification,
});
self.dispatcher
.ingest(Event {
id: notification.id.clone(),
source: self.name.clone(),
payload,
received_at: now,
})
.map_err(SourceError::Dispatch)?;
fed += 1;
}
}
}
if let Some(cursor) = page.cursor {
self.account_cursors
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(watch.account_id.clone(), cursor);
}
Ok(fed)
}
}
/// The device-notification text a [`Triage::Surface`] lands: the reason
/// and the thread title, so the human can judge without opening the
/// forge.
fn surface_body(notification: &Notification) -> String {
let title = notification.title.as_deref().unwrap_or("(untitled)");
format!(
"{} on {}#{}: {}",
notification.reason.as_str(),
notification.repo.path(),
notification.number,
title
)
}
#[cfg(test)]
mod tests {
use super::*;
/// The parts a test source polls through, all in memory.
fn test_parts(
store: Arc<ClawStore>,
) -> (
Arc<Dispatcher>,
Arc<dyn IdentityStore>,
Arc<crate::notifications::NotificationStore>,
) {
let _ = store;
let dispatcher = Arc::new(Dispatcher::new(
rhema_rsd::DispatcherConfig::default(),
Arc::new(rhema_rsd::SystemClock),
Arc::new(rhema_rsd::VolatileStore::new()),
));
let accounts: Arc<dyn IdentityStore> =
Arc::new(rhema_identity::VolatileIdentityStore::new());
let notifications = Arc::new(
crate::notifications::NotificationStore::in_memory().expect("notification store opens"),
);
(dispatcher, accounts, notifications)
}
/// The client cache pays off when two watches share their
/// coordinates: one client, not two.
#[tokio::test]
async fn same_coordinates_share_one_cached_client() {
let store = Arc::new(ClawStore::in_memory().expect("store opens"));
store
.add("github", "acme/widgets", None, "tok")
.expect("add");
store
.add("github", "acme/gadgets", None, "tok")
.expect("add");
store
.add("github", "acme/private", None, "other")
.expect("add");
let (dispatcher, accounts, notifications) = test_parts(store.clone());
let source = ForgeSource::new(&dispatcher, "github", store, accounts, notifications);
let records = source.store.list().expect("list reads");
let a = source.client_for(&records[0]);
let b = source.client_for(&records[1]);
let c = source.client_for(&records[2]);
assert!(Arc::ptr_eq(&a, &b), "same (forge, host, token): one client");
assert!(
!Arc::ptr_eq(&a, &c),
"a different token is a different client"
);
assert_eq!(source.clients.lock().unwrap().len(), 2);
}
/// One triaged notification ingest: the payload carries `repo` and
/// `number` at the top level, so the registered key function folds
/// it onto the same thread key a repo-watch event takes.
#[tokio::test]
async fn a_triaged_notification_lands_on_the_thread_key() {
use rhema_forge_integration::{NotificationReason, RepoRef, Timestamp};
let store = Arc::new(ClawStore::in_memory().expect("store opens"));
let (_dispatcher, accounts, _notifications) = test_parts(store.clone());
let account = rhema_identity::AccountDraft::new(
"acme bot",
"github",
rhema_identity::Secret::new(rhema_identity::SecretKind::Token, "tok"),
);
let account = accounts.put(account).expect("account lands");
store
.add_account_watch("github", None, &account.id)
.expect("watch lands");
// The key function must fold the triaged payload onto the thread
// key: build a payload the way `poll_account` does and run it
// through the registered `forge_key_fn`.
let notification = Notification {
id: "github:notification:7@2026-10-08T00:00:00Z".to_owned(),
reason: NotificationReason::ReviewRequested,
repo: RepoRef::new("acme", "widgets"),
number: 142,
is_pr: true,
actor: None,
title: Some("Add the flange".to_owned()),
url: None,
timestamp: Timestamp::parse("2026-10-08T00:00:00Z").expect("parses"),
raw: serde_json::json!({}),
};
let payload = serde_json::json!({
"repo": notification.repo,
"number": notification.number,
"role": "reviewer",
"notification": notification,
});
let key_fn = forge_key_fn("github");
let key = key_fn(&Event {
id: notification.id.clone(),
source: "github".to_owned(),
payload,
received_at: 0,
});
assert_eq!(key.as_str(), "github:acme/widgets#142");
}
}
+805
View File
@@ -0,0 +1,805 @@
//! The handler: one dispatch of a forge thread becomes one coding-agent
//! turn, and the turn's final text becomes the thread's comment reply.
//!
//! The deterministic part stays ahead of the model (spec §1, §8): the
//! watch's forge client is looked up by key, the snapshot is fetched, the
//! inbox is drained and decoded, the allowlist is resolved — v1 is
//! permissive, the spec's starting point, pending a real
//! profile-to-permissions mapping — and only then does the agent run.
//! One turn per dispatch; the final assistant text posts verbatim.
//!
//! Session continuity rides the dispatcher, not the filesystem: the
//! agent's session JSON is stashed into `ctx.session.core.driver_state`
//! before the run returns and restored with [`CodingAgent::from_session`]
//! on the next dispatch of the same key, so a resumed thread keeps its
//! conversation. Commit-before-execute rides it too: the run installs the
//! context's commit hook, so the intent is in the store before the first
//! tool runs, and a crash-left dangling call revives at turn entry.
//! Delivery is at-least-once: a re-delivered drained event
//! re-dispatches, and this handler treats a dispatch as work to do — the
//! dedup boundary is the dispatcher's, not re-derived here.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use crate::config::ForgeKind;
use crate::forge::record_client;
use crate::triage::Role;
use crate::watch_store::ClawStore;
use rhema_agent_tools::AllowAll;
use rhema_autonomous_agent::{Handler, SessionContext, async_trait};
use rhema_coding_agent::{CodingAgent, CodingAgentConfig};
use rhema_core::{Content, Event as ModelEvent, EventSink, Message, Session};
use rhema_forge_integration::{
EventKind, Forge, ForgeError, ForgeEvent, Notification, RepoRef, Snapshot, ThreadState, decode,
parse_key,
};
use rhema_rsd::{InboxItem, Outcome, ResourceKey};
use crate::backends::BackendRegistry;
/// The handler's tool permissions, resolved deterministically before the
/// agent's first step (spec §8) — never by the model, never mid-run. V1
/// is permissive, the spec's starting point; a profile-to-permissions
/// mapping lands here as a per-deployment policy — the triage's
/// [`Role`] is the seam it hangs on, a reviewer dispatch deserving a
/// narrower policy than an implementer's — and a policy that refuses the
/// shell refuses the run (see [`ClawHandler::dispatch`]).
fn allowlist() -> rhema_autonomous_agent::Allowlist {
rhema_autonomous_agent::Allowlist::permissive()
}
/// The coding agent over one watch: a provider, a model, a workspace
/// root, and the watch table the dispatch keys resolve through — live,
/// so a watch added at runtime is answerable without a restart.
pub struct ClawHandler {
/// The deployment's providers and the active pair, read per dispatch.
backends: Arc<BackendRegistry>,
workspace_root: PathBuf,
watches: Arc<ClawStore>,
clients: Mutex<HashMap<String, Arc<dyn Forge>>>,
}
impl ClawHandler {
/// A handler whose per-thread workspaces live under `workspace_root`
/// and whose dispatch keys resolve through `watches` at dispatch time.
pub fn new(
backends: Arc<BackendRegistry>,
workspace_root: impl Into<PathBuf>,
watches: Arc<ClawStore>,
) -> Self {
Self {
backends,
workspace_root: workspace_root.into(),
watches,
clients: Mutex::new(HashMap::new()),
}
}
/// The forge client a dispatch key answers to: `"github:acme/widgets#7"`
/// resolves its watch record from the table at dispatch time — the
/// table is the source of truth, not a boot-time snapshot — and the
/// client is cached per `(forge, host, token)` so one poll pool serves
/// every thread of a watch.
pub fn watch_forge(&self, key: &ResourceKey) -> Result<Arc<dyn Forge>, String> {
let watch = watch_key(key)
.ok_or_else(|| format!("resource key {key} is not a forge thread key"))?;
let (kind, repo) = watch
.split_once(':')
.ok_or_else(|| format!("watch entry {watch} has no forge kind"))?;
let record = self
.watches
.list()
.map_err(|err| format!("watch lookup for {key} failed: {err}"))?
.into_iter()
.find(|record| record.forge == kind && record.repo == repo)
.ok_or_else(|| format!("no watch configured for {key}"))?;
let kind = ForgeKind::parse(&record.forge)
.ok_or_else(|| format!("watch entry {watch} names an unknown forge"))?;
let cache_key = format!("{}|{:?}|{}", record.forge, record.host, record.token);
let mut clients = self.clients.lock().unwrap_or_else(PoisonError::into_inner);
Ok(clients
.entry(cache_key)
.or_insert_with(|| record_client(kind, record.host.as_deref(), &record.token))
.clone())
}
/// One dispatch, failed with a reason on any step. The session stash
/// lands before the comment posts, so a failed post still leaves the
/// conversation resumable.
async fn dispatch(&self, ctx: &mut SessionContext) -> Result<Outcome, String> {
// Owned: the key names the run through every step, while `ctx`
// itself goes mutable for the inbox drain.
let key = ctx.key.as_str().to_owned();
let (repo, number) = parse_key(&ctx.key)
.ok_or_else(|| format!("resource key {key} is not a forge thread key"))?;
let forge = self.watch_forge(&ctx.key)?;
let snapshot = forge
.snapshot(&repo, number)
.await
.map_err(|err| format!("snapshot of {key} failed: {err}"))?;
let news = self.drain_news(ctx, &key).await?;
let role = dominant_role(&news);
// The permission check is load-bearing: this handler's one tool
// is the agent's shell, so a policy without it is a run that
// could only flail — fail it loudly instead.
let permissions = allowlist();
if !permissions.allows("bash") {
return Err(format!(
"tool policy for {key} does not permit the shell; nothing to run"
));
}
let workspace = self.workspace_for(&key, &repo, number)?;
let mut agent = self.resume(ctx, &workspace, &key)?;
// Commit-before-execute: the intent is in the store before the
// first tool runs; a lease that cannot be committed under fails
// the step before any side effect.
agent.set_commit_hook(Some(ctx.commit_hook()));
let prompt = build_prompt(&key, &snapshot, &news, workspace.as_path(), role);
let mut sink = TurnSink;
let output = agent
.turn(prompt, &mut sink, Arc::new(AllowAll))
.await
.map_err(|err| format!("turn for {key} failed: {err}"))?;
// Stash before posting: the turn's work survives a failed comment.
ctx.session.core.driver_state = serde_json::to_value(agent.session())
.map_err(|err| format!("session for {key} does not serialize: {err}"))?;
let reply = final_text(&output.message)
.ok_or_else(|| format!("the model returned no text to post for {key}"))?;
forge
.comment(&repo, number, &reply)
.await
.map_err(|err| format!("comment on {key} failed: {err}"))?;
Ok(Outcome::Handled)
}
/// Renews the lease and drains the inbox, decoding every item into
/// the news it carries: a repo-watch forge event, or a triaged
/// account notification with its role. A payload that decodes to
/// neither is loud: a shape break must surface, not silently shrink
/// the prompt.
async fn drain_news(
&self,
ctx: &mut SessionContext,
key: &str,
) -> Result<Vec<InboxNews>, String> {
let items = ctx
.poll_inbox()
.await
.map_err(|err| format!("inbox poll for {key} failed: {err}"))?;
items
.iter()
.map(decode_news)
.collect::<Result<Vec<_>, _>>()
.map_err(|err| format!("an inbox item for {key} does not decode: {err}"))
}
/// The agent for this dispatch: the stashed session resumed when the
/// key has history, a fresh one otherwise. A stash that does not parse
/// fails the run loudly — silently resetting would re-orient the agent
/// mid-conversation without anyone knowing.
fn resume(
&self,
ctx: &SessionContext,
workspace: &Path,
key: &str,
) -> Result<CodingAgent, String> {
let (provider, model) = self.backends.current();
let config = CodingAgentConfig::new(model, workspace);
let agent = match &ctx.session.core.driver_state {
// A never-dispatched key carries no stash: start clean.
serde_json::Value::Null => CodingAgent::new(provider, config),
stashed => {
let session: Session = serde_json::from_value(stashed.clone())
.map_err(|err| format!("stashed session for {key} does not parse: {err}"))?;
CodingAgent::from_session(provider, config, session)
}
};
agent.map_err(|err| format!("cannot start the coding agent for {key}: {err}"))
}
/// The per-thread workspace: one directory under the root, created on
/// demand. The name mapping is not injective (separators collapse);
/// thread identity lives in the dispatch key — the directory is only
/// scratch space for the agent's tools.
fn workspace_for(&self, key: &str, repo: &RepoRef, number: u64) -> Result<PathBuf, String> {
let dir = self.workspace_root.join(workspace_name(key, repo, number));
std::fs::create_dir_all(&dir)
.map_err(|err| format!("cannot create the workspace {dir:?} for {key}: {err}"))?;
Ok(dir)
}
}
#[async_trait]
impl Handler for ClawHandler {
async fn handle(&self, ctx: &mut SessionContext) -> Outcome {
match self.dispatch(ctx).await {
Ok(outcome) => outcome,
Err(reason) => Outcome::Failed(reason),
}
}
}
/// The watch-table key of a resource key: `"github:acme/widgets#7"` builds
/// `"github:acme/widgets"`. `None` for a key that is not a forge thread
/// key at all.
pub fn watch_key(key: &ResourceKey) -> Option<String> {
let (head, _) = key.as_str().rsplit_once('#')?;
head.contains(':').then(|| head.to_owned())
}
/// The watch-table entry for a repo: `{kind}:{owner}/{name}` — the value
/// [`watch_key`] parses back out of a dispatch key. The pair is the
/// key-parse-to-watch-lookup seam.
pub fn watch_entry(kind: &str, repo: &RepoRef) -> String {
format!("{kind}:{}", repo.path())
}
/// The workspace directory name for a thread: kind, repo path, and number
/// flattened to filesystem-safe characters.
pub fn workspace_name(key: &str, repo: &RepoRef, number: u64) -> String {
let kind = key.split_once(':').map(|(kind, _)| kind).unwrap_or("forge");
format!("{kind}-{}-{number}", repo.path())
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c
} else {
'-'
}
})
.collect()
}
/// One drained inbox item: a plain repo-watch forge event, or a triaged
/// account notification carrying the role the triage assigned it.
#[derive(Debug, Clone)]
enum InboxNews {
Event(ForgeEvent),
Notified {
role: Role,
notification: Notification,
},
}
/// The triaged payload [`crate::forge::ForgeSource`] ingests: `repo` and
/// `number` ride at the top level for the key function, the rest is the
/// notification and the role its triage earned.
#[derive(serde::Deserialize)]
struct TriagedPayload {
role: Role,
notification: Notification,
}
/// Decodes one inbox item into its news. A triaged notification is tried
/// first — a plain forge event has none of its fields — and the plain
/// event second; anything else is a loud parse error.
fn decode_news(item: &InboxItem) -> Result<InboxNews, ForgeError> {
if let Ok(triaged) = serde_json::from_value::<TriagedPayload>(item.event.payload.clone()) {
return Ok(InboxNews::Notified {
role: triaged.role,
notification: triaged.notification,
});
}
decode(item).map(InboxNews::Event)
}
/// The batch's dominant role, for the prompt's persona framing:
/// implementing subsumes reviewing — a batch that asks for both is a
/// dispatch where the work is the point. Plain events imply no persona:
/// the default framing runs.
fn dominant_role(news: &[InboxNews]) -> Option<Role> {
if news.iter().any(|news| {
matches!(
news,
InboxNews::Notified {
role: Role::Implementer,
..
}
)
}) {
Some(Role::Implementer)
} else if news.iter().any(|news| {
matches!(
news,
InboxNews::Notified {
role: Role::Reviewer,
..
}
)
}) {
Some(Role::Reviewer)
} else {
None
}
}
/// The persona framing a dispatched role opens the prompt with. The key
/// stays the thread, so a role change across dispatches is the session's
/// continuity, not a new conversation.
fn persona_text(role: Role) -> &'static str {
match role {
Role::Reviewer => {
"You are acting as this pull request's reviewer. Check the change \
out in the workspace when you can, read the diff, and judge it: correctness, \
tests, edge cases, fit with the surrounding code. Your final reply is the \
review — findings first, ordered by severity, concrete and actionable; if \
there is nothing to fix, say so plainly and say what you checked."
}
Role::Implementer => {
"You are acting as this thread's implementer. Do the work the new \
activity asks for: address the review feedback or the assigned issue by \
changing the code in the workspace — build and test what you change. Your \
final reply is a short report of what you changed and why, or, when the \
whole change does not fit one turn, what landed now and the concrete plan \
for the rest."
}
}
}
/// The agent's prompt for one dispatch: the persona framing (when the
/// triage dispatched a role), the thread's standing state, the new news,
/// and the posting contract. The final assistant message is the comment
/// — everything else is orientation for the investigation.
fn build_prompt(
key: &str,
snapshot: &Snapshot,
news: &[InboxNews],
workspace: &Path,
role: Option<Role>,
) -> String {
let mut prompt = String::new();
if let Some(role) = role {
prompt.push_str(persona_text(role));
prompt.push_str("\n\n");
}
prompt.push_str(&format!(
"You are responding to new activity on forge thread {key}.\n\n\
Thread: {}\nKind: {}; state: {}\nOpened by: {}\nLabels: {}\nOpening body:\n{}\n\n",
snapshot.title,
if snapshot.is_pr {
"pull request"
} else {
"issue"
},
state_text(snapshot.state),
snapshot.author.as_deref().unwrap_or("unknown"),
if snapshot.labels.is_empty() {
"none".to_owned()
} else {
snapshot.labels.join(", ")
},
snapshot
.body
.as_deref()
.map(str::trim)
.filter(|body| !body.is_empty())
.unwrap_or("(none)"),
));
prompt.push_str("New activity to respond to:\n");
for news in news {
match news {
InboxNews::Event(event) => prompt.push_str(&format!(
"- {} by {}: {}\n",
kind_text(event.kind),
event.actor.as_deref().unwrap_or("unknown"),
event
.body
.as_deref()
.map(str::trim)
.filter(|body| !body.is_empty())
.unwrap_or("(no text)"),
)),
InboxNews::Notified { notification, .. } => prompt.push_str(&format!(
"- notification ({}) on {} #{} by {}: {}\n",
notification.reason.as_str(),
thread_kind(notification.is_pr),
notification.number,
notification.actor.as_deref().unwrap_or("unknown"),
notification
.title
.as_deref()
.map(str::trim)
.filter(|title| !title.is_empty())
.unwrap_or("(no title)"),
)),
}
}
prompt.push_str(&format!(
"\nThe workspace is at {}: investigate there with your shell tools (read\n\
code, run tests, reproduce reports) whenever the text alone does not\n\
settle it. Your FINAL reply — the message you end the turn on, with\n\
no tool calls after it — is posted to the thread verbatim: make it\n\
the exact comment text for the thread's participants, with no\n\
meta-commentary about this prompt.\n",
workspace.display(),
));
prompt
}
/// "pull request" or "issue", for the notification lines.
fn thread_kind(is_pr: bool) -> &'static str {
if is_pr { "pull request" } else { "issue" }
}
/// The thread state, in words.
fn state_text(state: ThreadState) -> &'static str {
match state {
ThreadState::Open => "open",
ThreadState::Closed => "closed",
ThreadState::Merged => "merged",
}
}
/// The event kind, in words.
fn kind_text(kind: EventKind) -> &'static str {
match kind {
EventKind::Opened => "opened",
EventKind::Reopened => "reopened",
EventKind::Closed => "closed",
EventKind::Merged => "merged",
EventKind::Comment => "comment",
EventKind::Review => "review",
}
}
/// The text a turn ends on: the assistant message's text parts, joined
/// and trimmed. `None` when the model stopped on tool calls and never
/// wrote a reply.
pub(crate) fn final_text(message: &Message) -> Option<String> {
let joined = message
.content
.iter()
.filter_map(|content| match content {
Content::Text(text) => Some(text.as_str()),
Content::ToolCall(_) | Content::ToolResult(_) => None,
})
.collect::<Vec<_>>()
.join("");
let trimmed = joined.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
/// The turn's stderr log: tool traffic only. The reply is not streamed —
/// it is posted to the thread, and a daemon log line per tool call is the
/// observability a resident run gets.
struct TurnSink;
impl EventSink for TurnSink {
fn emit(&mut self, event: ModelEvent<'_>) {
match event {
ModelEvent::ToolCallStart { name, .. } => eprintln!("[claw] tool call: {name}"),
ModelEvent::ToolResult {
content, is_error, ..
} => {
let mark = if is_error { "failed" } else { "ok" };
eprintln!("[claw] tool {mark}: {}", first_line(content));
}
_ => {}
}
}
}
/// The first line of a tool result, cut for the stderr stream.
fn first_line(content: &str) -> String {
let line = content.lines().next().unwrap_or("");
let mut cut = line.chars().take(72).collect::<String>();
if line.chars().count() > 72 {
cut.push('…');
}
cut
}
#[cfg(test)]
mod tests {
use super::*;
use rhema_core::ModelProvider;
use rhema_forge_integration::NotificationReason;
fn snapshot() -> Snapshot {
serde_json::from_value(serde_json::json!({
"title": "crash on save",
"body": "steps to reproduce…",
"state": "open",
"is_pr": false,
"author": "alice",
"labels": ["bug", "cli"],
"url": "https://github.com/acme/widgets/issues/7",
"raw": {},
}))
.expect("snapshot fixture parses")
}
fn event(kind: EventKind, actor: &str, body: Option<&str>) -> ForgeEvent {
serde_json::from_value(serde_json::json!({
"id": "github:comment:8001",
"kind": kind,
"repo": { "owner": "acme", "name": "widgets" },
"number": 7,
"is_pr": false,
"actor": actor,
"body": body,
"url": null,
"timestamp": "2026-10-03T09:00:00Z",
"raw": {},
}))
.expect("event fixture parses")
}
#[test]
fn the_prompt_carries_the_thread_the_events_and_the_contract() {
let prompt = build_prompt(
"github:acme/widgets#7",
&snapshot(),
&[InboxNews::Event(event(
EventKind::Comment,
"dave",
Some("any progress?"),
))],
Path::new("/tmp/workspaces/github-acme-widgets-7"),
None,
);
assert!(prompt.contains("github:acme/widgets#7"), "key: {prompt}");
assert!(prompt.contains("crash on save"), "title: {prompt}");
assert!(prompt.contains("state: open"), "state: {prompt}");
assert!(prompt.contains("Labels: bug, cli"), "labels: {prompt}");
assert!(
prompt.contains("- comment by dave: any progress?"),
"events: {prompt}"
);
assert!(
prompt.contains("posted to the thread verbatim"),
"contract: {prompt}"
);
}
#[test]
fn thread_level_events_carry_no_body_and_the_prompt_says_so() {
let prompt = build_prompt(
"github:acme/widgets#7",
&snapshot(),
&[InboxNews::Event(event(EventKind::Opened, "alice", None))],
Path::new("/tmp/w"),
None,
);
assert!(
prompt.contains("- opened by alice: (no text)"),
"got: {prompt}"
);
}
fn notification(reason: NotificationReason) -> Notification {
serde_json::from_value(serde_json::json!({
"id": "github:notification:42188@2026-10-03T09:00:00Z",
"reason": reason,
"repo": { "owner": "acme", "name": "widgets" },
"number": 142,
"is_pr": true,
"actor": "alice",
"title": "Add the widget flange",
"url": null,
"timestamp": "2026-10-03T09:00:00Z",
"raw": {},
}))
.expect("notification fixture parses")
}
fn triaged_item(role: Role, reason: NotificationReason) -> InboxItem {
InboxItem {
event: rhema_rsd::Event {
id: "github:notification:42188@2026-10-03T09:00:00Z".to_owned(),
source: "github".to_owned(),
payload: serde_json::json!({
"repo": "acme/widgets",
"number": 142,
"role": role,
"notification": notification(reason),
}),
received_at: 0,
},
delivered_at: 0,
}
}
#[test]
fn a_triaged_item_decodes_to_its_role_and_a_plain_event_to_its_event() {
let news = decode_news(&triaged_item(
Role::Reviewer,
NotificationReason::ReviewRequested,
))
.expect("decodes");
assert!(matches!(
news,
InboxNews::Notified {
role: Role::Reviewer,
..
}
));
let plain = InboxItem {
event: rhema_rsd::Event {
id: "github:comment:8001".to_owned(),
source: "github".to_owned(),
payload: serde_json::to_value(event(EventKind::Comment, "dave", None))
.expect("serializes"),
received_at: 0,
},
delivered_at: 0,
};
assert!(matches!(decode_news(&plain), Ok(InboxNews::Event(_))));
let garbage = InboxItem {
event: rhema_rsd::Event {
id: "x".to_owned(),
source: "github".to_owned(),
payload: serde_json::json!({"neither": true}),
received_at: 0,
},
delivered_at: 0,
};
assert!(decode_news(&garbage).is_err(), "an unknown shape is loud");
}
#[test]
fn the_batchs_dominant_role_subordinates_implementing_to_nothing() {
assert_eq!(dominant_role(&[]), None);
assert_eq!(
dominant_role(&[InboxNews::Event(event(EventKind::Comment, "dave", None))]),
None,
"plain events imply no persona"
);
let review = InboxNews::Notified {
role: Role::Reviewer,
notification: notification(NotificationReason::ReviewRequested),
};
let work = InboxNews::Notified {
role: Role::Implementer,
notification: notification(NotificationReason::Assigned),
};
assert_eq!(
dominant_role(std::slice::from_ref(&review)),
Some(Role::Reviewer)
);
assert_eq!(
dominant_role(std::slice::from_ref(&work)),
Some(Role::Implementer)
);
assert_eq!(
dominant_role(&[review, work]),
Some(Role::Implementer),
"doing the work subsumes judging it"
);
}
#[test]
fn a_reviewer_dispatch_frames_the_prompt_as_a_review() {
let prompt = build_prompt(
"github:acme/widgets#142",
&snapshot(),
&[InboxNews::Notified {
role: Role::Reviewer,
notification: notification(NotificationReason::ReviewRequested),
}],
Path::new("/tmp/w"),
Some(Role::Reviewer),
);
assert!(
prompt.contains("acting as this pull request's reviewer"),
"persona: {prompt}"
);
assert!(
prompt.contains("notification (review_requested) on pull request #142"),
"news line: {prompt}"
);
}
#[test]
fn the_final_text_joins_and_trims_but_not_tool_calls() {
let message = Message {
role: rhema_core::Role::Assistant,
content: vec![
Content::Text("on it".into()),
Content::Text(" — closing.".into()),
],
};
assert_eq!(final_text(&message).as_deref(), Some("on it — closing."));
let tool_only = Message {
role: rhema_core::Role::Assistant,
content: vec![Content::ToolCall(rhema_core::ToolCall {
id: "call_1".into(),
name: "bash".into(),
args: serde_json::json!({}),
provider_data: serde_json::Value::Null,
})],
};
assert_eq!(final_text(&tool_only), None, "no text, no reply");
}
#[test]
fn a_key_resolves_to_its_watch_entry_live_from_the_table() {
let watches = Arc::new(ClawStore::in_memory().expect("store opens"));
watches
.add("github", "acme/widgets", None, "tok")
.expect("watch added");
let registry = Arc::new(
crate::backends::BackendRegistry::new(
vec![crate::backends::Backend::headless(
"mock",
Arc::new(NoopProvider),
)],
None,
)
.expect("constructs"),
);
let handler = ClawHandler::new(registry, "/tmp/w", watches);
// A watch added to the table AFTER the handler was built resolves:
// the table is the source of truth, not a boot-time snapshot.
let key = ResourceKey::from("github:acme/widgets#7");
assert_eq!(watch_key(&key).as_deref(), Some("github:acme/widgets"));
assert!(handler.watch_forge(&key).is_ok(), "the watch resolves");
assert!(
handler
.watch_forge(&ResourceKey::from("gitea:acme/widgets#7"))
.is_err(),
"another forge's key does not"
);
assert!(
handler
.watch_forge(&ResourceKey::from("github:nonsense"))
.is_err(),
"a non-thread key does not"
);
}
#[test]
fn the_workspace_name_flattens_to_filesystem_safe_characters() {
assert_eq!(
workspace_name("github:acme/widgets#7", &RepoRef::new("acme", "widgets"), 7),
"github-acme-widgets-7"
);
assert_eq!(
workspace_name(
"gitlab:group/subgroup/widgets#7",
&RepoRef::new("group/subgroup", "widgets"),
7
),
"gitlab-group-subgroup-widgets-7"
);
}
#[test]
fn the_table_entry_round_trips_through_the_dispatch_key() {
let repo = RepoRef::new("acme", "widgets");
let entry = watch_entry("github", &repo);
let key = ResourceKey::from(format!("{entry}#7"));
assert_eq!(watch_key(&key).as_deref(), Some(entry.as_str()));
}
/// Stands in for the provider in pure-lookup tests; nothing calls it.
struct NoopProvider;
#[rhema_core::async_trait]
impl ModelProvider for NoopProvider {
async fn complete(
&self,
_req: rhema_core::ModelRequest,
_sink: &mut dyn EventSink,
) -> Result<rhema_core::ModelOutput, rhema_core::ProviderError> {
Err(rhema_core::ProviderError::Api("never called".into()))
}
}
// `allowlist()` pins the v1 policy: permissive. The assertion keeps
// the policy a decision someone wrote down, not an accident.
#[test]
fn the_v1_allowlist_is_permissive() {
let allowlist = allowlist();
assert!(allowlist.allows("bash"));
assert!(allowlist.allows("anything_at_all"));
}
}
File diff suppressed because it is too large. Load diff
+558
View File
@@ -0,0 +1,558 @@
//! The event hub: per-key broadcast of live turn events, and the
//! per-key record a late subscriber replays.
//!
//! HTTP subscribers stream one key's turns through it: a chat UI holds
//! one `GET /v1/sessions/{key}/events` connection open and watches every
//! turn of that session go by — whoever triggered the turn. The hub is
//! a [`tokio::sync::broadcast`] channel per key behind a mutex'd map.
//! `emit` records the event and broadcasts in one lock; a subscriber
//! that asks for history gets the record and a live receiver cut under
//! that same lock — every event lands in exactly one of the two, so a
//! replay has no gap and no overlap with the live stream, ever.
//!
//! Where the record lives depends on the assembly. With a turn store
//! attached (the daemon), every event is appended there as it streams
//! and a replay reads the store back: the transcript is unbounded and
//! survives restarts, so a session that runs for weeks replays whole —
//! the appends serialize under the hub's lock, which is what keeps the
//! table's order the stream's order. Without one (tests, store-less
//! assemblies) the record is a bounded in-memory window
//! ([`RECORD_CAPACITY`] events per key, the oldest falling off), good
//! enough for a process that lives as long as its transcript needs to.
//!
//! Live delivery itself stays best-effort: a subscriber that falls more
//! than [`EventHub::capacity`] events behind drops the oldest ones
//! (`broadcast`'s contract) and the receiver surfaces the skip as a
//! `Lagged` error so clients can say so.
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex, PoisonError};
use rhema_rsd::ResourceKey;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use crate::turn_store::TurnStore;
/// How many events a subscriber may trail by before the oldest drop.
/// Generous: a turn's deltas arrive faster than any viewer, but rarely
/// past four digits.
pub const TURN_CHANNEL_CAPACITY: usize = 1024;
/// How many events one key's in-memory record holds when no turn store
/// is attached. Wide enough that a reload rebuilds whole recent turns —
/// thinking traces and tool rows are deltas, and they add up.
pub const RECORD_CAPACITY: usize = 8192;
/// One event of a turn, as the wire sees it. Serialized with an internal
/// `type` tag, snake-cased: `{"type":"text_delta","text":"..."}`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TurnEvent {
/// The turn's input: the text (a batch folds into one) the turn
/// answers, recorded by the handler when the dispatch starts. It
/// exists for the replay — a chat surface shows its own messages
/// as it sends them, and skips the live echo.
UserMessage {
/// The input text, verbatim.
text: String,
},
/// A piece of the model's reasoning trace, in stream order.
ThinkingDelta {
/// The text fragment.
text: String,
},
/// A piece of the model's reply, in stream order.
TextDelta {
/// The text fragment.
text: String,
},
/// A tool call begins.
ToolStart {
/// The tool's name.
name: String,
},
/// A tool result landed.
ToolResult {
/// The tool's name.
name: String,
/// Whether the call failed.
is_error: bool,
/// A bounded snippet of the result content.
summary: String,
/// The process exit code of command-like tools; `None` otherwise.
code: Option<i32>,
/// The command line a shell tool ran, parsed from the call's
/// streamed arguments; `None` for every other tool.
command: Option<String>,
},
/// The turn is over — the clean end marker subscribers wait on,
/// emitted by the handler itself, so it arrives whoever triggered
/// the turn.
TurnDone {
/// The turn's final assistant text, when it produced one.
reply: Option<String>,
/// `handled` or `failed`.
outcome: String,
},
/// The agent asked the human something only they can answer: a
/// credential to store, permission to act. A chat surface renders
/// the ask as a card and answers through the consent routes.
ConsentRequest {
/// The request's id — the answer addresses it.
id: String,
/// What kind of answer: `secret` or `phone`.
kind: String,
/// The one-line ask.
summary: String,
/// The ask's context, the model's own reason.
detail: String,
/// When the request was made, epoch millis — a card that has
/// outlived the answer window greys out against it.
created_at: u64,
},
/// A consent request settled. The tool's answer rides the turn like
/// any tool result; this is how the card learns it can stop waiting.
ConsentResolved {
/// The request's id.
id: String,
/// Whether it was granted.
granted: bool,
},
/// The agent attached a file from its workspace: the metadata a
/// card needs, the bytes behind `GET /v1/attachments/{id}`.
Attachment {
/// The attachment's id.
id: String,
/// The file's name, as the tool call named it.
name: String,
/// What the name implies: `image`, `code`, or `file`.
media: String,
/// The byte size.
size: u64,
/// One line about the file, when the tool call carried one.
caption: Option<String>,
},
}
impl TurnEvent {
/// The event's kind, spelled the way the wire's `type` tag spells
/// it — for logs that name what they dropped.
pub fn kind(&self) -> &'static str {
match self {
Self::UserMessage { .. } => "user_message",
Self::ThinkingDelta { .. } => "thinking_delta",
Self::TextDelta { .. } => "text_delta",
Self::ToolStart { .. } => "tool_start",
Self::ToolResult { .. } => "tool_result",
Self::TurnDone { .. } => "turn_done",
Self::ConsentRequest { .. } => "consent_request",
Self::ConsentResolved { .. } => "consent_resolved",
Self::Attachment { .. } => "attachment",
}
}
}
/// One key's entry: the live channel, plus the in-memory record a
/// replay reads when no turn store is attached. Both live behind one
/// mutex, which is what makes a history snapshot + subscription atomic.
struct KeyEntry {
history: VecDeque<TurnEvent>,
sender: broadcast::Sender<TurnEvent>,
}
/// The per-key record and broadcast of [`TurnEvent`]s.
pub struct EventHub {
entries: Mutex<HashMap<ResourceKey, KeyEntry>>,
capacity: usize,
/// Where events append as they stream; `None` keeps the record
/// memory-only (tests, and any assembly without a database).
record_store: Option<Arc<TurnStore>>,
}
impl Default for EventHub {
fn default() -> Self {
Self::with_capacity(TURN_CHANNEL_CAPACITY)
}
}
impl EventHub {
/// A hub whose per-key channels hold `capacity` events.
pub fn with_capacity(capacity: usize) -> Self {
Self {
entries: Mutex::new(HashMap::new()),
capacity,
record_store: None,
}
}
/// A hub that appends every event to `store` as it streams — the
/// replay a restart serves reads back exactly what streamed, with no
/// window and no end-marker boundary.
pub fn with_record_store(store: Arc<TurnStore>) -> Self {
Self {
record_store: Some(store),
..Self::default()
}
}
/// Subscribes to `key`'s turns, creating the channel on first use.
/// Subscribe BEFORE triggering work: a turn that starts before the
/// subscriber exists is simply not streamed to it. The chat
/// surface's route uses [`Self::subscribe_with_history`] instead;
/// this live-only shape is for callers that already know where the
/// turn starts (the shim, the messages route).
pub fn subscribe(&self, key: &ResourceKey) -> broadcast::Receiver<TurnEvent> {
let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
Self::entry(&mut entries, key, self.capacity)
.sender
.subscribe()
}
/// Subscribes to `key`'s turns and hands back the record cut at the
/// same instant: everything emitted before this call is in
/// `history`, everything after arrives on the receiver. The pair is
/// cut under one lock, so no event is in both and none is in
/// neither — a client folds `history` first and the stream after,
/// and the transcript is whole.
pub fn subscribe_with_history(
&self,
key: &ResourceKey,
) -> (Vec<TurnEvent>, broadcast::Receiver<TurnEvent>) {
Self::subscribe_with_history_from(self, key, 0)
}
/// [`Self::subscribe_with_history`] with a cursor: `skip` names how
/// many of the record's oldest events the caller already holds (the
/// count a client tracks for its local transcript cache), and the
/// replay drops them, so a reconnecting client that kept its
/// transcript receives only what is new. A `skip` past the record's
/// end clamps to it — the client cannot skip into events that do not
/// exist, and the live feed after the cut is unaffected either way.
pub fn subscribe_with_history_from(
&self,
key: &ResourceKey,
skip: usize,
) -> (Vec<TurnEvent>, broadcast::Receiver<TurnEvent>) {
let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
let entry = Self::entry(&mut entries, key, self.capacity);
let mut history = match self.record_store.as_ref() {
Some(store) => store.events(key.as_str()).unwrap_or_else(|err| {
eprintln!("[claw] warning: replay for {key} failed: {err}");
Vec::new()
}),
None => entry.history.iter().cloned().collect(),
};
history.drain(..skip.min(history.len()));
(history, entry.sender.subscribe())
}
/// Publishes one event to `key`: the record takes it, and every
/// live subscriber gets a copy. A key with nobody listening is not
/// an error — turns run whether or not anyone watches, and the
/// record keeps the turn replayable. With a store attached the
/// append happens under the same lock as the broadcast, so the
/// table's order is the stream's order no matter how events race in
/// across keys.
pub fn emit(&self, key: &ResourceKey, event: TurnEvent) {
let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
let entry = Self::entry(&mut entries, key, self.capacity);
match self.record_store.as_ref() {
Some(store) => {
if let Err(err) = store.append(key.as_str(), std::slice::from_ref(&event)) {
eprintln!("[claw] warning: turn-history append for {key} failed: {err}");
}
}
None => {
entry.history.push_back(event.clone());
if entry.history.len() > RECORD_CAPACITY {
entry.history.pop_front();
}
}
}
let _ = entry.sender.send(event);
}
/// The map entry for `key`, creating it (and its channel) on first
/// use. Entries live for the process lifetime once created — the
/// live channel is the point; with a store attached the record
/// outlives the process, without one it stays bounded by
/// `RECORD_CAPACITY`.
fn entry<'a>(
entries: &'a mut HashMap<ResourceKey, KeyEntry>,
key: &ResourceKey,
capacity: usize,
) -> &'a mut KeyEntry {
entries.entry(key.clone()).or_insert_with(|| KeyEntry {
history: VecDeque::new(),
sender: broadcast::channel(capacity).0,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(name: &str) -> ResourceKey {
ResourceKey::from(format!("interactive:{name}"))
}
fn text(text: &str) -> TurnEvent {
TurnEvent::TextDelta { text: text.into() }
}
fn done(reply: &str) -> TurnEvent {
TurnEvent::TurnDone {
reply: Some(reply.into()),
outcome: "handled".into(),
}
}
#[test]
fn events_serialize_with_a_type_tag_and_snake_case() {
assert_eq!(
serde_json::to_string(&TurnEvent::TextDelta { text: "hi".into() }).unwrap(),
r#"{"type":"text_delta","text":"hi"}"#
);
assert_eq!(
serde_json::to_string(&TurnEvent::UserMessage { text: "hi".into() }).unwrap(),
r#"{"type":"user_message","text":"hi"}"#
);
assert_eq!(
serde_json::to_string(&TurnEvent::ToolResult {
name: "bash".into(),
is_error: true,
summary: "exit 1".into(),
code: Some(1),
command: Some("false".into()),
})
.unwrap(),
r#"{"type":"tool_result","name":"bash","is_error":true,"summary":"exit 1","code":1,"command":"false"}"#
);
assert_eq!(
serde_json::to_string(&TurnEvent::TurnDone {
reply: Some("done".into()),
outcome: "handled".into()
})
.unwrap(),
r#"{"type":"turn_done","reply":"done","outcome":"handled"}"#
);
}
#[test]
fn the_kind_is_the_wire_type_tag() {
assert_eq!(
TurnEvent::TextDelta { text: "x".into() }.kind(),
"text_delta"
);
assert_eq!(
TurnEvent::UserMessage { text: "x".into() }.kind(),
"user_message"
);
assert_eq!(
TurnEvent::TurnDone {
reply: None,
outcome: "handled".into()
}
.kind(),
"turn_done"
);
}
#[test]
fn a_subscriber_receives_what_is_emitted_after_it_subscribed() {
let hub = EventHub::default();
let key = key("web");
let mut receiver = hub.subscribe(&key);
hub.emit(
&key,
TurnEvent::ToolStart {
name: "bash".into(),
},
);
hub.emit(&key, done("done"));
assert_eq!(
receiver.try_recv().unwrap(),
TurnEvent::ToolStart {
name: "bash".into()
}
);
assert_eq!(receiver.try_recv().unwrap(), done("done"));
}
#[test]
fn a_history_subscription_replays_what_preceded_it_and_splits_exactly_once() {
let hub = EventHub::default();
let key = key("web");
hub.emit(
&key,
TurnEvent::UserMessage {
text: "hello".into(),
},
);
hub.emit(&key, text("hi"));
let (history, mut receiver) = hub.subscribe_with_history(&key);
assert_eq!(
history,
vec![
TurnEvent::UserMessage {
text: "hello".into()
},
text("hi"),
],
"everything before the cut is in the record"
);
hub.emit(&key, done("hi back"));
assert_eq!(
receiver.try_recv().unwrap(),
done("hi back"),
"everything after the cut is on the stream"
);
// A second cut contains the whole run — the live events were
// recorded too, and none of them twice.
let (history, _) = hub.subscribe_with_history(&key);
assert_eq!(history.len(), 3);
}
#[test]
fn emitting_to_nobody_listening_is_quiet_and_still_recorded() {
let hub = EventHub::default();
let key = key("alone");
// No subscriber ever: quiet — and the record still accrues, so
// a later viewer replays the turn nobody watched.
hub.emit(&key, text("x"));
assert!(
hub.entries
.lock()
.unwrap()
.get(&key)
.expect("the entry exists")
.sender
.receiver_count()
== 0
);
let (history, _) = hub.subscribe_with_history(&key);
assert_eq!(history, vec![text("x")]);
}
#[test]
fn keys_do_not_hear_each_other() {
let hub = EventHub::default();
let mut mine = hub.subscribe(&key("mine"));
hub.subscribe(&key("theirs"));
hub.emit(&key("theirs"), text("x"));
assert!(mine.try_recv().is_err(), "no cross-talk");
}
#[test]
fn a_lagging_subscriber_drops_the_oldest_and_is_told_so() {
let hub = EventHub::with_capacity(2);
let key = key("slow");
let mut receiver = hub.subscribe(&key);
for n in 1..=4 {
hub.emit(&key, text(&n.to_string()));
}
assert!(
matches!(
receiver.try_recv(),
Err(broadcast::error::TryRecvError::Lagged(2))
),
"the skip is surfaced, not silent"
);
assert_eq!(
receiver.try_recv().unwrap(),
text("3"),
"the newest capacity events survive"
);
}
#[test]
fn the_memory_record_is_capped_at_the_newest_events() {
let hub = EventHub::default();
let key = key("long");
for n in 0..RECORD_CAPACITY + 10 {
hub.emit(&key, text(&n.to_string()));
}
let (history, _) = hub.subscribe_with_history(&key);
assert_eq!(history.len(), RECORD_CAPACITY);
assert_eq!(
history.first(),
Some(&text("10")),
"the oldest fell off, not the newest"
);
}
/// The restart story: a store-backed hub's record outlives the
/// process, whole — a fresh hub over the same store replays
/// everything, and past the in-memory cap, too.
#[test]
fn a_stored_record_replays_whole_across_hubs_and_past_any_cap() {
let store = Arc::new(TurnStore::in_memory().expect("store opens"));
let hub = EventHub::with_record_store(store.clone());
let key = key("long");
for n in 0..RECORD_CAPACITY + 10 {
hub.emit(&key, text(&n.to_string()));
}
hub.emit(&key, done("hi back"));
let restarted = EventHub::with_record_store(store);
let (history, mut receiver) = restarted.subscribe_with_history(&key);
assert_eq!(
history.len(),
RECORD_CAPACITY + 11,
"nothing fell off: the table, not a window, is the record"
);
assert_eq!(history.first(), Some(&text("0")));
assert_eq!(history.last(), Some(&done("hi back")));
restarted.emit(&key, text("in this life"));
assert_eq!(
receiver.try_recv().unwrap(),
text("in this life"),
"the live cut is exact: replayed events never re-deliver"
);
}
#[test]
fn a_memory_record_replays_and_keeps_taking_new_events() {
let hub = EventHub::default();
let key = key("booted");
hub.emit(
&key,
TurnEvent::UserMessage {
text: "earlier".into(),
},
);
let (history, mut receiver) = hub.subscribe_with_history(&key);
assert_eq!(history.len(), 1);
hub.emit(&key, text("in this life"));
assert_eq!(receiver.try_recv().unwrap(), text("in this life"));
}
#[test]
fn a_cursor_skips_exactly_the_events_the_caller_already_holds() {
let hub = EventHub::default();
let key = key("cached");
for n in 0..4 {
hub.emit(&key, text(&n.to_string()));
}
// A client that cached the first three receives only the fourth;
// the live cut is untouched by the skip.
let (history, mut receiver) = hub.subscribe_with_history_from(&key, 3);
assert_eq!(history, vec![text("3")], "only the unseen part replays");
hub.emit(&key, done("hi back"));
assert_eq!(receiver.try_recv().unwrap(), done("hi back"));
// A cursor past the record's end clamps to it: nothing replays,
// and nothing the record still holds is skipped twice.
let (history, _) = hub.subscribe_with_history_from(&key, 100);
assert!(history.is_empty());
// Zero behaves as the plain replay.
let (history, _) = hub.subscribe_with_history_from(&key, 0);
assert_eq!(history.len(), 5);
}
}
+566
View File
@@ -0,0 +1,566 @@
//! The agent's presentation identity: the name, mark, tagline, and
//! images the interface renders everywhere — the rail, the chat, the
//! window title, the notifications' author. Stored claw-side so every
//! client reads the same identity over `GET /v1/agent`.
//!
//! Two kinds of facts live here. The text fields are keys in the
//! settings store; the images are files next to the daemon's database
//! with their content type in the store, because the bearer-guarded
//! route serves them back byte for byte. Behaviour — how the agent
//! talks, what it is like — is deliberately not here: that is SOUL.md,
//! the bootstrap store's domain, edited with the agent over onboarding.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
use rhema_core::ToolSpec;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::settings::SettingsStore;
/// Why an identity operation failed.
#[derive(Debug, thiserror::Error)]
pub enum IdentityError {
/// The settings store or the image directory failed.
#[error("identity store failed: {0}")]
Backend(String),
/// An image exceeded [`MAX_IMAGE_BYTES`].
#[error("image too large: {0} bytes, the limit is {1}")]
TooLarge(usize, usize),
/// An upload carried a non-image content type.
#[error("not an image: {0:?}")]
NotAnImage(String),
}
/// The cap on an uploaded avatar or logo: enough for a crisp square at
/// every size the UI renders, small enough that the route stays cheap.
pub const MAX_IMAGE_BYTES: usize = 2 * 1024 * 1024;
/// The agent's identity as the wire sees it: the optional text fields
/// (each `null` when unset) and whether the images exist — the images
/// themselves live behind their own routes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentIdentity {
/// The agent's display name: message author, window title,
/// notifications.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// The glyph or emoji in the rail, the favicon, the empty states.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mark: Option<String>,
/// Used when there is no mark and no avatar.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initials: Option<String>,
/// One line under the name on the agent pane.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tagline: Option<String>,
/// Appended to messages the agent sends outside the interface.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signoff: Option<String>,
/// Whether an avatar image is set (served at `/v1/agent/avatar`).
pub avatar: bool,
/// Whether a logo image is set (served at `/v1/agent/logo`).
pub logo: bool,
}
/// The five text fields, for a whole-identity save.
#[derive(Debug, Clone, Default)]
pub struct IdentityTexts {
pub name: Option<String>,
pub mark: Option<String>,
pub initials: Option<String>,
pub tagline: Option<String>,
pub signoff: Option<String>,
}
/// One of the two identity images.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageKind {
/// The square face the chat and rail render.
Avatar,
/// The mark the empty states and headers render.
Logo,
}
impl ImageKind {
/// The wire path segment and file stem.
pub fn name(self) -> &'static str {
match self {
ImageKind::Avatar => "avatar",
ImageKind::Logo => "logo",
}
}
}
/// The text field's settings key.
fn text_key(field: &str) -> String {
format!("identity.{field}")
}
/// The identity store: the text fields in the settings table, the
/// images in `dir` with their content types alongside in the store.
pub struct IdentityStore {
settings: Arc<SettingsStore>,
dir: PathBuf,
}
impl IdentityStore {
/// Opens the store over `settings`, keeping images in `dir`
/// (created on first write).
pub fn new(settings: Arc<SettingsStore>, dir: impl Into<PathBuf>) -> Self {
Self {
settings,
dir: dir.into(),
}
}
/// The current identity; unset fields read as `None`.
pub fn get(&self) -> Result<AgentIdentity, IdentityError> {
Ok(AgentIdentity {
name: self.text("name")?,
mark: self.text("mark")?,
initials: self.text("initials")?,
tagline: self.text("tagline")?,
signoff: self.text("signoff")?,
avatar: self.image_meta(ImageKind::Avatar)?.is_some(),
logo: self.image_meta(ImageKind::Logo)?.is_some(),
})
}
/// Saves the text fields whole: a `None` clears the field, a value
/// is trimmed and length-checked (the UI's inputs cap them, the
/// store enforces it).
pub fn set_texts(&self, texts: &IdentityTexts) -> Result<AgentIdentity, IdentityError> {
let fields = [
("name", &texts.name, 64usize),
("mark", &texts.mark, 8),
("initials", &texts.initials, 4),
("tagline", &texts.tagline, 120),
("signoff", &texts.signoff, 120),
];
for (field, value, cap) in fields {
let cleaned = value.as_deref().map(str::trim).filter(|v| !v.is_empty());
if let Some(value) = cleaned {
if value.chars().count() > cap {
return Err(IdentityError::Backend(format!(
"{field} is over {cap} characters"
)));
}
}
self.settings
.set(
&text_key(field),
&serde_json::to_string(&cleaned).expect("an option serializes"),
)
.map_err(|err| IdentityError::Backend(err.to_string()))?;
}
self.get()
}
/// Stores an image: the bytes in `dir`, the content type in the
/// settings store. Content types outside `image/` are refused, as
/// are bodies over [`MAX_IMAGE_BYTES`].
pub fn set_image(
&self,
kind: ImageKind,
bytes: &[u8],
content_type: &str,
) -> Result<AgentIdentity, IdentityError> {
let content_type = content_type
.split(';')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if !content_type.starts_with("image/") || content_type == "image/svg+xml" {
// SVG is refused deliberately: it is the one image format
// that can carry scripts, and the route serves these bytes
// back to a browser.
return Err(IdentityError::NotAnImage(content_type));
}
if bytes.len() > MAX_IMAGE_BYTES {
return Err(IdentityError::TooLarge(bytes.len(), MAX_IMAGE_BYTES));
}
std::fs::create_dir_all(&self.dir)
.map_err(|err| IdentityError::Backend(err.to_string()))?;
let file = self.image_file(kind);
std::fs::write(&file, bytes).map_err(|err| IdentityError::Backend(err.to_string()))?;
self.settings
.set(&format!("identity.{}.type", kind.name()), &content_type)
.map_err(|err| IdentityError::Backend(err.to_string()))?;
self.get()
}
/// The image bytes and their content type, when one is set.
pub fn image(&self, kind: ImageKind) -> Result<Option<(Vec<u8>, String)>, IdentityError> {
self.image_meta(kind)?
.map(|content_type| {
std::fs::read(self.image_file(kind))
.map(|bytes| (bytes, content_type))
.map_err(|err| IdentityError::Backend(err.to_string()))
})
.transpose()
}
/// Drops the image and its content type; a no-op when unset.
pub fn clear_image(&self, kind: ImageKind) -> Result<AgentIdentity, IdentityError> {
match std::fs::remove_file(self.image_file(kind)) {
Ok(()) => {}
// Absent is the cleared state, not a failure.
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(IdentityError::Backend(err.to_string())),
}
self.settings
.delete(&format!("identity.{}.type", kind.name()))
.map_err(|err| IdentityError::Backend(err.to_string()))?;
self.get()
}
/// The stored content type, `None` when no image is set.
fn image_meta(&self, kind: ImageKind) -> Result<Option<String>, IdentityError> {
self.settings
.get(&format!("identity.{}.type", kind.name()))
.map_err(|err| IdentityError::Backend(err.to_string()))
}
fn image_file(&self, kind: ImageKind) -> PathBuf {
self.dir.join(format!("{}.bin", kind.name()))
}
fn text(&self, field: &str) -> Result<Option<String>, IdentityError> {
Ok(self
.settings
.get(&text_key(field))
.map_err(|err| IdentityError::Backend(err.to_string()))?
.and_then(|json| serde_json::from_str::<Option<String>>(&json).ok())
.flatten())
}
}
/// The tool's name on the wire.
pub const IDENTITY_SET: &str = "identity_set";
/// `identity_set`: the model's surface over its own presentation —
/// the same store the settings pages write, so what it sets here is
/// what every client renders. Personality stays out of reach: that is
/// SOUL.md, and the tool has no path to it.
struct IdentitySet {
store: Arc<IdentityStore>,
workspace: PathBuf,
}
#[async_trait]
impl Tool for IdentitySet {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
IDENTITY_SET,
"Set your own presentation identity: the name, mark, \
initials, tagline, and signoff the interfaces render, and \
your avatar or logo images. This is how you look, not who \
you are — personality lives in SOUL.md. Leave a field out \
to keep what is set; an empty string clears it. The avatar \
and logo take a workspace-relative path to an image file \
(png, jpeg, gif, or webp, under 2 MiB — svg is refused).",
json!({
"type": "object",
"properties": {
"name": { "type": "string",
"description": "Display name: message author, \
window title, notifications. Up to 64 chars." },
"mark": { "type": "string",
"description": "Glyph or emoji for the rail and \
favicon. Up to 8 chars." },
"initials": { "type": "string",
"description": "Shown when there is no mark and \
no avatar. Up to 4 chars." },
"tagline": { "type": "string",
"description": "One line under the name. Up to \
120 chars." },
"signoff": { "type": "string",
"description": "Appended to messages sent \
outside the interface. Up to 120 chars." },
"avatar": { "type": "string",
"description": "Workspace-relative path to the \
avatar image." },
"logo": { "type": "string",
"description": "Workspace-relative path to the \
logo image." },
"clear": { "type": "array",
"items": { "type": "string",
"enum": ["avatar", "logo"] },
"description": "Images to remove." }
},
}),
)
}
async fn invoke(&self, args: Value) -> Result<String, String> {
if !args.is_object() {
return Err("bad arguments: expected an object".to_owned());
}
// The texts save whole, so an argument the call omits must carry
// the stored value forward — the same absent-means-keep rule the
// HTTP route applies. An empty string means clear.
let current = self.store.get().map_err(|err| err.to_string())?;
let field = |arg: Option<&str>, stored: Option<&str>| {
arg.map(str::to_owned).or_else(|| stored.map(str::to_owned))
};
let texts = IdentityTexts {
name: field(args["name"].as_str(), current.name.as_deref()),
mark: field(args["mark"].as_str(), current.mark.as_deref()),
initials: field(args["initials"].as_str(), current.initials.as_deref()),
tagline: field(args["tagline"].as_str(), current.tagline.as_deref()),
signoff: field(args["signoff"].as_str(), current.signoff.as_deref()),
};
// Every image is read and validated before anything is written,
// so a bad path fails the call without half a new identity.
let mut images = Vec::new();
for (field, kind) in [("avatar", ImageKind::Avatar), ("logo", ImageKind::Logo)] {
if let Some(path) = args[field].as_str() {
let file = workspace_image(&self.workspace, path)?;
let bytes =
std::fs::read(&file).map_err(|err| format!("cannot read {path:?}: {err}"))?;
images.push((kind, bytes, crate::attachments::content_type(path)));
}
}
let mut clears = Vec::new();
if let Some(list) = args["clear"].as_array() {
for name in list {
let name = name.as_str().ok_or("clear must name avatar or logo")?;
clears.push(match name {
"avatar" => ImageKind::Avatar,
"logo" => ImageKind::Logo,
other => return Err(format!("unknown image {other:?}")),
});
}
}
let mut identity = self
.store
.set_texts(&texts)
.map_err(|err| err.to_string())?;
for (kind, bytes, content_type) in images {
identity = self
.store
.set_image(kind, &bytes, content_type)
.map_err(|err| err.to_string())?;
}
for kind in clears {
identity = self
.store
.clear_image(kind)
.map_err(|err| err.to_string())?;
}
Ok(json!({ "saved": true, "identity": identity }).to_string())
}
}
/// Resolves a workspace-relative image path — absolute paths and `..`
/// escapes are refused, the same discipline the attachment tool keeps.
fn workspace_image(workspace: &Path, path: &str) -> Result<PathBuf, String> {
let relative = Path::new(path);
if relative.is_absolute()
|| relative
.components()
.any(|component| component == std::path::Component::ParentDir)
{
return Err(format!(
"path must be relative to the workspace, without '..': {path:?}"
));
}
Ok(workspace.join(relative))
}
/// The identity tool set over `store`: `identity_set`.
pub fn registry(store: Arc<IdentityStore>, workspace: impl Into<PathBuf>) -> ToolRegistry {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(IdentitySet {
store,
workspace: workspace.into(),
}));
registry
}
#[cfg(test)]
mod tests {
use super::*;
/// The store over a tempdir, with the guard returned so the
/// directory outlives the calls under test.
fn store() -> (IdentityStore, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let settings = Arc::new(SettingsStore::in_memory().expect("the store opens"));
(
IdentityStore::new(settings, dir.path().join("identity")),
dir,
)
}
#[test]
fn text_fields_round_trip_and_clear() {
let (store, _dir) = store();
let saved = store
.set_texts(&IdentityTexts {
name: Some(" Kosmos ".to_owned()),
mark: Some("✦".to_owned()),
initials: None,
tagline: Some("order".to_owned()),
signoff: Some(String::new()),
})
.expect("the texts save");
assert_eq!(saved.name.as_deref(), Some("Kosmos"), "trimmed");
assert_eq!(saved.mark.as_deref(), Some("✦"));
assert_eq!(saved.initials, None);
assert_eq!(saved.signoff, None, "an empty string clears");
assert!(!saved.avatar);
let cleared = store
.set_texts(&IdentityTexts::default())
.expect("the clear saves");
assert_eq!(cleared.name, None);
}
#[test]
fn an_overlong_field_is_refused() {
let (store, _dir) = store();
let long = "x".repeat(65);
let err = store
.set_texts(&IdentityTexts {
name: Some(long),
..IdentityTexts::default()
})
.expect_err("over the cap");
assert!(err.to_string().contains("over 64"));
}
#[test]
fn images_round_trip_and_clear() {
let (store, _dir) = store();
let saved = store
.set_image(ImageKind::Avatar, &[1, 2, 3], "image/png; charset=binary")
.expect("the avatar saves");
assert!(saved.avatar);
let (bytes, content_type) = store
.image(ImageKind::Avatar)
.expect("the image reads")
.expect("an image is set");
assert_eq!(bytes, vec![1, 2, 3]);
assert_eq!(content_type, "image/png", "parameters stripped");
let cleared = store
.clear_image(ImageKind::Avatar)
.expect("the clear saves");
assert!(!cleared.avatar);
assert!(store.image(ImageKind::Avatar).expect("reads").is_none());
// Clearing an unset image is a no-op, not an error.
store
.clear_image(ImageKind::Avatar)
.expect("the second clear is a no-op");
}
#[test]
fn non_images_and_oversized_bodies_are_refused() {
let (store, _dir) = store();
assert!(
store
.set_image(ImageKind::Logo, b"<svg/>", "image/svg+xml")
.is_err(),
"svg carries scripts"
);
assert!(
store
.set_image(ImageKind::Logo, b"plain", "text/plain")
.is_err()
);
let big = vec![0u8; MAX_IMAGE_BYTES + 1];
assert!(store.set_image(ImageKind::Logo, &big, "image/png").is_err());
}
/// The tool, over a real workspace file: the texts save, the image
/// lands with its extension's content type, and the answer carries
/// the resulting identity.
#[tokio::test]
async fn identity_set_saves_texts_and_a_workspace_avatar() {
let dir = tempfile::tempdir().expect("tempdir");
let workspace = dir.path().join("interactive");
std::fs::create_dir_all(&workspace).expect("workspace");
std::fs::write(workspace.join("face.png"), b"png-bytes").expect("the image writes");
let settings = Arc::new(SettingsStore::in_memory().expect("the store opens"));
let store = Arc::new(IdentityStore::new(settings, dir.path().join("identity")));
let tool = IdentitySet {
store: store.clone(),
workspace: workspace.clone(),
};
let answer = tool
.invoke(json!({
"name": "Kosmos",
"mark": "✦",
"avatar": "face.png",
}))
.await
.expect("in-band ok");
let answer: Value = serde_json::from_str(&answer).expect("parses");
assert_eq!(answer["saved"], true);
assert_eq!(answer["identity"]["name"], "Kosmos");
assert_eq!(answer["identity"]["avatar"], true);
let (bytes, content_type) = store
.image(ImageKind::Avatar)
.expect("reads")
.expect("the avatar is set");
assert_eq!(bytes, b"png-bytes");
assert_eq!(content_type, "image/png");
// An absent field carried the stored value forward.
tool.invoke(json!({ "tagline": "order" }))
.await
.expect("saves");
let identity = store.get().expect("reads");
assert_eq!(identity.name.as_deref(), Some("Kosmos"), "kept");
assert_eq!(identity.tagline.as_deref(), Some("order"), "added");
assert!(identity.avatar, "untouched by a text-only save");
}
#[tokio::test]
async fn identity_set_clears_fields_and_images_and_refuses_escapes() {
let dir = tempfile::tempdir().expect("tempdir");
let workspace = dir.path().join("interactive");
std::fs::create_dir_all(&workspace).expect("workspace");
let settings = Arc::new(SettingsStore::in_memory().expect("the store opens"));
let store = Arc::new(IdentityStore::new(settings, dir.path().join("identity")));
store
.set_image(ImageKind::Logo, b"bytes", "image/png")
.expect("the logo saves");
let tool = IdentitySet {
store: store.clone(),
workspace: workspace.clone(),
};
let answer = tool
.invoke(json!({
"name": "",
"clear": ["logo"],
}))
.await
.expect("in-band ok");
let answer: Value = serde_json::from_str(&answer).expect("parses");
assert_eq!(
answer["identity"]["name"],
Value::Null,
"an empty string clears"
);
assert_eq!(answer["identity"]["logo"], false);
for bad in [
json!({ "avatar": "../outside.png" }),
json!({ "avatar": "/etc/hostname" }),
json!({ "avatar": "missing.png" }),
json!({ "clear": ["soul"] }),
json!({ "name": "x".repeat(65) }),
] {
assert!(
tool.invoke(bad.clone()).await.is_err(),
"refused in band: {bad}"
);
}
}
}
File diff suppressed because it is too large. Load diff
+604
View File
@@ -0,0 +1,604 @@
//! The coding-job store: the instances the resident may drive a coding
//! agent in, and the jobs queued against them.
//!
//! An **instance** is a named workspace: the audit unit for "which
//! checkouts can the model drive a coding agent in". A **job** is one
//! `CodingAgent` turn over an instance's workspace: prompt in, final
//! text out, run off the chat turn by the jobs dispatcher (see
//! [`crate::jobs`]). The conversation carries across a job boundary in
//! the instance row's session blob — the same stash the forge handler
//! keeps in the dispatcher's `driver_state`, keyed by instance instead
//! of by thread, so "run the tests" after "fix the lint" continues the
//! same coding session.
//!
//! Two invariants the schema itself holds: at most one **live** job
//! (queued or running) per instance — a partial unique index refuses
//! the second enqueue, because two coding agents in one workspace is
//! two agents editing the same files — and [`CodingJobStore::recover`]
//! is the boot pass that makes a crash honest: a row left `running` by
//! a dead process is failed as interrupted, and a row still `queued`
//! gets its timestamp bumped so its re-ingest carries a fresh dedup id
//! (the old one may already sit in the dispatcher's consumed set).
use std::path::Path;
use std::sync::{Arc, Mutex, PoisonError};
use rusqlite::params;
/// Why a store operation failed.
#[derive(Debug, thiserror::Error)]
pub enum JobStoreError {
/// The SQLite layer failed.
#[error(transparent)]
Sqlite(#[from] rusqlite::Error),
/// The instance name is empty or carries a character the dispatch
/// key cannot hold (`#`, `/`, `:`) or whitespace.
#[error("invalid instance name {name:?}: use letters, digits, dash, dot, underscore")]
InvalidName {
/// The rejected name.
name: String,
},
/// An instance with this name already exists.
#[error("instance {name:?} already exists")]
Duplicate {
/// The refused name.
name: String,
},
/// The instance is not in the table.
#[error("no instance named {name:?}")]
UnknownInstance {
/// The name that resolved to nothing.
name: String,
},
/// The instance has a live job; the operation (a second enqueue, a
/// drop) is refused until it finishes.
#[error("instance {name:?} has a job in flight; check its status or wait")]
Busy {
/// The busy instance.
name: String,
},
}
/// How far a job has got.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobStatus {
/// Enqueued; the jobs source will ingest it.
Queued,
/// A coding-agent turn is running for it.
Running,
/// The turn's final text landed in `result`.
Done,
/// The turn failed; `result` carries the reason.
Failed,
}
impl JobStatus {
/// The spelling `status` answers carry.
pub fn as_str(self) -> &'static str {
match self {
JobStatus::Queued => "queued",
JobStatus::Running => "running",
JobStatus::Done => "done",
JobStatus::Failed => "failed",
}
}
fn parse(raw: &str) -> Self {
match raw {
"running" => JobStatus::Running,
"done" => JobStatus::Done,
"failed" => JobStatus::Failed,
_ => JobStatus::Queued,
}
}
/// Whether the job still has a turn ahead of it.
pub fn is_live(self) -> bool {
matches!(self, JobStatus::Queued | JobStatus::Running)
}
}
/// One named workspace the resident may drive a coding agent in.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstanceRecord {
/// The name the tools address it by.
pub name: String,
/// The workspace directory the coding agent's shell runs in.
pub path: String,
/// The coding session the next job resumes, as JSON; `None` until
/// the first job's turn returns.
pub session: Option<String>,
/// When the instance was added.
pub created_at_millis: i64,
}
/// One queued or finished coding run.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobRecord {
/// The row id; the dispatch key and the `status` answer carry it.
pub id: i64,
/// The instance the job runs against.
pub instance: String,
/// The prompt the coding agent turns on.
pub prompt: String,
/// How far the job has got.
pub status: JobStatus,
/// The final text on done, the reason on failed.
pub result: Option<String>,
/// When the job was enqueued.
pub created_at_millis: i64,
/// The last transition; also the dedup id's half, so a re-queued
/// row re-ingests after a restart.
pub updated_at_millis: i64,
}
/// The instances and jobs, in one SQLite file beside the daemon's other
/// tables.
pub struct CodingJobStore {
conn: Arc<Mutex<rusqlite::Connection>>,
}
impl CodingJobStore {
/// Opens the store at `path`, creating the tables.
pub fn open(path: impl AsRef<Path>) -> Result<Self, JobStoreError> {
let conn = rusqlite::Connection::open(path)?;
Self::init(conn)
}
/// A memory-backed store; the tests' shape.
pub fn in_memory() -> Result<Self, JobStoreError> {
Self::init(rusqlite::Connection::open_in_memory()?)
}
fn init(conn: rusqlite::Connection) -> Result<Self, JobStoreError> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS coding_instances (
name TEXT PRIMARY KEY,
path TEXT NOT NULL,
session TEXT,
created_at_millis INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS coding_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance TEXT NOT NULL,
prompt TEXT NOT NULL,
status TEXT NOT NULL,
result TEXT,
created_at_millis INTEGER NOT NULL,
updated_at_millis INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS coding_jobs_one_live_per_instance
ON coding_jobs(instance) WHERE status IN ('queued', 'running');",
)?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
})
}
/// Adds an instance. The name is validated here — the dispatch key
/// is `job:<name>#<id>`, so a `#` in the name would break the parse
/// the jobs handler depends on.
pub fn add_instance(&self, name: &str, path: &str) -> Result<InstanceRecord, JobStoreError> {
if name.is_empty()
|| name
.chars()
.any(|c| c.is_whitespace() || matches!(c, '#' | '/' | ':'))
{
return Err(JobStoreError::InvalidName { name: name.into() });
}
let created_at = now_millis();
let conn = self.lock();
conn.execute(
"INSERT INTO coding_instances (name, path, session, created_at_millis)
VALUES (?1, ?2, NULL, ?3)",
params![name, path, created_at],
)
.map_err(|err| match err {
rusqlite::Error::SqliteFailure(_, Some(text)) if text.contains("UNIQUE") => {
JobStoreError::Duplicate { name: name.into() }
}
other => other.into(),
})?;
Ok(InstanceRecord {
name: name.to_owned(),
path: path.to_owned(),
session: None,
created_at_millis: created_at,
})
}
/// Removes an instance. Refused while a job is live on it: a drop
/// under a running turn would orphan the run's store writes.
pub fn drop_instance(&self, name: &str) -> Result<bool, JobStoreError> {
let conn = self.lock();
if Self::live_locked(&conn, name)?.is_some() {
return Err(JobStoreError::Busy { name: name.into() });
}
let dropped = conn.execute(
"DELETE FROM coding_instances WHERE name = ?1",
params![name],
)?;
Ok(dropped > 0)
}
/// Every instance, oldest first.
pub fn instances(&self) -> Result<Vec<InstanceRecord>, JobStoreError> {
let conn = self.lock();
let mut statement = conn.prepare(
"SELECT name, path, session, created_at_millis
FROM coding_instances ORDER BY created_at_millis, name",
)?;
let rows = statement.query_map([], row_instance)?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(JobStoreError::from)
}
/// One instance by name.
pub fn instance(&self, name: &str) -> Result<Option<InstanceRecord>, JobStoreError> {
let conn = self.lock();
let mut statement = conn.prepare(
"SELECT name, path, session, created_at_millis
FROM coding_instances WHERE name = ?1",
)?;
let mut rows = statement.query_map(params![name], row_instance)?;
rows.next().transpose().map_err(JobStoreError::from)
}
/// The instance's session blob, `None` until the first job's turn
/// returns or after a `fresh` run cleared it.
pub fn session(&self, name: &str) -> Result<Option<String>, JobStoreError> {
self.instance(name)?
.map(|instance| instance.session)
.ok_or_else(|| JobStoreError::UnknownInstance { name: name.into() })
}
/// Replaces the instance's session blob — the coding conversation
/// the next job resumes.
pub fn save_session(&self, name: &str, session: &str) -> Result<(), JobStoreError> {
let conn = self.lock();
let updated = conn.execute(
"UPDATE coding_instances SET session = ?2 WHERE name = ?1",
params![name, session],
)?;
(updated > 0)
.then_some(())
.ok_or_else(|| JobStoreError::UnknownInstance { name: name.into() })
}
/// Drops the session blob: the next `run` starts a fresh coding
/// conversation.
pub fn clear_session(&self, name: &str) -> Result<(), JobStoreError> {
self.save_session_name(name, None)
}
fn save_session_name(&self, name: &str, session: Option<&str>) -> Result<(), JobStoreError> {
let conn = self.lock();
let updated = conn.execute(
"UPDATE coding_instances SET session = ?2 WHERE name = ?1",
params![name, session],
)?;
(updated > 0)
.then_some(())
.ok_or_else(|| JobStoreError::UnknownInstance { name: name.into() })
}
/// Enqueues one job. Refused when the instance is unknown or
/// already has a live job.
pub fn enqueue(&self, instance: &str, prompt: &str) -> Result<JobRecord, JobStoreError> {
let conn = self.lock();
if conn
.query_row(
"SELECT 1 FROM coding_instances WHERE name = ?1",
params![instance],
|_| Ok(()),
)
.is_err()
{
return Err(JobStoreError::UnknownInstance {
name: instance.into(),
});
}
let now = now_millis();
conn.execute(
"INSERT INTO coding_jobs (instance, prompt, status, created_at_millis, updated_at_millis)
VALUES (?1, ?2, 'queued', ?3, ?3)",
params![instance, prompt, now],
)
.map_err(|err| match err {
rusqlite::Error::SqliteFailure(_, Some(text))
if text.contains("UNIQUE") || text.contains("constraint") =>
{
JobStoreError::Busy { name: instance.into() }
}
other => other.into(),
})?;
let id = conn.last_insert_rowid();
Ok(JobRecord {
id,
instance: instance.to_owned(),
prompt: prompt.to_owned(),
status: JobStatus::Queued,
result: None,
created_at_millis: now,
updated_at_millis: now,
})
}
/// One job by id.
pub fn job(&self, id: i64) -> Result<Option<JobRecord>, JobStoreError> {
let conn = self.lock();
let mut statement = conn.prepare(
"SELECT id, instance, prompt, status, result, created_at_millis, updated_at_millis
FROM coding_jobs WHERE id = ?1",
)?;
let mut rows = statement.query_map(params![id], row_job)?;
rows.next().transpose().map_err(JobStoreError::from)
}
/// The instance's jobs, newest first, at most `limit`.
pub fn jobs_for(&self, instance: &str, limit: u32) -> Result<Vec<JobRecord>, JobStoreError> {
let conn = self.lock();
let mut statement = conn.prepare(
"SELECT id, instance, prompt, status, result, created_at_millis, updated_at_millis
FROM coding_jobs WHERE instance = ?1
ORDER BY id DESC LIMIT ?2",
)?;
let rows = statement.query_map(params![instance, limit], row_job)?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(JobStoreError::from)
}
/// The most recent jobs across every instance, newest first — the
/// no-argument `status` answer.
pub fn recent(&self, limit: u32) -> Result<Vec<JobRecord>, JobStoreError> {
let conn = self.lock();
let mut statement = conn.prepare(
"SELECT id, instance, prompt, status, result, created_at_millis, updated_at_millis
FROM coding_jobs ORDER BY id DESC LIMIT ?1",
)?;
let rows = statement.query_map(params![limit], row_job)?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(JobStoreError::from)
}
/// The queued rows, oldest first — the jobs source's detect pass.
pub fn queued(&self) -> Result<Vec<JobRecord>, JobStoreError> {
let conn = self.lock();
let mut statement = conn.prepare(
"SELECT id, instance, prompt, status, result, created_at_millis, updated_at_millis
FROM coding_jobs WHERE status = 'queued' ORDER BY id",
)?;
let rows = statement.query_map([], row_job)?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(JobStoreError::from)
}
/// The instance's live job, if any — the busy check the tools and
/// the drop path share.
pub fn live_job(&self, instance: &str) -> Result<Option<JobRecord>, JobStoreError> {
let conn = self.lock();
Self::live_locked(&conn, instance)
}
/// Marks the job running; the turn has begun.
pub fn mark_running(&self, id: i64) -> Result<(), JobStoreError> {
let conn = self.lock();
conn.execute(
"UPDATE coding_jobs SET status = 'running', updated_at_millis = ?2
WHERE id = ?1",
params![id, now_millis()],
)?;
Ok(())
}
/// Finishes the job with the turn's final text.
pub fn finish(&self, id: i64, result: &str) -> Result<(), JobStoreError> {
self.transition(id, "done", Some(result))
}
/// Fails the job with the reason; the model can read it via status.
pub fn fail(&self, id: i64, reason: &str) -> Result<(), JobStoreError> {
self.transition(id, "failed", Some(reason))
}
fn transition(&self, id: i64, status: &str, result: Option<&str>) -> Result<(), JobStoreError> {
let conn = self.lock();
conn.execute(
"UPDATE coding_jobs SET status = ?2, result = ?3, updated_at_millis = ?4
WHERE id = ?1",
params![id, status, result, now_millis()],
)?;
Ok(())
}
/// The boot pass: a `running` row belonged to the process that just
/// died and is failed as interrupted; a `queued` row gets its
/// timestamp bumped, so the source's re-ingest carries a dedup id
/// the previous process's consumed set cannot swallow. Returns the
/// number of rows it touched.
pub fn recover(&self) -> Result<usize, JobStoreError> {
let conn = self.lock();
let now = now_millis();
let interrupted = conn.execute(
"UPDATE coding_jobs
SET status = 'failed',
result = 'interrupted: the daemon restarted mid-job; run it again',
updated_at_millis = ?1
WHERE status = 'running'",
params![now],
)?;
let requeued = conn.execute(
"UPDATE coding_jobs SET updated_at_millis = ?1 WHERE status = 'queued'",
params![now],
)?;
Ok(interrupted + requeued)
}
fn live_locked(
conn: &rusqlite::Connection,
instance: &str,
) -> Result<Option<JobRecord>, JobStoreError> {
let mut statement = conn.prepare(
"SELECT id, instance, prompt, status, result, created_at_millis, updated_at_millis
FROM coding_jobs WHERE instance = ?1 AND status IN ('queued', 'running')
ORDER BY id LIMIT 1",
)?;
let mut rows = statement.query_map(params![instance], row_job)?;
rows.next().transpose().map_err(JobStoreError::from)
}
fn lock(&self) -> std::sync::MutexGuard<'_, rusqlite::Connection> {
self.conn.lock().unwrap_or_else(PoisonError::into_inner)
}
}
fn row_instance(row: &rusqlite::Row<'_>) -> rusqlite::Result<InstanceRecord> {
Ok(InstanceRecord {
name: row.get(0)?,
path: row.get(1)?,
session: row.get(2)?,
created_at_millis: row.get(3)?,
})
}
fn row_job(row: &rusqlite::Row<'_>) -> rusqlite::Result<JobRecord> {
Ok(JobRecord {
id: row.get(0)?,
instance: row.get(1)?,
prompt: row.get(2)?,
status: JobStatus::parse(&row.get::<_, String>(3)?),
result: row.get(4)?,
created_at_millis: row.get(5)?,
updated_at_millis: row.get(6)?,
})
}
/// Milliseconds since the Unix epoch, the table's clock everywhere.
fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_lists_and_drops_instances() {
let store = CodingJobStore::in_memory().unwrap();
store.add_instance("rhema", "/home/me/rhema").unwrap();
store.add_instance("widgets", "/home/me/widgets").unwrap();
let instances = store.instances().unwrap();
assert_eq!(instances.len(), 2);
assert_eq!(instances[0].name, "rhema");
assert_eq!(instances[0].session, None);
assert!(store.drop_instance("widgets").unwrap());
assert_eq!(store.instances().unwrap().len(), 1);
assert!(!store.drop_instance("widgets").unwrap(), "already gone");
}
#[test]
fn names_the_key_cannot_hold_are_refused() {
let store = CodingJobStore::in_memory().unwrap();
for bad in ["", "a#b", "a/b", "a:b", "a b"] {
let err = store.add_instance(bad, "/tmp").unwrap_err();
assert!(matches!(err, JobStoreError::InvalidName { .. }), "{bad}");
}
store.add_instance("ok-name_1", "/tmp").unwrap();
}
#[test]
fn a_duplicate_add_names_the_collision() {
let store = CodingJobStore::in_memory().unwrap();
store.add_instance("rhema", "/tmp/rhema").unwrap();
let err = store.add_instance("rhema", "/tmp/other").unwrap_err();
assert!(matches!(err, JobStoreError::Duplicate { .. }));
}
#[test]
fn one_live_job_per_instance() {
let store = CodingJobStore::in_memory().unwrap();
store.add_instance("rhema", "/tmp/rhema").unwrap();
let first = store.enqueue("rhema", "fix the lint").unwrap();
assert_eq!(first.status, JobStatus::Queued);
let err = store.enqueue("rhema", "also this").unwrap_err();
assert!(matches!(err, JobStoreError::Busy { .. }));
store.finish(first.id, "lint fixed").unwrap();
// Done unblocks the next enqueue.
store.enqueue("rhema", "run the tests").unwrap();
}
#[test]
fn finishing_frees_but_a_second_instance_runs_parallel() {
let store = CodingJobStore::in_memory().unwrap();
store.add_instance("a", "/tmp/a").unwrap();
store.add_instance("b", "/tmp/b").unwrap();
store.enqueue("a", "one").unwrap();
store.enqueue("b", "two").unwrap();
assert_eq!(store.queued().unwrap().len(), 2);
assert!(store.live_job("a").unwrap().is_some());
}
#[test]
fn enqueue_requires_the_instance_and_the_status_tool_finds_the_job() {
let store = CodingJobStore::in_memory().unwrap();
let err = store.enqueue("ghost", "x").unwrap_err();
assert!(matches!(err, JobStoreError::UnknownInstance { .. }));
store.add_instance("rhema", "/tmp/rhema").unwrap();
let job = store.enqueue("rhema", "do it").unwrap();
let read = store.job(job.id).unwrap().unwrap();
assert_eq!(read.prompt, "do it");
assert_eq!(store.jobs_for("rhema", 10).unwrap().len(), 1);
}
#[test]
fn a_drop_is_refused_while_a_job_is_live() {
let store = CodingJobStore::in_memory().unwrap();
store.add_instance("rhema", "/tmp/rhema").unwrap();
let job = store.enqueue("rhema", "long work").unwrap();
store.mark_running(job.id).unwrap();
let err = store.drop_instance("rhema").unwrap_err();
assert!(matches!(err, JobStoreError::Busy { .. }));
store.fail(job.id, "cancelled").unwrap();
assert!(store.drop_instance("rhema").unwrap());
}
#[test]
fn the_session_ride_transitions_marking_and_recover() {
let store = CodingJobStore::in_memory().unwrap();
store.add_instance("rhema", "/tmp/rhema").unwrap();
store.save_session("rhema", r#"{"history":[]}"#).unwrap();
assert_eq!(
store.instance("rhema").unwrap().unwrap().session,
Some(r#"{"history":[]}"#.to_owned())
);
store.clear_session("rhema").unwrap();
assert_eq!(store.instance("rhema").unwrap().unwrap().session, None);
store.save_session("ghost", "x").unwrap_err();
let job = store.enqueue("rhema", "work").unwrap();
store.mark_running(job.id).unwrap();
assert_eq!(
store.job(job.id).unwrap().unwrap().status,
JobStatus::Running
);
// The boot pass: the running row is failed as interrupted; the
// other instance's queued row only has its timestamp moved, so
// its re-ingest after the restart carries a fresh dedup id.
store.add_instance("other", "/tmp/other").unwrap();
let queued = store.enqueue("other", "queued work").unwrap();
let touched = store.recover().unwrap();
assert_eq!(touched, 2);
let after = store.job(job.id).unwrap().unwrap();
assert_eq!(after.status, JobStatus::Failed);
assert!(after.result.unwrap().contains("interrupted"));
assert!(!after.status.is_live());
assert_eq!(
store.job(queued.id).unwrap().unwrap().status,
JobStatus::Queued,
"still queued, only touched"
);
}
}
+398
View File
@@ -0,0 +1,398 @@
//! The `coding_agent` tool: how the resident drives a coding agent —
//! named instances over real workspaces, and async jobs against them.
//!
//! One tool, an `action` argument, the [`crate::phone`] tool's shape:
//! `add` pins a name to a workspace directory, `drop` removes it,
//! `run` enqueues a job (a `CodingAgent` turn over the instance's
//! workspace, resumed from the instance's last coding session unless
//! `fresh`), `list` names the instances, and `status` reports jobs.
//! The run is
//! **async by design**: a coding job is minutes of tool rounds, so the
//! call returns the job id in one model round, the jobs dispatcher
//! runs the turn off the chat turn ([`crate::jobs`]), and the final
//! text lands in the job row and the user's notifications. The model
//! answers "it's running" honestly and reads the result later with
//! `status`.
use std::sync::Arc;
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
use rhema_core::ToolSpec;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::job_store::{CodingJobStore, JobStoreError};
/// The coding-agent tool set over `store`: one tool, `coding_agent`.
pub fn registry(store: Arc<CodingJobStore>) -> ToolRegistry {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(CodingAgent { store }));
registry
}
#[derive(Deserialize)]
struct Args {
action: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
path: Option<String>,
#[serde(default)]
instance: Option<String>,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
fresh: Option<bool>,
#[serde(default)]
job: Option<i64>,
}
/// The tool: one `coding_agent` call, `action` picks the operation.
struct CodingAgent {
store: Arc<CodingJobStore>,
}
impl CodingAgent {
fn add(&self, name: &str, path: &str) -> Result<String, String> {
if !std::path::Path::new(path).is_dir() {
return Err(format!(
"no directory {path:?}: the workspace must exist; create or clone it first"
));
}
self.store
.add_instance(name, path)
.map(|_| json!({"added": name, "path": path}).to_string())
.map_err(in_band)
}
fn drop(&self, name: &str) -> Result<String, String> {
let dropped = self.store.drop_instance(name).map_err(in_band)?;
if dropped {
Ok(json!({"dropped": name}).to_string())
} else {
Err(format!("no instance named {name:?}"))
}
}
fn run(&self, instance: &str, prompt: &str, fresh: bool) -> Result<String, String> {
if prompt.trim().is_empty() {
return Err("`prompt` is empty; the coding agent needs a task".to_owned());
}
if fresh {
// The conversation reset lands before the enqueue: a fresh
// flag on a refused run must not wipe a session it did not
// replace.
self.store.clear_session(instance).map_err(in_band)?;
}
let job = self.store.enqueue(instance, prompt).map_err(in_band)?;
Ok(json!({
"job": job.id,
"instance": instance,
"status": "queued",
"note": "the job runs off this chat; its final text lands in \
coding_agent status and the user's notifications"
})
.to_string())
}
/// The instances: the audit surface for which checkouts a coding
/// agent may drive, each with its live job at a glance.
fn list(&self) -> Result<String, String> {
let instances = self.store.instances().map_err(in_band)?;
let listed = instances
.iter()
.map(|instance| {
let live = self
.store
.live_job(&instance.name)
.ok()
.flatten()
.map(|job| json!({"id": job.id, "status": job.status.as_str()}));
json!({
"name": instance.name,
"path": instance.path,
"has_session": instance.session.is_some(),
"live_job": live,
})
})
.collect::<Vec<_>>();
Ok(json!({ "instances": listed }).to_string())
}
/// The jobs: every recent one, one instance's, or one job's result.
fn status(&self, instance: Option<&str>, job: Option<i64>) -> Result<String, String> {
if let Some(id) = job {
let record = self
.store
.job(id)
.map_err(in_band)?
.ok_or_else(|| format!("no job {id}"))?;
return Ok(job_json(&record).to_string());
}
match instance {
Some(name) => {
if self.store.instance(name).map_err(in_band)?.is_none() {
return Err(format!("no instance named {name:?}"));
}
let jobs = self.store.jobs_for(name, 20).map_err(in_band)?;
Ok(json!({
"instance": name,
"jobs": jobs.iter().map(job_json).collect::<Vec<_>>(),
})
.to_string())
}
None => {
let jobs = self.store.recent(20).map_err(in_band)?;
Ok(json!({
"jobs": jobs.iter().map(job_json).collect::<Vec<_>>(),
})
.to_string())
}
}
}
}
fn job_json(job: &crate::job_store::JobRecord) -> Value {
json!({
"job": job.id,
"instance": job.instance,
"status": job.status.as_str(),
"result": job.result,
"prompt": if job.status.is_live() { Some(job.prompt.as_str()) } else { None },
})
}
/// The store's typed refusals are the model's in-band answers.
fn in_band(err: JobStoreError) -> String {
err.to_string()
}
#[async_trait]
impl Tool for CodingAgent {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
"coding_agent",
"Drive a coding agent — a full rhema-coding-agent session with \
its own shell — in a named workspace, off this chat. `add` \
pins a name to an existing directory; `drop` removes it; \
`list` names the instances (the workspaces a coding agent \
may drive); `run` enqueues a job whose turn runs in the \
background (minutes, not seconds): the answer is the job \
id, the final text reaches `status` and the user's \
notifications, and the instance's coding session carries \
over to the next run unless `fresh`. `status` reads jobs — \
every recent one, one instance's, or one job's result. One \
live job per instance.",
json!({
"type": "object",
"properties": {
"action": { "type": "string",
"enum": ["add", "drop", "run", "list", "status"],
"description": "Which operation." },
"name": { "type": "string",
"description": "add/drop: the instance's name." },
"path": { "type": "string",
"description": "add: the workspace directory; it \
must already exist." },
"instance": { "type": "string",
"description": "run/status: the instance to act on." },
"prompt": { "type": "string",
"description": "run: what the coding agent should do." },
"fresh": { "type": "boolean",
"description": "run: start a fresh coding session \
instead of resuming the instance's last one." },
"job": { "type": "integer",
"description": "status: one job's id, for its result." }
},
"required": ["action"]
}),
)
}
async fn invoke(&self, args: Value) -> Result<String, String> {
let args: Args =
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
match args.action.as_str() {
"add" => self.add(
required(&args.name, "add needs `name`")?,
required(&args.path, "add needs `path`")?,
),
"drop" => self.drop(required(&args.name, "drop needs `name`")?),
"run" => self.run(
required(&args.instance, "run needs `instance`")?,
required(&args.prompt, "run needs `prompt`")?,
args.fresh.unwrap_or(false),
),
"list" => self.list(),
"status" => self.status(args.instance.as_deref(), args.job),
other => Err(format!(
"unknown action {other:?}; one of add, drop, run, list, status"
)),
}
}
}
fn required<'a>(value: &'a Option<String>, complaint: &str) -> Result<&'a str, String> {
value
.as_deref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| complaint.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use rhema_autonomous_agent::Allowlist;
use rhema_core::{ToolCall, ToolContext, ToolResolver};
use serde_json::json;
use std::sync::Arc as StdArc;
fn resolver(store: StdArc<CodingJobStore>) -> StdArc<dyn ToolResolver> {
registry(store).resolver_for(&Allowlist::permissive())
}
fn tempdir() -> std::path::PathBuf {
let dir = tempfile::tempdir().unwrap().keep();
std::fs::create_dir_all(&dir).unwrap();
dir
}
async fn call(resolver: &StdArc<dyn ToolResolver>, args: Value) -> Result<String, String> {
let result = resolver
.resolve(
ToolCall {
id: "call_1".into(),
name: "coding_agent".into(),
args,
provider_data: Value::Null,
},
&ToolContext::default(),
)
.await;
if result.is_error {
Err(result.content)
} else {
Ok(result.content)
}
}
#[tokio::test]
async fn add_run_and_status_round_trip() {
let store = StdArc::new(CodingJobStore::in_memory().unwrap());
let resolver = resolver(store.clone());
let workspace = tempdir();
let added = call(
&resolver,
json!({"action": "add", "name": "rhema",
"path": workspace.to_str().unwrap()}),
)
.await
.unwrap();
assert!(added.contains("\"added\":\"rhema\""), "{added}");
let run = call(
&resolver,
json!({"action": "run", "instance": "rhema", "prompt": "fix the lint"}),
)
.await
.unwrap();
assert!(run.contains("\"status\":\"queued\""), "{run}");
let listed = call(&resolver, json!({"action": "list"})).await.unwrap();
assert!(listed.contains("rhema"), "{listed}");
assert!(listed.contains("live_job"), "{listed}");
let recent = call(&resolver, json!({"action": "status"})).await.unwrap();
assert!(recent.contains("fix the lint"), "{recent}");
let status = call(&resolver, json!({"action": "status", "instance": "rhema"}))
.await
.unwrap();
assert!(status.contains("fix the lint"), "{status}");
}
#[tokio::test]
async fn the_refusals_are_in_band_and_named() {
let store = StdArc::new(CodingJobStore::in_memory().unwrap());
let resolver = resolver(store.clone());
let workspace = tempdir();
let err = call(&resolver, json!({"action": "explode"}))
.await
.unwrap_err();
assert!(err.contains("unknown action"), "{err}");
let err = call(&resolver, json!({"action": "add", "name": "x"}))
.await
.unwrap_err();
assert!(err.contains("add needs `path`"), "{err}");
let err = call(
&resolver,
json!({"action": "add", "name": "x", "path": "/nowhere-at-all"}),
)
.await
.unwrap_err();
assert!(err.contains("no directory"), "{err}");
call(
&resolver,
json!({"action": "add", "name": "rhema", "path": workspace.to_str().unwrap()}),
)
.await
.unwrap();
let err = call(
&resolver,
json!({"action": "run", "instance": "ghost", "prompt": "x"}),
)
.await
.unwrap_err();
assert!(err.contains("no instance named"), "{err}");
call(
&resolver,
json!({"action": "run", "instance": "rhema", "prompt": "long work"}),
)
.await
.unwrap();
let err = call(
&resolver,
json!({"action": "run", "instance": "rhema", "prompt": "more"}),
)
.await
.unwrap_err();
assert!(err.contains("job in flight"), "{err}");
let err = call(&resolver, json!({"action": "drop", "name": "rhema"}))
.await
.unwrap_err();
assert!(err.contains("job in flight"), "{err}");
let err = call(&resolver, json!({"action": "status", "job": 999}))
.await
.unwrap_err();
assert!(err.contains("no job 999"), "{err}");
}
#[tokio::test]
async fn fresh_clears_the_session_only_on_a_valid_run() {
let store = StdArc::new(CodingJobStore::in_memory().unwrap());
store.add_instance("rhema", "/tmp/rhema").unwrap();
store.save_session("rhema", r#"{"history":[1]}"#).unwrap();
let resolver = resolver(store.clone());
// The refused run wipes nothing.
let err = call(
&resolver,
json!({"action": "run", "instance": "ghost", "prompt": "x", "fresh": true}),
)
.await
.unwrap_err();
assert!(err.contains("no instance named"), "{err}");
assert!(store.instance("rhema").unwrap().unwrap().session.is_some());
// The accepted run clears before enqueueing.
call(
&resolver,
json!({"action": "run", "instance": "rhema", "prompt": "x", "fresh": true}),
)
.await
.unwrap();
assert!(store.instance("rhema").unwrap().unwrap().session.is_none());
}
}
+477
View File
@@ -0,0 +1,477 @@
//! The coding jobs path: the source that ingests queued rows, and the
//! handler that turns one into a coding-agent turn off the chat.
//!
//! **The key:** `job:<instance>#<id>` — per job, like a forge thread is
//! per thread, so the dispatcher's dedup and lease machinery applies
//! per job while the *conversation* carries in the instance row's
//! session blob (`crate::job_store`), keyed by instance. A run the
//! restart cut is failed interrupted by the store's recover pass; its
//! next `run` resumes from the last saved session.
//!
//! **The handler** is the forge handler's sibling with a different
//! driver: the deterministic part stays ahead of the model — the
//! instance and job resolve through the store, the workspace is the
//! instance's path, the session blob (or a fresh agent) is loaded, the
//! allowlist is permissive exactly as the forge path's — and then one
//! `CodingAgent` turn runs. The final text lands in the job row and
//! the user's notifications; the turn itself streams nowhere, its
//! observability is the stderr tool log.
//!
//! The permission check is the forge handler's own argument: this
//! handler's one tool is the agent's shell, so a policy without it
//! fails before any model call. The trust boundary is unchanged from
//! the forge path — the bearer-gated surface and the instance list.
use std::sync::Arc;
use rhema_autonomous_agent::{Handler, SessionContext, Source, SourceError, async_trait};
use rhema_coding_agent::{AllowAll, CodingAgent, CodingAgentConfig};
use rhema_core::{Event as ModelEvent, EventSink};
use rhema_rsd::{Dispatcher, Event, KeyFn, Outcome, ResourceKey};
use crate::backends::BackendRegistry;
use crate::handler::final_text;
use crate::interactive::passthrough_key_fn;
use crate::job_store::{CodingJobStore, JobStatus};
use crate::notifications::{DEFAULT_DEVICE, NotificationStore};
/// The source name the coding jobs ingest under.
pub const JOBS_SOURCE: &str = "jobs";
/// One job's dispatch key: `job:<instance>#<id>`.
pub fn job_key(instance: &str, id: i64) -> String {
format!("job:{instance}#{id}")
}
/// Whether `key` belongs to the coding-jobs context — the check the
/// drivers' ownership split keys on, alongside the resident prefixes
/// and the forge keys.
pub fn is_job_key(key: &str) -> bool {
key.starts_with("job:")
}
/// Parses the dispatch key back into instance and job id.
pub fn parse_job_key(key: &ResourceKey) -> Option<(String, i64)> {
let raw = key.as_str().strip_prefix("job:")?;
let (instance, id) = raw.rsplit_once('#')?;
if instance.is_empty() || id.is_empty() {
return None;
}
Some((instance.to_owned(), id.parse().ok()?))
}
/// The detectable stream of queued coding jobs: the job table feeding
/// the jobs dispatcher — the task scheduler's shape, one source,
/// registered once, before the first detect.
pub struct JobsSource {
store: Arc<CodingJobStore>,
dispatcher: Arc<Dispatcher>,
key_fn: Arc<KeyFn>,
}
impl JobsSource {
/// Registers the `jobs` source's key function on `dispatcher` and
/// returns the source feeding it.
pub fn new(dispatcher: &Arc<Dispatcher>, store: Arc<CodingJobStore>) -> Self {
let key_fn = passthrough_key_fn();
dispatcher.register_source(JOBS_SOURCE, key_fn.clone());
Self {
store,
dispatcher: dispatcher.clone(),
key_fn,
}
}
/// The event one queued row becomes. The dedup id carries the
/// row's `updated_at_millis`, so a row the recover pass re-queued
/// after a restart ingests fresh instead of drowning in the
/// previous process's consumed set.
fn event(job: &crate::job_store::JobRecord, now: u64) -> Event {
Event {
id: format!("job-{}@{}", job.id, job.updated_at_millis),
source: JOBS_SOURCE.to_owned(),
payload: json_key(&job.instance, job.id),
received_at: now,
}
}
}
fn json_key(instance: &str, id: i64) -> serde_json::Value {
serde_json::json!({ "key": job_key(instance, id) })
}
#[async_trait]
impl Source for JobsSource {
fn name(&self) -> &str {
JOBS_SOURCE
}
fn key_fn(&self) -> Arc<KeyFn> {
self.key_fn.clone()
}
async fn detect(&self) -> Result<usize, SourceError> {
let queued = self
.store
.queued()
.map_err(|err| SourceError::Backend(format!("job store: {err}")))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let mut fed = 0;
for job in queued {
self.dispatcher
.ingest(Self::event(&job, now))
.map_err(|err| SourceError::Backend(format!("job ingest: {err}")))?;
fed += 1;
}
Ok(fed)
}
}
/// The coding-agent jobs handler: one dispatch of `job:<instance>#<id>`
/// is one coding-agent turn, and the turn's final text is the job's
/// result and the user's notification.
pub struct CodingJobHandler {
backends: Arc<BackendRegistry>,
store: Arc<CodingJobStore>,
notifications: Arc<NotificationStore>,
max_turn_steps: u32,
}
impl CodingJobHandler {
/// A handler whose turns run in the instance's workspace, read per
/// dispatch, with the deployment's active provider pair and the
/// resident turn's step budget.
pub fn new(
backends: Arc<BackendRegistry>,
store: Arc<CodingJobStore>,
notifications: Arc<NotificationStore>,
max_turn_steps: u32,
) -> Self {
Self {
backends,
store,
notifications,
max_turn_steps,
}
}
/// One dispatch, failed with a reason on any step; every failure
/// lands in the job row before it lands in the outcome, so the
/// model's `status` read and the user's notification agree.
async fn dispatch(&self, ctx: &mut SessionContext) -> Result<Outcome, String> {
let (instance_name, job_id) = parse_job_key(&ctx.key)
.ok_or_else(|| format!("resource key {} is not a job key", ctx.key))?;
let job = self
.store
.job(job_id)
.map_err(|err| format!("job lookup for {job_id} failed: {err}"))?
.ok_or_else(|| format!("job {job_id} is not in the table"))?;
if job.status != JobStatus::Queued {
// A re-delivered event over a row already running or done:
// the dedup boundary is the dispatcher's; this handler
// treats the row as the truth.
return Ok(Outcome::Handled);
}
let instance = self
.store
.instance(&instance_name)
.map_err(|err| format!("instance lookup for {instance_name} failed: {err}"))?
.ok_or_else(|| format!("instance {instance_name:?} is gone; the job cannot run"))?;
let workspace = std::path::PathBuf::from(&instance.path);
if !workspace.is_dir() {
let reason = format!(
"the workspace {workspace:?} does not exist; add the instance again with a live path"
);
self.fail(job_id, &instance_name, &reason);
return Ok(Outcome::Failed(reason));
}
self.store
.mark_running(job_id)
.map_err(|err| format!("job {job_id} could not start: {err}"))?;
let (provider, model) = self.backends.current();
let mut config = CodingAgentConfig::new(model, &workspace);
config.max_turn_steps = self.max_turn_steps;
let stashed = self
.store
.session(&instance_name)
.map_err(|err| format!("the session lookup for {instance_name:?} failed: {err}"))?;
let mut agent = match stashed {
Some(blob) => {
let session: rhema_core::Session = serde_json::from_str(&blob).map_err(|err| {
let reason = format!(
"the stashed coding session for {instance_name:?} does not parse: {err}"
);
self.fail(job_id, &instance_name, &reason);
reason
})?;
CodingAgent::from_session(provider, config, session)
}
None => CodingAgent::new(provider, config),
}
.map_err(|err| {
let reason = format!("cannot start the coding agent for {instance_name:?}: {err}");
self.fail(job_id, &instance_name, &reason);
reason
})?;
// Commit-before-execute rides the dispatcher exactly as the
// forge handler's does: the intent is in the store before the
// first tool runs, and a crash-left dangling call revives at
// turn entry (CodingAgent::turn's own revive, over the resumed
// session).
agent.set_commit_hook(Some(ctx.commit_hook()));
let mut sink = JobSink;
let output = match agent.turn(&job.prompt, &mut sink, Arc::new(AllowAll)).await {
Ok(output) => output,
Err(err) => {
let reason = format!("the coding turn for {instance_name:?} failed: {err}");
// The conversation up to the failure is still the
// instance's: saved, so a follow-up run continues it.
self.save_session(&instance_name, &agent);
self.fail(job_id, &instance_name, &reason);
return Ok(Outcome::Failed(reason));
}
};
self.save_session(&instance_name, &agent);
let reply = final_text(&output.message).ok_or_else(|| {
let reason = format!("the model returned no text to record for job {job_id}");
self.fail(job_id, &instance_name, &reason);
reason
})?;
self.store
.finish(job_id, &reply)
.map_err(|err| format!("job {job_id} could not be finished: {err}"))?;
// The implicit channel, the task keys' rule: the reply reaches
// the user away from this chat; `status` is the model's way to
// read it again.
self.notify(&instance_name, &reply);
Ok(Outcome::Handled)
}
fn save_session(&self, instance: &str, agent: &CodingAgent) {
match serde_json::to_string(agent.session()) {
Ok(blob) => {
let _ = self.store.save_session(instance, &blob);
}
Err(err) => {
eprintln!(
"[claw] jobs: the coding session for {instance:?} does not serialize: {err}"
);
}
}
}
fn fail(&self, job_id: i64, instance: &str, reason: &str) {
if let Err(err) = self.store.fail(job_id, reason) {
eprintln!("[claw] jobs: job {job_id} could not be failed: {err}");
}
self.notify(instance, &format!("coding job failed: {reason}"));
}
fn notify(&self, instance: &str, body: &str) {
if let Err(err) = self.notifications.push(DEFAULT_DEVICE, instance, body) {
eprintln!("[claw] jobs: the notification for {instance:?} failed: {err}");
}
}
}
#[async_trait]
impl Handler for CodingJobHandler {
async fn handle(&self, ctx: &mut SessionContext) -> Outcome {
match self.dispatch(ctx).await {
Ok(outcome) => outcome,
Err(reason) => Outcome::Failed(reason),
}
}
}
/// The job's stderr log: tool traffic only. Nothing streams to a chat
/// surface — the result is the row and the notification.
struct JobSink;
impl EventSink for JobSink {
fn emit(&mut self, event: ModelEvent<'_>) {
match event {
ModelEvent::ToolCallStart { name, .. } => eprintln!("[claw] job tool call: {name}"),
ModelEvent::ToolResult {
content, is_error, ..
} => {
let mark = if is_error { "failed" } else { "ok" };
let line = content.lines().next().unwrap_or("");
let mut cut = line.chars().take(72).collect::<String>();
if line.chars().count() > 72 {
cut.push('…');
}
eprintln!("[claw] job tool {mark}: {cut}");
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backends::Backend;
use rhema_autonomous_agent::Driver;
use rhema_core::{Message as CoreMessage, MockProvider, ModelOutput, Role, StopReason, Usage};
use rhema_rsd::{DispatcherConfig, SqliteStore, SystemClock};
/// A registry whose only backend is the scripted mock.
fn mock_handler(
replies: Vec<ModelOutput>,
store: Arc<CodingJobStore>,
notifications: Arc<NotificationStore>,
) -> CodingJobHandler {
let provider: Arc<dyn rhema_core::ModelProvider> = Arc::new(MockProvider::new(replies));
let backends = Arc::new(
BackendRegistry::new(vec![Backend::headless("mock", provider)], None)
.expect("a single mock backend always constructs"),
);
CodingJobHandler::new(backends, store, notifications, 16)
}
fn reply(text: &str) -> ModelOutput {
ModelOutput {
message: CoreMessage::text(Role::Assistant, text),
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
}
}
fn tempdir() -> std::path::PathBuf {
let dir = tempfile::tempdir().unwrap().keep();
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[tokio::test]
async fn a_queued_job_runs_to_done_and_notifies() {
let workspace = tempdir();
let store = Arc::new(CodingJobStore::in_memory().unwrap());
store
.add_instance("rhema", workspace.to_str().unwrap())
.unwrap();
let job = store.enqueue("rhema", "fix the lint").unwrap();
let notifications = Arc::new(NotificationStore::in_memory().unwrap());
let handler = mock_handler(
vec![reply("lint fixed, all green")],
store.clone(),
notifications.clone(),
);
// The whole path: source detects the queued row, the driver
// runs the handler over its own dispatcher.
let (source, driver) = harness(store.clone(), handler);
assert_eq!(source.detect().await.unwrap(), 1);
let outcomes = driver.run_once().await.unwrap();
assert_eq!(outcomes.len(), 1);
assert!(matches!(outcomes[0], Outcome::Handled));
let done = store.job(job.id).unwrap().unwrap();
assert_eq!(done.status, JobStatus::Done);
assert_eq!(done.result.as_deref(), Some("lint fixed, all green"));
let instance = store.instance("rhema").unwrap().unwrap();
assert!(instance.session.is_some(), "the session is stashed");
let pending = notifications.pending(DEFAULT_DEVICE, 0).unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].body, "lint fixed, all green");
// The source finds nothing more to feed.
assert_eq!(source.detect().await.unwrap(), 0);
}
#[tokio::test]
async fn the_second_job_resumes_the_first_session() {
let workspace = tempdir();
let store = Arc::new(CodingJobStore::in_memory().unwrap());
store
.add_instance("rhema", workspace.to_str().unwrap())
.unwrap();
let notifications = Arc::new(NotificationStore::in_memory().unwrap());
let handler = mock_handler(
vec![reply("first done"), reply("second done, history intact")],
store.clone(),
notifications.clone(),
);
let first = store.enqueue("rhema", "step one").unwrap();
let second = store.enqueue("rhema", "step two").unwrap_err();
assert!(
second.to_string().contains("job in flight"),
"one live job per instance: {second}"
);
let (source, driver) = harness(store.clone(), handler);
assert_eq!(source.detect().await.unwrap(), 1);
driver.run_once().await.unwrap();
assert_eq!(
store.job(first.id).unwrap().unwrap().status,
JobStatus::Done
);
// With the first job done, the follow-up enqueues and runs on
// the resumed session: the mock's second reply lands, and the
// stash the second turn loaded carried the first turn's rounds.
let follow = store.enqueue("rhema", "step two").unwrap();
assert_eq!(source.detect().await.unwrap(), 1);
driver.run_once().await.unwrap();
let done = store.job(follow.id).unwrap().unwrap();
assert_eq!(done.result.as_deref(), Some("second done, history intact"));
let session = store.instance("rhema").unwrap().unwrap().session.unwrap();
let session: rhema_core::Session = serde_json::from_str(&session).unwrap();
// Two turns: two user inputs, two assistant replies.
assert!(session.history.len() >= 4, "{:?}", session.history.len());
}
#[tokio::test]
async fn a_missing_workspace_fails_the_job_in_band() {
let store = Arc::new(CodingJobStore::in_memory().unwrap());
store.add_instance("gone", "/nowhere-at-all").unwrap();
let job = store.enqueue("gone", "work").unwrap();
let notifications = Arc::new(NotificationStore::in_memory().unwrap());
let handler = mock_handler(
vec![reply("never reached")],
store.clone(),
notifications.clone(),
);
let (source, driver) = harness(store.clone(), handler);
assert_eq!(source.detect().await.unwrap(), 1);
let outcomes = driver.run_once().await.unwrap();
assert!(
matches!(&outcomes[0], Outcome::Failed(reason) if reason.contains("does not exist")),
"{outcomes:?}"
);
assert_eq!(
store.job(job.id).unwrap().unwrap().status,
JobStatus::Failed
);
let pending = notifications.pending(DEFAULT_DEVICE, 0).unwrap();
assert!(pending[0].body.contains("coding job failed"), "{pending:?}");
}
/// The source and driver over one in-memory dispatcher, the shape
/// the daemon wires and these tests drive.
fn harness(store: Arc<CodingJobStore>, handler: CodingJobHandler) -> (JobsSource, Driver) {
let rsd_store = Arc::new(SqliteStore::open(tempdir().join("rsd.db")).unwrap());
// A zero debounce: the queued row is admitted on this pass,
// the shape the daemon's jobs dispatcher runs under.
let dispatcher = Arc::new(Dispatcher::new(
DispatcherConfig {
debounce_window_millis: 0,
..DispatcherConfig::default()
},
Arc::new(SystemClock),
rsd_store,
));
let source = JobsSource::new(&dispatcher, store);
let driver = Driver::new(dispatcher, Arc::new(handler))
.with_holder("test-jobs")
.owning(Arc::new(|key: &ResourceKey| is_job_key(key.as_str())));
(source, driver)
}
}
+119
View File
@@ -0,0 +1,119 @@
//! `rhema-claw` — the resident agent daemon: forge watches plus the chat
//! HTTP surface, the first consumer of `rhema-autonomous-agent`.
//!
//! The spec invariants this crate exists to keep (specs/
//! rhema-framework-v1.md): detection is deterministic code, never the
//! model (§1) — every timer here drives a detect pass and an admission
//! step, and the model is reached only inside a handler run; there is no
//! periodic, unconditional model-invoking timer (§7) — a scheduled task
//! is an event the [`TaskScheduler`] detects, not a heartbeat; and the
//! open interactive surface never resolves keys (§6.2) — one resident
//! key is one owned session, forever. Two dispatchers share one SQLite
//! store: the forge dispatcher turns watch activity into [`ClawHandler`]
//! runs (one turn per dispatch, the final text posted back as the
//! thread's comment); the resident dispatcher — keys prefixed
//! `interactive:`, `task:`, `phone:`, or `calendar:`, the contract
//! [`interactive`] documents — turns chat messages, `/v1/ingest`
//! events, due tasks, and due calendar reminders into
//! [`InteractiveHandler`] runs (one turn per dispatch, streamed to a
//! chat subscriber or auto-notified to a device).
//!
//! Layout:
//! - [`config`] — the YAML config: the watch seed, paths, HTTP surface,
//! provider and model
//! - [`bootstrap`] — the SOUL/USER/ENVIRONMENT store, the system prompt
//! rendered from it per dispatch with the dispatch's tool roster, and
//! the `bootstrap_save` tool
//! - [`watch_store`] — the persistent watch table (SQLite, shared file)
//! - [`notifications`] — the notification table: the outbound queue the
//! devices poll and ack
//! - [`task_store`] — the task table: scheduled prompts, inert until the
//! scheduler detects them due
//! - [`turn_store`] — the turn-history table: what each key's stream
//! streamed, appended per event, replayed whole after a restart
//! - [`hub`] — the event hub: per-key record and broadcast of turn events
//! - [`identity`] — the agent's presentation identity: name, mark,
//! images, served over `/v1/agent` and settable by the agent itself
//! through the `identity_set` tool
//! - [`consents`] — the consent table and wake bridge: a running turn
//! asks the human (a secret to store, permission to act) and waits;
//! the chat surface answers through the consent routes
//! - [`attachments`] — the attachment store and the `attach_file` tool:
//! workspace files handed to the human as fetchable cards
//! - [`phone`] — the phone state and command queue: reported status to
//! read, consent-gated control actions the device executes
//! - [`watch_tools`] — the agent's watch-management tool set
//! - [`notify_tools`] — the `notify` tool: the queue a device polls
//! - [`task_tools`] — `schedule_task`, `list_tasks`, `cancel_task`
//! - [`forge`] — the sources: one detectable stream per forge kind
//! - [`calendar`] — the [`CalendarSource`]: due calendar reminders in,
//! events out
//! - [`task_source`] — the [`TaskScheduler`]: due tasks in, events out
//! - [`handler`] — the forge dispatch: snapshot, inbox, one turn, one
//! comment
//! - [`interactive`] — the resident dispatch: the key-prefix contract,
//! inbox, one turn, one streamed reply, auto-notify
//! - [`http`] — the routes: chat, sessions, watches, ingest,
//! notifications, the event stream
//! - [`openai`] — the `/v1/chat/completions` shim for stock web UIs
//! - [`daemon`] — the assembly and the loop (`run` with HTTP, and the
//! headless `--once` shape [`run_once`])
#![forbid(unsafe_code)]
#![deny(missing_docs)]
pub mod accounts_tools;
pub mod attachments;
pub mod backends;
pub mod bootstrap;
pub mod calendar;
pub mod config;
pub mod consents;
pub mod daemon;
pub mod environment;
pub mod forge;
pub mod handler;
pub mod http;
pub mod hub;
mod identity;
pub mod interactive;
pub mod job_store;
pub mod job_tools;
pub mod jobs;
#[cfg(feature = "mock")]
pub mod mock;
pub mod notifications;
pub mod notify_tools;
pub mod openai;
pub mod phone;
pub mod settings;
pub mod task_source;
pub mod task_store;
pub mod task_tools;
pub mod triage;
pub mod turn_store;
pub mod watch_store;
pub mod watch_tools;
pub use backends::{Backend, BackendRegistry};
pub use bootstrap::{
BOOTSTRAP_SAVE, Bootstrap, BootstrapError, BootstrapKind, BootstrapStatus, DEFAULT_SYSTEM_MD,
render_system,
};
pub use calendar::CalendarSource;
pub use config::{ClawConfig, ConfigError, ForgeKind, Watch};
pub use daemon::{ClawError, Daemon, TickReport, run_once};
pub use environment::write_report;
pub use forge::ForgeSource;
pub use handler::ClawHandler;
pub use hub::{EventHub, TurnEvent};
pub use identity::{AgentIdentity, IdentityError, IdentityStore, ImageKind};
pub use interactive::InteractiveHandler;
#[cfg(feature = "mock")]
pub use mock::MockLoop;
pub use notifications::{NotificationRecord, NotificationStore, NotificationStoreError};
pub use settings::SettingsStore;
pub use task_source::{TASKS_SOURCE, TaskScheduler};
pub use task_store::{TaskKind, TaskRecord, TaskStore, TaskStoreError};
pub use turn_store::{TurnStore, TurnStoreError};
pub use watch_store::{ClawStore, WatchRecord, WatchStoreError};
+186
View File
@@ -0,0 +1,186 @@
//! Binary wiring only: parse the two args, resolve the model backend from
//! the stored rhema credentials, run the daemon. The loop and its
//! assembly live in `rhema_claw::daemon`.
#![forbid(unsafe_code)]
use std::path::PathBuf;
use std::sync::Arc;
use clap::Parser;
use rhema_claw::{ClawConfig, ClawError, Daemon, run_once};
use rhema_rsd::{DispatcherConfig, Outcome};
/// `rhema-claw` — the resident forge-watching agent daemon.
#[derive(Debug, Parser)]
#[command(
name = "rhema-claw",
version,
about = "Resident forge-watching agent daemon"
)]
struct Args {
/// Path to the claw config. Defaults to ~/.config/rhema/claw.yaml.
#[arg(long)]
config: Option<PathBuf>,
/// Run one detect pass and one driver pass, then exit — the
/// cron-friendly shape for ephemeral machines.
#[arg(long)]
once: bool,
/// Answer every turn with canned streamed text: the zero-credential
/// debug backend. Requires the `mock` feature.
#[arg(long)]
mock: bool,
}
#[tokio::main]
async fn main() {
if let Err(err) = run(Args::parse()).await {
eprintln!("error: {err}");
let mut source: &dyn std::error::Error = &err;
while let Some(cause) = source.source() {
eprintln!(" caused by: {cause}");
source = cause;
}
std::process::exit(1);
}
}
async fn run(args: Args) -> Result<(), ClawError> {
let path = match args.config {
Some(path) => path,
None => ClawConfig::default_path()?,
};
let config = ClawConfig::load_from(&path)?;
let backends = if args.mock {
mock_backends()
} else {
resolve_backends(&config)?
};
eprintln!(
"[claw] {} seeded watch(es), active {}/{}, db {}, http {}",
config.watches.len(),
backends.active().0,
backends.active().1,
config
.db_path()
.map(|db| db.display().to_string())
.unwrap_or_default(),
config.listen_addr(),
);
if args.once {
// The approval posture is deliberate (spec §8): say it once at
// startup so the operator reads it next to the config that
// turns it on, not in a README three weeks in.
if !config.watches.is_empty() {
eprintln!("[claw] note: forge dispatches run tool calls without approval prompts");
}
let report = run_once(&config, backends.clone()).await?;
// A cron pass reports its failures through the exit code.
if report
.outcomes
.iter()
.any(|outcome| matches!(outcome, Outcome::Failed(_)))
{
std::process::exit(1);
}
Ok(())
} else {
// The resident daemon always serves HTTP: the chat surface is
// how it is talked to. Only `--once` runs headless. The
// surface's posture is checked before anything binds: a wide
// listen with no token never comes up. The approval posture is
// deliberate (spec §8): say it once at startup so the operator
// reads it next to the config that turns it on.
config.validate_surface()?;
eprintln!("[claw] note: resident sessions run tool calls without approval prompts");
let daemon = Daemon::build(&config, backends, DispatcherConfig::default())?;
daemon.run().await
}
}
/// The `--mock` backend: an endless canned provider behind the `mock`
/// feature; without it the flag is a loud startup failure naming the fix.
#[cfg(feature = "mock")]
fn mock_backends() -> Arc<rhema_claw::BackendRegistry> {
eprintln!("[claw] mock backend: every turn answers with canned streamed text");
Arc::new(
rhema_claw::BackendRegistry::new(
vec![rhema_claw::Backend::headless(
"mock",
rhema_claw::mock::MockLoop::provider(),
)],
None,
)
.expect("a single mock backend always constructs"),
)
}
#[cfg(not(feature = "mock"))]
fn mock_backends() -> Arc<rhema_claw::BackendRegistry> {
eprintln!("error: --mock needs a mock build: cargo build -p rhema-claw --features mock");
std::process::exit(1);
}
/// The deployment's backends: every provider whose credentials resolve
/// (env wins, the stored rhema config serves), with the config's
/// provider/model pair as the active seed. A daemon always boots —
/// the keyless llama-cpp backend is always constructible — and a
/// dispatch against an unreachable provider fails loudly per turn.
fn resolve_backends(config: &ClawConfig) -> Result<Arc<rhema_claw::BackendRegistry>, ClawError> {
let stored = match rhema_config::Config::load() {
Ok(stored) => stored,
Err(err) => {
eprintln!("warning: ignoring unreadable rhema config: {err}");
rhema_config::Config::default()
}
};
let profile = stored.effective(None);
// A second connection to the shared database, read-only in spirit:
// discovery reads the credentials the HTTP surface persisted. An
// unopenable store boots the daemon without them rather than not at
// all — the same grace the rhema config gets above.
let claw_creds = rhema_claw::SettingsStore::open(config.db_path()?).ok();
let claw_rows = move || {
claw_creds
.as_ref()
.and_then(|store| {
store
.get_prefix(rhema_claw::backends::PROVIDER_KEY_PREFIX)
.ok()
})
.unwrap_or_default()
.into_iter()
.filter_map(|(key, json)| {
let id = key.strip_prefix(rhema_claw::backends::PROVIDER_KEY_PREFIX)?;
serde_json::from_str(&json)
.ok()
.map(|params| (id.to_owned(), params))
})
.collect::<Vec<_>>()
};
let env = |name: &str| std::env::var(name).ok();
// The daemon only reads the stored rhema config, keychain tier
// included; it never writes it, so no migration runs here — the
// file's logins belong to the frontends. In a container the
// keychain is unreachable and the literal tier serves.
let secrets = rhema_config::secrets::KeyringSecretStore::new();
let backends =
rhema_claw::backends::discover(config, &profile, &env, &claw_rows, Some(&secrets));
// The config's provider/model pair seeds the active backend when it
// names one. When it does not, the seed is the first credential
// discovery actually found — never a hardcoded default kind, and
// never the keyless llama.cpp fallback; with nothing keyed at all,
// the registry serves the keyless server and the UI's
// add-a-provider state is the honest answer.
let seed = config
.provider
.as_deref()
.map(|kind| (kind.to_owned(), config.model.clone()))
.or_else(|| rhema_claw::backends::credential_seed(&backends));
Ok(Arc::new(
rhema_claw::BackendRegistry::new(backends, seed).map_err(ClawError::Backend)?,
))
}
+73
View File
@@ -0,0 +1,73 @@
//! The debug provider (`mock` feature): an endless scripted stand-in so
//! the daemon and a UI can run with zero credentials.
//!
//! [`MockProvider`](rhema_core::MockProvider) replays a fixed script and
//! errors once it runs dry — right for tests, wrong for a daemon a
//! developer chats with all afternoon. This one never dries up: every
//! turn answers with a numbered canned reply, streamed as deltas so a UI
//! exercises the same rendering path a real turn takes.
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use rhema_core::{
Content, Event, EventSink, Message, ModelOutput, ModelProvider, ModelRequest, ProviderError,
Role, StopReason, Usage, async_trait,
};
/// Answers every request with a canned, streamed reply.
#[derive(Default)]
pub struct MockLoop {
turns: AtomicU64,
}
impl MockLoop {
/// The provider, shared as the daemon's backend seam. (Named for what
/// it returns: a `new` returning `Arc<dyn ModelProvider>` would lie
/// about constructing `Self`.)
pub fn provider() -> Arc<dyn ModelProvider> {
Arc::new(Self::default())
}
}
#[async_trait]
impl ModelProvider for MockLoop {
async fn complete(
&self,
_req: ModelRequest,
sink: &mut dyn EventSink,
) -> Result<ModelOutput, ProviderError> {
let turn = self.turns.fetch_add(1, Ordering::Relaxed) + 1;
let text = format!(
"Mock claw, turn {turn}. No model is attached - every reply \
is this canned text, streamed in deltas like the real thing."
);
// Three char-safe deltas: the UI's streaming path gets exercised,
// not just the single-frame one.
let chars: Vec<&str> = {
let total = text.chars().count();
let boundary = |n: usize| -> usize {
text.char_indices()
.nth(n)
.map(|(i, _)| i)
.unwrap_or(text.len())
};
vec![
&text[..boundary(total / 3)],
&text[boundary(total / 3)..boundary(2 * total / 3)],
&text[boundary(2 * total / 3)..],
]
};
for piece in chars {
sink.emit(Event::TextDelta(piece));
}
Ok(ModelOutput {
message: Message {
role: Role::Assistant,
content: vec![Content::Text(text)],
},
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
})
}
}
+265
View File
@@ -0,0 +1,265 @@
//! The notification table: outbound claw-to-device messages in SQLite,
//! the same database file the dispatcher's
//! [`SqliteStore`](rhema_rsd::SqliteStore) and the watch table use.
//!
//! Notifications are how the resident reaches a device that is not the
//! live chat surface: the `notify` agent tool enqueues, a task or phone
//! turn's reply auto-notifies, and the device polls
//! `GET /v1/notifications` and acks what it delivered. Delivery is
//! at-least-once until the ack — an unacked row keeps answering the
//! poll, a crash between delivery and ack re-delivers.
//!
//! Two connections, one file: this store opens its own rusqlite
//! connection to the same database as the dispatcher's store — WAL and a
//! busy timeout, the pattern [`ClawStore`](crate::watch_store::ClawStore)
//! established — and the two interleave through SQLite's own locking.
//! Writes are single INSERT / UPDATE statements, so there is no
//! read-modify-write window to guard.
use std::path::Path;
use std::sync::{Mutex, PoisonError};
use std::time::{SystemTime, UNIX_EPOCH};
use rusqlite::params;
use serde::Serialize;
/// The device a notification reaches when its producer names none: the
/// tool's default, the auto-notify rule's fallback, and the poll
/// route's default — one phone, one user (spec §7).
pub const DEFAULT_DEVICE: &str = "phone";
/// The notification table.
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY,
device TEXT NOT NULL DEFAULT 'phone',
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL,
delivered_at INTEGER NULL
);
";
/// Why a notification-table operation failed.
#[derive(Debug, thiserror::Error)]
pub enum NotificationStoreError {
/// The SQLite backend failed.
#[error("notification store backend: {0}")]
Backend(#[from] rusqlite::Error),
}
/// One outbound notification, as stored. Serializes whole: nothing here
/// is secret — this is the shape the poll route serves.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct NotificationRecord {
/// The row id; the ack route addresses it, `since` filters by it.
pub id: i64,
/// The device it is bound for (`phone` unless told otherwise).
pub device: String,
/// The one-line headline.
pub title: String,
/// The message body.
pub body: String,
/// Creation time, milliseconds since the Unix epoch.
pub created_at: i64,
/// When the device acked delivery; `None` while outstanding.
pub delivered_at: Option<i64>,
}
/// The persistent notification table over its own connection to the
/// daemon's SQLite file. Shareable behind an `Arc`; every operation
/// takes `&self`.
pub struct NotificationStore {
conn: Mutex<rusqlite::Connection>,
}
impl NotificationStore {
/// Opens (creating if needed) the notification table in the database
/// at `path` — the same file the dispatcher's store holds. WAL keeps
/// the connections from blocking each other's readers; the busy
/// timeout rides out the other's write locks.
pub fn open(path: impl AsRef<Path>) -> Result<Self, NotificationStoreError> {
let conn = rusqlite::Connection::open(path).map_err(NotificationStoreError::from)?;
Self::init(conn, true)
}
/// Opens a private in-memory table: the shape without the file, for
/// tests that need no sharing.
pub fn in_memory() -> Result<Self, NotificationStoreError> {
let conn = rusqlite::Connection::open_in_memory().map_err(NotificationStoreError::from)?;
Self::init(conn, false)
}
fn init(conn: rusqlite::Connection, durable: bool) -> Result<Self, NotificationStoreError> {
if durable {
// Same pragmas as the dispatcher's store on the same file:
// SQLite stores the journal mode per database, and both
// connections must agree that it is WAL.
let _mode: String = conn
.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
.map_err(NotificationStoreError::from)?;
conn.busy_timeout(std::time::Duration::from_secs(5))
.map_err(NotificationStoreError::from)?;
}
conn.execute_batch(SCHEMA)
.map_err(NotificationStoreError::from)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
/// Enqueues one notification and returns it with its row id. The
/// row is outstanding (`delivered_at` unset) until
/// [`Self::ack`].
pub fn push(
&self,
device: &str,
title: &str,
body: &str,
) -> Result<NotificationRecord, NotificationStoreError> {
let created_at = now_millis();
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
conn.execute(
"INSERT INTO notifications (device, title, body, created_at)
VALUES (?1, ?2, ?3, ?4)",
params![device, title, body, created_at],
)?;
Ok(NotificationRecord {
id: conn.last_insert_rowid(),
device: device.to_owned(),
title: title.to_owned(),
body: body.to_owned(),
created_at,
delivered_at: None,
})
}
/// The device's outstanding notifications with an id over `since`,
/// oldest first — exactly what a device that acks what it delivers
/// polls for. Delivered rows stop answering the poll; an unacked row
/// keeps coming back (at-least-once until the ack).
pub fn pending(
&self,
device: &str,
since: i64,
) -> Result<Vec<NotificationRecord>, NotificationStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let mut stmt = conn
.prepare(
"SELECT id, device, title, body, created_at, delivered_at
FROM notifications
WHERE device = ?1 AND delivered_at IS NULL AND id > ?2
ORDER BY id ASC",
)
.map_err(NotificationStoreError::from)?;
let rows = stmt
.query_map(params![device, since], |row| {
Ok(NotificationRecord {
id: row.get(0)?,
device: row.get(1)?,
title: row.get(2)?,
body: row.get(3)?,
created_at: row.get(4)?,
delivered_at: row.get(5)?,
})
})
.map_err(NotificationStoreError::from)?
.collect::<Result<Vec<_>, rusqlite::Error>>()
.map_err(NotificationStoreError::from)?;
Ok(rows)
}
/// Marks the notification delivered. Idempotent: `false` when the id
/// is unknown or already acked, never an error.
pub fn ack(&self, id: i64) -> Result<bool, NotificationStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let acked = conn.execute(
"UPDATE notifications SET delivered_at = ?2
WHERE id = ?1 AND delivered_at IS NULL",
params![id, now_millis()],
)?;
Ok(acked == 1)
}
}
/// Milliseconds since the Unix epoch; stamps `created_at` and
/// `delivered_at`.
fn now_millis() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_notification_polls_until_acked() {
let store = NotificationStore::in_memory().expect("store opens");
let first = store
.push(DEFAULT_DEVICE, "claw", "first")
.expect("push lands");
let second = store
.push(DEFAULT_DEVICE, "claw", "second")
.expect("push lands");
assert!(first.id < second.id, "ids ascend");
assert_eq!(first.delivered_at, None, "outstanding until acked");
// The poll answers outstanding rows oldest first.
let pending = store.pending(DEFAULT_DEVICE, 0).expect("poll reads");
assert_eq!(
pending
.iter()
.map(|record| record.body.as_str())
.collect::<Vec<_>>(),
["first", "second"],
);
// `since` skips what the device already saw.
assert_eq!(
store
.pending(DEFAULT_DEVICE, first.id)
.expect("poll reads")
.len(),
1,
);
// Acking delivers; the delivered row stops answering, and a
// re-ack is a quiet false, not an error.
assert!(store.ack(first.id).expect("ack lands"));
let pending = store.pending(DEFAULT_DEVICE, 0).expect("poll reads");
assert_eq!(
pending.iter().map(|record| record.id).collect::<Vec<_>>(),
[second.id],
);
assert!(!store.ack(first.id).expect("re-ack is quiet"));
assert!(!store.ack(10_000).expect("unknown id is quiet"));
}
#[test]
fn devices_do_not_hear_each_other() {
let store = NotificationStore::in_memory().expect("store opens");
store.push(DEFAULT_DEVICE, "claw", "phone").expect("push");
store.push("desk", "claw", "desk").expect("push");
let phones = store.pending(DEFAULT_DEVICE, 0).expect("poll reads");
assert_eq!(phones.len(), 1);
assert_eq!(phones[0].device, DEFAULT_DEVICE);
assert_eq!(phones[0].body, "phone");
assert_eq!(store.pending("desk", 0).expect("poll reads").len(), 1);
assert_eq!(store.pending("watch", 0).expect("poll reads").len(), 0);
}
#[test]
fn two_connections_share_one_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("dispatch.db");
let writer = NotificationStore::open(&path).expect("first connection");
let reader = NotificationStore::open(&path).expect("second connection");
writer.push(DEFAULT_DEVICE, "claw", "hello").expect("push");
let pending = reader.pending(DEFAULT_DEVICE, 0).expect("poll reads");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].body, "hello");
}
}
+180
View File
@@ -0,0 +1,180 @@
//! The agent-side notify tool: how the resident reaches a device that is
//! not the live chat surface — "remind me when CI lands" becomes a tool
//! call, the row lands in the [`NotificationStore`], and the device's
//! next poll carries it. No restart, no second mechanism.
//!
//! This is a [`Tool`] implementation for a [`ToolRegistry`], so the
//! interactive handler offers it through the same allowlist-filtered
//! seam as everything else the model may call (spec §8). Answers are
//! short JSON; failures are in-band strings the model can react to.
use std::sync::Arc;
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
use rhema_core::ToolSpec;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::notifications::{DEFAULT_DEVICE, NotificationStore};
/// The notification tool set over `store`: `notify`.
pub fn registry(store: Arc<NotificationStore>) -> ToolRegistry {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(Notify { store }));
registry
}
#[derive(Deserialize)]
struct NotifyArgs {
title: String,
body: String,
#[serde(default)]
device: Option<String>,
}
/// Sends one notification: one row in the table, delivered at the
/// device's next poll-and-ack.
struct Notify {
store: Arc<NotificationStore>,
}
#[async_trait]
impl Tool for Notify {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
"notify",
"Send a notification to one of the user's devices: the phone \
by default. Use it to reach the user away from this chat — \
a reminder they asked to be passed on, finished background \
work, anything that must not wait for their next message.",
json!({
"type": "object",
"properties": {
"title": { "type": "string",
"description": "The one-line headline." },
"body": { "type": "string",
"description": "The message body." },
"device": { "type": "string",
"description": "The target device; the phone \
when omitted." }
},
"required": ["title", "body"]
}),
)
}
async fn invoke(&self, args: Value) -> Result<String, String> {
let args: NotifyArgs =
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
let title = args.title.trim();
let body = args.body.trim();
if title.is_empty() {
return Err("title must not be empty".to_owned());
}
if body.is_empty() {
return Err("body must not be empty".to_owned());
}
let device = args
.device
.as_deref()
.map(str::trim)
.filter(|device| !device.is_empty())
.unwrap_or(DEFAULT_DEVICE);
let record = self
.store
.push(device, title, body)
.map_err(|err| err.to_string())?;
Ok(json!({
"queued": true,
"id": record.id,
"device": record.device,
})
.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> Arc<NotificationStore> {
Arc::new(NotificationStore::in_memory().expect("store opens"))
}
async fn call(store: &Arc<NotificationStore>, args: Value) -> rhema_core::ToolResult {
let resolver =
registry(store.clone()).resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
resolver
.resolve(
rhema_core::ToolCall {
id: "c1".into(),
name: "notify".into(),
args,
provider_data: Value::Null,
},
&rhema_core::ToolContext::default(),
)
.await
}
#[tokio::test]
async fn notify_enqueues_a_row_and_answers_one_line() {
let store = store();
let result = call(
&store,
json!({"title": "CI green", "body": "all checks pass"}),
)
.await;
assert!(!result.is_error, "{}", result.content);
assert_eq!(
serde_json::from_str::<Value>(&result.content).unwrap(),
json!({"queued": true, "id": 1, "device": "phone"}),
"{}",
result.content
);
let pending = store.pending(DEFAULT_DEVICE, 0).expect("poll reads");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].title, "CI green");
assert_eq!(pending[0].body, "all checks pass");
assert_eq!(pending[0].device, "phone", "the default device");
}
#[tokio::test]
async fn a_named_device_lands_on_its_own_queue() {
let store = store();
let result = call(
&store,
json!({"title": "done", "body": "shipped", "device": " desk "}),
)
.await;
assert!(!result.is_error, "{}", result.content);
assert!(
store.pending("desk", 0).expect("poll reads").len() == 1
&& store
.pending(DEFAULT_DEVICE, 0)
.expect("poll reads")
.is_empty(),
"the trimmed device owns the row"
);
}
#[tokio::test]
async fn empty_fields_and_bad_arguments_fail_in_band() {
let store = store();
for args in [
json!({"title": " ", "body": "x"}),
json!({"title": "x", "body": ""}),
json!({"title": "x"}),
] {
let result = call(&store, args).await;
assert!(result.is_error, "{}", result.content);
}
assert!(
store
.pending(DEFAULT_DEVICE, 0)
.expect("poll reads")
.is_empty(),
"nothing landed"
);
}
}
+408
View File
@@ -0,0 +1,408 @@
//! The OpenAI-compatible shim: `POST /v1/chat/completions`, so a stock
//! web UI plugs into the resident agent without knowing the claw's own
//! routes.
//!
//! **The incoming history is ignored — deliberately, and this is the
//! load-bearing caveat of the whole shim.** The daemon owns the session:
//! one key, one persistent conversation, restored and stashed by the
//! interactive handler across every turn. A chat UI over this endpoint
//! MIRRORS the replies (streamed as deltas or returned whole); it does
//! not own history and cannot rewrite it. Sending a full transcript
//! changes nothing but which message triggers the turn: only the LAST
//! user message is read. The session key is `interactive:<user>`,
//! defaulting to `interactive:web`.
//!
//! The wire is the familiar subset: `stream: true` answers SSE
//! chat-completion chunks (`delta.reasoning_content` for thinking,
//! `delta.content` for text, a final chunk with `finish_reason: "stop"`,
//! then `data: [DONE]`); `stream: false` answers one completion object
//! once the turn's end marker arrives.
use std::time::Duration;
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use rhema_rsd::{KeyState, ResourceKey};
use serde::Deserialize;
use serde_json::{Value, json};
use tokio::sync::broadcast::error::RecvError;
use tokio_stream::wrappers::ReceiverStream;
use crate::hub::TurnEvent;
use crate::interactive::KEY_PREFIX;
/// How long the non-stream path waits for the turn's end marker. Turns
/// can legitimately run minutes of shell work; a web UI's default
/// timeout is the caller's problem, this one is generous.
pub const TURN_WAIT: Duration = Duration::from_secs(600);
/// The session key when the request names no `user`.
pub const DEFAULT_USER: &str = "web";
/// The request body. `messages` is accepted in the OpenAI shape and
/// reduced to the last user message; everything else is ignored.
#[derive(Debug, Deserialize)]
pub struct ChatRequest {
/// Echoed back when present; the daemon's configured model
/// otherwise.
pub model: Option<String>,
/// The transcript-shaped input. Only the last `user` entry matters.
pub messages: Vec<ChatMessage>,
/// Stream chunks (SSE) or wait for the whole reply.
#[serde(default)]
pub stream: bool,
/// The session name: key `interactive:<user>`, `web` when unset.
pub user: Option<String>,
}
/// One transcript entry; `content` is a string or an array of parts.
#[derive(Debug, Deserialize)]
pub struct ChatMessage {
/// `user`, `assistant`, `system`, `tool` — only `user` is read.
pub role: String,
/// String content, or OpenAI's array-of-parts.
pub content: ChatContent,
}
/// OpenAI's two content shapes.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum ChatContent {
/// A plain string.
Text(String),
/// An array of typed parts; only the text parts are read.
Parts(Vec<ChatPart>),
}
impl ChatContent {
/// The message's text: the string, or the text parts joined. A part
/// array with no text parts (images only, say) reads as empty.
pub fn text(&self) -> String {
match self {
ChatContent::Text(text) => text.clone(),
ChatContent::Parts(parts) => parts
.iter()
.filter_map(|part| part.text.as_deref())
.collect::<Vec<_>>()
.join(""),
}
}
}
/// One content part; unknown parts carry no readable text.
#[derive(Debug, Deserialize)]
pub struct ChatPart {
/// The part's text, for `{"type": "text"}` parts.
#[serde(default)]
pub text: Option<String>,
}
/// The last `user` message's text — the only input a turn gets.
pub fn last_user_text(messages: &[ChatMessage]) -> Option<String> {
messages
.iter()
.rev()
.find(|message| message.role == "user")
.map(|message| message.content.text())
}
/// The completion/chunk id: `chatcmpl-` plus a process-unique suffix.
fn completion_id() -> String {
static SHIM_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
format!(
"chatcmpl-{}-{:04x}",
now.as_millis(),
SHIM_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
)
}
/// Seconds since the Unix epoch, the shim's `created`.
fn unix_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// The non-stream completion object. `content` is `null` when the turn
/// ended without a reply — OpenAI's shape allows it, and the session
/// records why.
fn completion_body(id: &str, created: u64, model: &str, reply: Option<String>) -> Value {
json!({
"id": id,
"object": "chat.completion",
"created": created,
"model": model,
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": reply},
"finish_reason": "stop",
}],
})
}
/// One streaming chunk.
fn chunk_body(id: &str, created: u64, model: &str, delta: Value, finish: Option<&str>) -> Value {
json!({
"id": id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [{
"index": 0,
"delta": delta,
"finish_reason": finish,
}],
})
}
/// The shim endpoint. Errors are OpenAI-shaped: `{"error": {"message":
/// ...}}` with a sensible status.
pub async fn chat_completions(
State(state): State<crate::http::ClawState>,
Json(request): Json<ChatRequest>,
) -> Response {
let Some(text) = last_user_text(&request.messages).filter(|t| !t.trim().is_empty()) else {
return error_response(
StatusCode::BAD_REQUEST,
"no usable user message: the shim reads only the last `user` entry",
);
};
let user = request
.user
.as_deref()
.map(str::trim)
.filter(|user| !user.is_empty())
.unwrap_or(DEFAULT_USER);
let key = ResourceKey::from(format!("{KEY_PREFIX}{user}"));
let model = request.model.clone().unwrap_or_else(|| {
let (_, active_model) = state.backends().active();
active_model
});
let id = completion_id();
let created = unix_secs();
// Subscribe first, ingest second, drain third — the same contract
// the messages route keeps.
let mut receiver = state.hub().subscribe(&key);
if let Err(err) = state
.dispatcher()
.ingest(crate::interactive::user_event(key.as_str(), &text))
{
return error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string());
}
if let Err(err) = crate::http::drain(state.driver(), state.dispatcher(), &key).await {
return error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string());
}
if request.stream {
return stream_response(receiver, &id, created, &model);
}
// The drain ran the turn; its end marker is queued. A queued
// follow-up behind a foreign turn delays ours — keep waiting through
// drains until the key settles, bounded by TURN_WAIT.
let deadline = tokio::time::Instant::now() + TURN_WAIT;
let reply = loop {
let done = match tokio::time::timeout_at(deadline, receiver.recv()).await {
Ok(Ok(TurnEvent::TurnDone { reply, .. })) => reply,
Ok(Ok(_)) => continue,
Ok(Err(RecvError::Lagged(_))) => continue,
Ok(Err(RecvError::Closed)) => {
return error_response(
StatusCode::BAD_GATEWAY,
"the event hub closed before the turn finished",
);
}
Err(_elapsed) => {
return error_response(
StatusCode::GATEWAY_TIMEOUT,
"the turn did not finish in time; its reply may still arrive on the event stream",
);
}
};
let settled = state
.dispatcher()
.status(&key)
.map(|status| status.inbox_depth == 0 && status.state != KeyState::Reacquire);
match settled {
Ok(true) => break done,
Ok(false) => {
// More work is queued behind what just finished — ours,
// most likely: drain and wait for its end marker.
if let Err(err) = crate::http::drain(state.driver(), state.dispatcher(), &key).await
{
return error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string());
}
}
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()),
}
};
Json(completion_body(&id, created, &model, reply)).into_response()
}
/// The `stream: true` answer: an SSE body of completion chunks pumped
/// from the hub. Thinking becomes `delta.reasoning_content`, text
/// becomes `delta.content`, and the turn's end marker becomes the final
/// `finish_reason: "stop"` chunk plus `data: [DONE]` — then the stream
/// closes. Tool traffic is not surfaced: it is the agent's plumbing.
fn stream_response(
mut receiver: tokio::sync::broadcast::Receiver<TurnEvent>,
id: &str,
created: u64,
model: &str,
) -> Response {
let id = id.to_owned();
let model = model.to_owned();
let (sender, body) =
tokio::sync::mpsc::channel::<Result<SseEvent, std::convert::Infallible>>(64);
tokio::spawn(async move {
loop {
let frame = match receiver.recv().await {
Ok(TurnEvent::ThinkingDelta { text }) => Ok(sse_chunk(
&id,
created,
&model,
json!({"reasoning_content": text}),
)),
Ok(TurnEvent::TextDelta { text }) => {
Ok(sse_chunk(&id, created, &model, json!({"content": text})))
}
Ok(TurnEvent::TurnDone { .. }) => {
// The turn is over regardless of who triggered it or
// how it ended: the client gets a clean stop.
let final_frame = Ok(sse_chunk_finish(
&id,
created,
&model,
json!({}),
Some("stop"),
));
let done = Ok(SseEvent::default().data("[DONE]"));
let _ = sender.send(final_frame).await;
let _ = sender.send(done).await;
break;
}
// The claw's own clients get the tool rows, the
// consent cards, and the attachments; an OpenAI caller
// gets text, reasoning, and the end marker.
Ok(
TurnEvent::ToolStart { .. }
| TurnEvent::ToolResult { .. }
| TurnEvent::ConsentRequest { .. }
| TurnEvent::ConsentResolved { .. }
| TurnEvent::Attachment { .. },
) => continue,
// The turn's input rides the stream for the claw's own
// clients; an OpenAI caller named it in the request.
Ok(TurnEvent::UserMessage { .. }) => continue,
Err(RecvError::Lagged(dropped)) => Ok(SseEvent::default()
.comment(format!("lagged; {dropped} earlier event(s) dropped"))),
Err(RecvError::Closed) => break,
};
if sender.send(frame).await.is_err() {
break;
}
}
});
Sse::new(ReceiverStream::new(body))
.keep_alive(
KeepAlive::default()
.interval(crate::http::KEEP_ALIVE_INTERVAL)
.text("keep-alive"),
)
.into_response()
}
/// One delta (or the final) chunk as an SSE frame.
fn sse_chunk(id: &str, created: u64, model: &str, delta: Value) -> SseEvent {
sse_chunk_finish(id, created, model, delta, None)
}
fn sse_chunk_finish(
id: &str,
created: u64,
model: &str,
delta: Value,
finish: Option<&str>,
) -> SseEvent {
SseEvent::default().data(chunk_body(id, created, model, delta, finish).to_string())
}
/// An OpenAI-shaped error: `{"error": {"message": ...}}`.
fn error_response(status: StatusCode, message: &str) -> Response {
(status, Json(json!({"error": {"message": message}}))).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
fn message(role: &str, content: ChatContent) -> ChatMessage {
ChatMessage {
role: role.to_owned(),
content,
}
}
#[test]
fn only_the_last_user_message_is_read() {
let messages = vec![
message("system", ChatContent::Text("be brief".into())),
message("user", ChatContent::Text("first".into())),
message("assistant", ChatContent::Text("no".into())),
message("user", ChatContent::Text("second".into())),
];
assert_eq!(last_user_text(&messages).as_deref(), Some("second"));
assert_eq!(last_user_text(&messages[0..1]).as_deref(), None);
assert_eq!(last_user_text(&[]), None);
}
#[test]
fn part_arrays_join_their_text_parts() {
let parts = ChatContent::Parts(vec![
ChatPart {
text: Some("look ".into()),
},
ChatPart {
text: None, // an image part, say
},
ChatPart {
text: Some("here".into()),
},
]);
assert_eq!(parts.text(), "look here");
assert_eq!(ChatContent::Text("plain".into()).text(), "plain");
}
#[test]
fn the_completion_shape_is_the_familiar_one() {
let body = completion_body("chatcmpl-1", 42, "m", Some("hi".into()));
assert_eq!(body["object"], "chat.completion");
assert_eq!(body["choices"][0]["message"]["content"], "hi");
assert_eq!(body["choices"][0]["finish_reason"], "stop");
let null = completion_body("chatcmpl-1", 42, "m", None);
assert!(null["choices"][0]["message"]["content"].is_null());
let chunk = chunk_body("chatcmpl-1", 42, "m", json!({"content": "x"}), None);
assert_eq!(chunk["object"], "chat.completion.chunk");
assert_eq!(chunk["choices"][0]["delta"]["content"], "x");
assert!(chunk["choices"][0]["finish_reason"].is_null());
let stop = chunk_body("chatcmpl-1", 42, "m", json!({}), Some("stop"));
assert_eq!(stop["choices"][0]["finish_reason"], "stop");
}
#[test]
fn ids_are_unique_and_shaped() {
let a = completion_id();
let b = completion_id();
assert_ne!(a, b);
assert!(a.starts_with("chatcmpl-"), "{a}");
}
}
File diff suppressed because it is too large. Load diff
+115
View File
@@ -0,0 +1,115 @@
//! Runtime settings the daemon persists: the key-value table behind the
//! deployment state that outlives a restart.
//!
//! The active provider/model pair lives here, not in `claw.yaml` — the
//! config file seeds it, the API changes it, and this store keeps it.
//! Same store shape as the crate's other tables: a second connection to
//! the shared SQLite file, WAL, busy timeout.
use std::path::Path;
use std::sync::{Mutex, PoisonError};
use rusqlite::OptionalExtension;
use rusqlite::params;
/// Why a settings operation failed.
#[derive(Debug, thiserror::Error)]
pub enum SettingsError {
/// The backend failed to read or write.
#[error("settings store failed: {0}")]
Backend(String),
}
/// The `settings` key-value table over the claw's shared database.
pub struct SettingsStore {
conn: Mutex<rusqlite::Connection>,
}
impl SettingsStore {
/// Opens (creating if needed) the settings table on the database at
/// `path`.
pub fn open(path: impl AsRef<Path>) -> Result<Self, SettingsError> {
let conn = rusqlite::Connection::open(path)
.map_err(|err| SettingsError::Backend(err.to_string()))?;
Self::init(conn)
}
/// Opens the settings table over an in-memory database.
pub fn in_memory() -> Result<Self, SettingsError> {
let conn = rusqlite::Connection::open_in_memory()
.map_err(|err| SettingsError::Backend(err.to_string()))?;
Self::init(conn)
}
fn init(conn: rusqlite::Connection) -> Result<Self, SettingsError> {
let conn = conn;
conn.busy_timeout(std::time::Duration::from_secs(5))
.map_err(|err| SettingsError::Backend(err.to_string()))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)
.map_err(|err| SettingsError::Backend(err.to_string()))?;
Ok(Self {
conn: Mutex::new(conn),
})
}
/// The value stored for `key`, when one was set.
pub fn get(&self, key: &str) -> Result<Option<String>, SettingsError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let value: Option<String> = conn
.query_row(
"SELECT value FROM settings WHERE key = ?1",
params![key],
|row| row.get(0),
)
.optional()
.map_err(|err| SettingsError::Backend(err.to_string()))?;
Ok(value)
}
/// Every `(key, value)` whose key starts with `prefix`, ascending
/// by key — discovery's view of the stored provider rows.
pub fn get_prefix(&self, prefix: &str) -> Result<Vec<(String, String)>, SettingsError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let mut statement = conn
.prepare("SELECT key, value FROM settings WHERE key LIKE (?1 || '%') ORDER BY key")
.map_err(|err| SettingsError::Backend(err.to_string()))?;
let rows = statement
.query_map(params![prefix], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(|err| SettingsError::Backend(err.to_string()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|err| SettingsError::Backend(err.to_string()))?;
Ok(rows)
}
/// Sets `key` to `value`, replacing any previous value.
pub fn set(&self, key: &str, value: &str) -> Result<(), SettingsError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
conn.execute(
"INSERT INTO settings (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)
.map_err(|err| SettingsError::Backend(err.to_string()))?;
Ok(())
}
/// Removes `key` from the store; a no-op when it was absent.
pub fn delete(&self, key: &str) -> Result<(), SettingsError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
.map_err(|err| SettingsError::Backend(err.to_string()))?;
Ok(())
}
}
/// The persisted settings keys: the active backend pair.
pub const ACTIVE_PROVIDER_KEY: &str = "active_provider";
/// The persisted active model.
pub const ACTIVE_MODEL_KEY: &str = "active_model";
+291
View File
@@ -0,0 +1,291 @@
//! The task source: deterministic detection of due scheduled tasks, the
//! spec §1-compliant cron.
//!
//! The [`TaskScheduler`] is a [`Source`] named `"tasks"`: its `detect`
//! pass reads the task table against an injected
//! [`Clock`](rhema_rsd::Clock), ingests one event per due task into the
//! resident dispatcher, then advances the row — the one-shot is
//! deleted, the recurring task re-arms. The model is never reached here;
//! the turn happens because the dispatcher admitted the key, exactly as
//! for a chat message. Cadence belongs to the daemon's loop, which runs
//! this source's detect each tick alongside the forge sources.
//!
//! Due/re-arm semantics (the loop-fire guards):
//!
//! - A row fires at most once per detect pass: every acted-on row is
//! advanced before the pass returns, and `due` reads the table once.
//! - A recurring task re-arms to `next_due + interval`; when that is
//! still past (the process was down, the clock jumped), it re-arms to
//! `now + interval` — a missed task catches up with one firing, never
//! one per elapsed interval.
//! - A recurring row without a usable interval is deleted, not skipped:
//! a skipped due row would fire again on every pass forever.
use std::sync::Arc;
use rhema_autonomous_agent::{Source, SourceError, async_trait};
use rhema_rsd::{Clock, Dispatcher, Event, KeyFn};
use serde_json::json;
use crate::interactive::passthrough_key_fn;
use crate::task_store::{TaskKind, TaskStore};
/// The source name the scheduler ingests under; registered on the
/// resident dispatcher, so allowlists and `/v1/ingest` can name it.
pub const TASKS_SOURCE: &str = "tasks";
/// One scheduled task's dispatch key: `task:<id>` — the session the
/// prompt's turn runs in, persistent across the task's firings.
pub fn task_key(id: i64) -> String {
format!("task:{id}")
}
/// The event one due firing becomes. The key is the task's delivery
/// target — its own `task:<id>` slot, or the session the scheduler was
/// asked to wake (a chat conversation with its context, a phone key
/// whose reply auto-notifies). The dedup id carries the firing's
/// `next_due`, so a recurring task's next firing never dedups against
/// the last one still pending in the key's inbox.
pub fn task_event(task: &crate::task_store::TaskRecord, now: u64) -> Event {
let default_key = task_key(task.id);
let key = task
.deliver_to
.as_deref()
.filter(|key| !key.is_empty())
.unwrap_or(default_key.as_str());
Event {
id: format!("task-{}@{}", task.id, task.next_due_millis),
source: TASKS_SOURCE.to_owned(),
payload: json!({ "key": key, "prompt": task.prompt }),
received_at: now,
}
}
/// The detectable stream of due tasks: the task table against a clock,
/// feeding the resident dispatcher.
pub struct TaskScheduler {
clock: Arc<dyn Clock>,
store: Arc<TaskStore>,
dispatcher: Arc<Dispatcher>,
key_fn: Arc<KeyFn>,
}
impl TaskScheduler {
/// Registers the `tasks` source's key function on `dispatcher` and
/// returns the scheduler feeding it — the ForgeSource shape: one
/// source, registered once, before the first detect.
pub fn new(dispatcher: &Arc<Dispatcher>, clock: Arc<dyn Clock>, store: Arc<TaskStore>) -> Self {
let key_fn = passthrough_key_fn();
dispatcher.register_source(TASKS_SOURCE, key_fn.clone());
Self {
clock,
store,
dispatcher: dispatcher.clone(),
key_fn,
}
}
}
#[async_trait]
impl Source for TaskScheduler {
fn name(&self) -> &str {
TASKS_SOURCE
}
fn key_fn(&self) -> Arc<KeyFn> {
self.key_fn.clone()
}
async fn detect(&self) -> Result<usize, SourceError> {
let now = self.clock.now_millis() as i64;
let due = self
.store
.due(now)
.map_err(|err| SourceError::Backend(format!("task store: {err}")))?;
let mut fed = 0;
for task in due {
self.dispatcher.ingest(task_event(&task, now as u64))?;
fed += 1;
match task.kind {
TaskKind::At => {
// Fired once; the row's work is done.
self.store
.remove(task.id)
.map_err(|err| SourceError::Backend(format!("task store: {err}")))?;
}
TaskKind::Every => match task.interval_millis {
Some(interval) if interval > 0 => {
let mut next = task.next_due_millis.saturating_add(interval);
if next <= now {
// Catch-up: the scheduled next is already past
// (downtime, a clock jump). One firing settles
// the debt; the rest never happens.
next = now.saturating_add(interval);
}
self.store
.rearm(task.id, next)
.map_err(|err| SourceError::Backend(format!("task store: {err}")))?;
}
// A recurring task that cannot re-arm must not sit due
// forever, re-firing every pass: retire it, loudly.
interval => {
eprintln!(
"[claw] warning: task {} has no usable interval \
({interval:?}); removing it",
task.id
);
self.store
.remove(task.id)
.map_err(|err| SourceError::Backend(format!("task store: {err}")))?;
}
},
}
}
Ok(fed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rhema_rsd::{DispatcherConfig, FixedClock, SystemClock, VolatileStore};
fn dispatcher() -> Arc<Dispatcher> {
Arc::new(Dispatcher::new(
DispatcherConfig {
debounce_window_millis: 0,
..DispatcherConfig::default()
},
Arc::new(SystemClock),
Arc::new(VolatileStore::new()),
))
}
/// The dispatcher status of one task's key: how many events wait on
/// it. A key never ingested reports the missing view — depth 0.
fn inbox_depth(dispatcher: &Dispatcher, id: i64) -> usize {
dispatcher
.status(&rhema_rsd::ResourceKey::from(task_key(id)))
.expect("status reads")
.inbox_depth
}
#[tokio::test]
async fn a_due_one_shot_fires_once_and_is_removed() {
let clock = Arc::new(FixedClock::new(1_000));
let store = Arc::new(TaskStore::in_memory().expect("store opens"));
let dispatcher = dispatcher();
let source = TaskScheduler::new(&dispatcher, clock.clone(), store.clone());
store
.schedule_at("remind the user: bread", 1_500, 1_000, None)
.expect("scheduled");
// Not yet due: skipped, nothing fed, no record.
assert_eq!(source.detect().await.expect("detect runs"), 0);
assert_eq!(inbox_depth(&dispatcher, 1), 0, "nothing fired");
// Due: exactly one event, on the task's key.
clock.advance(500);
assert_eq!(source.detect().await.expect("detect runs"), 1);
assert_eq!(inbox_depth(&dispatcher, 1), 1);
// The row is gone; a second pass re-fires nothing.
assert!(store.list().expect("list reads").is_empty());
assert_eq!(source.detect().await.expect("detect runs"), 0);
assert_eq!(inbox_depth(&dispatcher, 1), 1, "the inbox keeps it");
}
#[tokio::test]
async fn a_recurring_task_rearms_and_never_loop_fires() {
let clock = Arc::new(FixedClock::new(1_000));
let store = Arc::new(TaskStore::in_memory().expect("store opens"));
let dispatcher = dispatcher();
let source = TaskScheduler::new(&dispatcher, clock.clone(), store.clone());
store
.schedule_every("stand up", 1_000, 1_000, None)
.expect("scheduled");
// Due at 2000: one firing, re-armed to due + interval.
clock.advance(1_500); // now = 2500
assert_eq!(source.detect().await.expect("detect runs"), 1);
let task = &store.list().expect("list reads")[0];
assert_eq!(task.next_due_millis, 2_000 + 1_000, "next_due + interval");
assert_eq!(inbox_depth(&dispatcher, 1), 1);
// Still past due after re-arm (the firing lagged): the next pass
// fires again — but one detect never fires the same task twice,
// and the catch-up rule re-arms from now, not from the backlog.
clock.advance(10_000); // now = 12500
assert_eq!(source.detect().await.expect("detect runs"), 1);
let task = &store.list().expect("list reads")[0];
assert_eq!(
task.next_due_millis, 13_500,
"catch-up re-arms to now + interval, never loop-fires"
);
assert_eq!(source.detect().await.expect("detect runs"), 0);
assert_eq!(
inbox_depth(&dispatcher, 1),
2,
"one firing per pass: two passes, two events"
);
}
#[tokio::test]
async fn a_delivery_target_routes_the_firing_to_that_session() {
let clock = Arc::new(FixedClock::new(1_000));
let store = Arc::new(TaskStore::in_memory().expect("store opens"));
let dispatcher = dispatcher();
let source = TaskScheduler::new(&dispatcher, clock.clone(), store.clone());
store
.schedule_at(
"summarize this evening's work",
1_500,
1_000,
Some("interactive:web"),
)
.expect("scheduled");
clock.advance(500);
assert_eq!(source.detect().await.expect("detect runs"), 1);
// The firing woke the named session, not the task's own slot.
let depth = |key: &str| {
dispatcher
.status(&rhema_rsd::ResourceKey::from(key))
.expect("status reads")
.inbox_depth
};
assert_eq!(depth("interactive:web"), 1);
assert_eq!(depth(&task_key(1)), 0, "the default slot stays cold");
}
#[tokio::test]
async fn an_unusable_recurring_row_is_retired_not_loop_fired() {
let clock = Arc::new(FixedClock::new(1_000));
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("dispatch.db");
// The table exists before the hand edit, so the row is the only
// thing in it.
drop(TaskStore::open(&path).expect("store opens"));
{
// Simulate the hand-edited table the store's API cannot
// produce: an `every` row without an interval, due now. The
// CHECK constraint only pins the kind's spelling.
let conn = rusqlite::Connection::open(&path).expect("raw connection");
conn.execute(
"INSERT INTO tasks (prompt, kind, at_millis, interval_millis, next_due_millis, created_at)
VALUES ('tick', 'every', NULL, NULL, 0, 0)",
[],
)
.expect("the malformed row lands");
}
let store = Arc::new(TaskStore::open(&path).expect("store reopens"));
let dispatcher = dispatcher();
let source = TaskScheduler::new(&dispatcher, clock.clone(), store.clone());
// The bad row fires once (its prompt is real), then is retired:
// a skip would leave it due on every pass forever.
assert_eq!(source.detect().await.expect("detect runs"), 1);
assert!(store.list().expect("list reads").is_empty());
assert_eq!(source.detect().await.expect("detect runs"), 0);
}
}
+423
View File
@@ -0,0 +1,423 @@
//! The task table: user-scheduled prompts in SQLite, the same database
//! file the dispatcher's [`SqliteStore`](rhema_rsd::SqliteStore), the
//! watch table, and the notification table use.
//!
//! A task is the spec §1-compliant "cron": the row is inert data until
//! the [`TaskScheduler`](crate::task_source::TaskScheduler) — a
//! deterministic source, never the model — reads `next_due_millis`
//! against the clock and ingests the prompt as one event. No timer
//! invokes the model; a due task is an event, not a heartbeat.
//!
//! Two connections, one file: this store opens its own rusqlite
//! connection to the same database as the dispatcher's store — WAL and a
//! busy timeout, the pattern [`ClawStore`](crate::watch_store::ClawStore)
//! established — and the two interleave through SQLite's own locking.
//! Writes are single INSERT / UPDATE / DELETE statements, so there is no
//! read-modify-write window to guard.
use std::path::Path;
use std::sync::{Mutex, PoisonError};
use rusqlite::params;
use serde::Serialize;
/// The task table. The CHECK pins the two kinds' column pairing to what
/// the code writes: `at` rows carry `at_millis`, `every` rows carry
/// `interval_millis`. `deliver_to` names the session a firing wakes —
/// `NULL` meaning the task's own `task:<id>` slot.
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY,
prompt TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('at','every')),
at_millis INTEGER NULL,
interval_millis INTEGER NULL,
next_due_millis INTEGER NOT NULL,
created_at INTEGER NOT NULL,
deliver_to TEXT NULL
);
";
/// Adds `deliver_to` to a table created before it existed. The column
/// is the only schema the first release lacked, and `ALTER TABLE ADD
/// COLUMN` with a `NULL` default is the one shape SQLite can add
/// in place.
fn migrate_add_deliver_to(conn: &rusqlite::Connection) -> Result<(), TaskStoreError> {
let present: bool = conn
.prepare("PRAGMA table_info(tasks)")?
.query_map([], |row| row.get::<_, String>(1))?
.filter_map(Result::ok)
.any(|name| name == "deliver_to");
if !present {
conn.execute("ALTER TABLE tasks ADD COLUMN deliver_to TEXT NULL", [])?;
}
Ok(())
}
/// Why a task-table operation failed.
#[derive(Debug, thiserror::Error)]
pub enum TaskStoreError {
/// The SQLite backend failed.
#[error("task store backend: {0}")]
Backend(#[from] rusqlite::Error),
}
/// What kind of task a row is: fire once at a time (`at`) or repeat on
/// an interval (`every`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum TaskKind {
/// Fire once, at `at_millis`; the row leaves the table after
/// firing.
At,
/// Fire on every `interval_millis` tick; the row re-arms after each
/// firing.
Every,
}
impl TaskKind {
/// The name the table's `kind` column stores.
pub fn name(self) -> &'static str {
match self {
TaskKind::At => "at",
TaskKind::Every => "every",
}
}
/// Parses the column spelling. Private: rows only enter through
/// [`TaskStore::schedule_at`] / [`TaskStore::schedule_every`], and
/// this guards only hand-edited tables.
fn parse(name: &str) -> Option<Self> {
match name {
"at" => Some(TaskKind::At),
"every" => Some(TaskKind::Every),
_ => None,
}
}
}
/// One scheduled prompt, as stored. Serializes whole: this is the shape
/// `list_tasks` serves the model.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TaskRecord {
/// The row id; the dispatch key (`task:<id>`) and `cancel_task`
/// address it.
pub id: i64,
/// The prompt the scheduled turn runs.
pub prompt: String,
/// Fire once or repeat.
pub kind: TaskKind,
/// The one-shot's firing time, milliseconds since the Unix epoch.
pub at_millis: Option<i64>,
/// The recurring task's interval, in milliseconds.
pub interval_millis: Option<i64>,
/// When the task next fires, milliseconds since the Unix epoch.
pub next_due_millis: i64,
/// Creation time, milliseconds since the Unix epoch.
pub created_at: i64,
/// The session a firing wakes; `None` meaning the task's own
/// `task:<id>` slot.
pub deliver_to: Option<String>,
}
/// The persistent task table over its own connection to the daemon's
/// SQLite file. Shareable behind an `Arc`; every operation takes
/// `&self`.
pub struct TaskStore {
conn: Mutex<rusqlite::Connection>,
}
impl TaskStore {
/// Opens (creating if needed) the task table in the database at
/// `path` — the same file the dispatcher's store holds. WAL keeps
/// the connections from blocking each other's readers; the busy
/// timeout rides out the other's write locks.
pub fn open(path: impl AsRef<Path>) -> Result<Self, TaskStoreError> {
let conn = rusqlite::Connection::open(path).map_err(TaskStoreError::from)?;
Self::init(conn, true)
}
/// Opens a private in-memory table: the shape without the file, for
/// tests that need no sharing.
pub fn in_memory() -> Result<Self, TaskStoreError> {
let conn = rusqlite::Connection::open_in_memory().map_err(TaskStoreError::from)?;
Self::init(conn, false)
}
fn init(conn: rusqlite::Connection, durable: bool) -> Result<Self, TaskStoreError> {
if durable {
// Same pragmas as the dispatcher's store on the same file:
// SQLite stores the journal mode per database, and both
// connections must agree that it is WAL.
let _mode: String = conn
.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
.map_err(TaskStoreError::from)?;
conn.busy_timeout(std::time::Duration::from_secs(5))
.map_err(TaskStoreError::from)?;
}
conn.execute_batch(SCHEMA).map_err(TaskStoreError::from)?;
migrate_add_deliver_to(&conn)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
/// Schedules a one-shot firing at `at_millis`, waking `deliver_to`
/// (the task's own `task:<id>` slot when `None`). A time already
/// past is accepted: the next detect pass fires it immediately — a
/// task scheduled for "15:00" at 15:01 still runs.
pub fn schedule_at(
&self,
prompt: &str,
at_millis: i64,
now: i64,
deliver_to: Option<&str>,
) -> Result<TaskRecord, TaskStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
conn.execute(
"INSERT INTO tasks (prompt, kind, at_millis, interval_millis, next_due_millis, created_at, deliver_to)
VALUES (?1, 'at', ?2, NULL, ?2, ?3, ?4)",
params![prompt, at_millis, now, deliver_to],
)?;
Ok(TaskRecord {
id: conn.last_insert_rowid(),
prompt: prompt.to_owned(),
kind: TaskKind::At,
at_millis: Some(at_millis),
interval_millis: None,
next_due_millis: at_millis,
created_at: now,
deliver_to: deliver_to.map(str::to_owned),
})
}
/// Schedules a recurring firing every `interval_millis`, the first
/// one `interval_millis` from `now`, waking `deliver_to`.
pub fn schedule_every(
&self,
prompt: &str,
interval_millis: i64,
now: i64,
deliver_to: Option<&str>,
) -> Result<TaskRecord, TaskStoreError> {
let next_due = now + interval_millis;
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
conn.execute(
"INSERT INTO tasks (prompt, kind, at_millis, interval_millis, next_due_millis, created_at, deliver_to)
VALUES (?1, 'every', NULL, ?2, ?3, ?4, ?5)",
params![prompt, interval_millis, next_due, now, deliver_to],
)?;
Ok(TaskRecord {
id: conn.last_insert_rowid(),
prompt: prompt.to_owned(),
kind: TaskKind::Every,
at_millis: None,
interval_millis: Some(interval_millis),
next_due_millis: next_due,
created_at: now,
deliver_to: deliver_to.map(str::to_owned),
})
}
/// Every task, oldest first — the `list_tasks` view.
pub fn list(&self) -> Result<Vec<TaskRecord>, TaskStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let mut stmt = conn
.prepare(
"SELECT id, prompt, kind, at_millis, interval_millis, next_due_millis, created_at, deliver_to
FROM tasks ORDER BY id ASC",
)
.map_err(TaskStoreError::from)?;
let rows = stmt
.query_map([], row_task)
.map_err(TaskStoreError::from)?
.collect::<Result<Vec<_>, rusqlite::Error>>()
.map_err(TaskStoreError::from)?;
Ok(rows)
}
/// The due tasks at `now`, in firing order: `next_due_millis`
/// elapsed, oldest due first. One detect pass reads this once and
/// advances every row it acts on, so a task fires at most once per
/// pass.
pub fn due(&self, now: i64) -> Result<Vec<TaskRecord>, TaskStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let mut stmt = conn
.prepare(
"SELECT id, prompt, kind, at_millis, interval_millis, next_due_millis, created_at, deliver_to
FROM tasks WHERE next_due_millis <= ?1
ORDER BY next_due_millis ASC, id ASC",
)
.map_err(TaskStoreError::from)?;
let rows = stmt
.query_map(params![now], row_task)
.map_err(TaskStoreError::from)?
.collect::<Result<Vec<_>, rusqlite::Error>>()
.map_err(TaskStoreError::from)?;
Ok(rows)
}
/// Removes a task. `false` when there was none — the one-shot
/// completion path and `cancel_task` share it, and both treat "already
/// gone" as success.
pub fn remove(&self, id: i64) -> Result<bool, TaskStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let removed = conn.execute("DELETE FROM tasks WHERE id = ?1", params![id])?;
Ok(removed == 1)
}
/// Moves a recurring task's next firing to `next_due_millis`. The
/// scheduler computes the value — never `now + interval` inside a
/// loop — so a task missed while the process was down catches up
/// with one firing, not one per elapsed interval.
pub fn rearm(&self, id: i64, next_due_millis: i64) -> Result<(), TaskStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
conn.execute(
"UPDATE tasks SET next_due_millis = ?2 WHERE id = ?1",
params![id, next_due_millis],
)?;
Ok(())
}
}
/// Maps one row; the shared tail of [`TaskStore::list`] and
/// [`TaskStore::due`].
fn row_task(row: &rusqlite::Row<'_>) -> Result<TaskRecord, rusqlite::Error> {
let kind: String = row.get(2)?;
Ok(TaskRecord {
id: row.get(0)?,
prompt: row.get(1)?,
kind: TaskKind::parse(&kind)
.unwrap_or_else(|| unreachable!("the CHECK constraint only admits 'at' and 'every'")),
at_millis: row.get(3)?,
interval_millis: row.get(4)?,
next_due_millis: row.get(5)?,
created_at: row.get(6)?,
deliver_to: row.get(7)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tasks_round_trip_through_the_table() {
let store = TaskStore::in_memory().expect("store opens");
let at = store
.schedule_at("take the bread out", 1_500, 1_000, None)
.expect("at lands");
let every = store
.schedule_every("stand up", 1_800_000, 1_000, None)
.expect("every lands");
assert_ne!(at.id, every.id);
assert_eq!(at.deliver_to, None, "the default slot is the task's own");
let listed = store.list().expect("list reads");
assert_eq!(listed, vec![at.clone(), every.clone()]);
assert_eq!(at.kind, TaskKind::At);
assert_eq!(at.at_millis, Some(1_500));
assert_eq!(at.next_due_millis, 1_500, "a one-shot is due at its at");
assert_eq!(every.kind, TaskKind::Every);
assert_eq!(every.interval_millis, Some(1_800_000));
assert_eq!(every.next_due_millis, 1_801_000, "now + interval");
}
#[test]
fn due_selects_only_elapsed_tasks_in_firing_order() {
let store = TaskStore::in_memory().expect("store opens");
let late = store.schedule_at("late", 500, 0, None).expect("at lands");
let edge = store.schedule_at("edge", 1_000, 0, None).expect("at lands");
store
.schedule_at("future", 5_000, 0, None)
.expect("at lands");
assert!(
store.due(499).expect("due reads").is_empty(),
"nothing is due before the earliest"
);
assert_eq!(
store
.due(1_000)
.expect("due reads")
.iter()
.map(|task| task.id)
.collect::<Vec<_>>(),
[late.id, edge.id],
"the boundary is inclusive, in next-due order"
);
}
#[test]
fn remove_and_rearm_advance_rows() {
let store = TaskStore::in_memory().expect("store opens");
let task = store.schedule_every("tick", 1_000, 0, None).expect("lands");
store.rearm(task.id, 9_999).expect("rearm lands");
let listed = store.list().expect("list reads");
assert_eq!(listed[0].next_due_millis, 9_999);
assert!(store.remove(task.id).expect("remove lands"));
assert!(!store.remove(task.id).expect("re-remove is quiet"));
assert!(store.list().expect("list reads").is_empty());
}
#[test]
fn a_deliver_to_session_round_trips_and_defaults_absent() {
let store = TaskStore::in_memory().expect("store opens");
store
.schedule_at(
"summarize tonight's work",
1_500,
1_000,
Some("interactive:web"),
)
.expect("at lands");
let listed = store.list().expect("list reads");
assert_eq!(listed[0].deliver_to.as_deref(), Some("interactive:web"));
}
/// A table from before `deliver_to` opens into the new shape; its
/// rows read as defaulting to the task's own slot.
#[test]
fn a_pre_deliver_to_table_migrates_at_open() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("dispatch.db");
{
let conn = rusqlite::Connection::open(&path).expect("opens");
conn.execute_batch(
"CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
prompt TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('at','every')),
at_millis INTEGER NULL,
interval_millis INTEGER NULL,
next_due_millis INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
INSERT INTO tasks (prompt, kind, at_millis, next_due_millis, created_at)
VALUES ('old row', 'at', 42, 42, 0);",
)
.expect("legacy table");
}
let store = TaskStore::open(&path).expect("opens and migrates");
let listed = store.list().expect("list reads");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].deliver_to, None);
// The migrated row still schedules with a delivery target.
store
.schedule_at("new row", 50, 40, Some("task:9"))
.expect("schedules");
assert_eq!(store.list().expect("reads").len(), 2);
}
#[test]
fn two_connections_share_one_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("dispatch.db");
let writer = TaskStore::open(&path).expect("first connection");
let reader = TaskStore::open(&path).expect("second connection");
writer.schedule_at("hello", 42, 0, None).expect("schedule");
let listed = reader.list().expect("the other connection sees it");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].prompt, "hello");
}
}
+439
View File
@@ -0,0 +1,439 @@
//! The agent-side task tools: how the resident schedules its own future
//! turns — "remind me at 15:00" becomes a `schedule_task` call, the row
//! lands in the [`TaskStore`], and the next detect pass of the
//! [`TaskScheduler`](crate::task_source::TaskScheduler) picks it up. No
//! restart, no second mechanism, and no timer anywhere: a due task is
//! an event (spec §1), never a heartbeat.
//!
//! These are [`Tool`] implementations for a [`ToolRegistry`], so the
//! interactive handler offers them through the same allowlist-filtered
//! seam as everything else the model may call (spec §8). Answers are
//! short JSON; failures are in-band strings the model can react to.
use std::sync::Arc;
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
use rhema_core::ToolSpec;
use serde::Deserialize;
use serde_json::{Value, json};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::interactive::is_resident_key;
use crate::task_store::TaskStore;
/// The task tool set over `store`: `schedule_task`, `list_tasks`,
/// `cancel_task`.
pub fn registry(store: Arc<TaskStore>) -> ToolRegistry {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(ScheduleTask {
store: store.clone(),
}));
registry.register(Arc::new(ListTasks {
store: store.clone(),
}));
registry.register(Arc::new(CancelTask { store }));
registry
}
#[derive(Deserialize)]
struct ScheduleArgs {
prompt: String,
#[serde(default)]
at: Option<Value>,
#[serde(default)]
every_secs: Option<u64>,
#[serde(default)]
session: Option<String>,
}
#[derive(Deserialize)]
struct CancelArgs {
id: i64,
}
/// Milliseconds since the Unix epoch, the table's clock everywhere.
fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
/// Parses the `at` argument: epoch milliseconds as a JSON number, or a
/// string holding either epoch milliseconds or an RFC3339 timestamp.
fn parse_at_millis(value: Value) -> Result<i64, String> {
match value {
Value::Number(number) => number
.as_i64()
.ok_or_else(|| "`at` millis are out of range".to_owned()),
Value::String(raw) => {
let trimmed = raw.trim();
if !trimmed.is_empty() && trimmed.bytes().all(|byte| byte.is_ascii_digit()) {
trimmed
.parse::<i64>()
.map_err(|_| "`at` millis are out of range".to_owned())
} else {
let at = OffsetDateTime::parse(trimmed, &Rfc3339)
.map_err(|err| format!("`at` is not an RFC3339 timestamp: {err}"))?;
Ok(at.unix_timestamp() * 1000 + i64::from(at.millisecond()))
}
}
_ => Err("`at` is an RFC3339 timestamp or epoch milliseconds".to_owned()),
}
}
/// Schedules one prompt: fire once at `at` (an RFC3339 timestamp or
/// epoch milliseconds) or repeatedly every `every_secs` seconds —
/// exactly one of the two.
struct ScheduleTask {
store: Arc<TaskStore>,
}
#[async_trait]
impl Tool for ScheduleTask {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
"schedule_task",
"Schedule a future turn of yours. `prompt` runs as this \
agent's own turn — once at `at`, or repeatedly every \
`every_secs` seconds; set exactly one of the two. The \
scheduled turn has no chat surface: its reply reaches the \
user as a notification, so write prompts that produce one. \
Use list_tasks and cancel_task to manage what is scheduled.",
json!({
"type": "object",
"properties": {
"prompt": { "type": "string",
"description": "What the scheduled turn should do." },
"at": { "type": ["string", "number"],
"description": "When to fire once: an RFC3339 \
timestamp (\"2026-10-10T15:00:00Z\") or epoch \
milliseconds." },
"every_secs": { "type": "integer",
"description": "Repeat every this many seconds." }
},
"required": ["prompt"]
}),
)
}
async fn invoke(&self, args: Value) -> Result<String, String> {
let args: ScheduleArgs =
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
let prompt = args.prompt.trim();
if prompt.is_empty() {
return Err("prompt must not be empty".to_owned());
}
// A blank session reads as unset, and only a resident key can be
// woken: the delivery target must be a session the resident
// dispatcher answers.
let deliver_to = args
.session
.as_deref()
.map(str::trim)
.filter(|session| !session.is_empty());
if let Some(session) = deliver_to {
if !is_resident_key(session) {
return Err(format!(
"session must be a resident key (interactive:, task:, or phone:), got {session:?}"
));
}
}
let record = match (args.at, args.every_secs) {
(Some(at), None) => {
let at_millis = parse_at_millis(at)?;
self.store
.schedule_at(prompt, at_millis, now_millis(), deliver_to)
.map_err(|err| err.to_string())?
}
(None, Some(every_secs)) => {
if every_secs == 0 {
return Err("every_secs must be at least 1".to_owned());
}
let interval = every_secs
.checked_mul(1000)
.filter(|millis| *millis <= i64::MAX as u64)
.ok_or_else(|| "`every_secs` is out of range".to_owned())?;
self.store
.schedule_every(prompt, interval as i64, now_millis(), deliver_to)
.map_err(|err| err.to_string())?
}
(Some(_), Some(_)) => {
return Err("set exactly one of `at` or `every_secs`, not both".to_owned());
}
(None, None) => {
return Err("set one of `at` or `every_secs`".to_owned());
}
};
Ok(json!({
"scheduled": true,
"id": record.id,
"kind": record.kind.name(),
"next_due_millis": record.next_due_millis,
"session": record.deliver_to,
})
.to_string())
}
}
/// Lists what is scheduled.
struct ListTasks {
store: Arc<TaskStore>,
}
#[async_trait]
impl Tool for ListTasks {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
"list_tasks",
"List the scheduled tasks: id, prompt, kind (at or every), \
and next_due_millis — when each one fires next.",
json!({
"type": "object",
"properties": {},
}),
)
}
async fn invoke(&self, _args: Value) -> Result<String, String> {
let tasks = self.store.list().map_err(|err| err.to_string())?;
Ok(json!({ "tasks": tasks }).to_string())
}
}
/// Cancels one task by id.
struct CancelTask {
store: Arc<TaskStore>,
}
#[async_trait]
impl Tool for CancelTask {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
"cancel_task",
"Cancel a scheduled task by id (as list_tasks reports it). \
Already-fired one-shots report cancelled: false.",
json!({
"type": "object",
"properties": {
"id": { "type": "integer",
"description": "The task's id." }
},
"required": ["id"]
}),
)
}
async fn invoke(&self, args: Value) -> Result<String, String> {
let args: CancelArgs =
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
let removed = self.store.remove(args.id).map_err(|err| err.to_string())?;
Ok(json!({ "cancelled": removed, "id": args.id }).to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::task_store::TaskKind;
fn store() -> Arc<TaskStore> {
Arc::new(TaskStore::in_memory().expect("store opens"))
}
async fn call(store: &Arc<TaskStore>, name: &str, args: Value) -> rhema_core::ToolResult {
let resolver =
registry(store.clone()).resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
resolver
.resolve(
rhema_core::ToolCall {
id: "c1".into(),
name: name.into(),
args,
provider_data: Value::Null,
},
&rhema_core::ToolContext::default(),
)
.await
}
#[tokio::test]
async fn schedule_at_lands_a_one_shot_row() {
let store = store();
let result = call(
&store,
"schedule_task",
json!({"prompt": "remind the user: bread", "at": 2_000_000_000_000_i64}),
)
.await;
assert!(!result.is_error, "{}", result.content);
assert_eq!(
serde_json::from_str::<Value>(&result.content).unwrap(),
json!({"scheduled": true, "id": 1, "kind": "at", "next_due_millis": 2_000_000_000_000_i64, "session": null}),
"{}",
result.content
);
let listed = store.list().expect("list reads");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].kind, TaskKind::At);
assert_eq!(listed[0].prompt, "remind the user: bread");
}
#[tokio::test]
async fn an_rfc3339_at_parses_to_millis() {
let store = store();
let result = call(
&store,
"schedule_task",
json!({"prompt": "standup", "at": "2026-10-10T15:00:00Z"}),
)
.await;
assert!(!result.is_error, "{}", result.content);
let listed = store.list().expect("list reads");
assert_eq!(listed[0].kind, TaskKind::At);
assert_eq!(listed[0].next_due_millis, 1_791_644_400_000, "15:00Z");
}
#[tokio::test]
async fn schedule_every_lands_a_recurring_row() {
let store = store();
let result = call(
&store,
"schedule_task",
json!({"prompt": "stretch", "every_secs": 1800}),
)
.await;
assert!(!result.is_error, "{}", result.content);
let listed = store.list().expect("list reads");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].kind, TaskKind::Every);
assert_eq!(listed[0].interval_millis, Some(1_800_000));
assert!(
listed[0].next_due_millis > now_millis(),
"the first firing is in the future"
);
}
#[tokio::test]
async fn bad_shapes_fail_in_band_and_land_nothing() {
let store = store();
for (args, reason) in [
(
json!({"prompt": "x", "at": 1, "every_secs": 1}),
"exactly one",
),
(json!({"prompt": "x"}), "one of"),
(json!({"prompt": " ", "at": 1}), "empty"),
(json!({"prompt": "x", "at": "not a time"}), "RFC3339"),
(json!({"prompt": "x", "at": [1]}), "epoch milliseconds"),
(json!({"prompt": "x", "every_secs": 0}), "at least 1"),
] {
let result = call(&store, "schedule_task", args).await;
assert!(result.is_error, "{}", result.content);
assert!(
result.content.contains(reason),
"{reason} in {}",
result.content
);
}
assert!(
store.list().expect("list reads").is_empty(),
"nothing landed"
);
}
/// The wake-itself shape: a session-named task lands with its
/// delivery target, and a non-resident target is refused in band.
#[tokio::test]
async fn a_session_target_lands_and_a_foreign_one_is_refused() {
let store = store();
let result = call(
&store,
"schedule_task",
json!({
"prompt": "summarize this evening's work",
"at": "2026-10-15T08:00:00Z",
"session": "interactive:web",
}),
)
.await;
assert!(!result.is_error, "{}", result.content);
let answer: Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(answer["session"], "interactive:web");
assert_eq!(
store.list().expect("reads")[0].deliver_to.as_deref(),
Some("interactive:web")
);
// A blank session reads as unset — the default task slot.
for blank in ["", " "] {
let accepted = call(
&store,
"schedule_task",
json!({"prompt": "x", "at": 1, "session": blank}),
)
.await;
assert!(!accepted.is_error, "{blank:?} reads as unset");
assert_eq!(
store
.list()
.expect("reads")
.last()
.expect("lands")
.deliver_to,
None,
"{blank:?} schedules into the task's own slot"
);
}
// A non-resident target, or a bare name, cannot be woken.
for foreign in ["github:acme/w#7", "web"] {
let refused = call(
&store,
"schedule_task",
json!({"prompt": "x", "at": 1, "session": foreign}),
)
.await;
assert!(refused.is_error, "{foreign:?} must be refused");
}
assert_eq!(
store
.list()
.expect("reads")
.iter()
.filter(|task| task.deliver_to.is_some())
.count(),
1,
"the named session is the only targeted row"
);
}
#[tokio::test]
async fn list_and_cancel_round_trip_through_the_table() {
let store = store();
store.schedule_at("one", 42, 0, None).expect("seed");
store.schedule_every("two", 1_000, 0, None).expect("seed");
let listed = call(&store, "list_tasks", json!({})).await;
assert!(!listed.is_error, "{}", listed.content);
let value: Value = serde_json::from_str(&listed.content).unwrap();
assert_eq!(value["tasks"].as_array().map(Vec::len), Some(2));
assert_eq!(value["tasks"][0]["kind"], "at");
assert_eq!(value["tasks"][1]["kind"], "every");
let cancelled = call(&store, "cancel_task", json!({"id": 1})).await;
assert!(!cancelled.is_error, "{}", cancelled.content);
assert_eq!(
serde_json::from_str::<Value>(&cancelled.content).unwrap(),
json!({"cancelled": true, "id": 1})
);
let already_gone = call(&store, "cancel_task", json!({"id": 1})).await;
assert!(!already_gone.is_error, "{}", already_gone.content);
assert_eq!(
serde_json::from_str::<Value>(&already_gone.content).unwrap(),
json!({"cancelled": false, "id": 1})
);
assert_eq!(store.list().expect("list reads").len(), 1);
}
}
+140
View File
@@ -0,0 +1,140 @@
//! The triage: what a notification earns, decided deterministically
//! before any model call (spec §1 — the model is never the detector).
//!
//! A [`Notification`] carries the forge's own reason for bothering the
//! account; [`classify`] reduces it to one of three verdicts. An
//! [`Triage::Agent`] dispatch becomes an RSD event whose payload names
//! the [`Role`] — the persona the handler's prompt opens with, so one
//! thread can move from review-requested to implement-feedback across
//! dispatches with the session carrying the continuity. A
//! [`Triage::Surface`] never dispatches an agent: a mention is not a
//! assignment, and the account's human hears about it through the
//! device-notification queue instead. [`Triage::Ignore`] drops the noise
//! (CI churn) on the floor.
//!
//! The mapping is a pure function and this module's tests pin it: the
//! forges' vocabularies are external data, and the policy must not drift
//! with them.
use rhema_forge_integration::{Notification, NotificationReason};
/// The agent persona a dispatched notification runs as. The key stays
/// the thread — one session, one voice per thread, ever — and the role
/// rides the dispatch: it selects the prompt's opening framing and is
/// the seam where per-role tool policies land later.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
/// Asked to review: judge the pull request and post the review.
Reviewer,
/// Asked to do the work: implement the issue or answer the feedback.
Implementer,
}
impl Role {
/// The payload and prompt spelling.
pub fn as_str(self) -> &'static str {
match self {
Role::Reviewer => "reviewer",
Role::Implementer => "implementer",
}
}
}
/// What one notification earns.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Triage {
/// Dispatch the thread's agent as this role.
Agent(Role),
/// No agent: surface the notification to the account's human through
/// the device-notification queue.
Surface,
/// Drop it: noise the account should not hear about.
Ignore,
}
/// Classifies one notification. A forge reason the vocabulary does not
/// know ([`NotificationReason::Other`]) surfaces rather than acts: an
/// unknown shape is the user's to judge, never the agent's to guess at.
pub fn classify(notification: &Notification) -> Triage {
match &notification.reason {
NotificationReason::ReviewRequested => Triage::Agent(Role::Reviewer),
NotificationReason::Assigned => Triage::Agent(Role::Implementer),
NotificationReason::ReviewFeedback => Triage::Agent(Role::Implementer),
NotificationReason::Author => Triage::Agent(Role::Implementer),
NotificationReason::Mention => Triage::Surface,
NotificationReason::CiActivity => Triage::Ignore,
NotificationReason::Other(_) => Triage::Surface,
}
}
#[cfg(test)]
mod tests {
use super::*;
use rhema_forge_integration::{RepoRef, Timestamp};
fn notification(reason: NotificationReason) -> Notification {
Notification {
id: format!("github:notification:1@{}", reason.as_str()),
reason,
repo: RepoRef::new("acme", "widgets"),
number: 7,
is_pr: true,
actor: None,
title: None,
url: None,
timestamp: Timestamp::parse("2026-10-08T00:00:00Z").expect("parses"),
raw: serde_json::json!({}),
}
}
#[test]
fn asks_become_agent_dispatches_of_the_matching_role() {
assert_eq!(
classify(&notification(NotificationReason::ReviewRequested)),
Triage::Agent(Role::Reviewer)
);
for reason in [
NotificationReason::Assigned,
NotificationReason::ReviewFeedback,
NotificationReason::Author,
] {
let triage = classify(&notification(reason.clone()));
assert_eq!(
triage,
Triage::Agent(Role::Implementer),
"{} implements",
reason.as_str()
);
}
}
#[test]
fn mentions_surface_ci_ignores_and_unknowns_surface() {
assert_eq!(
classify(&notification(NotificationReason::Mention)),
Triage::Surface
);
assert_eq!(
classify(&notification(NotificationReason::CiActivity)),
Triage::Ignore
);
assert_eq!(
classify(&notification(NotificationReason::Other(
"subscription".to_owned()
))),
Triage::Surface
);
}
#[test]
fn roles_round_trip_through_serde() {
for role in [Role::Reviewer, Role::Implementer] {
let encoded = serde_json::to_value(role).expect("serializes");
assert_eq!(
serde_json::from_value::<Role>(encoded).expect("deserializes"),
role
);
}
}
}
+340
View File
@@ -0,0 +1,340 @@
//! The turn-history table: one durable row per streamed event, in the
//! same database file the dispatcher's
//! [`SqliteStore`](rhema_rsd::SqliteStore) and the other claw tables
//! use.
//!
//! The table is append-only and unbounded, on purpose: it keeps the
//! *transcript* — what a viewer saw, tool rows and thinking traces
//! included — and a resident agent's sessions run for weeks, so a
//! rolling window would eat history no client could ever get back. The
//! event hub appends every event here as it streams and replays from
//! here on subscribe; the session stash keeps the *conversation* (what
//! the model sees). A pre-window table (`turn_history`, one JSON blob
//! per key rewritten whole) migrates once, at open, into this shape.
use std::path::Path;
use std::sync::{Mutex, PoisonError};
use rusqlite::params;
use serde_json::Value;
use crate::hub::TurnEvent;
/// The turn-history table.
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS turn_events (
key TEXT NOT NULL,
seq INTEGER NOT NULL,
event TEXT NOT NULL,
PRIMARY KEY (key, seq)
);
";
/// Why a turn-history operation failed. A row whose JSON does not parse
/// is its own variant: a transcript that cannot replay must say so, not
/// silently read as "no history".
#[derive(Debug, thiserror::Error)]
pub enum TurnStoreError {
/// The SQLite backend failed.
#[error("turn history backend: {0}")]
Backend(#[from] rusqlite::Error),
/// A stored event is not a turn event.
#[error("turn history for {key} does not parse: {source}")]
Corrupt {
/// The key whose row failed.
key: String,
/// The parse failure.
#[source]
source: serde_json::Error,
},
}
/// The persistent turn-history table over its own connection to the
/// daemon's SQLite file. Shareable behind an `Arc`; every operation
/// takes `&self`.
pub struct TurnStore {
conn: Mutex<rusqlite::Connection>,
}
impl TurnStore {
/// Opens (creating if needed) the turn-history table in the database
/// at `path`, migrating a legacy whole-blob table if one is present.
/// WAL keeps the connections from blocking each other's readers; the
/// busy timeout rides out the other's write locks.
pub fn open(path: impl AsRef<Path>) -> Result<Self, TurnStoreError> {
let conn = rusqlite::Connection::open(path).map_err(TurnStoreError::from)?;
Self::init(conn, true)
}
/// Opens a private in-memory table: the shape without the file, for
/// tests that need no sharing.
pub fn in_memory() -> Result<Self, TurnStoreError> {
let conn = rusqlite::Connection::open_in_memory().map_err(TurnStoreError::from)?;
Self::init(conn, false)
}
fn init(conn: rusqlite::Connection, durable: bool) -> Result<Self, TurnStoreError> {
if durable {
// Same pragmas as the dispatcher's store on the same file:
// SQLite stores the journal mode per database, and both
// connections must agree that it is WAL.
let _mode: String = conn
.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
.map_err(TurnStoreError::from)?;
conn.busy_timeout(std::time::Duration::from_secs(5))
.map_err(TurnStoreError::from)?;
}
conn.execute_batch(SCHEMA).map_err(TurnStoreError::from)?;
migrate_legacy_blob_table(&conn)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
/// Appends `events` to `key`'s stream, in order, durable before it
/// returns. The stream's sequence is the table's, so appends extend
/// and never rewrite what a replay already read.
pub fn append(&self, key: &str, events: &[TurnEvent]) -> Result<(), TurnStoreError> {
if events.is_empty() {
return Ok(());
}
let mut conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let tx = conn.transaction().map_err(TurnStoreError::from)?;
let next: i64 = tx
.query_row(
"SELECT COALESCE(MAX(seq) + 1, 0) FROM turn_events WHERE key = ?1",
params![key],
|row| row.get(0),
)
.map_err(TurnStoreError::from)?;
for (offset, event) in events.iter().enumerate() {
let json = serde_json::to_string(event).map_err(|err| TurnStoreError::Corrupt {
key: key.to_owned(),
source: err,
})?;
tx.execute(
"INSERT INTO turn_events (key, seq, event) VALUES (?1, ?2, ?3)",
params![key, next + offset as i64, json],
)
.map_err(TurnStoreError::from)?;
}
tx.commit().map_err(TurnStoreError::from)?;
Ok(())
}
/// `key`'s whole recorded stream, oldest first — the replay a fresh
/// subscriber folds before the live feed.
pub fn events(&self, key: &str) -> Result<Vec<TurnEvent>, TurnStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let mut stmt = conn.prepare("SELECT event FROM turn_events WHERE key = ?1 ORDER BY seq")?;
let rows = stmt.query_map(params![key], |row| row.get::<_, String>(0))?;
let mut out = Vec::new();
for row in rows {
let json = row.map_err(TurnStoreError::from)?;
out.push(parse(key, &json)?);
}
Ok(out)
}
}
/// Moves a legacy `turn_history` table (one JSON-array blob per key,
/// rewritten whole) into per-event rows, then drops it. A blob that no
/// longer parses is skipped with a warning: half a migration beats a
/// boot that cannot open its history at all. No-op when the table is
/// absent (every database this code created fresh).
fn migrate_legacy_blob_table(conn: &rusqlite::Connection) -> Result<(), TurnStoreError> {
let legacy: bool = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'turn_history'",
[],
|row| row.get::<_, i64>(0),
)
.map(|count| count > 0)
.map_err(TurnStoreError::from)?;
if !legacy {
return Ok(());
}
let mut stmt = conn.prepare("SELECT key, events FROM turn_history")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut migrated = 0usize;
for row in rows {
let (key, blob) = row.map_err(TurnStoreError::from)?;
let events: Vec<Value> = match serde_json::from_str(&blob) {
Ok(events) => events,
Err(err) => {
eprintln!(
"[claw] warning: legacy transcript for {key} is unreadable, dropped: {err}"
);
continue;
}
};
for (seq, event) in events.into_iter().enumerate() {
conn.execute(
"INSERT OR IGNORE INTO turn_events (key, seq, event) VALUES (?1, ?2, ?3)",
params![key, seq as i64, event.to_string()],
)
.map_err(TurnStoreError::from)?;
}
migrated += 1;
}
drop(stmt);
conn.execute("DROP TABLE turn_history", [])
.map_err(TurnStoreError::from)?;
if migrated > 0 {
eprintln!("[claw] migrated {migrated} legacy transcript(s) to the append-only table");
}
Ok(())
}
/// Parses one stored event. The JSON value step keeps the error message
/// about *what* the row holds, not just that serde choked.
fn parse(key: &str, json: &str) -> Result<TurnEvent, TurnStoreError> {
let value: Value = serde_json::from_str(json).map_err(|source| TurnStoreError::Corrupt {
key: key.to_owned(),
source,
})?;
serde_json::from_value(value).map_err(|source| TurnStoreError::Corrupt {
key: key.to_owned(),
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn text_delta(text: &str) -> TurnEvent {
TurnEvent::TextDelta {
text: text.to_owned(),
}
}
#[test]
fn an_appended_history_round_trips_through_events() {
let store = TurnStore::in_memory().expect("store opens");
assert_eq!(store.events("interactive:web").expect("reads"), vec![]);
store
.append(
"interactive:web",
&[
TurnEvent::UserMessage {
text: "hello".into(),
},
text_delta("hi"),
],
)
.expect("appends");
store
.append(
"interactive:web",
&[TurnEvent::TurnDone {
reply: Some("hi".into()),
outcome: "handled".into(),
}],
)
.expect("appends again");
assert_eq!(
store.events("interactive:web").expect("reads"),
vec![
TurnEvent::UserMessage {
text: "hello".into()
},
text_delta("hi"),
TurnEvent::TurnDone {
reply: Some("hi".into()),
outcome: "handled".into(),
},
],
"appends extend the stream in order"
);
}
#[test]
fn an_empty_append_is_quiet() {
let store = TurnStore::in_memory().expect("store opens");
store
.append("interactive:web", &[])
.expect("an empty append is a no-op");
assert_eq!(store.events("interactive:web").expect("reads"), vec![]);
}
#[test]
fn keys_are_separate_streams() {
let store = TurnStore::in_memory().expect("store opens");
store.append("task:2", &[text_delta("b")]).expect("appends");
store
.append("interactive:web", &[text_delta("a")])
.expect("appends");
assert_eq!(
store.events("task:2").expect("reads"),
vec![text_delta("b")],
"one key's stream never leaks into another's"
);
assert_eq!(store.events("interactive:web").expect("reads").len(), 1);
}
#[test]
fn a_corrupt_event_row_is_loud_not_empty() {
let store = TurnStore::in_memory().expect("store opens");
{
let conn = store.conn.lock().unwrap_or_else(PoisonError::into_inner);
conn.execute(
"INSERT INTO turn_events (key, seq, event) VALUES ('interactive:web', 0, 'garbage')",
[],
)
.expect("writes raw");
}
let err = store
.events("interactive:web")
.expect_err("corruption is loud");
assert!(err.to_string().contains("interactive:web"), "{err}");
}
/// The one-time migration: a pre-window blob table opens into the
/// append-only shape, its rows gone.
#[test]
fn a_legacy_blob_table_migrates_into_event_rows() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("dispatch.db");
{
let conn = rusqlite::Connection::open(&path).expect("opens");
conn.execute_batch(
"CREATE TABLE turn_history (
key TEXT PRIMARY KEY,
events TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
INSERT INTO turn_history VALUES ('interactive:web',
'[{\"type\":\"text_delta\",\"text\":\"old days\"}]', 1);
INSERT INTO turn_history VALUES ('interactive:broke', 'not json', 2);",
)
.expect("legacy table");
}
let store = TurnStore::open(&path).expect("opens and migrates");
assert_eq!(
store.events("interactive:web").expect("reads"),
vec![text_delta("old days")],
"the readable blob migrated"
);
assert_eq!(store.events("interactive:broke").expect("reads"), vec![]);
{
let conn = store.conn.lock().unwrap_or_else(PoisonError::into_inner);
let legacy: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE name = 'turn_history'",
[],
|row| row.get(0),
)
.expect("reads");
assert_eq!(legacy, 0, "the legacy table is gone");
}
// The migrated rows keep appending after the migration.
store
.append("interactive:web", &[text_delta("new days")])
.expect("appends");
assert_eq!(store.events("interactive:web").expect("reads").len(), 2);
}
}
+488
View File
@@ -0,0 +1,488 @@
//! The watch table: agent-managed watches in SQLite, the same database
//! file the dispatcher's [`SqliteStore`](rhema_rsd::SqliteStore) uses.
//!
//! Watches are data the daemon reconfigures at runtime — the chat
//! surface's [`watch_repo`](crate::watch_tools) tool and the HTTP
//! `POST /v1/watches` route both land here — so the table is read fresh
//! on every detect pass instead of living only in the YAML config. The
//! config's `watches:` list is a one-time seed ([`ClawStore::
//! seed_from_config`]): imported when the table is still empty, ignored
//! once the table has rows.
//!
//! Two connections, one file: this store opens its own rusqlite
//! connection to the same database as the dispatcher's store — WAL and a
//! busy timeout, the pattern rhema-rsd's SQLite tests use — and the two
//! interleave through SQLite's own locking. Writes are single INSERT /
//! DELETE statements, so there is no read-modify-write window to guard.
use std::path::Path;
use std::sync::{Mutex, PoisonError};
use std::time::{SystemTime, UNIX_EPOCH};
use rhema_forge_integration::RepoRef;
use rusqlite::params;
use serde::Serialize;
use crate::config::{ForgeKind, Watch};
/// The watch table, and the account-watch table beside it: one row per
/// identity whose forge notifications the daemon polls. An account watch
/// is derived state — it names the [`rhema_identity`] account the
/// notifications belong to, and its token is resolved through the
/// identity store at poll time, never stored here.
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS watches (
id INTEGER PRIMARY KEY,
forge TEXT NOT NULL,
repo TEXT NOT NULL,
host TEXT,
token TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
UNIQUE(forge, repo)
);
CREATE TABLE IF NOT EXISTS account_watches (
id INTEGER PRIMARY KEY,
forge TEXT NOT NULL,
host TEXT,
account_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(forge, account_id)
);
";
/// Why a watch-table operation failed.
#[derive(Debug, thiserror::Error)]
pub enum WatchStoreError {
/// The SQLite backend failed.
#[error("watch store backend: {0}")]
Backend(#[from] rusqlite::Error),
}
/// One watched repository, as stored. `token` never serializes: it
/// reaches the forge clients, not the wire or the model.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WatchRecord {
/// The row id.
pub id: i64,
/// The forge's source name: `github`, `gitea`, or `gitlab`.
pub forge: String,
/// The repository, `owner/name` (GitLab: `namespace/project`).
pub repo: String,
/// The instance base URL; `None` for the forges with production
/// bases. Required for gitea, which is self-hosted only.
pub host: Option<String>,
/// The forge token; blank means the forge's environment variable is
/// consulted at poll time. Kept out of every serialized form.
#[serde(skip_serializing)]
pub token: String,
/// Creation time, milliseconds since the Unix epoch.
pub created_at: i64,
}
impl WatchRecord {
/// The repository coordinates, splitting `owner/name` — the same
/// shape check a config watch passes. GitLab's owner keeps its full
/// namespace path, which is why the split is lazy.
pub fn repo_ref(&self) -> Result<RepoRef, String> {
let (owner, name) = self
.repo
.split_once('/')
.ok_or_else(|| format!("repo must be owner/name, got {:?}", self.repo))?;
if owner.is_empty() || name.is_empty() {
return Err(format!("repo must be owner/name, got {:?}", self.repo));
}
Ok(RepoRef::new(owner, name))
}
}
/// The persistent watch table over its own connection to the daemon's
/// SQLite file. Shareable behind an `Arc`; every operation takes `&self`.
pub struct ClawStore {
conn: Mutex<rusqlite::Connection>,
}
/// One watched identity: the daemon polls this forge's notifications as
/// the named account. `account_id` is the [`rhema_identity`] store's id —
/// the credential stays there, never in this table.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AccountWatchRecord {
/// The row id.
pub id: i64,
/// The forge's source name: `github`, `gitea`, or `gitlab`.
pub forge: String,
/// The instance base URL; `None` for the forges with production
/// bases. Required for gitea, which is self-hosted only.
pub host: Option<String>,
/// The identity store's account id the watch polls as.
pub account_id: String,
/// Creation time, milliseconds since the Unix epoch.
pub created_at: i64,
}
impl ClawStore {
/// Opens (creating if needed) the watch table in the database at
/// `path` — the same file the dispatcher's store holds. WAL keeps the
/// two connections from blocking each other's readers; the busy
/// timeout rides out the other's write locks.
pub fn open(path: impl AsRef<Path>) -> Result<Self, WatchStoreError> {
let conn = rusqlite::Connection::open(path).map_err(WatchStoreError::from)?;
Self::init(conn, true)
}
/// Opens a private in-memory table: the shape without the file, for
/// tests that need no sharing.
pub fn in_memory() -> Result<Self, WatchStoreError> {
let conn = rusqlite::Connection::open_in_memory().map_err(WatchStoreError::from)?;
Self::init(conn, false)
}
fn init(conn: rusqlite::Connection, durable: bool) -> Result<Self, WatchStoreError> {
if durable {
// Same pragmas as the dispatcher's store on the same file:
// SQLite stores the journal mode per database, and both
// connections must agree that it is WAL.
let _mode: String = conn
.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
.map_err(WatchStoreError::from)?;
conn.busy_timeout(std::time::Duration::from_secs(5))
.map_err(WatchStoreError::from)?;
}
conn.execute_batch(SCHEMA).map_err(WatchStoreError::from)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
/// Adds a watch. `false` when `(forge, repo)` is already watched —
/// the unique index turns a re-add into a no-op, so callers can be
/// liberal.
pub fn add(
&self,
forge: &str,
repo: &str,
host: Option<&str>,
token: &str,
) -> Result<bool, WatchStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let inserted = conn.execute(
"INSERT INTO watches (forge, repo, host, token, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(forge, repo) DO NOTHING",
params![
forge.trim(),
repo.trim(),
host.map(str::trim),
token.trim(),
now_millis()
],
)?;
Ok(inserted == 1)
}
/// Removes the watch for `(forge, repo)`; `false` when there was
/// none.
pub fn remove(&self, forge: &str, repo: &str) -> Result<bool, WatchStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let removed = conn.execute(
"DELETE FROM watches WHERE forge = ?1 AND repo = ?2",
params![forge.trim(), repo.trim()],
)?;
Ok(removed == 1)
}
/// Every watch, oldest first. Read fresh by every detect pass, so a
/// watch added at runtime takes effect without a restart.
pub fn list(&self) -> Result<Vec<WatchRecord>, WatchStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let mut stmt = conn
.prepare("SELECT id, forge, repo, host, token, created_at FROM watches ORDER BY id ASC")
.map_err(WatchStoreError::from)?;
let rows = stmt
.query_map([], |row| {
Ok(WatchRecord {
id: row.get(0)?,
forge: row.get(1)?,
repo: row.get(2)?,
host: row.get(3)?,
token: row.get(4)?,
created_at: row.get(5)?,
})
})
.map_err(WatchStoreError::from)?
.collect::<Result<Vec<_>, rusqlite::Error>>()
.map_err(WatchStoreError::from)?;
Ok(rows)
}
/// The config seed: imports `watches` when the table is empty — the
/// one-time bootstrap of a fresh deployment — and reports how many
/// landed. A populated table is never rewritten by the seed: once
/// any watch exists, the table rules and the config list is inert.
/// Only the config's own token is stored; the forge's environment
/// variable still serves at poll time when the stored one is blank.
pub fn seed_from_config(&self, watches: &[Watch]) -> Result<usize, WatchStoreError> {
if !self.list()?.is_empty() {
return Ok(0);
}
let mut seeded = 0;
for watch in watches {
let token = watch.token.as_deref().map(str::trim).unwrap_or("");
if self.add(
watch.forge.name(),
&watch.repo,
watch.host.as_deref(),
token,
)? {
seeded += 1;
}
}
Ok(seeded)
}
/// Registers the account watch: the daemon polls `forge`'s
/// notifications as the account. `false` when the account is already
/// watched on that forge. The identity's own secret is *not* passed
/// here — the watch names the account, and the token is resolved
/// through the identity store at poll time.
pub fn add_account_watch(
&self,
forge: &str,
host: Option<&str>,
account_id: &str,
) -> Result<bool, WatchStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let inserted = conn.execute(
"INSERT INTO account_watches (forge, host, account_id, created_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(forge, account_id) DO NOTHING",
params![
forge.trim(),
host.map(str::trim),
account_id.trim(),
now_millis()
],
)?;
Ok(inserted == 1)
}
/// Removes the account watch for `(forge, account_id)`; `false` when
/// there was none.
pub fn remove_account_watch(
&self,
forge: &str,
account_id: &str,
) -> Result<bool, WatchStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let removed = conn.execute(
"DELETE FROM account_watches WHERE forge = ?1 AND account_id = ?2",
params![forge.trim(), account_id.trim()],
)?;
Ok(removed == 1)
}
/// Removes every account watch of one account — the cascade its
/// `identity_forget` runs. Returns how many rows went.
pub fn remove_account_watches_for(&self, account_id: &str) -> Result<usize, WatchStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let removed = conn.execute(
"DELETE FROM account_watches WHERE account_id = ?1",
params![account_id.trim()],
)?;
Ok(removed)
}
/// Every account watch, oldest first. Read fresh by every detect
/// pass, like the repo watches.
pub fn list_account_watches(&self) -> Result<Vec<AccountWatchRecord>, WatchStoreError> {
let conn = self.conn.lock().unwrap_or_else(PoisonError::into_inner);
let mut stmt = conn
.prepare(
"SELECT id, forge, host, account_id, created_at
FROM account_watches ORDER BY id ASC",
)
.map_err(WatchStoreError::from)?;
let rows = stmt
.query_map([], |row| {
Ok(AccountWatchRecord {
id: row.get(0)?,
forge: row.get(1)?,
host: row.get(2)?,
account_id: row.get(3)?,
created_at: row.get(4)?,
})
})
.map_err(WatchStoreError::from)?
.collect::<Result<Vec<_>, rusqlite::Error>>()
.map_err(WatchStoreError::from)?;
Ok(rows)
}
}
/// Milliseconds since the Unix epoch; stamps `created_at`.
fn now_millis() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
/// The shape checks every watch must pass, wherever it enters (the
/// config seed, the HTTP route, the agent tool): a parseable forge, an
/// `owner/name` repo, and — gitea being self-hosted only — a host for
/// gitea. Tokens are not required here: a blank one falls back to the
/// forge's environment variable at poll time.
pub fn validate_watch(forge: ForgeKind, repo: &str, host: Option<&str>) -> Result<(), String> {
let watch = Watch {
forge,
repo: repo.trim().to_owned(),
host: host.map(str::trim).map(str::to_owned),
token: None,
};
watch.repo_ref()?;
if forge == ForgeKind::Gitea && watch.host.as_deref().filter(|h| !h.is_empty()).is_none() {
return Err("gitea is self-hosted: set host to the instance base URL".to_owned());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_watch_round_trips_through_the_table() {
let store = ClawStore::in_memory().expect("store opens");
assert!(
store
.add("github", "acme/widgets", None, "tok")
.expect("add lands")
);
assert!(
store
.add("gitea", "acme/barn", Some("https://git.example.net"), "")
.expect("add lands")
);
let watches = store.list().expect("list reads");
assert_eq!(watches.len(), 2);
assert_eq!(watches[0].forge, "github");
assert_eq!(watches[0].repo, "acme/widgets");
assert_eq!(watches[0].host, None);
assert_eq!(watches[0].token, "tok");
assert_eq!(watches[1].host.as_deref(), Some("https://git.example.net"));
assert_eq!(watches[1].token, "", "a missing token stores blank");
assert!(store.remove("github", "acme/widgets").expect("remove"));
assert!(!store.remove("github", "acme/widgets").expect("re-remove"));
assert_eq!(store.list().expect("list reads").len(), 1);
}
#[test]
fn a_re_add_is_a_no_op_not_an_error() {
let store = ClawStore::in_memory().expect("store opens");
assert!(
store
.add("gitlab", "group/sub/prop", None, "")
.expect("first")
);
assert!(
!store
.add("gitlab", "group/sub/prop", None, "")
.expect("second")
);
assert_eq!(store.list().expect("list reads").len(), 1);
}
#[test]
fn a_record_repo_ref_splits_owner_from_name_lazily() {
let store = ClawStore::in_memory().expect("store opens");
store
.add("gitlab", "group/subgroup/widgets", None, "")
.expect("add");
let record = &store.list().expect("list reads")[0];
assert_eq!(
record.repo_ref().expect("parses").path(),
"group/subgroup/widgets"
);
let bad = WatchRecord {
id: 0,
forge: "github".into(),
repo: "ownerless".into(),
host: None,
token: String::new(),
created_at: 0,
};
assert!(bad.repo_ref().is_err());
}
#[test]
fn the_seed_imports_only_an_empty_table() {
let store = ClawStore::in_memory().expect("store opens");
let watches = vec![Watch {
forge: ForgeKind::GitHub,
repo: "acme/widgets".to_owned(),
host: None,
token: Some(" ".to_owned()),
}];
assert_eq!(
store.seed_from_config(&watches).expect("seeds"),
1,
"the empty table takes the seed"
);
assert_eq!(
store.list().expect("list reads")[0].token,
"",
"blank stays blank"
);
assert_eq!(
store.seed_from_config(&watches).expect("skips"),
0,
"a populated table ignores the seed"
);
}
#[test]
fn validation_checks_shape_and_giteas_host() {
assert!(validate_watch(ForgeKind::GitHub, "acme/widgets", None).is_ok());
assert!(validate_watch(ForgeKind::GitHub, "ownerless", None).is_err());
assert!(validate_watch(ForgeKind::GitHub, "acme/", None).is_err());
assert!(validate_watch(ForgeKind::Gitea, "acme/barn", None).is_err());
assert!(validate_watch(ForgeKind::Gitea, "acme/barn", Some(" ")).is_err());
assert!(
validate_watch(
ForgeKind::Gitea,
"acme/barn",
Some("https://git.example.net")
)
.is_ok()
);
assert!(
validate_watch(ForgeKind::GitLab, "group/subgroup/widgets", None).is_ok(),
"gitlab has a production base"
);
}
#[test]
fn the_token_never_serializes() {
let store = ClawStore::in_memory().expect("store opens");
store
.add("github", "acme/widgets", None, "sekrit")
.expect("add");
let json =
serde_json::to_string(&store.list().expect("list reads")[0]).expect("serializes");
assert!(!json.contains("sekrit"), "token leaked: {json}");
assert!(json.contains("acme/widgets"), "{json}");
}
#[test]
fn two_connections_share_one_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("dispatch.db");
let writer = ClawStore::open(&path).expect("first connection");
let reader = ClawStore::open(&path).expect("second connection");
writer.add("github", "acme/widgets", None, "").expect("add");
let watches = reader.list().expect("the other connection sees it");
assert_eq!(watches.len(), 1);
assert_eq!(watches[0].repo, "acme/widgets");
}
}
+353
View File
@@ -0,0 +1,353 @@
//! The agent-side watch tools: how the resident agent reconfigures its
//! own watch list mid-conversation — "watch this repo too" becomes a
//! tool call, the row lands in the [`ClawStore`], and the next detect
//! pass picks it up. No restart, no second mechanism.
//!
//! These are [`Tool`] implementations for a [`ToolRegistry`], so the
//! interactive handler offers them through the same allowlist-filtered
//! seam as everything else the model may call (spec §8: the allowlist is
//! fixed before the first step, never decided mid-run). Answers are
//! short JSON; failures are in-band strings the model can react to.
//! Tokens never appear in an answer.
use std::sync::Arc;
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait};
use rhema_core::ToolSpec;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::config::ForgeKind;
use crate::watch_store::{ClawStore, validate_watch};
/// The watch-management tool set over `store`: `watch_repo`,
/// `unwatch_repo`, `list_watches`.
pub fn registry(store: Arc<ClawStore>) -> ToolRegistry {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(WatchRepo {
store: store.clone(),
}));
registry.register(Arc::new(UnwatchRepo {
store: store.clone(),
}));
registry.register(Arc::new(ListWatches { store }));
registry
}
#[derive(Deserialize)]
struct WatchArgs {
forge: String,
repo: String,
#[serde(default)]
host: Option<String>,
#[serde(default)]
token: Option<String>,
}
#[derive(Deserialize)]
struct UnwatchArgs {
forge: String,
repo: String,
}
/// Starts watching: one row in the table.
struct WatchRepo {
store: Arc<ClawStore>,
}
#[async_trait]
impl Tool for WatchRepo {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
"watch_repo",
"Start watching a repository: new activity (issues, pull \
requests, comments, reviews) arrives as its own dispatch. \
`forge` is `github`, `gitea`, or `gitlab`; `repo` is \
`owner/name` (GitLab: `namespace/project`); gitea also needs \
`host`, its instance base URL. `token` is optional and falls \
back to the forge's environment variable.",
json!({
"type": "object",
"properties": {
"forge": { "type": "string",
"description": "github, gitea, or gitlab." },
"repo": { "type": "string",
"description": "The repository as owner/name." },
"host": { "type": "string",
"description": "Instance base URL; required for gitea." },
"token": { "type": "string",
"description": "Forge token; optional, env fallback." }
},
"required": ["forge", "repo"]
}),
)
}
async fn invoke(&self, args: Value) -> Result<String, String> {
let args: WatchArgs =
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
let kind = ForgeKind::parse(&args.forge)
.ok_or_else(|| format!("unknown forge {:?}: github, gitea, or gitlab", args.forge))?;
let host = args.host.as_deref().map(str::trim);
validate_watch(kind, &args.repo, host)?;
let added = self
.store
.add(
kind.name(),
args.repo.trim(),
host,
args.token.as_deref().map(str::trim).unwrap_or(""),
)
.map_err(|err| err.to_string())?;
Ok(json!({
"watched": true,
"added": added,
"forge": kind.name(),
"repo": args.repo.trim(),
})
.to_string())
}
}
/// Stops watching: the row leaves the table.
struct UnwatchRepo {
store: Arc<ClawStore>,
}
#[async_trait]
impl Tool for UnwatchRepo {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
"unwatch_repo",
"Stop watching a repository: no more activity arrives from \
it. Same `forge` and `repo` spelling as `watch_repo`.",
json!({
"type": "object",
"properties": {
"forge": { "type": "string",
"description": "github, gitea, or gitlab." },
"repo": { "type": "string",
"description": "The repository as owner/name." }
},
"required": ["forge", "repo"]
}),
)
}
async fn invoke(&self, args: Value) -> Result<String, String> {
let args: UnwatchArgs =
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
let kind = ForgeKind::parse(&args.forge)
.ok_or_else(|| format!("unknown forge {:?}: github, gitea, or gitlab", args.forge))?;
let removed = self
.store
.remove(kind.name(), args.repo.trim())
.map_err(|err| err.to_string())?;
Ok(json!({
"watched": false,
"removed": removed,
"forge": kind.name(),
"repo": args.repo.trim(),
})
.to_string())
}
}
/// Lists what is watched.
struct ListWatches {
store: Arc<ClawStore>,
}
#[async_trait]
impl Tool for ListWatches {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
"list_watches",
"List the watched repositories: forge, repo, and host for \
each.",
json!({
"type": "object",
"properties": {},
}),
)
}
async fn invoke(&self, _args: Value) -> Result<String, String> {
let watches = self.store.list().map_err(|err| err.to_string())?;
let rows: Vec<Value> = watches
.iter()
.map(|watch| {
json!({
"forge": watch.forge,
"repo": watch.repo,
"host": watch.host,
})
})
.collect();
Ok(json!({ "watches": rows }).to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> Arc<ClawStore> {
Arc::new(ClawStore::in_memory().expect("store opens"))
}
fn names(registry: &ToolRegistry) -> Vec<String> {
registry
.resolver_for(&rhema_autonomous_agent::Allowlist::permissive())
.specs()
.into_iter()
.map(|spec| spec.name)
.collect()
}
#[tokio::test]
async fn the_registry_offers_the_three_tools() {
let tools = registry(store());
assert_eq!(
names(&tools),
["list_watches", "unwatch_repo", "watch_repo"].map(str::to_owned),
"BTreeMap order: the registry sorts by name"
);
}
#[tokio::test]
async fn watch_repo_lands_a_row_and_answers_json() {
let store = store();
let tools = registry(store.clone());
let resolver = tools.resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
let ctx = rhema_core::ToolContext::default();
let result = resolver
.resolve(
rhema_core::ToolCall {
id: "c1".into(),
name: "watch_repo".into(),
args: json!({
"forge": "github",
"repo": "acme/widgets",
"token": "sekrit",
}),
provider_data: Value::Null,
},
&ctx,
)
.await;
assert!(!result.is_error, "{}", result.content);
assert_eq!(
serde_json::from_str::<Value>(&result.content).unwrap(),
json!({"watched": true, "added": true, "forge": "github", "repo": "acme/widgets"}),
"{}",
result.content
);
let watches = store.list().expect("list reads");
assert_eq!(watches[0].repo, "acme/widgets");
assert_eq!(
watches[0].token, "sekrit",
"the token is stored, not echoed"
);
assert!(
!result.content.contains("sekrit"),
"answers never carry tokens"
);
}
#[tokio::test]
async fn a_bad_watch_fails_in_band_with_the_reason() {
let store = store();
let tools = registry(store.clone());
let resolver = tools.resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
let ctx = rhema_core::ToolContext::default();
let call = |name: &str, args: Value| {
resolver.resolve(
rhema_core::ToolCall {
id: "c1".into(),
name: name.into(),
args,
provider_data: Value::Null,
},
&ctx,
)
};
let hostless = call("watch_repo", json!({"forge": "gitea", "repo": "acme/barn"})).await;
assert!(hostless.is_error);
assert!(
hostless.content.contains("self-hosted"),
"{}",
hostless.content
);
let unknown = call(
"watch_repo",
json!({"forge": "sourcehut", "repo": "acme/barn"}),
)
.await;
assert!(unknown.is_error);
assert!(
unknown.content.contains("unknown forge"),
"{}",
unknown.content
);
let malformed = call("watch_repo", json!({"forge": "github"})).await;
assert!(malformed.is_error);
assert!(
malformed.content.contains("bad arguments"),
"{}",
malformed.content
);
assert!(
store.list().expect("list reads").is_empty(),
"nothing landed"
);
}
#[tokio::test]
async fn unwatch_and_list_round_trip_through_the_same_table() {
let store = store();
store.add("github", "acme/widgets", None, "").expect("seed");
store.add("gitlab", "group/prop", None, "").expect("seed");
let tools = registry(store.clone());
let resolver = tools.resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
let ctx = rhema_core::ToolContext::default();
let call = |name: &str, args: Value| {
resolver.resolve(
rhema_core::ToolCall {
id: "c1".into(),
name: name.into(),
args,
provider_data: Value::Null,
},
&ctx,
)
};
let listed = call("list_watches", json!({})).await;
assert!(!listed.is_error);
assert_eq!(
serde_json::from_str::<Value>(&listed.content).unwrap(),
json!({"watches": [
{"forge": "github", "repo": "acme/widgets", "host": null},
{"forge": "gitlab", "repo": "group/prop", "host": null},
]}),
"{}",
listed.content
);
let removed = call(
"unwatch_repo",
json!({"forge": "github", "repo": "acme/widgets"}),
)
.await;
assert!(!removed.is_error, "{}", removed.content);
assert_eq!(
serde_json::from_str::<Value>(&removed.content).unwrap(),
json!({"watched": false, "removed": true, "forge": "github", "repo": "acme/widgets"}),
);
assert_eq!(store.list().expect("list reads").len(), 1);
}
}
+221
View File
@@ -0,0 +1,221 @@
//! Session continuity across processes: two `--once` passes over the same
//! SQLite db and the same thread. The first pass's conversation is
//! stashed into the dispatcher's `driver_state`; the second pass's agent
//! resumes from it — the MockProvider cannot record its requests, so the
//! stashed state itself is the proof: the restored history carries both
//! exchanges.
use std::sync::Arc;
use rhema_claw::{ClawConfig, ForgeKind, Watch, run_once};
use rhema_core::{Message, MockProvider, ModelOutput, Role, Session, StopReason, Usage};
use rhema_rsd::{KeyState, Outcome, ResourceKey, SqliteStore, Store};
use serde_json::{Value, json};
use wiremock::matchers::{body_partial_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// An open issue row, created and updated in one instant: its dedup id
/// `github:issue:1007@…` never changes, so every pass's poll re-feeds it
/// and delivery stays at-least-once — the second pass sees the replay
/// plus the new comment.
fn issue_row() -> Value {
json!({
"id": 1007,
"number": 7,
"state": "open",
"title": "crash on save",
"user": { "login": "alice" },
"labels": [{ "name": "bug" }],
"body": "steps to reproduce…",
"created_at": "2026-10-02T10:00:00Z",
"updated_at": "2026-10-02T10:00:00Z",
"closed_at": null,
"html_url": "https://github.com/acme/widgets/issues/7"
})
}
/// A comment row on thread 7.
fn comment_row(body: &str) -> Value {
json!({
"id": 8001,
"user": { "login": "dave" },
"body": body,
"created_at": "2026-10-03T09:00:00Z",
"updated_at": "2026-10-03T09:00:00Z",
"issue_url": "https://api.github.com/repos/acme/widgets/issues/7",
"html_url": "https://github.com/acme/widgets/issues/7#issuecomment-8001"
})
}
fn reply(text: &str) -> ModelOutput {
ModelOutput {
message: Message::text(Role::Assistant, text),
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
}
}
fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig {
ClawConfig {
db: Some(dir.path().join("dispatch.db")),
workspace_root: Some(dir.path().join("workspaces")),
memory_root: None,
bootstrap_root: None,
poll_interval_secs: 60,
max_turn_steps: rhema_claw::config::DEFAULT_MAX_TURN_STEPS,
provider: Some("google".to_owned()),
model: Some("test-model".to_owned()),
listen: None,
token: None,
watches: vec![Watch {
forge: ForgeKind::GitHub,
repo: "acme/widgets".to_owned(),
host: Some(server.uri()),
token: Some("tok".to_owned()),
}],
calendars: Vec::new(),
}
}
/// The thread's standing state: open, unchanged across both passes.
async fn mount_snapshot(server: &MockServer) {
Mock::given(method("GET"))
.and(path("/repos/acme/widgets/issues/7"))
.respond_with(ResponseTemplate::new(200).set_body_json(issue_row()))
.mount(server)
.await;
}
#[tokio::test]
async fn the_second_dispatch_resumes_the_first_conversation() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/acme/widgets/issues"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([issue_row()])))
.mount(&server)
.await;
// The comments feed answers per pass: quiet first, then dave's
// comment. Same matchers, mounted in order — the first serves the
// first request only, then falls through.
Mock::given(method("GET"))
.and(path("/repos/acme/widgets/issues/comments"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/acme/widgets/issues/comments"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!([comment_row("any progress?")])),
)
.mount(&server)
.await;
mount_snapshot(&server).await;
let first_post = Mock::given(method("POST"))
.and(path("/repos/acme/widgets/issues/7/comments"))
.and(body_partial_json(json!({ "body": "on it" })))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 43 })))
.expect(1)
.mount_as_scoped(&server)
.await;
let second_post = Mock::given(method("POST"))
.and(path("/repos/acme/widgets/issues/7/comments"))
.and(body_partial_json(json!({ "body": "still on it" })))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 44 })))
.expect(1)
.mount_as_scoped(&server)
.await;
let dir = tempfile::tempdir().expect("tempdir");
let config = config(&server, &dir);
let key = ResourceKey::from("github:acme/widgets#7");
// Pass one: the opened event alone. The cron shape — a fresh process,
// a fresh watcher, the same db.
let first = run_once(
&config,
mock_registry(Arc::new(MockProvider::new([reply("on it")]))),
)
.await
.expect("the first pass runs");
assert_eq!(first.detected, 1);
assert_eq!(first.outcomes, vec![Outcome::Handled]);
drop(first_post);
// Pass two: the replayed opened event plus dave's comment, both
// decoded into one prompt for one resumed run.
let second = run_once(
&config,
mock_registry(Arc::new(MockProvider::new([reply("still on it")]))),
)
.await
.expect("the second pass runs");
assert_eq!(second.detected, 2, "the replay re-delivers, at-least-once");
assert_eq!(second.outcomes, vec![Outcome::Handled]);
drop(second_post);
// The proof: the second run's stashed session carries the first
// conversation — a fresh agent would hold only its own exchange.
let store = SqliteStore::open(dir.path().join("dispatch.db")).expect("db reopens");
let record = store
.load(&key)
.expect("the record loads")
.expect("the key has a record");
assert_eq!(record.state, KeyState::Idle);
let stashed: Session = serde_json::from_value(
record
.session
.expect("the key has a session")
.core
.driver_state,
)
.expect("the stash parses back into a session");
let user_prompts: Vec<&str> = stashed
.history
.iter()
.filter_map(|message| match &message.content[0] {
rhema_core::Content::Text(text) if message.role == Role::User => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(
user_prompts.len(),
2,
"both dispatches prompted: {user_prompts:?}"
);
assert!(
user_prompts[0].contains("opened by alice"),
"pass one's prompt carried the opened event"
);
assert!(
user_prompts[1].contains("any progress?"),
"pass two's prompt carried the new comment"
);
let replies: Vec<&str> = stashed
.history
.iter()
.filter_map(|message| match &message.content[0] {
rhema_core::Content::Text(text) if message.role == Role::Assistant => {
Some(text.as_str())
}
_ => None,
})
.collect();
assert_eq!(
replies,
["on it", "still on it"],
"the conversation resumed"
);
}
/// A registry whose only backend is the scripted mock — the shape every
/// integration test runs on.
fn mock_registry(provider: Arc<dyn rhema_core::ModelProvider>) -> Arc<rhema_claw::BackendRegistry> {
Arc::new(
rhema_claw::BackendRegistry::new(
vec![rhema_claw::Backend::headless("mock", provider)],
None,
)
.expect("a single mock backend always constructs"),
)
}
+136
View File
@@ -0,0 +1,136 @@
//! Offline end-to-end: one `--once` pass over a wiremock GitHub — the
//! feed is polled, the MockProvider's reply lands as the thread's
//! comment, and the dispatch state persists in the SQLite db. Zero
//! network.
use std::sync::Arc;
use rhema_claw::{ClawConfig, ForgeKind, Watch, run_once};
use rhema_core::{Message, MockProvider, ModelOutput, Role, StopReason, Usage};
use rhema_rsd::{
Dispatcher, DispatcherConfig, KeyState, Outcome, ResourceKey, SqliteStore, SystemClock,
};
use serde_json::{Value, json};
use wiremock::matchers::{body_partial_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// A closed issue row, updated at `updated_at`: the feed's event source.
fn issue_row(updated_at: &str) -> Value {
json!({
"id": 1007,
"number": 7,
"state": "closed",
"title": "crash on save",
"user": { "login": "alice" },
"labels": [{ "name": "bug" }],
"body": "steps to reproduce…",
"created_at": "2026-10-01T09:00:00Z",
"updated_at": updated_at,
"closed_at": "2026-10-02T10:00:00Z",
"html_url": "https://github.com/acme/widgets/issues/7"
})
}
/// The scripted assistant turn: one text reply, nothing else.
fn reply(text: &str) -> ModelOutput {
ModelOutput {
message: Message::text(Role::Assistant, text),
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
}
}
/// The test's claw config: everything under one tempdir, the watch aimed
/// at the mock server. The provider never resolves through it — the
/// daemon library takes the built provider, and the test builds a mock.
fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig {
ClawConfig {
db: Some(dir.path().join("dispatch.db")),
workspace_root: Some(dir.path().join("workspaces")),
memory_root: None,
bootstrap_root: None,
poll_interval_secs: 60,
max_turn_steps: rhema_claw::config::DEFAULT_MAX_TURN_STEPS,
provider: Some("google".to_owned()),
model: Some("test-model".to_owned()),
listen: None,
token: None,
watches: vec![Watch {
forge: ForgeKind::GitHub,
repo: "acme/widgets".to_owned(),
host: Some(server.uri()),
token: Some("tok".to_owned()),
}],
calendars: Vec::new(),
}
}
#[tokio::test]
async fn one_pass_polls_reasons_and_posts() {
let server = MockServer::start().await;
// The feeds a first poll reads: the issues list (one closed issue)
// and the quiet comments feed.
Mock::given(method("GET"))
.and(path("/repos/acme/widgets/issues"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!([issue_row("2026-10-02T10:00:00Z")])),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/acme/widgets/issues/comments"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
.mount(&server)
.await;
// The handler's orientation call: the thread snapshot.
Mock::given(method("GET"))
.and(path("/repos/acme/widgets/issues/7"))
.respond_with(ResponseTemplate::new(200).set_body_json(issue_row("2026-10-02T10:00:00Z")))
.mount(&server)
.await;
// The reply, counted: the assertion is that it posts exactly once
// with the mock's text.
let comment = Mock::given(method("POST"))
.and(path("/repos/acme/widgets/issues/7/comments"))
.and(body_partial_json(json!({ "body": "on it" })))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 43 })))
.expect(1)
.mount_as_scoped(&server)
.await;
let dir = tempfile::tempdir().expect("tempdir");
let config = config(&server, &dir);
let provider = Arc::new(MockProvider::new([reply("on it")]));
let report = run_once(&config, mock_registry(provider))
.await
.expect("the pass runs");
assert_eq!(report.detected, 1, "the feed's one event was fed");
assert_eq!(report.outcomes, vec![Outcome::Handled]);
drop(comment);
// Reopen the db cold: the dispatch state persisted — the key is idle,
// drained, and carries its outcome.
let store = SqliteStore::open(dir.path().join("dispatch.db")).expect("db reopens");
let dispatcher = Dispatcher::new(
DispatcherConfig::default(),
Arc::new(SystemClock),
Arc::new(store),
);
let key = ResourceKey::from("github:acme/widgets#7");
let status = dispatcher.status(&key).expect("status reads");
assert_eq!(status.state, KeyState::Idle);
assert_eq!(status.inbox_depth, 0);
assert_eq!(status.last_outcome, Some(Outcome::Handled));
}
/// A registry whose only backend is the scripted mock — the shape every
/// integration test runs on.
fn mock_registry(provider: Arc<dyn rhema_core::ModelProvider>) -> Arc<rhema_claw::BackendRegistry> {
Arc::new(
rhema_claw::BackendRegistry::new(
vec![rhema_claw::Backend::headless("mock", provider)],
None,
)
.expect("a single mock backend always constructs"),
)
}
File diff suppressed because it is too large. Load diff