memoryd: the Honcho worker sends conclusions, their deletions and fleet notes
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -127,6 +127,7 @@ pub struct Clients {
|
||||
untrusted: Client,
|
||||
fleet: Client,
|
||||
agent: String,
|
||||
owner: String,
|
||||
ensured: HashSet<(Workspace, Ensured)>,
|
||||
}
|
||||
|
||||
@@ -233,6 +234,7 @@ impl Clients {
|
||||
)?,
|
||||
fleet: client(&config.url, &config.fleet, fleet_key.as_ref(), timeouts)?,
|
||||
agent: config.agent.clone(),
|
||||
owner: config.owner.clone(),
|
||||
ensured: HashSet::new(),
|
||||
};
|
||||
Ok((clients, warnings))
|
||||
@@ -307,6 +309,36 @@ impl Clients {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make sure memory session `name` (a Boxmaker session's `<id>_memory`) exists in
|
||||
/// `workspace`, after the workspace, the agent's peer and the owner's. It holds conclusions,
|
||||
/// no messages, so neither peer observes (M5c spec, section 4).
|
||||
pub fn ensure_memory_session(
|
||||
&mut self,
|
||||
workspace: Workspace,
|
||||
name: &str,
|
||||
) -> Result<(), HonchoError> {
|
||||
self.ensure_workspace(workspace)?;
|
||||
let (agent, owner) = (self.agent.clone(), self.owner.clone());
|
||||
self.ensure_peer(workspace, &agent)?;
|
||||
self.ensure_peer(workspace, &owner)?;
|
||||
let key = (workspace, Ensured::Session(name.to_string()));
|
||||
if self.ensured.contains(&key) {
|
||||
return Ok(());
|
||||
}
|
||||
let unobserved = || {
|
||||
SessionPeerConfig::new()
|
||||
.observe_me(false)
|
||||
.observe_others(false)
|
||||
};
|
||||
let mut peers = HashMap::new();
|
||||
peers.insert(agent, unobserved());
|
||||
peers.insert(owner, unobserved());
|
||||
let create = SessionCreate::new(name).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) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Memory writes sent to Honcho (M5c spec, sections 4 and 5), for the worker: a conclusion made
|
||||
//! in the Boxmaker session's memory session, `<id>_memory`; a conclusion deleted by the id its
|
||||
//! making was given; an untrusted note added to the fleet workspace as a message from the agent.
|
||||
//! The text is neutralised here, as it leaves.
|
||||
|
||||
use bxcore::SessionId;
|
||||
use bxcore::sanitize::neutralise;
|
||||
use roncho::{ConclusionBatchCreate, ConclusionCreate, MessageCreate};
|
||||
use serde_json::{Map, json};
|
||||
|
||||
use crate::honcho::client::{Clients, HonchoError, classify};
|
||||
use crate::honcho::ledger::Workspace;
|
||||
use crate::honcho::naming::HonchoSession;
|
||||
use crate::honcho::worker::parts;
|
||||
|
||||
/// What names a Boxmaker session's memory session: `_` is in no Boxmaker id, so the name is no
|
||||
/// other session's.
|
||||
pub const MEMORY_SUFFIX: &str = "_memory";
|
||||
|
||||
/// The memory session of `session`: `<id>_memory`.
|
||||
pub fn memory_session(session: &SessionId) -> String {
|
||||
format!("{}{MEMORY_SUFFIX}", session.as_str())
|
||||
}
|
||||
|
||||
/// Make conclusion `text` about `peer` (its observer and the observed) in `workspace`, stamped
|
||||
/// with `session`'s memory session, which is made sure of first. Its one id.
|
||||
pub fn conclude(
|
||||
clients: &mut Clients,
|
||||
workspace: Workspace,
|
||||
peer: &str,
|
||||
session: &SessionId,
|
||||
text: &str,
|
||||
) -> Result<Vec<String>, HonchoError> {
|
||||
let name = memory_session(session);
|
||||
clients.ensure_memory_session(workspace, &name)?;
|
||||
let conclusion = ConclusionCreate::new(neutralise(text), peer, peer).with_session_id(name);
|
||||
let batch = ConclusionBatchCreate::new(vec![conclusion]);
|
||||
match clients.client(workspace).create_conclusions(&batch) {
|
||||
Ok(made) => Ok(made.into_iter().map(|c| c.id).collect()),
|
||||
Err(e) => Err(classify(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete conclusion `id` in `workspace`; with none to delete, nothing is asked. The ids of what
|
||||
/// was deleted.
|
||||
pub fn unconclude(
|
||||
clients: &Clients,
|
||||
workspace: Workspace,
|
||||
id: Option<&str>,
|
||||
) -> Result<Vec<String>, HonchoError> {
|
||||
match id {
|
||||
Some(id) => {
|
||||
clients.delete_conclusion(workspace, id)?;
|
||||
Ok(vec![id.to_string()])
|
||||
}
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// An untrusted note's messages for the fleet workspace: from the agent's peer, neutralised, in
|
||||
/// parts of at most `MAX_PART` characters, each with the note's metadata.
|
||||
pub fn fleet_batch(text: &str, agent: &str, session: &SessionId, edit: &str) -> Vec<MessageCreate> {
|
||||
let mut metadata = Map::new();
|
||||
metadata.insert("agent".to_string(), json!(agent));
|
||||
metadata.insert("boxmaker_session".to_string(), json!(session.as_str()));
|
||||
metadata.insert("edit".to_string(), json!(edit));
|
||||
parts(&neutralise(text))
|
||||
.into_iter()
|
||||
.map(|part| MessageCreate::new(part, agent).with_metadata(metadata.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Add `messages` to `session`'s Honcho session in the fleet workspace, which holds only the
|
||||
/// agent's peer, made sure of first. The ids of the messages.
|
||||
pub fn fleet(
|
||||
clients: &mut Clients,
|
||||
session: &SessionId,
|
||||
messages: &[MessageCreate],
|
||||
) -> Result<Vec<String>, HonchoError> {
|
||||
let honcho = HonchoSession {
|
||||
name: session.as_str().to_string(),
|
||||
speakers: Vec::new(),
|
||||
};
|
||||
clients.ensure_session(Workspace::Fleet, &honcho)?;
|
||||
match clients
|
||||
.client(Workspace::Fleet)
|
||||
.add_messages(&honcho.name, messages)
|
||||
{
|
||||
Ok(made) => Ok(made.into_iter().map(|m| m.id).collect()),
|
||||
Err(e) => Err(classify(&e)),
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod decide;
|
||||
pub mod edits;
|
||||
pub mod ledger;
|
||||
pub mod mirror;
|
||||
pub mod naming;
|
||||
pub mod start;
|
||||
pub mod state;
|
||||
pub mod turns;
|
||||
pub mod worker;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! `memoryd`'s Honcho side at start (M5c spec, sections 5 and 10): the ledger read through, the
|
||||
//! sessions that wait again, the clients with their keys, and the worker's thread.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use bxcore::SessionId;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::honcho::client::{Clients, Timeouts};
|
||||
use crate::honcho::config::HonchoConfig;
|
||||
use crate::honcho::ledger::Ledger;
|
||||
use crate::honcho::mirror::Mirror;
|
||||
use crate::honcho::state;
|
||||
use crate::honcho::turns::{LEDGER, TurnSync};
|
||||
use crate::honcho::worker::{AskBox, Tuning, Wake, Worker};
|
||||
use crate::serve::{Log, StartError};
|
||||
|
||||
/// With `[honcho]`, at start: the ledger read through, the sessions that wait again, and the
|
||||
/// clients with their keys read. Nothing is bound or started yet.
|
||||
pub struct Opened {
|
||||
pub sync: Arc<Mutex<TurnSync>>,
|
||||
clients: Clients,
|
||||
agent: String,
|
||||
owner: String,
|
||||
}
|
||||
|
||||
impl Opened {
|
||||
/// The memory writes' side of the ledger (task 12).
|
||||
pub fn mirror(&self) -> Mirror {
|
||||
let ledger = self
|
||||
.sync
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.shared_ledger();
|
||||
Mirror::new(ledger, &self.agent, &self.owner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the ledger (a cut-off last line is logged), resume the waiting sessions, and build the
|
||||
/// clients (their key files' warnings are logged). A key that cannot be read is `Secret`.
|
||||
pub fn open(config: &Config, honcho: &HonchoConfig, log: &Log) -> Result<Opened, StartError> {
|
||||
let path = config.memory_dir().join(LEDGER);
|
||||
let ledger = Ledger::open(&path).map_err(StartError::Ledger)?;
|
||||
if ledger.repaired() {
|
||||
(log)(&format!(
|
||||
"memoryd: {}: a last line cut off by a crash was dropped",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let mut sync = TurnSync::new(ledger, &honcho.agent, &config.sessions_dir());
|
||||
sync.resume();
|
||||
let env = |name: &str| std::env::var_os(name);
|
||||
let (clients, warnings) =
|
||||
Clients::new(honcho, &env, Timeouts::WRITES).map_err(StartError::Secret)?;
|
||||
for warning in warnings {
|
||||
(log)(&warning);
|
||||
}
|
||||
Ok(Opened {
|
||||
sync: Arc::new(Mutex::new(sync)),
|
||||
clients,
|
||||
agent: honcho.agent.clone(),
|
||||
owner: honcho.owner.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Start the worker's thread, asking `brokerd` on `config`'s state socket.
|
||||
pub fn spawn(opened: Opened, config: &Config, wake: Arc<Wake>, log: Log) -> Result<(), String> {
|
||||
let socket = config.state_socket();
|
||||
let ask: AskBox =
|
||||
Box::new(move |session: &SessionId| state::ask(&socket, session, state::WAIT));
|
||||
let worker = Worker::new(
|
||||
opened.sync,
|
||||
opened.clients,
|
||||
wake,
|
||||
ask,
|
||||
log,
|
||||
Tuning::DEFAULT,
|
||||
&opened.agent,
|
||||
);
|
||||
std::thread::Builder::new()
|
||||
.name("honcho".to_string())
|
||||
.spawn(move || worker.run())
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("cannot start the Honcho worker: {e}"))
|
||||
}
|
||||
+136
-111
@@ -15,16 +15,15 @@ use bxcore::{SessionId, SessionStateReply, Timestamp};
|
||||
use roncho::MessageCreate;
|
||||
use serde_json::{Map, json};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::honcho::client::{Clients, HonchoError, Timeouts, classify};
|
||||
use crate::honcho::config::HonchoConfig;
|
||||
use crate::honcho::client::{Clients, HonchoError, classify};
|
||||
use crate::honcho::decide::{final_reply, turn_records, user_text};
|
||||
use crate::honcho::edits;
|
||||
use crate::honcho::ledger::{self, Action, Item, Ledger, Record, Workspace};
|
||||
use crate::honcho::mirror::Mirror;
|
||||
use crate::honcho::naming::{HonchoSession, honcho_session};
|
||||
use crate::honcho::state;
|
||||
use crate::honcho::turns::{LEDGER, TurnSync, log_path, read_log};
|
||||
use crate::serve::{Log, StartError};
|
||||
use crate::honcho::turns::{TurnSync, log_path, read_log};
|
||||
use crate::serve::Log;
|
||||
|
||||
pub use crate::honcho::start::{Opened, open, spawn};
|
||||
|
||||
/// The most characters Honcho takes in one message (recorded: 25,000 is taken, 25,001 refused).
|
||||
pub const MAX_PART: usize = 25_000;
|
||||
@@ -103,14 +102,36 @@ enum Unsent {
|
||||
Local(&'static str),
|
||||
}
|
||||
|
||||
/// A turn to send: its item, workspace, speaker and Honcho session.
|
||||
/// What to send for one waiting item (M5c spec, section 5).
|
||||
enum Job {
|
||||
/// A turn: its session and number, its speaker, and its Honcho session.
|
||||
Turn {
|
||||
session: SessionId,
|
||||
turn: u64,
|
||||
speaker: String,
|
||||
honcho: Option<HonchoSession>,
|
||||
},
|
||||
/// A conclusion about `peer`, stamped with `session`'s memory session.
|
||||
Conclude {
|
||||
peer: String,
|
||||
session: SessionId,
|
||||
text: String,
|
||||
},
|
||||
/// Delete the conclusion with this id; `None` when no sent conclusion is live.
|
||||
Unconclude { id: Option<String> },
|
||||
/// An untrusted note: a fleet message from the agent.
|
||||
Fleet {
|
||||
session: SessionId,
|
||||
edit: String,
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// The oldest waiting item: what it is, where it goes, and what to send.
|
||||
struct Next {
|
||||
item: Item,
|
||||
session: SessionId,
|
||||
turn: u64,
|
||||
workspace: Workspace,
|
||||
speaker: String,
|
||||
honcho: Option<HonchoSession>,
|
||||
job: Job,
|
||||
}
|
||||
|
||||
pub struct Worker {
|
||||
@@ -173,6 +194,14 @@ pub fn batch(
|
||||
messages
|
||||
}
|
||||
|
||||
/// An item as the log lines name it: `turn 3 of session mm-a`, `part 0 of edit 2026-09-28-0001`.
|
||||
fn label(item: &Item) -> String {
|
||||
match item {
|
||||
Item::Turn { session, turn } => format!("turn {turn} of session {}", session.as_str()),
|
||||
Item::Edit { id, part } => format!("part {part} of edit {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids of the messages Honcho made, in order.
|
||||
fn ids(messages: &[roncho::Message]) -> Vec<String> {
|
||||
messages.iter().map(|m| m.id.clone()).collect()
|
||||
@@ -255,9 +284,8 @@ impl Worker {
|
||||
Err(Unsent::Honcho(e)) => {
|
||||
self.recovered();
|
||||
(self.log)(&format!(
|
||||
"memoryd: turn {} of session {} was not sent: {e}; it is marked failed; {SEND_REFUSED}",
|
||||
next.turn,
|
||||
next.session.as_str()
|
||||
"memoryd: {} was not sent: {e}; it is marked failed; {SEND_REFUSED}",
|
||||
label(&next.item)
|
||||
));
|
||||
self.record(Record::Failed {
|
||||
time: Timestamp::now(),
|
||||
@@ -273,9 +301,8 @@ impl Worker {
|
||||
SEND_REFUSED
|
||||
};
|
||||
(self.log)(&format!(
|
||||
"memoryd: turn {} of session {} cannot be sent ({kind}); it is marked failed; {pointer}",
|
||||
next.turn,
|
||||
next.session.as_str()
|
||||
"memoryd: {} cannot be sent ({kind}); it is marked failed; {pointer}",
|
||||
label(&next.item)
|
||||
));
|
||||
self.record(Record::Failed {
|
||||
time: Timestamp::now(),
|
||||
@@ -287,50 +314,117 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// The oldest waiting turn whose workspace's key was not refused. Other actions wait.
|
||||
/// The oldest waiting item whose workspace's key was not refused. A retraction, which
|
||||
/// nothing decides any more, waits.
|
||||
fn next(&self) -> Option<Next> {
|
||||
let ledger = self.ledger.lock().unwrap_or_else(|p| p.into_inner());
|
||||
ledger.outbox().into_iter().find_map(|(item, action)| {
|
||||
let Item::Turn { session, turn } = &item else {
|
||||
return None;
|
||||
};
|
||||
let Action::Turn { workspace, speaker } = action else {
|
||||
return None;
|
||||
let (workspace, job) = match (&item, action) {
|
||||
(Item::Turn { session, turn }, Action::Turn { workspace, speaker }) => (
|
||||
workspace,
|
||||
Job::Turn {
|
||||
honcho: honcho_session(ledger.decisions(), session, *turn),
|
||||
session: session.clone(),
|
||||
turn: *turn,
|
||||
speaker,
|
||||
},
|
||||
),
|
||||
(
|
||||
Item::Edit { .. },
|
||||
Action::Conclude {
|
||||
workspace,
|
||||
peer,
|
||||
session,
|
||||
text,
|
||||
},
|
||||
) => (
|
||||
workspace,
|
||||
Job::Conclude {
|
||||
peer,
|
||||
session,
|
||||
text,
|
||||
},
|
||||
),
|
||||
(Item::Edit { .. }, Action::Unconclude { workspace, text }) => (
|
||||
workspace,
|
||||
Job::Unconclude {
|
||||
id: ledger.conclusion(workspace, &text).map(|(_, id)| id),
|
||||
},
|
||||
),
|
||||
(Item::Edit { id, .. }, Action::Fleet { session, text }) => (
|
||||
Workspace::Fleet,
|
||||
Job::Fleet {
|
||||
session,
|
||||
edit: id.clone(),
|
||||
text,
|
||||
},
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
if self.refused.contains(&workspace) {
|
||||
return None;
|
||||
}
|
||||
Some(Next {
|
||||
honcho: honcho_session(ledger.decisions(), session, *turn),
|
||||
session: session.clone(),
|
||||
turn: *turn,
|
||||
item: item.clone(),
|
||||
workspace,
|
||||
speaker,
|
||||
job,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the turn again, make sure of its Honcho session, and add its messages as one batch.
|
||||
/// Send one item, by what it is.
|
||||
fn send(&mut self, next: &Next) -> Result<Vec<String>, Unsent> {
|
||||
let (text, reply) = read_turn(&log_path(&self.sessions, &next.session), next.turn)
|
||||
.ok_or(Unsent::Local("log"))?;
|
||||
let honcho = next.honcho.as_ref().ok_or(Unsent::Local("log"))?;
|
||||
let messages = batch(
|
||||
&text,
|
||||
&reply,
|
||||
&next.speaker,
|
||||
&self.agent,
|
||||
&next.session,
|
||||
next.turn,
|
||||
);
|
||||
let honcho = |e| Unsent::Honcho(e);
|
||||
match &next.job {
|
||||
Job::Turn {
|
||||
session,
|
||||
turn,
|
||||
speaker,
|
||||
honcho: name,
|
||||
} => self.send_turn(next.workspace, session, *turn, speaker, name.as_ref()),
|
||||
Job::Conclude {
|
||||
peer,
|
||||
session,
|
||||
text,
|
||||
} => edits::conclude(&mut self.clients, next.workspace, peer, session, text)
|
||||
.map_err(honcho),
|
||||
Job::Unconclude { id } => {
|
||||
edits::unconclude(&self.clients, next.workspace, id.as_deref()).map_err(honcho)
|
||||
}
|
||||
Job::Fleet {
|
||||
session,
|
||||
edit,
|
||||
text,
|
||||
} => {
|
||||
let messages = edits::fleet_batch(text, &self.agent, session, edit);
|
||||
if messages.len() > MAX_BATCH {
|
||||
return Err(Unsent::Local("too-long"));
|
||||
}
|
||||
edits::fleet(&mut self.clients, session, &messages).map_err(honcho)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the turn again, make sure of its Honcho session, and add its messages as one batch.
|
||||
fn send_turn(
|
||||
&mut self,
|
||||
workspace: Workspace,
|
||||
session: &SessionId,
|
||||
turn: u64,
|
||||
speaker: &str,
|
||||
honcho: Option<&HonchoSession>,
|
||||
) -> Result<Vec<String>, Unsent> {
|
||||
let (text, reply) =
|
||||
read_turn(&log_path(&self.sessions, session), turn).ok_or(Unsent::Local("log"))?;
|
||||
let honcho = honcho.ok_or(Unsent::Local("log"))?;
|
||||
let messages = batch(&text, &reply, speaker, &self.agent, session, turn);
|
||||
if messages.len() > MAX_BATCH {
|
||||
return Err(Unsent::Local("too-long"));
|
||||
}
|
||||
self.clients
|
||||
.ensure_session(next.workspace, honcho)
|
||||
.ensure_session(workspace, honcho)
|
||||
.map_err(Unsent::Honcho)?;
|
||||
let client = self.clients.client(next.workspace);
|
||||
let client = self.clients.client(workspace);
|
||||
match client.add_messages(&honcho.name, &messages) {
|
||||
Ok(made) => Ok(ids(&made)),
|
||||
Err(e) => Err(Unsent::Honcho(classify(&e))),
|
||||
@@ -390,72 +484,3 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// With `[honcho]`, at start: the ledger read through, the sessions that wait again, and the
|
||||
/// clients with their keys read. Nothing is bound or started yet.
|
||||
pub struct Opened {
|
||||
pub sync: Arc<Mutex<TurnSync>>,
|
||||
clients: Clients,
|
||||
agent: String,
|
||||
owner: String,
|
||||
}
|
||||
|
||||
impl Opened {
|
||||
/// The memory writes' side of the ledger (task 12).
|
||||
pub fn mirror(&self) -> Mirror {
|
||||
let ledger = self
|
||||
.sync
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.shared_ledger();
|
||||
Mirror::new(ledger, &self.agent, &self.owner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the ledger (a cut-off last line is logged), resume the waiting sessions, and build the
|
||||
/// clients (their key files' warnings are logged). A key that cannot be read is `Secret`.
|
||||
pub fn open(config: &Config, honcho: &HonchoConfig, log: &Log) -> Result<Opened, StartError> {
|
||||
let path = config.memory_dir().join(LEDGER);
|
||||
let ledger = Ledger::open(&path).map_err(StartError::Ledger)?;
|
||||
if ledger.repaired() {
|
||||
(log)(&format!(
|
||||
"memoryd: {}: a last line cut off by a crash was dropped",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let mut sync = TurnSync::new(ledger, &honcho.agent, &config.sessions_dir());
|
||||
sync.resume();
|
||||
let env = |name: &str| std::env::var_os(name);
|
||||
let (clients, warnings) =
|
||||
Clients::new(honcho, &env, Timeouts::WRITES).map_err(StartError::Secret)?;
|
||||
for warning in warnings {
|
||||
(log)(&warning);
|
||||
}
|
||||
Ok(Opened {
|
||||
sync: Arc::new(Mutex::new(sync)),
|
||||
clients,
|
||||
agent: honcho.agent.clone(),
|
||||
owner: honcho.owner.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Start the worker's thread, asking `brokerd` on `config`'s state socket.
|
||||
pub fn spawn(opened: Opened, config: &Config, wake: Arc<Wake>, log: Log) -> Result<(), String> {
|
||||
let socket = config.state_socket();
|
||||
let ask: AskBox =
|
||||
Box::new(move |session: &SessionId| state::ask(&socket, session, state::WAIT));
|
||||
let worker = Worker::new(
|
||||
opened.sync,
|
||||
opened.clients,
|
||||
wake,
|
||||
ask,
|
||||
log,
|
||||
Tuning::DEFAULT,
|
||||
&opened.agent,
|
||||
);
|
||||
std::thread::Builder::new()
|
||||
.name("honcho".to_string())
|
||||
.spawn(move || worker.run())
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("cannot start the Honcho worker: {e}"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
//! The worker sending memory writes (M5c spec, sections 4 and 5), stepped by hand against the fake
|
||||
//! Honcho: a conclusion made in the session's `<id>_memory` session with the agent's and the
|
||||
//! owner's peers; one deleted by the id its making got, or nothing to delete; a 404 on delete as
|
||||
//! done; an untrusted note as a fleet message from the agent; the text neutralised; one outbox
|
||||
//! across turns and edits; errors as for turns; a restart sending nothing again. Do not edit.
|
||||
|
||||
#[path = "support/honcho.rs"]
|
||||
mod honcho;
|
||||
#[path = "support/logs.rs"]
|
||||
mod logs;
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
#[path = "support/turns.rs"]
|
||||
mod turns;
|
||||
#[path = "support/worker.rs"]
|
||||
mod worker;
|
||||
|
||||
use bxcore::sanitize::TAGS;
|
||||
use bxcore::{SessionId, Timestamp};
|
||||
use honcho::{FakeHoncho, Mode, Request};
|
||||
use logs::assistant;
|
||||
use memoryd::honcho::edits::{MEMORY_SUFFIX, memory_session};
|
||||
use memoryd::honcho::ledger::{Action, Item, Record, Workspace};
|
||||
use memoryd::honcho::worker::{MAX_PART, Step};
|
||||
use serde_json::{Value, json};
|
||||
use turns::spoken;
|
||||
use worker::{FAST, Home};
|
||||
|
||||
/// The id fixture 11 gives the first conclusion it makes.
|
||||
const CONCLUSION: &str = "YUBMqIqHhuJprV4o-inrg";
|
||||
|
||||
fn sid(s: &str) -> SessionId {
|
||||
SessionId::new(s).unwrap()
|
||||
}
|
||||
|
||||
fn edit(id: &str, part: u32) -> Item {
|
||||
Item::Edit {
|
||||
id: id.to_string(),
|
||||
part,
|
||||
}
|
||||
}
|
||||
|
||||
fn conclude(peer: &str, text: &str) -> Action {
|
||||
Action::Conclude {
|
||||
workspace: Workspace::Trusted,
|
||||
peer: peer.to_string(),
|
||||
session: sid("mm-a"),
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn unconclude(text: &str) -> Action {
|
||||
Action::Unconclude {
|
||||
workspace: Workspace::Trusted,
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fleet(text: &str) -> Action {
|
||||
Action::Fleet {
|
||||
session: sid("mm-c"),
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide `action` for `item`, as task 12 does when an edit is journalled.
|
||||
fn decide(home: &Home, item: Item, action: Action) {
|
||||
let sync = home.sync.lock().unwrap();
|
||||
sync.ledger()
|
||||
.append(Record::Decided {
|
||||
time: Timestamp::parse("2026-09-28T10:00:00.000Z").unwrap(),
|
||||
item,
|
||||
action,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn sent_ids(home: &Home, item: &Item) -> Option<Vec<String>> {
|
||||
home.records().into_iter().find_map(|r| match r {
|
||||
Record::Sent { item: i, ids, .. } if i == *item => Some(ids),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn posts(fake: &FakeHoncho, suffix: &str) -> Vec<Request> {
|
||||
fake.requests()
|
||||
.into_iter()
|
||||
.filter(|r| r.path.ends_with(suffix))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_memory_session_is_named_for_the_boxmaker_session() {
|
||||
assert_eq!(MEMORY_SUFFIX, "_memory");
|
||||
assert_eq!(memory_session(&sid("mm-a")), "mm-a_memory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conclusion_goes_into_the_sessions_memory_session() {
|
||||
let fake = FakeHoncho::start();
|
||||
let home = Home::new("edits-conclude");
|
||||
decide(&home, edit("e1", 0), conclude("kyle", "Prefers UTC."));
|
||||
let mut w = home.worker(&fake, FAST);
|
||||
assert_eq!(w.step(), Step::Done);
|
||||
let w_path = "/v3/workspaces/boxmaker0-trusted";
|
||||
let seen: Vec<(String, Value)> = fake
|
||||
.requests()
|
||||
.into_iter()
|
||||
.map(|r| (r.path, r.body.unwrap_or(Value::Null)))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
seen,
|
||||
vec![
|
||||
(
|
||||
"/v3/workspaces".to_string(),
|
||||
json!({ "id": "boxmaker0-trusted" })
|
||||
),
|
||||
(
|
||||
format!("{w_path}/peers"),
|
||||
json!({ "id": "boxmaker0", "configuration": { "observe_me": false } })
|
||||
),
|
||||
(format!("{w_path}/peers"), json!({ "id": "kyle" })),
|
||||
(
|
||||
format!("{w_path}/sessions"),
|
||||
json!({ "id": "mm-a_memory", "peers": {
|
||||
"boxmaker0": { "observe_me": false, "observe_others": false },
|
||||
"kyle": { "observe_me": false, "observe_others": false },
|
||||
} })
|
||||
),
|
||||
(
|
||||
format!("{w_path}/conclusions"),
|
||||
json!({ "conclusions": [{
|
||||
"content": "Prefers UTC.",
|
||||
"observer_id": "kyle",
|
||||
"observed_id": "kyle",
|
||||
"session_id": "mm-a_memory",
|
||||
}] })
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
sent_ids(&home, &edit("e1", 0)),
|
||||
Some(vec![CONCLUSION.to_string()])
|
||||
);
|
||||
let sync = home.sync.lock().unwrap();
|
||||
assert_eq!(
|
||||
sync.ledger().conclusion(Workspace::Trusted, "Prefers UTC."),
|
||||
Some((edit("e1", 0), CONCLUSION.to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_replace_deletes_then_makes_and_an_undo_reverses_it() {
|
||||
let fake = FakeHoncho::start();
|
||||
let home = Home::new("edits-replace");
|
||||
decide(&home, edit("e1", 0), conclude("kyle", "old"));
|
||||
decide(&home, edit("e2", 0), unconclude("old"));
|
||||
decide(&home, edit("e2", 1), conclude("kyle", "new"));
|
||||
// The owner's undo of e2: the new deleted, the old made again.
|
||||
decide(&home, edit("e3", 0), unconclude("new"));
|
||||
decide(&home, edit("e3", 1), conclude("kyle", "old"));
|
||||
let mut w = home.worker(&fake, FAST);
|
||||
for _ in 0..5 {
|
||||
assert_eq!(w.step(), Step::Done);
|
||||
}
|
||||
assert_eq!(w.step(), Step::Idle);
|
||||
let order: Vec<(String, String)> = fake
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|r| r.path.contains("/conclusions"))
|
||||
.map(|r| {
|
||||
let text = r.body.map_or(String::new(), |b| {
|
||||
b["conclusions"][0]["content"].as_str().unwrap().to_string()
|
||||
});
|
||||
(r.method, text)
|
||||
})
|
||||
.collect();
|
||||
let post = |t: &str| ("POST".to_string(), t.to_string());
|
||||
let delete = ("DELETE".to_string(), String::new());
|
||||
assert_eq!(
|
||||
order,
|
||||
vec![
|
||||
post("old"),
|
||||
delete.clone(),
|
||||
post("new"),
|
||||
delete,
|
||||
post("old")
|
||||
]
|
||||
);
|
||||
// Each deletion names the id its conclusion got, and records it.
|
||||
let deletes = fake
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|r| r.method == "DELETE")
|
||||
.map(|r| r.path)
|
||||
.collect::<Vec<_>>();
|
||||
let path = format!("/v3/workspaces/boxmaker0-trusted/conclusions/{CONCLUSION}");
|
||||
assert_eq!(deletes, vec![path.clone(), path]);
|
||||
// The memory session, its workspace and peers are made sure of once in this run.
|
||||
assert_eq!(posts(&fake, "/sessions").len(), 1);
|
||||
assert_eq!(posts(&fake, "/peers").len(), 2);
|
||||
assert_eq!(
|
||||
sent_ids(&home, &edit("e2", 0)),
|
||||
Some(vec![CONCLUSION.to_string()])
|
||||
);
|
||||
let sync = home.sync.lock().unwrap();
|
||||
assert_eq!(sync.ledger().conclusion(Workspace::Trusted, "new"), None);
|
||||
assert_eq!(
|
||||
sync.ledger().conclusion(Workspace::Trusted, "old"),
|
||||
Some((edit("e3", 1), CONCLUSION.to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deletion_with_nothing_to_delete_is_sent_with_no_id() {
|
||||
let fake = FakeHoncho::start();
|
||||
let home = Home::new("edits-nothing");
|
||||
decide(&home, edit("e1", 0), unconclude("never made"));
|
||||
// A conclusion that failed is not live either.
|
||||
decide(&home, edit("e2", 0), conclude("kyle", "refused"));
|
||||
decide(&home, edit("e3", 0), unconclude("refused"));
|
||||
let mut w = home.worker(&fake, FAST);
|
||||
assert_eq!(w.step(), Step::Done);
|
||||
assert!(fake.requests().is_empty());
|
||||
assert_eq!(sent_ids(&home, &edit("e1", 0)), Some(Vec::new()));
|
||||
fake.set(Mode::Status(422));
|
||||
assert_eq!(w.step(), Step::Done);
|
||||
fake.set(Mode::Fixtures);
|
||||
fake.clear();
|
||||
assert_eq!(w.step(), Step::Done);
|
||||
assert!(fake.requests().is_empty(), "{:?}", fake.requests());
|
||||
assert_eq!(sent_ids(&home, &edit("e3", 0)), Some(Vec::new()));
|
||||
assert_eq!(
|
||||
home.lines().len(),
|
||||
1,
|
||||
"only the refusal: {:?}",
|
||||
home.lines()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_404_on_delete_is_done() {
|
||||
let fake = FakeHoncho::start();
|
||||
let home = Home::new("edits-404");
|
||||
decide(&home, edit("e1", 0), conclude("kyle", "x"));
|
||||
decide(&home, edit("e2", 0), unconclude("x"));
|
||||
let mut w = home.worker(&fake, FAST);
|
||||
assert_eq!(w.step(), Step::Done);
|
||||
fake.set(Mode::Status(404));
|
||||
assert_eq!(w.step(), Step::Done);
|
||||
assert_eq!(
|
||||
sent_ids(&home, &edit("e2", 0)),
|
||||
Some(vec![CONCLUSION.to_string()])
|
||||
);
|
||||
assert!(home.lines().is_empty(), "{:?}", home.lines());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untrusted_note_is_a_fleet_message_from_the_agent() {
|
||||
let fake = FakeHoncho::start();
|
||||
let home = Home::new("edits-fleet");
|
||||
decide(&home, edit("e1", 0), fleet("The page says 3.2."));
|
||||
let mut w = home.worker(&fake, FAST);
|
||||
assert_eq!(w.step(), Step::Done);
|
||||
let seen: Vec<(String, Value)> = fake
|
||||
.requests()
|
||||
.into_iter()
|
||||
.map(|r| (r.path, r.body.unwrap_or(Value::Null)))
|
||||
.collect();
|
||||
assert_eq!(seen.len(), 4);
|
||||
assert_eq!(seen[0].1["configuration"]["reasoning"]["enabled"], false);
|
||||
assert_eq!(seen[2].0, "/v3/workspaces/fleet/sessions");
|
||||
assert_eq!(
|
||||
seen[2].1,
|
||||
json!({ "id": "mm-c", "peers": {
|
||||
"boxmaker0": { "observe_me": false, "observe_others": false },
|
||||
} })
|
||||
);
|
||||
assert_eq!(
|
||||
seen[3],
|
||||
(
|
||||
"/v3/workspaces/fleet/sessions/mm-c/messages".to_string(),
|
||||
json!({ "messages": [{
|
||||
"content": "The page says 3.2.",
|
||||
"peer_id": "boxmaker0",
|
||||
"metadata": { "agent": "boxmaker0", "boxmaker_session": "mm-c", "edit": "e1" },
|
||||
}] })
|
||||
)
|
||||
);
|
||||
assert_eq!(sent_ids(&home, &edit("e1", 0)).map(|i| i.len()), Some(1));
|
||||
// A long note in parts, in one batch.
|
||||
decide(&home, edit("e2", 0), fleet(&"n".repeat(MAX_PART + 1)));
|
||||
assert_eq!(w.step(), Step::Done);
|
||||
let last = posts(&fake, "/messages").pop().unwrap().body.unwrap();
|
||||
assert_eq!(last["messages"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_note_too_long_for_one_batch_is_marked_failed() {
|
||||
let fake = FakeHoncho::start();
|
||||
let home = Home::new("edits-long");
|
||||
decide(&home, edit("e1", 0), fleet(&"n".repeat(MAX_PART * 100 + 1)));
|
||||
let mut w = home.worker(&fake, FAST);
|
||||
assert_eq!(w.step(), Step::Done);
|
||||
assert!(matches!(
|
||||
home.records().last(),
|
||||
Some(Record::Failed { status: None, kind, .. }) if kind == "too-long"
|
||||
));
|
||||
assert!(fake.requests().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_text_is_neutralised() {
|
||||
let fake = FakeHoncho::start();
|
||||
let home = Home::new("edits-neutral");
|
||||
decide(
|
||||
&home,
|
||||
edit("e1", 0),
|
||||
conclude("boxmaker0", &format!("a{}b", TAGS[0].text)),
|
||||
);
|
||||
decide(&home, edit("e2", 0), fleet(&format!("c{}d", TAGS[1].text)));
|
||||
let mut w = home.worker(&fake, FAST);
|
||||
assert_eq!(w.step(), Step::Done);
|
||||
assert_eq!(w.step(), Step::Done);
|
||||
let concluded = posts(&fake, "/conclusions")[0].body.clone().unwrap();
|
||||
let content = concluded["conclusions"][0]["content"].as_str().unwrap();
|
||||
assert!(
|
||||
!content.contains(TAGS[0].text) && content.contains("<"),
|
||||
"{content}"
|
||||
);
|
||||
let message = posts(&fake, "/messages")[0].body.clone().unwrap();
|
||||
let content = message["messages"][0]["content"].as_str().unwrap();
|
||||
assert!(
|
||||
!content.contains(TAGS[1].text) && content.contains("<"),
|
||||
"{content}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_outbox_in_order_across_turns_and_edits() {
|
||||
let fake = FakeHoncho::start();
|
||||
let mut home = Home::new("edits-order");
|
||||
home.log(
|
||||
"mm-a",
|
||||
&[
|
||||
vec![spoken("one", Some("kyle")), assistant(2, Some("a"), &[])],
|
||||
vec![spoken("two", Some("kyle")), assistant(2, Some("b"), &[])],
|
||||
],
|
||||
);
|
||||
home.report("mm-a", 1);
|
||||
decide(&home, edit("e1", 0), conclude("kyle", "Prefers UTC."));
|
||||
home.report("mm-a", 2);
|
||||
let mut w = home.worker(&fake, FAST);
|
||||
for _ in 0..3 {
|
||||
assert_eq!(w.step(), Step::Done);
|
||||
}
|
||||
let order: Vec<String> = fake
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|r| r.path.ends_with("/messages") || r.path.ends_with("/conclusions"))
|
||||
.map(|r| r.path)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
order,
|
||||
vec![
|
||||
"/v3/workspaces/boxmaker0-trusted/sessions/mm-a/messages".to_string(),
|
||||
"/v3/workspaces/boxmaker0-trusted/conclusions".to_string(),
|
||||
"/v3/workspaces/boxmaker0-trusted/sessions/mm-a/messages".to_string(),
|
||||
]
|
||||
);
|
||||
// A restart sends nothing again.
|
||||
home.restart();
|
||||
let fake2 = FakeHoncho::start();
|
||||
let mut w = home.worker(&fake2, FAST);
|
||||
assert_eq!(w.step(), Step::Idle);
|
||||
assert!(fake2.requests().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_as_for_turns() {
|
||||
let fake = FakeHoncho::start();
|
||||
let home = Home::new("edits-errors");
|
||||
decide(&home, edit("e1", 0), conclude("kyle", "private-fact"));
|
||||
decide(&home, edit("e2", 0), fleet("private-note"));
|
||||
decide(&home, edit("e3", 0), conclude("kyle", "second"));
|
||||
let mut w = home.worker(&fake, FAST);
|
||||
fake.set(Mode::Status(503));
|
||||
assert!(matches!(w.step(), Step::Backoff(_)));
|
||||
fake.set(Mode::Status(422));
|
||||
assert_eq!(w.step(), Step::Done);
|
||||
assert!(matches!(
|
||||
home.records().last(),
|
||||
Some(Record::Failed { status: Some(422), kind, .. }) if kind == "status"
|
||||
));
|
||||
fake.set(Mode::Status(401));
|
||||
assert_eq!(w.step(), Step::Done, "the fleet's key refused");
|
||||
fake.set(Mode::Fixtures);
|
||||
assert_eq!(w.step(), Step::Done, "the trusted workspace goes on");
|
||||
assert!(sent_ids(&home, &edit("e3", 0)).is_some());
|
||||
assert_eq!(w.step(), Step::Idle, "the fleet note waits");
|
||||
let lines = home.lines();
|
||||
assert_eq!(lines.len(), 4, "{lines:?}");
|
||||
assert!(
|
||||
lines[2].starts_with("memoryd: part 0 of edit e1 was not sent: "),
|
||||
"{lines:?}"
|
||||
);
|
||||
assert!(lines[3].contains("workspace fleet"), "{lines:?}");
|
||||
for line in lines {
|
||||
assert!(!line.contains("private"), "{line}");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
//! The Honcho worker's other duties (M5c spec, section 5): with nothing to send it decides what
|
||||
//! `brokerd` could not answer for; after a restart nothing is sent again, and a session with a
|
||||
//! decided turn waits again (a session without one does not); a running turn is never decided;
|
||||
//! actions of later tasks wait; a ledger that cannot be written stops it; no log line holds a
|
||||
//! turn's text. Then `memoryd` itself: a reported turn reaches Honcho, woken at once, and a key
|
||||
//! that cannot be read stops the start. Do not edit.
|
||||
//! a retraction waits (nothing decides one); a ledger that cannot be written stops it; no log
|
||||
//! line holds a turn's text. Then `memoryd` itself: a reported turn reaches Honcho, woken at
|
||||
//! once, and a key that cannot be read stops the start. Do not edit.
|
||||
|
||||
#[path = "support/brokerd.rs"]
|
||||
mod brokerd;
|
||||
@@ -150,15 +150,17 @@ fn a_restart_never_decides_a_running_turn() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actions_of_later_tasks_wait() {
|
||||
fn a_retraction_waits() {
|
||||
// Nothing decides a retraction any more (M5c spec, section 4, 2026-09-28): one in the
|
||||
// ledger is neither sent nor failed.
|
||||
let fake = FakeHoncho::start();
|
||||
let mut home = Home::new("worker-later");
|
||||
let conclude = r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0001","part":0},"action":{"type":"conclude","workspace":"trusted","peer":"kyle","session":"mm-a","text":"a fact"}}"#;
|
||||
std::fs::write(home.ledger_path(), format!("{conclude}\n")).unwrap();
|
||||
let retract = r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0001","part":0},"action":{"type":"retract","session":"mm-a","text":"a note"}}"#;
|
||||
std::fs::write(home.ledger_path(), format!("{retract}\n")).unwrap();
|
||||
home.restart();
|
||||
let mut w = home.worker(&fake, FAST);
|
||||
assert_eq!(w.step(), Step::Idle);
|
||||
assert_eq!(home.records().len(), 1, "the conclusion was failed or sent");
|
||||
assert_eq!(home.records().len(), 1, "the retraction was failed or sent");
|
||||
assert!(fake.requests().is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 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
|
||||
//! responses recorded from rift (`tests/fixtures/honcho/`), by method and path (a batch of
|
||||
//! messages or conclusions answered with as many as it asked for, up to the two recorded), 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.
|
||||
|
||||
@@ -214,8 +215,13 @@ fn answer(request: &Request, made: &Mutex<HashSet<String>>) -> (u16, String) {
|
||||
}
|
||||
("POST", ["v3", "workspaces", _, "sessions"]) => fixture("06-session-create.json"),
|
||||
("POST", ["v3", "workspaces", _, "sessions", _, "messages"]) => {
|
||||
fixture("08-messages-add-batch.json")
|
||||
first(fixture("08-messages-add-batch.json"), request, "messages")
|
||||
}
|
||||
("POST", ["v3", "workspaces", _, "conclusions"]) => first(
|
||||
fixture("11-conclusions-create-batch.json"),
|
||||
request,
|
||||
"conclusions",
|
||||
),
|
||||
("DELETE", ["v3", "workspaces", _, "conclusions", _]) if again => {
|
||||
fixture("16-conclusion-delete-again.json")
|
||||
}
|
||||
@@ -226,6 +232,19 @@ fn answer(request: &Request, made: &Mutex<HashSet<String>>) -> (u16, String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A recorded batch answer cut to as many items as the request's `field` holds (the recorded ones
|
||||
/// hold two), so that one message or conclusion gets one id.
|
||||
fn first(recorded: (u16, String), request: &Request, field: &str) -> (u16, String) {
|
||||
let asked = request
|
||||
.body
|
||||
.as_ref()
|
||||
.and_then(|b| b[field].as_array())
|
||||
.map_or(0, Vec::len);
|
||||
let mut items: Vec<Value> = serde_json::from_str(&recorded.1).unwrap();
|
||||
items.truncate(asked);
|
||||
(recorded.0, Value::Array(items).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 {
|
||||
|
||||
Reference in New Issue
Block a user