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#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct HandoffArtifact {
50 pub handoff_id: String,
52 pub source_automation_id: String,
54 pub source_run_id: String,
56 pub source_node_id: String,
58 pub target_automation_id: String,
61 pub artifact_type: String,
64 pub created_at_ms: u64,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub content_path: Option<String>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub content_digest: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub metadata: Option<serde_json::Value>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub consumed_by_run_id: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub consumed_by_automation_id: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub consumed_at_ms: Option<u64>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90#[serde(rename_all = "snake_case", tag = "kind")]
91pub enum WatchCondition {
92 HandoffAvailable {
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 source_automation_id: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 artifact_type: Option<String>,
101 },
102 }
104
105#[derive(Debug, Clone, Serialize, Deserialize, Default)]
113pub struct AutomationScopePolicy {
114 #[serde(default)]
117 pub readable_paths: Vec<String>,
118 #[serde(default)]
121 pub writable_paths: Vec<String>,
122 #[serde(default)]
125 pub denied_paths: Vec<String>,
126 #[serde(default)]
129 pub watch_paths: Vec<String>,
130}
131
132impl AutomationScopePolicy {
133 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 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(()); }
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 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(()); }
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 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(()); }
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
232fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct AutomationHandoffConfig {
245 #[serde(default = "default_handoff_inbox_dir")]
248 pub inbox_dir: String,
249 #[serde(default = "default_handoff_approved_dir")]
252 pub approved_dir: String,
253 #[serde(default = "default_handoff_archived_dir")]
256 pub archived_dir: String,
257 #[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 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 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub scope_policy: Option<AutomationScopePolicy>,
729 #[serde(default, skip_serializing_if = "Vec::is_empty")]
732 pub watch_conditions: Vec<WatchCondition>,
733 #[serde(default, skip_serializing_if = "Option::is_none")]
735 pub handoff_config: Option<AutomationHandoffConfig>,
736}
737
738impl AutomationV2Spec {
739 pub fn effective_handoff_config(&self) -> AutomationHandoffConfig {
741 self.handoff_config.clone().unwrap_or_default()
742 }
743
744 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 #[serde(default, skip_serializing_if = "Option::is_none")]
1000 pub trigger_reason: Option<String>,
1001 #[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 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 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 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 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(); 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 assert!(policy
1140 .check_watch("shared/handoffs/inbox/alert.json")
1141 .is_ok());
1142 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")); 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}