Skip to main content

beam_daemon/
lib.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::path::PathBuf;
3use std::process::Stdio;
4use std::sync::Arc;
5use std::sync::{Mutex as StdMutex, OnceLock};
6use std::time::{Duration, Instant};
7
8mod ask;
9mod card_i18n;
10mod connector_runtime;
11mod connector_store;
12mod daemon_types;
13mod dashboard_support;
14mod dir_select;
15mod final_output;
16mod grant;
17mod lark_api_helpers;
18mod lark_card_builders;
19mod lark_delivery;
20mod lark_dispatch;
21mod lark_history;
22mod lark_identity;
23mod lark_ingress;
24mod lark_parse;
25mod lark_replies;
26mod lark_security;
27mod lark_session_cards;
28mod opencode_adopt_resolver;
29mod persistence;
30mod prompt;
31mod route_handlers;
32mod session_cards;
33mod session_creation;
34mod terminal_auth;
35mod terminal_proxy;
36mod trigger_log;
37mod utils;
38mod webhook_key;
39mod webhook_lifecycle;
40mod worker_lifecycle;
41mod workflow_approval_cards;
42mod workflow_cancellation;
43mod workflow_catalog;
44mod workflow_commands;
45mod workflow_event_fanout;
46mod workflow_execution;
47mod workflow_host_executors;
48mod workflow_progress_card;
49mod workflow_reconcilers;
50mod workflow_resume;
51mod workflow_runtime_driver;
52mod zellij_adopt;
53mod zellij_web;
54
55// Re-export daemon types (used across modules and externally)
56pub use daemon_types::RunOptions;
57pub(crate) use daemon_types::*;
58
59// Re-export workflow catalog items for backward compatibility (used by route handlers and tests)
60pub(crate) use workflow_catalog::*;
61// Re-export workflow execution items for backward compatibility (used by route handlers and tests)
62pub(crate) use workflow_execution::*;
63// Re-export workflow resume items for backward compatibility (used by route handlers and tests)
64pub(crate) use connector_runtime::*;
65pub(crate) use final_output::*;
66pub(crate) use lark_api_helpers::*;
67pub(crate) use lark_card_builders::*;
68pub(crate) use lark_delivery::*;
69pub(crate) use lark_dispatch::*;
70#[allow(unused_imports)]
71pub(crate) use lark_history::*;
72pub(crate) use lark_identity::*;
73pub(crate) use lark_ingress::*;
74pub(crate) use lark_parse::*;
75pub(crate) use lark_replies::*;
76pub(crate) use lark_security::*;
77pub(crate) use lark_session_cards::*;
78pub(crate) use opencode_adopt_resolver::*;
79pub(crate) use persistence::*;
80pub(crate) use route_handlers::*;
81pub(crate) use session_cards::*;
82pub(crate) use session_creation::*;
83pub(crate) use utils::*;
84pub(crate) use worker_lifecycle::*;
85pub(crate) use workflow_approval_cards::*;
86pub(crate) use workflow_resume::*;
87pub(crate) use zellij_adopt::*;
88
89#[cfg(test)]
90use dashboard_support::{
91    WebhookTriggerRecord, extract_dashboard_token, write_webhook_trigger_records,
92};
93use dashboard_support::{
94    dashboard_gate, dashboard_token_is_valid, detect_external_host,
95    load_observed_bot_open_ids_for_app, load_observed_bots_for_chat, mint_dashboard_token,
96    read_webhook_trigger_records, record_observed_bots,
97};
98
99use anyhow::{Context, Result};
100use axum::{
101    Json, Router,
102    body::Bytes,
103    extract::{Path as AxumPath, Query, State},
104    http::{HeaderMap, StatusCode},
105    middleware,
106    response::{IntoResponse, Redirect},
107    routing::{get, get_service, post, put},
108};
109use base64::Engine;
110use beam_core::{
111    AdoptedFrom, AgentAttention, ApiHealth, AttemptResumeRequest, AttentionRequest, BeamPaths,
112    BotConfig, BotSummary, ChatMode, CliUsageLimitState, ColdWorkflowRun, Config,
113    CreateSessionRequest, DaemonOverview, DaemonRuntimeState, DaemonToWorker, DisplayMode,
114    EventDraft, EventLog, EventWindowOpts, FinalOutputKind, FinalOutputRequest, InitConfig,
115    PendingResponseCardState, RestartSessionRequest, ResumeSessionRequest, RunChatBinding,
116    RunStatus, ScheduleChatType, ScreenStatus, Session, SessionGroup, SessionInputRequest,
117    SessionLocateInfo, SessionScope, SessionStatus, SessionSummary, TalkEvaluation, TermActionKey,
118    TranscriptChoice, TuiPromptOption, WaitResolution, WorkerToDaemon, WorkflowActor,
119    WorkflowOutputRef, can_operate, evaluate_talk, grant_restricted, parse_workflow_definition,
120    read_event_window, read_run_events_pure, read_run_snapshot, scan_cold_workflow_runs,
121};
122use chrono::Utc;
123use connector_store::{
124    ConnectorDefinition, ConnectorLifecycleExtractors, ConnectorLoggingPolicy,
125    ConnectorPromptEnvelope, ConnectorRateLimit, ConnectorTarget, ConnectorVerify,
126    delete_connector, get_connector, list_connectors, upsert_connector,
127};
128use feishu_sdk::{
129    card::CardAction,
130    core as feishu_core,
131    event::{
132        Event, EventDispatcher, EventDispatcherConfig, EventHandler, EventHandlerResult, EventResp,
133    },
134    ws::{StreamClient, StreamConfig},
135};
136use hmac::KeyInit;
137use hmac::{Hmac, Mac};
138use reqwest::Client;
139use serde_json::Value;
140use sha2::{Digest, Sha256};
141use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
142use tokio::net::TcpListener;
143use tokio::process::{Child, ChildStdin, Command};
144use tokio::sync::Mutex;
145use tower_http::services::ServeDir;
146use tracing::{debug, error, info, warn};
147use trigger_log::{
148    TriggerLogStats, list_trigger_logs, new_trigger_id as new_trigger_log_id, prune_trigger_logs,
149    summarize_trigger_logs,
150};
151use uuid::Uuid;
152use webhook_key::{
153    create_webhook_secret, delete_webhook_secret, generate_webhook_secret_plaintext,
154    get_webhook_secret, list_webhook_secret_refs, set_webhook_secret,
155};
156use webhook_lifecycle::{begin_webhook_lifecycle_firing, resolve_webhook_lifecycle_group};
157
158pub async fn run(paths: BeamPaths, options: RunOptions) -> Result<()> {
159    let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
160    tokio::fs::create_dir_all(paths.run_dir()).await?;
161    tokio::fs::create_dir_all(paths.logs_dir()).await?;
162    tokio::fs::create_dir_all(paths.sessions_dir()).await?;
163
164    let config = load_config(&paths)?;
165    let bots = load_bot_configs(&paths)?;
166    let mut sessions = load_sessions(&paths).await?;
167    for session in sessions.values_mut() {
168        let marker = read_pending_response_patch_marker(&paths, &session.session_id).await?;
169        if should_treat_pending_card_as_patched_by_marker(
170            session.pending_response_card_id.as_deref(),
171            marker.as_ref(),
172        ) {
173            mark_pending_response_card_patched(session);
174            let _ = clear_pending_response_patch_marker(&paths, &session.session_id).await;
175        }
176    }
177    let listener = TcpListener::bind("127.0.0.1:7893").await?;
178    let addr = listener.local_addr()?;
179    let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
180    let external_host = detect_external_host(&config.web.host);
181    let started_at = Utc::now();
182    let runtime = DaemonRuntimeState {
183        pid: std::process::id(),
184        api_addr: addr.to_string(),
185        started_at,
186        log_path: paths.daemon_log().display().to_string(),
187    };
188    persist_runtime_state(&paths, &runtime).await?;
189
190    // Load persisted in-memory state that should survive restarts.
191    let workflow_progress_cards =
192        workflow_runtime_driver::load_workflow_progress_cards(&paths).await;
193    info!(
194        "loaded {} persisted workflow progress cards",
195        workflow_progress_cards.len()
196    );
197
198    let ask_pending_map = ask::load_ask_pending(&paths).await;
199    info!(
200        "loaded {} persisted ask pending entries",
201        ask_pending_map.len()
202    );
203
204    let grant_pending_map = grant::load_grant_pending(&paths);
205    info!(
206        "loaded {} persisted grant pending entries",
207        grant_pending_map.len()
208    );
209
210    let pending_creates_map = dir_select::load_pending_creates(&paths).await;
211    info!(
212        "loaded {} persisted pending creates",
213        pending_creates_map.len()
214    );
215
216    // Load persisted recent Lark events (dedupe state).
217    let recent_lark_events = load_recent_lark_events(&paths).await;
218    info!(
219        "loaded {} persisted recent lark events",
220        recent_lark_events.len()
221    );
222
223    let state = AppState {
224        paths: paths.clone(),
225        started_at,
226        sessions: Arc::new(Mutex::new(sessions)),
227        workers: Arc::new(Mutex::new(HashMap::new())),
228        attempt_resumes: Arc::new(Mutex::new(HashMap::new())),
229        shutdown: Arc::new(Mutex::new(Some(shutdown_tx))),
230        options,
231        http: Client::new(),
232        config,
233        bots: Arc::new(bots),
234        lark_tokens: Arc::new(Mutex::new(HashMap::new())),
235        chat_mode_cache: Arc::new(Mutex::new(HashMap::new())),
236        recent_lark_events: Arc::new(Mutex::new(recent_lark_events)),
237        inflight_final_output_turns: Arc::new(Mutex::new(HashSet::new())),
238        workflow_progress_cards: Arc::new(Mutex::new(workflow_progress_cards)),
239        ask_pending: Arc::new(Mutex::new(ask_pending_map)),
240        grant_pending: Arc::new(Mutex::new(grant_pending_map)),
241        pending_creates: Arc::new(Mutex::new(pending_creates_map)),
242        dashboard_token: Arc::new(Mutex::new(None)),
243        external_host,
244    };
245
246    if matches!(
247        state.config.lark.event_mode.as_str(),
248        "ws" | "websocket" | "stream"
249    ) {
250        spawn_lark_ws_clients(&state);
251    }
252
253    // Load replay nonces and rate buckets from disk into static stores.
254    {
255        let path = paths.replay_nonces_json();
256        if let Ok(Some(map)) = beam_core::persist::read_json::<HashMap<String, u64>>(&path) {
257            let now = now_ms();
258            let mut guard = replay_nonce_store()
259                .lock()
260                .unwrap_or_else(|p| p.into_inner());
261            for (key, expiry) in map {
262                if expiry > now {
263                    guard.insert(key, expiry);
264                }
265            }
266            info!("loaded {} persisted replay nonces", guard.len());
267        }
268        let path = paths.rate_buckets_json();
269        if let Ok(Some(map)) = beam_core::persist::read_json::<HashMap<String, (u64, u64)>>(&path) {
270            let mut guard = rate_bucket_store()
271                .lock()
272                .unwrap_or_else(|p| p.into_inner());
273            for (key, val) in map {
274                guard.insert(key, val);
275            }
276            info!("loaded {} persisted rate buckets", guard.len());
277        }
278    }
279
280    // Probe bot open_id / app_name from Lark API and persist to bots-info.json.
281    // Best-effort; failures are logged and do not block startup.
282    for bot in state.bots.values() {
283        let paths = state.paths.clone();
284        let bot = bot.clone();
285        tokio::spawn(async move {
286            probe_and_persist_bot_info(&paths, &bot).await;
287        });
288    }
289
290    let restore_candidates = {
291        let mut sessions = state.sessions.lock().await;
292        let restore_candidates = reconcile_restored_sessions_with(
293            &mut sessions,
294            state.config.daemon.quiet_restart,
295            zellij_has_session,
296        );
297        let snapshot = sessions.clone();
298        drop(sessions);
299        persist_sessions(&state.paths, &snapshot).await?;
300        restore_candidates
301    };
302    for session in restore_candidates {
303        match build_init_from_session(&session, &state.config, &state.bots) {
304            Ok(init) => {
305                if let Err(err) = spawn_worker(state.clone(), session.clone(), init).await {
306                    warn!("failed to restore session {}: {}", session.session_id, err);
307                }
308            }
309            Err(err) => warn!(
310                "failed to rebuild init for session {}: {}",
311                session.session_id, err
312            ),
313        }
314    }
315    {
316        let sessions = state.sessions.lock().await;
317        for session in sessions.values() {
318            if let Some(usage_limit) = session.usage_limit.clone() {
319                arm_usage_limit_retry_timer(state.clone(), session.session_id.clone(), usage_limit);
320            }
321        }
322    }
323    // Recover pending final output retries from before restart.
324    {
325        let markers = load_final_output_retry_markers(&state.paths);
326        if !markers.is_empty() {
327            let active_sessions: HashSet<String> = {
328                let sessions = state.sessions.lock().await;
329                sessions
330                    .values()
331                    .filter(|s| s.status == SessionStatus::Active)
332                    .map(|s| s.session_id.clone())
333                    .collect()
334            };
335            let mut recovered = 0usize;
336            let mut skipped = 0usize;
337            for marker in &markers {
338                if !active_sessions.contains(&marker.session_id) {
339                    skipped += 1;
340                    continue; // Session was closed during restart
341                }
342                // Check idempotency: resume only if this turn has NOT yet been delivered
343                let should_resume = {
344                    let sessions = state.sessions.lock().await;
345                    sessions
346                        .get(&marker.session_id)
347                        .map(|s| {
348                            marker.turn_id.as_deref().map_or(true, |tid| {
349                                // Resume if last_final_output_turn_id doesn't match this turn
350                                s.last_final_output_turn_id.as_deref() != Some(tid)
351                            })
352                        })
353                        .unwrap_or(false) // session not found → skip
354                };
355                if should_resume {
356                    info!(
357                        "final output retry: resuming delivery for session {} turn {:?} attempt {}",
358                        marker.session_id, marker.turn_id, marker.attempt
359                    );
360                    schedule_final_output_delivery(
361                        state.clone(),
362                        marker.session_id.clone(),
363                        marker.content.clone(),
364                        marker.turn_id.clone(),
365                        marker.kind,
366                        marker.user_text.clone(),
367                        marker.attempt,
368                    );
369                    recovered += 1;
370                } else {
371                    skipped += 1;
372                }
373            }
374            info!(
375                "final output retry: {} markers recovered, {} skipped (closed/duplicate)",
376                recovered, skipped
377            );
378        }
379    }
380
381    let cold_scan_bots: Vec<String> = state.bots.keys().cloned().collect();
382    for lark_app_id in &cold_scan_bots {
383        match scan_cold_workflow_runs(&state.paths, lark_app_id).await {
384            Ok((runs, stats)) => {
385                if stats.discovered > 0 {
386                    info!(
387                        "cold-scan: discovered {} non-terminal workflow runs for bot {}",
388                        stats.discovered, lark_app_id
389                    );
390                }
391                for skipped in &stats.skipped {
392                    warn!("cold-scan skipped: {}", skipped);
393                }
394                for run in runs {
395                    let run_id = run.run_id.clone();
396                    info!("cold-attaching workflow run {}", run_id);
397                    let s = state.clone();
398                    tokio::spawn(async move {
399                        if let Err(err) = drive_workflow_run_after_cold_attach(s, run).await {
400                            warn!("cold-attach workflow run {} failed: {}", run_id, err);
401                        }
402                    });
403                }
404            }
405            Err(err) => {
406                warn!("cold-scan failed for bot {}: {}", lark_app_id, err);
407            }
408        }
409    }
410
411    async fn drive_workflow_run_after_cold_attach(
412        state: AppState,
413        run: ColdWorkflowRun,
414    ) -> Result<()> {
415        let workflow_json =
416            serde_json::to_string(&run.def).context("failed to serialize workflow definition")?;
417        workflow_runtime_driver::run(&state, &run.run_id, &workflow_json).await;
418        Ok(())
419    }
420
421    async fn create_schedule(
422        State(state): State<AppState>,
423        Json(body): Json<Value>,
424    ) -> Json<Value> {
425        let content = body.get("content").and_then(Value::as_str).unwrap_or("");
426        let schedule_id = uuid::Uuid::new_v4().to_string();
427        let task = serde_json::json!({
428            "scheduleId": schedule_id,
429            "content": content,
430            "createdAt": chrono::Utc::now().to_rfc3339(),
431            "status": "active",
432        });
433        let schedules_path = state.paths.schedules_json();
434        let mut schedules: Vec<Value> = tokio::fs::read_to_string(&schedules_path)
435            .await
436            .ok()
437            .and_then(|raw| serde_json::from_str(&raw).ok())
438            .unwrap_or_default();
439        schedules.push(task.clone());
440        let _ = tokio::fs::write(
441            &schedules_path,
442            serde_json::to_string_pretty(&schedules).unwrap_or_default(),
443        )
444        .await;
445        Json(task)
446    }
447
448    async fn report_session(
449        State(state): State<AppState>,
450        AxumPath(session_id): AxumPath<String>,
451        Json(body): Json<Value>,
452    ) -> Result<Json<Value>, (StatusCode, String)> {
453        let content = body
454            .get("content")
455            .and_then(Value::as_str)
456            .unwrap_or("")
457            .trim()
458            .to_string();
459        if content.is_empty() {
460            return Err((
461                StatusCode::BAD_REQUEST,
462                "content must not be empty".to_string(),
463            ));
464        }
465        let session = {
466            let sessions = state.sessions.lock().await;
467            sessions.get(&session_id).cloned()
468        }
469        .ok_or_else(|| (StatusCode::NOT_FOUND, "session not found".to_string()))?;
470        if session.lark_app_id == "local" {
471            return Ok(Json(serde_json::json!({
472                "ok": true,
473                "sessionId": session_id,
474                "local": true,
475            })));
476        }
477        let Some(bot) = state.bots.get(&session.lark_app_id) else {
478            return Err((StatusCode::NOT_FOUND, "bot not registered".to_string()));
479        };
480        let post = build_report_post_content(&session, &content);
481        let target_message_id = session
482            .quote_target_id
483            .as_deref()
484            .filter(|value| !value.trim().is_empty());
485        let message_id = if let Some(target_message_id) = target_message_id {
486            match lark_reply_post_message(&state, bot, target_message_id, &post).await {
487                Ok(message_id) => message_id,
488                Err(err) => return Err((StatusCode::BAD_GATEWAY, err.to_string())),
489            }
490        } else {
491            match lark_send_post_message(&state, bot, &session.chat_id, &post).await {
492                Ok(message_id) => message_id,
493                Err(err) => return Err((StatusCode::BAD_GATEWAY, err.to_string())),
494            }
495        };
496        Ok(Json(serde_json::json!({
497            "ok": true,
498            "sessionId": session_id,
499            "messageId": message_id,
500            "targetMessageId": target_message_id,
501        })))
502    }
503
504    async fn list_bots(State(state): State<AppState>) -> Json<Vec<BotSummary>> {
505        let sessions = state.sessions.lock().await;
506        Json(
507            state
508                .bots
509                .iter()
510                .map(|(app_id, bot)| {
511                    let active = sessions
512                        .values()
513                        .filter(|s| s.lark_app_id == *app_id && s.status == SessionStatus::Active)
514                        .count();
515                    BotSummary {
516                        lark_app_id: app_id.clone(),
517                        name: bot.name.clone(),
518                        cli_id: bot.cli_id.clone(),
519                        model: bot.model.clone(),
520                        allowed_users: bot.allowed_users.clone(),
521                        allowed_chat_groups: bot.allowed_chat_groups.clone(),
522                        oncall_chats: bot
523                            .oncall_chats
524                            .iter()
525                            .map(|oc| oc.chat_id.clone())
526                            .collect(),
527                        private_card: bot.private_card,
528                        active_sessions: active,
529                    }
530                })
531                .collect(),
532        )
533    }
534
535    async fn get_bot(
536        State(state): State<AppState>,
537        AxumPath(app_id): AxumPath<String>,
538    ) -> Result<Json<BotSummary>, (StatusCode, String)> {
539        let sessions = state.sessions.lock().await;
540        let bot = state
541            .bots
542            .get(&app_id)
543            .ok_or_else(|| (StatusCode::NOT_FOUND, format!("bot {} not found", app_id)))?;
544        let active = sessions
545            .values()
546            .filter(|s| s.lark_app_id == app_id && s.status == SessionStatus::Active)
547            .count();
548        Ok(Json(BotSummary {
549            lark_app_id: app_id,
550            name: bot.name.clone(),
551            cli_id: bot.cli_id.clone(),
552            model: bot.model.clone(),
553            allowed_users: bot.allowed_users.clone(),
554            allowed_chat_groups: bot.allowed_chat_groups.clone(),
555            oncall_chats: bot
556                .oncall_chats
557                .iter()
558                .map(|oc| oc.chat_id.clone())
559                .collect(),
560            private_card: bot.private_card,
561            active_sessions: active,
562        }))
563    }
564
565    async fn list_session_groups(State(state): State<AppState>) -> Json<Vec<SessionGroup>> {
566        let sessions = state.sessions.lock().await;
567        let mut groups: HashMap<String, SessionGroup> = HashMap::new();
568        for session in sessions.values() {
569            let key = session.chat_id.clone();
570            let summary = SessionSummary::from(session);
571            groups
572                .entry(key)
573                .and_modify(|g| g.sessions.push(summary.clone()))
574                .or_insert_with(|| SessionGroup {
575                    chat_id: session.chat_id.clone(),
576                    title: Some(session.title.clone()),
577                    sessions: vec![summary],
578                });
579        }
580        Json(groups.into_values().collect())
581    }
582
583    async fn locate_session(
584        State(state): State<AppState>,
585        AxumPath(session_id): AxumPath<String>,
586    ) -> Result<Json<SessionLocateInfo>, (StatusCode, String)> {
587        let sessions = state.sessions.lock().await;
588        let session = sessions.get(&session_id).ok_or_else(|| {
589            (
590                StatusCode::NOT_FOUND,
591                format!("session {} not found", session_id),
592            )
593        })?;
594        Ok(Json(SessionLocateInfo {
595            session_id: session.session_id.clone(),
596            terminal_url: session.terminal_url.clone(),
597            worker_pid: session.worker_pid,
598        }))
599    }
600
601    async fn overview(State(state): State<AppState>) -> Json<DaemonOverview> {
602        let sessions = state.sessions.lock().await;
603        let active = sessions
604            .values()
605            .filter(|s| s.status == SessionStatus::Active)
606            .count();
607        let closed = sessions
608            .values()
609            .filter(|s| s.status == SessionStatus::Closed)
610            .count();
611        Json(DaemonOverview {
612            pid: std::process::id(),
613            started_at: state.started_at,
614            session_count: sessions.len(),
615            active_session_count: active,
616            closed_session_count: closed,
617            bot_count: state.bots.len(),
618            worker_count: state.workers.lock().await.len(),
619            config_path: state.paths.config_toml().display().to_string(),
620            data_dir: state.paths.root().display().to_string(),
621        })
622    }
623
624    async fn preferences(State(state): State<AppState>) -> Json<Value> {
625        Json(serde_json::json!({
626            "web": state.config.web,
627            "daemon": state.config.daemon,
628            "lark": state.config.lark,
629            "screenAnalyzer": state.config.screen_analyzer,
630        }))
631    }
632
633    async fn auth(State(state): State<AppState>) -> Json<Value> {
634        let token = mint_dashboard_token();
635        let expires_at = Instant::now() + Duration::from_secs(24 * 60 * 60);
636        {
637            let mut guard = state.dashboard_token.lock().await;
638            *guard = Some(DashboardAuthToken {
639                token: token.clone(),
640                expires_at,
641            });
642        }
643        Json(serde_json::json!({
644            "authenticated": true,
645            "token": token,
646            "loginPath": format!("/dashboard/login?token={}", token),
647            "dashboardPath": "/dashboard/",
648            "expiresInSeconds": expires_at
649                .checked_duration_since(Instant::now())
650                .map(|d| d.as_secs())
651                .unwrap_or(0),
652            "mode": state.config.lark.event_mode,
653            "botCount": state.bots.len(),
654            "daemonPid": std::process::id(),
655            "dashboard": {
656                "host": state.config.web.host,
657                "proxyBasePort": state.config.web.proxy_base_port,
658            },
659        }))
660    }
661
662    async fn dashboard_login(
663        State(state): State<AppState>,
664        Query(query): Query<HashMap<String, String>>,
665    ) -> Result<impl IntoResponse, (StatusCode, String)> {
666        let Some(token) = query
667            .get("token")
668            .map(|s| s.trim())
669            .filter(|s| !s.is_empty())
670        else {
671            return Err((
672                StatusCode::BAD_REQUEST,
673                "missing dashboard token".to_string(),
674            ));
675        };
676        if !dashboard_token_is_valid(&state, token).await {
677            return Err((
678                StatusCode::UNAUTHORIZED,
679                "dashboard token expired".to_string(),
680            ));
681        }
682        let mut response = Redirect::temporary("/dashboard/").into_response();
683        response.headers_mut().insert(
684            axum::http::header::SET_COOKIE,
685            axum::http::HeaderValue::from_str(&format!(
686                "beam-dashboard-token={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400",
687                token
688            ))
689            .map_err(internal_error)?,
690        );
691        Ok(response)
692    }
693
694    let protected_dashboard = Router::new()
695        .route(
696            "/api/workflows/definitions",
697            get(list_workflow_definitions_api),
698        )
699        .route(
700            "/api/workflows/definitions/{workflow_id}",
701            get(get_workflow_definition_api),
702        )
703        .route(
704            "/api/workflows/definitions/{workflow_id}/run",
705            post(trigger_workflow_definition_run_api),
706        )
707        .route("/api/workflows/runs", get(list_workflow_runs_api))
708        .route(
709            "/api/workflows/runs/{run_id}/snapshot",
710            get(get_workflow_run_snapshot_api),
711        )
712        .route(
713            "/api/workflows/runs/{run_id}/events",
714            get(get_workflow_run_events),
715        )
716        .route(
717            "/api/workflows/runs/{run_id}/approve",
718            post(approve_workflow_run),
719        )
720        .route(
721            "/api/workflows/runs/{run_id}/reject",
722            post(reject_workflow_run),
723        )
724        .route(
725            "/api/workflows/runs/{run_id}/attempts/{activity_id}/{attempt_id}/resume",
726            post(start_workflow_attempt_resume),
727        )
728        .route(
729            "/api/workflows/runs/{run_id}/attempts/{activity_id}/{attempt_id}/resume/end",
730            post(end_workflow_attempt_resume),
731        )
732        .route(
733            "/api/workflows/runs/{run_id}/cancel",
734            post(cancel_workflow_run),
735        )
736        .route(
737            "/api/workflows/runs/{run_id}/resume",
738            post(resume_workflow_run),
739        )
740        .route("/sessions", post(create_session))
741        .route("/sessions/{session_id}", get(get_session))
742        .route("/sessions/{session_id}/input", post(send_input))
743        .route("/sessions/{session_id}/report", post(report_session))
744        .route("/sessions/{session_id}/refresh", post(refresh_session))
745        .route("/sessions/{session_id}/restart", post(restart_session))
746        .route("/sessions/{session_id}/resume", post(resume_session))
747        .route("/sessions/{session_id}/close", post(close_session))
748        .route(
749            "/api/workflows/{workflow_id}/run",
750            post(trigger_workflow_run),
751        )
752        .route("/api/workflows/{run_id}", get(get_workflow_run))
753        .route("/api/trigger", post(api_trigger))
754        .route(
755            "/adopt/zellij",
756            get(list_zellij_adopt_candidates).post(adopt_zellij_session),
757        )
758        .route("/api/bots", get(list_bots))
759        .route("/api/bots/{app_id}", get(get_bot))
760        .route("/api/preferences", get(preferences))
761        .route("/api/connectors", get(connectors).post(create_connector))
762        .route("/api/connectors/stats", get(connector_stats))
763        .route(
764            "/api/connectors/{id}",
765            get(get_connector_api)
766                .put(update_connector_api)
767                .patch(patch_connector_api)
768                .delete(delete_connector_api),
769        )
770        .route(
771            "/api/webhook-secrets",
772            get(list_webhook_secrets_api).post(create_webhook_secret_api),
773        )
774        .route(
775            "/api/webhook-secrets/{ref}",
776            put(update_webhook_secret_api).delete(delete_webhook_secret_api),
777        )
778        .route("/api/trigger-logs", get(trigger_logs_api))
779        .route("/api/trigger-logs/prune", post(prune_trigger_logs_api))
780        .route("/api/connectors/webhooks", get(list_webhook_triggers))
781        .route("/api/sessions/groups", get(list_session_groups))
782        .route("/api/sessions/{session_id}/locate", get(locate_session))
783        .route("/api/overview", get(overview))
784        .nest_service(
785            "/dashboard",
786            get_service(ServeDir::new("src/dashboard/web")),
787        )
788        .route_layer(middleware::from_fn_with_state(
789            state.clone(),
790            dashboard_gate,
791        ));
792
793    let open_routes = Router::new()
794        .route("/health", get(health))
795        .route("/shutdown", post(shutdown))
796        .route("/sessions", get(list_sessions))
797        .route("/api/auth", get(auth))
798        .route("/dashboard/login", get(dashboard_login))
799        .route("/lark/events/{app_id}", post(handle_lark_event))
800        .route("/lark/cards/{app_id}", post(handle_lark_card_action))
801        .route("/api/schedules", post(create_schedule))
802        .route("/webhook/{workflow_id}", post(handle_webhook_trigger))
803        .route(
804            "/sessions/{session_id}/history",
805            get(lark_history::session_history),
806        )
807        .route(
808            "/sessions/{session_id}/quoted/{message_id}",
809            get(lark_history::quoted_message),
810        )
811        .route("/sessions/{session_id}/final-output", post(final_output))
812        .route("/api/asks", post(ask::create_ask))
813        .route("/api/attention", post(set_attention_route));
814
815    // Start zellij web server and ensure tokens
816    let zellij_web_port = state.config.web.proxy_base_port + 1;
817    zellij_web::ensure_zellij_web(zellij_web_port)
818        .with_context(|| format!("failed to start zellij web server on port {zellij_web_port}"))?;
819    let zellij_tokens = zellij_web::ensure_zellij_web_tokens(
820        &state.paths.zellij_web_tokens_json(),
821        zellij_web_port,
822    )
823    .with_context(|| "failed to create zellij web tokens")?;
824    zellij_web::spawn_zellij_web_watchdog(zellij_web_port);
825
826    // Start terminal proxy with auth bridge
827    let proxy_host = state.config.web.host.clone();
828    let proxy_port = state.config.web.proxy_base_port;
829    let proxy_sessions = state.sessions.clone();
830    let auth_state = terminal_auth::TerminalAuthState::new();
831    // Load persisted used tickets for terminal auth anti-replay.
832    auth_state
833        .load_used_tickets(&paths.used_tickets_json())
834        .await;
835    terminal_proxy::start_proxy(
836        &proxy_host,
837        proxy_port,
838        zellij_web_port,
839        proxy_sessions,
840        zellij_tokens,
841        auth_state.clone(),
842    )
843    .await
844    .with_context(|| format!("failed to start terminal proxy on {proxy_host}:{proxy_port}"))?;
845
846    // Periodic state persistence for stores that are updated frequently.
847    let periodic_paths = paths.clone();
848    let periodic_auth = auth_state.clone();
849    let periodic_recent_events = state.recent_lark_events.clone();
850    tokio::spawn(async move {
851        loop {
852            tokio::time::sleep(std::time::Duration::from_secs(30)).await;
853            // Save replay nonces
854            let replay_map: HashMap<String, u64> = {
855                let guard = replay_nonce_store()
856                    .lock()
857                    .unwrap_or_else(|p| p.into_inner());
858                guard.clone()
859            };
860            if !replay_map.is_empty() {
861                let path = periodic_paths.replay_nonces_json();
862                let _ = tokio::task::spawn_blocking(move || {
863                    let _ = beam_core::persist::atomic_write_json(&path, &replay_map);
864                })
865                .await;
866            } else {
867                let _ = tokio::fs::remove_file(periodic_paths.replay_nonces_json()).await;
868            }
869            // Save rate buckets
870            let rate_map: HashMap<String, (u64, u64)> = {
871                let guard = rate_bucket_store()
872                    .lock()
873                    .unwrap_or_else(|p| p.into_inner());
874                guard.clone()
875            };
876            if !rate_map.is_empty() {
877                let path = periodic_paths.rate_buckets_json();
878                let _ = tokio::task::spawn_blocking(move || {
879                    let _ = beam_core::persist::atomic_write_json(&path, &rate_map);
880                })
881                .await;
882            } else {
883                let _ = tokio::fs::remove_file(periodic_paths.rate_buckets_json()).await;
884            }
885            // Save used tickets
886            periodic_auth
887                .save_used_tickets(&periodic_paths.used_tickets_json())
888                .await;
889            // Save recent Lark events dedupe state
890            {
891                let events = periodic_recent_events.lock().await;
892                save_recent_lark_events(&periodic_paths, &events).await;
893            }
894        }
895    });
896
897    // Schedule loop: periodically check schedules and trigger due tasks.
898    let schedule_paths = paths.clone();
899    let schedule_state = state.clone();
900    tokio::spawn(async move {
901        loop {
902            tokio::time::sleep(std::time::Duration::from_secs(30)).await;
903            let tasks = match beam_core::list_tasks(&schedule_paths) {
904                Ok(tasks) => tasks,
905                Err(_) => continue,
906            };
907            let now = chrono::Utc::now();
908            for task in &tasks {
909                if !task.enabled {
910                    continue;
911                }
912                let Some(next_run_at_str) = task.next_run_at.as_deref() else {
913                    continue;
914                };
915                let Ok(next_run_at) = chrono::DateTime::parse_from_rfc3339(next_run_at_str) else {
916                    continue;
917                };
918                let next_run_at = next_run_at.with_timezone(&chrono::Utc);
919                if next_run_at > now {
920                    continue;
921                }
922                // Task is due: execute it.
923                info!("schedule: triggering task {} ({})", task.id, task.name);
924                let state = schedule_state.clone();
925                let task_id = task.id.clone();
926                let task_prompt = task.prompt.clone();
927                let task_working_dir = task.working_dir.clone();
928                let task_chat_id = task.chat_id.clone();
929                let task_lark_app_id = task.lark_app_id.clone();
930                let task_root_message_id = task.root_message_id.clone();
931                let task_scope = task.scope.clone();
932                let task_chat_type = task.chat_type.clone();
933                let task_name = task.name.clone();
934                let paths = schedule_paths.clone();
935                tokio::spawn(async move {
936                    let result = execute_schedule_task(
937                        &state,
938                        &task_id,
939                        &task_prompt,
940                        &task_working_dir,
941                        &task_chat_id,
942                        task_lark_app_id.as_deref(),
943                        task_root_message_id.as_deref(),
944                        task_scope.as_deref(),
945                        task_chat_type.as_ref(),
946                        &task_name,
947                    )
948                    .await;
949                    let success = result.is_ok();
950                    let error = result.as_ref().err().map(|e| e.to_string());
951                    let _ = beam_core::mark_run(&paths, &task_id, success, error.as_deref(), None);
952                    if let Err(err) = result {
953                        warn!("schedule task {} failed: {}", task_id, err);
954                    }
955                });
956            }
957        }
958    });
959
960    let app = Router::new()
961        .merge(protected_dashboard)
962        .merge(open_routes)
963        .with_state(state);
964
965    info!("beam daemon listening on {}", addr);
966    axum::serve(listener, app)
967        .with_graceful_shutdown(async move {
968            let _ = shutdown_rx.await;
969        })
970        .await?;
971
972    let _ = tokio::fs::remove_file(paths.runtime_state_json()).await;
973    Ok(())
974}
975
976#[cfg(test)]
977pub(crate) mod tests {
978    pub(crate) mod test_helpers;
979    use test_helpers::*;
980
981    use super::*;
982    use beam_core::{
983        BootstrapWorkflowRunInput, WorkflowDispatchOutcome, WorkflowDispatchRun,
984        bootstrap_workflow_run,
985    };
986
987    #[test]
988    fn parses_lark_history_text_and_post_content() {
989        let item = serde_json::json!({
990            "message_id": "om_1",
991            "root_id": "om_root",
992            "thread_id": "omt_1",
993            "chat_id": "oc_1",
994            "msg_type": "post",
995            "body": {
996                "content": serde_json::json!({
997                    "content": [[
998                        { "tag": "text", "text": "hello" },
999                        { "tag": "a", "text": "link" }
1000                    ]]
1001                }).to_string()
1002            },
1003            "sender": { "id": "ou_1", "sender_type": "user" },
1004            "create_time": "1234"
1005        });
1006        let parsed = parse_lark_history_message(&item);
1007        assert_eq!(parsed.message_id, "om_1");
1008        assert_eq!(parsed.root_id.as_deref(), Some("om_root"));
1009        assert_eq!(parsed.content, "hello link");
1010        assert_eq!(parsed.create_time, Some(1234));
1011    }
1012
1013    #[tokio::test]
1014    async fn list_chat_history_returns_chronological_tail() {
1015        let _env_lock = lark_base_url_env_lock().lock().expect("lark env lock");
1016        let base_url = start_mock_lark_server().await;
1017        let _env_guard = LarkBaseUrlEnvGuard::set(&base_url);
1018        let app_id = "app-history-chat";
1019        let bot = make_bot(app_id);
1020        let state = make_state(
1021            temp_paths("history-chat"),
1022            HashMap::from([(app_id.to_string(), bot.clone())]),
1023        );
1024
1025        let raw = lark_list_chat_history(&state, &bot, "chat-1", 10)
1026            .await
1027            .expect("chat history");
1028        let messages = raw
1029            .iter()
1030            .map(parse_lark_history_message)
1031            .collect::<Vec<_>>();
1032        assert_eq!(
1033            messages
1034                .iter()
1035                .map(|m| m.content.as_str())
1036                .collect::<Vec<_>>(),
1037            vec!["chat oldest", "chat newest"]
1038        );
1039    }
1040
1041    #[tokio::test]
1042    async fn list_thread_history_uses_session_thread_id_first() {
1043        let _env_lock = lark_base_url_env_lock().lock().expect("lark env lock");
1044        let base_url = start_mock_lark_server().await;
1045        let _env_guard = LarkBaseUrlEnvGuard::set(&base_url);
1046        let app_id = "app-history-thread";
1047        let bot = make_bot(app_id);
1048        let state = make_state(
1049            temp_paths("history-thread"),
1050            HashMap::from([(app_id.to_string(), bot.clone())]),
1051        );
1052        let mut session = make_session("sess-history-thread");
1053        session.lark_app_id = app_id.to_string();
1054        session.root_message_id = "omt_not_a_message".to_string();
1055        session.thread_id = Some("omt_direct_thread".to_string());
1056
1057        let raw = lark_list_thread_history(&state, &bot, &session, 10)
1058            .await
1059            .expect("thread history");
1060        let messages = raw
1061            .iter()
1062            .map(parse_lark_history_message)
1063            .collect::<Vec<_>>();
1064        assert_eq!(messages.len(), 2);
1065        assert_eq!(messages[0].thread_id.as_deref(), Some("omt_direct_thread"));
1066        assert_eq!(messages[0].content, "thread one");
1067    }
1068
1069    #[tokio::test]
1070    async fn quoted_message_api_fetches_message_detail() {
1071        let _env_lock = lark_base_url_env_lock().lock().expect("lark env lock");
1072        let base_url = start_mock_lark_server().await;
1073        let _env_guard = LarkBaseUrlEnvGuard::set(&base_url);
1074        let app_id = "app-quoted";
1075        let state = make_state(
1076            temp_paths("quoted"),
1077            HashMap::from([(app_id.to_string(), make_bot(app_id))]),
1078        );
1079        let mut session = make_session("sess-quoted");
1080        session.lark_app_id = app_id.to_string();
1081        {
1082            let mut sessions = state.sessions.lock().await;
1083            sessions.insert(session.session_id.clone(), session.clone());
1084        }
1085
1086        let Json(value) = quoted_message(
1087            State(state),
1088            AxumPath((session.session_id.clone(), "om_quoted".to_string())),
1089        )
1090        .await
1091        .expect("quoted message");
1092        assert_eq!(
1093            value.pointer("/message/messageId").and_then(Value::as_str),
1094            Some("om_quoted")
1095        );
1096        assert_eq!(
1097            value.pointer("/message/content").and_then(Value::as_str),
1098            Some("quoted detail")
1099        );
1100    }
1101
1102    #[tokio::test]
1103    async fn quoted_message_api_errors_when_session_missing() {
1104        let app_id = "app-quoted-missing";
1105        let state = make_state(
1106            temp_paths("quoted-missing"),
1107            HashMap::from([(app_id.to_string(), make_bot(app_id))]),
1108        );
1109
1110        let err = quoted_message(
1111            State(state),
1112            AxumPath(("missing-session".to_string(), "om_quoted".to_string())),
1113        )
1114        .await
1115        .expect_err("missing session should fail");
1116        assert_eq!(err.0, StatusCode::NOT_FOUND);
1117    }
1118
1119    #[tokio::test]
1120    async fn session_history_api_returns_session_scoped_messages() {
1121        let _env_lock = lark_base_url_env_lock().lock().expect("lark env lock");
1122        let base_url = start_mock_lark_server().await;
1123        let _env_guard = LarkBaseUrlEnvGuard::set(&base_url);
1124        let app_id = "app-history-api";
1125        let state = make_state(
1126            temp_paths("history-api"),
1127            HashMap::from([(app_id.to_string(), make_bot(app_id))]),
1128        );
1129        let mut session = make_session("sess-history-api");
1130        session.lark_app_id = app_id.to_string();
1131        session.scope = SessionScope::Chat;
1132        session.status = SessionStatus::Active;
1133        {
1134            let mut sessions = state.sessions.lock().await;
1135            sessions.insert(session.session_id.clone(), session.clone());
1136        }
1137
1138        let Json(value) = session_history(
1139            State(state),
1140            AxumPath(session.session_id.clone()),
1141            Query(LarkHistoryQuery {
1142                limit: Some(10),
1143                scope: Some("session".to_string()),
1144            }),
1145        )
1146        .await
1147        .expect("history api");
1148        assert_eq!(value.get("scope").and_then(Value::as_str), Some("chat"));
1149        assert_eq!(value.get("total").and_then(Value::as_u64), Some(2));
1150        assert_eq!(
1151            value.pointer("/messages/0/content").and_then(Value::as_str),
1152            Some("chat oldest")
1153        );
1154    }
1155
1156    #[tokio::test]
1157    async fn list_workflow_definitions_prefers_first_search_path_and_hashes_canonically() {
1158        let paths = temp_paths("workflow-defs");
1159        maybe_remove_dir(&paths.root().to_path_buf());
1160        let dir_a = paths.root().join("workflows-a");
1161        let dir_b = paths.root().join("workflows-b");
1162        std::fs::create_dir_all(&dir_a).unwrap();
1163        std::fs::create_dir_all(&dir_b).unwrap();
1164        let def_a = r#"{"workflowId":"flow-a","version":1,"params":{"name":{"type":"string","required":true}},"nodes":{"root":{"type":"hostExecutor","executor":"beam-schedule","input":{"name":"demo","schedule":"0 9 * * *","parsed":{"kind":"cron","expr":"0 9 * * *","display":"0 9 * * *"},"prompt":"Schedule demo","workingDir":"/tmp/demo","chatId":"oc_demo","scope":"thread"},"unsafeAllowUngated":true}}}"#;
1165        let def_b = r#"{"workflowId":"flow-a","version":2,"nodes":{"alt":{"type":"subagent","bot":"bot-a","prompt":"hi"}}}"#;
1166        tokio::fs::write(dir_a.join("flow-a.workflow.json"), def_a)
1167            .await
1168            .unwrap();
1169        tokio::fs::write(dir_b.join("flow-a.workflow.json"), def_b)
1170            .await
1171            .unwrap();
1172
1173        let defs = list_workflow_definitions_in(vec![dir_a.clone(), dir_b.clone()])
1174            .await
1175            .expect("defs");
1176        assert_eq!(defs.len(), 1);
1177        let def = &defs[0];
1178        assert_eq!(def.workflow_id, "flow-a");
1179        assert_eq!(def.version, 1);
1180        assert_eq!(
1181            def.path,
1182            dir_a.join("flow-a.workflow.json").display().to_string()
1183        );
1184        assert_eq!(def.param_count, 1);
1185        assert_eq!(def.required_param_count, 1);
1186        assert_eq!(def.node_count, 1);
1187        assert_eq!(def.revision_id.len(), 64);
1188        let _ = std::fs::remove_dir_all(paths.root());
1189    }
1190
1191    #[tokio::test]
1192    async fn list_workflow_runs_respects_terminal_filter_and_status_filters() {
1193        let paths = temp_paths("workflow-runs");
1194        maybe_remove_dir(&paths.root().to_path_buf());
1195        let params: BTreeMap<String, Value> =
1196            BTreeMap::from([(String::from("name"), Value::String("beam".to_string()))]);
1197        bootstrap_workflow_run(
1198            &paths,
1199            BootstrapWorkflowRunInput {
1200                run_id: "run-active",
1201                workflow_json: r#"{"workflowId":"flow-active","version":1,"params":{"name":{"type":"string"}},"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"hello"}}}"#,
1202                expected_workflow_id: Some("flow-active"),
1203                params: &params,
1204                initiator: "cli",
1205                chat_binding: Some(RunChatBinding {
1206                    chat_id: "chat-1".to_string(),
1207                    lark_app_id: "app-1".to_string(),
1208                }),
1209            },
1210        )
1211        .unwrap();
1212        bootstrap_workflow_run(
1213            &paths,
1214            BootstrapWorkflowRunInput {
1215                run_id: "run-done",
1216                workflow_json: r#"{"workflowId":"flow-done","version":1,"params":{"name":{"type":"string"}},"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"hello"}}}"#,
1217                expected_workflow_id: Some("flow-done"),
1218                params: &params,
1219                initiator: "cli",
1220                chat_binding: Some(RunChatBinding {
1221                    chat_id: "chat-2".to_string(),
1222                    lark_app_id: "app-2".to_string(),
1223                }),
1224            },
1225        )
1226        .unwrap();
1227        {
1228            let mut log = EventLog::new("run-done", paths.workflow_runs_dir()).unwrap();
1229            let _ = log
1230                .append(EventDraft {
1231                    event_type: "runSucceeded".to_string(),
1232                    actor: WorkflowActor::Scheduler,
1233                    payload: serde_json::json!({
1234                        "outputRef": {
1235                            "outputHash": "sha256:done",
1236                            "outputPath": paths.workflow_run_dir("run-done").join("blobs").join("done").display().to_string(),
1237                            "outputBytes": 1,
1238                            "outputSchemaVersion": 1,
1239                            "contentType": "application/json",
1240                        }
1241                    }),
1242                    timestamp: None,
1243                    payload_hash: None,
1244                })
1245                .unwrap();
1246        }
1247
1248        let default_rows = list_workflow_runs(&paths, false, None).await.expect("runs");
1249        assert_eq!(default_rows.len(), 1);
1250        assert_eq!(default_rows[0].run_id, "run-active");
1251        assert_eq!(default_rows[0].chat_id.as_deref(), Some("chat-1"));
1252
1253        let all_rows = list_workflow_runs(&paths, true, None)
1254            .await
1255            .expect("all runs");
1256        assert_eq!(all_rows.len(), 2);
1257
1258        let filtered_rows = list_workflow_runs(
1259            &paths,
1260            true,
1261            Some(HashSet::from([String::from("succeeded")])),
1262        )
1263        .await
1264        .expect("filtered");
1265        assert_eq!(filtered_rows.len(), 1);
1266        assert_eq!(filtered_rows[0].run_id, "run-done");
1267        assert_eq!(filtered_rows[0].status, "succeeded");
1268
1269        let _ = std::fs::remove_dir_all(paths.root());
1270    }
1271
1272    #[tokio::test]
1273    async fn load_workflow_catalog_definition_in_hashes_canonically() {
1274        let paths = temp_paths("workflow-catalog-canonical");
1275        maybe_remove_dir(&paths.root().to_path_buf());
1276        let dir_a = paths.root().join("catalog-a");
1277        let dir_b = paths.root().join("catalog-b");
1278        std::fs::create_dir_all(&dir_a).unwrap();
1279        std::fs::create_dir_all(&dir_b).unwrap();
1280        let raw_a = r#"{"workflowId":"flow-catalog","version":1,"nodes":{"root":{"type":"subagent","bot":"bot-a","prompt":"hi","workingDir":"/tmp/demo"}}}"#;
1281        let raw_b = r#"
1282        {
1283            "nodes": {
1284                "root": {
1285                    "workingDir": "/tmp/demo",
1286                    "prompt": "hi",
1287                    "bot": "bot-a",
1288                    "type": "subagent"
1289                }
1290            },
1291            "version": 1,
1292            "workflowId": "flow-catalog"
1293        }
1294        "#;
1295        tokio::fs::write(dir_a.join("flow-catalog.workflow.json"), raw_a)
1296            .await
1297            .unwrap();
1298        tokio::fs::write(dir_b.join("flow-catalog.workflow.json"), raw_b)
1299            .await
1300            .unwrap();
1301
1302        let def_a = load_workflow_catalog_definition_in(
1303            "flow-catalog",
1304            vec![dir_a.join("flow-catalog.workflow.json")],
1305        )
1306        .await
1307        .expect("catalog a")
1308        .expect("catalog a present");
1309        let def_b = load_workflow_catalog_definition_in(
1310            "flow-catalog",
1311            vec![dir_b.join("flow-catalog.workflow.json")],
1312        )
1313        .await
1314        .expect("catalog b")
1315        .expect("catalog b present");
1316
1317        assert_eq!(def_a.revision_id, def_b.revision_id);
1318        assert_eq!(
1319            def_a.path,
1320            dir_a
1321                .join("flow-catalog.workflow.json")
1322                .display()
1323                .to_string()
1324        );
1325        assert_eq!(
1326            def_b.path,
1327            dir_b
1328                .join("flow-catalog.workflow.json")
1329                .display()
1330                .to_string()
1331        );
1332        let _ = std::fs::remove_dir_all(paths.root());
1333    }
1334
1335    #[tokio::test]
1336    async fn webhook_trigger_records_round_trip_and_list_api_shape() {
1337        let paths = temp_paths("webhook-triggers");
1338        maybe_remove_dir(&paths.root().to_path_buf());
1339        let records = vec![WebhookTriggerRecord {
1340            workflow_id: "flow-a".to_string(),
1341            created_at: "2026-06-07T00:00:00Z".to_string(),
1342            secret_valid: true,
1343            request_body: serde_json::json!({"hello":"world"}),
1344            run_id: Some("run-1".to_string()),
1345            workflow_run_id: Some("run-1".to_string()),
1346            status: "accepted".to_string(),
1347        }];
1348        write_webhook_trigger_records(&paths, &records).expect("write records");
1349        let loaded = read_webhook_trigger_records(&paths)
1350            .await
1351            .expect("read records");
1352        assert_eq!(loaded, records);
1353        maybe_remove_dir(&paths.root().to_path_buf());
1354    }
1355
1356    #[tokio::test]
1357    async fn dashboard_auth_helpers_support_header_and_cookie_tokens() {
1358        let paths = temp_paths("dashboard-auth");
1359        maybe_remove_dir(&paths.root().to_path_buf());
1360        let (shutdown_tx, _shutdown_rx) = tokio::sync::oneshot::channel();
1361        let state = AppState {
1362            paths: paths.clone(),
1363            started_at: Utc::now(),
1364            sessions: Arc::new(Mutex::new(HashMap::new())),
1365            workers: Arc::new(Mutex::new(HashMap::new())),
1366            attempt_resumes: Arc::new(Mutex::new(HashMap::new())),
1367            shutdown: Arc::new(Mutex::new(Some(shutdown_tx))),
1368            options: RunOptions {
1369                worker_exe: PathBuf::from("/bin/true"),
1370            },
1371            http: Client::new(),
1372            config: Config::default(),
1373            bots: Arc::new(HashMap::new()),
1374            lark_tokens: Arc::new(Mutex::new(HashMap::new())),
1375            chat_mode_cache: Arc::new(Mutex::new(HashMap::new())),
1376            recent_lark_events: Arc::new(Mutex::new(HashMap::new())),
1377            inflight_final_output_turns: Arc::new(Mutex::new(HashSet::new())),
1378            workflow_progress_cards: Arc::new(Mutex::new(HashMap::new())),
1379            ask_pending: Arc::new(Mutex::new(HashMap::new())),
1380            grant_pending: Arc::new(Mutex::new(HashMap::new())),
1381            pending_creates: Arc::new(Mutex::new(HashMap::new())),
1382            dashboard_token: Arc::new(Mutex::new(None)),
1383            external_host: "localhost".to_string(),
1384        };
1385
1386        let token = mint_dashboard_token();
1387        {
1388            let mut guard = state.dashboard_token.lock().await;
1389            *guard = Some(DashboardAuthToken {
1390                token: token.clone(),
1391                expires_at: Instant::now() + Duration::from_secs(30),
1392            });
1393        }
1394        let header_token = extract_dashboard_token(
1395            &HeaderMap::from_iter([(
1396                axum::http::header::AUTHORIZATION,
1397                axum::http::HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(),
1398            )]),
1399            None,
1400        )
1401        .expect("bearer token");
1402        assert_eq!(header_token, token);
1403
1404        let cookie_token = extract_dashboard_token(
1405            &HeaderMap::from_iter([(
1406                axum::http::header::COOKIE,
1407                axum::http::HeaderValue::from_str(&format!(
1408                    "beam-dashboard-token={}; foo=bar",
1409                    token
1410                ))
1411                .unwrap(),
1412            )]),
1413            None,
1414        )
1415        .expect("cookie token");
1416        assert_eq!(cookie_token, token);
1417
1418        assert!(dashboard_token_is_valid(&state, &token).await);
1419        let mut expired = state.dashboard_token.lock().await;
1420        *expired = Some(DashboardAuthToken {
1421            token: token.clone(),
1422            expires_at: Instant::now() - Duration::from_secs(1),
1423        });
1424        drop(expired);
1425        assert!(!dashboard_token_is_valid(&state, &token).await);
1426        maybe_remove_dir(&paths.root().to_path_buf());
1427    }
1428
1429    #[tokio::test]
1430    async fn beam_schedule_host_executor_creates_task_and_returns_task_id() {
1431        let paths = temp_paths("schedule-host");
1432        maybe_remove_dir(&paths.root().to_path_buf());
1433        let (shutdown_tx, _shutdown_rx) = tokio::sync::oneshot::channel();
1434        let state = AppState {
1435            paths: paths.clone(),
1436            started_at: Utc::now(),
1437            sessions: Arc::new(Mutex::new(HashMap::new())),
1438            workers: Arc::new(Mutex::new(HashMap::new())),
1439            shutdown: Arc::new(Mutex::new(Some(shutdown_tx))),
1440            options: RunOptions {
1441                worker_exe: PathBuf::from("/bin/true"),
1442            },
1443            http: Client::new(),
1444            config: Config::default(),
1445            bots: Arc::new(HashMap::new()),
1446            lark_tokens: Arc::new(Mutex::new(HashMap::new())),
1447            chat_mode_cache: Arc::new(Mutex::new(HashMap::new())),
1448            recent_lark_events: Arc::new(Mutex::new(HashMap::new())),
1449            attempt_resumes: Arc::new(Mutex::new(HashMap::new())),
1450            inflight_final_output_turns: Arc::new(Mutex::new(HashSet::new())),
1451            workflow_progress_cards: Arc::new(Mutex::new(HashMap::new())),
1452            ask_pending: Arc::new(Mutex::new(HashMap::new())),
1453            grant_pending: Arc::new(Mutex::new(HashMap::new())),
1454            pending_creates: Arc::new(Mutex::new(HashMap::new())),
1455            dashboard_token: Arc::new(Mutex::new(None)),
1456            external_host: "localhost".to_string(),
1457        };
1458        let node = beam_core::HostExecutorNode {
1459            base: beam_core::workflow_definition::NodeBase {
1460                description: None,
1461                depends: None,
1462                human_gate: None,
1463                retry_policy: None,
1464                timeout_ms: None,
1465                max_output_bytes: None,
1466                output_schema: None,
1467                unsafe_allow_ungated: None,
1468            },
1469            executor: "beam-schedule".to_string(),
1470            input: serde_json::json!({
1471                "name": "schedule-demo daily 9am",
1472                "schedule": "0 9 * * *",
1473                "parsed": {
1474                    "kind": "cron",
1475                    "expr": "0 9 * * *",
1476                    "display": "0 9 * * *"
1477                },
1478                "prompt": "Schedule demo: run workflow self-check.",
1479                "workingDir": "/tmp/beam-schedule-demo",
1480                "chatId": "oc_workflow_demo",
1481                "scope": "thread"
1482            }),
1483        };
1484        let outcome = run_workflow_host_executor(
1485            &state,
1486            WorkflowDispatchRun {
1487                run_id: "run-1",
1488                workflow_id: "flow-a",
1489                revision_id: "rev-1",
1490                activity_id: "activity-1",
1491                attempt_id: "attempt-1",
1492                node_id: "node-1",
1493            },
1494            &node,
1495            node.input.clone(),
1496            None,
1497        )
1498        .await
1499        .expect("host executor");
1500        match outcome {
1501            WorkflowDispatchOutcome::Succeeded { output, session } => {
1502                assert_eq!(
1503                    output["taskId"],
1504                    derive_workflow_idempotency_key(
1505                        "flow-a",
1506                        "rev-1",
1507                        "run-1",
1508                        "node-1",
1509                        "attempt-1"
1510                    )
1511                );
1512                assert!(session.is_none());
1513            }
1514            other => panic!("unexpected outcome: {other:?}"),
1515        }
1516        assert!(paths.schedules_json().exists());
1517        maybe_remove_dir(&paths.root().to_path_buf());
1518    }
1519
1520    #[tokio::test]
1521    async fn clear_pending_response_patch_marker_is_idempotent() {
1522        let paths = temp_paths("pending-marker-clear");
1523        maybe_remove_dir(&paths.root().to_path_buf());
1524
1525        write_pending_response_patch_marker(&paths, "sess-1", "om_card")
1526            .await
1527            .expect("write marker");
1528        clear_pending_response_patch_marker(&paths, "sess-1")
1529            .await
1530            .expect("clear once");
1531        clear_pending_response_patch_marker(&paths, "sess-1")
1532            .await
1533            .expect("clear twice");
1534        let marker = read_pending_response_patch_marker(&paths, "sess-1")
1535            .await
1536            .expect("read marker");
1537        assert!(marker.is_none());
1538
1539        maybe_remove_dir(&paths.root().to_path_buf());
1540    }
1541
1542    #[tokio::test]
1543    async fn park_stream_card_merges_with_existing_on_disk_entries() {
1544        let paths = temp_paths("park-merge");
1545        maybe_remove_dir(&paths.root().to_path_buf());
1546
1547        let mut existing = HashMap::new();
1548        existing.insert(
1549            "persisted_a".to_string(),
1550            FrozenCard {
1551                message_id: "om_disk_a".to_string(),
1552                content: "old".to_string(),
1553                title: "older".to_string(),
1554                display_mode: Some(DisplayMode::Hidden),
1555                image_key: None,
1556            },
1557        );
1558        save_frozen_cards(&paths, "sess-merge", &existing)
1559            .await
1560            .expect("save existing");
1561
1562        let mut session = make_session("sess-merge");
1563        session.status = SessionStatus::Active;
1564        session.closed_at = None;
1565        session.stream_card_id = Some("om_live".to_string());
1566        session.stream_card_nonce = Some("nonce_live".to_string());
1567
1568        park_stream_card(&paths, &session)
1569            .await
1570            .expect("park succeeds");
1571        let frozen_cards = load_frozen_cards(&paths, &session.session_id)
1572            .await
1573            .expect("load merged");
1574        assert_eq!(frozen_cards.len(), 2);
1575        assert!(frozen_cards.contains_key("persisted_a"));
1576        assert!(frozen_cards.contains_key("nonce_live"));
1577
1578        maybe_remove_dir(&paths.root().to_path_buf());
1579    }
1580
1581    #[tokio::test]
1582    async fn load_clicked_frozen_card_only_returns_stale_snapshot() {
1583        let paths = temp_paths("load-frozen");
1584        maybe_remove_dir(&paths.root().to_path_buf());
1585
1586        let mut cards = HashMap::new();
1587        cards.insert(
1588            "nonce_old".to_string(),
1589            FrozenCard {
1590                message_id: "om_old".to_string(),
1591                content: "frozen output".to_string(),
1592                title: "old turn".to_string(),
1593                display_mode: Some(DisplayMode::Screenshot),
1594                image_key: None,
1595            },
1596        );
1597        save_frozen_cards(&paths, "sess-load", &cards)
1598            .await
1599            .expect("save succeeds");
1600
1601        let mut session = make_session("sess-load");
1602        session.status = SessionStatus::Active;
1603        session.closed_at = None;
1604        session.stream_card_nonce = Some("nonce_live".to_string());
1605
1606        let stale = load_clicked_frozen_card(&paths, &session, Some("nonce_old"))
1607            .await
1608            .expect("load stale");
1609        assert_eq!(
1610            stale.as_ref().map(|card| card.content.as_str()),
1611            Some("frozen output")
1612        );
1613
1614        let live = load_clicked_frozen_card(&paths, &session, Some("nonce_live"))
1615            .await
1616            .expect("load live");
1617        assert!(live.is_none());
1618
1619        session.stream_card_nonce = None;
1620        let after_turn_reset = load_clicked_frozen_card(&paths, &session, Some("nonce_old"))
1621            .await
1622            .expect("load after reset");
1623        assert_eq!(
1624            after_turn_reset.as_ref().map(|card| card.content.as_str()),
1625            Some("frozen output")
1626        );
1627
1628        maybe_remove_dir(&paths.root().to_path_buf());
1629    }
1630
1631    #[test]
1632    fn dir_select_filter_response_includes_card_and_toast() {
1633        // Verify that a filter action response returns both toast and card fields,
1634        // so the Feishu client updates the card inline instead of just showing a toast.
1635        let card_json = dir_select::build_dir_select_card(
1636            "pending-1",
1637            "/home/user/projects",
1638            "test message",
1639            &[".".to_string(), "project-a".to_string()],
1640            &[
1641                ".".to_string(),
1642                "project-a".to_string(),
1643                "project-b".to_string(),
1644            ],
1645            Some(&["project-a".to_string()]),
1646            Some("project"),
1647            None,
1648            Some("zh"),
1649        );
1650        let card_data: Value = serde_json::from_str(&card_json).expect("card should be valid JSON");
1651        let toast_msg = "已筛选 \"project\"";
1652        let response = serde_json::json!({
1653            "toast": { "type": "success", "content": toast_msg },
1654            "card": { "type": "raw", "data": card_data }
1655        });
1656
1657        // Response must contain both toast and card fields
1658        assert!(
1659            response.get("toast").is_some(),
1660            "response must have toast field"
1661        );
1662        assert!(
1663            response.get("card").is_some(),
1664            "response must have card field"
1665        );
1666        assert_eq!(
1667            response.pointer("/toast/content").and_then(Value::as_str),
1668            Some(toast_msg)
1669        );
1670        assert_eq!(
1671            response.pointer("/card/type").and_then(Value::as_str),
1672            Some("raw")
1673        );
1674        // The card data should contain the filtered directory button
1675        let card_str = response.pointer("/card/data").unwrap().to_string();
1676        assert!(
1677            card_str.contains("project-a"),
1678            "filtered card must show project-a"
1679        );
1680        assert!(
1681            card_str.contains("dir_select_pick"),
1682            "filtered card must retain pickable buttons"
1683        );
1684    }
1685
1686    #[test]
1687    fn dir_select_filter_response_card_contains_filtered_dirs_only() {
1688        // When filtering with a keyword, the response card should show only matching dirs.
1689        let all_dirs: Vec<String> = vec![
1690            ".".to_string(),
1691            "project-a".to_string(),
1692            "project-b".to_string(),
1693            "other".to_string(),
1694        ];
1695        let filtered: Vec<String> = vec!["project-a".to_string(), "project-b".to_string()];
1696
1697        let card_json = dir_select::build_dir_select_card(
1698            "pending-2",
1699            "/root",
1700            "test",
1701            &[],
1702            &all_dirs,
1703            Some(&filtered),
1704            Some("project"),
1705            None,
1706            Some("zh"),
1707        );
1708        let card_data: Value = serde_json::from_str(&card_json).expect("card should be valid JSON");
1709        let response = serde_json::json!({
1710            "toast": { "type": "success", "content": "已筛选 \"project\"" },
1711            "card": { "type": "raw", "data": card_data }
1712        });
1713
1714        let card_str = response.pointer("/card/data").unwrap().to_string();
1715        // Must contain the matching dirs
1716        assert!(
1717            card_str.contains("project-a"),
1718            "card must contain project-a"
1719        );
1720        assert!(
1721            card_str.contains("project-b"),
1722            "card must contain project-b"
1723        );
1724        // Should NOT contain the non-matching dir "other"
1725        assert!(
1726            !card_str.contains("\"working_dir\":\"other\""),
1727            "card must NOT contain non-matching dir 'other'"
1728        );
1729        // Must still have pickable buttons
1730        assert!(
1731            card_str.contains("dir_select_pick"),
1732            "filtered card must retain dir_select_pick buttons"
1733        );
1734    }
1735
1736    #[test]
1737    fn dir_select_filter_response_empty_keyword_shows_all_dirs() {
1738        // Empty keyword should show all candidates (clear filter / show all).
1739        let all_dirs: Vec<String> = vec![
1740            ".".to_string(),
1741            "project-a".to_string(),
1742            "project-b".to_string(),
1743        ];
1744
1745        let card_json = dir_select::build_dir_select_card(
1746            "pending-3",
1747            "/root",
1748            "test",
1749            &[],
1750            &all_dirs,
1751            Some(&all_dirs),
1752            None,
1753            None,
1754            Some("zh"),
1755        );
1756        let card_data: Value = serde_json::from_str(&card_json).expect("card should be valid JSON");
1757        let response = serde_json::json!({
1758            "toast": { "type": "success", "content": "已显示全部目录" },
1759            "card": { "type": "raw", "data": card_data }
1760        });
1761
1762        // Response must have both fields
1763        assert!(response.get("toast").is_some());
1764        assert!(response.get("card").is_some());
1765
1766        let card_str = response.pointer("/card/data").unwrap().to_string();
1767        // All dirs should be present as buttons
1768        assert!(card_str.contains("dir_select_pick"));
1769        // The search keyword field should be empty (cleared)
1770        let v: Value = serde_json::from_str(&card_str).expect("valid card JSON");
1771        let elements = v["elements"].as_array().unwrap();
1772        let form = elements
1773            .iter()
1774            .find(|e| e["tag"].as_str() == Some("form"))
1775            .unwrap();
1776        let form_els = form["elements"].as_array().unwrap();
1777        let input = form_els
1778            .iter()
1779            .find(|e| e["tag"].as_str() == Some("input"))
1780            .unwrap();
1781        assert_eq!(
1782            input["default_value"].as_str(),
1783            Some(""),
1784            "empty keyword should clear the input field"
1785        );
1786    }
1787
1788    #[test]
1789    fn dir_select_filter_response_empty_result_shows_warning() {
1790        // When no directories match, the card should show a warning message.
1791        let all_dirs: Vec<String> = vec![".".to_string(), "project-a".to_string()];
1792        let filtered: Vec<String> = vec![];
1793
1794        let card_json = dir_select::build_dir_select_card(
1795            "pending-4",
1796            "/root",
1797            "test",
1798            &[],
1799            &all_dirs,
1800            Some(&filtered),
1801            Some("nonexistent"),
1802            Some("⚠️ 没有目录匹配关键词 \"nonexistent\",请尝试其他关键词。"),
1803            Some("zh"),
1804        );
1805        let card_data: Value = serde_json::from_str(&card_json).expect("card should be valid JSON");
1806        let response = serde_json::json!({
1807            "toast": { "type": "success", "content": "已筛选 \"nonexistent\"" },
1808            "card": { "type": "raw", "data": card_data }
1809        });
1810
1811        let card_str = response.pointer("/card/data").unwrap().to_string();
1812        // Must show the warning message
1813        assert!(
1814            card_str.contains("没有目录匹配"),
1815            "empty result card must show warning message"
1816        );
1817        assert!(
1818            card_str.contains("请尝试其他关键词"),
1819            "empty result card must suggest trying other keywords"
1820        );
1821        // Must still have the search form to allow retry
1822        assert!(
1823            card_str.contains("dir_search_keyword"),
1824            "empty result card must retain search input"
1825        );
1826    }
1827
1828    #[test]
1829    fn dir_select_card_uses_action_buttons() {
1830        // Verify that the card exposes directory choices as clickable buttons
1831        // AND a select_static dropdown as an alternative entry point.
1832        let all_dirs: Vec<String> = (0..10).map(|i| format!("project-{}", i)).collect();
1833        let card_json = dir_select::build_dir_select_card(
1834            "pid",
1835            "/root",
1836            "test",
1837            &all_dirs,
1838            &all_dirs,
1839            None,
1840            None,
1841            None,
1842            Some("zh"),
1843        );
1844        let v: Value = serde_json::from_str(&card_json).expect("valid card JSON");
1845        let elements = v["elements"].as_array().unwrap();
1846
1847        let action_elements: Vec<&Value> = elements
1848            .iter()
1849            .filter(|e| e["tag"].as_str() == Some("action"))
1850            .collect();
1851        assert!(
1852            !action_elements.is_empty(),
1853            "card must contain directory action groups"
1854        );
1855
1856        let buttons: Vec<&Value> = action_elements
1857            .iter()
1858            .flat_map(|e| e["actions"].as_array().into_iter().flatten())
1859            .filter(|child| child["tag"].as_str() == Some("button"))
1860            .collect();
1861        // 10 dirs ≤ MAX_BUTTON_DIRS(40), so all show as buttons
1862        assert_eq!(buttons.len(), 10, "should have one button per directory");
1863        for (i, button) in buttons.iter().enumerate() {
1864            assert_eq!(
1865                button.pointer("/value/action").and_then(Value::as_str),
1866                Some("dir_select_pick")
1867            );
1868            assert_eq!(
1869                button.pointer("/value/pending_id").and_then(Value::as_str),
1870                Some("pid")
1871            );
1872            assert_eq!(
1873                button.pointer("/value/working_dir").and_then(Value::as_str),
1874                Some(format!("project-{}", i).as_str())
1875            );
1876        }
1877        // Card now includes select_static inside action.actions as alternative entry point.
1878        // A bare select_static at the top level is not valid in Feishu card schema.
1879        let select_static = elements
1880            .iter()
1881            .filter(|e| e["tag"].as_str() == Some("action"))
1882            .find_map(|e| {
1883                e["actions"].as_array().and_then(|actions| {
1884                    actions
1885                        .iter()
1886                        .find(|a| a["tag"].as_str() == Some("select_static"))
1887                })
1888            })
1889            .expect("card should contain select_static dropdown inside action.actions");
1890        // Verify no bare select_static at the top level
1891        assert!(
1892            elements
1893                .iter()
1894                .find(|e| e["tag"].as_str() == Some("select_static"))
1895                .is_none(),
1896            "select_static must not be a bare top-level element"
1897        );
1898        let options = select_static["options"].as_array().unwrap();
1899        assert_eq!(
1900            options.len(),
1901            10,
1902            "select_static should have all 10 options"
1903        );
1904        // Verify first option value is valid JSON with correct fields
1905        let first_opt_val = options[0]["value"].as_str().unwrap();
1906        let opt_parsed: Value = serde_json::from_str(first_opt_val).unwrap();
1907        assert_eq!(opt_parsed["action"].as_str(), Some("dir_select_pick"));
1908        assert_eq!(opt_parsed["pending_id"].as_str(), Some("pid"));
1909        assert!(opt_parsed["working_dir"].as_str().is_some());
1910    }
1911
1912    #[test]
1913    fn grant_add_chat_grant_includes_quota_key() {
1914        let mut config = serde_json::json!([{
1915            "larkAppId": "app-1",
1916            "larkAppSecret": "s",
1917            "cliId": "codex",
1918            "allowedUsers": ["ou_owner"]
1919        }]);
1920        grant::add_chat_grant(&mut config, "app-1", "chat-1", "ou_user", Some(5)).unwrap();
1921        let bot = &config.as_array().unwrap()[0];
1922        let grants = bot["chatGrants"]["chat-1"].as_array().unwrap();
1923        assert!(grants.iter().any(|v| v.as_str() == Some("ou_user")));
1924        let quota = &bot["quotaState"]["chat:chat-1:ou_user"];
1925        assert_eq!(quota["limit"].as_u64().unwrap(), 5);
1926        assert_eq!(quota["used"].as_u64().unwrap(), 0);
1927    }
1928
1929    #[test]
1930    fn grant_revoke_removes_from_all_lists() {
1931        let mut config = serde_json::json!([{
1932            "larkAppId": "app-1",
1933            "larkAppSecret": "s",
1934            "cliId": "codex",
1935            "allowedUsers": ["ou_owner", "ou_user"],
1936            "chatGrants": {"chat-1": ["ou_user"]},
1937            "globalGrants": ["ou_user"],
1938            "quotaState": {"chat:chat-1:ou_user": {"limit": 5, "used": 3}}
1939        }]);
1940        grant::revoke_grant(
1941            &mut config,
1942            "app-1",
1943            "chat-1",
1944            "ou_user",
1945            &["ou_owner".to_string()],
1946        )
1947        .unwrap();
1948        let bot = &config.as_array().unwrap()[0];
1949        assert!(
1950            !bot["allowedUsers"]
1951                .as_array()
1952                .unwrap()
1953                .iter()
1954                .any(|v| v.as_str() == Some("ou_user"))
1955        );
1956        assert!(
1957            bot["chatGrants"]["chat-1"]
1958                .as_array()
1959                .unwrap_or(&vec![])
1960                .is_empty()
1961        );
1962        assert!(bot["globalGrants"].as_array().unwrap_or(&vec![]).is_empty());
1963        assert!(bot["quotaState"].as_object().unwrap().is_empty());
1964    }
1965
1966    #[test]
1967    fn grant_cannot_revoke_owner() {
1968        let mut config = serde_json::json!([{
1969            "larkAppId": "app-1",
1970            "larkAppSecret": "s",
1971            "cliId": "codex",
1972            "allowedUsers": ["ou_owner"]
1973        }]);
1974        let result = grant::revoke_grant(
1975            &mut config,
1976            "app-1",
1977            "chat-1",
1978            "ou_owner",
1979            &["ou_owner".to_string()],
1980        );
1981        assert!(result.is_err());
1982    }
1983
1984    #[tokio::test]
1985    async fn inbound_quota_consumes_and_exhausts() {
1986        let paths = temp_paths("inbound-quota");
1987        maybe_remove_dir(&paths.root().to_path_buf());
1988        std::fs::create_dir_all(paths.root()).unwrap();
1989        let bot = BotConfig {
1990            name: None,
1991            lark_app_id: "app-1".to_string(),
1992            lark_app_secret: "secret".to_string(),
1993            cli_id: "codex".to_string(),
1994            cli_bin: None,
1995            cli_args: Vec::new(),
1996            skip_working_dir_prompt: false,
1997            model: None,
1998            working_dir: None,
1999            lark_encrypt_key: None,
2000            lark_verification_token: None,
2001            allowed_users: vec!["ou_owner".to_string()],
2002            private_card: false,
2003            allowed_chat_groups: Vec::new(),
2004            chat_grants: std::collections::HashMap::from([(
2005                "chat-1".to_string(),
2006                vec!["ou_user".to_string()],
2007            )]),
2008            global_grants: Vec::new(),
2009            oncall_chats: Vec::new(),
2010            restrict_grant_commands: false,
2011            message_quota: None,
2012            quota_state: std::collections::HashMap::from([(
2013                "chat:chat-1:ou_user".to_string(),
2014                beam_core::QuotaEntry { limit: 2, used: 1 },
2015            )]),
2016        };
2017        let app_id = bot.lark_app_id.clone();
2018        std::fs::write(
2019            paths.bots_json(),
2020            serde_json::to_string_pretty(&serde_json::json!([{
2021                "larkAppId": "app-1",
2022                "larkAppSecret": "secret",
2023                "cliId": "codex",
2024                "allowedUsers": ["ou_owner"],
2025                "chatGrants": {"chat-1": ["ou_user"]},
2026                "globalGrants": [],
2027                "oncallChats": [],
2028                "restrictGrantCommands": false,
2029                "quotaState": {
2030                    "chat:chat-1:ou_user": { "limit": 2, "used": 1 }
2031                }
2032            }]))
2033            .unwrap(),
2034        )
2035        .unwrap();
2036        let state = make_state(paths.clone(), HashMap::from([(app_id, bot)]));
2037        let before = consume_inbound_quota(&state, "app-1", "chat:chat-1:ou_user")
2038            .await
2039            .expect("quota consume");
2040        assert!(before.allowed);
2041        assert!(before.exhausted);
2042        let raw = std::fs::read_to_string(paths.bots_json()).unwrap();
2043        let value: Value = serde_json::from_str(&raw).unwrap();
2044        assert_eq!(
2045            value[0]["quotaState"]["chat:chat-1:ou_user"]["used"]
2046                .as_u64()
2047                .unwrap(),
2048            2
2049        );
2050
2051        let after = consume_inbound_quota(&state, "app-1", "chat:chat-1:ou_user")
2052            .await
2053            .expect("quota consume");
2054        assert!(!after.allowed);
2055        assert!(after.exhausted);
2056        let raw = std::fs::read_to_string(paths.bots_json()).unwrap();
2057        let value: Value = serde_json::from_str(&raw).unwrap();
2058        assert_eq!(
2059            value[0]["quotaState"]["chat:chat-1:ou_user"]["used"]
2060                .as_u64()
2061                .unwrap(),
2062            2
2063        );
2064        maybe_remove_dir(&paths.root().to_path_buf());
2065    }
2066
2067    #[test]
2068    fn schedule_create_appends_to_file() {
2069        let tmp = std::env::temp_dir().join(format!("beam-sched-test-{}", uuid::Uuid::new_v4()));
2070        let paths = BeamPaths::from_root(&tmp);
2071        std::fs::create_dir_all(&tmp).unwrap();
2072
2073        let task = serde_json::json!({
2074            "scheduleId": "sched-1",
2075            "content": "daily at 9am",
2076            "createdAt": "2026-01-01T00:00:00Z",
2077            "status": "active",
2078        });
2079        let schedules_path = paths.schedules_json();
2080        std::fs::write(
2081            &schedules_path,
2082            serde_json::to_string_pretty(&vec![task]).unwrap(),
2083        )
2084        .unwrap();
2085
2086        let loaded: Vec<serde_json::Value> =
2087            serde_json::from_str(&std::fs::read_to_string(&schedules_path).unwrap()).unwrap();
2088        assert_eq!(loaded.len(), 1);
2089        assert_eq!(loaded[0]["scheduleId"].as_str().unwrap(), "sched-1");
2090
2091        let _ = std::fs::remove_dir_all(&tmp);
2092    }
2093
2094    // -----------------------------------------------------------------------
2095    // Task 6.3: Worker termination tests
2096    // -----------------------------------------------------------------------
2097
2098    /// Spawn a child process, register it in both `state.sessions` and
2099    /// `state.workers`, then call `terminate_workflow_worker_process`.
2100    /// Verify that the *`try_wait()` path* detects the child's exit promptly
2101    /// (well before the 5-second grace), so that a SIGINT-responsive worker
2102    /// does not suffer a pointless full-grace wait + SIGKILL.
2103    #[tokio::test]
2104    async fn terminate_workflow_worker_process_exits_early_via_try_wait() {
2105        let paths = temp_paths("terminate-trywait");
2106        maybe_remove_dir(&paths.root().to_path_buf());
2107        let state = make_state(paths.clone(), HashMap::new());
2108
2109        // Spawn a long-running "worker" (sleep 60).
2110        let mut child = tokio::process::Command::new("sleep")
2111            .arg("60")
2112            .stdin(std::process::Stdio::piped())
2113            .stdout(std::process::Stdio::null())
2114            .stderr(std::process::Stdio::null())
2115            .spawn()
2116            .expect("spawn sleep");
2117
2118        let worker_pid = child.id().expect("child should have a pid");
2119        let session_id = "session-trywait";
2120
2121        // Register both the session *and* the worker handle so that the
2122        // grace poll uses try_wait() rather than the zombie-prone kill(0).
2123        {
2124            let mut sessions = state.sessions.lock().await;
2125            sessions.insert(
2126                session_id.to_string(),
2127                Session {
2128                    session_id: session_id.to_string(),
2129                    worker_pid: Some(worker_pid),
2130                    status: SessionStatus::Active,
2131                    closed_at: None,
2132                    ..make_session(session_id)
2133                },
2134            );
2135        }
2136        {
2137            let stdin = child.stdin.take().expect("stdin");
2138            state.workers.lock().await.insert(
2139                session_id.to_string(),
2140                WorkerHandle {
2141                    child,
2142                    stdin: std::sync::Arc::new(tokio::sync::Mutex::new(stdin)),
2143                },
2144            );
2145        }
2146
2147        // Verify the process is alive before termination.
2148        let alive_before = unsafe { libc::kill(worker_pid as i32, 0) == 0 };
2149        assert!(alive_before, "child should be alive before termination");
2150
2151        // Terminate — `sleep` honours SIGINT, so try_wait should detect the
2152        // exit within a few poll cycles.
2153        let start = tokio::time::Instant::now();
2154        terminate_workflow_worker_process(&state, session_id).await;
2155        let elapsed = start.elapsed();
2156
2157        // The grace period is 5 s.  A SIGINT-responsive process should exit
2158        // *much* faster (typically < 1 s).  We use 3 s as a generous upper
2159        // bound to prove we didn't wait the full grace.
2160        assert!(
2161            elapsed < std::time::Duration::from_secs(3),
2162            "try_wait should detect exit well before 5 s grace, got {:?}",
2163            elapsed
2164        );
2165
2166        // Retrieve the child and verify its exit status.
2167        let mut child = {
2168            let mut workers = state.workers.lock().await;
2169            workers
2170                .remove(session_id)
2171                .expect("worker handle should still be there")
2172                .child
2173        };
2174        let exit_status = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait())
2175            .await
2176            .expect("wait should not time out")
2177            .expect("wait should succeed");
2178
2179        assert!(
2180            !exit_status.success(),
2181            "sleep process should be killed by signal (exit status: {:?})",
2182            exit_status
2183        );
2184
2185        maybe_remove_dir(&paths.root().to_path_buf());
2186    }
2187
2188    /// Fallback path: when the child is *not* registered in `state.workers`,
2189    /// `terminate_workflow_worker_process` falls back to `kill(pid, 0)`.  A
2190    /// responsive child still exits, but the zombie means `kill(0)` keeps
2191    /// returning success, so we wait the full 5-second grace and escalate to
2192    /// SIGKILL.  This test documents the *current behaviour* of the fallback
2193    /// and verifies the child is killed regardless.
2194    #[tokio::test]
2195    async fn terminate_workflow_worker_process_fallback_kills_child() {
2196        let paths = temp_paths("terminate-fallback");
2197        maybe_remove_dir(&paths.root().to_path_buf());
2198        let state = make_state(paths.clone(), HashMap::new());
2199
2200        let mut child = tokio::process::Command::new("sleep")
2201            .arg("60")
2202            .stdin(std::process::Stdio::piped())
2203            .stdout(std::process::Stdio::null())
2204            .stderr(std::process::Stdio::null())
2205            .spawn()
2206            .expect("spawn sleep");
2207
2208        let worker_pid = child.id().expect("child should have a pid");
2209        let session_id = "session-fallback";
2210
2211        // Session has worker_pid but *no* worker handle in state.workers.
2212        {
2213            let mut sessions = state.sessions.lock().await;
2214            sessions.insert(
2215                session_id.to_string(),
2216                Session {
2217                    session_id: session_id.to_string(),
2218                    worker_pid: Some(worker_pid),
2219                    status: SessionStatus::Active,
2220                    closed_at: None,
2221                    ..make_session(session_id)
2222                },
2223            );
2224        }
2225
2226        let alive_before = unsafe { libc::kill(worker_pid as i32, 0) == 0 };
2227        assert!(alive_before, "child should be alive before termination");
2228
2229        // Without a handle, the grace loop falls back to kill(pid, 0) which
2230        // is zombie-prone → we'll wait the full grace + send SIGKILL.  The
2231        // child is killed eventually.
2232        let start = tokio::time::Instant::now();
2233        terminate_workflow_worker_process(&state, session_id).await;
2234        let elapsed = start.elapsed();
2235
2236        // Fallback path will wait close to the full 5 s grace period.
2237        assert!(
2238            elapsed >= std::time::Duration::from_secs(3),
2239            "fallback should wait at least most of the grace, got {:?}",
2240            elapsed
2241        );
2242
2243        let exit_status = tokio::time::timeout(std::time::Duration::from_secs(10), child.wait())
2244            .await
2245            .expect("child wait should not time out")
2246            .expect("child wait should succeed");
2247
2248        assert!(
2249            !exit_status.success(),
2250            "sleep process should be killed by signal (exit status: {:?})",
2251            exit_status
2252        );
2253
2254        maybe_remove_dir(&paths.root().to_path_buf());
2255    }
2256
2257    /// Verify that `terminate_workflow_worker_process` is a no-op when there
2258    /// is no worker PID (session exists but worker was never spawned).
2259    #[tokio::test]
2260    async fn terminate_workflow_worker_process_no_pid_is_noop() {
2261        let paths = temp_paths("terminate-no-pid");
2262        maybe_remove_dir(&paths.root().to_path_buf());
2263        let state = make_state(paths.clone(), HashMap::new());
2264        let session_id = "session-no-pid";
2265
2266        {
2267            let mut sessions = state.sessions.lock().await;
2268            sessions.insert(
2269                session_id.to_string(),
2270                Session {
2271                    session_id: session_id.to_string(),
2272                    worker_pid: None,
2273                    status: SessionStatus::Active,
2274                    closed_at: None,
2275                    ..make_session(session_id)
2276                },
2277            );
2278        }
2279
2280        // Should not panic or error.
2281        terminate_workflow_worker_process(&state, session_id).await;
2282
2283        // Session should still exist and be active.
2284        {
2285            let sessions = state.sessions.lock().await;
2286            let session = sessions.get(session_id).expect("session should exist");
2287            assert_eq!(session.status, SessionStatus::Active);
2288            assert!(session.worker_pid.is_none());
2289        }
2290
2291        maybe_remove_dir(&paths.root().to_path_buf());
2292    }
2293
2294    /// Verify that when we call cancel_run on a run that has an active
2295    /// cancellation token registered, the token is cancelled immediately
2296    /// (existing behaviour from Task 6.2), and the registry is cleaned up.
2297    #[tokio::test]
2298    async fn cancel_run_clears_registry_and_session_cleanup_works() {
2299        use beam_core::{BootstrapWorkflowRunInput, bootstrap_workflow_run};
2300
2301        let paths = temp_paths("cancel-session-cleanup");
2302        maybe_remove_dir(&paths.root().to_path_buf());
2303        let state = make_state(paths.clone(), HashMap::new());
2304        let run_id = "run-cancel-cleanup";
2305
2306        // Bootstrap a human-gate workflow so the run stays in Waiting state.
2307        let def = r#"{"workflowId":"flow-a","version":1,"nodes":{"nodeA":{"type":"hostExecutor","executor":"beam-shell","input":{"command":"echo hello"},"humanGate":{"stage":"approve","prompt":"wait"}}}}"#;
2308        let _ = bootstrap_workflow_run(
2309            &paths,
2310            BootstrapWorkflowRunInput {
2311                run_id,
2312                workflow_json: def,
2313                expected_workflow_id: Some("flow-a"),
2314                params: &BTreeMap::<String, Value>::new(),
2315                initiator: "test",
2316                chat_binding: None,
2317            },
2318        )
2319        .expect("bootstrap");
2320
2321        crate::run_workflow_runtime_once(&state, run_id, def).await;
2322
2323        // Register a fake activity token to simulate active dispatch.
2324        let reg = crate::workflow_cancellation::global_cancellation_registry();
2325        let token = reg.register_activity(run_id, &format!("{}::work::nodeA", run_id));
2326        assert_eq!(reg.total_activities(), 1);
2327
2328        // Cancel the run.
2329        let outcome =
2330            crate::workflow_commands::cancel_run(&state, run_id, Some("test".to_string()))
2331                .await
2332                .expect("cancel");
2333
2334        assert!(outcome.ok);
2335        assert_eq!(outcome.status, "cancelled");
2336
2337        // Token should be cancelled and registry cleaned up.
2338        assert!(token.is_cancelled());
2339        assert_eq!(reg.total_activities(), 0);
2340
2341        maybe_remove_dir(&paths.root().to_path_buf());
2342    }
2343
2344    // -----------------------------------------------------------------------
2345    // Task 7.2: cold attach 使用统一 recovery run loop
2346    // -----------------------------------------------------------------------
2347
2348    /// Verifies that cold scan discovers non-terminal runs and skips terminal
2349    /// (succeeded/failed/cancelled) runs, even when both have chat bindings.
2350    #[tokio::test]
2351    async fn cold_scan_discovers_non_terminal_and_skips_terminal_runs() {
2352        use beam_core::{
2353            BootstrapWorkflowRunInput, EventDraft, EventLog, WorkflowActor, bootstrap_workflow_run,
2354            scan_cold_workflow_runs,
2355        };
2356
2357        let paths = temp_paths("cold-scan-disc");
2358        maybe_remove_dir(&paths.root().to_path_buf());
2359
2360        let lark_app_id = "app-cold-scan";
2361        let def = r#"{"workflowId":"flow-cs","version":1,"nodes":{"a":{"type":"subagent","bot":"bot","prompt":"hello"}}}"#;
2362        let params: BTreeMap<String, Value> = BTreeMap::new();
2363        let binding = beam_core::RunChatBinding {
2364            chat_id: "chat-1".to_string(),
2365            lark_app_id: lark_app_id.to_string(),
2366        };
2367
2368        // Non-terminal run (no terminal event written yet — just bootstrapped).
2369        bootstrap_workflow_run(
2370            &paths,
2371            BootstrapWorkflowRunInput {
2372                run_id: "run-nonterm",
2373                workflow_json: def,
2374                expected_workflow_id: Some("flow-cs"),
2375                params: &params,
2376                initiator: "test",
2377                chat_binding: Some(binding.clone()),
2378            },
2379        )
2380        .expect("bootstrap nonterm");
2381
2382        // Terminal run — write runSucceeded manually.
2383        bootstrap_workflow_run(
2384            &paths,
2385            BootstrapWorkflowRunInput {
2386                run_id: "run-term",
2387                workflow_json: def,
2388                expected_workflow_id: Some("flow-cs"),
2389                params: &params,
2390                initiator: "test",
2391                chat_binding: Some(binding),
2392            },
2393        )
2394        .expect("bootstrap term");
2395        {
2396            let mut log = EventLog::new("run-term", paths.workflow_runs_dir()).unwrap();
2397            log.append(EventDraft {
2398                event_type: "runSucceeded".to_string(),
2399                actor: WorkflowActor::Scheduler,
2400                payload: serde_json::json!({}),
2401                timestamp: None,
2402                payload_hash: None,
2403            })
2404            .unwrap();
2405        }
2406
2407        let (runs, stats) = scan_cold_workflow_runs(&paths, lark_app_id).await.unwrap();
2408        assert_eq!(
2409            stats.discovered, 1,
2410            "only the non-terminal run should be discovered"
2411        );
2412        assert_eq!(runs.len(), 1);
2413        assert_eq!(runs[0].run_id, "run-nonterm");
2414        assert!(
2415            stats.skipped.is_empty(),
2416            "no runs should be skipped with errors"
2417        );
2418
2419        maybe_remove_dir(&paths.root().to_path_buf());
2420    }
2421
2422    /// Verifies that cold-attaching a workflow with an open human-gate wait
2423    /// does NOT terminalize it — the unified driver / run_loop recovery
2424    /// correctly returns AwaitingWait and leaves the wait dangling.
2425    #[tokio::test]
2426    async fn cold_attach_open_human_gate_wait_not_terminalized() {
2427        use beam_core::{
2428            BootstrapWorkflowRunInput, RunStatus, bootstrap_workflow_run, read_run_snapshot,
2429        };
2430
2431        let paths = temp_paths("cold-attach-open");
2432        maybe_remove_dir(&paths.root().to_path_buf());
2433        let state = make_state(paths.clone(), HashMap::new());
2434        let run_id = "run-cold-open";
2435
2436        // Human-gate workflow: will create a wait and stay in AwaitingWait.
2437        let def = r#"{"workflowId":"flow-co","version":1,"nodes":{"gate":{"type":"hostExecutor","executor":"beam-shell","input":{"command":"echo hi"},"humanGate":{"stage":"approve","prompt":"Approve?"}}}}"#;
2438        let binding = beam_core::RunChatBinding {
2439            chat_id: "oc_test".to_string(),
2440            lark_app_id: "app_test".to_string(),
2441        };
2442        bootstrap_workflow_run(
2443            &paths,
2444            BootstrapWorkflowRunInput {
2445                run_id,
2446                workflow_json: def,
2447                expected_workflow_id: Some("flow-co"),
2448                params: &BTreeMap::<String, Value>::new(),
2449                initiator: "test",
2450                chat_binding: Some(binding),
2451            },
2452        )
2453        .expect("bootstrap");
2454
2455        // Advance once to create the wait.
2456        crate::run_workflow_runtime_once(&state, run_id, def).await;
2457
2458        // Verify we have an open wait (not terminal).
2459        let sn = read_run_snapshot(&paths.workflow_run_dir(run_id))
2460            .await
2461            .unwrap()
2462            .unwrap();
2463        assert!(!sn.dangling.waits.is_empty(), "should have an open wait");
2464        assert!(
2465            !matches!(
2466                sn.run.status,
2467                RunStatus::Succeeded | RunStatus::Failed | RunStatus::Cancelled
2468            ),
2469            "run should NOT be terminal"
2470        );
2471
2472        // Simulate cold attach: call the unified driver again.
2473        // The driver calls run_loop which has built-in recovery; it should
2474        // detect the open wait and return AwaitingWait, NOT terminalize.
2475        workflow_runtime_driver::run(&state, run_id, def).await;
2476
2477        // After cold attach, the wait should still be open and the run
2478        // should still be non-terminal.
2479        let sn2 = read_run_snapshot(&paths.workflow_run_dir(run_id))
2480            .await
2481            .unwrap()
2482            .unwrap();
2483        assert!(
2484            !sn2.dangling.waits.is_empty(),
2485            "open wait should still be dangling after cold attach"
2486        );
2487        assert!(
2488            !matches!(
2489                sn2.run.status,
2490                RunStatus::Succeeded | RunStatus::Failed | RunStatus::Cancelled
2491            ),
2492            "run should NOT be terminal after cold attach with open wait"
2493        );
2494
2495        maybe_remove_dir(&paths.root().to_path_buf());
2496    }
2497
2498    /// Verifies that cold-attaching a workflow whose wait was resolved but
2499    /// whose terminal event was never written (e.g. crash after resolution)
2500    /// correctly materializes the terminal via the unified run_loop recovery.
2501    #[tokio::test]
2502    async fn cold_attach_recovery_materializes_resolved_wait_terminal() {
2503        use beam_core::{
2504            BootstrapWorkflowRunInput, EventDraft, EventLog, WorkflowActor, bootstrap_workflow_run,
2505        };
2506
2507        let paths = temp_paths("cold-attach-rec");
2508        maybe_remove_dir(&paths.root().to_path_buf());
2509        let state = make_state(paths.clone(), HashMap::new());
2510        let run_id = "run-cold-rec";
2511
2512        // Single-node human-gate workflow.
2513        let def = r#"{"workflowId":"flow-cr","version":1,"nodes":{"gate":{"type":"hostExecutor","executor":"beam-shell","input":{"command":"echo hi"},"humanGate":{"stage":"approve","prompt":"OK?"}}}}"#;
2514        let binding = beam_core::RunChatBinding {
2515            chat_id: "oc_test".to_string(),
2516            lark_app_id: "app_test".to_string(),
2517        };
2518        bootstrap_workflow_run(
2519            &paths,
2520            BootstrapWorkflowRunInput {
2521                run_id,
2522                workflow_json: def,
2523                expected_workflow_id: Some("flow-cr"),
2524                params: &BTreeMap::<String, Value>::new(),
2525                initiator: "test",
2526                chat_binding: Some(binding),
2527            },
2528        )
2529        .expect("bootstrap");
2530
2531        // Advance to create the wait, then read the wait info.
2532        crate::run_workflow_runtime_once(&state, run_id, def).await;
2533
2534        // Grab the activity_id from the wait, and the attempt_id from the
2535        // activity's latest attempt, so we can craft a valid resolution event.
2536        let sn = beam_core::read_run_snapshot(&paths.workflow_run_dir(run_id))
2537            .await
2538            .unwrap()
2539            .unwrap();
2540        let activity_id = sn
2541            .dangling
2542            .waits
2543            .first()
2544            .expect("should have a wait")
2545            .clone();
2546        // Sanity-check: the activity exists and has an attempt.
2547        let _activity = sn
2548            .activities
2549            .iter()
2550            .find(|a| a.activity_id == activity_id)
2551            .expect("should find the waiting activity");
2552        assert!(
2553            !_activity.attempts.is_empty(),
2554            "activity should have at least one attempt"
2555        );
2556
2557        // Simulate a crash scenario: write waitResolved (resolution approved)
2558        // but NOT activitySucceeded (terminal).  This leaves the wait in a
2559        // "resolved but no terminal" dangling state.
2560        {
2561            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
2562            log.append(EventDraft {
2563                event_type: "waitResolved".to_string(),
2564                actor: WorkflowActor::Human,
2565                payload: serde_json::json!({
2566                    "activityId": activity_id,
2567                    "resolution": "approved",
2568                    "by": "test_user",
2569                    "comment": "LGTM",
2570                }),
2571                timestamp: None,
2572                payload_hash: None,
2573            })
2574            .unwrap();
2575        }
2576
2577        // Verify the snapshot now has a wait resolution but no terminal for
2578        // the activity — i.e. `dangling.wait_resolutions` is non-empty.
2579        let sn_pre = beam_core::read_run_snapshot(&paths.workflow_run_dir(run_id))
2580            .await
2581            .unwrap()
2582            .unwrap();
2583        assert!(
2584            sn_pre.dangling.waits.is_empty(),
2585            "after resolution, waits should be cleared"
2586        );
2587        assert!(
2588            !sn_pre.dangling.wait_resolutions.is_empty(),
2589            "should have dangling wait resolutions (resolved but no terminal)"
2590        );
2591
2592        // Simulate cold attach: the unified driver will call run_loop, and
2593        // the built-in wait-resolution recovery phase should materialize the
2594        // activitySucceeded terminal.
2595        workflow_runtime_driver::run(&state, run_id, def).await;
2596
2597        // After recovery, the wait resolution should be cleared and the
2598        // activity should have been terminalized.
2599        let sn_post = beam_core::read_run_snapshot(&paths.workflow_run_dir(run_id))
2600            .await
2601            .unwrap()
2602            .unwrap();
2603        assert!(
2604            sn_post.dangling.wait_resolutions.is_empty(),
2605            "after recovery, dangling wait resolutions should be cleared"
2606        );
2607        assert!(sn_post.dangling.waits.is_empty(), "no waits should remain");
2608
2609        // The workflow should have progressed — since this is a single-node
2610        // workflow and the node has now succeeded, the run should be terminal.
2611        let terminal = matches!(
2612            sn_post.run.status,
2613            beam_core::RunStatus::Succeeded
2614                | beam_core::RunStatus::Failed
2615                | beam_core::RunStatus::Cancelled
2616        );
2617        assert!(
2618            terminal,
2619            "run should be terminal after recovery, got {:?}",
2620            sn_post.run.status
2621        );
2622
2623        maybe_remove_dir(&paths.root().to_path_buf());
2624    }
2625
2626    #[tokio::test]
2627    async fn recent_lark_events_save_load_roundtrip() {
2628        let paths = temp_paths("recent-lark-events-roundtrip");
2629        maybe_remove_dir(&paths.root().to_path_buf());
2630
2631        // Simulate a fresh event (just inserted)
2632        let mut events = HashMap::new();
2633        events.insert("evt-fresh".to_string(), std::time::Instant::now());
2634        // Simulate an event that's 4 minutes old (still within 5 min TTL)
2635        events.insert(
2636            "evt-old".to_string(),
2637            std::time::Instant::now() - std::time::Duration::from_secs(240),
2638        );
2639
2640        save_recent_lark_events(&paths, &events).await;
2641        let loaded = load_recent_lark_events(&paths).await;
2642
2643        // Both events should survive roundtrip (they're within the 5-min TTL)
2644        assert!(
2645            loaded.contains_key("evt-fresh"),
2646            "fresh event should survive roundtrip"
2647        );
2648        assert!(
2649            loaded.contains_key("evt-old"),
2650            "4-min-old event should survive roundtrip"
2651        );
2652
2653        // The "old" event's Instant should approximate the original (within a few seconds)
2654        let loaded_instant = loaded.get("evt-old").unwrap();
2655        let elapsed = loaded_instant.elapsed();
2656        assert!(
2657            elapsed >= std::time::Duration::from_secs(239)
2658                && elapsed <= std::time::Duration::from_secs(242),
2659            "old event elapsed should be ~240s, got {:?}",
2660            elapsed
2661        );
2662
2663        let _ = std::fs::remove_file(paths.recent_lark_events_json());
2664        maybe_remove_dir(&paths.root().to_path_buf());
2665    }
2666}