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 AutomationRequiredToolCall {
533 pub tool: String,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub args: Option<Value>,
536 #[serde(default, skip_serializing_if = "Option::is_none")]
537 pub evidence_key: Option<String>,
538 #[serde(default = "default_required_tool_call_success")]
539 pub required_success: bool,
540}
541
542fn default_required_tool_call_success() -> bool {
543 true
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
547pub struct AutomationOutputEnforcement {
548 #[serde(default, skip_serializing_if = "Option::is_none")]
549 pub validation_profile: Option<String>,
550 #[serde(default)]
551 pub required_tools: Vec<String>,
552 #[serde(default)]
553 pub required_tool_calls: Vec<AutomationRequiredToolCall>,
554 #[serde(default)]
555 pub required_evidence: Vec<String>,
556 #[serde(default)]
557 pub required_sections: Vec<String>,
558 #[serde(default)]
559 pub prewrite_gates: Vec<String>,
560 #[serde(default)]
561 pub retry_on_missing: Vec<String>,
562 #[serde(default)]
563 pub terminal_on: Vec<String>,
564 #[serde(default, skip_serializing_if = "Option::is_none")]
565 pub repair_budget: Option<u32>,
566 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub session_text_recovery: Option<String>,
568}
569
570#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
571#[serde(rename_all = "snake_case")]
572pub enum AutomationOutputValidatorKind {
573 CodePatch,
574 ResearchBrief,
575 ReviewDecision,
576 StructuredJson,
577 GenericArtifact,
578 StandupUpdate,
582}
583
584impl AutomationOutputValidatorKind {
585 pub fn stable_key(self) -> &'static str {
586 match self {
587 Self::CodePatch => "code_patch",
588 Self::ResearchBrief => "research_brief",
589 Self::ReviewDecision => "review_decision",
590 Self::StructuredJson => "structured_json",
591 Self::GenericArtifact => "generic_artifact",
592 Self::StandupUpdate => "standup_update",
593 }
594 }
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct AutomationFlowSpec {
599 #[serde(default)]
600 pub nodes: Vec<AutomationFlowNode>,
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize)]
604pub struct AutomationExecutionPolicy {
605 #[serde(default, skip_serializing_if = "Option::is_none")]
606 pub max_parallel_agents: Option<u32>,
607 #[serde(default, skip_serializing_if = "Option::is_none")]
608 pub max_total_runtime_ms: Option<u64>,
609 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub max_total_tool_calls: Option<u32>,
611 #[serde(default, skip_serializing_if = "Option::is_none")]
612 pub max_total_tokens: Option<u64>,
613 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub max_total_cost_usd: Option<f64>,
615}
616
617impl From<tandem_plan_compiler::api::ProjectedAutomationExecutionPolicy>
618 for AutomationExecutionPolicy
619{
620 fn from(value: tandem_plan_compiler::api::ProjectedAutomationExecutionPolicy) -> Self {
621 Self {
622 max_parallel_agents: value.max_parallel_agents,
623 max_total_runtime_ms: value.max_total_runtime_ms,
624 max_total_tool_calls: value.max_total_tool_calls,
625 max_total_tokens: value.max_total_tokens,
626 max_total_cost_usd: value.max_total_cost_usd,
627 }
628 }
629}
630
631impl AutomationV2Spec {
632 fn metadata_value<T>(&self, key: &str) -> Option<T>
633 where
634 T: DeserializeOwned,
635 {
636 self.metadata
637 .as_ref()
638 .and_then(|metadata| metadata.get(key).cloned())
639 .and_then(|value| serde_json::from_value(value).ok())
640 }
641
642 pub fn runtime_context_materialization(
643 &self,
644 ) -> Option<AutomationRuntimeContextMaterialization> {
645 self.metadata_value("context_materialization")
646 }
647
648 pub fn approved_plan_runtime_context_materialization(
649 &self,
650 ) -> Option<AutomationRuntimeContextMaterialization> {
651 let approved_plan = self.approved_plan_materialization()?;
652 let scope_snapshot = self.plan_scope_snapshot_materialization()?;
653 let context_objects = scope_snapshot
654 .context_objects
655 .into_iter()
656 .map(|context_object: ContextObject| {
657 (context_object.context_object_id.clone(), context_object)
658 })
659 .collect::<HashMap<_, _>>();
660 let routines = approved_plan
661 .routines
662 .into_iter()
663 .map(|routine| ProjectedRoutineContextPartition {
664 routine_id: routine.routine_id,
665 visible_context_objects: routine
666 .visible_context_object_ids
667 .into_iter()
668 .filter_map(|context_object_id| {
669 context_objects.get(&context_object_id).cloned()
670 })
671 .collect(),
672 step_context_bindings: routine
673 .step_context_bindings
674 .into_iter()
675 .map(|binding| ProjectedStepContextBindings {
676 step_id: binding.step_id,
677 context_reads: binding.context_reads,
678 context_writes: binding.context_writes,
679 })
680 .collect(),
681 })
682 .collect();
683 Some(AutomationRuntimeContextMaterialization { routines })
684 }
685
686 pub fn requires_runtime_context(&self) -> bool {
687 self.runtime_context_materialization().is_some()
688 || self.approved_plan_materialization().is_some()
689 || !crate::http::context_packs::shared_context_pack_ids_from_metadata(
690 self.metadata.as_ref(),
691 )
692 .is_empty()
693 }
694
695 pub fn plan_scope_snapshot_materialization(&self) -> Option<PlanScopeSnapshot> {
696 self.metadata
697 .as_ref()
698 .and_then(|metadata| metadata.get("plan_package_bundle"))
699 .and_then(|bundle| bundle.get("scope_snapshot"))
700 .cloned()
701 .and_then(|value| serde_json::from_value(value).ok())
702 }
703
704 pub(crate) fn plan_package_validation_report(&self) -> Option<PlanValidationReport> {
705 self.metadata_value("plan_package_validation")
706 }
707
708 pub(crate) fn approved_plan_materialization(
709 &self,
710 ) -> Option<tandem_plan_compiler::api::ApprovedPlanMaterialization> {
711 self.metadata_value("approved_plan_materialization")
712 }
713}
714
715#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct AutomationV2Spec {
717 pub automation_id: String,
718 pub name: String,
719 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub description: Option<String>,
721 pub status: AutomationV2Status,
722 pub schedule: AutomationV2Schedule,
723 #[serde(default)]
724 pub knowledge: KnowledgeBinding,
725 #[serde(default)]
726 pub agents: Vec<AutomationAgentProfile>,
727 pub flow: AutomationFlowSpec,
728 pub execution: AutomationExecutionPolicy,
729 #[serde(default)]
730 pub output_targets: Vec<String>,
731 pub created_at_ms: u64,
732 pub updated_at_ms: u64,
733 pub creator_id: String,
734 #[serde(default, skip_serializing_if = "Option::is_none")]
735 pub workspace_root: Option<String>,
736 #[serde(default, skip_serializing_if = "Option::is_none")]
737 pub metadata: Option<Value>,
738 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub next_fire_at_ms: Option<u64>,
740 #[serde(default, skip_serializing_if = "Option::is_none")]
741 pub last_fired_at_ms: Option<u64>,
742 #[serde(default, skip_serializing_if = "Option::is_none")]
745 pub scope_policy: Option<AutomationScopePolicy>,
746 #[serde(default, skip_serializing_if = "Vec::is_empty")]
749 pub watch_conditions: Vec<WatchCondition>,
750 #[serde(default, skip_serializing_if = "Option::is_none")]
752 pub handoff_config: Option<AutomationHandoffConfig>,
753}
754
755impl AutomationV2Spec {
756 pub fn effective_handoff_config(&self) -> AutomationHandoffConfig {
758 self.handoff_config.clone().unwrap_or_default()
759 }
760
761 pub fn has_watch_conditions(&self) -> bool {
763 !self.watch_conditions.is_empty()
764 }
765}
766
767#[derive(Debug, Clone, Serialize, Deserialize)]
768pub struct AutomationNodeOutput {
769 pub contract_kind: String,
770 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub validator_kind: Option<AutomationOutputValidatorKind>,
772 #[serde(default, skip_serializing_if = "Option::is_none")]
773 pub validator_summary: Option<AutomationValidatorSummary>,
774 pub summary: String,
775 pub content: Value,
776 pub created_at_ms: u64,
777 pub node_id: String,
778 #[serde(default, skip_serializing_if = "Option::is_none")]
779 pub status: Option<String>,
780 #[serde(default, skip_serializing_if = "Option::is_none")]
781 pub blocked_reason: Option<String>,
782 #[serde(default, skip_serializing_if = "Option::is_none")]
783 pub approved: Option<bool>,
784 #[serde(default, skip_serializing_if = "Option::is_none")]
785 pub workflow_class: Option<String>,
786 #[serde(default, skip_serializing_if = "Option::is_none")]
787 pub phase: Option<String>,
788 #[serde(default, skip_serializing_if = "Option::is_none")]
789 pub failure_kind: Option<String>,
790 #[serde(default, skip_serializing_if = "Option::is_none")]
791 pub tool_telemetry: Option<Value>,
792 #[serde(default, skip_serializing_if = "Option::is_none")]
793 pub preflight: Option<Value>,
794 #[serde(default, skip_serializing_if = "Option::is_none")]
795 pub knowledge_preflight: Option<Value>,
796 #[serde(default, skip_serializing_if = "Option::is_none")]
797 pub capability_resolution: Option<Value>,
798 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub attempt_evidence: Option<Value>,
800 #[serde(default, skip_serializing_if = "Option::is_none")]
801 pub blocker_category: Option<String>,
802 #[serde(default, skip_serializing_if = "Option::is_none")]
803 pub receipt_timeline: Option<Value>,
804 #[serde(default, skip_serializing_if = "Option::is_none")]
805 pub quality_mode: Option<String>,
806 #[serde(default, skip_serializing_if = "Option::is_none")]
807 pub requested_quality_mode: Option<String>,
808 #[serde(default, skip_serializing_if = "Option::is_none")]
809 pub emergency_rollback_enabled: Option<bool>,
810 #[serde(default, skip_serializing_if = "Option::is_none")]
811 pub fallback_used: Option<bool>,
812 #[serde(default, skip_serializing_if = "Option::is_none")]
813 pub artifact_validation: Option<Value>,
814 #[serde(default, skip_serializing_if = "Option::is_none")]
815 pub provenance: Option<AutomationNodeOutputProvenance>,
816}
817
818#[derive(Debug, Clone, Serialize, Deserialize)]
819pub struct AutomationValidatorSummary {
820 pub kind: AutomationOutputValidatorKind,
821 pub outcome: String,
822 #[serde(default, skip_serializing_if = "Option::is_none")]
823 pub reason: Option<String>,
824 #[serde(default)]
825 pub unmet_requirements: Vec<String>,
826 #[serde(default)]
827 pub warning_requirements: Vec<String>,
828 #[serde(default)]
829 pub warning_count: u32,
830 #[serde(default, skip_serializing_if = "Option::is_none")]
831 pub accepted_candidate_source: Option<String>,
832 #[serde(default, skip_serializing_if = "Option::is_none")]
833 pub verification_outcome: Option<String>,
834 #[serde(default, skip_serializing_if = "Option::is_none")]
835 pub validation_basis: Option<Value>,
836 #[serde(default)]
837 pub repair_attempted: bool,
838 #[serde(default)]
839 pub repair_attempt: u32,
840 #[serde(default)]
841 pub repair_attempts_remaining: u32,
842 #[serde(default)]
843 pub repair_succeeded: bool,
844 #[serde(default)]
845 pub repair_exhausted: bool,
846}
847
848#[derive(Debug, Clone, Serialize, Deserialize)]
849pub struct AutomationNodeOutputFreshness {
850 pub current_run: bool,
851 pub current_attempt: bool,
852}
853
854#[derive(Debug, Clone, Serialize, Deserialize)]
855pub struct AutomationNodeOutputProvenance {
856 pub session_id: String,
857 pub node_id: String,
858 #[serde(default, skip_serializing_if = "Option::is_none")]
859 pub run_id: Option<String>,
860 #[serde(default, skip_serializing_if = "Option::is_none")]
861 pub output_path: Option<String>,
862 #[serde(default, skip_serializing_if = "Option::is_none")]
863 pub content_digest: Option<String>,
864 #[serde(default, skip_serializing_if = "Option::is_none")]
865 pub accepted_candidate_source: Option<String>,
866 #[serde(default, skip_serializing_if = "Option::is_none")]
867 pub validation_outcome: Option<String>,
868 #[serde(default, skip_serializing_if = "Option::is_none")]
869 pub repair_attempt: Option<u64>,
870 #[serde(default, skip_serializing_if = "Option::is_none")]
871 pub repair_succeeded: Option<bool>,
872 #[serde(default, skip_serializing_if = "Option::is_none")]
873 pub reuse_allowed: Option<bool>,
874 pub freshness: AutomationNodeOutputFreshness,
875}
876
877#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
878#[serde(rename_all = "snake_case")]
879pub enum AutomationRunStatus {
880 Queued,
881 Running,
882 Pausing,
883 Paused,
884 AwaitingApproval,
885 Completed,
886 Blocked,
887 Failed,
888 Cancelled,
889}
890
891#[derive(Debug, Clone, Serialize, Deserialize)]
892pub struct AutomationPendingGate {
893 pub node_id: String,
894 pub title: String,
895 #[serde(default, skip_serializing_if = "Option::is_none")]
896 pub instructions: Option<String>,
897 #[serde(default)]
898 pub decisions: Vec<String>,
899 #[serde(default)]
900 pub rework_targets: Vec<String>,
901 pub requested_at_ms: u64,
902 #[serde(default)]
903 pub upstream_node_ids: Vec<String>,
904}
905
906#[derive(Debug, Clone, Serialize, Deserialize)]
907pub struct AutomationGateDecisionRecord {
908 pub node_id: String,
909 pub decision: String,
910 #[serde(default, skip_serializing_if = "Option::is_none")]
911 pub reason: Option<String>,
912 pub decided_at_ms: u64,
913}
914
915#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
916#[serde(rename_all = "snake_case")]
917pub enum AutomationStopKind {
918 Cancelled,
919 OperatorStopped,
920 GuardrailStopped,
921 Panic,
922 Shutdown,
923 ServerRestart,
924 StaleReaped,
925}
926
927#[derive(Debug, Clone, Serialize, Deserialize)]
928pub struct AutomationLifecycleRecord {
929 pub event: String,
930 pub recorded_at_ms: u64,
931 #[serde(default, skip_serializing_if = "Option::is_none")]
932 pub reason: Option<String>,
933 #[serde(default, skip_serializing_if = "Option::is_none")]
934 pub stop_kind: Option<AutomationStopKind>,
935 #[serde(default, skip_serializing_if = "Option::is_none")]
936 pub metadata: Option<Value>,
937}
938
939#[derive(Debug, Clone, Serialize, Deserialize)]
940pub struct AutomationFailureRecord {
941 pub node_id: String,
942 pub reason: String,
943 pub failed_at_ms: u64,
944}
945
946#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
947#[serde(rename_all = "snake_case")]
948pub enum WorkflowLearningCandidateKind {
949 MemoryFact,
950 RepairHint,
951 PromptPatch,
952 GraphPatch,
953}
954
955#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
956#[serde(rename_all = "snake_case")]
957pub enum WorkflowLearningCandidateStatus {
958 Proposed,
959 Approved,
960 Rejected,
961 Applied,
962 Superseded,
963 Regressed,
964}
965
966#[derive(Debug, Clone, Serialize, Deserialize, Default)]
967pub struct WorkflowLearningMetricsSnapshot {
968 #[serde(default)]
969 pub sample_size: usize,
970 #[serde(default)]
971 pub completion_rate: f64,
972 #[serde(default)]
973 pub validation_pass_rate: f64,
974 #[serde(default)]
975 pub mean_attempts_per_node: f64,
976 #[serde(default)]
977 pub repairable_failure_rate: f64,
978 #[serde(default)]
979 pub median_wall_clock_ms: u64,
980 #[serde(default)]
981 pub human_intervention_count: u64,
982 #[serde(default)]
983 pub computed_at_ms: u64,
984}
985
986#[derive(Debug, Clone, Serialize, Deserialize)]
987pub struct WorkflowLearningCandidate {
988 pub candidate_id: String,
989 pub workflow_id: String,
990 pub project_id: String,
991 pub source_run_id: String,
992 pub kind: WorkflowLearningCandidateKind,
993 pub status: WorkflowLearningCandidateStatus,
994 #[serde(default)]
995 pub confidence: f64,
996 pub summary: String,
997 pub fingerprint: String,
998 #[serde(default, skip_serializing_if = "Option::is_none")]
999 pub node_id: Option<String>,
1000 #[serde(default, skip_serializing_if = "Option::is_none")]
1001 pub node_kind: Option<String>,
1002 #[serde(default, skip_serializing_if = "Option::is_none")]
1003 pub validator_family: Option<String>,
1004 #[serde(default)]
1005 pub evidence_refs: Vec<Value>,
1006 #[serde(default)]
1007 pub artifact_refs: Vec<String>,
1008 #[serde(default, skip_serializing_if = "Option::is_none")]
1009 pub proposed_memory_payload: Option<Value>,
1010 #[serde(default, skip_serializing_if = "Option::is_none")]
1011 pub proposed_revision_prompt: Option<String>,
1012 #[serde(default, skip_serializing_if = "Option::is_none")]
1013 pub source_memory_id: Option<String>,
1014 #[serde(default, skip_serializing_if = "Option::is_none")]
1015 pub promoted_memory_id: Option<String>,
1016 #[serde(default)]
1017 pub needs_plan_bundle: bool,
1018 #[serde(default, skip_serializing_if = "Option::is_none")]
1019 pub baseline_before: Option<WorkflowLearningMetricsSnapshot>,
1020 #[serde(default, skip_serializing_if = "Option::is_none")]
1021 pub latest_observed_metrics: Option<WorkflowLearningMetricsSnapshot>,
1022 #[serde(default, skip_serializing_if = "Option::is_none")]
1023 pub last_revision_session_id: Option<String>,
1024 #[serde(default)]
1025 pub run_ids: Vec<String>,
1026 pub created_at_ms: u64,
1027 pub updated_at_ms: u64,
1028}
1029
1030#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1031pub struct WorkflowLearningRunSummary {
1032 #[serde(default)]
1033 pub generated_candidate_ids: Vec<String>,
1034 #[serde(default)]
1035 pub injected_learning_ids: Vec<String>,
1036 #[serde(default)]
1037 pub approved_learning_ids_considered: Vec<String>,
1038 #[serde(default, skip_serializing_if = "Option::is_none")]
1039 pub post_run_metrics: Option<WorkflowLearningMetricsSnapshot>,
1040}
1041
1042#[derive(Debug, Clone, Serialize, Deserialize)]
1043pub struct AutomationRunCheckpoint {
1044 #[serde(default)]
1045 pub completed_nodes: Vec<String>,
1046 #[serde(default)]
1047 pub pending_nodes: Vec<String>,
1048 #[serde(default)]
1049 pub node_outputs: std::collections::HashMap<String, Value>,
1050 #[serde(default)]
1051 pub node_attempts: std::collections::HashMap<String, u32>,
1052 #[serde(default)]
1053 pub blocked_nodes: Vec<String>,
1054 #[serde(default, skip_serializing_if = "Option::is_none")]
1055 pub awaiting_gate: Option<AutomationPendingGate>,
1056 #[serde(default)]
1057 pub gate_history: Vec<AutomationGateDecisionRecord>,
1058 #[serde(default)]
1059 pub lifecycle_history: Vec<AutomationLifecycleRecord>,
1060 #[serde(default, skip_serializing_if = "Option::is_none")]
1061 pub last_failure: Option<AutomationFailureRecord>,
1062}
1063
1064#[derive(Debug, Clone, Serialize, Deserialize)]
1065pub struct AutomationV2RunRecord {
1066 pub run_id: String,
1067 pub automation_id: String,
1068 #[serde(default = "default_tenant_context")]
1069 pub tenant_context: TenantContext,
1070 pub trigger_type: String,
1071 pub status: AutomationRunStatus,
1072 pub created_at_ms: u64,
1073 pub updated_at_ms: u64,
1074 #[serde(default, skip_serializing_if = "Option::is_none")]
1075 pub started_at_ms: Option<u64>,
1076 #[serde(default, skip_serializing_if = "Option::is_none")]
1077 pub finished_at_ms: Option<u64>,
1078 #[serde(default)]
1079 pub active_session_ids: Vec<String>,
1080 #[serde(default, skip_serializing_if = "Option::is_none")]
1081 pub latest_session_id: Option<String>,
1082 #[serde(default)]
1083 pub active_instance_ids: Vec<String>,
1084 pub checkpoint: AutomationRunCheckpoint,
1085 #[serde(default, skip_serializing_if = "Option::is_none")]
1086 pub runtime_context: Option<AutomationRuntimeContextMaterialization>,
1087 #[serde(default, skip_serializing_if = "Option::is_none")]
1088 pub automation_snapshot: Option<AutomationV2Spec>,
1089 #[serde(default, skip_serializing_if = "Option::is_none")]
1090 pub pause_reason: Option<String>,
1091 #[serde(default, skip_serializing_if = "Option::is_none")]
1092 pub resume_reason: Option<String>,
1093 #[serde(default, skip_serializing_if = "Option::is_none")]
1094 pub detail: Option<String>,
1095 #[serde(default, skip_serializing_if = "Option::is_none")]
1096 pub stop_kind: Option<AutomationStopKind>,
1097 #[serde(default, skip_serializing_if = "Option::is_none")]
1098 pub stop_reason: Option<String>,
1099 #[serde(default)]
1100 pub prompt_tokens: u64,
1101 #[serde(default)]
1102 pub completion_tokens: u64,
1103 #[serde(default)]
1104 pub total_tokens: u64,
1105 #[serde(default)]
1106 pub estimated_cost_usd: f64,
1107 #[serde(default, skip_serializing_if = "Option::is_none")]
1108 pub scheduler: Option<crate::app::state::automation::scheduler::SchedulerMetadata>,
1109 #[serde(default, skip_serializing_if = "Option::is_none")]
1113 pub trigger_reason: Option<String>,
1114 #[serde(default, skip_serializing_if = "Option::is_none")]
1117 pub consumed_handoff_id: Option<String>,
1118 #[serde(default, skip_serializing_if = "Option::is_none")]
1119 pub learning_summary: Option<WorkflowLearningRunSummary>,
1120}
1121
1122fn default_tenant_context() -> TenantContext {
1123 TenantContext::local_implicit()
1124}
1125
1126#[cfg(test)]
1127mod tests {
1128 use super::*;
1129 use serde_json::json;
1130 use tandem_orchestrator::{KnowledgeReuseMode, KnowledgeTrustLevel};
1131 use tandem_plan_compiler::api::{
1132 OutputContractSeed, ProjectedAutomationNode, ProjectedMissionInputRef,
1133 };
1134
1135 #[test]
1136 fn projected_node_metadata_lifts_knowledge_binding() {
1137 let projected = ProjectedAutomationNode::<ProjectedMissionInputRef, OutputContractSeed> {
1138 node_id: "node-a".to_string(),
1139 agent_id: "agent-a".to_string(),
1140 objective: "Map the topic".to_string(),
1141 depends_on: vec![],
1142 input_refs: vec![],
1143 output_contract: None,
1144 retry_policy: None,
1145 timeout_ms: None,
1146 stage_kind: None,
1147 gate: None,
1148 metadata: Some(json!({
1149 "builder": {
1150 "knowledge": {
1151 "enabled": true,
1152 "reuse_mode": "preflight",
1153 "trust_floor": "promoted",
1154 "read_spaces": [{"scope": "project"}],
1155 "promote_spaces": [{"scope": "project"}],
1156 "subject": "Topic map"
1157 }
1158 }
1159 })),
1160 };
1161
1162 let node = AutomationFlowNode::from(projected);
1163 assert!(node.knowledge.enabled);
1164 assert_eq!(node.knowledge.reuse_mode, KnowledgeReuseMode::Preflight);
1165 assert_eq!(node.knowledge.trust_floor, KnowledgeTrustLevel::Promoted);
1166 assert_eq!(node.knowledge.subject.as_deref(), Some("Topic map"));
1167 assert_eq!(node.knowledge.read_spaces.len(), 1);
1168 assert_eq!(node.knowledge.promote_spaces.len(), 1);
1169 }
1170
1171 fn open_policy() -> AutomationScopePolicy {
1174 AutomationScopePolicy::default()
1175 }
1176
1177 fn restricted_policy() -> AutomationScopePolicy {
1178 AutomationScopePolicy {
1179 readable_paths: vec!["shared/".to_string(), "job-search/reports/".to_string()],
1180 writable_paths: vec!["job-search/reports/".to_string()],
1181 denied_paths: vec!["shared/secrets/".to_string()],
1182 watch_paths: vec![],
1183 }
1184 }
1185
1186 #[test]
1187 fn scope_policy_open_allows_any_read() {
1188 let policy = open_policy();
1189 assert!(policy.check_read("anything/here.md").is_ok());
1190 assert!(policy.check_read("shared/secrets/token.txt").is_ok());
1191 }
1192
1193 #[test]
1194 fn scope_policy_open_allows_any_write() {
1195 let policy = open_policy();
1196 assert!(policy.check_write("anywhere/file.txt").is_ok());
1197 }
1198
1199 #[test]
1200 fn scope_policy_deny_wins_over_readable() {
1201 let policy = restricted_policy();
1202 assert!(policy.check_read("shared/secrets/token.txt").is_err());
1204 assert!(policy.check_write("shared/secrets/token.txt").is_err());
1205 }
1206
1207 #[test]
1208 fn scope_policy_readable_path_allows_read() {
1209 let policy = restricted_policy();
1210 assert!(policy
1211 .check_read("shared/handoffs/approved/handoff.json")
1212 .is_ok());
1213 }
1214
1215 #[test]
1216 fn scope_policy_unreadable_path_denied() {
1217 let policy = restricted_policy();
1218 assert!(policy.check_read("private/notes.md").is_err());
1220 }
1221
1222 #[test]
1223 fn scope_policy_writable_path_allows_write() {
1224 let policy = restricted_policy();
1225 assert!(policy.check_write("job-search/reports/week1.md").is_ok());
1226 }
1227
1228 #[test]
1229 fn scope_policy_non_writable_path_denied_for_write() {
1230 let policy = restricted_policy();
1231 assert!(policy
1233 .check_write("shared/handoffs/approved/handoff.json")
1234 .is_err());
1235 }
1236
1237 #[test]
1238 fn scope_policy_watch_falls_back_to_readable_when_watch_paths_empty() {
1239 let policy = restricted_policy(); assert!(policy.check_watch("shared/handoffs/inbox/").is_ok());
1242 assert!(policy.check_watch("private/something").is_err());
1243 }
1244
1245 #[test]
1246 fn scope_policy_explicit_watch_paths_override_readable() {
1247 let policy = AutomationScopePolicy {
1248 readable_paths: vec!["shared/".to_string()],
1249 writable_paths: vec![],
1250 denied_paths: vec![],
1251 watch_paths: vec!["shared/handoffs/inbox/".to_string()],
1252 };
1253 assert!(policy
1255 .check_watch("shared/handoffs/inbox/alert.json")
1256 .is_ok());
1257 assert!(policy.check_watch("shared/other/file.md").is_err());
1259 }
1260
1261 #[test]
1262 fn scope_path_prefix_matches_exact_and_children() {
1263 assert!(scope_path_matches_prefix("shared", "shared"));
1264 assert!(scope_path_matches_prefix("shared/foo/bar.json", "shared"));
1265 assert!(!scope_path_matches_prefix("sharedfoo", "shared")); assert!(!scope_path_matches_prefix("other/shared", "shared"));
1267 }
1268
1269 #[test]
1270 fn scope_policy_is_open_reflects_empty_lists() {
1271 assert!(open_policy().is_open());
1272 assert!(!restricted_policy().is_open());
1273 }
1274}