Skip to main content

substrate_core/
ports.rs

1//! The five port traits — the only seams adapters may implement.
2//!
3//! Ports are expressed as `async_trait` object-safe traits returning
4//! [`crate::error::Result`]. Core defines them; adapter crates implement them;
5//! the application layer is generic over them. Core never implements a port.
6
7use async_trait::async_trait;
8
9use crate::domain::{
10    ConversationDump, EngineCapabilities, Mailbox, Message, RoutingDecision, StructuredResult, Task,
11};
12use crate::error::Result;
13
14/// Drives a concrete agent engine (a CLI such as `forge`, or an SDK).
15///
16/// Lifecycle: [`start`](EnginePort::start) launches a run and yields a
17/// [`Session`]. [`dump`](EnginePort::dump) exports the raw conversation, which
18/// [`extract_result`](EnginePort::extract_result) normalizes into a
19/// [`StructuredResult`].
20#[async_trait]
21pub trait EnginePort: Send + Sync {
22    /// Start a new run for `task`, returning a live session handle.
23    async fn start(&self, task: &Task) -> Result<crate::domain::Session>;
24
25    /// Resume an existing conversation with a follow-up prompt.
26    async fn resume(&self, conv_id: &str, prompt: &str) -> Result<crate::domain::Session>;
27
28    /// Export the raw conversation for `conv_id`.
29    async fn dump(&self, conv_id: &str) -> Result<ConversationDump>;
30
31    /// Cancel a running conversation.
32    async fn cancel(&self, conv_id: &str) -> Result<()>;
33
34    /// Attach a mailbox so the engine can emit/consume A2A messages.
35    async fn wire_mailbox(&self, conv_id: &str, mailbox: &Mailbox) -> Result<()>;
36
37    /// Normalize a raw dump into a [`StructuredResult`]. Pure transform;
38    /// implementations must not perform IO here.
39    fn extract_result(&self, dump: &ConversationDump) -> Result<StructuredResult>;
40
41    /// Advertise static capabilities.
42    fn capabilities(&self) -> EngineCapabilities;
43}
44
45/// Selects an engine/agent target for a task.
46///
47/// Adapters may delegate to [`crate::routing_port::RoutingSuperset`] for
48/// load-balancing strategies, per-target circuit breakers, and weighted fallback.
49#[async_trait]
50pub trait RoutingPort: Send + Sync {
51    /// Return the engine name that should handle `task`.
52    async fn route(&self, task: &Task) -> Result<String> {
53        // Backwards-compatible default: delegate to the structured decision.
54        Ok(self.route_decision(task).await?.engine)
55    }
56
57    /// Return a full [`RoutingDecision`] (engine + model + rationale).
58    ///
59    /// Phase 1 introduces the structured decision so adapters (e.g. the
60    /// `routing-phenotype-router` adapter) can route to a specific
61    /// model/provider while preserving the engine target.
62    async fn route_decision(&self, task: &Task) -> Result<RoutingDecision>;
63}
64
65/// A message bus / mailbox transport.
66///
67/// # Atomic claim contract
68///
69/// [`claim`](TransportPort::claim) MUST be atomic: at most one caller may
70/// successfully claim a given message. Implementations realize this via a
71/// compare-and-swap on a `state`/lease field (e.g. a lockfile + CAS), so that
72/// concurrent workers never double-process. A failed claim returns
73/// [`crate::error::SubstrateError::ClaimConflict`].
74#[async_trait]
75pub trait TransportPort: Send + Sync {
76    /// Publish `message` to its recipient's mailbox.
77    async fn publish(&self, message: &Message) -> Result<()>;
78
79    /// Return all messages currently addressed to `owner`.
80    async fn subscribe(&self, owner: &str) -> Result<Vec<Message>>;
81
82    /// Atomically claim `message_id` for exclusive processing (CAS-lease).
83    /// Returns the claimed message, or `ClaimConflict` if already held.
84    async fn claim(&self, owner: &str, message_id: &uuid::Uuid) -> Result<Message>;
85
86    /// Snapshot the full mailbox for `owner`.
87    async fn mailbox(&self, owner: &str) -> Result<Mailbox>;
88}
89
90/// Durable persistence for tasks and results.
91///
92/// [`claim_atomic`](StorePort::claim_atomic) provides the same CAS-lease
93/// guarantee as the transport: at most one worker may move a task out of its
94/// claimable state.
95#[async_trait]
96pub trait StorePort: Send + Sync {
97    /// Persist (insert or update) a task.
98    async fn persist(&self, task: &Task) -> Result<()>;
99
100    /// Load a task by id.
101    async fn load(&self, id: &uuid::Uuid) -> Result<Task>;
102
103    /// Persist a normalized result for a task.
104    async fn persist_result(&self, task_id: &uuid::Uuid, result: &StructuredResult) -> Result<()>;
105
106    /// Atomically claim a task for exclusive work (CAS on lifecycle state).
107    /// Returns `ClaimConflict` if another worker already holds it.
108    async fn claim_atomic(&self, id: &uuid::Uuid) -> Result<Task>;
109}
110
111/// The inbound application API (driving side of the hexagon).
112#[async_trait]
113pub trait DispatchApi: Send + Sync {
114    /// Dispatch a task end-to-end and return its normalized result.
115    async fn dispatch(&self, task: Task) -> Result<StructuredResult>;
116
117    /// Fetch a previously dispatched task.
118    async fn get(&self, id: &uuid::Uuid) -> Result<Task>;
119
120    /// Cancel a previously dispatched task.
121    async fn cancel(&self, id: &uuid::Uuid) -> Result<()>;
122}