Skip to main content

roder_api/
dynamic_workflows.rs

1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3
4use crate::events::{ThreadId, TurnId};
5use crate::inference::TokenUsage;
6use crate::subagents::{SubagentExitReason, SubagentLane};
7
8pub type WorkflowRunId = String;
9pub type WorkflowScriptId = String;
10pub type WorkflowScriptHash = String;
11pub type WorkflowPhaseId = String;
12pub type WorkflowAgentRunId = String;
13pub type WorkflowApprovalId = String;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "camelCase")]
17pub enum WorkflowScriptSourceKind {
18    Generated,
19    BuiltIn,
20    User,
21    Workspace,
22    Extension,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "camelCase")]
27pub struct WorkflowScriptSource {
28    pub kind: WorkflowScriptSourceKind,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub path: Option<String>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub command_name: Option<String>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub extension_id: Option<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38#[serde(rename_all = "camelCase")]
39pub struct WorkflowScript {
40    pub script_id: WorkflowScriptId,
41    pub name: String,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub description: Option<String>,
44    pub source: WorkflowScriptSource,
45    pub hash: WorkflowScriptHash,
46    pub host_api_version: u32,
47    #[serde(default)]
48    pub arguments_schema: serde_json::Value,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub body: Option<String>,
51    pub limits: WorkflowRunLimits,
52    #[serde(with = "time::serde::rfc3339")]
53    pub created_at: OffsetDateTime,
54    #[serde(with = "time::serde::rfc3339")]
55    pub updated_at: OffsetDateTime,
56}
57
58#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
59#[serde(rename_all = "camelCase")]
60pub enum WorkflowRunStatus {
61    Drafted,
62    AwaitingApproval,
63    Queued,
64    #[default]
65    Running,
66    Paused,
67    ApprovalWait,
68    Completed,
69    Failed,
70    Stopped,
71}
72
73#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
74#[serde(rename_all = "camelCase")]
75pub enum WorkflowPhaseStatus {
76    #[default]
77    Queued,
78    Running,
79    Completed,
80    Failed,
81    Skipped,
82}
83
84#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
85#[serde(rename_all = "camelCase")]
86pub enum WorkflowAgentStatus {
87    #[default]
88    Queued,
89    Running,
90    Completed,
91    Failed,
92    Timeout,
93    Cancelled,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
97#[serde(rename_all = "camelCase")]
98pub struct WorkflowRunLimits {
99    pub max_concurrent_agents: u32,
100    pub max_agents_per_run: u32,
101    pub default_agent_timeout_seconds: u64,
102    pub default_run_timeout_seconds: u64,
103    pub default_checkpoint_bytes: u64,
104    pub max_report_bytes: u64,
105}
106
107impl Default for WorkflowRunLimits {
108    fn default() -> Self {
109        Self {
110            max_concurrent_agents: 16,
111            max_agents_per_run: 1000,
112            default_agent_timeout_seconds: 180,
113            default_run_timeout_seconds: 14_400,
114            default_checkpoint_bytes: 1_048_576,
115            max_report_bytes: 65_536,
116        }
117    }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
121#[serde(rename_all = "camelCase")]
122pub struct WorkflowCostEstimate {
123    #[serde(default)]
124    pub min_child_agents: u32,
125    #[serde(default)]
126    pub max_child_agents: u32,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub estimated_prompt_tokens: Option<u64>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub estimated_completion_tokens: Option<u64>,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub warning: Option<String>,
133}
134
135#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
136#[serde(rename_all = "camelCase")]
137pub enum WorkflowApprovalDecision {
138    RunOnce,
139    AlwaysForScriptAndWorkspace,
140    Deny,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
144#[serde(rename_all = "camelCase")]
145pub struct WorkflowApproval {
146    pub approval_id: WorkflowApprovalId,
147    pub run_id: WorkflowRunId,
148    pub script_hash: WorkflowScriptHash,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub workspace: Option<String>,
151    pub decision: WorkflowApprovalDecision,
152    #[serde(default)]
153    pub approved_capabilities: Vec<String>,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub reason: Option<String>,
156    #[serde(with = "time::serde::rfc3339")]
157    pub decided_at: OffsetDateTime,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
161#[serde(rename_all = "camelCase")]
162pub struct WorkflowConsent {
163    pub script_hash: WorkflowScriptHash,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub workspace: Option<String>,
166    pub decision: WorkflowApprovalDecision,
167    #[serde(default)]
168    pub approved_capabilities: Vec<String>,
169    #[serde(with = "time::serde::rfc3339")]
170    pub decided_at: OffsetDateTime,
171    #[serde(default, with = "time::serde::rfc3339::option")]
172    pub expires_at: Option<OffsetDateTime>,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
176#[serde(rename_all = "camelCase")]
177pub struct WorkflowPhase {
178    pub phase_id: WorkflowPhaseId,
179    pub name: String,
180    pub status: WorkflowPhaseStatus,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub description: Option<String>,
183    #[serde(default)]
184    pub queued_agents: u32,
185    #[serde(default)]
186    pub completed_agents: u32,
187    #[serde(default)]
188    pub failed_agents: u32,
189    #[serde(default, with = "time::serde::rfc3339::option")]
190    pub started_at: Option<OffsetDateTime>,
191    #[serde(default, with = "time::serde::rfc3339::option")]
192    pub completed_at: Option<OffsetDateTime>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
196#[serde(rename_all = "camelCase")]
197pub struct WorkflowAgentRun {
198    pub agent_id: WorkflowAgentRunId,
199    pub phase_id: WorkflowPhaseId,
200    pub description: String,
201    pub status: WorkflowAgentStatus,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub lane: Option<SubagentLane>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub model: Option<String>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub thread_id: Option<ThreadId>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub turn_id: Option<TurnId>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub usage: Option<TokenUsage>,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub exit_reason: Option<SubagentExitReason>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub error: Option<String>,
216    #[serde(default, with = "time::serde::rfc3339::option")]
217    pub started_at: Option<OffsetDateTime>,
218    #[serde(default, with = "time::serde::rfc3339::option")]
219    pub completed_at: Option<OffsetDateTime>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
223#[serde(rename_all = "camelCase")]
224pub struct WorkflowRunSummary {
225    pub run_id: WorkflowRunId,
226    pub status: WorkflowRunStatus,
227    pub title: String,
228    #[serde(default)]
229    pub phase_count: u32,
230    #[serde(default)]
231    pub completed_phase_count: u32,
232    #[serde(default)]
233    pub agent_count: u32,
234    #[serde(default)]
235    pub completed_agent_count: u32,
236    #[serde(default)]
237    pub failed_agent_count: u32,
238    #[serde(default)]
239    pub concurrency_peak: u32,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub usage: Option<TokenUsage>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub elapsed_ms: Option<u64>,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub report_preview: Option<String>,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
249#[serde(rename_all = "camelCase")]
250pub struct WorkflowRun {
251    pub run_id: WorkflowRunId,
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub thread_id: Option<ThreadId>,
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub turn_id: Option<TurnId>,
256    pub script: WorkflowScript,
257    pub status: WorkflowRunStatus,
258    pub limits: WorkflowRunLimits,
259    #[serde(default)]
260    pub phases: Vec<WorkflowPhase>,
261    #[serde(default)]
262    pub agents: Vec<WorkflowAgentRun>,
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub approval: Option<WorkflowApproval>,
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub cost_estimate: Option<WorkflowCostEstimate>,
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub summary: Option<WorkflowRunSummary>,
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub error: Option<String>,
271    #[serde(with = "time::serde::rfc3339")]
272    pub created_at: OffsetDateTime,
273    #[serde(with = "time::serde::rfc3339")]
274    pub updated_at: OffsetDateTime,
275    #[serde(default, with = "time::serde::rfc3339::option")]
276    pub started_at: Option<OffsetDateTime>,
277    #[serde(default, with = "time::serde::rfc3339::option")]
278    pub completed_at: Option<OffsetDateTime>,
279}
280
281macro_rules! workflow_event {
282    ($name:ident { $($field:ident : $ty:ty),* $(,)? }) => {
283        #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
284        #[serde(rename_all = "camelCase")]
285        pub struct $name {
286            pub run_id: WorkflowRunId,
287            #[serde(default, skip_serializing_if = "Option::is_none")]
288            pub thread_id: Option<ThreadId>,
289            #[serde(default, skip_serializing_if = "Option::is_none")]
290            pub turn_id: Option<TurnId>,
291            $(pub $field: $ty,)*
292            #[serde(with = "time::serde::rfc3339")]
293            pub timestamp: OffsetDateTime,
294        }
295    };
296}
297
298workflow_event!(WorkflowRunDrafted { run: WorkflowRun });
299workflow_event!(WorkflowApprovalRequested {
300    approval_id: WorkflowApprovalId,
301    run: WorkflowRun
302});
303workflow_event!(WorkflowRunApproved {
304    approval: WorkflowApproval
305});
306workflow_event!(WorkflowRunDenied {
307    approval: WorkflowApproval
308});
309workflow_event!(WorkflowRunQueued {
310    status: WorkflowRunStatus
311});
312workflow_event!(WorkflowRunStarted {
313    status: WorkflowRunStatus
314});
315workflow_event!(WorkflowPhaseStarted {
316    phase: WorkflowPhase
317});
318workflow_event!(WorkflowPhaseCompleted {
319    phase: WorkflowPhase
320});
321workflow_event!(WorkflowAgentQueued {
322    agent: WorkflowAgentRun
323});
324workflow_event!(WorkflowAgentStarted {
325    agent: WorkflowAgentRun
326});
327workflow_event!(WorkflowAgentCompleted {
328    agent: WorkflowAgentRun
329});
330workflow_event!(WorkflowAgentFailed {
331    agent: WorkflowAgentRun,
332    error: String
333});
334workflow_event!(WorkflowOutputRecorded { phase_id: Option<WorkflowPhaseId>, output: String, truncated: bool });
335workflow_event!(WorkflowCheckpointRecorded { phase_id: Option<WorkflowPhaseId>, key: String, byte_count: u64 });
336workflow_event!(WorkflowRunPaused { reason: Option<String> });
337workflow_event!(WorkflowRunResumed {
338    status: WorkflowRunStatus
339});
340workflow_event!(WorkflowRunStopped { reason: Option<String> });
341workflow_event!(WorkflowRunCompleted {
342    summary: WorkflowRunSummary
343});
344workflow_event!(WorkflowRunFailed { error: String, summary: Option<WorkflowRunSummary> });
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    fn script() -> WorkflowScript {
351        WorkflowScript {
352            script_id: "script-1".to_string(),
353            name: "audit".to_string(),
354            description: Some("Audit the repo".to_string()),
355            source: WorkflowScriptSource {
356                kind: WorkflowScriptSourceKind::Generated,
357                path: None,
358                command_name: None,
359                extension_id: None,
360            },
361            hash: "sha256:abc".to_string(),
362            host_api_version: 1,
363            arguments_schema: serde_json::json!({"type": "object"}),
364            body: None,
365            limits: WorkflowRunLimits::default(),
366            created_at: OffsetDateTime::UNIX_EPOCH,
367            updated_at: OffsetDateTime::UNIX_EPOCH,
368        }
369    }
370
371    #[test]
372    fn workflow_run_uses_camel_case_wire_shape() {
373        let run = WorkflowRun {
374            run_id: "run-1".to_string(),
375            thread_id: Some("thread-1".to_string()),
376            turn_id: Some("turn-1".to_string()),
377            script: script(),
378            status: WorkflowRunStatus::AwaitingApproval,
379            limits: WorkflowRunLimits::default(),
380            phases: vec![WorkflowPhase {
381                phase_id: "phase-1".to_string(),
382                name: "Scout".to_string(),
383                status: WorkflowPhaseStatus::Queued,
384                description: None,
385                queued_agents: 2,
386                completed_agents: 0,
387                failed_agents: 0,
388                started_at: None,
389                completed_at: None,
390            }],
391            agents: vec![WorkflowAgentRun {
392                agent_id: "agent-1".to_string(),
393                phase_id: "phase-1".to_string(),
394                description: "Inspect crate".to_string(),
395                status: WorkflowAgentStatus::Queued,
396                lane: Some(SubagentLane::Scout),
397                model: Some("mock".to_string()),
398                thread_id: None,
399                turn_id: None,
400                usage: None,
401                exit_reason: None,
402                error: None,
403                started_at: None,
404                completed_at: None,
405            }],
406            approval: None,
407            cost_estimate: Some(WorkflowCostEstimate {
408                min_child_agents: 2,
409                max_child_agents: 4,
410                estimated_prompt_tokens: Some(1000),
411                estimated_completion_tokens: Some(500),
412                warning: Some("workflow may fan out".to_string()),
413            }),
414            summary: None,
415            error: None,
416            created_at: OffsetDateTime::UNIX_EPOCH,
417            updated_at: OffsetDateTime::UNIX_EPOCH,
418            started_at: None,
419            completed_at: None,
420        };
421
422        let value = serde_json::to_value(&run).unwrap();
423
424        assert_eq!(value["runId"], "run-1");
425        assert_eq!(value["threadId"], "thread-1");
426        assert_eq!(value["status"], "awaitingApproval");
427        assert_eq!(value["script"]["hostApiVersion"], 1);
428        assert_eq!(value["phases"][0]["queuedAgents"], 2);
429        assert_eq!(value["agents"][0]["lane"], "scout");
430        assert_eq!(value["costEstimate"]["maxChildAgents"], 4);
431
432        let decoded: WorkflowRun = serde_json::from_value(value).unwrap();
433        assert_eq!(decoded, run);
434    }
435}