build: the cloud frontend stands alone
The Durable Object, the sandbox daemon, and their deploy assets leave the rhema workspace for a repository of their own. The DO was already a deployment artifact (publish = false, worker feature wasm-only), and rhema-sandbox-server's only consumer was the image beside it — both are product, not framework. The harness and the seam stay behind: rhema-cloud-agent and the sandbox family join the rhema repository's publish list, and this tree consumes them from crates.io at 0.2.6, the same arrangement the claw keeps its framework crates under. The sandboxd image build carries the fix its extraction needed: the old Dockerfile still staged the claw's workspace (claw and tools manifests, integrations globs, the vendored crossterm) and missed rhema-provider-registry's manifest entirely. The manifest stage is now this workspace's two members, fetched from the registry. Not yet buildable: the rhema 0.2.6 batch (rhema-sandbox, -local, -http, rhema-cloud-agent) must publish first — then one cargo check generates Cargo.lock and CI goes green.
This commit is contained in:
commit
c1059c23e0
26 files changed
+3304
No files matched your search
@@ -0,0 +1,302 @@
|
||||
# Handoff: implement `rhema-cloudflare-code` — the coding agent as a Cloudflare Durable Object
|
||||
|
||||
> Handoff prompt for an implementation session. Everything needed is in this
|
||||
> file; a fresh session will not have the conversation that produced it.
|
||||
> Verified facts in §1 were established against this tree by compiling —
|
||||
> re-verify only if the tree has moved since (git log, then the §1 checks).
|
||||
|
||||
## 0. Mission
|
||||
|
||||
Add a new frontend consumer of `rhema-coding-agent` (alongside `rhema-code` and
|
||||
`rhema-acp`) named **`rhema-cloudflare-code`**, under `crates/`. It runs the
|
||||
coding agent inside a Cloudflare **Durable Object**, with the agent's `bash`
|
||||
tool executing inside Cloudflare's **Sandbox**
|
||||
(https://developers.cloudflare.com/agents/tools/sandbox/ — a Durable
|
||||
Object-on-Container product: exec, file ops, idle sleep, persisted workspace).
|
||||
|
||||
Result: a WebSocket/HTTP-addressable coding agent whose sessions checkpoint
|
||||
into DO storage, whose workspace lives in the sandbox container, and which
|
||||
survives DO/container eviction via the engine's existing crash-resume machinery.
|
||||
|
||||
Non-goal: porting the terminal UI, the claw daemon, or the local-only provider
|
||||
stack to wasm. Non-goal v1: any provider adapter other than Anthropic.
|
||||
|
||||
## 1. Verified ground truth (already established; do not re-derive)
|
||||
|
||||
Verified in this tree with `cargo check --target wasm32-unknown-unknown`
|
||||
(add the target first: `rustup target add wasm32-unknown-unknown`; toolchain
|
||||
pinned at 1.98.1 in `rust-toolchain.toml`):
|
||||
|
||||
**Compiles to wasm today, unchanged:**
|
||||
- `rhema-core` (deps: serde, serde_json, thiserror, async-trait only — no
|
||||
tokio, fs, threads, or time)
|
||||
- `rhema-provider` (including the transport-agnostic `SseDecoder` in
|
||||
`src/stream.rs`, which consumes byte slices)
|
||||
- `rhema-transcript`
|
||||
- `rhema-rsd`, `rhema-autonomous-agent`, `rhema-memory`, `rhema-config` with
|
||||
`--no-default-features` (excludes the `sqlite` feature)
|
||||
|
||||
**Does not compile to wasm, with exact reasons:**
|
||||
- `rhema-agent-tools` (and transitively `rhema-subagents`):
|
||||
`crates/rhema-agent-tools/src/shell.rs:137` hardcodes
|
||||
`tokio::process::Command::new("bash")`, and the crate enables tokio's
|
||||
`process`/`time` features; `mio` does not build on wasm.
|
||||
- `rhema-provider-anthropic`, two reqwest-on-wasm issues:
|
||||
(a) `connect_timeout` does not exist on wasm (`provider.rs:70`) — fetch has
|
||||
no socket knobs; (b) `response.bytes_stream()` (`provider.rs:357`) is backed
|
||||
by JS objects (`Rc<RefCell<js_sys::futures::Inner>>`) and therefore `!Send`,
|
||||
while `#[async_trait]` requires `Send` futures.
|
||||
|
||||
**Why `Send` is non-negotiable:** `EventSink: Send`
|
||||
(`rhema-core/src/event.rs:80`, reason documented at :78-79) — the sink
|
||||
reference crosses await points inside provider implementations. All
|
||||
JS-backed wasm-bindgen values are `!Send` by soundness (raw handles into the
|
||||
JS heap); this cannot be "fixed upstream". The fix is always bridging: run the
|
||||
`!Send` stream in `wasm_bindgen_futures::spawn_local` and forward plain
|
||||
`Vec<u8>` chunks over a channel whose receiver *is* `Send` on single-threaded
|
||||
wasm (plain data is trivially `Send` there). The `async-compat` crate packages
|
||||
exactly this pattern.
|
||||
|
||||
**Runtime facts (workers):** workerd gives wasm no owned event loop, no
|
||||
sockets, no filesystem, no Rust-side timers. All I/O goes through host
|
||||
bindings (`fetch`, DO storage, sandbox bindings). `tokio::time` sleeps do not
|
||||
fire inside workerd; use `worker::Delay`. reqwest-on-wasm has no streaming
|
||||
bodies worth using — do not attempt to keep it. rusqlite technically
|
||||
*compiles* on wasm but cannot open files in workerd — compile ≠ works.
|
||||
|
||||
**Relevant existing seams (read these files first):**
|
||||
- `rhema-core/src/provider.rs` — `ModelProvider::complete(&self, ModelRequest,
|
||||
&mut dyn EventSink) -> Result<ModelOutput, ProviderError>`. IMPORTANT:
|
||||
`ProviderError::is_retryable()` parses the `"... (HTTP <status>): ..."`
|
||||
message shape — any new transport must keep adapters formatting errors that
|
||||
way, and must map transport failures to
|
||||
`ProviderError::Other("network error: ...")` (that prefix is what makes
|
||||
them retryable).
|
||||
- `rhema-provider/src/retry.rs` — `Retrying` decorator: 3 attempts, 1 s base /
|
||||
2 s cap, never retries once an event was emitted, retries an empty success.
|
||||
Sleeps via `tokio::time` (tests use `start_paused`; see `retry.rs:320`).
|
||||
- `rhema-agent-tools/src/shell.rs` — `ShellTools`: the ONLY tool is `bash`
|
||||
("reading, listing, and editing are shell work, not separate tools").
|
||||
Output caps: 50 KiB / 2000 lines per stream (`BASH_OUTPUT_LIMIT`,
|
||||
`BASH_OUTPUT_LINES`); `BASH_TIMEOUT` = 120 s; approval consulted per call
|
||||
from `pending: Mutex<Option<Arc<dyn Approval>>>`; no hook installed =
|
||||
reject. `ShellTools::builtin_specs()` is the single source of truth for the
|
||||
tool spec, and the system-prompt renderer renders the same specs so the
|
||||
prompt and the request's tools array cannot drift — preserve this invariant.
|
||||
- `rhema-agent-tools/src/approval.rs` — `trait Approval: Send + Sync`
|
||||
(+ `AllowAll`, `DenyAll`).
|
||||
- `rhema-coding-agent/src/agent.rs` — the driver: owns one `ShellTools` for
|
||||
the agent lifetime, installs the per-turn approval hook, runs
|
||||
`revive_dangling_calls` on resume (committed-but-unanswered calls:
|
||||
replay-safe tools re-run, others reported interrupted),
|
||||
`stamped_system(base, workspace, extra)` assembles the system prompt
|
||||
(discovers skills from the global directory internally; sessions save/load
|
||||
via `std::fs` at `agent.rs:233/238`).
|
||||
- `rhema-core/src/session.rs` — `Session { history, driver_state: Value,
|
||||
cost_usd }`: plain serde, checkpoint anywhere.
|
||||
- `rhema-core/src/tool.rs` — `ToolContext { cwd: PathBuf, run_id,
|
||||
cancelled: Arc<AtomicBool> }`; `ReplaySafety` per tool.
|
||||
- `rhema-rsd/src/store.rs:117` — `trait Store { load(key); save(record,
|
||||
expected_revision); keys() }` — revision-guarded persistence over any
|
||||
store. A DO is the single writer per key, so the revision check can be
|
||||
maintained in-memory + persisted counter.
|
||||
- `AGENTS.md` — conventions; summary in §5 below.
|
||||
|
||||
## 2. Target architecture
|
||||
|
||||
```
|
||||
browser/editor client
|
||||
| WebSocket (chat, streaming deltas, approval prompts) + HTTPS
|
||||
v
|
||||
CodingAgent DO (workers-rs, wasm32-unknown-unknown; one instance per session id)
|
||||
|- turn loop ........... rhema-coding-agent over rhema-core::run_turn
|
||||
|- step cost control ... rhema-transcript (on by default)
|
||||
|- provider ............ Anthropic Messages over host fetch (new transport)
|
||||
|- session checkpoints . DO SQLite storage (rev-guarded, rhema-rsd Store impl)
|
||||
|- approvals ........... DenyAll default; WS round-trip or per-session policy
|
||||
|- memory/subagents .... rhema-memory tools; subagents inherit the sandbox
|
||||
`- bash tool ........... -> Sandbox DO (same session name) over HTTP
|
||||
`- container: bash -c in the workspace
|
||||
```
|
||||
|
||||
One DO instance per session (`idFromName(sessionId)`), binding to the sandbox
|
||||
instance of the same name. Turn driving v1: the turn runs inside the client's
|
||||
live request while its WebSocket is connected (streaming deltas live);
|
||||
checkpoint the session after every step so any eviction resumes via
|
||||
`revive_dangling_calls`. Alarm-driven step queues are a v2 refinement.
|
||||
|
||||
## 3. Layout: one crate, strict module hygiene
|
||||
|
||||
The crate is `crates/rhema-cloudflare-code/` (auto-added by the workspace's
|
||||
`members = ["crates/*"]`). Two families of modules, never mixed:
|
||||
|
||||
**Platform-neutral modules** (no wasm-bindgen, no `worker`, no fetch deps —
|
||||
host-side `cargo test` must compile and run them):
|
||||
- `sandbox` — `trait Sandbox` (object-safe, async): `exec(ExecRequest) ->
|
||||
streamed stdout/stderr lines + exit code` (+ v1: workspace seed — git clone
|
||||
URL or tarball upload; file get/put if the skills flow needs it).
|
||||
`ExecRequest` carries command, cwd, timeout (echo the 120 s default),
|
||||
cancellation. `ShellError` maps transport failure so the bash tool result
|
||||
stays an in-band `is_error` result, never an abort.
|
||||
- `shell` — `ShellSandbox`: the `bash` tool resolver (or the sandbox-side
|
||||
`ShellExecutor` impl for Phase 1's seam) exposing the SAME spec as
|
||||
`ShellTools::builtin_specs()` — reuse it verbatim (its description says
|
||||
"Requires user approval", which must stay true).
|
||||
- `assembly`/config types — model id, api key source, approval policy,
|
||||
agent construction.
|
||||
- Scripted `Sandbox` impl for offline tests (house pattern: `rhema-core`'s
|
||||
`mock` feature), so the full turn loop tests host-side with
|
||||
`rhema-core`'s `MockProvider`.
|
||||
- Every platform-neutral module carries a module-level
|
||||
`#![forbid(unsafe_code)]`; the crate carries `#![deny(missing_docs)]`.
|
||||
|
||||
**The `worker` module** (and ONLY it): the workers-rs glue — the
|
||||
`#[durable_object] class CodingAgent`, the host-fetch provider transport, the
|
||||
`!Send` bridge, the `worker::Delay` sleeper, the DO-storage impls
|
||||
(`rhema-rsd::Store`, `rhema-memory::MemoryStore`; prefer a SQLite-backed DO
|
||||
class — KV values cap at 128 KiB and sessions grow; record the workers-rs
|
||||
version you pin and why, it is 0.x and APIs move).
|
||||
|
||||
This module is why the crate cannot carry a crate-level
|
||||
`#![forbid(unsafe_code)]`: the `#[durable_object]` macro emits unsafe
|
||||
internally and `forbid` cannot be overridden by `allow` in inner scopes.
|
||||
Document that exception in `src/lib.rs` (precedent for sanctioned exceptions:
|
||||
`vendor/crossterm` is excluded for toolchain reasons).
|
||||
|
||||
## 4. Phased work plan
|
||||
|
||||
### Phase 1 — shell seam in `rhema-agent-tools` (unblocks wasm + is the core abstraction)
|
||||
- Extract the execution half of `shell.rs` into a seam, e.g.
|
||||
`trait ShellExecutor: Send + Sync { async fn run(&self, command: &str,
|
||||
cwd: &Path, timeout: Duration, cancelled: &AtomicBool, progress: callback)
|
||||
-> Result<Executed, ShellError> }` where `Executed` = stdout tail, stderr
|
||||
tail, exit code. `ShellTools` keeps: the spec, the approval gate, caps,
|
||||
timeout, error-shaping; it delegates execution.
|
||||
- Local impl = today's `tokio::process` code verbatim, behind a default-on
|
||||
feature (e.g. `local-shell`). The crate's tokio `process`/`time` features
|
||||
become feature-dependent so the wasm build excludes them.
|
||||
- Accept: `cargo check -p rhema-agent-tools -p rhema-subagents --target
|
||||
wasm32-unknown-unknown --no-default-features` is clean; native tests green
|
||||
(`cargo test -p rhema-agent-tools`); existing `ShellTools` behavior
|
||||
unchanged (caps, timeout-kill, line-streaming, approval).
|
||||
|
||||
### Phase 2 — session/skills/prompt inputs in `rhema-coding-agent` (small)
|
||||
- Sessions: the cloud crate serializes `Session` itself; if the fs helpers at
|
||||
`agent.rs:233/238` are the only consumers, no seam is needed — confirm and
|
||||
note it.
|
||||
- Skills/system prompt: `stamped_system` discovers skills from the filesystem
|
||||
internally. Add an input path that accepts an already-collected skill list
|
||||
(keep the fs discovery as the default used by `rhema-code`), so the cloud
|
||||
crate can feed skills from DO storage or discovered inside the sandbox.
|
||||
SYSTEM.md `base` is already a parameter — the cloud crate supplies it from
|
||||
DO storage with the built-in default as fallback.
|
||||
- Accept: native behavior unchanged; the cloud crate can assemble a full
|
||||
agent with zero filesystem access.
|
||||
|
||||
### Phase 3 — transport seam in the provider family
|
||||
- Add a `Transport` trait to `rhema-provider` (it is the adapter-family
|
||||
base): request in (method/url/headers/json body) → status + a **Send**
|
||||
stream of byte chunks out. Contract: JS types must never leak into the
|
||||
trait; on wasm, bridge via `spawn_local` + channel (or `async-compat`).
|
||||
- `rhema-provider-anthropic`: put reqwest behind a default-on `native`
|
||||
feature; take `Arc<dyn Transport>`; move `connect_timeout` and the
|
||||
"network error: " mapping into the native impl; keep the
|
||||
`(HTTP <status>):` error shape; the `bytes_stream` while-loop becomes
|
||||
transport-agnostic over the shared `SseDecoder`.
|
||||
- `rhema-provider/src/retry.rs`: make `Retrying`'s sleep injectable (additive
|
||||
builder, e.g. `with_sleeper`), default = today's tokio behavior so existing
|
||||
tests stay green. The cloud crate passes a `worker::Delay` sleeper.
|
||||
- Accept: `cargo check --workspace --all-features` green natively; wasm check
|
||||
green for `rhema-provider` and the anthropic wire module with
|
||||
`--no-default-features`; no wire-type duplication between native and fetch
|
||||
paths.
|
||||
|
||||
### Phase 4 — `rhema-cloudflare-code` itself
|
||||
- Neutral half first: `sandbox` trait + scripted mock, `ShellSandbox`,
|
||||
assembly; host-side e2e (scripted sandbox + `MockProvider`): full turn with
|
||||
a bash call, approval round-trip, session checkpoint, and a simulated crash
|
||||
mid-command whose resume reports the call interrupted (not re-run).
|
||||
- Then the `worker` module: DO class `CodingAgent` — WS chat surface (stream
|
||||
`Event`s as they arrive; approval requests as WS round-trips with
|
||||
deny-on-timeout default and per-session policy: deny-all / read-only
|
||||
allowlist / allow-all flag), session checkpointing after every step,
|
||||
`run_id` per turn, `ToolContext.cancelled` wired to a disconnect/stop
|
||||
message; `alarm` reserved for v2.
|
||||
- Approval plumbing note: approval fires inside tool resolution inside the
|
||||
turn task; the WS handler and turn task share workerd's single thread —
|
||||
connect them with channels, never shared-blocking waits.
|
||||
- Secrets via wrangler secrets (`env.secret(...)`), never DO storage.
|
||||
Profiles in DO storage; do not use `rhema-config`'s XDG store here.
|
||||
- Sandbox HTTP client: call the Sandbox DO over its binding (`stub.fetch()`
|
||||
with JSON commands). If the current Sandbox surface is JS-RPC-only, write a
|
||||
~30-line JS shim DO/worker that converts plain JSON HTTP → sandbox calls
|
||||
(prefer this over fighting JS RPC from Rust).
|
||||
|
||||
### Phase 5 — deploy assets + CI
|
||||
- `deploy/cloudflare/`: wrangler config (`main = build/worker/shim.mjs`,
|
||||
`new_sqlite_classes` migration, DO + sandbox namespace bindings, secrets
|
||||
docs), the optional JS shim, README notes — sibling of `docker/` and
|
||||
`helm/`.
|
||||
- Add the wasm gate to CI (scope `ci`): `cargo check` over the cloud closure
|
||||
with `--target wasm32-unknown-unknown` (the full crate with the `worker`
|
||||
feature is the real build; keep it green).
|
||||
- Accept: a fresh clone with `wrangler dev` + one secret works.
|
||||
|
||||
### Phase 6 — docs & conventions debt
|
||||
- README: a component mention for `rhema-cloudflare-code`. AGENTS.md: add
|
||||
commit scope
|
||||
`cloudflare-code`. SPEC.md: the design-decision section (why DO + sandbox;
|
||||
the replay-safety implications of container eviction; the approval model;
|
||||
the turn-driving model).
|
||||
|
||||
## 5. Guardrails (from AGENTS.md and this design — non-negotiable)
|
||||
|
||||
- Per commit: `cargo fmt --all`, `cargo clippy --workspace --all-targets
|
||||
--all-features -- -D warnings`, `cargo test --workspace --all-features`
|
||||
green. Conventional commits with component scopes, linear history (rebase,
|
||||
no merge commits).
|
||||
- Do not weaken `#![forbid(unsafe_code)]` / `#![deny(missing_docs)]` anywhere
|
||||
except the sanctioned `worker` module exception (documented in §3).
|
||||
- No wasm-bindgen, fetch, or `worker` dependencies in `rhema-core`,
|
||||
`rhema-coding-agent`, `rhema-agent-tools`; none in the platform-neutral
|
||||
modules of `rhema-cloudflare-code`. The platform lives at the edges.
|
||||
- `rhema-core` keeps exactly its two engine consumers (coding agent, RSD
|
||||
agent) — the cloud crate is a new *frontend*, not a new engine consumer.
|
||||
Dispatch/lease/persistence belongs in `rhema-rsd`, never in the engine.
|
||||
- Keep the single `bash` spec as the source of truth shared by prompt and
|
||||
tools array; never fork the spec text.
|
||||
- Do not attempt to make reqwest, `tokio::time`, or rusqlite work inside
|
||||
workerd. Host bindings or nothing.
|
||||
|
||||
## 6. Open questions to resolve from current Cloudflare docs (verify, don't assume)
|
||||
|
||||
1. The Sandbox's exact callable surface today (REST-over-DO-fetch vs JS RPC
|
||||
only) and its auth between your DO and the sandbox binding. If RPC-only,
|
||||
build the JS shim (Phase 4).
|
||||
2. Sandbox idle-sleep model and workspace persistence guarantees — map onto
|
||||
`ReplaySafety` explicitly: a command killed by container sleep/eviction is
|
||||
the committed-unanswered-call case; `bash` is `Unsafe` to replay, so
|
||||
resume must report it interrupted (existing machinery — verify the wiring
|
||||
reaches it).
|
||||
3. Current workers-rs DO support level: `DurableObject` trait shape, WS
|
||||
hibernation, SQLite (`SqlStorage`) bindings — pin the version accordingly.
|
||||
4. Current CPU-limit and subrequest-limit numbers (per invocation), and
|
||||
whether the target plan's limits fit a live-request turn (~1 model fetch +
|
||||
1-2 sandbox calls + approval round-trips per step; a 16-step turn ≈ 30-50
|
||||
subrequests). If not, implement the alarm-driven step queue (v2) instead
|
||||
of fighting limits.
|
||||
|
||||
## 7. Definition of done
|
||||
|
||||
- [ ] All three gate commands green: fmt / clippy (-D warnings) / test
|
||||
(`--workspace --all-features`), plus the wasm check over the cloud
|
||||
closure.
|
||||
- [ ] Offline e2e test (host-side): scripted sandbox + `MockProvider` → full
|
||||
turn with a bash call, approval round-trip, session checkpoint, and a
|
||||
simulated crash mid-command whose resume reports the call interrupted
|
||||
(not re-run).
|
||||
- [ ] `wrangler dev` e2e: real Anthropic key, chat drives real bash in the
|
||||
sandbox; DO restart resumes the session; workspace files persist.
|
||||
- [ ] README row, AGENTS.md scope, SPEC.md section landed.
|
||||
- [ ] A short "running it" section in `deploy/cloudflare/` (secrets, deploy,
|
||||
limits, pricing caveat for containers).
|
||||
Reference in new issue
Block a user