Skip to main content

roder_api/
workflow.rs

1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3
4pub type WorkflowImportId = String;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
7#[serde(rename_all = "camelCase")]
8pub enum WorkflowSourceType {
9    Guidance,
10    Skill,
11    McpServer,
12    SlashCommand,
13    Hook,
14    Plugin,
15    Unknown,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "camelCase")]
20pub enum WorkflowImportState {
21    Detected,
22    Previewed,
23    Enabled,
24    Ignored,
25    Disabled,
26    Removed,
27    Stale,
28    Failed,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "camelCase")]
33pub enum WorkflowImportRisk {
34    Passive,
35    ReadsWorkspace,
36    StartsProcess,
37    RunsHook,
38    Unknown,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "camelCase")]
43pub struct WorkflowSource {
44    pub source_type: WorkflowSourceType,
45    pub path: String,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub name: Option<String>,
48    pub hash: String,
49    #[serde(with = "time::serde::rfc3339")]
50    pub detected_at: OffsetDateTime,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "camelCase")]
55pub struct WorkflowConflict {
56    pub field: String,
57    pub existing: String,
58    pub incoming: String,
59    pub detail: String,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
63#[serde(rename_all = "camelCase")]
64pub struct WorkflowImportItem {
65    pub id: WorkflowImportId,
66    pub title: String,
67    pub summary: String,
68    pub source: WorkflowSource,
69    pub state: WorkflowImportState,
70    pub risk: WorkflowImportRisk,
71    #[serde(default)]
72    pub command_capable: bool,
73    #[serde(default)]
74    pub approval_required: bool,
75    #[serde(default)]
76    pub preview: serde_json::Value,
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub conflicts: Vec<WorkflowConflict>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    #[serde(with = "time::serde::rfc3339::option")]
81    pub enabled_at: Option<OffsetDateTime>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85#[serde(rename_all = "camelCase")]
86pub enum WorkflowImportDecisionKind {
87    Preview,
88    Enable,
89    Ignore,
90    Disable,
91    Refresh,
92    Remove,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(rename_all = "camelCase")]
97pub struct WorkflowImportDecision {
98    pub item_id: WorkflowImportId,
99    pub decision: WorkflowImportDecisionKind,
100    pub source_hash: String,
101    #[serde(default)]
102    pub approved_side_effects: bool,
103    #[serde(with = "time::serde::rfc3339")]
104    pub decided_at: OffsetDateTime,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108#[serde(rename_all = "camelCase")]
109pub struct WorkflowImportScan {
110    pub workspace: String,
111    pub items: Vec<WorkflowImportItem>,
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub errors: Vec<WorkflowImportError>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
117#[serde(rename_all = "camelCase")]
118pub struct WorkflowImportError {
119    pub path: String,
120    pub message: String,
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn workflow_import_records_are_camel_case_and_source_attributed() {
129        let item = WorkflowImportItem {
130            id: "skill-demo".to_string(),
131            title: "Demo Skill".to_string(),
132            summary: "Imported skill".to_string(),
133            source: WorkflowSource {
134                source_type: WorkflowSourceType::Skill,
135                path: ".agents/skills/demo/SKILL.md".to_string(),
136                name: Some("demo".to_string()),
137                hash: "abc123".to_string(),
138                detected_at: OffsetDateTime::UNIX_EPOCH,
139            },
140            state: WorkflowImportState::Detected,
141            risk: WorkflowImportRisk::Passive,
142            command_capable: false,
143            approval_required: false,
144            preview: serde_json::json!({ "description": "safe" }),
145            conflicts: Vec::new(),
146            enabled_at: None,
147        };
148
149        let value = serde_json::to_value(&item).unwrap();
150
151        assert_eq!(value["source"]["sourceType"], "skill");
152        assert_eq!(value["source"]["detectedAt"], "1970-01-01T00:00:00Z");
153        assert_eq!(value["commandCapable"], false);
154        assert_eq!(value["source"]["path"], ".agents/skills/demo/SKILL.md");
155    }
156
157    #[test]
158    fn command_capable_imports_can_require_approval() {
159        let decision = WorkflowImportDecision {
160            item_id: "mcp-server-local".to_string(),
161            decision: WorkflowImportDecisionKind::Enable,
162            source_hash: "hash".to_string(),
163            approved_side_effects: true,
164            decided_at: OffsetDateTime::UNIX_EPOCH,
165        };
166
167        let value = serde_json::to_value(decision).unwrap();
168
169        assert_eq!(value["approvedSideEffects"], true);
170        assert_eq!(value["decision"], "enable");
171    }
172}