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
55pub use daemon_types::RunOptions;
57pub(crate) use daemon_types::*;
58
59pub(crate) use workflow_catalog::*;
61pub(crate) use workflow_execution::*;
63pub(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 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 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 {
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 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 {
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; }
342 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 s.last_final_output_turn_id.as_deref() != Some(tid)
351 })
352 })
353 .unwrap_or(false) };
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 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 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 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 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 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 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 periodic_auth
887 .save_used_tickets(&periodic_paths.used_tickets_json())
888 .await;
889 {
891 let events = periodic_recent_events.lock().await;
892 save_recent_lark_events(&periodic_paths, &events).await;
893 }
894 }
895 });
896
897 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 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: ¶ms,
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: ¶ms,
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 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 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 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 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 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 assert!(
1726 !card_str.contains("\"working_dir\":\"other\""),
1727 "card must NOT contain non-matching dir 'other'"
1728 );
1729 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 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 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 assert!(card_str.contains("dir_select_pick"));
1769 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 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 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 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 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 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 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 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 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 #[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 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 {
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 let alive_before = unsafe { libc::kill(worker_pid as i32, 0) == 0 };
2149 assert!(alive_before, "child should be alive before termination");
2150
2151 let start = tokio::time::Instant::now();
2154 terminate_workflow_worker_process(&state, session_id).await;
2155 let elapsed = start.elapsed();
2156
2157 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 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 #[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 {
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 let start = tokio::time::Instant::now();
2233 terminate_workflow_worker_process(&state, session_id).await;
2234 let elapsed = start.elapsed();
2235
2236 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 #[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 terminate_workflow_worker_process(&state, session_id).await;
2282
2283 {
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 #[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 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 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 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 assert!(token.is_cancelled());
2339 assert_eq!(reg.total_activities(), 0);
2340
2341 maybe_remove_dir(&paths.root().to_path_buf());
2342 }
2343
2344 #[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 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: ¶ms,
2376 initiator: "test",
2377 chat_binding: Some(binding.clone()),
2378 },
2379 )
2380 .expect("bootstrap nonterm");
2381
2382 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: ¶ms,
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 #[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 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 crate::run_workflow_runtime_once(&state, run_id, def).await;
2457
2458 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 workflow_runtime_driver::run(&state, run_id, def).await;
2476
2477 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 #[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 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 crate::run_workflow_runtime_once(&state, run_id, def).await;
2533
2534 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 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 {
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 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 workflow_runtime_driver::run(&state, run_id, def).await;
2596
2597 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 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 let mut events = HashMap::new();
2633 events.insert("evt-fresh".to_string(), std::time::Instant::now());
2634 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 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 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}