memoryd: a Honcho client per workspace, and its workspace, peers and sessions made sure of

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-28 12:13:48 -07:00
co-authored by ornith-1.5-35b-a3b
parent 28df2a0e3b
commit aabfbc2f0a
39 changed files with 2324 additions and 0 deletions
@@ -0,0 +1,42 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces",
"body": {
"id": "boxmaker-plan-probe",
"configuration": {
"dream": {
"enabled": false
},
"reasoning": {
"enabled": false
},
"summary": {
"enabled": false
}
}
}
},
"response": {
"status": 201,
"headers": {
"content-type": "application/json"
},
"body": {
"id": "boxmaker-plan-probe",
"metadata": {},
"configuration": {
"reasoning": {
"enabled": false
},
"summary": {
"enabled": false
},
"dream": {
"enabled": false
}
},
"created_at": "2026-09-28T05:55:49.212118Z"
}
}
}
@@ -0,0 +1,42 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces",
"body": {
"id": "boxmaker-plan-probe",
"configuration": {
"dream": {
"enabled": false
},
"reasoning": {
"enabled": false
},
"summary": {
"enabled": false
}
}
}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"id": "boxmaker-plan-probe",
"metadata": {},
"configuration": {
"reasoning": {
"enabled": false
},
"summary": {
"enabled": false
},
"dream": {
"enabled": false
}
},
"created_at": "2026-09-28T05:55:49.212118Z"
}
}
}
@@ -0,0 +1,22 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/peers",
"body": {
"id": "kyle"
}
},
"response": {
"status": 201,
"headers": {
"content-type": "application/json"
},
"body": {
"id": "kyle",
"workspace_id": "boxmaker-plan-probe",
"created_at": "2026-09-28T05:55:56.303995Z",
"metadata": {},
"configuration": {}
}
}
}
@@ -0,0 +1,27 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/peers",
"body": {
"id": "boxmaker0",
"configuration": {
"observe_me": false
}
}
},
"response": {
"status": 201,
"headers": {
"content-type": "application/json"
},
"body": {
"id": "boxmaker0",
"workspace_id": "boxmaker-plan-probe",
"created_at": "2026-09-28T05:55:56.326330Z",
"metadata": {},
"configuration": {
"observe_me": false
}
}
}
}
@@ -0,0 +1,27 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/peers",
"body": {
"id": "boxmaker0",
"configuration": {
"observe_me": false
}
}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"id": "boxmaker0",
"workspace_id": "boxmaker-plan-probe",
"created_at": "2026-09-28T05:55:56.326330Z",
"metadata": {},
"configuration": {
"observe_me": false
}
}
}
}
@@ -0,0 +1,33 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions",
"body": {
"id": "s-probe-1",
"peers": {
"kyle": {
"observe_me": true,
"observe_others": false
},
"boxmaker0": {
"observe_me": false,
"observe_others": false
}
}
}
},
"response": {
"status": 201,
"headers": {
"content-type": "application/json"
},
"body": {
"id": "s-probe-1",
"is_active": true,
"workspace_id": "boxmaker-plan-probe",
"metadata": {},
"configuration": {},
"created_at": "2026-09-28T05:56:01.274224Z"
}
}
}
@@ -0,0 +1,33 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions",
"body": {
"id": "s-probe-1",
"peers": {
"kyle": {
"observe_me": true,
"observe_others": false
},
"boxmaker0": {
"observe_me": false,
"observe_others": false
}
}
}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"id": "s-probe-1",
"is_active": true,
"workspace_id": "boxmaker-plan-probe",
"metadata": {},
"configuration": {},
"created_at": "2026-09-28T05:56:01.274224Z"
}
}
}
@@ -0,0 +1,64 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/s-probe-1/messages",
"body": {
"messages": [
{
"content": "I prefer tea to coffee in the afternoon.",
"peer_id": "kyle",
"metadata": {
"agent": "boxmaker0",
"boxmaker_session": "s-probe-1",
"turn": 1
}
},
{
"content": "Noted: tea in the afternoon.",
"peer_id": "boxmaker0",
"metadata": {
"agent": "boxmaker0",
"boxmaker_session": "s-probe-1",
"turn": 1
}
}
]
}
},
"response": {
"status": 201,
"headers": {
"content-type": "application/json"
},
"body": [
{
"id": "7HqDWzXcKAdw9tnElCNFg",
"content": "I prefer tea to coffee in the afternoon.",
"peer_id": "kyle",
"session_id": "s-probe-1",
"metadata": {
"agent": "boxmaker0",
"boxmaker_session": "s-probe-1",
"turn": 1
},
"created_at": "2026-09-28T05:56:08.742363Z",
"workspace_id": "boxmaker-plan-probe",
"token_count": 9
},
{
"id": "mjsyhwuGsXTh1_zG_tWRx",
"content": "Noted: tea in the afternoon.",
"peer_id": "boxmaker0",
"session_id": "s-probe-1",
"metadata": {
"agent": "boxmaker0",
"boxmaker_session": "s-probe-1",
"turn": 1
},
"created_at": "2026-09-28T05:56:08.742363Z",
"workspace_id": "boxmaker-plan-probe",
"token_count": 8
}
]
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,52 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/conclusions",
"body": {
"conclusions": [
{
"content": "The owner's builds run on straylight.",
"observer_id": "boxmaker0",
"observed_id": "boxmaker0",
"session_id": "s-probe-1"
},
{
"content": "Kyle prefers tea in the afternoon.",
"observer_id": "kyle",
"observed_id": "kyle",
"session_id": "s-probe-1"
}
]
}
},
"response": {
"status": 201,
"headers": {
"content-type": "application/json"
},
"body": [
{
"id": "YUBMqIqHhuJprV4o-inrg",
"content": "The owner's builds run on straylight.",
"observer_id": "boxmaker0",
"observed_id": "boxmaker0",
"session_id": "s-probe-1",
"level": "explicit",
"source_ids": null,
"times_derived": 1,
"created_at": "2026-09-28T05:56:22.130594Z"
},
{
"id": "JuoCFtoo5lwFL7O4ySZ6f",
"content": "Kyle prefers tea in the afternoon.",
"observer_id": "kyle",
"observed_id": "kyle",
"session_id": "s-probe-1",
"level": "explicit",
"source_ids": null,
"times_derived": 1,
"created_at": "2026-09-28T05:56:22.130594Z"
}
]
}
}
@@ -0,0 +1,32 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/conclusions/query",
"body": {
"query": "builds",
"filters": {
"observed_id": "boxmaker0",
"observer_id": "boxmaker0"
}
}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": [
{
"id": "YUBMqIqHhuJprV4o-inrg",
"content": "The owner's builds run on straylight.",
"observer_id": "boxmaker0",
"observed_id": "boxmaker0",
"session_id": "s-probe-1",
"level": "explicit",
"source_ids": null,
"times_derived": 1,
"created_at": "2026-09-28T05:56:22.130594Z"
}
]
}
}
@@ -0,0 +1,43 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/conclusions/list",
"body": {}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"items": [
{
"id": "YUBMqIqHhuJprV4o-inrg",
"content": "The owner's builds run on straylight.",
"observer_id": "boxmaker0",
"observed_id": "boxmaker0",
"session_id": "s-probe-1",
"level": "explicit",
"source_ids": null,
"times_derived": 1,
"created_at": "2026-09-28T05:56:22.130594Z"
},
{
"id": "JuoCFtoo5lwFL7O4ySZ6f",
"content": "Kyle prefers tea in the afternoon.",
"observer_id": "kyle",
"observed_id": "kyle",
"session_id": "s-probe-1",
"level": "explicit",
"source_ids": null,
"times_derived": 1,
"created_at": "2026-09-28T05:56:22.130594Z"
}
],
"total": 2,
"page": 1,
"size": 50,
"pages": 1
}
}
}
@@ -0,0 +1,37 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/conclusions/list",
"body": {
"filters": {
"observed_id": "boxmaker0",
"observer_id": "boxmaker0"
}
}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"items": [
{
"id": "YUBMqIqHhuJprV4o-inrg",
"content": "The owner's builds run on straylight.",
"observer_id": "boxmaker0",
"observed_id": "boxmaker0",
"session_id": "s-probe-1",
"level": "explicit",
"source_ids": null,
"times_derived": 1,
"created_at": "2026-09-28T05:56:22.130594Z"
}
],
"total": 1,
"page": 1,
"size": 50,
"pages": 1
}
}
}
@@ -0,0 +1,14 @@
{
"request": {
"method": "DELETE",
"path": "/v3/workspaces/boxmaker-plan-probe/conclusions/YUBMqIqHhuJprV4o-inrg",
"body": null
},
"response": {
"status": 204,
"headers": {
"content-type": "application/json"
},
"body": ""
}
}
@@ -0,0 +1,16 @@
{
"request": {
"method": "DELETE",
"path": "/v3/workspaces/boxmaker-plan-probe/conclusions/YUBMqIqHhuJprV4o-inrg",
"body": null
},
"response": {
"status": 404,
"headers": {
"content-type": "application/json"
},
"body": {
"detail": "Document YUBMqIqHhuJprV4o-inrg not found or does not belong to workspace boxmaker-plan-probe"
}
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/chat",
"body": {
"query": "What does the owner drink in the afternoon?",
"reasoning_level": "minimal"
}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"content": "Based on what I found in memory, the owner in this workspace is **kyle**.\n\nThe relevant exchange (session s-probe-1, 2026-09-28):\n\n- **kyle**: \"I prefer tea to coffee in the afternoon.\"\n- **boxmaker0**: \"Noted: tea in the afternoon.\"\n\nSo the owner (kyle) drinks **tea** in the afternoon — specifically, tea rather than coffee.\n\nOne caveat: the second message from kyle in that conversation is a long run of repeated \"a\"s rather than a substantive statement, so there's no additional afternoon-drink detail beyond the tea preference.",
"evidence": null
}
}
}
@@ -0,0 +1,32 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/s-missing/messages",
"body": {
"messages": [
{
"content": "probe",
"peer_id": "kyle"
}
]
}
},
"response": {
"status": 201,
"headers": {
"content-type": "application/json"
},
"body": [
{
"id": "HaXlV5eTh7GTEuID4JXir",
"content": "probe",
"peer_id": "kyle",
"session_id": "s-missing",
"metadata": {},
"created_at": "2026-09-28T05:57:01.015107Z",
"workspace_id": "boxmaker-plan-probe",
"token_count": 1
}
]
}
}
@@ -0,0 +1,31 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/peers",
"body": {
"id": "bad peer"
}
},
"response": {
"status": 422,
"headers": {
"content-type": "application/json"
},
"body": {
"detail": [
{
"type": "string_pattern_mismatch",
"loc": [
"body",
"id"
],
"msg": "String should match pattern '^[a-zA-Z0-9_-]+$'",
"input": "bad peer",
"ctx": {
"pattern": "^[a-zA-Z0-9_-]+$"
}
}
]
}
}
}
@@ -0,0 +1,20 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/s-never/messages/list",
"body": {}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"items": [],
"total": 0,
"page": 1,
"size": 50,
"pages": 0
}
}
}
@@ -0,0 +1,16 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/s-never/search",
"body": {
"query": "tea"
}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": []
}
}
@@ -0,0 +1,16 @@
{
"request": {
"method": "DELETE",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/s-never",
"body": null
},
"response": {
"status": 404,
"headers": {
"content-type": "application/json"
},
"body": {
"detail": "Session s-never not found in workspace boxmaker-plan-probe"
}
}
}
@@ -0,0 +1,37 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/list",
"body": {}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"items": [
{
"id": "s-probe-1",
"is_active": true,
"workspace_id": "boxmaker-plan-probe",
"metadata": {},
"configuration": {},
"created_at": "2026-09-28T05:56:01.274224Z"
},
{
"id": "s-missing",
"is_active": true,
"workspace_id": "boxmaker-plan-probe",
"metadata": {},
"configuration": {},
"created_at": "2026-09-28T05:57:00.994684Z"
}
],
"total": 2,
"page": 1,
"size": 50,
"pages": 1
}
}
}
@@ -0,0 +1,16 @@
{
"request": {
"method": "DELETE",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/s-probe-1",
"body": null
},
"response": {
"status": 202,
"headers": {
"content-type": "application/json"
},
"body": {
"message": "Session deleted successfully"
}
}
}
@@ -0,0 +1,16 @@
{
"request": {
"method": "DELETE",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/s-missing",
"body": null
},
"response": {
"status": 202,
"headers": {
"content-type": "application/json"
},
"body": {
"message": "Session deleted successfully"
}
}
}
@@ -0,0 +1,20 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/list",
"body": {}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"items": [],
"total": 0,
"page": 1,
"size": 50,
"pages": 0
}
}
}
@@ -0,0 +1,16 @@
{
"request": {
"method": "DELETE",
"path": "/v3/workspaces/boxmaker-plan-probe",
"body": null
},
"response": {
"status": 202,
"headers": {
"content-type": "application/json"
},
"body": {
"message": "Workspace deletion accepted"
}
}
}
+7
View File
@@ -0,0 +1,7 @@
# Honcho responses, recorded
Real requests and responses from the owner's Honcho (3.2.0, rift), recorded on 2026-09-27 by the
design model for M5c-1, in a throwaway workspace, `boxmaker-plan-probe`, created for it and deleted
afterwards. Each request is exactly what `roncho` 1.0.0's blocking client sends. Each file is
`{"request": {"method", "path", "body"}, "response": {"status", "headers", "body"}}`. The fake
Honcho server in `memoryd`'s tests answers from them. Do not edit.
+439
View File
@@ -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 })
);
}
+171
View File
@@ -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);
}
+150
View File
@@ -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());
}
+260
View File
@@ -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());
}