#[tokio::test]
async fn automation_v2_publish_block_smoke_skips_external_action_receipts() {
let state = test_state().await;
let app = app_router(state.clone());
let automation = crate::AutomationV2Spec {
automation_id: "auto-v2-smoke-editorial-publish".to_string(),
name: "Editorial Publish Smoke".to_string(),
description: Some("Publish is blocked until editorial issues are resolved".to_string()),
status: crate::AutomationV2Status::Active,
schedule: crate::AutomationV2Schedule {
schedule_type: crate::AutomationV2ScheduleType::Manual,
cron_expression: None,
interval_seconds: None,
timezone: "UTC".to_string(),
misfire_policy: crate::RoutineMisfirePolicy::RunOnce,
},
knowledge: tandem_orchestrator::KnowledgeBinding::default(),
agents: vec![crate::AutomationAgentProfile {
agent_id: "publisher".to_string(),
template_id: None,
display_name: "Publisher".to_string(),
avatar_url: None,
model_policy: None,
skills: Vec::new(),
tool_policy: crate::AutomationAgentToolPolicy {
allowlist: vec!["workflow_test.slack".to_string()],
denylist: Vec::new(),
},
mcp_policy: crate::AutomationAgentMcpPolicy {
allowed_servers: Vec::new(),
allowed_tools: None,
},
approval_policy: None,
}],
flow: crate::AutomationFlowSpec {
nodes: vec![
crate::AutomationFlowNode {
knowledge: tandem_orchestrator::KnowledgeBinding::default(),
node_id: "draft-report".to_string(),
agent_id: "publisher".to_string(),
objective: "Draft the final markdown report".to_string(),
depends_on: Vec::new(),
input_refs: Vec::new(),
output_contract: Some(crate::AutomationFlowOutputContract {
kind: "report_markdown".to_string(),
validator: Some(crate::AutomationOutputValidatorKind::GenericArtifact),
enforcement: None,
schema: None,
summary_guidance: None,
}),
tool_policy: None,
mcp_policy: None,
retry_policy: None,
timeout_ms: None,
max_tool_calls: None,
stage_kind: Some(crate::AutomationNodeStageKind::Workstream),
gate: None,
metadata: Some(json!({
"builder": {
"output_path": "final-report.md",
"role": "writer"
}
})),
},
crate::AutomationFlowNode {
knowledge: tandem_orchestrator::KnowledgeBinding::default(),
node_id: "publish-report".to_string(),
agent_id: "publisher".to_string(),
objective: "Publish the final report to Slack".to_string(),
depends_on: vec!["draft-report".to_string()],
input_refs: vec![crate::AutomationFlowInputRef {
from_step_id: "draft-report".to_string(),
alias: "draft".to_string(),
}],
output_contract: None,
tool_policy: None,
mcp_policy: None,
retry_policy: None,
timeout_ms: None,
max_tool_calls: None,
stage_kind: Some(crate::AutomationNodeStageKind::Workstream),
gate: None,
metadata: Some(json!({
"builder": {
"role": "publisher"
}
})),
},
],
},
execution: crate::AutomationExecutionPolicy {
profile: None,
max_parallel_agents: Some(1),
max_total_runtime_ms: None,
max_total_tool_calls: None,
max_total_tokens: None,
max_total_cost_usd: None,
},
output_targets: vec!["final-report.md".to_string()],
created_at_ms: 0,
updated_at_ms: 0,
creator_id: "test".to_string(),
workspace_root: Some("/tmp".to_string()),
metadata: None,
next_fire_at_ms: None,
last_fired_at_ms: None,
scope_policy: None,
watch_conditions: Vec::new(),
handoff_config: None,
};
state
.put_automation_v2(automation.clone())
.await
.expect("store automation");
let run = state
.create_automation_v2_run(&automation, "manual")
.await
.expect("create run");
state
.update_automation_v2_run(&run.run_id, |row| {
row.status = crate::AutomationRunStatus::Blocked;
row.detail = Some("publish is blocked pending editorial fixes".to_string());
row.checkpoint.pending_nodes = vec!["publish-report".to_string()];
row.checkpoint.blocked_nodes =
vec!["draft-report".to_string(), "publish-report".to_string()];
row.checkpoint.node_outputs.insert(
"draft-report".to_string(),
json!({
"node_id": "draft-report",
"status": "blocked",
"workflow_class": "artifact",
"phase": "editorial_validation",
"failure_kind": "editorial_quality_failed",
"summary": "Blocked editorial draft is too weak to publish.",
"validator_kind": "generic_artifact",
"validator_summary": {
"kind": "generic_artifact",
"outcome": "blocked",
"reason": "editorial artifact is missing expected markdown structure",
"unmet_requirements": ["editorial_substance_missing", "markdown_structure_missing"]
},
"artifact_validation": {
"accepted_artifact_path": "final-report.md",
"heading_count": 1,
"paragraph_count": 1,
"repair_attempted": false,
"repair_succeeded": false,
"unmet_requirements": ["editorial_substance_missing", "markdown_structure_missing"]
}
}),
);
row.checkpoint.node_outputs.insert(
"publish-report".to_string(),
json!({
"node_id": "publish-report",
"status": "blocked",
"workflow_class": "artifact",
"phase": "editorial_validation",
"failure_kind": "editorial_quality_failed",
"summary": "Publish blocked until editorial issues are resolved.",
"validator_summary": {
"outcome": "blocked",
"reason": "publish step blocked until upstream editorial issues are resolved: draft-report",
"unmet_requirements": ["editorial_clearance_required"]
},
"artifact_validation": {
"unmet_requirements": ["editorial_clearance_required"],
"semantic_block_reason": "publish step blocked until upstream editorial issues are resolved: draft-report"
}
}),
);
})
.await
.expect("update run");
let run_resp = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/automations/v2/runs/{}", run.run_id))
.body(Body::empty())
.expect("run request"),
)
.await
.expect("run response");
assert_eq!(run_resp.status(), StatusCode::OK);
let run_body = to_bytes(run_resp.into_body(), usize::MAX)
.await
.expect("run body");
let run_payload: Value = serde_json::from_slice(&run_body).expect("run json");
let publish_output = run_payload
.get("run")
.and_then(|value| value.get("checkpoint"))
.and_then(|value| value.get("node_outputs"))
.and_then(|value| value.get("publish-report"))
.expect("publish output");
assert_eq!(
publish_output.get("failure_kind").and_then(Value::as_str),
Some("editorial_quality_failed")
);
assert_eq!(
publish_output.get("phase").and_then(Value::as_str),
Some("editorial_validation")
);
assert_eq!(
publish_output
.get("validator_summary")
.and_then(|value| value.get("unmet_requirements"))
.and_then(Value::as_array)
.map(|rows| rows.clone()),
Some(vec![json!("editorial_clearance_required")])
);
let external_actions_resp = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/external-actions?limit=10")
.body(Body::empty())
.expect("external actions request"),
)
.await
.expect("external actions response");
assert_eq!(external_actions_resp.status(), StatusCode::OK);
let external_actions_body = to_bytes(external_actions_resp.into_body(), usize::MAX)
.await
.expect("external actions body");
let external_actions_payload: Value =
serde_json::from_slice(&external_actions_body).expect("external actions json");
assert_eq!(
external_actions_payload
.get("actions")
.and_then(Value::as_array)
.map(|rows| rows.len()),
Some(0)
);
}