M5c-1 plan: task 10, the Honcho clients and the ensure steps in memoryd
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,362 @@
|
|||||||
|
# M5c-1 task 10: `memoryd`'s Honcho clients, and making sure a workspace, its peers and a session exist
|
||||||
|
|
||||||
|
**Branch:** `m5c1` (run `git switch m5c1`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `memoryd: a Honcho client per workspace, and its workspace, peers and sessions made sure of`
|
||||||
|
**Log row:** the Task cell is `M5c1/10-memoryd-honcho-client`.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Tasks 11 and 13 send turns, conclusions and fleet messages to Honcho. This task gives them what
|
||||||
|
they send through (M5c spec, sections 3 and 6):
|
||||||
|
|
||||||
|
- **A client per workspace.** One `roncho` blocking client for each of this agent's three
|
||||||
|
workspaces (`[honcho] trusted`, `untrusted`, `fleet`), each with its own key when
|
||||||
|
`[secrets.honcho_trusted]` (and so on) is configured. Every client is built with
|
||||||
|
`.without_config_file()`: without it `roncho` reads `~/.config/roncho/roncho.toml` and takes an
|
||||||
|
`api_key` from it. Timeouts: connect 5 s, read ("no byte for") 30 s; chat, which M5c-2 uses, 180
|
||||||
|
s (the constant only, here).
|
||||||
|
- **Making sure things exist** before anything is written: the workspace, the agent's peer, the
|
||||||
|
speakers' peers, then the session. Each is Honcho's get-or-create (`POST`: 201 when made, 200
|
||||||
|
when it was there; both are success, and so is a 409). A session must be made before a message
|
||||||
|
is added to it: Honcho makes a missing one on the fly, with no peers configured (recorded:
|
||||||
|
`20-messages-add-missing-session.json`).
|
||||||
|
- The fleet workspace is made with `reasoning`, `summary` and `dream` all `enabled: false`; the
|
||||||
|
other two with Honcho's defaults (no configuration sent).
|
||||||
|
- The agent's peer is made with `observe_me: false`; a speaker's peer with nothing.
|
||||||
|
- A session holds the agent's peer (`observe_me: false, observe_others: false`) and each
|
||||||
|
speaker's (`observe_me: true, observe_others: false`).
|
||||||
|
- What was made sure of is remembered in memory for the run. After a restart everything is made
|
||||||
|
sure of again, which costs one request each and changes nothing.
|
||||||
|
- **Which Honcho session a turn goes to** (spec 3): the Boxmaker session's id; a speaker new to it
|
||||||
|
starts the next, `<id>-2`, `<id>-3`, holding every speaker so far (a Honcho session's peers are
|
||||||
|
fixed when it is made). The numbering comes from the ledger alone: the speakers of that
|
||||||
|
session's decided `Turn` actions, in any workspace, in the order of the decisions, each counted
|
||||||
|
once, up to and including the turn. A restart numbers the same.
|
||||||
|
- **Errors by kind and status, never a body.** A 422 echoes the whole request, memory text
|
||||||
|
included, and `roncho`'s `Status` and `Decode` errors carry up to 4 KiB of the response. So a
|
||||||
|
`HonchoError` keeps only a kind (a fixed word) and a status.
|
||||||
|
- **Deleting a conclusion** (for task 13, which uses it): 204 is done, and 404 (deleted before) is
|
||||||
|
done too.
|
||||||
|
|
||||||
|
Nothing calls this yet: task 11 builds the clients at start and sends turns. No request goes to
|
||||||
|
a real Honcho in any test: a fake Honcho on `127.0.0.1` answers from the responses the design
|
||||||
|
model recorded from rift in task 00, `crates/memoryd/tests/fixtures/honcho/`, which this task
|
||||||
|
copies in.
|
||||||
|
|
||||||
|
## Interfaces
|
||||||
|
|
||||||
|
In `crates/memoryd/src/honcho/client.rs` (the module `memoryd::honcho::client`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub const CONNECT: Duration = Duration::from_secs(5);
|
||||||
|
pub const READ: Duration = Duration::from_secs(30);
|
||||||
|
pub const CHAT_READ: Duration = Duration::from_secs(180);
|
||||||
|
pub struct Timeouts { pub connect: Duration, pub read: Duration } // Timeouts::WRITES = (CONNECT, READ)
|
||||||
|
pub enum HonchoError {
|
||||||
|
Unreachable { kind: &'static str, status: Option<u16> },
|
||||||
|
Refused { kind: &'static str, status: Option<u16> },
|
||||||
|
KeyRefused { status: u16 },
|
||||||
|
}
|
||||||
|
impl HonchoError { pub fn kind(&self) -> &'static str; pub fn status(&self) -> Option<u16>; }
|
||||||
|
pub fn classify(error: &roncho::blocking::Error) -> HonchoError;
|
||||||
|
pub fn fleet_configuration() -> serde_json::Map<String, Value>;
|
||||||
|
pub fn session_peers(agent: &str, speakers: &[String]) -> HashMap<String, SessionPeerConfig>;
|
||||||
|
pub struct Clients { /* private */ }
|
||||||
|
impl Clients {
|
||||||
|
pub fn new(config: &HonchoConfig, env: &dyn Fn(&str) -> Option<OsString>, timeouts: Timeouts)
|
||||||
|
-> Result<(Clients, Vec<String>), String>;
|
||||||
|
pub fn client(&self, workspace: Workspace) -> &roncho::blocking::Client;
|
||||||
|
pub fn ensure_workspace(&mut self, workspace: Workspace) -> Result<(), HonchoError>;
|
||||||
|
pub fn ensure_peer(&mut self, workspace: Workspace, peer: &str) -> Result<(), HonchoError>;
|
||||||
|
pub fn ensure_session(&mut self, workspace: Workspace, session: &HonchoSession)
|
||||||
|
-> Result<(), HonchoError>;
|
||||||
|
pub fn delete_conclusion(&self, workspace: Workspace, id: &str) -> Result<(), HonchoError>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `crates/memoryd/src/honcho/naming.rs` (the module `memoryd::honcho::naming`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct HonchoSession { pub name: String, pub speakers: Vec<String> }
|
||||||
|
pub fn session_name(session: &SessionId, count: usize) -> String;
|
||||||
|
pub fn honcho_session<'a, I>(decisions: I, session: &SessionId, turn: u64) -> Option<HonchoSession>
|
||||||
|
where I: IntoIterator<Item = (&'a Item, &'a Action)>;
|
||||||
|
```
|
||||||
|
|
||||||
|
In `crates/memoryd/src/honcho/ledger.rs`, one new method (edit L1):
|
||||||
|
`pub fn decisions(&self) -> impl Iterator<Item = (&Item, &Action)> + '_`, every decided item and
|
||||||
|
its action (`Skip` included) in the order of the `Decided` records.
|
||||||
|
|
||||||
|
`Clients::new` returns the key files' plaintext warnings for the caller to log. Its error is one
|
||||||
|
line naming the key (`bxcore::secrets`' own text, with its pointer) or the workspace; never a
|
||||||
|
key's value.
|
||||||
|
|
||||||
|
## The errors
|
||||||
|
|
||||||
|
`classify` is given whole in the skeleton. It maps `roncho`'s error so:
|
||||||
|
|
||||||
|
| `roncho::blocking::Error` | `HonchoError` |
|
||||||
|
|---|---|
|
||||||
|
| `Connect`, `Timeout`, `Closed`, `Io` | `Unreachable`, kind `connect`, `timeout`, `closed`, `io`; no status |
|
||||||
|
| `Status` with a status of 500 or more | `Unreachable`, kind `server`, the status |
|
||||||
|
| `Status` below 500 | `Refused`, kind `status`, the status |
|
||||||
|
| `Unauthorized` (401 or 403) | `KeyRefused`, the status |
|
||||||
|
| `Redirect` | `Refused`, kind `redirect`, the status |
|
||||||
|
| `ActiveSessions` | `Refused`, kind `active-sessions`, 409 |
|
||||||
|
| `TooLarge`, `Decode`, `Stream`, `Config`, any other | `Refused`, kind `too-large`, `decode`, `stream`, `config`, `other`; no status |
|
||||||
|
|
||||||
|
`Display` (given): `Honcho is unreachable (<kind>[, HTTP <status>])`, `Honcho refused the request
|
||||||
|
(<kind>[, HTTP <status>])`, `Honcho refused the key (key-refused, HTTP <status>)`. For a create,
|
||||||
|
a `Refused` with status 409 is success (`created`); for a delete, one with 404 is.
|
||||||
|
|
||||||
|
## The requests
|
||||||
|
|
||||||
|
Exactly what the given tests expect (`Value` bodies, compared as JSON):
|
||||||
|
|
||||||
|
| Step | Request | Body |
|
||||||
|
|---|---|---|
|
||||||
|
| a workspace | `POST /v3/workspaces` | `{"id":"<name>"}`; the fleet: `{"id":"<name>","configuration":{"reasoning":{"enabled":false},"summary":{"enabled":false},"dream":{"enabled":false}}}` |
|
||||||
|
| the agent's peer | `POST /v3/workspaces/<name>/peers` | `{"id":"<agent>","configuration":{"observe_me":false}}` |
|
||||||
|
| a speaker's peer | the same | `{"id":"<speaker>"}` |
|
||||||
|
| a session | `POST /v3/workspaces/<name>/sessions` | `{"id":"<session>","peers":{"<agent>":{"observe_me":false,"observe_others":false},"<speaker>":{"observe_me":true,"observe_others":false},…}}` |
|
||||||
|
| delete a conclusion | `DELETE /v3/workspaces/<name>/conclusions/<id>` | none |
|
||||||
|
|
||||||
|
`ensure_session` (given whole) makes sure of the workspace, the agent's peer, each speaker's peer,
|
||||||
|
and then the session, each only if not yet made sure of in this run. A failed step is not
|
||||||
|
remembered, so the next call tries it again.
|
||||||
|
|
||||||
|
## Every place that changes
|
||||||
|
|
||||||
|
This is the complete list.
|
||||||
|
|
||||||
|
| Place | What happens there |
|
||||||
|
|---|---|
|
||||||
|
| `crates/memoryd/src/honcho/mod.rs` | `pub mod client;` and `pub mod naming;` (edit H1) |
|
||||||
|
| `crates/memoryd/src/honcho/ledger.rs` | `decisions` (edit L1) |
|
||||||
|
| `crates/memoryd/src/honcho/client.rs` | new: a skeleton you copy, then fill 11 bodies |
|
||||||
|
| `crates/memoryd/src/honcho/naming.rs` | new: a skeleton you copy, then fill 2 bodies |
|
||||||
|
| `crates/memoryd/tests/fixtures/honcho/` | new: the recorded responses, copied |
|
||||||
|
| everything else (`serve.rs`, `turns.rs`, `config.rs`, `Cargo.toml`, other crates) | **no change** |
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy, from `docs/plans/M5c1/files/` (the same path under it):
|
||||||
|
- new tests: `crates/memoryd/tests/honcho_client.rs`, `crates/memoryd/tests/honcho_keys.rs`,
|
||||||
|
`crates/memoryd/tests/honcho_naming.rs`
|
||||||
|
- new test support: `crates/memoryd/tests/support/honcho.rs` (the fake Honcho)
|
||||||
|
- the directory `crates/memoryd/tests/fixtures/honcho/` (29 JSON files and a README), whole
|
||||||
|
- the skeletons `crates/memoryd/src/honcho/client.rs` and `crates/memoryd/src/honcho/naming.rs`
|
||||||
|
- Modify: `crates/memoryd/src/honcho/mod.rs`, `crates/memoryd/src/honcho/ledger.rs`,
|
||||||
|
`docs/implementer-log.md`.
|
||||||
|
- Do not touch: any other source file, any other test or support file, the fixtures (never edit
|
||||||
|
one, and never record new ones: there is no Honcho to reach from here), any `Cargo.toml`, and in
|
||||||
|
the skeletons anything but the 13 `todo!()` bodies.
|
||||||
|
|
||||||
|
## Facts you need
|
||||||
|
|
||||||
|
- **`roncho` 1.0.0** is already a dependency (`roncho.workspace = true`, the feature `tls`, which
|
||||||
|
brings `blocking`). Its blocking client is HTTP/1.1 over `std::net`, one request per
|
||||||
|
connection. The calls this task uses, all on `roncho::blocking::Client`:
|
||||||
|
|
||||||
|
| Call | Signature, and what it gives |
|
||||||
|
|---|---|
|
||||||
|
| `Client::builder()` | `-> ClientBuilder`; then `.without_config_file()`, `.base_url(url)`, `.workspace_id(name)`, `.connect_timeout(d)`, `.read_timeout(d)`, `.api_key(key)` (each `self -> Self`), `.build() -> Result<Client, Error>` |
|
||||||
|
| `workspace_id` | `client.workspace_id() -> &str` |
|
||||||
|
| `get_or_create_workspace` | `client.get_or_create_workspace(&WorkspaceCreate) -> Result<roncho::Workspace, Error>` |
|
||||||
|
| `peer` | `client.peer(&PeerCreate) -> Result<roncho::models::Peer, Error>` |
|
||||||
|
| `session` | `client.session(&SessionCreate) -> Result<roncho::models::Session, Error>` |
|
||||||
|
| `delete_conclusion` | `client.delete_conclusion(id: &str) -> Result<(), Error>` |
|
||||||
|
| `WorkspaceCreate` | `roncho::WorkspaceCreate::new(id)`, `.with_configuration(Map<String, Value>)` |
|
||||||
|
| `PeerCreate` | `roncho::PeerCreate::new(id)`, `.with_configuration(Map<String, Value>)` |
|
||||||
|
| `SessionCreate` | `roncho::SessionCreate::new(id)`, `.with_peers(HashMap<String, SessionPeerConfig>)` |
|
||||||
|
| `SessionPeerConfig` | `roncho::SessionPeerConfig::new().observe_me(bool).observe_others(bool)` |
|
||||||
|
|
||||||
|
`roncho`'s `Client` and `ClientBuilder` `Debug` show `api_key_present`, never the key; the
|
||||||
|
client keeps the key in a `Zeroizing<String>`.
|
||||||
|
- **Keys.** `HonchoConfig.keys` (task 06) maps `honcho_trusted`, `honcho_untrusted`,
|
||||||
|
`honcho_fleet` to a checked `SecretSource`. `bxcore::secrets::load("memoryd", name, source,
|
||||||
|
env) -> Result<Loaded, SecretError>` reads it (`Loaded { secret: Secret, warning:
|
||||||
|
Option<String> }`; a file source always has a warning). `Secret::expose() -> &str` is the one
|
||||||
|
place the value is taken, to hand it to `roncho`. `SecretError`'s `Display` names the key and
|
||||||
|
ends with `see docs/runbook.md#secret-unavailable`. `env` is `std::env::var_os` in `memoryd`;
|
||||||
|
tests pass their own.
|
||||||
|
- **serde_json.** `serde_json::Map::new()`, `map.insert(String, Value)`, and
|
||||||
|
`serde_json::json!({ "enabled": false })` (imported as `json`).
|
||||||
|
- **The fake Honcho** (`tests/support/honcho.rs`) answers by method and the shape of the path:
|
||||||
|
the first create of a thing with its 201 fixture, a repeat with its 200 one; the first delete
|
||||||
|
of a conclusion 204, a repeat 404; anything else 404. It can be set to answer every request with
|
||||||
|
one status (a 422 echoing the request's body, as Honcho does) or to answer nothing, and it
|
||||||
|
records every request with its headers and body.
|
||||||
|
- **The given tests.**
|
||||||
|
- `honcho_client.rs` (13 tests): the timeouts; each client's workspace and timeouts (from
|
||||||
|
`roncho`'s `Debug`); the fleet's configuration equal to the recorded one; the four requests of
|
||||||
|
a session, in order and exactly; nothing asked twice in a run, a new speaker's peer and
|
||||||
|
session only, another workspace on its own; everything asked again after a restart (200 is
|
||||||
|
success); the fleet's workspace and a session with the agent alone; 409 on create; errors
|
||||||
|
by kind and status, not remembered, and a timeout (the fake answers nothing, the read timeout
|
||||||
|
is 300 ms); nothing listening; no response text in an error; `classify` for every error;
|
||||||
|
deleting a conclusion twice.
|
||||||
|
- `honcho_keys.rs` (3 tests, and 1 ignored that the third runs): each key sent as a bearer
|
||||||
|
token to its own workspace only, and shown by no client, error or warning; a key that cannot
|
||||||
|
be read; a `roncho.toml` with an `api_key` under `HOME` not read (a child process of the test
|
||||||
|
binary, since `HOME` is the process's).
|
||||||
|
- `honcho_naming.rs` (5 tests): the names; each new speaker, in any workspace, starting the next
|
||||||
|
session; a skipped or undecided turn; the order of decision; a restart; `decisions`.
|
||||||
|
- **Where the delete lives.** `delete_conclusion` is here, with its two recorded answers; task 13
|
||||||
|
calls it for an undo.
|
||||||
|
|
||||||
|
## The edits, exactly
|
||||||
|
|
||||||
|
### `crates/memoryd/src/honcho/mod.rs`
|
||||||
|
|
||||||
|
**H1.** The lines
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod config;
|
||||||
|
pub mod decide;
|
||||||
|
pub mod ledger;
|
||||||
|
```
|
||||||
|
|
||||||
|
become
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod client;
|
||||||
|
pub mod config;
|
||||||
|
pub mod decide;
|
||||||
|
pub mod ledger;
|
||||||
|
pub mod naming;
|
||||||
|
```
|
||||||
|
|
||||||
|
### `crates/memoryd/src/honcho/ledger.rs`
|
||||||
|
|
||||||
|
**L1.** The lines
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// The items waiting to be sent, with their actions, in the order they were decided.
|
||||||
|
pub fn outbox(&self) -> Vec<(Item, Action)> {
|
||||||
|
```
|
||||||
|
|
||||||
|
become
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Every decided item with its action, `Skip` included, in the order they were decided.
|
||||||
|
pub fn decisions(&self) -> impl Iterator<Item = (&Item, &Action)> + '_ {
|
||||||
|
self.order
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| Some((item, &self.entries.get(item)?.action)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The items waiting to be sent, with their actions, in the order they were decided.
|
||||||
|
pub fn outbox(&self) -> Vec<(Item, Action)> {
|
||||||
|
```
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy the given files.** From the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p crates/memoryd/tests/fixtures
|
||||||
|
cp -r docs/plans/M5c1/files/crates/memoryd/tests/fixtures/honcho crates/memoryd/tests/fixtures/
|
||||||
|
cp docs/plans/M5c1/files/crates/memoryd/tests/honcho_client.rs crates/memoryd/tests/
|
||||||
|
cp docs/plans/M5c1/files/crates/memoryd/tests/honcho_keys.rs crates/memoryd/tests/
|
||||||
|
cp docs/plans/M5c1/files/crates/memoryd/tests/honcho_naming.rs crates/memoryd/tests/
|
||||||
|
cp docs/plans/M5c1/files/crates/memoryd/tests/support/honcho.rs crates/memoryd/tests/support/
|
||||||
|
```
|
||||||
|
|
||||||
|
`ls crates/memoryd/tests/fixtures/honcho | wc -l` prints `30`.
|
||||||
|
- [ ] **2. See them fail.** `cargo build --tests --keep-going -p memoryd`, with a shell timeout
|
||||||
|
of 600000 ms: three test targets do not compile. `honcho_client`, 2 errors: `E0432`
|
||||||
|
(``unresolved import `memoryd::honcho::client` ``) and `E0432` (the same for
|
||||||
|
`memoryd::honcho::naming`). `honcho_keys`, 1 error: the `E0432` for `client`. `honcho_naming`,
|
||||||
|
8 errors: the `E0432` for `naming`, and seven `E0599` (``no method named `decisions` found for
|
||||||
|
struct `memoryd::honcho::ledger::Ledger` ``). Every other target compiles.
|
||||||
|
- [ ] **3. The modules.** Make edits H1 and L1, then copy the two skeletons:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp docs/plans/M5c1/files/crates/memoryd/src/honcho/client.rs crates/memoryd/src/honcho/
|
||||||
|
cp docs/plans/M5c1/files/crates/memoryd/src/honcho/naming.rs crates/memoryd/src/honcho/
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo check -p memoryd`: no error, and 26 warnings, all from the unfilled bodies: ``unused
|
||||||
|
import: `json` ``; ``unused imports: `PeerCreate` and `WorkspaceCreate` ``; twenty-two `unused
|
||||||
|
variable` (`workspace` five times, `session` twice, and `agent`, `config`, `count`,
|
||||||
|
`decisions`, `env`, `id`, `key`, `name`, `peer`, `result`, `speakers`, `timeouts`, `turn`,
|
||||||
|
`url`, `warnings` once each); ``fields `trusted`, `untrusted`, and `fleet` are never read``;
|
||||||
|
and ``variants `Workspace` and `Peer` are never constructed``. They go as you fill the bodies.
|
||||||
|
- [ ] **4. Fill the thirteen bodies, one at a time, in the order of each file**, each from its
|
||||||
|
comment, and run `cargo check -p memoryd` after each. In `client.rs`: `kind`, `status`,
|
||||||
|
`fleet_configuration`, `session_peers`, `created`, `client` (the free function), `key`,
|
||||||
|
`Clients::client`, `ensure_workspace`, `ensure_peer`, `delete_conclusion`. In `naming.rs`:
|
||||||
|
`session_name`, `honcho_session`. When a body is filled, delete its instruction comment (the
|
||||||
|
`//` lines inside the body) and keep the function's doc comment (`///`) as it is. Change
|
||||||
|
nothing else: `classify`, `Display`, `Clients::new` and `ensure_session` are given. After the
|
||||||
|
last, `cargo check -p memoryd` prints no warning.
|
||||||
|
- [ ] **5. The tests.** With a shell timeout of 600000 ms, and every test passes (13, 3 and 5; one
|
||||||
|
test is ignored: the child the third test of `honcho_keys` runs):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test -p memoryd --test honcho_client --test honcho_keys --test honcho_naming
|
||||||
|
```
|
||||||
|
- [ ] **6. Check.** Three commands:
|
||||||
|
- `grep -c 'without_config_file' crates/memoryd/src/honcho/client.rs` prints `1`.
|
||||||
|
- `grep -n 'expose()' crates/memoryd/src -r` prints exactly one line, in `client` (the free
|
||||||
|
function): the one place a key's value is taken.
|
||||||
|
- `git diff --stat -- crates/bxcore crates/loopd crates/brokerd crates/gatewayd crates/bxctl
|
||||||
|
Cargo.toml Cargo.lock crates/memoryd/Cargo.toml crates/memoryd/src/serve.rs` prints nothing.
|
||||||
|
- [ ] **7. Run the gate.** `cargo fmt --all`, then `make gate`, with a shell timeout of 600000 ms.
|
||||||
|
Expected last line: `gate: ok`, with 1278 tests: 1257 before this task, plus 21 new ones (13 in
|
||||||
|
`honcho_client.rs`, 3 in `honcho_keys.rs`, 5 in `honcho_naming.rs`).
|
||||||
|
- [ ] **8. Log and commit.** Add your row to `docs/implementer-log.md`; its Task cell is
|
||||||
|
`M5c1/10-memoryd-honcho-client`. `git status --short` lists `honcho/mod.rs`, `honcho/ledger.rs`
|
||||||
|
and your log as modified, and `honcho/client.rs`, `honcho/naming.rs`, the three test files,
|
||||||
|
`tests/support/honcho.rs` and `tests/fixtures/` as new. Stage with
|
||||||
|
`git add crates/memoryd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says.
|
||||||
|
`Cargo.lock` does not change in this task; the `git add` is harmless.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- `make gate` prints `gate: ok` with 1278 tests.
|
||||||
|
- `git diff HEAD~1 --stat` lists only the files of step 8.
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- A "before" block of H1 or L1 is not in its file exactly as shown, or is there more than once.
|
||||||
|
- Step 1 does not give 30 files, or step 2 or 3 shows a different result: other test targets,
|
||||||
|
errors of another kind, or other warnings.
|
||||||
|
- `cargo check` reports an error after a body you filled, and the comment does not tell you how
|
||||||
|
to fix it. Quote the error, and stop.
|
||||||
|
- A comment in a skeleton does not fit the code under it, or disagrees with this task's tables.
|
||||||
|
Quote both and stop.
|
||||||
|
- After step 4 a test fails although each body follows its comment. Do not change a test, the
|
||||||
|
fake, a fixture or the skeletons' given code: stop, and give the failing tests' names and their
|
||||||
|
messages.
|
||||||
|
- A test seems to need a fixture that is not there, or a request to a real Honcho. It does not;
|
||||||
|
stop and say why you think so. Never record a response.
|
||||||
|
- The fix seems to need a change anywhere else: `serve.rs`, `turns.rs`, a `Cargo.toml`, another
|
||||||
|
crate. It does not; stop and say why you think so.
|
||||||
|
- A test outside the new files fails in the gate: check once whether it fails without your
|
||||||
|
change, then stop and report both results.
|
||||||
|
- The gate passes with a count other than 1278.
|
||||||
|
|
||||||
|
## What the given tests pin
|
||||||
|
|
||||||
|
Each of these changes to the finished code makes at least one given test fail (44 tried, one
|
||||||
|
cannot change behaviour): either timeout ignored or changed; `roncho.toml` read; each error
|
||||||
|
mapped to the other side (a 5xx refused, a 401 refused, a timeout, a connect failure or a closed
|
||||||
|
socket refused, a bad body unreachable); a 409 on create an error, or any refusal on create
|
||||||
|
taken as success; a 404 on delete an error; the fleet's settings on another workspace, missing
|
||||||
|
one, or on; the agent observed, or every peer unobserved; the workspace, a peer or a session not
|
||||||
|
remembered, or remembered for every workspace, or before it was made; the session made without
|
||||||
|
its workspace, its agent or its speakers first; the agent observed or left out of a session, the
|
||||||
|
speakers unobserved or observing others; a key sent to the wrong workspace or dropped; a key
|
||||||
|
file's warning dropped; a key that cannot be read taken as no key; the wrong client for a
|
||||||
|
workspace; an error shown without its status; a session named `<id>-1`; a speaker counted
|
||||||
|
twice; another session's speakers counted; the speakers of later turns counted; `decisions`
|
||||||
|
without the skipped items or in reverse.
|
||||||
|
|
||||||
|
## After this task
|
||||||
|
|
||||||
|
Task 11 builds the `Clients` at start (after the ledger, logging the warnings, a key that cannot
|
||||||
|
be read stopping `memoryd` with `secret-unavailable`), and sends turns with `ensure_session` and
|
||||||
|
`honcho_session`. Task 13 uses `delete_conclusion`, and makes the fleet's sessions with no
|
||||||
|
speakers.
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
//! This agent's three Honcho workspaces through `roncho`'s blocking client (M5c spec, section 6):
|
||||||
|
//! one client per workspace, with its own key, and the steps that make sure a workspace, a peer
|
||||||
|
//! and a session exist before anything is written to them. Nothing here keeps or logs a response
|
||||||
|
//! body or a key: an error is its kind and its status.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use bxcore::secrets;
|
||||||
|
use roncho::blocking::{Client, Error};
|
||||||
|
use roncho::{PeerCreate, SessionCreate, SessionPeerConfig, WorkspaceCreate};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
|
use crate::honcho::config::{FLEET_KEY, HonchoConfig, TRUSTED_KEY, UNTRUSTED_KEY};
|
||||||
|
use crate::honcho::ledger::Workspace;
|
||||||
|
use crate::honcho::naming::HonchoSession;
|
||||||
|
|
||||||
|
/// How long a connection may take, and a read may go without a byte.
|
||||||
|
pub const CONNECT: Duration = Duration::from_secs(5);
|
||||||
|
pub const READ: Duration = Duration::from_secs(30);
|
||||||
|
/// A chat's read: a non-streamed chat sends nothing until it is done (M5c-2's `ask_memory`).
|
||||||
|
pub const CHAT_READ: Duration = Duration::from_secs(180);
|
||||||
|
|
||||||
|
/// The timeouts a client is built with. Tests shorten them.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Timeouts {
|
||||||
|
pub connect: Duration,
|
||||||
|
pub read: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Timeouts {
|
||||||
|
pub const WRITES: Timeouts = Timeouts {
|
||||||
|
connect: CONNECT,
|
||||||
|
read: READ,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a call to Honcho failed: its kind and status only, never the response's body.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum HonchoError {
|
||||||
|
/// No answer that counts: no connection, a timeout, a closed socket, or a 5xx. Try again later.
|
||||||
|
Unreachable {
|
||||||
|
kind: &'static str,
|
||||||
|
status: Option<u16>,
|
||||||
|
},
|
||||||
|
/// Honcho answered, and will answer the same again: a 4xx, a redirect, or a body that is not
|
||||||
|
/// what `roncho` expects.
|
||||||
|
Refused {
|
||||||
|
kind: &'static str,
|
||||||
|
status: Option<u16>,
|
||||||
|
},
|
||||||
|
/// The workspace's key was refused (401 or 403).
|
||||||
|
KeyRefused { status: u16 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HonchoError {
|
||||||
|
pub fn kind(&self) -> &'static str {
|
||||||
|
// `Unreachable { kind, .. }` and `Refused { kind, .. }` (one arm, `|`): `kind`;
|
||||||
|
// `KeyRefused { .. }`: `"key-refused"`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> Option<u16> {
|
||||||
|
// `Unreachable { status, .. }` and `Refused { status, .. }` (one arm): `*status`;
|
||||||
|
// `KeyRefused { status }`: `Some(*status)`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for HonchoError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let what = match self {
|
||||||
|
HonchoError::Unreachable { .. } => "Honcho is unreachable",
|
||||||
|
HonchoError::Refused { .. } => "Honcho refused the request",
|
||||||
|
HonchoError::KeyRefused { .. } => "Honcho refused the key",
|
||||||
|
};
|
||||||
|
match self.status() {
|
||||||
|
Some(status) => write!(f, "{what} ({}, HTTP {status})", self.kind()),
|
||||||
|
None => write!(f, "{what} ({})", self.kind()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for HonchoError {}
|
||||||
|
|
||||||
|
/// `roncho`'s error as a `HonchoError`. Its text is dropped: a `Status` or `Decode` error carries
|
||||||
|
/// up to 4 KiB of the response, which can echo memory text (a 422 echoes the whole input).
|
||||||
|
pub fn classify(error: &Error) -> HonchoError {
|
||||||
|
let unreachable = |kind, status| HonchoError::Unreachable { kind, status };
|
||||||
|
let refused = |kind, status| HonchoError::Refused { kind, status };
|
||||||
|
match error {
|
||||||
|
Error::Connect(_) => unreachable("connect", None),
|
||||||
|
Error::Timeout => unreachable("timeout", None),
|
||||||
|
Error::Closed => unreachable("closed", None),
|
||||||
|
Error::Io(_) => unreachable("io", None),
|
||||||
|
Error::Status { status, .. } if *status >= 500 => unreachable("server", Some(*status)),
|
||||||
|
Error::Status { status, .. } => refused("status", Some(*status)),
|
||||||
|
Error::Unauthorized { status } => HonchoError::KeyRefused { status: *status },
|
||||||
|
Error::Redirect { status } => refused("redirect", Some(*status)),
|
||||||
|
Error::ActiveSessions { .. } => refused("active-sessions", Some(409)),
|
||||||
|
Error::TooLarge => refused("too-large", None),
|
||||||
|
Error::Decode(_) => refused("decode", None),
|
||||||
|
Error::Stream(_) => refused("stream", None),
|
||||||
|
Error::Config(_) => refused("config", None),
|
||||||
|
_ => refused("other", None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What has been made sure of in one workspace during this run.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
enum Ensured {
|
||||||
|
Workspace,
|
||||||
|
Peer(String),
|
||||||
|
Session(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The three clients, and what they have made sure of since `memoryd` started. After a restart
|
||||||
|
/// everything is made sure of again: each step is get-or-create, so that costs a request, no more.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Clients {
|
||||||
|
trusted: Client,
|
||||||
|
untrusted: Client,
|
||||||
|
fleet: Client,
|
||||||
|
agent: String,
|
||||||
|
ensured: HashSet<(Workspace, Ensured)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fleet workspace's configuration: nothing reasons over the fleet's notes (M5c spec,
|
||||||
|
/// section 6). All three are set, since Honcho checks each on its own.
|
||||||
|
pub fn fleet_configuration() -> Map<String, Value> {
|
||||||
|
// A `Map::new()`; for each feature of `["reasoning", "summary", "dream"]`, insert
|
||||||
|
// `feature.to_string()` -> `json!({ "enabled": false })`. Return the map.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session's peers: the agent's, unobserved, and each speaker's, observed; no peer forms a
|
||||||
|
/// picture of the others.
|
||||||
|
pub fn session_peers(agent: &str, speakers: &[String]) -> HashMap<String, SessionPeerConfig> {
|
||||||
|
// `HashMap::new()`; insert `agent.to_string()` -> `SessionPeerConfig::new()
|
||||||
|
// .observe_me(false).observe_others(false)`; then for each speaker, `speaker.clone()` ->
|
||||||
|
// `SessionPeerConfig::new().observe_me(true).observe_others(false)`. Return the map.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A create that found the thing there (409) did what it was for.
|
||||||
|
fn created<T>(result: Result<T, Error>) -> Result<(), HonchoError> {
|
||||||
|
// `Ok(_)` -> `Ok(())`. `Err(e)`: `match classify(&e)`: `HonchoError::Refused { status:
|
||||||
|
// Some(409), .. }` -> `Ok(())`; any other `other` -> `Err(other)`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One workspace's client: no `roncho.toml` read, the timeouts given, the key when there is one.
|
||||||
|
fn client(
|
||||||
|
url: &str,
|
||||||
|
workspace: &str,
|
||||||
|
key: Option<&secrets::Secret>,
|
||||||
|
timeouts: Timeouts,
|
||||||
|
) -> Result<Client, String> {
|
||||||
|
// `let mut builder = Client::builder()` then, chained: `.without_config_file()`,
|
||||||
|
// `.base_url(url)`, `.workspace_id(workspace)`, `.connect_timeout(timeouts.connect)`,
|
||||||
|
// `.read_timeout(timeouts.read)`. When `key` is `Some(key)`: `builder = builder.api_key(
|
||||||
|
// key.expose())`. Then `builder.build()`, its error `e` mapped to
|
||||||
|
// `format!("the Honcho client for {workspace}: {e}")`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key named `name` in `[secrets]`, if one is configured, read now.
|
||||||
|
fn key(
|
||||||
|
config: &HonchoConfig,
|
||||||
|
name: &str,
|
||||||
|
env: &dyn Fn(&str) -> Option<OsString>,
|
||||||
|
warnings: &mut Vec<String>,
|
||||||
|
) -> Result<Option<secrets::Secret>, String> {
|
||||||
|
// `let Some(source) = config.keys.get(name) else { return Ok(None) };`. Then
|
||||||
|
// `secrets::load("memoryd", name, source, env)`, its error mapped with `e.to_string()` and `?`,
|
||||||
|
// into `loaded`. `warnings.extend(loaded.warning);` then `Ok(Some(loaded.secret))`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clients {
|
||||||
|
/// A client for each of the three workspaces, each with its key when one is configured. The
|
||||||
|
/// warnings are the key files' plaintext warnings, for the caller to log. An error names the
|
||||||
|
/// key or the workspace, never a key's value.
|
||||||
|
pub fn new(
|
||||||
|
config: &HonchoConfig,
|
||||||
|
env: &dyn Fn(&str) -> Option<OsString>,
|
||||||
|
timeouts: Timeouts,
|
||||||
|
) -> Result<(Clients, Vec<String>), String> {
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
let trusted_key = key(config, TRUSTED_KEY, env, &mut warnings)?;
|
||||||
|
let untrusted_key = key(config, UNTRUSTED_KEY, env, &mut warnings)?;
|
||||||
|
let fleet_key = key(config, FLEET_KEY, env, &mut warnings)?;
|
||||||
|
let clients = Clients {
|
||||||
|
trusted: client(&config.url, &config.trusted, trusted_key.as_ref(), timeouts)?,
|
||||||
|
untrusted: client(
|
||||||
|
&config.url,
|
||||||
|
&config.untrusted,
|
||||||
|
untrusted_key.as_ref(),
|
||||||
|
timeouts,
|
||||||
|
)?,
|
||||||
|
fleet: client(&config.url, &config.fleet, fleet_key.as_ref(), timeouts)?,
|
||||||
|
agent: config.agent.clone(),
|
||||||
|
ensured: HashSet::new(),
|
||||||
|
};
|
||||||
|
Ok((clients, warnings))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The client of `workspace`.
|
||||||
|
pub fn client(&self, workspace: Workspace) -> &Client {
|
||||||
|
// `match workspace`: `Trusted` -> `&self.trusted`, `Untrusted` -> `&self.untrusted`,
|
||||||
|
// `Fleet` -> `&self.fleet`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make sure `workspace` exists; the fleet one is made with `fleet_configuration`, the other
|
||||||
|
/// two with Honcho's defaults.
|
||||||
|
pub fn ensure_workspace(&mut self, workspace: Workspace) -> Result<(), HonchoError> {
|
||||||
|
// `let key = (workspace, Ensured::Workspace);`; if `self.ensured.contains(&key)`, return
|
||||||
|
// `Ok(())`. `let client = self.client(workspace);` and
|
||||||
|
// `let mut create = WorkspaceCreate::new(client.workspace_id());`;
|
||||||
|
// `if workspace == Workspace::Fleet`,
|
||||||
|
// `create = create.with_configuration(fleet_configuration());`. Then
|
||||||
|
// `created(client.get_or_create_workspace(&create))?;`, `self.ensured.insert(key);`,
|
||||||
|
// `Ok(())`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make sure peer `peer` exists in `workspace`. The agent's own peer is made unobserved
|
||||||
|
/// (`observe_me` false): Honcho draws no conclusions from the agent's own output.
|
||||||
|
pub fn ensure_peer(&mut self, workspace: Workspace, peer: &str) -> Result<(), HonchoError> {
|
||||||
|
// `let key = (workspace, Ensured::Peer(peer.to_string()));`; if already there, return
|
||||||
|
// `Ok(())`. `let mut create = PeerCreate::new(peer);`. `if peer == self.agent`: `let mut
|
||||||
|
// configuration = Map::new();`, insert `"observe_me".to_string()` -> `json!(false)`, and
|
||||||
|
// `create = create.with_configuration(configuration);`. Then
|
||||||
|
// `created(self.client(workspace).peer(&create))?;`, `self.ensured.insert(key);`, `Ok(())`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make sure `session` exists in `workspace`, with the agent's peer and its speakers' peers,
|
||||||
|
/// after the workspace and every peer. A session must exist before a message is added to
|
||||||
|
/// it: Honcho makes a missing one on the fly, with no peers configured.
|
||||||
|
pub fn ensure_session(
|
||||||
|
&mut self,
|
||||||
|
workspace: Workspace,
|
||||||
|
session: &HonchoSession,
|
||||||
|
) -> Result<(), HonchoError> {
|
||||||
|
self.ensure_workspace(workspace)?;
|
||||||
|
let agent = self.agent.clone();
|
||||||
|
self.ensure_peer(workspace, &agent)?;
|
||||||
|
for speaker in &session.speakers {
|
||||||
|
self.ensure_peer(workspace, speaker)?;
|
||||||
|
}
|
||||||
|
let key = (workspace, Ensured::Session(session.name.clone()));
|
||||||
|
if self.ensured.contains(&key) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let peers = session_peers(&agent, &session.speakers);
|
||||||
|
let create = SessionCreate::new(session.name.as_str()).with_peers(peers);
|
||||||
|
created(self.client(workspace).session(&create))?;
|
||||||
|
self.ensured.insert(key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete conclusion `id` in `workspace`. Gone already (404) counts as done.
|
||||||
|
pub fn delete_conclusion(&self, workspace: Workspace, id: &str) -> Result<(), HonchoError> {
|
||||||
|
// `match self.client(workspace).delete_conclusion(id)`: `Ok(())` -> `Ok(())`; `Err(e)` ->
|
||||||
|
// `match classify(&e)`: `HonchoError::Refused { status: Some(404), .. }` -> `Ok(())`; any
|
||||||
|
// other `other` -> `Err(other)`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
//! Which Honcho session a turn goes to (M5c spec, section 3). A Boxmaker session maps to the Honcho
|
||||||
|
//! session of its own id; a speaker new to it starts the next one, `<id>-2`, `<id>-3` and so on,
|
||||||
|
//! holding every speaker so far, since a Honcho session's peers are fixed when it is made. The
|
||||||
|
//! numbering is read from the ledger alone, so a restart numbers the same way.
|
||||||
|
|
||||||
|
use bxcore::SessionId;
|
||||||
|
|
||||||
|
use crate::honcho::ledger::{Action, Item};
|
||||||
|
|
||||||
|
/// A Honcho session: its name, and the speakers' peers it holds besides the agent's.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct HonchoSession {
|
||||||
|
pub name: String,
|
||||||
|
pub speakers: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name of the Honcho session for the `count`-th speaker of `session`: the id itself for the
|
||||||
|
/// first (and for none), then `<id>-<count>`.
|
||||||
|
pub fn session_name(session: &SessionId, count: usize) -> String {
|
||||||
|
// When `count <= 1`: `session.as_str().to_string()`; otherwise
|
||||||
|
// `format!("{}-{count}", session.as_str())`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Honcho session of turn `turn` of `session`, from the ledger's decisions in their order:
|
||||||
|
/// the speakers of that session's `Turn` actions (in any workspace) up to and including this
|
||||||
|
/// turn, each counted once, in the order they first spoke. `None` when the turn has no `Turn`
|
||||||
|
/// action: undecided, or decided to go nowhere.
|
||||||
|
pub fn honcho_session<'a, I>(decisions: I, session: &SessionId, turn: u64) -> Option<HonchoSession>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = (&'a Item, &'a Action)>,
|
||||||
|
{
|
||||||
|
// `let mut speakers: Vec<String> = Vec::new();`. For each `(item, action)`: `let
|
||||||
|
// Item::Turn { session: s, turn: t } = item else { continue; };`, then `let Action::Turn {
|
||||||
|
// speaker, .. } = action else { continue; };`; skip it when `s != session`. Push
|
||||||
|
// `speaker.clone()` unless `speakers.contains(speaker)`. When `*t == turn`, return `Some(
|
||||||
|
// HonchoSession { name: session_name(session, speakers.len()), speakers })`. After: `None`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
//! `memoryd`'s Honcho clients (M5c spec, sections 3 and 6), against a fake Honcho that answers
|
||||||
|
//! from the responses recorded from rift: the workspace, the peers and the session made before
|
||||||
|
//! anything is written, each once per run, with the fleet's settings and the agent unobserved;
|
||||||
|
//! the errors by kind and status, never a body. The keys are in `honcho_keys.rs`. Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/honcho.rs"]
|
||||||
|
mod honcho;
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use honcho::{FakeHoncho, Mode, Request};
|
||||||
|
use memoryd::honcho::client::{
|
||||||
|
CHAT_READ, CONNECT, Clients, HonchoError, READ, Timeouts, classify, fleet_configuration,
|
||||||
|
};
|
||||||
|
use memoryd::honcho::config::HonchoConfig;
|
||||||
|
use memoryd::honcho::ledger::Workspace;
|
||||||
|
use memoryd::honcho::naming::HonchoSession;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
const FAST: Timeouts = Timeouts {
|
||||||
|
connect: Duration::from_secs(5),
|
||||||
|
read: Duration::from_millis(300),
|
||||||
|
};
|
||||||
|
|
||||||
|
fn config(url: &str) -> HonchoConfig {
|
||||||
|
HonchoConfig {
|
||||||
|
url: url.to_string(),
|
||||||
|
agent: "boxmaker0".to_string(),
|
||||||
|
owner: "kyle".to_string(),
|
||||||
|
trusted: "boxmaker0-trusted".to_string(),
|
||||||
|
untrusted: "boxmaker0-untrusted".to_string(),
|
||||||
|
fleet: "fleet".to_string(),
|
||||||
|
read: Vec::new(),
|
||||||
|
keys: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn no_env(_: &str) -> Option<OsString> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clients(fake: &FakeHoncho) -> Clients {
|
||||||
|
Clients::new(&config(&fake.url), &no_env, FAST).unwrap().0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session(name: &str, speakers: &[&str]) -> HonchoSession {
|
||||||
|
HonchoSession {
|
||||||
|
name: name.to_string(),
|
||||||
|
speakers: speakers.iter().map(|s| s.to_string()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A request as (method, path, body).
|
||||||
|
fn seen(requests: &[Request]) -> Vec<(String, String, Value)> {
|
||||||
|
requests
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
(
|
||||||
|
r.method.clone(),
|
||||||
|
r.path.clone(),
|
||||||
|
r.body.clone().unwrap_or(Value::Null),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post(path: &str, body: Value) -> (String, String, Value) {
|
||||||
|
("POST".to_string(), path.to_string(), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_timeouts() {
|
||||||
|
assert_eq!(CONNECT, Duration::from_secs(5));
|
||||||
|
assert_eq!(READ, Duration::from_secs(30));
|
||||||
|
assert_eq!(CHAT_READ, Duration::from_secs(180));
|
||||||
|
assert_eq!(
|
||||||
|
Timeouts::WRITES,
|
||||||
|
Timeouts {
|
||||||
|
connect: CONNECT,
|
||||||
|
read: READ
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_client_has_its_workspace_and_timeouts() {
|
||||||
|
let fake = FakeHoncho::start();
|
||||||
|
let c = clients(&fake);
|
||||||
|
for (workspace, name) in [
|
||||||
|
(Workspace::Trusted, "boxmaker0-trusted"),
|
||||||
|
(Workspace::Untrusted, "boxmaker0-untrusted"),
|
||||||
|
(Workspace::Fleet, "fleet"),
|
||||||
|
] {
|
||||||
|
let client = c.client(workspace);
|
||||||
|
assert_eq!(client.workspace_id(), name);
|
||||||
|
// `roncho`'s `Debug` shows the timeouts, and whether there is a key, never the key.
|
||||||
|
let shown = format!("{client:?}");
|
||||||
|
assert!(shown.contains("connect_timeout: 5s"), "{shown}");
|
||||||
|
assert!(shown.contains("read_timeout: 300ms"), "{shown}");
|
||||||
|
assert!(shown.contains("api_key_present: false"), "{shown}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_fleets_configuration_is_the_recorded_one() {
|
||||||
|
let path = concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/tests/fixtures/honcho/01-workspace-create.json"
|
||||||
|
);
|
||||||
|
let recorded: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
Value::Object(fleet_configuration()),
|
||||||
|
recorded["request"]["body"]["configuration"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_session_is_made_after_its_workspace_and_peers() {
|
||||||
|
let fake = FakeHoncho::start();
|
||||||
|
let mut c = clients(&fake);
|
||||||
|
c.ensure_session(Workspace::Trusted, &session("chat-1", &["kyle"]))
|
||||||
|
.unwrap();
|
||||||
|
let w = "/v3/workspaces/boxmaker0-trusted";
|
||||||
|
assert_eq!(
|
||||||
|
seen(&fake.requests()),
|
||||||
|
vec![
|
||||||
|
post("/v3/workspaces", json!({ "id": "boxmaker0-trusted" })),
|
||||||
|
post(
|
||||||
|
&format!("{w}/peers"),
|
||||||
|
json!({ "id": "boxmaker0", "configuration": { "observe_me": false } })
|
||||||
|
),
|
||||||
|
post(&format!("{w}/peers"), json!({ "id": "kyle" })),
|
||||||
|
post(
|
||||||
|
&format!("{w}/sessions"),
|
||||||
|
json!({ "id": "chat-1", "peers": {
|
||||||
|
"boxmaker0": { "observe_me": false, "observe_others": false },
|
||||||
|
"kyle": { "observe_me": true, "observe_others": false },
|
||||||
|
} })
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_was_made_is_not_asked_for_again_in_this_run() {
|
||||||
|
let fake = FakeHoncho::start();
|
||||||
|
let mut c = clients(&fake);
|
||||||
|
c.ensure_session(Workspace::Trusted, &session("chat-1", &["kyle"]))
|
||||||
|
.unwrap();
|
||||||
|
fake.clear();
|
||||||
|
c.ensure_session(Workspace::Trusted, &session("chat-1", &["kyle"]))
|
||||||
|
.unwrap();
|
||||||
|
assert!(fake.requests().is_empty(), "{:?}", fake.requests());
|
||||||
|
// A new speaker: only its peer and the next session.
|
||||||
|
c.ensure_session(Workspace::Trusted, &session("chat-1-2", &["kyle", "ana_b"]))
|
||||||
|
.unwrap();
|
||||||
|
let w = "/v3/workspaces/boxmaker0-trusted";
|
||||||
|
assert_eq!(
|
||||||
|
seen(&fake.requests()),
|
||||||
|
vec![
|
||||||
|
post(&format!("{w}/peers"), json!({ "id": "ana_b" })),
|
||||||
|
post(
|
||||||
|
&format!("{w}/sessions"),
|
||||||
|
json!({ "id": "chat-1-2", "peers": {
|
||||||
|
"boxmaker0": { "observe_me": false, "observe_others": false },
|
||||||
|
"kyle": { "observe_me": true, "observe_others": false },
|
||||||
|
"ana_b": { "observe_me": true, "observe_others": false },
|
||||||
|
} })
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// Another workspace is made sure of on its own.
|
||||||
|
fake.clear();
|
||||||
|
c.ensure_session(Workspace::Untrusted, &session("chat-1", &["kyle"]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fake.requests().len(), 4);
|
||||||
|
assert!(
|
||||||
|
fake.requests()
|
||||||
|
.iter()
|
||||||
|
.skip(1)
|
||||||
|
.all(|r| r.path.starts_with("/v3/workspaces/boxmaker0-untrusted/"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn after_a_restart_everything_is_made_sure_of_again() {
|
||||||
|
let fake = FakeHoncho::start();
|
||||||
|
clients(&fake)
|
||||||
|
.ensure_session(Workspace::Trusted, &session("chat-1", &["kyle"]))
|
||||||
|
.unwrap();
|
||||||
|
fake.clear();
|
||||||
|
// Honcho answers 200 now: it has them all. That is success too.
|
||||||
|
clients(&fake)
|
||||||
|
.ensure_session(Workspace::Trusted, &session("chat-1", &["kyle"]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fake.requests().len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_fleet_is_made_with_its_settings_and_the_agent_alone() {
|
||||||
|
let fake = FakeHoncho::start();
|
||||||
|
let mut c = clients(&fake);
|
||||||
|
c.ensure_session(Workspace::Fleet, &session("chat-1", &[]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
seen(&fake.requests()),
|
||||||
|
vec![
|
||||||
|
post(
|
||||||
|
"/v3/workspaces",
|
||||||
|
json!({ "id": "fleet", "configuration": {
|
||||||
|
"reasoning": { "enabled": false },
|
||||||
|
"summary": { "enabled": false },
|
||||||
|
"dream": { "enabled": false },
|
||||||
|
} })
|
||||||
|
),
|
||||||
|
post(
|
||||||
|
"/v3/workspaces/fleet/peers",
|
||||||
|
json!({ "id": "boxmaker0", "configuration": { "observe_me": false } })
|
||||||
|
),
|
||||||
|
post(
|
||||||
|
"/v3/workspaces/fleet/sessions",
|
||||||
|
json!({ "id": "chat-1", "peers": {
|
||||||
|
"boxmaker0": { "observe_me": false, "observe_others": false },
|
||||||
|
} })
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_409_on_create_means_it_is_there() {
|
||||||
|
let fake = FakeHoncho::start();
|
||||||
|
let mut c = clients(&fake);
|
||||||
|
fake.set(Mode::Status(409));
|
||||||
|
c.ensure_workspace(Workspace::Trusted).unwrap();
|
||||||
|
c.ensure_peer(Workspace::Trusted, "kyle").unwrap();
|
||||||
|
fake.clear();
|
||||||
|
c.ensure_workspace(Workspace::Trusted).unwrap();
|
||||||
|
assert!(fake.requests().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn errors_by_kind_and_status() {
|
||||||
|
let fake = FakeHoncho::start();
|
||||||
|
let mut c = clients(&fake);
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
500,
|
||||||
|
HonchoError::Unreachable {
|
||||||
|
kind: "server",
|
||||||
|
status: Some(500),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
503,
|
||||||
|
HonchoError::Unreachable {
|
||||||
|
kind: "server",
|
||||||
|
status: Some(503),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(401, HonchoError::KeyRefused { status: 401 }),
|
||||||
|
(403, HonchoError::KeyRefused { status: 403 }),
|
||||||
|
(
|
||||||
|
400,
|
||||||
|
HonchoError::Refused {
|
||||||
|
kind: "status",
|
||||||
|
status: Some(400),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
404,
|
||||||
|
HonchoError::Refused {
|
||||||
|
kind: "status",
|
||||||
|
status: Some(404),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
422,
|
||||||
|
HonchoError::Refused {
|
||||||
|
kind: "status",
|
||||||
|
status: Some(422),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (status, expected) in cases {
|
||||||
|
fake.set(Mode::Status(status));
|
||||||
|
assert_eq!(
|
||||||
|
c.ensure_workspace(Workspace::Trusted),
|
||||||
|
Err(expected.clone())
|
||||||
|
);
|
||||||
|
assert_eq!(c.ensure_peer(Workspace::Fleet, "kyle"), Err(expected));
|
||||||
|
}
|
||||||
|
// Nothing failed is remembered: the next call asks again, and succeeds.
|
||||||
|
fake.set(Mode::Fixtures);
|
||||||
|
fake.clear();
|
||||||
|
c.ensure_workspace(Workspace::Trusted).unwrap();
|
||||||
|
assert_eq!(fake.requests().len(), 1);
|
||||||
|
fake.set(Mode::Hang);
|
||||||
|
assert_eq!(
|
||||||
|
c.ensure_peer(Workspace::Trusted, "ana_b"),
|
||||||
|
Err(HonchoError::Unreachable {
|
||||||
|
kind: "timeout",
|
||||||
|
status: None
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_listening_is_unreachable() {
|
||||||
|
let port = {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
listener.local_addr().unwrap().port()
|
||||||
|
};
|
||||||
|
let (mut c, _) =
|
||||||
|
Clients::new(&config(&format!("http://127.0.0.1:{port}")), &no_env, FAST).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
c.ensure_workspace(Workspace::Trusted),
|
||||||
|
Err(HonchoError::Unreachable {
|
||||||
|
kind: "connect",
|
||||||
|
status: None
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_error_never_holds_the_response() {
|
||||||
|
let fake = FakeHoncho::start();
|
||||||
|
let mut c = clients(&fake);
|
||||||
|
fake.set(Mode::Status(422));
|
||||||
|
let err = c
|
||||||
|
.ensure_peer(Workspace::Trusted, "private-note-text")
|
||||||
|
.unwrap_err();
|
||||||
|
let body = fake.requests()[0].body.clone().unwrap();
|
||||||
|
assert_eq!(body["id"], "private-note-text", "the fake did not echo it");
|
||||||
|
for shown in [err.to_string(), format!("{err:?}")] {
|
||||||
|
assert!(!shown.contains("private-note-text"), "{shown}");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
"Honcho refused the request (status, HTTP 422)"
|
||||||
|
);
|
||||||
|
assert_eq!((err.kind(), err.status()), ("status", Some(422)));
|
||||||
|
let lines = [
|
||||||
|
HonchoError::Unreachable {
|
||||||
|
kind: "timeout",
|
||||||
|
status: None,
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
HonchoError::KeyRefused { status: 401 }.to_string(),
|
||||||
|
];
|
||||||
|
assert_eq!(lines[0], "Honcho is unreachable (timeout)");
|
||||||
|
assert_eq!(lines[1], "Honcho refused the key (key-refused, HTTP 401)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_roncho_error_is_classified() {
|
||||||
|
use roncho::blocking::Error;
|
||||||
|
let io = || std::io::Error::other("x");
|
||||||
|
let cases = [
|
||||||
|
(Error::Connect(io()), "connect", None, true),
|
||||||
|
(Error::Timeout, "timeout", None, true),
|
||||||
|
(Error::Closed, "closed", None, true),
|
||||||
|
(Error::Io(io()), "io", None, true),
|
||||||
|
(
|
||||||
|
Error::Status {
|
||||||
|
status: 502,
|
||||||
|
body: "b".into(),
|
||||||
|
},
|
||||||
|
"server",
|
||||||
|
Some(502),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Error::Status {
|
||||||
|
status: 499,
|
||||||
|
body: "b".into(),
|
||||||
|
},
|
||||||
|
"status",
|
||||||
|
Some(499),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Error::Redirect { status: 301 },
|
||||||
|
"redirect",
|
||||||
|
Some(301),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Error::ActiveSessions { body: "b".into() },
|
||||||
|
"active-sessions",
|
||||||
|
Some(409),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(Error::TooLarge, "too-large", None, false),
|
||||||
|
(Error::Decode("b".into()), "decode", None, false),
|
||||||
|
(Error::Stream("b".into()), "stream", None, false),
|
||||||
|
(Error::Config("b".into()), "config", None, false),
|
||||||
|
];
|
||||||
|
for (error, kind, status, unreachable) in cases {
|
||||||
|
let got = classify(&error);
|
||||||
|
assert_eq!((got.kind(), got.status()), (kind, status), "{error:?}");
|
||||||
|
assert_eq!(
|
||||||
|
matches!(got, HonchoError::Unreachable { .. }),
|
||||||
|
unreachable,
|
||||||
|
"{error:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let got = classify(&Error::Unauthorized { status: 403 });
|
||||||
|
assert_eq!(got, HonchoError::KeyRefused { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_conclusion_deleted_or_gone_is_done() {
|
||||||
|
let fake = FakeHoncho::start();
|
||||||
|
let c = clients(&fake);
|
||||||
|
c.delete_conclusion(Workspace::Trusted, "YUBMqIqHhuJprV4o-inrg")
|
||||||
|
.unwrap();
|
||||||
|
c.delete_conclusion(Workspace::Trusted, "YUBMqIqHhuJprV4o-inrg")
|
||||||
|
.unwrap();
|
||||||
|
let requests = fake.requests();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
assert!(requests.iter().all(|r| r.method == "DELETE"
|
||||||
|
&& r.path == "/v3/workspaces/boxmaker0-trusted/conclusions/YUBMqIqHhuJprV4o-inrg"));
|
||||||
|
fake.set(Mode::Status(500));
|
||||||
|
assert_eq!(
|
||||||
|
c.delete_conclusion(Workspace::Trusted, "x"),
|
||||||
|
Err(HonchoError::Unreachable {
|
||||||
|
kind: "server",
|
||||||
|
status: Some(500)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
fake.set(Mode::Status(403));
|
||||||
|
assert_eq!(
|
||||||
|
c.delete_conclusion(Workspace::Trusted, "x"),
|
||||||
|
Err(HonchoError::KeyRefused { status: 403 })
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
//! The keys of `memoryd`'s Honcho clients (M5c spec, section 10): each read with
|
||||||
|
//! `bxcore::secrets`, sent as a bearer token to its own workspace only, and never shown by a
|
||||||
|
//! client, an error or a warning; a key that cannot be read is an error naming it. Against a fake
|
||||||
|
//! Honcho. Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/honcho.rs"]
|
||||||
|
mod honcho;
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use bxcore::secrets::SecretSource;
|
||||||
|
use honcho::{FakeHoncho, Mode};
|
||||||
|
use memoryd::honcho::client::{Clients, Timeouts};
|
||||||
|
use memoryd::honcho::config::{FLEET_KEY, HonchoConfig, TRUSTED_KEY};
|
||||||
|
use memoryd::honcho::ledger::Workspace;
|
||||||
|
use tmp::TempDir;
|
||||||
|
|
||||||
|
const FAST: Timeouts = Timeouts {
|
||||||
|
connect: Duration::from_secs(5),
|
||||||
|
read: Duration::from_millis(300),
|
||||||
|
};
|
||||||
|
|
||||||
|
fn config(url: &str) -> HonchoConfig {
|
||||||
|
HonchoConfig {
|
||||||
|
url: url.to_string(),
|
||||||
|
agent: "boxmaker0".to_string(),
|
||||||
|
owner: "kyle".to_string(),
|
||||||
|
trusted: "boxmaker0-trusted".to_string(),
|
||||||
|
untrusted: "boxmaker0-untrusted".to_string(),
|
||||||
|
fleet: "fleet".to_string(),
|
||||||
|
read: Vec::new(),
|
||||||
|
keys: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn no_env(_: &str) -> Option<OsString> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key values built from pieces, so that no file holds one whole.
|
||||||
|
fn env_key() -> String {
|
||||||
|
["hk", "trusted", "5e1f0a"].join("-")
|
||||||
|
}
|
||||||
|
fn file_key() -> String {
|
||||||
|
["hk", "fleet", "c27d94"].join("-")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_key_goes_to_its_own_workspace_and_is_never_shown() {
|
||||||
|
let dir = TempDir::new("honcho-keys");
|
||||||
|
let file = dir.write("fleet.key", &format!("{}\n", file_key()));
|
||||||
|
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||||
|
let fake = FakeHoncho::start();
|
||||||
|
let mut config = config(&fake.url);
|
||||||
|
config.keys.insert(
|
||||||
|
TRUSTED_KEY.to_string(),
|
||||||
|
SecretSource::Env("HONCHO_TRUSTED".to_string()),
|
||||||
|
);
|
||||||
|
config
|
||||||
|
.keys
|
||||||
|
.insert(FLEET_KEY.to_string(), SecretSource::File(file.clone()));
|
||||||
|
let env = |name: &str| (name == "HONCHO_TRUSTED").then(|| OsString::from(env_key()));
|
||||||
|
let (mut c, warnings) = Clients::new(&config, &env, FAST).unwrap();
|
||||||
|
assert_eq!(warnings.len(), 1, "{warnings:?}");
|
||||||
|
assert!(warnings[0].contains("honcho_fleet"), "{}", warnings[0]);
|
||||||
|
c.ensure_workspace(Workspace::Trusted).unwrap();
|
||||||
|
c.ensure_workspace(Workspace::Untrusted).unwrap();
|
||||||
|
c.ensure_workspace(Workspace::Fleet).unwrap();
|
||||||
|
let auth: Vec<Option<String>> = fake
|
||||||
|
.requests()
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.header("authorization").map(str::to_string))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
auth,
|
||||||
|
vec![
|
||||||
|
Some(format!("Bearer {}", env_key())),
|
||||||
|
None,
|
||||||
|
Some(format!("Bearer {}", file_key())),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// Nothing shows a key: the clients, their errors, the warnings.
|
||||||
|
fake.set(Mode::Status(401));
|
||||||
|
let err = c.ensure_peer(Workspace::Trusted, "kyle").unwrap_err();
|
||||||
|
let shown = [
|
||||||
|
format!("{c:?}"),
|
||||||
|
format!("{err:?}"),
|
||||||
|
err.to_string(),
|
||||||
|
warnings.join("\n"),
|
||||||
|
];
|
||||||
|
for text in shown {
|
||||||
|
assert!(!text.contains(&env_key()), "{text}");
|
||||||
|
assert!(!text.contains(&file_key()), "{text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_key_that_cannot_be_read_is_an_error_naming_it() {
|
||||||
|
let fake = FakeHoncho::start();
|
||||||
|
let mut config = config(&fake.url);
|
||||||
|
config.keys.insert(
|
||||||
|
TRUSTED_KEY.to_string(),
|
||||||
|
SecretSource::Env("HONCHO_TRUSTED".to_string()),
|
||||||
|
);
|
||||||
|
let err = Clients::new(&config, &no_env, FAST).unwrap_err();
|
||||||
|
assert!(err.contains("honcho_trusted"), "{err}");
|
||||||
|
assert!(
|
||||||
|
err.contains("see docs/runbook.md#secret-unavailable"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set in the process `a_roncho_toml_in_home_is_not_read` starts, and only there.
|
||||||
|
const CHILD: &str = "MEMORYD_TEST_RONCHO_HOME";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_roncho_toml_in_home_is_not_read() {
|
||||||
|
// `roncho` reads `$HOME/.config/roncho/roncho.toml` unless told not to, and takes its
|
||||||
|
// `api_key`. HOME is the process's, so the check runs in a process of its own: this test
|
||||||
|
// binary, running only the test below.
|
||||||
|
let dir = TempDir::new("honcho-home");
|
||||||
|
let key = ["hk", "from", "roncho", "toml"].join("-");
|
||||||
|
dir.write(
|
||||||
|
".config/roncho/roncho.toml",
|
||||||
|
&format!("api_key = \"{key}\"\n"),
|
||||||
|
);
|
||||||
|
let mut child = Command::new(std::env::current_exe().unwrap())
|
||||||
|
.args(["--exact", "in_a_home_with_a_roncho_toml", "--ignored"])
|
||||||
|
.env("HOME", dir.path())
|
||||||
|
.env_remove("XDG_CONFIG_HOME")
|
||||||
|
.env(CHILD, "1")
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(20);
|
||||||
|
let mut status = None;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if let Ok(Some(done)) = child.try_wait() {
|
||||||
|
status = Some(done);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
let _ = child.kill();
|
||||||
|
let output = child.wait_with_output().unwrap();
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(status.is_some_and(|s| s.success()), "{stdout}");
|
||||||
|
assert!(stdout.contains("1 passed"), "{stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "run by a_roncho_toml_in_home_is_not_read, in a process of its own"]
|
||||||
|
fn in_a_home_with_a_roncho_toml() {
|
||||||
|
if std::env::var_os(CHILD).is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let fake = FakeHoncho::start();
|
||||||
|
let (mut c, _) = Clients::new(&config(&fake.url), &no_env, FAST).unwrap();
|
||||||
|
c.ensure_workspace(Workspace::Trusted).unwrap();
|
||||||
|
let requests = fake.requests();
|
||||||
|
assert_eq!(requests.len(), 1);
|
||||||
|
assert_eq!(requests[0].header("authorization"), None);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
//! Which Honcho session a turn goes to (M5c spec, section 3): the Boxmaker session's id, then
|
||||||
|
//! `<id>-2`, `<id>-3` for each new speaker, holding every speaker so far; numbered from the
|
||||||
|
//! ledger's decisions alone, so a restart numbers the same. Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use bxcore::{SessionId, Timestamp};
|
||||||
|
use memoryd::honcho::ledger::{Action, Item, Ledger, Record, Workspace};
|
||||||
|
use memoryd::honcho::naming::{HonchoSession, honcho_session, session_name};
|
||||||
|
use tmp::TempDir;
|
||||||
|
|
||||||
|
fn sid(s: &str) -> SessionId {
|
||||||
|
SessionId::new(s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn turn(session: &str, turn: u64) -> Item {
|
||||||
|
Item::Turn {
|
||||||
|
session: sid(session),
|
||||||
|
turn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spoke(workspace: Workspace, speaker: &str) -> Action {
|
||||||
|
Action::Turn {
|
||||||
|
workspace,
|
||||||
|
speaker: speaker.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip() -> Action {
|
||||||
|
Action::Skip {
|
||||||
|
why: "the turn has no single speaker".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hs(name: &str, speakers: &[&str]) -> Option<HonchoSession> {
|
||||||
|
Some(HonchoSession {
|
||||||
|
name: name.to_string(),
|
||||||
|
speakers: speakers.iter().map(|s| s.to_string()).collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ledger with these decisions, in this order.
|
||||||
|
fn ledger(dir: &TempDir, decisions: &[(Item, Action)]) -> Ledger {
|
||||||
|
let path = dir.path().join("honcho.jsonl");
|
||||||
|
let mut ledger = Ledger::open(&path).unwrap();
|
||||||
|
for (item, action) in decisions {
|
||||||
|
ledger
|
||||||
|
.append(Record::Decided {
|
||||||
|
time: Timestamp::parse("2026-09-28T10:00:00.000Z").unwrap(),
|
||||||
|
item: item.clone(),
|
||||||
|
action: action.clone(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
ledger
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_names() {
|
||||||
|
assert_eq!(session_name(&sid("chat-1"), 0), "chat-1");
|
||||||
|
assert_eq!(session_name(&sid("chat-1"), 1), "chat-1");
|
||||||
|
assert_eq!(session_name(&sid("chat-1"), 2), "chat-1-2");
|
||||||
|
assert_eq!(session_name(&sid("chat-1"), 12), "chat-1-12");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_new_speaker_starts_the_next_session() {
|
||||||
|
let dir = TempDir::new("naming-order");
|
||||||
|
let decisions = [
|
||||||
|
(turn("chat-1", 1), spoke(Workspace::Trusted, "kyle")),
|
||||||
|
(turn("chat-2", 1), spoke(Workspace::Trusted, "zed")),
|
||||||
|
(turn("chat-1", 2), spoke(Workspace::Trusted, "kyle")),
|
||||||
|
(turn("chat-1", 3), spoke(Workspace::Untrusted, "ana_b")),
|
||||||
|
(turn("chat-1", 4), skip()),
|
||||||
|
(turn("chat-1", 5), spoke(Workspace::Trusted, "kyle")),
|
||||||
|
(turn("chat-1", 6), spoke(Workspace::Untrusted, "bob")),
|
||||||
|
(turn("chat-1", 7), spoke(Workspace::Trusted, "ana_b")),
|
||||||
|
];
|
||||||
|
let l = ledger(&dir, &decisions);
|
||||||
|
let at = |t: u64| honcho_session(l.decisions(), &sid("chat-1"), t);
|
||||||
|
assert_eq!(at(1), hs("chat-1", &["kyle"]));
|
||||||
|
assert_eq!(at(2), hs("chat-1", &["kyle"]));
|
||||||
|
// A new speaker in any workspace counts.
|
||||||
|
assert_eq!(at(3), hs("chat-1-2", &["kyle", "ana_b"]));
|
||||||
|
assert_eq!(at(4), None, "a skipped turn goes nowhere");
|
||||||
|
assert_eq!(at(5), hs("chat-1-2", &["kyle", "ana_b"]));
|
||||||
|
assert_eq!(at(6), hs("chat-1-3", &["kyle", "ana_b", "bob"]));
|
||||||
|
assert_eq!(at(7), hs("chat-1-3", &["kyle", "ana_b", "bob"]));
|
||||||
|
assert_eq!(at(8), None, "an undecided turn");
|
||||||
|
// Another session's speakers do not count.
|
||||||
|
assert_eq!(
|
||||||
|
honcho_session(l.decisions(), &sid("chat-2"), 1),
|
||||||
|
hs("chat-2", &["zed"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_order_is_the_order_of_decision() {
|
||||||
|
let dir = TempDir::new("naming-decided");
|
||||||
|
// Turn 2 was decided first (turn 1 waited for brokerd): its speaker is the first.
|
||||||
|
let decisions = [
|
||||||
|
(turn("chat-1", 2), spoke(Workspace::Trusted, "ana_b")),
|
||||||
|
(turn("chat-1", 1), spoke(Workspace::Trusted, "kyle")),
|
||||||
|
];
|
||||||
|
let l = ledger(&dir, &decisions);
|
||||||
|
assert_eq!(
|
||||||
|
honcho_session(l.decisions(), &sid("chat-1"), 2),
|
||||||
|
hs("chat-1", &["ana_b"])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
honcho_session(l.decisions(), &sid("chat-1"), 1),
|
||||||
|
hs("chat-1-2", &["ana_b", "kyle"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_restart_numbers_the_same() {
|
||||||
|
let dir = TempDir::new("naming-restart");
|
||||||
|
let decisions = [
|
||||||
|
(turn("chat-1", 1), spoke(Workspace::Trusted, "kyle")),
|
||||||
|
(turn("chat-1", 2), skip()),
|
||||||
|
(turn("chat-1", 3), spoke(Workspace::Trusted, "ana_b")),
|
||||||
|
];
|
||||||
|
let before: Vec<Option<HonchoSession>> = {
|
||||||
|
let l = ledger(&dir, &decisions);
|
||||||
|
(1..=3)
|
||||||
|
.map(|t| honcho_session(l.decisions(), &sid("chat-1"), t))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let l = Ledger::open(&dir.path().join("honcho.jsonl")).unwrap();
|
||||||
|
let after: Vec<Option<HonchoSession>> = (1..=3)
|
||||||
|
.map(|t| honcho_session(l.decisions(), &sid("chat-1"), t))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(before, after);
|
||||||
|
assert_eq!(after[2], hs("chat-1-2", &["kyle", "ana_b"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decisions_are_every_decided_item_in_order() {
|
||||||
|
let dir = TempDir::new("naming-decisions");
|
||||||
|
let decisions = [
|
||||||
|
(turn("chat-1", 2), skip()),
|
||||||
|
(turn("chat-1", 1), spoke(Workspace::Trusted, "kyle")),
|
||||||
|
];
|
||||||
|
let l = ledger(&dir, &decisions);
|
||||||
|
let got: Vec<(Item, Action)> = l.decisions().map(|(i, a)| (i.clone(), a.clone())).collect();
|
||||||
|
assert_eq!(got, decisions.to_vec());
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
//! A fake Honcho for tests, on 127.0.0.1 and a port of its own: it answers each request from the
|
||||||
|
//! responses recorded from rift (`tests/fixtures/honcho/`), by method and path, and records every
|
||||||
|
//! request, body included. It can be set to answer every request with a status instead, or to
|
||||||
|
//! answer nothing. Stopped, and its held connections released, when dropped; every wait has a
|
||||||
|
//! deadline. Do not edit.
|
||||||
|
|
||||||
|
#![allow(dead_code)] // each test file uses a different part of this module
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// One request as the fake read it.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Request {
|
||||||
|
pub method: String,
|
||||||
|
pub path: String,
|
||||||
|
/// Header names in lower case.
|
||||||
|
pub headers: Vec<(String, String)>,
|
||||||
|
pub body: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Request {
|
||||||
|
pub fn header(&self, name: &str) -> Option<&str> {
|
||||||
|
self.headers
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k == name)
|
||||||
|
.map(|(_, v)| v.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the fake answers.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Mode {
|
||||||
|
/// From the fixtures.
|
||||||
|
Fixtures,
|
||||||
|
/// Every request with this status. A 422 echoes the request's body, as Honcho does.
|
||||||
|
Status(u16),
|
||||||
|
/// Read the request, then answer nothing until the fake is dropped (at most 30 s).
|
||||||
|
Hang,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Shared {
|
||||||
|
mode: Mutex<Mode>,
|
||||||
|
requests: Mutex<Vec<Request>>,
|
||||||
|
/// What was made, by method and path and id: a second create answers 200, a second delete
|
||||||
|
/// 404.
|
||||||
|
made: Mutex<HashSet<String>>,
|
||||||
|
stop: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FakeHoncho {
|
||||||
|
pub url: String,
|
||||||
|
port: u16,
|
||||||
|
shared: Arc<Shared>,
|
||||||
|
accept: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A recorded fixture: its response's status and body.
|
||||||
|
fn fixture(name: &str) -> (u16, String) {
|
||||||
|
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("tests/fixtures/honcho")
|
||||||
|
.join(name);
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap();
|
||||||
|
let value: Value = serde_json::from_str(&text).unwrap();
|
||||||
|
let status = u16::try_from(value["response"]["status"].as_u64().unwrap()).unwrap();
|
||||||
|
let body = match &value["response"]["body"] {
|
||||||
|
Value::String(s) if s.is_empty() => String::new(),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
(status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeHoncho {
|
||||||
|
pub fn start() -> FakeHoncho {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let shared = Arc::new(Shared {
|
||||||
|
mode: Mutex::new(Mode::Fixtures),
|
||||||
|
requests: Mutex::new(Vec::new()),
|
||||||
|
made: Mutex::new(HashSet::new()),
|
||||||
|
stop: AtomicBool::new(false),
|
||||||
|
});
|
||||||
|
let for_accept = Arc::clone(&shared);
|
||||||
|
let accept = std::thread::spawn(move || {
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
if for_accept.stop.load(Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(stream) = stream else { return };
|
||||||
|
let shared = Arc::clone(&for_accept);
|
||||||
|
std::thread::spawn(move || serve(stream, &shared));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
FakeHoncho {
|
||||||
|
url: format!("http://127.0.0.1:{port}"),
|
||||||
|
port,
|
||||||
|
shared,
|
||||||
|
accept: Some(accept),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&self, mode: Mode) {
|
||||||
|
*self.shared.mode.lock().unwrap() = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every request read so far, in order.
|
||||||
|
pub fn requests(&self) -> Vec<Request> {
|
||||||
|
self.shared.requests.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the requests read so far (not what was made).
|
||||||
|
pub fn clear(&self) {
|
||||||
|
self.shared.requests.lock().unwrap().clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits up to 10 s until at least `n` requests were read; true if they were.
|
||||||
|
pub fn wait_for(&self, n: usize) -> bool {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if self.requests().len() >= n {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for FakeHoncho {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.shared.stop.store(true, Ordering::SeqCst);
|
||||||
|
let _ = TcpStream::connect(("127.0.0.1", self.port));
|
||||||
|
if let Some(accept) = self.accept.take() {
|
||||||
|
let _ = accept.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads one request: the head up to its blank line, then `Content-Length` bytes of body.
|
||||||
|
fn read_request(stream: &mut TcpStream) -> Option<Request> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut byte = [0u8; 1];
|
||||||
|
while !buf.ends_with(b"\r\n\r\n") {
|
||||||
|
if stream.read(&mut byte).ok()? == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
buf.push(byte[0]);
|
||||||
|
}
|
||||||
|
let head = String::from_utf8(buf).ok()?;
|
||||||
|
let mut lines = head.split("\r\n");
|
||||||
|
let mut first = lines.next()?.split(' ');
|
||||||
|
let (method, path) = (first.next()?.to_string(), first.next()?.to_string());
|
||||||
|
let headers: Vec<(String, String)> = lines
|
||||||
|
.filter_map(|l| l.split_once(": "))
|
||||||
|
.map(|(k, v)| (k.to_ascii_lowercase(), v.to_string()))
|
||||||
|
.collect();
|
||||||
|
let length: usize = headers
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k == "content-length")
|
||||||
|
.map_or(Some(0), |(_, v)| v.parse().ok())?;
|
||||||
|
let mut body = vec![0u8; length];
|
||||||
|
stream.read_exact(&mut body).ok()?;
|
||||||
|
let body = if length == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
serde_json::from_slice(&body).ok()
|
||||||
|
};
|
||||||
|
Some(Request {
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixture that answers `request`, by its method and the shape of its path; the second of a
|
||||||
|
/// pair when the same thing was made (or deleted) before.
|
||||||
|
fn answer(request: &Request, made: &Mutex<HashSet<String>>) -> (u16, String) {
|
||||||
|
let parts: Vec<&str> = request.path.trim_start_matches('/').split('/').collect();
|
||||||
|
let id = request
|
||||||
|
.body
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|b| b["id"].as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let again = !made
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(format!("{} {} {id}", request.method, request.path));
|
||||||
|
let configured = request
|
||||||
|
.body
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|b| b.get("configuration").is_some());
|
||||||
|
match (request.method.as_str(), parts.as_slice()) {
|
||||||
|
("POST", ["v3", "workspaces"]) if again => fixture("02-workspace-create-again.json"),
|
||||||
|
("POST", ["v3", "workspaces"]) => fixture("01-workspace-create.json"),
|
||||||
|
("POST", ["v3", "workspaces", _, "peers"]) if again => {
|
||||||
|
fixture("05-peer-create-boxmaker0-again.json")
|
||||||
|
}
|
||||||
|
("POST", ["v3", "workspaces", _, "peers"]) if configured => {
|
||||||
|
fixture("04-peer-create-boxmaker0.json")
|
||||||
|
}
|
||||||
|
("POST", ["v3", "workspaces", _, "peers"]) => fixture("03-peer-create-kyle.json"),
|
||||||
|
("POST", ["v3", "workspaces", _, "sessions"]) if again => {
|
||||||
|
fixture("07-session-create-again.json")
|
||||||
|
}
|
||||||
|
("POST", ["v3", "workspaces", _, "sessions"]) => fixture("06-session-create.json"),
|
||||||
|
("POST", ["v3", "workspaces", _, "sessions", _, "messages"]) => {
|
||||||
|
fixture("08-messages-add-batch.json")
|
||||||
|
}
|
||||||
|
("DELETE", ["v3", "workspaces", _, "conclusions", _]) if again => {
|
||||||
|
fixture("16-conclusion-delete-again.json")
|
||||||
|
}
|
||||||
|
("DELETE", ["v3", "workspaces", _, "conclusions", _]) => {
|
||||||
|
fixture("15-conclusion-delete.json")
|
||||||
|
}
|
||||||
|
_ => (404, r#"{"detail":"Not Found"}"#.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve(mut stream: TcpStream, shared: &Shared) {
|
||||||
|
let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
|
||||||
|
let Some(request) = read_request(&mut stream) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
shared.requests.lock().unwrap().push(request.clone());
|
||||||
|
let mode = *shared.mode.lock().unwrap();
|
||||||
|
let (status, body) = match mode {
|
||||||
|
Mode::Fixtures => answer(&request, &shared.made),
|
||||||
|
Mode::Status(422) => {
|
||||||
|
let input = request.body.clone().unwrap_or(Value::Null);
|
||||||
|
(
|
||||||
|
422,
|
||||||
|
serde_json::json!({ "detail": [{ "input": input }] }).to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Mode::Status(status) => (status, r#"{"detail":"refused"}"#.to_string()),
|
||||||
|
Mode::Hang => {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
while !shared.stop.load(Ordering::SeqCst) && Instant::now() < deadline {
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let head = format!(
|
||||||
|
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
let _ = stream.write_all(head.as_bytes());
|
||||||
|
let _ = stream.write_all(body.as_bytes());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user