Skip to main content

substrate_core/
domain.rs

1//! Pure domain model: entities, value objects, and the task lifecycle FSM.
2//!
3//! Nothing in this module performs IO. Every type is serde-serializable so
4//! that adapters can persist/transport them without re-declaring shapes.
5
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use crate::error::{Result, SubstrateError};
10
11// ---------------------------------------------------------------------------
12// Task lifecycle
13// ---------------------------------------------------------------------------
14
15/// The lifecycle state of a [`Task`].
16///
17/// Legal flow:
18/// `Submitted -> Working -> InputRequired -> Working -> Completed`
19/// with `Failed`/`Cancelled` reachable from any non-terminal state.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum TaskState {
23    /// Accepted into the system, not yet picked up.
24    Submitted,
25    /// Actively being worked by an engine.
26    Working,
27    /// Blocked awaiting human/agent input.
28    InputRequired,
29    /// Finished successfully (terminal).
30    Completed,
31    /// Finished unsuccessfully (terminal).
32    Failed,
33    /// Aborted before completion (terminal).
34    Cancelled,
35}
36
37impl TaskState {
38    /// Returns true if no further transitions are legal from this state.
39    pub fn is_terminal(self) -> bool {
40        matches!(
41            self,
42            TaskState::Completed | TaskState::Failed | TaskState::Cancelled
43        )
44    }
45
46    /// Pure transition predicate for the lifecycle FSM.
47    ///
48    /// Terminal states have no outgoing edges. Non-terminal states may always
49    /// move to `Failed` or `Cancelled`. The happy-path edges are explicit.
50    pub fn can_transition(from: TaskState, to: TaskState) -> bool {
51        use TaskState::*;
52        if from == to {
53            return false;
54        }
55        if from.is_terminal() {
56            return false;
57        }
58        // Any live task may fail or be cancelled.
59        if matches!(to, Failed | Cancelled) {
60            return true;
61        }
62        matches!(
63            (from, to),
64            (Submitted, Working)
65                | (Working, InputRequired)
66                | (Working, Completed)
67                | (InputRequired, Working)
68        )
69    }
70}
71
72// ---------------------------------------------------------------------------
73// Task
74// ---------------------------------------------------------------------------
75
76/// A unit of dispatchable work.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct Task {
79    /// Stable identity.
80    pub id: Uuid,
81    /// The instruction handed to an engine.
82    pub prompt: String,
83    /// Working directory the engine should run in.
84    pub cwd: String,
85    /// Current lifecycle state.
86    pub state: TaskState,
87    /// Parent task, if this is a delegated subtask.
88    pub parent_task_id: Option<Uuid>,
89    /// Traceability link to a requirement (FR/NFR id).
90    pub requirement_id: Option<String>,
91    /// Traceability link to an epic.
92    pub epic_id: Option<String>,
93    /// Engine conversation id, set after [`EnginePort::start`].
94    #[serde(default)]
95    pub conv_id: Option<String>,
96}
97
98impl Task {
99    /// Create a freshly-submitted task with a new id.
100    pub fn new(prompt: impl Into<String>, cwd: impl Into<String>) -> Self {
101        Task {
102            id: Uuid::new_v4(),
103            prompt: prompt.into(),
104            cwd: cwd.into(),
105            state: TaskState::Submitted,
106            parent_task_id: None,
107            requirement_id: None,
108            epic_id: None,
109            conv_id: None,
110        }
111    }
112
113    /// Advance the task to `to`, enforcing the lifecycle FSM.
114    ///
115    /// Returns [`SubstrateError::InvalidTransition`] if the edge is illegal,
116    /// leaving the task unchanged.
117    pub fn advance(&mut self, to: TaskState) -> Result<()> {
118        if TaskState::can_transition(self.state, to) {
119            self.state = to;
120            Ok(())
121        } else {
122            Err(SubstrateError::InvalidTransition {
123                from: self.state,
124                to,
125            })
126        }
127    }
128}
129
130// ---------------------------------------------------------------------------
131// Agents & teams
132// ---------------------------------------------------------------------------
133
134/// The role an agent plays in a team hierarchy.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum AgentRole {
138    /// Owns a stream end-to-end and delegates downward.
139    Lead,
140    /// A peer working under a lead.
141    Teammate,
142    /// A worker spawned by a lead/teammate.
143    Subagent,
144}
145
146/// An addressable participant.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct Agent {
149    /// Stable identity.
150    pub id: Uuid,
151    /// Human-readable handle (addressable in a mailbox).
152    pub name: String,
153    /// Role in the hierarchy.
154    pub role: AgentRole,
155    /// The engine that backs this agent (e.g. "forge").
156    pub engine: String,
157}
158
159/// A named set of agents collaborating on a goal.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct Team {
162    /// Stable identity.
163    pub id: Uuid,
164    /// Human-readable name.
165    pub name: String,
166    /// The lead agent's id.
167    pub lead: Uuid,
168    /// All member ids (including the lead).
169    pub members: Vec<Uuid>,
170}
171
172// ---------------------------------------------------------------------------
173// A2A-shaped messaging
174// ---------------------------------------------------------------------------
175
176/// The semantic kind of a [`Message`], mirroring the A2A protocol shape.
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum MessageKind {
180    /// A delegated unit of work.
181    Task,
182    /// A response to a prior message.
183    Reply,
184    /// A request for information/decision.
185    Question,
186    /// A progress/status update.
187    Status,
188    /// An emitted artifact (file, PR, diff).
189    Artifact,
190}
191
192/// A single content part of a message (A2A `parts`).
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(tag = "type", rename_all = "snake_case")]
195pub enum Part {
196    /// Plain text content.
197    Text {
198        /// The text body.
199        text: String,
200    },
201    /// A reference to a produced artifact.
202    Artifact {
203        /// Artifact name/identifier.
204        name: String,
205        /// Where the artifact lives (path/URL).
206        uri: String,
207    },
208}
209
210/// An A2A-shaped message exchanged between agents.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct Message {
213    /// Stable identity.
214    pub id: Uuid,
215    /// Sender handle.
216    pub from: String,
217    /// Recipient handle.
218    pub to: String,
219    /// Semantic kind.
220    pub kind: MessageKind,
221    /// Ordered content parts.
222    pub parts: Vec<Part>,
223    /// The message this is a reply to, if any.
224    pub in_reply_to: Option<Uuid>,
225}
226
227/// A per-recipient ordered collection of messages.
228#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
229pub struct Mailbox {
230    /// Owner handle of this mailbox.
231    pub owner: String,
232    /// Messages in arrival order.
233    pub messages: Vec<Message>,
234}
235
236/// A threaded exchange of messages, scoped to an engine conversation.
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238pub struct Conversation {
239    /// Stable identity (often the engine's conversation id).
240    pub id: String,
241    /// Ordered messages in the thread.
242    pub messages: Vec<Message>,
243}
244
245// ---------------------------------------------------------------------------
246// Engine I/O value objects
247// ---------------------------------------------------------------------------
248
249/// The normalized outcome of an engine run.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251pub struct StructuredResult {
252    /// Final assistant text.
253    pub text: String,
254    /// Emitted artifacts (name -> uri).
255    pub artifacts: Vec<Part>,
256    /// Pull-request URLs discovered in the output.
257    pub pr_urls: Vec<String>,
258    /// Terminal lifecycle status implied by the run.
259    pub status: TaskState,
260}
261
262/// Static capabilities advertised by an engine adapter.
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
264pub struct EngineCapabilities {
265    /// Can resume an existing conversation by id.
266    pub supports_resume: bool,
267    /// Can spawn subagents.
268    pub supports_subagents: bool,
269    /// Can import MCP server definitions.
270    pub supports_mcp_import: bool,
271}
272
273/// A raw, engine-specific conversation export (pre-normalization).
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct ConversationDump {
276    /// The engine conversation id this dump came from.
277    pub conversation_id: String,
278    /// The raw payload (usually JSON text from the engine).
279    pub raw: String,
280}
281
282/// A live (or recorded) engine process handle.
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct Session {
285    /// The engine conversation id.
286    pub conv_id: String,
287    /// OS process id, if the engine ran as a subprocess.
288    pub pid: Option<u32>,
289    /// Path to the captured log file, if any.
290    pub logfile: Option<String>,
291}
292
293/// A routing decision: which engine + which model the router chose for a task.
294///
295/// Returned by [`crate::ports::RoutingPort::route_decision`]. The default
296/// engine is `forge`; the default model is
297/// `accounts/fireworks/routers/kimi-k2p6-turbo` (Phase 1's OmniRoute target).
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299pub struct RoutingDecision {
300    /// The engine name that should handle the task (e.g. `"forge"`).
301    pub engine: String,
302    /// The model identifier the engine should use.
303    pub model: String,
304    /// Free-form rationale (provider hint, reason code, etc.).
305    #[serde(default)]
306    pub reason: Option<String>,
307}
308
309impl RoutingDecision {
310    /// The Phase 1 default decision (forge + OmniRoute kimi router).
311    pub fn default_forge_kimi() -> Self {
312        RoutingDecision {
313            engine: "forge".to_string(),
314            model: "accounts/fireworks/routers/kimi-k2p6-turbo".to_string(),
315            reason: Some("phase1-default".to_string()),
316        }
317    }
318}