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:
@@ -0,0 +1,322 @@
|
|||||||
|
//! 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 {
|
||||||
|
match self {
|
||||||
|
HonchoError::Unreachable { kind, .. } | HonchoError::Refused { kind, .. } => kind,
|
||||||
|
HonchoError::KeyRefused { .. } => "key-refused",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> Option<u16> {
|
||||||
|
match self {
|
||||||
|
HonchoError::Unreachable { status, .. } | HonchoError::Refused { status, .. } => {
|
||||||
|
*status
|
||||||
|
}
|
||||||
|
HonchoError::KeyRefused { status } => Some(*status),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
let mut map = Map::new();
|
||||||
|
for feature in ["reasoning", "summary", "dream"] {
|
||||||
|
map.insert(feature.to_string(), json!({ "enabled": false }));
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
let mut peers = HashMap::new();
|
||||||
|
peers.insert(
|
||||||
|
agent.to_string(),
|
||||||
|
SessionPeerConfig::new()
|
||||||
|
.observe_me(false)
|
||||||
|
.observe_others(false),
|
||||||
|
);
|
||||||
|
for speaker in speakers {
|
||||||
|
peers.insert(
|
||||||
|
speaker.clone(),
|
||||||
|
SessionPeerConfig::new()
|
||||||
|
.observe_me(true)
|
||||||
|
.observe_others(false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
peers
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A create that found the thing there (409) did what it was for.
|
||||||
|
fn created<T>(result: Result<T, Error>) -> Result<(), HonchoError> {
|
||||||
|
match result {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(e) => match classify(&e) {
|
||||||
|
HonchoError::Refused {
|
||||||
|
status: Some(409), ..
|
||||||
|
} => Ok(()),
|
||||||
|
other => Err(other),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
.without_config_file()
|
||||||
|
.base_url(url)
|
||||||
|
.workspace_id(workspace)
|
||||||
|
.connect_timeout(timeouts.connect)
|
||||||
|
.read_timeout(timeouts.read);
|
||||||
|
if let Some(key) = key {
|
||||||
|
builder = builder.api_key(key.expose());
|
||||||
|
}
|
||||||
|
builder
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("the Honcho client for {workspace}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
};
|
||||||
|
let loaded = secrets::load("memoryd", name, source, env).map_err(|e| e.to_string())?;
|
||||||
|
warnings.extend(loaded.warning);
|
||||||
|
Ok(Some(loaded.secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Workspace::Trusted => &self.trusted,
|
||||||
|
Workspace::Untrusted => &self.untrusted,
|
||||||
|
Workspace::Fleet => &self.fleet,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
let mut create = WorkspaceCreate::new(client.workspace_id());
|
||||||
|
if workspace == Workspace::Fleet {
|
||||||
|
create = create.with_configuration(fleet_configuration());
|
||||||
|
}
|
||||||
|
created(client.get_or_create_workspace(&create))?;
|
||||||
|
self.ensured.insert(key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 self.ensured.contains(&key) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut create = PeerCreate::new(peer);
|
||||||
|
if peer == self.agent {
|
||||||
|
let mut configuration = Map::new();
|
||||||
|
configuration.insert("observe_me".to_string(), json!(false));
|
||||||
|
create = create.with_configuration(configuration);
|
||||||
|
}
|
||||||
|
created(self.client(workspace).peer(&create))?;
|
||||||
|
self.ensured.insert(key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(()),
|
||||||
|
other => Err(other),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -208,6 +208,13 @@ impl Ledger {
|
|||||||
self.entries.contains_key(item)
|
self.entries.contains_key(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// The items waiting to be sent, with their actions, in the order they were decided.
|
||||||
pub fn outbox(&self) -> Vec<(Item, Action)> {
|
pub fn outbox(&self) -> Vec<(Item, Action)> {
|
||||||
self.order
|
self.order
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
//! Honcho, the memory this agent shares with the fleet, reached through `roncho` (M5c spec).
|
//! Honcho, the memory this agent shares with the fleet, reached through `roncho` (M5c spec).
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod decide;
|
pub mod decide;
|
||||||
pub mod ledger;
|
pub mod ledger;
|
||||||
|
pub mod naming;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod turns;
|
pub mod turns;
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
//! 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 {
|
||||||
|
if count <= 1 {
|
||||||
|
session.as_str().to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}-{count}", session.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 (item, action) in decisions {
|
||||||
|
let Item::Turn {
|
||||||
|
session: s,
|
||||||
|
turn: t,
|
||||||
|
} = item
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Action::Turn { speaker, .. } = action else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if s != session {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !speakers.contains(speaker) {
|
||||||
|
speakers.push(speaker.clone());
|
||||||
|
}
|
||||||
|
if *t == turn {
|
||||||
|
return Some(HonchoSession {
|
||||||
|
name: session_name(session, speakers.len()),
|
||||||
|
speakers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
|||||||
|
|
||||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| M5c1/10-memoryd-honcho-client | 2026-09-28 | done | 1 | pass | none | Copied the given files from `docs/plans/M5c1/files/`: the tests `crates/memoryd/tests/honcho_client.rs`, `honcho_keys.rs` and `honcho_naming.rs` (from `honcho_naming.rs-task10`) and the support `crates/memoryd/tests/support/honcho.rs` (from `honcho.rs-task10`), and the directory `crates/memoryd/tests/fixtures/honcho/` (30 files, 29 JSON and a README). Step 2 showed exactly the named failures: three test targets (`honcho_client`, `honcho_keys`, `honcho_naming`) with E0432 for the `memoryd::honcho::client` and `...::naming` imports and E0599 for the missing `decisions` method on `Ledger`; every other target compiled. Made H1 (`pub mod client;` and `pub mod naming;` in `honcho/mod.rs`, the former first so the imports resolve and the latter last to keep the alphabetical order) and L1 (the `decisions` method before `outbox`, walking `order` with `.iter().filter_map` returning `(item, &self.entries.get(item)?.action)` for every decided item, `Skip` included), then copied the two skeletons; `cargo check` gave 26 warnings (unused `json`; unused `PeerCreate` and `WorkspaceCreate`; unused variables `workspace` five, `session` twice, the rest once each; the three `Clients` fields never read; the `Ensured::Workspace` and `Peer` variants never constructed). Filled its thirteen bodies in the order of each file, deleting each instruction comment and keeping the doc comments, each from its comment and `cargo check` after: in `client.rs` `kind` (one arm for the two `{ kind, .. }` variants, `"key-refused"` for `KeyRefused`), `status` (the two `{ status, .. }` arms, `Some(*status)` for `KeyRefused`), `fleet_configuration` (`Map::new`, insert each of `reasoning`, `summary`, `dream` -> `{ "enabled": false }`), `session_peers` (agent `observe_me(false)`, each speaker `observe_me(true)`), `created` (`Ok` and a `Refused` 409 are success), the free `client` (chain `.without_config_file()`, `.base_url`, `.workspace_id`, the two timeouts, `.api_key` only when there is a key, `.build()`), `key` (`config.keys.get(name)` else `None`, `secrets::load`, extend the warnings, return the secret), `Clients::client` (match the workspace to its field), `ensure_workspace` (the fleet one with `fleet_configuration`, the other two with defaults, remembered), `ensure_peer` (the agent's peer with `observe_me(false)`, remembered), `delete_conclusion` (Ok and a 404 are done); in `naming.rs` `session_name` (the id for `count <= 1`, `<id>-<count>` otherwise) and `honcho_session` (walk `decisions`, keep each `Turn` of the session's speakers each counted once in decision order, return when the turn-th is reached, else `None`). After the last `cargo check` is clean with no warning; `cargo test -p memoryd --test honcho_client --test honcho_keys --test honcho_naming` gives 13, 3 (one ignored, run by the third) and 5 pass. Step 6 all pass: `without_config_file` counts once, `expose()` appears once in the free `client`, the other-crates `git diff --stat` is empty. `make gate` printed `gate: ok` with 1278 tests (1257 before this task, plus the 21 new: 13, 3 and 5). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |
|
||||||
| M5c1/09-memoryd-honcho-turns | 2026-09-28 | done | 1 | pass | none | Copied the five given files from `docs/plans/M5c1/files/`: the tests `crates/memoryd/tests/honcho_state.rs`, `honcho_turns.rs` (from `honcho_turns.rs-task09`) and `honcho_serve.rs` (from `honcho_serve.rs-task09`) and the support `crates/memoryd/tests/support/brokerd.rs` and `support/turns.rs` (from `turns.rs-task09`). Step 2 showed exactly the named failures: three test targets (`honcho_state`, `honcho_turns`, `honcho_serve`) with one E0432 each (`unresolved import memoryd::honcho::state`, `...::turns` and `memoryd::serve::StartError`); every other target compiled. Made H1 (`pub mod state;` and `pub mod turns;` after `pub mod ledger;` in `honcho/mod.rs`) and copied the two skeletons; `cargo check` gave 19 warnings (four unused imports: `UnixStream`; the five `bxcore` frame items plus `crate::honcho::state` and `turn_records`/`turns`; fourteen unused variables: `session` four, `log` twice, the rest once each; three fields never read: `sessions`, `broker_down`, `unreadable`). Filled its seven bodies in the order of the file, each from its comment and `cargo check` after: `ask` (`state.rs`) — connect, set read and write timeout to `wait`, write an `Envelope { v: PROTOCOL_VERSION, id: 1, r#final: true, msg: Message::SessionState(SessionState { session }) }`, match `read_frame`'s msg: `SessionStateReply` -> Ok, `Error` -> `Err(format!("brokerd: {}", e.detail))`, anything else the wrong-message error, every io error through `socket: <e>`; `log_path` (`sessions.join(id).join("0.jsonl")`); `read_log` (`fs::read`, NotFound -> empty, else Io with the runbook, walk the complete lines up to the last newline so a partial last line is left out, each line parsed and a bad line named with its number and the runbook); `finished` (last `Assistant` with empty `tool_calls`); `records` (`read_log`, remove from `unreadable` on Ok, log a damaged log once on Err); `state` (`ask`, log the outage and its return once each); `decidable` (`reported.min(count)`, hold the log's last turn when it is not finished). After the seventh `cargo check` is clean with no warning; `cargo test -p memoryd --test honcho_state --test honcho_turns` gives all 21 pass. Step 6 all pass: `honcho_serve` 6, `honcho_turns` 16, `honcho_state` 5, `serve` 8, `honcho_start` 4, the existing suites unchanged; the three step-8 greps print as expected and the other-crates `git diff --stat` is empty. `make gate` printed `gate: ok` with 1257 tests (1256 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately with `BOXMAKER_BROKERD`). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |
|
| M5c1/09-memoryd-honcho-turns | 2026-09-28 | done | 1 | pass | none | Copied the five given files from `docs/plans/M5c1/files/`: the tests `crates/memoryd/tests/honcho_state.rs`, `honcho_turns.rs` (from `honcho_turns.rs-task09`) and `honcho_serve.rs` (from `honcho_serve.rs-task09`) and the support `crates/memoryd/tests/support/brokerd.rs` and `support/turns.rs` (from `turns.rs-task09`). Step 2 showed exactly the named failures: three test targets (`honcho_state`, `honcho_turns`, `honcho_serve`) with one E0432 each (`unresolved import memoryd::honcho::state`, `...::turns` and `memoryd::serve::StartError`); every other target compiled. Made H1 (`pub mod state;` and `pub mod turns;` after `pub mod ledger;` in `honcho/mod.rs`) and copied the two skeletons; `cargo check` gave 19 warnings (four unused imports: `UnixStream`; the five `bxcore` frame items plus `crate::honcho::state` and `turn_records`/`turns`; fourteen unused variables: `session` four, `log` twice, the rest once each; three fields never read: `sessions`, `broker_down`, `unreadable`). Filled its seven bodies in the order of the file, each from its comment and `cargo check` after: `ask` (`state.rs`) — connect, set read and write timeout to `wait`, write an `Envelope { v: PROTOCOL_VERSION, id: 1, r#final: true, msg: Message::SessionState(SessionState { session }) }`, match `read_frame`'s msg: `SessionStateReply` -> Ok, `Error` -> `Err(format!("brokerd: {}", e.detail))`, anything else the wrong-message error, every io error through `socket: <e>`; `log_path` (`sessions.join(id).join("0.jsonl")`); `read_log` (`fs::read`, NotFound -> empty, else Io with the runbook, walk the complete lines up to the last newline so a partial last line is left out, each line parsed and a bad line named with its number and the runbook); `finished` (last `Assistant` with empty `tool_calls`); `records` (`read_log`, remove from `unreadable` on Ok, log a damaged log once on Err); `state` (`ask`, log the outage and its return once each); `decidable` (`reported.min(count)`, hold the log's last turn when it is not finished). After the seventh `cargo check` is clean with no warning; `cargo test -p memoryd --test honcho_state --test honcho_turns` gives all 21 pass. Step 6 all pass: `honcho_serve` 6, `honcho_turns` 16, `honcho_state` 5, `serve` 8, `honcho_start` 4, the existing suites unchanged; the three step-8 greps print as expected and the other-crates `git diff --stat` is empty. `make gate` printed `gate: ok` with 1257 tests (1256 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately with `BOXMAKER_BROKERD`). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |
|
||||||
| M5c1/08-memoryd-honcho-decide | 2026-09-28 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_decide.rs` and the skeleton `crates/memoryd/src/honcho/decide.rs` from `docs/plans/M5c1/files/`. Step 2 showed exactly the named failure: `honcho_decide` with one E0432 (`unresolved import memoryd::honcho::decide`); every other target compiled. Made H1 (`pub mod decide;` after `pub mod config;` in `honcho/mod.rs`) and copied the skeleton; `cargo check` gave 11 warnings (one unused import `bxcore::sanitize::refuse`, ten unused variables: `records` four, `turn` four, `speaker` and `text` once each). Step 3 `cargo test -p memoryd --test honcho_decide` gave 1 pass (`the_skip_reasons_are_these_texts`) and 17 fail with `not yet implemented`. Filled its eight bodies in the order of the file, deleting each instruction comment as it went and keeping the doc comments, each from its comment and `cargo check` after: `turns` (filter `User`, `u64::try_from(count).unwrap_or(u64::MAX)`); `bounds` (walk `enumerate`, when a second `User` follows the tracked turn-th one return `(start, index)`, else count and set `start` at `seen == turn`, then `start.map(|s| (s, len))`); `turn_records` (`bounds` then `get`); `peer_name` (`replace('.', "_")`); `has_secret` (map each char `refuse` refuses on its own to a space via a 4-byte `encode_utf8` buffer, then keep only a `secret-` rule); `user_text` (first record's `User`, `speaker.as_deref()`); `final_reply` (last `Assistant` with empty `tool_calls`, `content.as_deref()`); `labelled_untrusted` (any `ToolResult { untrusted: true }`). After the eighth `cargo check` is clean with no warning; `cargo test` gives all 18 pass. Step 6 all pass: the grep for `fs::`/`std::io`/`Timestamp::now` prints 0, the two `refuse(` lines (87, 94) are both in `has_secret`, and the other-crates `git diff --stat` is empty. `make gate` printed `gate: ok` with 1230 tests (1229 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately with `BOXMAKER_BROKERD` set). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |
|
| M5c1/08-memoryd-honcho-decide | 2026-09-28 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_decide.rs` and the skeleton `crates/memoryd/src/honcho/decide.rs` from `docs/plans/M5c1/files/`. Step 2 showed exactly the named failure: `honcho_decide` with one E0432 (`unresolved import memoryd::honcho::decide`); every other target compiled. Made H1 (`pub mod decide;` after `pub mod config;` in `honcho/mod.rs`) and copied the skeleton; `cargo check` gave 11 warnings (one unused import `bxcore::sanitize::refuse`, ten unused variables: `records` four, `turn` four, `speaker` and `text` once each). Step 3 `cargo test -p memoryd --test honcho_decide` gave 1 pass (`the_skip_reasons_are_these_texts`) and 17 fail with `not yet implemented`. Filled its eight bodies in the order of the file, deleting each instruction comment as it went and keeping the doc comments, each from its comment and `cargo check` after: `turns` (filter `User`, `u64::try_from(count).unwrap_or(u64::MAX)`); `bounds` (walk `enumerate`, when a second `User` follows the tracked turn-th one return `(start, index)`, else count and set `start` at `seen == turn`, then `start.map(|s| (s, len))`); `turn_records` (`bounds` then `get`); `peer_name` (`replace('.', "_")`); `has_secret` (map each char `refuse` refuses on its own to a space via a 4-byte `encode_utf8` buffer, then keep only a `secret-` rule); `user_text` (first record's `User`, `speaker.as_deref()`); `final_reply` (last `Assistant` with empty `tool_calls`, `content.as_deref()`); `labelled_untrusted` (any `ToolResult { untrusted: true }`). After the eighth `cargo check` is clean with no warning; `cargo test` gives all 18 pass. Step 6 all pass: the grep for `fs::`/`std::io`/`Timestamp::now` prints 0, the two `refuse(` lines (87, 94) are both in `has_secret`, and the other-crates `git diff --stat` is empty. `make gate` printed `gate: ok` with 1230 tests (1229 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately with `BOXMAKER_BROKERD` set). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |
|
||||||
| M5c1/07-memoryd-honcho-ledger | 2026-09-28 | done | 1 | pass | none | Copied the three given files from `docs/plans/M5c1/files/`: the tests `crates/memoryd/tests/honcho_ledger.rs`, `honcho_ledger_file.rs` and the support `crates/memoryd/tests/support/ledger.rs`. Step 2 showed exactly the named failures: two test targets (`honcho_ledger` and `honcho_ledger_file`) with two E0432 each (`unresolved import memoryd::honcho::ledger`), one in each test file and one in `support/ledger.rs`; every other target compiled. Made H1 (`pub mod ledger;` after `pub mod config;` in `honcho/mod.rs`) and copied the skeleton `crates/memoryd/src/honcho/ledger.rs`; `cargo check` gave 20 warnings from the unfilled bodies. Step 3 `cargo test -p memoryd --test honcho_ledger` gave 2 pass (`each_record_is_one_line_exactly_as_given`, `an_unknown_field_or_value_is_refused`) and 10 fail with `not yet implemented`. Filled its twelve bodies in the order of the file, each from its comment and `cargo check` after: `is_decided` (`contains_key`); `outbox` (walk `order` with `.iter().filter_map`, keeping `(item, action)` when `state == Waiting`); `sent_ids` (match `State::Sent(ids)` -> `ids.as_slice()`); `conclusion` (walk `sends` in order, pushing a sent `Conclude`'s first id and popping on a matching sent `Unconclude`, returning `live.pop()` so only workspace and text match and a `Sent` with no ids pushes nothing); `counts` (one per entry, matching `&entry.state` since `State` is not `Copy`); `check` (`AlreadyDecided` for a second `Decided`, `NotWaiting` unless a `Sent`/`Failed` finds its entry `Waiting`); `apply` (`Skip` -> `Skipped` else `Waiting`, push to `order` and `entries`; `Sent` sets `Sent(ids)` and pushes to `sends`, `Failed` sets `Failed` without pushing); `write_line` (append, create, mode 0600, one `write_all` then `sync_data`); then `read` (NotFound -> None, else Io), `complete` (rposition of `\n`), `parse` (serde error -> Damaged), `cut` (`set_len` then `sync_data`). Removed the `mut` in `cut` that clippy flagged (`set_len` and `sync_data` take `&self`); after the twelfth `cargo check` was clean and `cargo fmt` left the file. Both test files passed 12 and 12; appended `runbook-honcho-ledger-damaged.md` (`grep -c '^## honcho-ledger-damaged$'` printed 1). Step 7: `sync_data` 2, `write_all` 1, one runbook pointer line in `src`, no `create_dir`, no diff in the other crates. `make gate` printed `gate: ok` with 1212 tests (1211 in `cargo test --workspace`, plus the ignored `end_to_end` the gate runs with `BOXMAKER_BROKERD` set). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |
|
| M5c1/07-memoryd-honcho-ledger | 2026-09-28 | done | 1 | pass | none | Copied the three given files from `docs/plans/M5c1/files/`: the tests `crates/memoryd/tests/honcho_ledger.rs`, `honcho_ledger_file.rs` and the support `crates/memoryd/tests/support/ledger.rs`. Step 2 showed exactly the named failures: two test targets (`honcho_ledger` and `honcho_ledger_file`) with two E0432 each (`unresolved import memoryd::honcho::ledger`), one in each test file and one in `support/ledger.rs`; every other target compiled. Made H1 (`pub mod ledger;` after `pub mod config;` in `honcho/mod.rs`) and copied the skeleton `crates/memoryd/src/honcho/ledger.rs`; `cargo check` gave 20 warnings from the unfilled bodies. Step 3 `cargo test -p memoryd --test honcho_ledger` gave 2 pass (`each_record_is_one_line_exactly_as_given`, `an_unknown_field_or_value_is_refused`) and 10 fail with `not yet implemented`. Filled its twelve bodies in the order of the file, each from its comment and `cargo check` after: `is_decided` (`contains_key`); `outbox` (walk `order` with `.iter().filter_map`, keeping `(item, action)` when `state == Waiting`); `sent_ids` (match `State::Sent(ids)` -> `ids.as_slice()`); `conclusion` (walk `sends` in order, pushing a sent `Conclude`'s first id and popping on a matching sent `Unconclude`, returning `live.pop()` so only workspace and text match and a `Sent` with no ids pushes nothing); `counts` (one per entry, matching `&entry.state` since `State` is not `Copy`); `check` (`AlreadyDecided` for a second `Decided`, `NotWaiting` unless a `Sent`/`Failed` finds its entry `Waiting`); `apply` (`Skip` -> `Skipped` else `Waiting`, push to `order` and `entries`; `Sent` sets `Sent(ids)` and pushes to `sends`, `Failed` sets `Failed` without pushing); `write_line` (append, create, mode 0600, one `write_all` then `sync_data`); then `read` (NotFound -> None, else Io), `complete` (rposition of `\n`), `parse` (serde error -> Damaged), `cut` (`set_len` then `sync_data`). Removed the `mut` in `cut` that clippy flagged (`set_len` and `sync_data` take `&self`); after the twelfth `cargo check` was clean and `cargo fmt` left the file. Both test files passed 12 and 12; appended `runbook-honcho-ledger-damaged.md` (`grep -c '^## honcho-ledger-damaged$'` printed 1). Step 7: `sync_data` 2, `write_all` 1, one runbook pointer line in `src`, no `create_dir`, no diff in the other crates. `make gate` printed `gate: ok` with 1212 tests (1211 in `cargo test --workspace`, plus the ignored `end_to_end` the gate runs with `BOXMAKER_BROKERD` set). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |
|
||||||
|
|||||||
Reference in New Issue
Block a user