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}