use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::Result;
use axum::extract::{Path as AxumPath, State};
use beam_core::{
BootstrapWorkflowRunInput, RunChatBinding, SessionScope, WorkflowDispatchOutcome,
WorkflowDispatchRun, WorkflowDispatchSession, WorkflowExecutionHooks, bootstrap_workflow_run,
mint_workflow_run_id, parse_workflow_output, with_workflow_output_protocol,
};
use chrono::Utc;
use serde_json::Value;
use crate::{
AppState, SessionCreateSpec, await_session_final_output, close_session,
create_session_internal, expand_tilde, workflow_cancellation, workflow_host_executors,
workflow_reconcilers, workflow_runtime_driver,
};
#[derive(Clone)]
pub(crate) struct DaemonWorkflowExecutionHooks {
pub(crate) state: AppState,
}
#[async_trait::async_trait]
impl WorkflowExecutionHooks for DaemonWorkflowExecutionHooks {
async fn execute_subagent(
&mut self,
ctx: WorkflowDispatchRun<'_>,
node: &beam_core::SubagentNode,
resolved_prompt: String,
) -> anyhow::Result<WorkflowDispatchOutcome> {
let guard = workflow_cancellation::ActivityTokenGuard::register(
workflow_cancellation::global_cancellation_registry(),
ctx.run_id,
ctx.activity_id,
);
run_workflow_subagent_session(&self.state, ctx, node, resolved_prompt, Some(&guard.token))
.await
}
async fn execute_host_executor(
&mut self,
ctx: WorkflowDispatchRun<'_>,
node: &beam_core::HostExecutorNode,
parsed_input: Value,
) -> anyhow::Result<WorkflowDispatchOutcome> {
let guard = workflow_cancellation::ActivityTokenGuard::register(
workflow_cancellation::global_cancellation_registry(),
ctx.run_id,
ctx.activity_id,
);
run_workflow_host_executor(&self.state, ctx, node, parsed_input, Some(&guard.token)).await
}
fn prepare_host_executor(
&self,
executor_name: &str,
resolved_input: &Value,
) -> anyhow::Result<beam_core::HostExecutorPrepareResult> {
let registry = workflow_host_executors::global_host_executor_registry();
if let Some(executor) = registry.get(executor_name) {
let parsed = executor.parse_input(resolved_input).map_err(|err| {
anyhow::anyhow!("{} parse_input failed: {:#}", executor.name(), err)
})?;
let canonical = executor.canonical_input(&parsed).map_err(|err| {
anyhow::anyhow!("{} canonical_input failed: {:#}", executor.name(), err)
})?;
Ok(beam_core::HostExecutorPrepareResult {
parsed_input: parsed,
canonical_input: canonical,
provider: executor.provider().to_string(),
idempotency_ttl_ms: executor.idempotency_ttl_ms(),
})
} else {
let (provider, idempotency_ttl_ms) =
beam_core::get_host_executor_provider_meta(executor_name);
Ok(beam_core::HostExecutorPrepareResult {
parsed_input: resolved_input.clone(),
canonical_input: resolved_input.clone(),
provider: provider.to_string(),
idempotency_ttl_ms,
})
}
}
async fn recover_dangling_effects(
&mut self,
log: &mut beam_core::EventLog,
_snapshot: &beam_core::RunSnapshotDTO,
) -> anyhow::Result<beam_core::RecoveryResult> {
let registry = workflow_reconcilers::global_reconciler_registry();
let run_dir = self.state.paths.workflow_run_dir(&log.run_id);
let events_before = log.read_all()?.len();
let known_providers: Vec<String> = registry.providers().map(|s| s.to_string()).collect();
for provider in &known_providers {
let current_snapshot = beam_core::read_run_snapshot(&run_dir)
.await?
.ok_or_else(|| anyhow::anyhow!("snapshot disappeared during recovery"))?;
let _ = workflow_reconcilers::reconcile_provider_dangling_effects(
registry,
&self.state,
log,
&run_dir,
provider,
¤t_snapshot,
)
.await?;
}
let after_registered = beam_core::read_run_snapshot(&run_dir)
.await?
.ok_or_else(|| anyhow::anyhow!("snapshot disappeared after registered providers"))?;
let _ = workflow_reconcilers::handle_missing_provider_dangling_effects(
registry,
log,
&after_registered,
)?;
let events_after = log.read_all()?.len();
let had_progress = events_after > events_before;
let final_snapshot = beam_core::read_run_snapshot(&run_dir)
.await?
.ok_or_else(|| anyhow::anyhow!("snapshot disappeared after full recovery"))?;
let has_remaining = !final_snapshot.dangling.effect_attempted.is_empty();
Ok(beam_core::RecoveryResult {
had_progress,
has_remaining_dangling: has_remaining,
})
}
async fn on_activities_cancelled(
&mut self,
activity_ids: &[String],
node_ids: &[String],
run_id: &str,
) {
let registry = workflow_cancellation::global_cancellation_registry();
if let Ok(Some(snap)) =
beam_core::read_run_snapshot(&self.state.paths.workflow_run_dir(run_id)).await
{
if snap.run.cancelled_run_intent.is_some() {
let count = registry.cancel_run(run_id).len();
if count > 0 {
tracing::debug!(
"cancellation registry: cancelled run {} ({} activities)",
run_id,
count
);
}
return;
}
}
for node_id in node_ids {
let count = registry.cancel_node(run_id, node_id).len();
if count > 0 {
tracing::debug!(
"cancellation registry: cancelled node {} in run {} ({} activities)",
node_id,
run_id,
count
);
}
}
for activity_id in activity_ids {
if registry.cancel_activity(run_id, activity_id) {
tracing::debug!(
"cancellation registry: cancelled activity {} in run {}",
activity_id,
run_id
);
}
}
}
}
pub(crate) async fn terminate_workflow_worker_process(state: &AppState, session_id: &str) {
let worker_pid = {
let sessions = state.sessions.lock().await;
sessions.get(session_id).and_then(|s| s.worker_pid)
};
let Some(pid) = worker_pid else {
tracing::debug!(
"terminate_workflow_worker_process: no worker_pid for session {}",
session_id
);
return;
};
let sigint_ok = unsafe { libc::kill(pid as i32, libc::SIGINT) == 0 };
tracing::info!(
"terminate_workflow_worker: SIGINT sent to pid={} (ok={})",
pid,
sigint_ok
);
let grace_duration = std::time::Duration::from_secs(5);
let poll_interval = std::time::Duration::from_millis(200);
let deadline = tokio::time::Instant::now() + grace_duration;
let mut process_exited = false;
let mut has_handle = false;
while tokio::time::Instant::now() < deadline {
let try_wait_exited = {
let mut workers = state.workers.lock().await;
match workers.get_mut(session_id) {
Some(handle) => {
has_handle = true;
match handle.child.try_wait() {
Ok(Some(_status)) => {
true
}
Ok(None) => {
false
}
Err(_) => {
true
}
}
}
None => false,
}
};
if try_wait_exited {
process_exited = true;
tracing::info!(
"terminate_workflow_worker: pid={} exited (detected via try_wait)",
pid
);
break;
}
if !has_handle {
let alive = unsafe { libc::kill(pid as i32, 0) == 0 };
if !alive {
process_exited = true;
tracing::info!(
"terminate_workflow_worker: pid={} exited (detected via kill(0))",
pid
);
break;
}
}
tokio::time::sleep(poll_interval).await;
}
if !process_exited {
let sigkill_ok = unsafe { libc::kill(pid as i32, libc::SIGKILL) == 0 };
tracing::warn!(
"terminate_workflow_worker: SIGKILL sent to pid={} (ok={}) – did not exit after SIGINT + {:?}",
pid,
sigkill_ok,
grace_duration,
);
}
}
async fn run_workflow_subagent_session(
state: &AppState,
ctx: WorkflowDispatchRun<'_>,
node: &beam_core::SubagentNode,
resolved_prompt: String,
cancel_token: Option<&tokio_util::sync::CancellationToken>,
) -> Result<WorkflowDispatchOutcome> {
let Some(bot) = state.bots.get(&node.bot).cloned() else {
return Ok(WorkflowDispatchOutcome::Failed {
error_code: "UnknownProviderError".to_string(),
error_class: "manual".to_string(),
error_message: format!("bot '{}' is not registered.", node.bot),
session: None,
});
};
if cancel_token.map_or(false, |t| t.is_cancelled()) {
return Ok(WorkflowDispatchOutcome::Cancelled {
cancel_origin_event_id: String::new(),
session: None,
});
}
let working_dir = expand_tilde(
&node
.working_dir
.clone()
.or_else(|| bot.working_dir.clone())
.or_else(|| state.config.daemon.working_dirs.first().cloned())
.unwrap_or_else(|| ".".to_string()),
);
let title = node
.base
.description
.clone()
.unwrap_or_else(|| format!("workflow {} {}", ctx.run_id, ctx.node_id));
let session = create_session_internal(
state,
SessionCreateSpec {
title,
chat_id: format!("workflow-{}", ctx.run_id),
chat_type: Some("local".to_string()),
root_message_id: ctx.run_id.to_string(),
quote_target_id: None,
scope: SessionScope::Thread,
thread_id: None,
working_dir: working_dir.clone(),
cli_id: bot.cli_id.clone(),
cli_bin: bot.cli_bin.clone().unwrap_or_else(|| bot.cli_id.clone()),
cli_args: Vec::new(),
prompt: with_workflow_output_protocol(&resolved_prompt),
lark_app_id: "local".to_string(),
owner_open_id: None,
adopted_from: None,
},
)
.await
.map_err(|err| anyhow::anyhow!(err.to_string()))?;
let session_id = session.session_id.clone();
let output = match await_session_final_output(
state,
&session_id,
Duration::from_secs(180),
cancel_token,
)
.await
{
Ok(output) => output,
Err(err) => {
if cancel_token.map_or(false, |t| t.is_cancelled()) {
terminate_workflow_worker_process(state, &session_id).await;
let _ = close_session(State(state.clone()), AxumPath(session_id.clone())).await;
return Ok(WorkflowDispatchOutcome::Cancelled {
cancel_origin_event_id: String::new(),
session: Some(WorkflowDispatchSession {
session_id,
bot_name: node.bot.clone(),
started_at: Utc::now().timestamp_millis().max(0) as u64,
ended_at: Some(Utc::now().timestamp_millis().max(0) as u64),
cli_session_id: None,
lark_app_id: Some("local".to_string()),
cli_id: Some(bot.cli_id.clone()),
working_dir: Some(working_dir),
web_port: None,
log_path: None,
}),
});
}
let _ = close_session(State(state.clone()), AxumPath(session_id.clone())).await;
return Ok(WorkflowDispatchOutcome::Failed {
error_code: "WorkerCrashed".to_string(),
error_class: "retryable".to_string(),
error_message: err.to_string(),
session: Some(WorkflowDispatchSession {
session_id,
bot_name: node.bot.clone(),
started_at: Utc::now().timestamp_millis().max(0) as u64,
ended_at: Some(Utc::now().timestamp_millis().max(0) as u64),
cli_session_id: None,
lark_app_id: Some("local".to_string()),
cli_id: Some(bot.cli_id.clone()),
working_dir: Some(working_dir),
web_port: None,
log_path: None,
}),
});
}
};
let parsed_output = parse_workflow_output(&output).unwrap_or(Value::String(output.clone()));
let _ = close_session(State(state.clone()), AxumPath(session_id.clone())).await;
Ok(WorkflowDispatchOutcome::Succeeded {
output: parsed_output,
session: Some(WorkflowDispatchSession {
session_id,
bot_name: bot.name.clone().unwrap_or_else(|| node.bot.clone()),
started_at: Utc::now().timestamp_millis().max(0) as u64,
ended_at: Some(Utc::now().timestamp_millis().max(0) as u64),
cli_session_id: None,
lark_app_id: Some("local".to_string()),
cli_id: Some(bot.cli_id.clone()),
working_dir: Some(working_dir),
web_port: None,
log_path: None,
}),
})
}
pub(crate) async fn run_workflow_host_executor(
state: &AppState,
ctx: WorkflowDispatchRun<'_>,
node: &beam_core::HostExecutorNode,
parsed_input: Value,
cancel_token: Option<&tokio_util::sync::CancellationToken>,
) -> Result<WorkflowDispatchOutcome> {
if cancel_token.map_or(false, |t| t.is_cancelled()) {
return Ok(WorkflowDispatchOutcome::Cancelled {
cancel_origin_event_id: String::new(),
session: None,
});
}
let registry = workflow_host_executors::global_host_executor_registry();
let executor = match registry.resolve(&node.executor) {
Ok(executor) => executor,
Err(outcome) => return Ok(outcome),
};
match executor.invoke(state, &ctx, node, &parsed_input).await {
Ok(outcome) => Ok(outcome),
Err(err) => Ok(executor.classify_error(&err, &ctx)),
}
}
#[allow(dead_code)]
pub(crate) fn derive_workflow_idempotency_key(
workflow_id: &str,
revision_id: &str,
run_id: &str,
node_id: &str,
attempt_id: &str,
) -> String {
beam_core::derive_workflow_idempotency_key(
workflow_id,
revision_id,
run_id,
node_id,
attempt_id,
)
}
pub(crate) async fn run_workflow_runtime_once(state: &AppState, run_id: &str, workflow_json: &str) {
workflow_runtime_driver::run(state, run_id, workflow_json).await;
}
pub(crate) async fn bootstrap_and_start_workflow_run(
state: &AppState,
workflow_id: &str,
raw_def: &str,
params: &BTreeMap<String, Value>,
initiator: &str,
chat_binding: Option<RunChatBinding>,
) -> Result<beam_core::WorkflowRunBootstrap> {
let run_id = mint_workflow_run_id(workflow_id, Utc::now().timestamp_millis().max(0) as u64);
let bootstrap = bootstrap_workflow_run(
&state.paths,
BootstrapWorkflowRunInput {
run_id: &run_id,
workflow_json: raw_def,
expected_workflow_id: Some(workflow_id),
params,
initiator,
chat_binding,
},
)?;
run_workflow_runtime_once(state, bootstrap.run_id.as_str(), raw_def).await;
Ok(bootstrap)
}