use anyhow::{bail, Context};
use serde_json::json;
use tandem_automation::{
GoalRunLink, LongRunningGoal, LongRunningGoalStatus, OrchestrationNodeKind, OrchestrationStatus,
};
use tandem_types::{PrincipalRef, TenantContext};
pub(crate) const MAX_GOAL_METADATA_BYTES: usize = 16 * 1024;
use super::AppState;
use crate::stateful_runtime::{
automation_definition_snapshot_hash, stable_definition_snapshot_hash, GoalPauseOutcome,
GoalResumeOutcome, OrchestrationStateStore, StartGoalOutcome,
};
#[derive(Debug, Clone)]
pub(crate) struct StartGoalRequest {
pub orchestration_id: String,
pub orchestration_version: Option<u64>,
pub objective: String,
pub idempotency_key: String,
pub metadata: Option<serde_json::Value>,
pub now_ms: u64,
}
impl StartGoalRequest {
pub fn goal_id(&self, tenant: &TenantContext) -> String {
stable_lifecycle_id(
"goal",
&format!(
"{}:{}:{}:{}",
tenant.org_id,
tenant.workspace_id,
tenant.deployment_id.as_deref().unwrap_or(""),
self.orchestration_id
),
&self.idempotency_key,
)
}
pub fn root_run_id(&self, tenant: &TenantContext) -> String {
stable_lifecycle_id(
"automation-v2-run",
&self.goal_id(tenant),
&format!("root:{}", self.idempotency_key),
)
}
}
fn stable_lifecycle_id(prefix: &str, scope: &str, idempotency_key: &str) -> String {
let hash = stable_definition_snapshot_hash(&json!({
"scope": scope,
"idempotency_key": idempotency_key,
"kind": prefix,
}));
format!(
"{prefix}-{}",
hash.trim_start_matches("sha256:")
.chars()
.take(32)
.collect::<String>()
)
}
impl AppState {
pub(crate) async fn start_long_running_goal(
&self,
tenant: &TenantContext,
request: &StartGoalRequest,
actor: &PrincipalRef,
) -> anyhow::Result<StartGoalOutcome> {
if request.idempotency_key.trim().is_empty() || request.idempotency_key.len() > 256 {
bail!("goal start requires a bounded idempotency key");
}
if request.objective.trim().is_empty() {
bail!("goal start requires an objective");
}
if let Some(metadata) = request.metadata.as_ref() {
let bytes = serde_json::to_vec(metadata)?.len();
if bytes > MAX_GOAL_METADATA_BYTES {
bail!(
"goal metadata is {bytes} bytes; the admission bound is {MAX_GOAL_METADATA_BYTES} bytes"
);
}
}
let store =
OrchestrationStateStore::from_automation_runs_path(&self.automation_v2_runs_path)?;
let version = match request.orchestration_version {
Some(version) => version,
None => store
.latest_published_orchestration_version(tenant, &request.orchestration_id)?
.context("orchestration has no published version")?,
};
let orchestration = store
.get_orchestration_for_tenant(tenant, &request.orchestration_id, version)?
.context("published orchestration version not found")?;
if orchestration.status != OrchestrationStatus::Published {
bail!("goals can only start from a published orchestration version");
}
let same_scope = orchestration.tenant_context.org_id == tenant.org_id
&& orchestration.tenant_context.workspace_id == tenant.workspace_id
&& orchestration.tenant_context.deployment_id == tenant.deployment_id;
if !same_scope {
bail!("published orchestration version not found");
}
let root_node = orchestration
.nodes
.iter()
.find(|node| node.node_id == orchestration.root_node_id)
.context("published orchestration is missing its root node")?;
let OrchestrationNodeKind::Workflow {
automation_id,
pinned_definition_hash,
..
} = &root_node.node
else {
bail!("orchestration root node must reference a workflow");
};
let automation = self
.get_automation_v2(automation_id)
.await
.context("root workflow definition not found")?;
let pinned_hash = pinned_definition_hash
.as_deref()
.context("published root workflow is missing its pinned definition hash")?;
if automation_definition_snapshot_hash(&automation) != pinned_hash {
bail!("root workflow definition changed after orchestration publication");
}
let goal_id = request.goal_id(tenant);
let root_run_id = request.root_run_id(tenant);
let goal = LongRunningGoal {
schema_version: 1,
goal_id: goal_id.clone(),
orchestration_id: orchestration.orchestration_id.clone(),
orchestration_version: orchestration.version,
objective: request.objective.clone(),
status: LongRunningGoalStatus::Active,
tenant_context: orchestration.tenant_context.clone(),
policy: orchestration.goal_policy.clone(),
active_run_id: Some(root_run_id.clone()),
current_node_id: Some(orchestration.root_node_id.clone()),
hop_count: 0,
total_tokens: 0,
total_cost_usd: 0.0,
created_at_ms: request.now_ms,
updated_at_ms: request.now_ms,
finished_at_ms: None,
final_artifact: None,
metadata: Some(goal_metadata_with_orchestration_snapshot(
request.metadata.clone(),
&orchestration,
)),
};
let root_run = self
.prepare_orchestration_node_run(
&goal,
&automation,
root_run_id.clone(),
request.now_ms,
"orchestration_goal_start",
format!("goal {goal_id} started"),
None,
)
.await?;
let link = GoalRunLink {
goal_id: goal_id.clone(),
run_id: root_run_id.clone(),
orchestration_node_id: orchestration.root_node_id.clone(),
orchestration_version: orchestration.version,
hop_index: 0,
parent_run_id: None,
triggering_handoff_id: None,
created_at_ms: request.now_ms,
};
let outcome = store.start_goal(&goal, &root_run, &link, actor)?;
if let StartGoalOutcome::Created { root_run, goal } = &outcome {
self.automation_v2_runs
.write()
.await
.insert(root_run.run_id.clone(), root_run.clone());
if let Err(error) = self.persist_automation_v2_runs().await {
tracing::warn!(
goal_id = %goal.goal_id,
run_id = %root_run.run_id,
%error,
"durable goal start committed; compatibility run persistence will recover from SQLite"
);
}
if let Err(status) = crate::http::context_runs::sync_automation_v2_run_blackboard(
self,
&automation,
root_run,
)
.await
{
tracing::warn!(
goal_id = %goal.goal_id,
run_id = %root_run.run_id,
?status,
"durable goal start committed; context blackboard synchronization will be retried"
);
}
self.event_bus
.publish(crate::routines::types::tenant_scoped_engine_event(
"orchestration.goal.started",
tenant,
json!({
"goalID": goal.goal_id,
"orchestrationID": goal.orchestration_id,
"orchestrationVersion": goal.orchestration_version,
"rootRunID": root_run.run_id,
}),
));
}
Ok(outcome)
}
pub(crate) async fn pause_long_running_goal(
&self,
goal_id: &str,
tenant: &TenantContext,
reason: &str,
actor: &PrincipalRef,
) -> anyhow::Result<(GoalPauseOutcome, LongRunningGoal)> {
let store =
OrchestrationStateStore::from_automation_runs_path(&self.automation_v2_runs_path)?;
let (outcome, goal) =
store.pause_goal(goal_id, tenant, reason, actor, crate::util::time::now_ms())?;
if outcome == GoalPauseOutcome::Applied {
self.event_bus
.publish(crate::routines::types::tenant_scoped_engine_event(
"orchestration.goal.paused",
tenant,
json!({"goalID": goal_id, "reason": reason}),
));
}
Ok((outcome, goal))
}
pub(crate) async fn resume_long_running_goal(
&self,
goal_id: &str,
tenant: &TenantContext,
reason: &str,
actor: &PrincipalRef,
) -> anyhow::Result<(GoalResumeOutcome, LongRunningGoal)> {
let store =
OrchestrationStateStore::from_automation_runs_path(&self.automation_v2_runs_path)?;
let (outcome, goal) =
store.resume_goal(goal_id, tenant, reason, actor, crate::util::time::now_ms())?;
if outcome == GoalResumeOutcome::Applied {
self.event_bus
.publish(crate::routines::types::tenant_scoped_engine_event(
"orchestration.goal.resumed",
tenant,
json!({"goalID": goal_id, "reason": reason}),
));
}
Ok((outcome, goal))
}
}
fn goal_metadata_with_orchestration_snapshot(
metadata: Option<serde_json::Value>,
orchestration: &tandem_automation::OrchestrationSpec,
) -> serde_json::Value {
let mut object = match metadata {
Some(serde_json::Value::Object(object)) => object,
Some(value) => serde_json::Map::from_iter([("submitted_metadata".to_string(), value)]),
None => serde_json::Map::new(),
};
object.insert(
"orchestration_snapshot".to_string(),
serde_json::to_value(orchestration).expect("orchestration snapshot is serializable"),
);
serde_json::Value::Object(object)
}