Skip to main content

imp_core/workflow/
contract.rs

1use std::collections::BTreeSet;
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7
8/// Input used to construct a lightweight implicit workflow contract for
9/// existing imp runs.
10#[derive(Debug, Clone, PartialEq, Eq, Default)]
11pub struct ImplicitWorkflowContractInput {
12    pub objective: String,
13    pub cwd: Option<PathBuf>,
14    pub autonomy_mode: Option<AutonomyMode>,
15    pub workflow_type: Option<WorkflowType>,
16    pub risk_level: Option<RiskLevel>,
17    pub mana_unit_ref: Option<String>,
18}
19
20impl ImplicitWorkflowContractInput {
21    pub fn prompt(objective: impl Into<String>) -> Self {
22        Self {
23            objective: objective.into(),
24            ..Self::default()
25        }
26    }
27
28    pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
29        self.cwd = Some(cwd.into());
30        self
31    }
32
33    pub fn autonomy_mode(mut self, autonomy_mode: AutonomyMode) -> Self {
34        self.autonomy_mode = Some(autonomy_mode);
35        self
36    }
37
38    pub fn workflow_type(mut self, workflow_type: WorkflowType) -> Self {
39        self.workflow_type = Some(workflow_type);
40        self
41    }
42
43    pub fn risk_level(mut self, risk_level: RiskLevel) -> Self {
44        self.risk_level = Some(risk_level);
45        self
46    }
47
48    pub fn mana_unit_ref(mut self, mana_unit_ref: impl Into<String>) -> Self {
49        self.mana_unit_ref = Some(mana_unit_ref.into());
50        self
51    }
52}
53
54/// A runtime-readable declaration of what a workflow is trying to do and what
55/// constraints/proof obligations apply to it.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(default)]
58pub struct WorkflowContract {
59    pub id: Option<String>,
60    pub title: Option<String>,
61    pub objective: String,
62    pub workflow_type: WorkflowType,
63    pub risk_level: RiskLevel,
64    pub autonomy_mode: AutonomyMode,
65    pub workspace_scope: WorkspaceScope,
66    pub tool_permissions: ToolPermissionSet,
67    pub required_verification: Vec<VerificationRequirement>,
68    pub approval_requirements: Vec<ApprovalRequirement>,
69    pub trust_scope: TrustScope,
70    pub closeout_criteria: CloseoutCriteria,
71    pub mana_unit_ref: Option<String>,
72    pub parent_workflow_ref: Option<String>,
73}
74
75impl WorkflowContract {
76    pub fn implicit(objective: impl Into<String>) -> Self {
77        Self::implicit_from(ImplicitWorkflowContractInput::prompt(objective))
78    }
79
80    pub fn implicit_from(input: ImplicitWorkflowContractInput) -> Self {
81        let workspace_scope = input
82            .cwd
83            .as_deref()
84            .map(workspace_scope_for_cwd)
85            .unwrap_or_default();
86
87        Self {
88            title: title_from_objective(&input.objective),
89            objective: input.objective,
90            workflow_type: input.workflow_type.unwrap_or_default(),
91            risk_level: input.risk_level.unwrap_or_default(),
92            autonomy_mode: input.autonomy_mode.unwrap_or_default(),
93            workspace_scope,
94            mana_unit_ref: input.mana_unit_ref,
95            ..Self::default()
96        }
97    }
98
99    pub fn with_workspace_scope(mut self, workspace_scope: WorkspaceScope) -> Self {
100        self.workspace_scope = workspace_scope;
101        self
102    }
103
104    pub fn with_autonomy_mode(mut self, autonomy_mode: AutonomyMode) -> Self {
105        self.autonomy_mode = autonomy_mode;
106        self
107    }
108
109    pub fn with_mana_unit_ref(mut self, mana_unit_ref: impl Into<String>) -> Self {
110        self.mana_unit_ref = Some(mana_unit_ref.into());
111        self
112    }
113}
114
115impl Default for WorkflowContract {
116    fn default() -> Self {
117        Self {
118            id: None,
119            title: None,
120            objective: String::new(),
121            workflow_type: WorkflowType::AdHoc,
122            risk_level: RiskLevel::Unknown,
123            autonomy_mode: AutonomyMode::Safe,
124            workspace_scope: WorkspaceScope::CurrentDirectory,
125            tool_permissions: ToolPermissionSet::default(),
126            required_verification: Vec::new(),
127            approval_requirements: Vec::new(),
128            trust_scope: TrustScope::default(),
129            closeout_criteria: CloseoutCriteria::default(),
130            mana_unit_ref: None,
131            parent_workflow_ref: None,
132        }
133    }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
137#[serde(rename_all = "kebab-case")]
138pub enum WorkflowType {
139    #[default]
140    AdHoc,
141    CodeChange,
142    Investigation,
143    Review,
144    Planning,
145    Documentation,
146    Verification,
147    Orchestration,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
151#[serde(rename_all = "kebab-case")]
152pub enum RiskLevel {
153    Low,
154    Medium,
155    High,
156    Critical,
157    #[default]
158    Unknown,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
162#[serde(rename_all = "kebab-case")]
163pub enum AutonomyMode {
164    Suggest,
165    #[default]
166    Safe,
167    LocalAuto,
168    WorktreeAuto,
169    AllowAllLocal,
170    AllowAll,
171    Ci,
172}
173
174impl AutonomyMode {
175    pub const ALL: [Self; 7] = [
176        Self::Suggest,
177        Self::Safe,
178        Self::LocalAuto,
179        Self::WorktreeAuto,
180        Self::AllowAllLocal,
181        Self::AllowAll,
182        Self::Ci,
183    ];
184
185    pub fn canonical_name(self) -> &'static str {
186        match self {
187            AutonomyMode::Suggest => "suggest",
188            AutonomyMode::Safe => "safe",
189            AutonomyMode::LocalAuto => "local-auto",
190            AutonomyMode::WorktreeAuto => "worktree-auto",
191            AutonomyMode::AllowAllLocal => "allow-all-local",
192            AutonomyMode::AllowAll => "allow-all",
193            AutonomyMode::Ci => "ci",
194        }
195    }
196}
197
198impl fmt::Display for AutonomyMode {
199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200        f.write_str(self.canonical_name())
201    }
202}
203
204impl FromStr for AutonomyMode {
205    type Err = ParseAutonomyModeError;
206
207    fn from_str(value: &str) -> Result<Self, Self::Err> {
208        let normalized = value.trim().to_ascii_lowercase().replace('_', "-");
209        match normalized.as_str() {
210            "suggest" | "plan" | "planning" | "review" | "review-only" => Ok(Self::Suggest),
211            "safe" | "default" | "interactive" => Ok(Self::Safe),
212            "local" | "auto-local" | "local-auto" => Ok(Self::LocalAuto),
213            "worktree" | "auto-worktree" | "worktree-auto" => Ok(Self::WorktreeAuto),
214            "allow-all-local" | "all-local" | "local-all" | "yolo-local" => Ok(Self::AllowAllLocal),
215            "allow-all" | "all" | "yolo" => Ok(Self::AllowAll),
216            "ci" | "headless" | "noninteractive" | "non-interactive" => Ok(Self::Ci),
217            _ => Err(ParseAutonomyModeError(value.to_owned())),
218        }
219    }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct ParseAutonomyModeError(String);
224
225impl fmt::Display for ParseAutonomyModeError {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        write!(f, "unknown autonomy mode `{}`", self.0)
228    }
229}
230
231impl std::error::Error for ParseAutonomyModeError {}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
234#[serde(rename_all = "kebab-case")]
235pub enum WorkspaceScope {
236    #[default]
237    CurrentDirectory,
238    Repository {
239        root: PathBuf,
240    },
241    Worktree {
242        path: PathBuf,
243        branch: Option<String>,
244    },
245    Custom {
246        root: PathBuf,
247    },
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
251#[serde(default)]
252pub struct ToolPermissionSet {
253    pub allowed_tools: BTreeSet<String>,
254    pub denied_tools: BTreeSet<String>,
255}
256
257impl ToolPermissionSet {
258    pub fn allow(mut self, tool: impl Into<String>) -> Self {
259        self.allowed_tools.insert(normalize_tool_name(tool.into()));
260        self
261    }
262
263    pub fn deny(mut self, tool: impl Into<String>) -> Self {
264        self.denied_tools.insert(normalize_tool_name(tool.into()));
265        self
266    }
267
268    pub fn allows_all_by_default(&self) -> bool {
269        self.allowed_tools.is_empty()
270    }
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274#[serde(default)]
275pub struct VerificationRequirement {
276    pub name: Option<String>,
277    pub kind: VerificationRequirementKind,
278    pub required: bool,
279}
280
281impl VerificationRequirement {
282    pub fn command(command: impl Into<String>) -> Self {
283        Self {
284            name: None,
285            kind: VerificationRequirementKind::Command {
286                command: command.into(),
287            },
288            required: true,
289        }
290    }
291}
292
293impl Default for VerificationRequirement {
294    fn default() -> Self {
295        Self {
296            name: None,
297            kind: VerificationRequirementKind::Manual,
298            required: true,
299        }
300    }
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
304#[serde(rename_all = "kebab-case", tag = "kind")]
305pub enum VerificationRequirementKind {
306    Command {
307        command: String,
308    },
309    Diff,
310    Policy,
311    #[default]
312    Manual,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(default)]
317pub struct ApprovalRequirement {
318    pub action: ApprovalAction,
319    pub reason: Option<String>,
320    pub required: bool,
321}
322
323impl ApprovalRequirement {
324    pub fn required(action: ApprovalAction, reason: impl Into<String>) -> Self {
325        Self {
326            action,
327            reason: Some(reason.into()),
328            required: true,
329        }
330    }
331}
332
333impl Default for ApprovalRequirement {
334    fn default() -> Self {
335        Self {
336            action: ApprovalAction::HighRiskTool,
337            reason: None,
338            required: true,
339        }
340    }
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
344#[serde(rename_all = "kebab-case")]
345pub enum ApprovalAction {
346    #[default]
347    HighRiskTool,
348    Network,
349    SecretAccess,
350    OutsideWorkspaceWrite,
351    DestructiveShell,
352    DependencyChange,
353    SchemaMigration,
354    Deployment,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(default)]
359pub struct TrustScope {
360    pub allow_external_context: bool,
361    pub allow_durable_memory_writes: bool,
362    pub low_trust_requires_review: bool,
363}
364
365impl Default for TrustScope {
366    fn default() -> Self {
367        Self {
368            allow_external_context: true,
369            allow_durable_memory_writes: true,
370            low_trust_requires_review: true,
371        }
372    }
373}
374
375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
376#[serde(default)]
377pub struct CloseoutCriteria {
378    pub require_summary: bool,
379    pub require_evidence_packet: bool,
380    pub require_no_unresolved_required_verification: bool,
381    pub criteria: Vec<String>,
382}
383
384impl Default for CloseoutCriteria {
385    fn default() -> Self {
386        Self {
387            require_summary: true,
388            require_evidence_packet: false,
389            require_no_unresolved_required_verification: true,
390            criteria: Vec::new(),
391        }
392    }
393}
394
395fn workspace_scope_for_cwd(cwd: &Path) -> WorkspaceScope {
396    match std::fs::canonicalize(cwd) {
397        Ok(root) => WorkspaceScope::Repository { root },
398        Err(_) => WorkspaceScope::Repository {
399            root: cwd.to_path_buf(),
400        },
401    }
402}
403
404fn normalize_tool_name(tool: String) -> String {
405    tool.trim().to_ascii_lowercase()
406}
407
408fn title_from_objective(objective: &str) -> Option<String> {
409    let title = objective.lines().next().unwrap_or_default().trim();
410    if title.is_empty() {
411        None
412    } else {
413        Some(title.chars().take(80).collect())
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn workflow_contract_defaults_are_safe_and_lightweight() {
423        let contract = WorkflowContract::implicit("Fix the failing auth test");
424        assert_eq!(contract.objective, "Fix the failing auth test");
425        assert_eq!(contract.title.as_deref(), Some("Fix the failing auth test"));
426        assert_eq!(contract.workflow_type, WorkflowType::AdHoc);
427        assert_eq!(contract.risk_level, RiskLevel::Unknown);
428        assert_eq!(contract.autonomy_mode, AutonomyMode::Safe);
429        assert_eq!(contract.workspace_scope, WorkspaceScope::CurrentDirectory);
430        assert!(contract.tool_permissions.allows_all_by_default());
431        assert!(contract.required_verification.is_empty());
432        assert!(contract.closeout_criteria.require_summary);
433    }
434
435    #[test]
436    fn implicit_workflow_contract_uses_run_context() {
437        let temp = tempfile::TempDir::new().unwrap();
438        let contract = WorkflowContract::implicit_from(
439            ImplicitWorkflowContractInput::prompt("Fix login tests")
440                .cwd(temp.path())
441                .autonomy_mode(AutonomyMode::LocalAuto)
442                .workflow_type(WorkflowType::CodeChange)
443                .risk_level(RiskLevel::Medium),
444        );
445
446        assert_eq!(contract.objective, "Fix login tests");
447        assert_eq!(contract.title.as_deref(), Some("Fix login tests"));
448        assert_eq!(contract.autonomy_mode, AutonomyMode::LocalAuto);
449        assert_eq!(contract.workflow_type, WorkflowType::CodeChange);
450        assert_eq!(contract.risk_level, RiskLevel::Medium);
451        assert!(matches!(
452            contract.workspace_scope,
453            WorkspaceScope::Repository { .. }
454        ));
455        assert!(contract.tool_permissions.allows_all_by_default());
456        assert!(contract.closeout_criteria.require_summary);
457    }
458
459    #[test]
460    fn implicit_workflow_contract_records_mana_unit_ref() {
461        let contract = WorkflowContract::implicit_from(
462            ImplicitWorkflowContractInput::prompt("Implement mana task").mana_unit_ref("394.2.2"),
463        );
464
465        assert_eq!(contract.mana_unit_ref.as_deref(), Some("394.2.2"));
466        assert_eq!(contract.objective, "Implement mana task");
467        assert_eq!(contract.autonomy_mode, AutonomyMode::Safe);
468        assert_eq!(contract.risk_level, RiskLevel::Unknown);
469    }
470
471    #[test]
472    fn workflow_contract_serializes_with_kebab_case_modes() {
473        let contract = WorkflowContract::implicit("Refactor parser")
474            .with_autonomy_mode(AutonomyMode::LocalAuto)
475            .with_workspace_scope(WorkspaceScope::Repository {
476                root: PathBuf::from("/tmp/repo"),
477            });
478
479        let json = serde_json::to_string(&contract).expect("serialize contract");
480        assert!(json.contains("local-auto"));
481        let decoded: WorkflowContract = serde_json::from_str(&json).expect("deserialize contract");
482        assert_eq!(decoded, contract);
483    }
484
485    #[test]
486    fn autonomy_modes_have_canonical_names_and_safe_default() {
487        assert_eq!(AutonomyMode::default(), AutonomyMode::Safe);
488        let names: Vec<_> = AutonomyMode::ALL
489            .iter()
490            .map(|mode| mode.canonical_name())
491            .collect();
492        assert_eq!(
493            names,
494            vec![
495                "suggest",
496                "safe",
497                "local-auto",
498                "worktree-auto",
499                "allow-all-local",
500                "allow-all",
501                "ci"
502            ]
503        );
504        for mode in AutonomyMode::ALL {
505            assert_eq!(mode.to_string(), mode.canonical_name());
506        }
507    }
508
509    #[test]
510    fn autonomy_modes_parse_canonical_names_and_aliases() {
511        let cases = [
512            ("suggest", AutonomyMode::Suggest),
513            ("plan", AutonomyMode::Suggest),
514            ("planning", AutonomyMode::Suggest),
515            ("review-only", AutonomyMode::Suggest),
516            ("safe", AutonomyMode::Safe),
517            ("default", AutonomyMode::Safe),
518            ("interactive", AutonomyMode::Safe),
519            ("local", AutonomyMode::LocalAuto),
520            ("local_auto", AutonomyMode::LocalAuto),
521            ("auto-local", AutonomyMode::LocalAuto),
522            ("worktree", AutonomyMode::WorktreeAuto),
523            ("worktree_auto", AutonomyMode::WorktreeAuto),
524            ("auto-worktree", AutonomyMode::WorktreeAuto),
525            ("allow-all-local", AutonomyMode::AllowAllLocal),
526            ("all-local", AutonomyMode::AllowAllLocal),
527            ("local-all", AutonomyMode::AllowAllLocal),
528            ("yolo-local", AutonomyMode::AllowAllLocal),
529            ("allow-all", AutonomyMode::AllowAll),
530            ("all", AutonomyMode::AllowAll),
531            ("yolo", AutonomyMode::AllowAll),
532            ("ci", AutonomyMode::Ci),
533            ("headless", AutonomyMode::Ci),
534            ("noninteractive", AutonomyMode::Ci),
535            ("non-interactive", AutonomyMode::Ci),
536        ];
537
538        for (input, expected) in cases {
539            assert_eq!(input.parse::<AutonomyMode>().unwrap(), expected, "{input}");
540            assert_eq!(
541                input.to_ascii_uppercase().parse::<AutonomyMode>().unwrap(),
542                expected,
543                "uppercase {input}"
544            );
545        }
546        assert!("dangerous".parse::<AutonomyMode>().is_err());
547    }
548
549    #[test]
550    fn autonomy_modes_serde_roundtrip_canonical_names() {
551        for mode in AutonomyMode::ALL {
552            let json = serde_json::to_string(&mode).unwrap();
553            assert_eq!(json, format!("\"{}\"", mode.canonical_name()));
554            let decoded: AutonomyMode = serde_json::from_str(&json).unwrap();
555            assert_eq!(decoded, mode);
556        }
557    }
558
559    #[test]
560    fn autonomy_mode_parses_canonical_names_and_aliases() {
561        assert_eq!("safe".parse::<AutonomyMode>().unwrap(), AutonomyMode::Safe);
562        assert_eq!(
563            "local".parse::<AutonomyMode>().unwrap(),
564            AutonomyMode::LocalAuto
565        );
566        assert_eq!(
567            "worktree-auto".parse::<AutonomyMode>().unwrap(),
568            AutonomyMode::WorktreeAuto
569        );
570        assert_eq!(
571            "yolo".parse::<AutonomyMode>().unwrap(),
572            AutonomyMode::AllowAll
573        );
574        assert!("nope".parse::<AutonomyMode>().is_err());
575    }
576
577    #[test]
578    fn tool_permission_names_are_normalized() {
579        let perms = ToolPermissionSet::default().allow(" Read ").deny(" BASH ");
580        assert!(perms.allowed_tools.contains("read"));
581        assert!(perms.denied_tools.contains("bash"));
582    }
583
584    #[test]
585    fn verification_and_approval_requirements_round_trip() {
586        let contract = WorkflowContract {
587            required_verification: vec![VerificationRequirement::command("cargo test")],
588            approval_requirements: vec![ApprovalRequirement::required(
589                ApprovalAction::Network,
590                "fetch issue details",
591            )],
592            closeout_criteria: CloseoutCriteria {
593                criteria: vec!["targeted tests pass".to_owned()],
594                ..CloseoutCriteria::default()
595            },
596            ..WorkflowContract::implicit("Implement feature")
597        };
598
599        let value = serde_json::to_value(&contract).expect("serialize");
600        let decoded: WorkflowContract = serde_json::from_value(value).expect("deserialize");
601        assert_eq!(decoded, contract);
602    }
603}