From 2b70495b9f0f8912ed76e3cfa9449b2ab5e2f046 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 14 Oct 2026 19:26:54 +0300 Subject: [PATCH] feat(bus): ConsequenceHost review of approved irreversible and paying actions (contract 2.19) Part of tinyhumansai/openhuman#7328 Co-authored-by: Medulla --- .../consequence_host_tests.rs | 34 +++ .../src/consequence_host/mod.rs | 74 +++++++ crates/tinycomputer-bus/src/lib.rs | 1 + crates/tinycomputer-bus/src/version/mod.rs | 4 +- .../src/version/version_tests.rs | 8 +- .../consequence_gate_tests.rs | 194 ++++++++++++++++++ .../tinybus_module/consequence_gate/mod.rs | 179 ++++++++++++++++ .../src/tinybus_module/dispatch/mod.rs | 6 +- .../src/tinybus_module/dispatch/service.rs | 42 +++- crates/tinycomputer/src/tinybus_module/mod.rs | 10 +- crates/tinycomputer/tests/public_api_tests.rs | 2 +- docs/crates/tinycomputer-bus/versioning.md | 12 +- .../specs/desktop-module-contract.md | 10 + 13 files changed, 562 insertions(+), 14 deletions(-) create mode 100644 crates/tinycomputer-bus/src/consequence_host/consequence_host_tests.rs create mode 100644 crates/tinycomputer-bus/src/consequence_host/mod.rs create mode 100644 crates/tinycomputer/src/tinybus_module/consequence_gate/consequence_gate_tests.rs create mode 100644 crates/tinycomputer/src/tinybus_module/consequence_gate/mod.rs diff --git a/crates/tinycomputer-bus/src/consequence_host/consequence_host_tests.rs b/crates/tinycomputer-bus/src/consequence_host/consequence_host_tests.rs new file mode 100644 index 00000000..7a341a29 --- /dev/null +++ b/crates/tinycomputer-bus/src/consequence_host/consequence_host_tests.rs @@ -0,0 +1,34 @@ +//! Tests pinning the `ConsequenceHost` interface identity and wire forms. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +#[test] +fn the_interface_is_its_own_not_the_desktop_or_site_memory_one() { + assert_ne!(INTERFACE, crate::names::INTERFACE); + assert_ne!(INTERFACE, crate::site_memory::INTERFACE); + assert_ne!(OBJECT_PATH, crate::site_memory::OBJECT_PATH); + assert_eq!(METHODS, ["Review"]); +} + +#[test] +fn a_review_request_keeps_its_wire_form() { + let request: ReviewActionRequest = serde_json::from_value(serde_json::json!({ + "task": "t1", "action": "pay now", "target": "Pay", "kind": "payment" + })) + .unwrap(); + assert_eq!(request.kind, ConsequenceKind::Payment); + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["kind"], "payment"); + let irreversible = serde_json::to_value(ConsequenceKind::Irreversible).unwrap(); + assert_eq!(irreversible, "irreversible"); +} + +#[test] +fn a_reason_is_optional_and_left_out_when_absent() { + let reply: ReviewActionResponse = serde_json::from_str(r#"{"allow":true}"#).unwrap(); + assert_eq!(reply.reason, None); + let json = serde_json::to_value(&reply).unwrap(); + assert!(json.get("reason").is_none()); +} diff --git a/crates/tinycomputer-bus/src/consequence_host/mod.rs b/crates/tinycomputer-bus/src/consequence_host/mod.rs new file mode 100644 index 00000000..ec2491e8 --- /dev/null +++ b/crates/tinycomputer-bus/src/consequence_host/mod.rs @@ -0,0 +1,74 @@ +//! The `ConsequenceHost` interface: the host callback that lets a host's +//! policy and approval path rule on an irreversible or paying action before +//! the module carries it out. +//! +//! The module classifies controls itself (`tinycomputer-core::safety`): a +//! control that cannot be taken back, or that moves money, is never pressed +//! on the model's say-so. A task pauses before it as `NeedsApproval`, and +//! answering `ContinueTask { approve: true }` is what lets it go ahead. With +//! this interface served, the module asks the host first and only honours +//! the approval when the host allows it, so the host's security policy, not +//! just a person's click, has the last word. +//! +//! One member, synchronous request/response over plain data: +//! +//! - [`methods::REVIEW`] takes a [`ReviewActionRequest`], returns a +//! [`ReviewActionResponse`]. +//! +//! A host that does not serve the interface leaves the module's own approval +//! pause as the only gate. A host that serves it but answers an error, or +//! cannot be reached, denies: the action is declined, never assumed safe. + +use serde::{Deserialize, Serialize}; + +/// The interface the host serves consequence review on. +pub const INTERFACE: &str = "ai.tinyhumans.tinycomputer.ConsequenceHost"; + +/// The object path the host serves [`INTERFACE`] at. +pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinycomputer/ConsequenceHost"; + +/// One constant per member of [`INTERFACE`]. +pub mod methods { + /// Rules on one action. Takes a [`crate::consequence_host::ReviewActionRequest`]. + pub const REVIEW: &str = "Review"; +} + +/// Every member of [`INTERFACE`], in dispatch order. +pub const METHODS: [&str; 1] = [methods::REVIEW]; + +/// What an action commits to; the module's classification of its control. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConsequenceKind { + /// Cannot be taken back: a message sent, data deleted, a booking + /// confirmed. + Irreversible, + /// Money changes hands. + Payment, +} + +/// `Review`: one action the module is about to carry out. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewActionRequest { + /// The task asking, as `TaskView.id` names it. + pub task: String, + /// What it would do, in plain words. + pub action: String, + /// The control it would press. + pub target: String, + /// What the module classified the action as. + pub kind: ConsequenceKind, +} + +/// The reply to `Review`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewActionResponse { + /// Whether the host lets the action go ahead. + pub allow: bool, + /// Why not, in plain words, when `allow` is `false`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[cfg(test)] +mod consequence_host_tests; diff --git a/crates/tinycomputer-bus/src/lib.rs b/crates/tinycomputer-bus/src/lib.rs index bf8718d8..c35f152c 100644 --- a/crates/tinycomputer-bus/src/lib.rs +++ b/crates/tinycomputer-bus/src/lib.rs @@ -119,6 +119,7 @@ pub mod apps; pub mod browser; pub mod catalogue; pub mod clipboard; +pub mod consequence_host; pub mod desktop_tool; pub mod envelope; mod error; diff --git a/crates/tinycomputer-bus/src/version/mod.rs b/crates/tinycomputer-bus/src/version/mod.rs index 6d49a302..ad675d3e 100644 --- a/crates/tinycomputer-bus/src/version/mod.rs +++ b/crates/tinycomputer-bus/src/version/mod.rs @@ -8,7 +8,7 @@ //! release workflow bumps, which tracks the shipped artifact. /// The wire contract version this crate defines. -pub const CONTRACT_VERSION: (u32, u32) = (2, 18); +pub const CONTRACT_VERSION: (u32, u32) = (2, 19); /// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module /// reporting `module`. @@ -22,7 +22,7 @@ pub const CONTRACT_VERSION: (u32, u32) = (2, 18); /// ``` /// # use tinycomputer_bus::{is_compatible, CONTRACT_VERSION}; /// assert!(is_compatible(CONTRACT_VERSION)); -/// assert!(is_compatible((2, 18))); +/// assert!(is_compatible((2, 19))); /// assert!(!is_compatible((2, 11))); /// assert!(!is_compatible((1, 8))); /// ``` diff --git a/crates/tinycomputer-bus/src/version/version_tests.rs b/crates/tinycomputer-bus/src/version/version_tests.rs index f80202ed..1be61cc7 100644 --- a/crates/tinycomputer-bus/src/version/version_tests.rs +++ b/crates/tinycomputer-bus/src/version/version_tests.rs @@ -6,7 +6,7 @@ use super::{CONTRACT_VERSION, binds, is_compatible}; #[test] fn the_shipped_contract_version_is_pinned() { - assert_eq!(CONTRACT_VERSION, (2, 18)); + assert_eq!(CONTRACT_VERSION, (2, 19)); } #[test] @@ -16,7 +16,7 @@ fn the_contract_binds_to_itself() { #[test] fn a_newer_minor_on_the_module_side_binds() { - assert!(is_compatible((2, 18))); + assert!(is_compatible((2, 19))); assert!(is_compatible((2, 97))); // 2.12 adds replayable GlobeRead and joined terminal GlobeShutdown. // A 2.11 module does not implement those members. @@ -32,6 +32,10 @@ fn a_newer_minor_on_the_module_side_binds() { // 2.18 gives `Press` an optional `target`, which the module focuses and // checks before the key goes: a 2.17 module would ignore the field and // press the key wherever the focus is. + // 2.19 adds the ConsequenceHost host-callback interface (Review): a 2.19 + // host serves it, which a 2.18 module does not call, so its approvals + // would never reach the host's policy. + assert!(!is_compatible((2, 18))); assert!(!is_compatible((2, 17))); assert!(!is_compatible((2, 16))); assert!(!is_compatible((2, 15))); diff --git a/crates/tinycomputer/src/tinybus_module/consequence_gate/consequence_gate_tests.rs b/crates/tinycomputer/src/tinybus_module/consequence_gate/consequence_gate_tests.rs new file mode 100644 index 00000000..dbc9a423 --- /dev/null +++ b/crates/tinycomputer/src/tinybus_module/consequence_gate/consequence_gate_tests.rs @@ -0,0 +1,194 @@ +//! The host's `ConsequenceHost` rules on an approval before the task takes +//! its action, and a host that cannot answer never lets one through. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::{Arc, Mutex}; + +use tinybus::Connection; +use tinycomputer_bus::agent::{ContinueTaskRequest, TaskId}; + +use super::*; + +/// What a fake host answers. +#[derive(Debug, Clone)] +enum Answer { + Allow, + Deny(Option<&'static str>), + NotServed, + Broken, +} + +#[derive(Debug, Clone)] +struct FakeHost { + answer: Answer, + seen: Arc>>, +} + +impl FakeHost { + fn new(answer: Answer) -> Self { + Self { + answer, + seen: Arc::default(), + } + } +} + +impl ConsequencePort for FakeHost { + fn review(&self, request: ReviewActionRequest) -> PortFuture<'_, ReviewActionResponse> { + self.seen.lock().unwrap().push(request); + Box::pin(async move { + match &self.answer { + Answer::Allow => Ok(ReviewActionResponse { + allow: true, + reason: None, + }), + Answer::Deny(reason) => Ok(ReviewActionResponse { + allow: false, + reason: reason.map(str::to_owned), + }), + Answer::NotServed => Err(PortError::NotServed), + Answer::Broken => Err(PortError::Failed("timed out".to_owned())), + } + }) + } +} + +fn approval(approve: Option) -> ContinueTaskRequest { + ContinueTaskRequest { + id: TaskId::new("t1"), + inputs: std::collections::BTreeMap::new(), + approve, + answer: None, + } +} + +fn pending() -> (String, String) { + ("send the message".to_owned(), "Send".to_owned()) +} + +async fn screened(answer: Answer, approve: Option) -> (Option, FakeHost) { + let host = FakeHost::new(answer); + let link = ConsequenceLink::new(Box::new(host.clone())); + let mut request = approval(approve); + link.screen(&mut request, Some(pending())).await; + (request.approve, host) +} + +#[test] +fn a_payment_label_on_either_side_makes_the_action_a_payment() { + assert_eq!(kind_of("pay now", "Continue"), ConsequenceKind::Payment); + assert_eq!( + kind_of("press the button", "Place order"), + ConsequenceKind::Payment + ); + assert_eq!( + kind_of("send the message", "Send"), + ConsequenceKind::Irreversible + ); +} + +#[test] +fn a_control_that_reads_as_reversible_still_counts_as_irreversible() { + assert_eq!(kind_of("continue", "Next"), ConsequenceKind::Irreversible); +} + +#[tokio::test] +async fn an_allowed_approval_goes_through() { + let (approve, host) = screened(Answer::Allow, Some(true)).await; + assert_eq!(approve, Some(true)); + let seen = host.seen.lock().unwrap(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].task, "t1"); + assert_eq!(seen[0].target, "Send"); + assert_eq!(seen[0].kind, ConsequenceKind::Irreversible); +} + +#[tokio::test] +async fn a_denied_approval_becomes_a_decline() { + let (approve, _) = screened(Answer::Deny(Some("policy: no sends")), Some(true)).await; + assert_eq!(approve, Some(false)); + let (approve, _) = screened(Answer::Deny(None), Some(true)).await; + assert_eq!(approve, Some(false)); +} + +#[tokio::test] +async fn a_host_that_fails_to_answer_declines_the_action() { + let (approve, _) = screened(Answer::Broken, Some(true)).await; + assert_eq!(approve, Some(false)); +} + +#[tokio::test] +async fn a_host_that_serves_nothing_leaves_the_approval_alone() { + let (approve, host) = screened(Answer::NotServed, Some(true)).await; + assert_eq!(approve, Some(true)); + assert_eq!(host.seen.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn a_decline_or_an_unanswered_pause_is_not_reviewed() { + for approve in [Some(false), None] { + let (after, host) = screened(Answer::Deny(None), approve).await; + assert_eq!(after, approve); + assert!(host.seen.lock().unwrap().is_empty()); + } +} + +#[tokio::test] +async fn an_approval_of_nothing_pending_is_not_reviewed() { + let host = FakeHost::new(Answer::Deny(None)); + let link = ConsequenceLink::new(Box::new(host.clone())); + let mut request = approval(Some(true)); + link.screen(&mut request, None).await; + assert_eq!(request.approve, Some(true)); + assert!(host.seen.lock().unwrap().is_empty()); +} + +/// A host serving `ConsequenceHost` over a real bus. +#[derive(Clone)] +struct BusHost(FakeHost); + +#[tinybus::interface(name = "ai.tinyhumans.tinycomputer.ConsequenceHost")] +impl BusHost { + async fn review(&self, request: ReviewActionRequest) -> tinybus::Result { + self.0 + .review(request) + .await + .map_err(|error| tinybus::Error::failed(format!("{error:?}"))) + } +} + +#[tokio::test] +async fn the_bus_port_reaches_the_hosts_interface() -> tinybus::Result<()> { + use tinybus::broker::Broker; + use tinybus::transport::memory::MemoryBus; + + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let host_connection = Connection::connect(bus.connect().await?).await?; + let fake = FakeHost::new(Answer::Deny(Some("no"))); + host_connection + .serve_at(OBJECT_PATH.try_into()?, BusHost(fake.clone())) + .await?; + host_connection.request_name(INTERFACE).await?; + let module = Connection::connect(bus.connect().await?).await?; + + let link = ConsequenceLink::new(Box::new(BusPort::new(module))); + let verdict = link.review("t1", "pay now", "Pay").await; + assert_eq!(verdict, Verdict::Denied("no".to_owned())); + assert_eq!(fake.seen.lock().unwrap()[0].kind, ConsequenceKind::Payment); + Ok(()) +} + +#[tokio::test] +async fn the_bus_port_treats_a_host_that_serves_nothing_as_unreviewed() -> tinybus::Result<()> { + use tinybus::broker::Broker; + use tinybus::transport::memory::MemoryBus; + + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let module = Connection::connect(bus.connect().await?).await?; + let link = ConsequenceLink::new(Box::new(BusPort::new(module))); + assert_eq!(link.review("t1", "send", "Send").await, Verdict::Unreviewed); + Ok(()) +} diff --git a/crates/tinycomputer/src/tinybus_module/consequence_gate/mod.rs b/crates/tinycomputer/src/tinybus_module/consequence_gate/mod.rs new file mode 100644 index 00000000..93dd0e77 --- /dev/null +++ b/crates/tinycomputer/src/tinybus_module/consequence_gate/mod.rs @@ -0,0 +1,179 @@ +//! Asking the host before an approved irreversible or paying action goes +//! ahead, through its `ConsequenceHost`. +//! +//! A task pauses before a control the module classifies as irreversible or +//! paying (`tinycomputer_core::safety`). Answering that pause with +//! `ContinueTask { approve: true }` used to be the whole gate. With the host +//! serving `ConsequenceHost`, the module first sends it a `Review` and +//! honours the approval only on `allow`; otherwise the task is declined, as +//! if `approve` had been `false`. +//! +//! - A host that does not serve the interface leaves the pause as the only +//! gate ([`Verdict::Unreviewed`]). +//! - A host that serves it but errors, times out or is unreachable denies: +//! an action nobody could rule on is never assumed safe. + +use std::future::Future; +use std::pin::Pin; + +use tinybus::Connection; +use tinycomputer_bus::agent::ContinueTaskRequest; +use tinycomputer_bus::consequence_host::{ + ConsequenceKind, INTERFACE, OBJECT_PATH, ReviewActionRequest, ReviewActionResponse, methods, +}; +use tinycomputer_core::{Consequence, consequence}; + +/// The deadline for one call to the host. +const CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// The bus errors that mean the host does not serve the interface at all, +/// rather than serving it and failing. +const NOT_SERVED: [&str; 3] = [ + "ai.tinyhumans.tinybus.Error.NameHasNoOwner", + "ai.tinyhumans.tinybus.Error.UnknownObject", + "ai.tinyhumans.tinybus.Error.UnknownInterface", +]; + +/// Why a call to the host failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PortError { + /// Nothing serves the interface. + NotServed, + /// The host serves it and the call failed. + Failed(String), +} + +/// A pending call to the host. +pub(crate) type PortFuture<'a, T> = Pin> + Send + 'a>>; + +/// The host's `ConsequenceHost`, as the module calls it. +pub(crate) trait ConsequencePort: Send + Sync + std::fmt::Debug { + /// `Review`: rules on one action. + fn review(&self, request: ReviewActionRequest) -> PortFuture<'_, ReviewActionResponse>; +} + +/// The port over the bus connection the module was set up on. +#[derive(Clone)] +pub(crate) struct BusPort { + connection: Connection, +} + +impl std::fmt::Debug for BusPort { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.debug_struct("BusPort").finish_non_exhaustive() + } +} + +impl BusPort { + pub(crate) fn new(connection: Connection) -> Self { + Self { connection } + } +} + +impl ConsequencePort for BusPort { + fn review(&self, request: ReviewActionRequest) -> PortFuture<'_, ReviewActionResponse> { + Box::pin(async move { + let proxy = self + .connection + .proxy(INTERFACE, OBJECT_PATH, INTERFACE) + .map(|proxy| proxy.with_timeout(CALL_TIMEOUT)) + .map_err(|error| PortError::Failed(error.to_string()))?; + proxy + .call(methods::REVIEW, (request,)) + .await + .map_err(|error| { + // Across the bus an error keeps its dotted name, not its + // variant, so the name is what says nobody serves this. + if NOT_SERVED.contains(&error.wire_name()) { + PortError::NotServed + } else { + PortError::Failed(error.to_string()) + } + }) + }) + } +} + +/// What the host ruled on an action. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Verdict { + /// The host does not review actions; the task's own pause is the gate. + Unreviewed, + /// The host allows the action. + Allowed, + /// The host, or its failure to answer, denies the action. + Denied(String), +} + +/// What `action` and `target` commit to, the worse of the two readings. A +/// control the module paused on is never treated as harmless, so one that +/// reads as reversible still counts as irreversible. +pub(crate) fn kind_of(action: &str, target: &str) -> ConsequenceKind { + if consequence(action) == Consequence::Payment || consequence(target) == Consequence::Payment { + ConsequenceKind::Payment + } else { + ConsequenceKind::Irreversible + } +} + +/// The module's link to the host's consequence review. +#[derive(Debug)] +pub(crate) struct ConsequenceLink { + port: Box, +} + +impl ConsequenceLink { + pub(crate) fn new(port: Box) -> Self { + Self { port } + } + + /// Asks the host whether task `task` may go ahead with `action` on + /// `target`. + pub(crate) async fn review(&self, task: &str, action: &str, target: &str) -> Verdict { + let kind = kind_of(action, target); + let request = ReviewActionRequest { + task: task.to_owned(), + action: action.to_owned(), + target: target.to_owned(), + kind, + }; + match self.port.review(request).await { + Ok(reply) if reply.allow => Verdict::Allowed, + Ok(reply) => Verdict::Denied( + reply + .reason + .unwrap_or_else(|| "the host denied the action".to_owned()), + ), + Err(PortError::NotServed) => Verdict::Unreviewed, + Err(PortError::Failed(error)) => { + Verdict::Denied(format!("the host could not review the action: {error}")) + } + } + } +} + +impl ConsequenceLink { + /// Rules on an answer to a paused task. `paused` is the `(action, + /// target)` the task is waiting on, when it waits on an approval. An + /// approval the host denies becomes a decline, so the task never takes + /// the action; a decline, an answer to nothing pending, an allow, and a + /// host that does not review actions leave `request` as it was. + pub(crate) async fn screen( + &self, + request: &mut ContinueTaskRequest, + paused: Option<(String, String)>, + ) { + if request.approve != Some(true) { + return; + } + let Some((action, target)) = paused else { + return; + }; + if let Verdict::Denied(_) = self.review(&request.id.to_string(), &action, &target).await { + request.approve = Some(false); + } + } +} + +#[cfg(test)] +mod consequence_gate_tests; diff --git a/crates/tinycomputer/src/tinybus_module/dispatch/mod.rs b/crates/tinycomputer/src/tinybus_module/dispatch/mod.rs index ff2d16ee..54ef05e9 100644 --- a/crates/tinycomputer/src/tinybus_module/dispatch/mod.rs +++ b/crates/tinycomputer/src/tinybus_module/dispatch/mod.rs @@ -47,6 +47,7 @@ use tinycomputer_bus::agent::{ TaskReportRequest, TaskView, }; +use super::consequence_gate::ConsequenceLink; use super::runner::jev_not_configured; use super::site_memory::SiteLink; use crate::Desktop; @@ -78,6 +79,8 @@ pub(crate) struct DesktopService { accessibility: Arc, /// The host's per-site memory, when the module was set up on a bus. site_link: Option>, + /// The host's consequence review, when the module was set up on a bus. + consequence_link: Option>, } #[tinybus::interface(name = "ai.tinyhumans.tinycomputer.Desktop")] @@ -169,11 +172,12 @@ impl DesktopService { #[tinybus(confidential)] async fn continue_task( &self, - request: ContinueTaskRequest, + mut request: ContinueTaskRequest, ) -> TinyBusResult> { if let Some(link) = &self.site_link { link.note_inputs(&request.id, &request.inputs); } + self.review_approval(&mut request).await?; let reply = self .on_tasks(move |tasks| tasks.continue_task(request)) .await?; diff --git a/crates/tinycomputer/src/tinybus_module/dispatch/service.rs b/crates/tinycomputer/src/tinybus_module/dispatch/service.rs index 14d033b9..f4d1847b 100644 --- a/crates/tinycomputer/src/tinybus_module/dispatch/service.rs +++ b/crates/tinycomputer/src/tinybus_module/dispatch/service.rs @@ -6,13 +6,15 @@ use std::sync::Arc; use tinybus::{Error as TinyBusError, Result as TinyBusResult}; use tinycomputer_browser::{AgentBrowser, Browser}; use tinycomputer_bus::agent::{ - AgentResponse, StartTaskRequest, SurfaceAvailability, SurfaceKind, TaskView, + AgentResponse, ContinueTaskRequest, StartTaskRequest, SurfaceAvailability, SurfaceKind, + TaskStatus, TaskView, }; use tinycomputer_bus::{DesktopResponse, JevConfig}; use tinycomputer_engine as agentic; use super::DesktopService; use crate::tinybus_module::config::{BrowserDefaults, cursor_config}; +use crate::tinybus_module::consequence_gate::{ConsequenceLink, ConsequencePort}; use crate::tinybus_module::runner::WorkspaceRunner; use crate::tinybus_module::site_memory::{SiteLink, SiteMemoryPort, refused_flow}; use crate::{Desktop, Result}; @@ -91,6 +93,7 @@ impl DesktopService { browser_defaults, accessibility: Arc::new(super::accessibility::Access::default()), site_link: None, + consequence_link: None, }) } @@ -213,6 +216,43 @@ impl DesktopService { self } + /// Has the host rule on actions its tasks are approved to take, through + /// `port`, the host's `ConsequenceHost`. + pub(crate) fn with_consequence_review(mut self, port: Box) -> Self { + self.consequence_link = Some(Arc::new(ConsequenceLink::new(port))); + self + } + + /// Before an approval reaches the task, asks the host whether the action + /// it is paused on may go ahead. A denial turns the approval into a + /// decline, so the task never takes the action; an approval of nothing + /// pending, a decline, or a host that does not review actions passes + /// through unchanged. + pub(super) async fn review_approval( + &self, + request: &mut ContinueTaskRequest, + ) -> TinyBusResult<()> { + let Some(link) = self.consequence_link.clone() else { + return Ok(()); + }; + if request.approve != Some(true) { + return Ok(()); + } + let id = request.id.clone(); + let listed = self.on_tasks(agentic::Tasks::list).await?; + let paused = listed + .data + .unwrap_or_default() + .into_iter() + .find(|view| view.id == id) + .and_then(|view| match view.status { + TaskStatus::NeedsApproval { action, target, .. } => Some((action, target)), + _ => None, + }); + link.screen(request, paused).await; + Ok(()) + } + /// Starts a task with what earlier tasks on its `site` left, and follows /// it for what it may teach. A saved plan the engine refuses is /// forgotten and the task started again to be planned afresh. diff --git a/crates/tinycomputer/src/tinybus_module/mod.rs b/crates/tinycomputer/src/tinybus_module/mod.rs index 0ac330e2..7fd82e39 100644 --- a/crates/tinycomputer/src/tinybus_module/mod.rs +++ b/crates/tinycomputer/src/tinybus_module/mod.rs @@ -20,6 +20,7 @@ //! an `await`. mod config; +mod consequence_gate; mod dispatch; mod runner; mod site_memory; @@ -42,6 +43,10 @@ async fn setup(connection: Connection, config: Value) -> TinyBusResult<()> { // The host's per-site memory; a host that does not serve it only costs // tasks what earlier ones learned. let service = service.with_site_memory(Box::new(site_memory::BusPort::new(connection.clone()))); + // The host's consequence review; a host that does not serve it leaves a + // task's own approval pause as the only gate. + let service = service + .with_consequence_review(Box::new(consequence_gate::BusPort::new(connection.clone()))); #[cfg(test)] let service = service.with_native_fixture(); service.sweep_outputs(); @@ -86,7 +91,10 @@ tinybus_module::module_export_optional_static! { ], signals = [], requires = [], - optional = ["ai.tinyhumans.tinycomputer.SiteMemoryHost"], + optional = [ + "ai.tinyhumans.tinycomputer.SiteMemoryHost", + "ai.tinyhumans.tinycomputer.ConsequenceHost", + ], lazy = false, } diff --git a/crates/tinycomputer/tests/public_api_tests.rs b/crates/tinycomputer/tests/public_api_tests.rs index 7b8e9e33..5856950e 100644 --- a/crates/tinycomputer/tests/public_api_tests.rs +++ b/crates/tinycomputer/tests/public_api_tests.rs @@ -34,7 +34,7 @@ fn the_crate_re_exports_the_contract_rather_than_redefining_it() { #[test] fn the_shipped_contract_binds_to_itself() { assert!(is_compatible(CONTRACT_VERSION)); - assert_eq!(CONTRACT_VERSION, (2, 18)); + assert_eq!(CONTRACT_VERSION, (2, 19)); } #[test] diff --git a/docs/crates/tinycomputer-bus/versioning.md b/docs/crates/tinycomputer-bus/versioning.md index cffd1940..c6d9ede8 100644 --- a/docs/crates/tinycomputer-bus/versioning.md +++ b/docs/crates/tinycomputer-bus/versioning.md @@ -9,7 +9,7 @@ code, so this page exists mostly to keep them apart. ## `CONTRACT_VERSION`: does this host understand this module ```rust,ignore -pub const CONTRACT_VERSION: (u32, u32) = (2, 18); +pub const CONTRACT_VERSION: (u32, u32) = (2, 19); ``` This describes the *vocabulary*: the member set and the payload shapes, @@ -45,15 +45,15 @@ Two conditions, both necessary: relies on changed shape or disappeared, so a mismatched major is refused outright, in either direction. 2. **The module must be at least as new, minor-wise, as the host.** A host - built against `(2, 18)` expects at least the members and fields that - existed at `(2, 18)`. A module reporting `(2, 11)` might be missing one of - them, so it is rejected. A module reporting `(2, 18)` has everything the + built against `(2, 19)` expects at least the members and fields that + existed at `(2, 19)`. A module reporting `(2, 11)` might be missing one of + them, so it is rejected. A module reporting `(2, 19)` has everything the host expects, plus something newer the host simply does not use yet, so it is accepted. ```rust,ignore -assert!(is_compatible(CONTRACT_VERSION)); // (2, 18), the exact version this crate ships -assert!(is_compatible((2, 18))); // a newer, still-compatible module +assert!(is_compatible(CONTRACT_VERSION)); // (2, 19), the exact version this crate ships +assert!(is_compatible((2, 19))); // a newer, still-compatible module assert!(!is_compatible((2, 11))); // an older module, missing something assert!(!is_compatible((1, 10))); // a different major, all bets off ``` diff --git a/docs/technical/specs/desktop-module-contract.md b/docs/technical/specs/desktop-module-contract.md index abb84164..df9365af 100644 --- a/docs/technical/specs/desktop-module-contract.md +++ b/docs/technical/specs/desktop-module-contract.md @@ -78,6 +78,16 @@ engine's argument types, the permission preflight, and the bus surface. 2.17 module ignores a press's `target`. The `ai.tinyhumans.tinycomputer.Browser` interface and its unprefixed names, never served by any release, are retired; the error names keep that prefix because they are published values, not members. + +Consequence review (2.19): a host may serve the optional +`ai.tinyhumans.tinycomputer.ConsequenceHost` interface (`tinycomputer_bus::consequence_host`), +one member, `Review`, taking the task, the action, the target and the module's +classification (`irreversible` or `payment`). The module calls it from +`ContinueTask` when `approve` is `true` and the task is paused on a +`needs_approval`, before the approval reaches the task. A denial, or a host that +serves the interface and fails to answer, turns the approval into a decline, so +the task never takes the action. A host that serves nothing leaves the pause as +the only gate. A payment checkpoint is never continuable and needs no review. - `TaskReport` takes a `TaskReportRequest` (2.7), `{"id", "trace"}` with `trace` always serialized, rather than a `TaskRef`. A TinyBus client refuses a confidential body holding an object whose only field is a string