Skip to main content

tandem_server/automation_v2/
types.rs

1use std::collections::HashMap;
2
3use serde::de::DeserializeOwned;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use tandem_orchestrator::KnowledgeBinding;
7use tandem_plan_compiler::api::{
8    ContextObject, PlanScopeSnapshot, PlanValidationReport,
9    ProjectedAutomationContextMaterialization, ProjectedRoutineContextPartition,
10    ProjectedStepContextBindings,
11};
12use tandem_types::TenantContext;
13
14use crate::routines::types::RoutineMisfirePolicy;
15
16pub type AutomationV2Schedule =
17    tandem_workflows::plan_package::AutomationV2Schedule<RoutineMisfirePolicy>;
18pub use tandem_workflows::plan_package::AutomationV2ScheduleType;
19
20pub type WorkflowPlanStep = tandem_workflows::plan_package::WorkflowPlanStep<
21    AutomationFlowInputRef,
22    AutomationFlowOutputContract,
23>;
24pub type WorkflowPlan =
25    tandem_workflows::plan_package::WorkflowPlan<AutomationV2Schedule, WorkflowPlanStep>;
26pub use tandem_workflows::plan_package::{WorkflowPlanChatMessage, WorkflowPlanConversation};
27pub type WorkflowPlanDraftRecord =
28    tandem_workflows::plan_package::WorkflowPlanDraftRecord<WorkflowPlan>;
29pub type AutomationRuntimeContextMaterialization = ProjectedAutomationContextMaterialization;
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "snake_case")]
33pub enum AutomationV2Status {
34    Active,
35    Paused,
36    Draft,
37}
38
39// ---------------------------------------------------------------------------
40// Connected-agent coordination types
41// ---------------------------------------------------------------------------
42
43/// A file-based handoff envelope written by an upstream automation and consumed
44/// by a downstream automation. Deposited in the workspace `shared/handoffs/`
45/// directory and processed by the scheduler's watch-condition loop.
46///
47/// Lifecycle: `inbox/` → (auto-approve) → `approved/` → (consumed) → `archived/`.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct HandoffArtifact {
50    /// Stable unique ID for this handoff, e.g. `hoff-20260406-<uuid>`.
51    pub handoff_id: String,
52    /// The automation that produced this handoff.
53    pub source_automation_id: String,
54    /// The run that produced this handoff.
55    pub source_run_id: String,
56    /// The node within that run that produced this handoff.
57    pub source_node_id: String,
58    /// The downstream automation that should consume this handoff.
59    /// The watch evaluator enforces this match.
60    pub target_automation_id: String,
61    /// Semantic type of the artifact, e.g. `"shortlist"`, `"brief"`, `"report"`.
62    /// Used to match against watch condition `artifact_type` filters.
63    pub artifact_type: String,
64    /// Unix epoch milliseconds when the handoff was created.
65    pub created_at_ms: u64,
66    /// Relative path (from workspace root) of the real content file.
67    /// For example `"job-search/shortlists/2026-04-06.md"`.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub content_path: Option<String>,
70    /// SHA-256 hex digest of the content at `content_path`, if computed.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub content_digest: Option<String>,
73    /// Arbitrary operator-controlled metadata.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub metadata: Option<serde_json::Value>,
76    // --- Fields added when the handoff is consumed and the file is archived ---
77    /// The run ID of the automation that consumed this handoff.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub consumed_by_run_id: Option<String>,
80    /// The automation ID of the consumer (mirrors `target_automation_id`).
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub consumed_by_automation_id: Option<String>,
83    /// Unix epoch milliseconds when the handoff was consumed.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub consumed_at_ms: Option<u64>,
86}
87
88/// The kind of watch condition. Only `HandoffAvailable` is implemented in Phase 1.
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90#[serde(rename_all = "snake_case", tag = "kind")]
91pub enum WatchCondition {
92    /// Fire when at least one handoff artifact is available in the `approved/`
93    /// directory that matches all specified filter fields.
94    HandoffAvailable {
95        /// Optional filter: only match handoffs from this source automation.
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        source_automation_id: Option<String>,
98        /// Optional filter: only match handoffs with this `artifact_type` value.
99        #[serde(default, skip_serializing_if = "Option::is_none")]
100        artifact_type: Option<String>,
101    },
102    // Phase 2: FileExists, FlagSet, UpstreamCompleted
103}
104
105/// Per-automation filesystem scope restriction.
106///
107/// When present, all paths accessed by agents in this automation are validated
108/// against this policy in addition to the existing workspace-root sandbox.
109/// Paths are relative to `workspace_root`.
110///
111/// If absent, the automation has full workspace-root access (backward-compatible).
112#[derive(Debug, Clone, Serialize, Deserialize, Default)]
113pub struct AutomationScopePolicy {
114    /// Paths readable by agents in this automation.
115    /// An empty list means "inherit workspace root" (no extra restriction).
116    #[serde(default)]
117    pub readable_paths: Vec<String>,
118    /// Paths writable by agents in this automation.
119    /// A write-allowed path is implicitly also readable.
120    #[serde(default)]
121    pub writable_paths: Vec<String>,
122    /// Paths explicitly denied even if they fall inside readable/writable.
123    /// Deny-wins: this list is checked first.
124    #[serde(default)]
125    pub denied_paths: Vec<String>,
126    /// Paths the scheduler watch evaluator may scan on behalf of this automation.
127    /// Defaults to readable_paths. Watching does not grant write access.
128    #[serde(default)]
129    pub watch_paths: Vec<String>,
130}
131
132impl AutomationScopePolicy {
133    /// Returns `true` if this policy is effectively unrestricted (all lists empty).
134    pub fn is_open(&self) -> bool {
135        self.readable_paths.is_empty()
136            && self.writable_paths.is_empty()
137            && self.denied_paths.is_empty()
138    }
139
140    /// Check whether `path` (relative to workspace root) is readable under this
141    /// policy. Returns `Err(reason)` if the access is denied.
142    ///
143    /// Rules (evaluated in order):
144    /// 1. If `path` is covered by `denied_paths` → deny.
145    /// 2. If `writable_paths` is non-empty and `path` is covered → allow.
146    /// 3. If `readable_paths` is non-empty and `path` is covered → allow.
147    /// 4. If both `readable_paths` and `writable_paths` are empty → allow (open policy).
148    /// 5. Otherwise → deny.
149    pub fn check_read(&self, path: &str) -> Result<(), String> {
150        let path = path.trim_start_matches('/');
151        if self.path_is_denied(path) {
152            return Err(format!(
153                "scope policy: read denied for `{path}` (path is in denied_paths)"
154            ));
155        }
156        if self.readable_paths.is_empty() && self.writable_paths.is_empty() {
157            return Ok(()); // open policy
158        }
159        if self.path_is_readable(path) || self.path_is_writable(path) {
160            return Ok(());
161        }
162        Err(format!(
163            "scope policy: read denied for `{path}` (not in readable_paths or writable_paths)"
164        ))
165    }
166
167    /// Check whether `path` is writable under this policy.
168    pub fn check_write(&self, path: &str) -> Result<(), String> {
169        let path = path.trim_start_matches('/');
170        if self.path_is_denied(path) {
171            return Err(format!(
172                "scope policy: write denied for `{path}` (path is in denied_paths)"
173            ));
174        }
175        if self.writable_paths.is_empty() {
176            return Ok(()); // no write restriction
177        }
178        if self.path_is_writable(path) {
179            return Ok(());
180        }
181        Err(format!(
182            "scope policy: write denied for `{path}` (not in writable_paths)"
183        ))
184    }
185
186    /// Check whether `path` is scannable by the watch evaluator.
187    pub fn check_watch(&self, path: &str) -> Result<(), String> {
188        let path = path.trim_start_matches('/');
189        if self.path_is_denied(path) {
190            return Err(format!(
191                "scope policy: watch denied for `{path}` (path is in denied_paths)"
192            ));
193        }
194        let watch_paths = if self.watch_paths.is_empty() {
195            &self.readable_paths
196        } else {
197            &self.watch_paths
198        };
199        if watch_paths.is_empty() {
200            return Ok(()); // open watch policy
201        }
202        if watch_paths
203            .iter()
204            .any(|prefix| scope_path_matches_prefix(path, prefix))
205        {
206            return Ok(());
207        }
208        Err(format!(
209            "scope policy: watch denied for `{path}` (not in watch_paths / readable_paths)"
210        ))
211    }
212
213    fn path_is_denied(&self, path: &str) -> bool {
214        self.denied_paths
215            .iter()
216            .any(|prefix| scope_path_matches_prefix(path, prefix))
217    }
218
219    fn path_is_readable(&self, path: &str) -> bool {
220        self.readable_paths
221            .iter()
222            .any(|prefix| scope_path_matches_prefix(path, prefix))
223    }
224
225    fn path_is_writable(&self, path: &str) -> bool {
226        self.writable_paths
227            .iter()
228            .any(|prefix| scope_path_matches_prefix(path, prefix))
229    }
230}
231
232/// Returns true if `path` is equal to `prefix` or starts with `prefix + "/"`.
233fn scope_path_matches_prefix(path: &str, prefix: &str) -> bool {
234    let prefix = prefix.trim_matches('/');
235    let path = path.trim_matches('/');
236    path == prefix || path.starts_with(&format!("{prefix}/"))
237}
238
239/// Per-automation handoff directory configuration.
240///
241/// Paths are relative to `workspace_root` (or the automation's scoped workspace).
242/// Defaults follow the standard layout: `shared/handoffs/{inbox,approved,archived}`.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct AutomationHandoffConfig {
245    /// Directory where newly created handoffs are deposited.
246    /// Default: `"shared/handoffs/inbox"`
247    #[serde(default = "default_handoff_inbox_dir")]
248    pub inbox_dir: String,
249    /// Directory where approved handoffs wait for consumption.
250    /// Default: `"shared/handoffs/approved"`
251    #[serde(default = "default_handoff_approved_dir")]
252    pub approved_dir: String,
253    /// Directory where consumed handoffs are archived.
254    /// Default: `"shared/handoffs/archived"`
255    #[serde(default = "default_handoff_archived_dir")]
256    pub archived_dir: String,
257    /// When `true`, newly created handoffs bypass the approval step and are
258    /// moved directly from `inbox/` to `approved/`. Default: `true` (Phase 1).
259    #[serde(default = "default_auto_approve")]
260    pub auto_approve: bool,
261}
262
263fn default_handoff_inbox_dir() -> String {
264    "shared/handoffs/inbox".to_string()
265}
266fn default_handoff_approved_dir() -> String {
267    "shared/handoffs/approved".to_string()
268}
269fn default_handoff_archived_dir() -> String {
270    "shared/handoffs/archived".to_string()
271}
272fn default_auto_approve() -> bool {
273    true
274}
275
276impl Default for AutomationHandoffConfig {
277    fn default() -> Self {
278        Self {
279            inbox_dir: default_handoff_inbox_dir(),
280            approved_dir: default_handoff_approved_dir(),
281            archived_dir: default_handoff_archived_dir(),
282            auto_approve: default_auto_approve(),
283        }
284    }
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct AutomationAgentToolPolicy {
289    #[serde(default)]
290    pub allowlist: Vec<String>,
291    #[serde(default)]
292    pub denylist: Vec<String>,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct AutomationAgentMcpPolicy {
297    #[serde(default)]
298    pub allowed_servers: Vec<String>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub allowed_tools: Option<Vec<String>>,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct AutomationAgentProfile {
305    pub agent_id: String,
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub template_id: Option<String>,
308    pub display_name: String,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub avatar_url: Option<String>,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub model_policy: Option<Value>,
313    #[serde(default)]
314    pub skills: Vec<String>,
315    pub tool_policy: AutomationAgentToolPolicy,
316    pub mcp_policy: AutomationAgentMcpPolicy,
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub approval_policy: Option<String>,
319}
320
321impl From<tandem_plan_compiler::api::ProjectedAutomationAgentProfile> for AutomationAgentProfile {
322    fn from(value: tandem_plan_compiler::api::ProjectedAutomationAgentProfile) -> Self {
323        Self {
324            agent_id: value.agent_id,
325            template_id: value.template_id,
326            display_name: value.display_name,
327            avatar_url: None,
328            model_policy: value.model_policy,
329            skills: Vec::new(),
330            tool_policy: AutomationAgentToolPolicy {
331                allowlist: value.tool_allowlist,
332                denylist: Vec::new(),
333            },
334            mcp_policy: AutomationAgentMcpPolicy {
335                allowed_servers: value.allowed_mcp_servers,
336                allowed_tools: None,
337            },
338            approval_policy: None,
339        }
340    }
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
344#[serde(rename_all = "snake_case")]
345pub enum AutomationNodeStageKind {
346    Orchestrator,
347    Workstream,
348    Review,
349    Test,
350    Approval,
351}
352
353impl From<tandem_plan_compiler::api::ProjectedAutomationStageKind> for AutomationNodeStageKind {
354    fn from(value: tandem_plan_compiler::api::ProjectedAutomationStageKind) -> Self {
355        match value {
356            tandem_plan_compiler::api::ProjectedAutomationStageKind::Workstream => Self::Workstream,
357            tandem_plan_compiler::api::ProjectedAutomationStageKind::Review => Self::Review,
358            tandem_plan_compiler::api::ProjectedAutomationStageKind::Test => Self::Test,
359            tandem_plan_compiler::api::ProjectedAutomationStageKind::Approval => Self::Approval,
360        }
361    }
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct AutomationApprovalGate {
366    #[serde(default)]
367    pub required: bool,
368    #[serde(default)]
369    pub decisions: Vec<String>,
370    #[serde(default)]
371    pub rework_targets: Vec<String>,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub instructions: Option<String>,
374}
375
376impl From<tandem_plan_compiler::api::ProjectedAutomationApprovalGate> for AutomationApprovalGate {
377    fn from(value: tandem_plan_compiler::api::ProjectedAutomationApprovalGate) -> Self {
378        Self {
379            required: value.required,
380            decisions: value.decisions,
381            rework_targets: value.rework_targets,
382            instructions: value.instructions,
383        }
384    }
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct AutomationFlowNode {
389    pub node_id: String,
390    pub agent_id: String,
391    pub objective: String,
392    #[serde(default)]
393    pub knowledge: KnowledgeBinding,
394    #[serde(default)]
395    pub depends_on: Vec<String>,
396    #[serde(default)]
397    pub input_refs: Vec<AutomationFlowInputRef>,
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub output_contract: Option<AutomationFlowOutputContract>,
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub retry_policy: Option<Value>,
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub timeout_ms: Option<u64>,
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub max_tool_calls: Option<u32>,
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub stage_kind: Option<AutomationNodeStageKind>,
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub gate: Option<AutomationApprovalGate>,
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub metadata: Option<Value>,
412}
413
414impl<I, O> From<tandem_plan_compiler::api::ProjectedAutomationNode<I, O>> for AutomationFlowNode
415where
416    I: Into<AutomationFlowInputRef>,
417    O: Into<AutomationFlowOutputContract>,
418{
419    fn from(value: tandem_plan_compiler::api::ProjectedAutomationNode<I, O>) -> Self {
420        fn knowledge_from_metadata(metadata: Option<&Value>, objective: &str) -> KnowledgeBinding {
421            let mut binding = KnowledgeBinding::default();
422            if let Some(parsed) = metadata
423                .and_then(|metadata| metadata.get("builder"))
424                .and_then(Value::as_object)
425                .and_then(|builder| builder.get("knowledge"))
426                .cloned()
427                .and_then(|value| serde_json::from_value::<KnowledgeBinding>(value).ok())
428            {
429                binding = parsed;
430            }
431            if binding
432                .subject
433                .as_deref()
434                .map(str::trim)
435                .unwrap_or("")
436                .is_empty()
437            {
438                let subject = objective.trim();
439                if !subject.is_empty() {
440                    binding.subject = Some(subject.to_string());
441                }
442            }
443            binding
444        }
445
446        let objective = value.objective;
447        let knowledge = knowledge_from_metadata(value.metadata.as_ref(), &objective);
448
449        Self {
450            node_id: value.node_id,
451            agent_id: value.agent_id,
452            objective,
453            knowledge,
454            depends_on: value.depends_on,
455            input_refs: value.input_refs.into_iter().map(Into::into).collect(),
456            output_contract: value.output_contract.map(Into::into),
457            retry_policy: value.retry_policy,
458            timeout_ms: value.timeout_ms,
459            max_tool_calls: None,
460            stage_kind: value.stage_kind.map(Into::into),
461            gate: value.gate.map(Into::into),
462            metadata: value.metadata,
463        }
464    }
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize, Default)]
468pub struct AutomationFlowInputRef {
469    pub from_step_id: String,
470    pub alias: String,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize, Default)]
474pub struct AutomationFlowOutputContract {
475    pub kind: String,
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub validator: Option<AutomationOutputValidatorKind>,
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub enforcement: Option<AutomationOutputEnforcement>,
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub schema: Option<Value>,
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub summary_guidance: Option<String>,
484}
485
486impl From<tandem_plan_compiler::api::ProjectedMissionInputRef> for AutomationFlowInputRef {
487    fn from(value: tandem_plan_compiler::api::ProjectedMissionInputRef) -> Self {
488        Self {
489            from_step_id: value.from_step_id,
490            alias: value.alias,
491        }
492    }
493}
494
495impl tandem_plan_compiler::api::WorkflowInputRefLike for AutomationFlowInputRef {
496    fn from_step_id(&self) -> &str {
497        self.from_step_id.as_str()
498    }
499}
500
501impl From<tandem_plan_compiler::api::OutputContractSeed> for AutomationFlowOutputContract {
502    fn from(value: tandem_plan_compiler::api::OutputContractSeed) -> Self {
503        Self {
504            kind: value.kind,
505            validator: value.validator_kind.map(|kind| match kind {
506                tandem_plan_compiler::api::ProjectedOutputValidatorKind::ResearchBrief => {
507                    AutomationOutputValidatorKind::ResearchBrief
508                }
509                tandem_plan_compiler::api::ProjectedOutputValidatorKind::ReviewDecision => {
510                    AutomationOutputValidatorKind::ReviewDecision
511                }
512                tandem_plan_compiler::api::ProjectedOutputValidatorKind::StructuredJson => {
513                    AutomationOutputValidatorKind::StructuredJson
514                }
515                tandem_plan_compiler::api::ProjectedOutputValidatorKind::CodePatch => {
516                    AutomationOutputValidatorKind::CodePatch
517                }
518                tandem_plan_compiler::api::ProjectedOutputValidatorKind::GenericArtifact => {
519                    AutomationOutputValidatorKind::GenericArtifact
520                }
521            }),
522            enforcement: value
523                .enforcement
524                .and_then(|raw| serde_json::from_value(raw).ok()),
525            schema: value.schema,
526            summary_guidance: value.summary_guidance,
527        }
528    }
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
532pub struct AutomationOutputEnforcement {
533    #[serde(default, skip_serializing_if = "Option::is_none")]
534    pub validation_profile: Option<String>,
535    #[serde(default)]
536    pub required_tools: Vec<String>,
537    #[serde(default)]
538    pub required_evidence: Vec<String>,
539    #[serde(default)]
540    pub required_sections: Vec<String>,
541    #[serde(default)]
542    pub prewrite_gates: Vec<String>,
543    #[serde(default)]
544    pub retry_on_missing: Vec<String>,
545    #[serde(default)]
546    pub terminal_on: Vec<String>,
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub repair_budget: Option<u32>,
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    pub session_text_recovery: Option<String>,
551}
552
553#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
554#[serde(rename_all = "snake_case")]
555pub enum AutomationOutputValidatorKind {
556    CodePatch,
557    ResearchBrief,
558    ReviewDecision,
559    StructuredJson,
560    GenericArtifact,
561    /// Standup participant nodes. Produces a JSON object with `yesterday`, `today`, and
562    /// `blockers` fields. Status detection short-circuits all review-approval and
563    /// research-brief logic for this kind — participants either complete or need repair.
564    StandupUpdate,
565}
566
567impl AutomationOutputValidatorKind {
568    pub fn stable_key(self) -> &'static str {
569        match self {
570            Self::CodePatch => "code_patch",
571            Self::ResearchBrief => "research_brief",
572            Self::ReviewDecision => "review_decision",
573            Self::StructuredJson => "structured_json",
574            Self::GenericArtifact => "generic_artifact",
575            Self::StandupUpdate => "standup_update",
576        }
577    }
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
581pub struct AutomationFlowSpec {
582    #[serde(default)]
583    pub nodes: Vec<AutomationFlowNode>,
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize)]
587pub struct AutomationExecutionPolicy {
588    #[serde(default, skip_serializing_if = "Option::is_none")]
589    pub max_parallel_agents: Option<u32>,
590    #[serde(default, skip_serializing_if = "Option::is_none")]
591    pub max_total_runtime_ms: Option<u64>,
592    #[serde(default, skip_serializing_if = "Option::is_none")]
593    pub max_total_tool_calls: Option<u32>,
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub max_total_tokens: Option<u64>,
596    #[serde(default, skip_serializing_if = "Option::is_none")]
597    pub max_total_cost_usd: Option<f64>,
598}
599
600impl From<tandem_plan_compiler::api::ProjectedAutomationExecutionPolicy>
601    for AutomationExecutionPolicy
602{
603    fn from(value: tandem_plan_compiler::api::ProjectedAutomationExecutionPolicy) -> Self {
604        Self {
605            max_parallel_agents: value.max_parallel_agents,
606            max_total_runtime_ms: value.max_total_runtime_ms,
607            max_total_tool_calls: value.max_total_tool_calls,
608            max_total_tokens: value.max_total_tokens,
609            max_total_cost_usd: value.max_total_cost_usd,
610        }
611    }
612}
613
614impl AutomationV2Spec {
615    fn metadata_value<T>(&self, key: &str) -> Option<T>
616    where
617        T: DeserializeOwned,
618    {
619        self.metadata
620            .as_ref()
621            .and_then(|metadata| metadata.get(key).cloned())
622            .and_then(|value| serde_json::from_value(value).ok())
623    }
624
625    pub fn runtime_context_materialization(
626        &self,
627    ) -> Option<AutomationRuntimeContextMaterialization> {
628        self.metadata_value("context_materialization")
629    }
630
631    pub fn approved_plan_runtime_context_materialization(
632        &self,
633    ) -> Option<AutomationRuntimeContextMaterialization> {
634        let approved_plan = self.approved_plan_materialization()?;
635        let scope_snapshot = self.plan_scope_snapshot_materialization()?;
636        let context_objects = scope_snapshot
637            .context_objects
638            .into_iter()
639            .map(|context_object: ContextObject| {
640                (context_object.context_object_id.clone(), context_object)
641            })
642            .collect::<HashMap<_, _>>();
643        let routines = approved_plan
644            .routines
645            .into_iter()
646            .map(|routine| ProjectedRoutineContextPartition {
647                routine_id: routine.routine_id,
648                visible_context_objects: routine
649                    .visible_context_object_ids
650                    .into_iter()
651                    .filter_map(|context_object_id| {
652                        context_objects.get(&context_object_id).cloned()
653                    })
654                    .collect(),
655                step_context_bindings: routine
656                    .step_context_bindings
657                    .into_iter()
658                    .map(|binding| ProjectedStepContextBindings {
659                        step_id: binding.step_id,
660                        context_reads: binding.context_reads,
661                        context_writes: binding.context_writes,
662                    })
663                    .collect(),
664            })
665            .collect();
666        Some(AutomationRuntimeContextMaterialization { routines })
667    }
668
669    pub fn requires_runtime_context(&self) -> bool {
670        self.runtime_context_materialization().is_some()
671            || self.approved_plan_materialization().is_some()
672            || !crate::http::context_packs::shared_context_pack_ids_from_metadata(
673                self.metadata.as_ref(),
674            )
675            .is_empty()
676    }
677
678    pub fn plan_scope_snapshot_materialization(&self) -> Option<PlanScopeSnapshot> {
679        self.metadata
680            .as_ref()
681            .and_then(|metadata| metadata.get("plan_package_bundle"))
682            .and_then(|bundle| bundle.get("scope_snapshot"))
683            .cloned()
684            .and_then(|value| serde_json::from_value(value).ok())
685    }
686
687    pub(crate) fn plan_package_validation_report(&self) -> Option<PlanValidationReport> {
688        self.metadata_value("plan_package_validation")
689    }
690
691    pub(crate) fn approved_plan_materialization(
692        &self,
693    ) -> Option<tandem_plan_compiler::api::ApprovedPlanMaterialization> {
694        self.metadata_value("approved_plan_materialization")
695    }
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize)]
699pub struct AutomationV2Spec {
700    pub automation_id: String,
701    pub name: String,
702    #[serde(default, skip_serializing_if = "Option::is_none")]
703    pub description: Option<String>,
704    pub status: AutomationV2Status,
705    pub schedule: AutomationV2Schedule,
706    #[serde(default)]
707    pub knowledge: KnowledgeBinding,
708    #[serde(default)]
709    pub agents: Vec<AutomationAgentProfile>,
710    pub flow: AutomationFlowSpec,
711    pub execution: AutomationExecutionPolicy,
712    #[serde(default)]
713    pub output_targets: Vec<String>,
714    pub created_at_ms: u64,
715    pub updated_at_ms: u64,
716    pub creator_id: String,
717    #[serde(default, skip_serializing_if = "Option::is_none")]
718    pub workspace_root: Option<String>,
719    #[serde(default, skip_serializing_if = "Option::is_none")]
720    pub metadata: Option<Value>,
721    #[serde(default, skip_serializing_if = "Option::is_none")]
722    pub next_fire_at_ms: Option<u64>,
723    #[serde(default, skip_serializing_if = "Option::is_none")]
724    pub last_fired_at_ms: Option<u64>,
725    /// Optional per-automation filesystem scope restrictions.
726    /// When absent, the automation has full workspace-root access (backward-compatible).
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub scope_policy: Option<AutomationScopePolicy>,
729    /// Watch conditions evaluated by the scheduler on each tick.
730    /// When any condition matches, a new run is created with `trigger_type: "watch_condition"`.
731    #[serde(default, skip_serializing_if = "Vec::is_empty")]
732    pub watch_conditions: Vec<WatchCondition>,
733    /// Handoff directory configuration. Uses defaults if absent.
734    #[serde(default, skip_serializing_if = "Option::is_none")]
735    pub handoff_config: Option<AutomationHandoffConfig>,
736}
737
738impl AutomationV2Spec {
739    /// Returns the effective handoff config, using defaults if none is set.
740    pub fn effective_handoff_config(&self) -> AutomationHandoffConfig {
741        self.handoff_config.clone().unwrap_or_default()
742    }
743
744    /// Returns true if this automation has any watch conditions configured.
745    pub fn has_watch_conditions(&self) -> bool {
746        !self.watch_conditions.is_empty()
747    }
748}
749
750#[derive(Debug, Clone, Serialize, Deserialize)]
751pub struct AutomationNodeOutput {
752    pub contract_kind: String,
753    #[serde(default, skip_serializing_if = "Option::is_none")]
754    pub validator_kind: Option<AutomationOutputValidatorKind>,
755    #[serde(default, skip_serializing_if = "Option::is_none")]
756    pub validator_summary: Option<AutomationValidatorSummary>,
757    pub summary: String,
758    pub content: Value,
759    pub created_at_ms: u64,
760    pub node_id: String,
761    #[serde(default, skip_serializing_if = "Option::is_none")]
762    pub status: Option<String>,
763    #[serde(default, skip_serializing_if = "Option::is_none")]
764    pub blocked_reason: Option<String>,
765    #[serde(default, skip_serializing_if = "Option::is_none")]
766    pub approved: Option<bool>,
767    #[serde(default, skip_serializing_if = "Option::is_none")]
768    pub workflow_class: Option<String>,
769    #[serde(default, skip_serializing_if = "Option::is_none")]
770    pub phase: Option<String>,
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub failure_kind: Option<String>,
773    #[serde(default, skip_serializing_if = "Option::is_none")]
774    pub tool_telemetry: Option<Value>,
775    #[serde(default, skip_serializing_if = "Option::is_none")]
776    pub preflight: Option<Value>,
777    #[serde(default, skip_serializing_if = "Option::is_none")]
778    pub knowledge_preflight: Option<Value>,
779    #[serde(default, skip_serializing_if = "Option::is_none")]
780    pub capability_resolution: Option<Value>,
781    #[serde(default, skip_serializing_if = "Option::is_none")]
782    pub attempt_evidence: Option<Value>,
783    #[serde(default, skip_serializing_if = "Option::is_none")]
784    pub blocker_category: Option<String>,
785    #[serde(default, skip_serializing_if = "Option::is_none")]
786    pub receipt_timeline: Option<Value>,
787    #[serde(default, skip_serializing_if = "Option::is_none")]
788    pub quality_mode: Option<String>,
789    #[serde(default, skip_serializing_if = "Option::is_none")]
790    pub requested_quality_mode: Option<String>,
791    #[serde(default, skip_serializing_if = "Option::is_none")]
792    pub emergency_rollback_enabled: Option<bool>,
793    #[serde(default, skip_serializing_if = "Option::is_none")]
794    pub fallback_used: Option<bool>,
795    #[serde(default, skip_serializing_if = "Option::is_none")]
796    pub artifact_validation: Option<Value>,
797    #[serde(default, skip_serializing_if = "Option::is_none")]
798    pub provenance: Option<AutomationNodeOutputProvenance>,
799}
800
801#[derive(Debug, Clone, Serialize, Deserialize)]
802pub struct AutomationValidatorSummary {
803    pub kind: AutomationOutputValidatorKind,
804    pub outcome: String,
805    #[serde(default, skip_serializing_if = "Option::is_none")]
806    pub reason: Option<String>,
807    #[serde(default)]
808    pub unmet_requirements: Vec<String>,
809    #[serde(default)]
810    pub warning_requirements: Vec<String>,
811    #[serde(default)]
812    pub warning_count: u32,
813    #[serde(default, skip_serializing_if = "Option::is_none")]
814    pub accepted_candidate_source: Option<String>,
815    #[serde(default, skip_serializing_if = "Option::is_none")]
816    pub verification_outcome: Option<String>,
817    #[serde(default, skip_serializing_if = "Option::is_none")]
818    pub validation_basis: Option<Value>,
819    #[serde(default)]
820    pub repair_attempted: bool,
821    #[serde(default)]
822    pub repair_attempt: u32,
823    #[serde(default)]
824    pub repair_attempts_remaining: u32,
825    #[serde(default)]
826    pub repair_succeeded: bool,
827    #[serde(default)]
828    pub repair_exhausted: bool,
829}
830
831#[derive(Debug, Clone, Serialize, Deserialize)]
832pub struct AutomationNodeOutputFreshness {
833    pub current_run: bool,
834    pub current_attempt: bool,
835}
836
837#[derive(Debug, Clone, Serialize, Deserialize)]
838pub struct AutomationNodeOutputProvenance {
839    pub session_id: String,
840    pub node_id: String,
841    #[serde(default, skip_serializing_if = "Option::is_none")]
842    pub run_id: Option<String>,
843    #[serde(default, skip_serializing_if = "Option::is_none")]
844    pub output_path: Option<String>,
845    #[serde(default, skip_serializing_if = "Option::is_none")]
846    pub content_digest: Option<String>,
847    #[serde(default, skip_serializing_if = "Option::is_none")]
848    pub accepted_candidate_source: Option<String>,
849    #[serde(default, skip_serializing_if = "Option::is_none")]
850    pub validation_outcome: Option<String>,
851    #[serde(default, skip_serializing_if = "Option::is_none")]
852    pub repair_attempt: Option<u64>,
853    #[serde(default, skip_serializing_if = "Option::is_none")]
854    pub repair_succeeded: Option<bool>,
855    #[serde(default, skip_serializing_if = "Option::is_none")]
856    pub reuse_allowed: Option<bool>,
857    pub freshness: AutomationNodeOutputFreshness,
858}
859
860#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
861#[serde(rename_all = "snake_case")]
862pub enum AutomationRunStatus {
863    Queued,
864    Running,
865    Pausing,
866    Paused,
867    AwaitingApproval,
868    Completed,
869    Blocked,
870    Failed,
871    Cancelled,
872}
873
874#[derive(Debug, Clone, Serialize, Deserialize)]
875pub struct AutomationPendingGate {
876    pub node_id: String,
877    pub title: String,
878    #[serde(default, skip_serializing_if = "Option::is_none")]
879    pub instructions: Option<String>,
880    #[serde(default)]
881    pub decisions: Vec<String>,
882    #[serde(default)]
883    pub rework_targets: Vec<String>,
884    pub requested_at_ms: u64,
885    #[serde(default)]
886    pub upstream_node_ids: Vec<String>,
887}
888
889#[derive(Debug, Clone, Serialize, Deserialize)]
890pub struct AutomationGateDecisionRecord {
891    pub node_id: String,
892    pub decision: String,
893    #[serde(default, skip_serializing_if = "Option::is_none")]
894    pub reason: Option<String>,
895    pub decided_at_ms: u64,
896}
897
898#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
899#[serde(rename_all = "snake_case")]
900pub enum AutomationStopKind {
901    Cancelled,
902    OperatorStopped,
903    GuardrailStopped,
904    Panic,
905    Shutdown,
906    ServerRestart,
907    StaleReaped,
908}
909
910#[derive(Debug, Clone, Serialize, Deserialize)]
911pub struct AutomationLifecycleRecord {
912    pub event: String,
913    pub recorded_at_ms: u64,
914    #[serde(default, skip_serializing_if = "Option::is_none")]
915    pub reason: Option<String>,
916    #[serde(default, skip_serializing_if = "Option::is_none")]
917    pub stop_kind: Option<AutomationStopKind>,
918    #[serde(default, skip_serializing_if = "Option::is_none")]
919    pub metadata: Option<Value>,
920}
921
922#[derive(Debug, Clone, Serialize, Deserialize)]
923pub struct AutomationFailureRecord {
924    pub node_id: String,
925    pub reason: String,
926    pub failed_at_ms: u64,
927}
928
929#[derive(Debug, Clone, Serialize, Deserialize)]
930pub struct AutomationRunCheckpoint {
931    #[serde(default)]
932    pub completed_nodes: Vec<String>,
933    #[serde(default)]
934    pub pending_nodes: Vec<String>,
935    #[serde(default)]
936    pub node_outputs: std::collections::HashMap<String, Value>,
937    #[serde(default)]
938    pub node_attempts: std::collections::HashMap<String, u32>,
939    #[serde(default)]
940    pub blocked_nodes: Vec<String>,
941    #[serde(default, skip_serializing_if = "Option::is_none")]
942    pub awaiting_gate: Option<AutomationPendingGate>,
943    #[serde(default)]
944    pub gate_history: Vec<AutomationGateDecisionRecord>,
945    #[serde(default)]
946    pub lifecycle_history: Vec<AutomationLifecycleRecord>,
947    #[serde(default, skip_serializing_if = "Option::is_none")]
948    pub last_failure: Option<AutomationFailureRecord>,
949}
950
951#[derive(Debug, Clone, Serialize, Deserialize)]
952pub struct AutomationV2RunRecord {
953    pub run_id: String,
954    pub automation_id: String,
955    #[serde(default = "default_tenant_context")]
956    pub tenant_context: TenantContext,
957    pub trigger_type: String,
958    pub status: AutomationRunStatus,
959    pub created_at_ms: u64,
960    pub updated_at_ms: u64,
961    #[serde(default, skip_serializing_if = "Option::is_none")]
962    pub started_at_ms: Option<u64>,
963    #[serde(default, skip_serializing_if = "Option::is_none")]
964    pub finished_at_ms: Option<u64>,
965    #[serde(default)]
966    pub active_session_ids: Vec<String>,
967    #[serde(default, skip_serializing_if = "Option::is_none")]
968    pub latest_session_id: Option<String>,
969    #[serde(default)]
970    pub active_instance_ids: Vec<String>,
971    pub checkpoint: AutomationRunCheckpoint,
972    #[serde(default, skip_serializing_if = "Option::is_none")]
973    pub runtime_context: Option<AutomationRuntimeContextMaterialization>,
974    #[serde(default, skip_serializing_if = "Option::is_none")]
975    pub automation_snapshot: Option<AutomationV2Spec>,
976    #[serde(default, skip_serializing_if = "Option::is_none")]
977    pub pause_reason: Option<String>,
978    #[serde(default, skip_serializing_if = "Option::is_none")]
979    pub resume_reason: Option<String>,
980    #[serde(default, skip_serializing_if = "Option::is_none")]
981    pub detail: Option<String>,
982    #[serde(default, skip_serializing_if = "Option::is_none")]
983    pub stop_kind: Option<AutomationStopKind>,
984    #[serde(default, skip_serializing_if = "Option::is_none")]
985    pub stop_reason: Option<String>,
986    #[serde(default)]
987    pub prompt_tokens: u64,
988    #[serde(default)]
989    pub completion_tokens: u64,
990    #[serde(default)]
991    pub total_tokens: u64,
992    #[serde(default)]
993    pub estimated_cost_usd: f64,
994    #[serde(default, skip_serializing_if = "Option::is_none")]
995    pub scheduler: Option<crate::app::state::automation::scheduler::SchedulerMetadata>,
996    /// Human-readable description of why this run was triggered, e.g.
997    /// `"handoff shortlist from opportunity-scout approved"`.
998    /// Populated for `trigger_type: "watch_condition"` runs.
999    #[serde(default, skip_serializing_if = "Option::is_none")]
1000    pub trigger_reason: Option<String>,
1001    /// The `handoff_id` of the `HandoffArtifact` that triggered this run, if any.
1002    /// Used for idempotency: a retry of this run will not re-consume a second handoff.
1003    #[serde(default, skip_serializing_if = "Option::is_none")]
1004    pub consumed_handoff_id: Option<String>,
1005}
1006
1007fn default_tenant_context() -> TenantContext {
1008    TenantContext::local_implicit()
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013    use super::*;
1014    use serde_json::json;
1015    use tandem_orchestrator::{KnowledgeReuseMode, KnowledgeTrustLevel};
1016    use tandem_plan_compiler::api::{
1017        OutputContractSeed, ProjectedAutomationNode, ProjectedMissionInputRef,
1018    };
1019
1020    #[test]
1021    fn projected_node_metadata_lifts_knowledge_binding() {
1022        let projected = ProjectedAutomationNode::<ProjectedMissionInputRef, OutputContractSeed> {
1023            node_id: "node-a".to_string(),
1024            agent_id: "agent-a".to_string(),
1025            objective: "Map the topic".to_string(),
1026            depends_on: vec![],
1027            input_refs: vec![],
1028            output_contract: None,
1029            retry_policy: None,
1030            timeout_ms: None,
1031            stage_kind: None,
1032            gate: None,
1033            metadata: Some(json!({
1034                "builder": {
1035                    "knowledge": {
1036                        "enabled": true,
1037                        "reuse_mode": "preflight",
1038                        "trust_floor": "promoted",
1039                        "read_spaces": [{"scope": "project"}],
1040                        "promote_spaces": [{"scope": "project"}],
1041                        "subject": "Topic map"
1042                    }
1043                }
1044            })),
1045        };
1046
1047        let node = AutomationFlowNode::from(projected);
1048        assert!(node.knowledge.enabled);
1049        assert_eq!(node.knowledge.reuse_mode, KnowledgeReuseMode::Preflight);
1050        assert_eq!(node.knowledge.trust_floor, KnowledgeTrustLevel::Promoted);
1051        assert_eq!(node.knowledge.subject.as_deref(), Some("Topic map"));
1052        assert_eq!(node.knowledge.read_spaces.len(), 1);
1053        assert_eq!(node.knowledge.promote_spaces.len(), 1);
1054    }
1055
1056    // ── AutomationScopePolicy ────────────────────────────────────────────────
1057
1058    fn open_policy() -> AutomationScopePolicy {
1059        AutomationScopePolicy::default()
1060    }
1061
1062    fn restricted_policy() -> AutomationScopePolicy {
1063        AutomationScopePolicy {
1064            readable_paths: vec!["shared/".to_string(), "job-search/reports/".to_string()],
1065            writable_paths: vec!["job-search/reports/".to_string()],
1066            denied_paths: vec!["shared/secrets/".to_string()],
1067            watch_paths: vec![],
1068        }
1069    }
1070
1071    #[test]
1072    fn scope_policy_open_allows_any_read() {
1073        let policy = open_policy();
1074        assert!(policy.check_read("anything/here.md").is_ok());
1075        assert!(policy.check_read("shared/secrets/token.txt").is_ok());
1076    }
1077
1078    #[test]
1079    fn scope_policy_open_allows_any_write() {
1080        let policy = open_policy();
1081        assert!(policy.check_write("anywhere/file.txt").is_ok());
1082    }
1083
1084    #[test]
1085    fn scope_policy_deny_wins_over_readable() {
1086        let policy = restricted_policy();
1087        // shared/secrets/ is explicitly denied, even though "shared/" is readable
1088        assert!(policy.check_read("shared/secrets/token.txt").is_err());
1089        assert!(policy.check_write("shared/secrets/token.txt").is_err());
1090    }
1091
1092    #[test]
1093    fn scope_policy_readable_path_allows_read() {
1094        let policy = restricted_policy();
1095        assert!(policy
1096            .check_read("shared/handoffs/approved/handoff.json")
1097            .is_ok());
1098    }
1099
1100    #[test]
1101    fn scope_policy_unreadable_path_denied() {
1102        let policy = restricted_policy();
1103        // "private/" is not in readable_paths
1104        assert!(policy.check_read("private/notes.md").is_err());
1105    }
1106
1107    #[test]
1108    fn scope_policy_writable_path_allows_write() {
1109        let policy = restricted_policy();
1110        assert!(policy.check_write("job-search/reports/week1.md").is_ok());
1111    }
1112
1113    #[test]
1114    fn scope_policy_non_writable_path_denied_for_write() {
1115        let policy = restricted_policy();
1116        // "shared/" is readable but not writable
1117        assert!(policy
1118            .check_write("shared/handoffs/approved/handoff.json")
1119            .is_err());
1120    }
1121
1122    #[test]
1123    fn scope_policy_watch_falls_back_to_readable_when_watch_paths_empty() {
1124        let policy = restricted_policy(); // watch_paths is empty
1125                                          // watched paths should follow readable_paths
1126        assert!(policy.check_watch("shared/handoffs/inbox/").is_ok());
1127        assert!(policy.check_watch("private/something").is_err());
1128    }
1129
1130    #[test]
1131    fn scope_policy_explicit_watch_paths_override_readable() {
1132        let policy = AutomationScopePolicy {
1133            readable_paths: vec!["shared/".to_string()],
1134            writable_paths: vec![],
1135            denied_paths: vec![],
1136            watch_paths: vec!["shared/handoffs/inbox/".to_string()],
1137        };
1138        // Only the explicit watch path is watchable
1139        assert!(policy
1140            .check_watch("shared/handoffs/inbox/alert.json")
1141            .is_ok());
1142        // "shared/other/" is readable but not in watch_paths
1143        assert!(policy.check_watch("shared/other/file.md").is_err());
1144    }
1145
1146    #[test]
1147    fn scope_path_prefix_matches_exact_and_children() {
1148        assert!(scope_path_matches_prefix("shared", "shared"));
1149        assert!(scope_path_matches_prefix("shared/foo/bar.json", "shared"));
1150        assert!(!scope_path_matches_prefix("sharedfoo", "shared")); // no slash boundary
1151        assert!(!scope_path_matches_prefix("other/shared", "shared"));
1152    }
1153
1154    #[test]
1155    fn scope_policy_is_open_reflects_empty_lists() {
1156        assert!(open_policy().is_open());
1157        assert!(!restricted_policy().is_open());
1158    }
1159}