Skip to content

Commit 2c7cfe8

Browse files
authored
feat(team): add run state snapshot endpoint (#549)
## Summary - Add a read-only `GET /api/teams/{id}/run-state` endpoint that returns the authoritative active team run snapshot. - Introduce `TeamRunStateResponse` with `active_run: Option<TeamRunPayload>` and export it from `aionui-api-types`. - Validate team ownership before reading existing session state, returning `active_run: null` without creating sessions or runs. ## Validation - `just push -u origin aionissue/fix-130884740-002` - `cargo fmt --all -- --check` - `cargo test -p aionui-api-types team_run_state_response -- --nocapture` - `cargo test -p aionui-team run_state_returns_ -- --nocapture` - `cargo test -p aionui-app trs -- --nocapture` - `cargo clippy -p aionui-api-types -p aionui-team -p aionui-app -- -D warnings` - `git diff --check` ## Notes - Paired AionUi PR: iOfficeAI/AionUi#3480 - No database migration is included. Co-authored-by: zynx <>
1 parent 0cb3a9a commit 2c7cfe8

5 files changed

Lines changed: 220 additions & 7 deletions

File tree

crates/aionui-api-types/src/lib.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,10 @@ pub use team::{
156156
RenameAgentRequest, RenameTeamRequest, SendAgentMessageRequest, SendTeamMessageRequest, TeamAgentInput,
157157
TeamAgentRemovedPayload, TeamAgentRenamedPayload, TeamAgentResponse, TeamAgentSpawnedPayload,
158158
TeamAgentStatusPayload, TeamChildTurnPayload, TeamListResponse, TeamMcpPhase, TeamMcpRuntimeConfig,
159-
TeamMcpStatusPayload, TeamResponse, TeamRunAckResponse, TeamRunPayload, TeamRunSource, TeamRunStatus,
160-
TeamRunTargetRole, TeamRuntimeSeed, TeamSendMessageDelivery, TeamSendMessageQueuedResponse, TeamSendMessageReason,
161-
TeamSendMessageStatus, TeamSendMessageTargetQueueState, TeamSessionBinding, TeamSlotRuntimeHealth,
162-
TeamSlotWorkPayload, TeammateMessagePayload,
159+
TeamMcpStatusPayload, TeamResponse, TeamRunAckResponse, TeamRunPayload, TeamRunSource, TeamRunStateResponse,
160+
TeamRunStatus, TeamRunTargetRole, TeamRuntimeSeed, TeamSendMessageDelivery, TeamSendMessageQueuedResponse,
161+
TeamSendMessageReason, TeamSendMessageStatus, TeamSendMessageTargetQueueState, TeamSessionBinding,
162+
TeamSlotRuntimeHealth, TeamSlotWorkPayload, TeammateMessagePayload,
163163
};
164164
pub use team_mcp::{TEAM_MCP_SERVER_NAME, TeamMcpStdioConfig};
165165
pub use websocket::WebSocketMessage;

crates/aionui-api-types/src/team.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,11 @@ pub struct TeamRunPayload {
372372
pub slot_work: Vec<TeamSlotWorkPayload>,
373373
}
374374

375+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
376+
pub struct TeamRunStateResponse {
377+
pub active_run: Option<TeamRunPayload>,
378+
}
379+
375380
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
376381
pub struct TeamChildTurnPayload {
377382
pub team_id: String,
@@ -1452,6 +1457,57 @@ mod tests {
14521457
assert_eq!(json["message_id"], "mailbox-1");
14531458
}
14541459

1460+
fn active_run_payload() -> TeamRunPayload {
1461+
TeamRunPayload {
1462+
team_id: "team-1".to_owned(),
1463+
team_run_id: "run-1".to_owned(),
1464+
source: TeamRunSource::UserMessage,
1465+
has_user_intervention: true,
1466+
target_slot_id: "lead".to_owned(),
1467+
target_role: TeamRunTargetRole::Lead,
1468+
status: TeamRunStatus::Running,
1469+
active_child_count: 1,
1470+
pending_wake_count: 0,
1471+
starting_child_count: 0,
1472+
slot_work: vec![TeamSlotWorkPayload {
1473+
slot_id: "lead".to_owned(),
1474+
role: TeamRunTargetRole::Lead,
1475+
pending_wake_count: 0,
1476+
starting_child_count: 0,
1477+
paused: false,
1478+
suppressed_wake_count: 0,
1479+
active_turn_id: Some("turn-1".to_owned()),
1480+
active_turn_started_at_ms: Some(10),
1481+
active_turn_elapsed_ms: Some(20),
1482+
active_turn_slow: Some(false),
1483+
active_turn_slow_threshold_ms: Some(600_000),
1484+
runtime_health: None,
1485+
}],
1486+
}
1487+
}
1488+
1489+
#[test]
1490+
fn team_run_state_response_serializes_null_active_run() {
1491+
let value = serde_json::to_value(TeamRunStateResponse { active_run: None }).unwrap();
1492+
assert_eq!(value, serde_json::json!({ "active_run": null }));
1493+
}
1494+
1495+
#[test]
1496+
fn team_run_state_response_serializes_some_active_run() {
1497+
let payload = active_run_payload();
1498+
let value = serde_json::to_value(TeamRunStateResponse {
1499+
active_run: Some(payload.clone()),
1500+
})
1501+
.unwrap();
1502+
1503+
assert_eq!(value["active_run"]["team_id"], payload.team_id);
1504+
assert_eq!(value["active_run"]["team_run_id"], payload.team_run_id);
1505+
assert_eq!(value["active_run"]["source"], "user_message");
1506+
assert_eq!(value["active_run"]["has_user_intervention"], true);
1507+
assert_eq!(value["active_run"]["status"], "running");
1508+
assert_eq!(value["active_run"]["slot_work"][0]["slot_id"], "lead");
1509+
}
1510+
14551511
#[test]
14561512
fn team_run_payload_omits_sensitive_content() {
14571513
let payload = TeamRunPayload {

crates/aionui-app/tests/team_e2e.rs

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use serde_json::json;
66
use tower::ServiceExt;
77

88
use common::{
9-
body_json, build_app, build_app_with_mock_agents, delete_with_token, get_with_token, json_with_token,
9+
body_json, build_app, build_app_with_mock_agents, delete_with_token, get_request, get_with_token, json_with_token,
1010
setup_and_login,
1111
};
1212

@@ -422,6 +422,98 @@ async fn pause_team_slot_endpoint_requires_owned_team_and_active_run() {
422422
assert!(body["success"].as_bool().is_some_and(|success| !success));
423423
}
424424

425+
#[tokio::test]
426+
async fn trs1_run_state_returns_null_for_existing_team_without_active_run() {
427+
let (mut app, services) = build_app_with_mock_agents().await;
428+
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
429+
let data = create_team(&mut app, &token, &csrf).await;
430+
let team_id = data["id"].as_str().unwrap();
431+
432+
let stop_req = delete_with_token(&format!("/api/teams/{team_id}/session"), &token, &csrf);
433+
let stop_resp = app.clone().oneshot(stop_req).await.unwrap();
434+
assert_eq!(stop_resp.status(), StatusCode::OK);
435+
436+
let req = get_with_token(&format!("/api/teams/{team_id}/run-state"), &token);
437+
let resp = app.oneshot(req).await.unwrap();
438+
439+
assert_eq!(resp.status(), StatusCode::OK);
440+
let body = body_json(resp).await;
441+
assert_eq!(body["success"], true);
442+
assert!(body["data"]["active_run"].is_null());
443+
}
444+
445+
#[tokio::test]
446+
async fn trs2_run_state_returns_active_run_payload() {
447+
let (mut app, services) = build_app_with_mock_agents().await;
448+
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
449+
let data = create_team(&mut app, &token, &csrf).await;
450+
let team_id = data["id"].as_str().unwrap();
451+
452+
let send_req = json_with_token(
453+
"POST",
454+
&format!("/api/teams/{team_id}/messages"),
455+
json!({ "content": "hello", "files": [] }),
456+
&token,
457+
&csrf,
458+
);
459+
let send_resp = app.clone().oneshot(send_req).await.unwrap();
460+
assert_eq!(send_resp.status(), StatusCode::OK);
461+
let send_body = body_json(send_resp).await;
462+
let team_run_id = send_body["data"]["team_run_id"].as_str().unwrap();
463+
464+
let req = get_with_token(&format!("/api/teams/{team_id}/run-state"), &token);
465+
let resp = app.oneshot(req).await.unwrap();
466+
467+
assert_eq!(resp.status(), StatusCode::OK);
468+
let body = body_json(resp).await;
469+
assert_eq!(body["success"], true);
470+
assert_eq!(body["data"]["active_run"]["team_id"], team_id);
471+
assert_eq!(body["data"]["active_run"]["team_run_id"], team_run_id);
472+
assert_eq!(body["data"]["active_run"]["source"], "user_message");
473+
assert_eq!(body["data"]["active_run"]["has_user_intervention"], true);
474+
assert_eq!(body["data"]["active_run"]["status"], "accepted");
475+
assert_eq!(body["data"]["active_run"]["pending_wake_count"], 1);
476+
assert!(body["data"]["active_run"]["slot_work"].as_array().unwrap().len() >= 1);
477+
}
478+
479+
#[tokio::test]
480+
async fn trs3_run_state_unauthenticated_returns_401() {
481+
let (app, _services) = build_app().await;
482+
483+
let resp = app.oneshot(get_request("/api/teams/team-1/run-state")).await.unwrap();
484+
485+
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
486+
let body = body_json(resp).await;
487+
assert_eq!(body["code"], "UNAUTHORIZED");
488+
}
489+
490+
#[tokio::test]
491+
async fn trs4_run_state_missing_team_returns_404() {
492+
let (mut app, services) = build_app().await;
493+
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
494+
495+
let req = get_with_token("/api/teams/team-missing/run-state", &token);
496+
let resp = app.oneshot(req).await.unwrap();
497+
498+
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
499+
}
500+
501+
#[tokio::test]
502+
async fn trs5_run_state_rejects_cross_user_access() {
503+
let (mut app, services) = build_app_with_mock_agents().await;
504+
let (admin_token, admin_csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
505+
let data = create_team(&mut app, &admin_token, &admin_csrf).await;
506+
let team_id = data["id"].as_str().unwrap();
507+
508+
let (other_token, _other_csrf) = setup_and_login(&mut app, &services, "other", "StrongP@ss2").await;
509+
let req = get_with_token(&format!("/api/teams/{team_id}/run-state"), &other_token);
510+
let resp = app.oneshot(req).await.unwrap();
511+
512+
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
513+
let body = body_json(resp).await;
514+
assert_eq!(body["code"], "FORBIDDEN");
515+
}
516+
425517
// TL-3: Each team contains full assistants info
426518
#[tokio::test]
427519
async fn tl3_teams_contain_full_agent_info() {

crates/aionui-team/src/routes.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use axum::routing::{get, post};
1111
use aionui_api_types::{
1212
AddAgentRequest, ApiResponse, CancelTeamChildTurnRequest, CancelTeamRunRequest, CreateTeamRequest,
1313
PauseTeamSlotRequest, RenameAgentRequest, RenameTeamRequest, SendAgentMessageRequest, SendTeamMessageRequest,
14-
SetModeRequest, TeamAgentResponse, TeamListResponse, TeamResponse, TeamRunAckResponse,
14+
SetModeRequest, TeamAgentResponse, TeamListResponse, TeamResponse, TeamRunAckResponse, TeamRunStateResponse,
1515
};
1616
use aionui_auth::CurrentUser;
1717
use aionui_common::ApiError;
@@ -67,6 +67,7 @@ pub fn team_routes(state: TeamRouterState) -> Router {
6767
Router::new()
6868
.route("/api/teams", post(create_team).get(list_teams))
6969
.route("/api/teams/{id}", get(get_team).delete(remove_team))
70+
.route("/api/teams/{id}/run-state", get(get_run_state))
7071
.route("/api/teams/{id}/name", axum::routing::patch(rename_team))
7172
.route("/api/teams/{id}/agents", post(add_agent))
7273
.route("/api/teams/{id}/agents/{slot_id}", axum::routing::delete(remove_agent))
@@ -117,6 +118,15 @@ async fn get_team(
117118
Ok(Json(ApiResponse::ok(team)))
118119
}
119120

121+
async fn get_run_state(
122+
State(state): State<TeamRouterState>,
123+
Extension(user): Extension<CurrentUser>,
124+
Path(id): Path<String>,
125+
) -> Result<Json<ApiResponse<TeamRunStateResponse>>, ApiError> {
126+
let run_state = state.service.get_run_state(&user.id, &id).await?;
127+
Ok(Json(ApiResponse::ok(run_state)))
128+
}
129+
120130
async fn remove_team(
121131
State(state): State<TeamRouterState>,
122132
Extension(user): Extension<CurrentUser>,

crates/aionui-team/src/service.rs

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::sync::{Arc, Weak};
88
use aionui_ai_agent::{AgentError, AgentInstance, IWorkerTaskManager};
99
use aionui_api_types::{
1010
AddAgentRequest, CreateTeamRequest, TeamAgentResponse, TeamMcpPhase, TeamMcpStatusPayload, TeamResponse,
11-
TeamRunAckResponse, TeamRunTargetRole, WebSocketMessage,
11+
TeamRunAckResponse, TeamRunStateResponse, TeamRunTargetRole, WebSocketMessage,
1212
};
1313
use aionui_common::{AgentKillReason, generate_id, now_ms};
1414
use aionui_db::models::TeamRow;
@@ -697,6 +697,17 @@ impl TeamSessionService {
697697
self.sessions.get(team_id).map(|e| e.session.user_id().to_owned())
698698
}
699699

700+
pub async fn get_run_state(&self, user_id: &str, team_id: &str) -> Result<TeamRunStateResponse, TeamError> {
701+
self.load_owned_team(user_id, team_id).await?;
702+
let session = self.sessions.get(team_id).map(|entry| Arc::clone(&entry.session));
703+
let active_run = match session {
704+
Some(session) => session.team_run_manager().current_payload().await,
705+
None => None,
706+
};
707+
708+
Ok(TeamRunStateResponse { active_run })
709+
}
710+
700711
pub fn get_session_scheduler(&self, team_id: &str) -> Option<Arc<crate::scheduler::TeammateManager>> {
701712
self.sessions.get(team_id).map(|e| e.session.scheduler().clone())
702713
}
@@ -709,6 +720,11 @@ impl TeamSessionService {
709720
.unwrap_or(false)
710721
}
711722

723+
#[cfg(test)]
724+
fn session_count_for_test(&self) -> usize {
725+
self.sessions.len()
726+
}
727+
712728
pub async fn stop_session(&self, user_id: &str, team_id: &str) -> Result<(), TeamError> {
713729
self.load_owned_team(user_id, team_id).await?;
714730
self.stop_session_unchecked(team_id);
@@ -1002,4 +1018,43 @@ mod tests {
10021018
assert!(svc.session_has_slow_monitor(&created.id));
10031019
svc.stop_session("user-test", &created.id).await.unwrap();
10041020
}
1021+
1022+
#[tokio::test]
1023+
async fn run_state_returns_none_without_session_and_does_not_create_session() {
1024+
let (svc, _repo, _task_manager, _conv_repo) = setup_with_factory_metadata_team_repo_and_conversation_repo();
1025+
let created = svc
1026+
.create_team("user-test", single_agent_team_request("Run State"))
1027+
.await
1028+
.unwrap();
1029+
svc.stop_session("user-test", &created.id).await.unwrap();
1030+
1031+
assert_eq!(svc.session_count_for_test(), 0);
1032+
1033+
let state = svc.get_run_state("user-test", &created.id).await.unwrap();
1034+
1035+
assert!(state.active_run.is_none());
1036+
assert_eq!(svc.session_count_for_test(), 0);
1037+
}
1038+
1039+
#[tokio::test]
1040+
async fn run_state_returns_current_active_payload() {
1041+
let (svc, _repo, _task_manager, _conv_repo) = setup_with_factory_metadata_team_repo_and_conversation_repo();
1042+
let created = svc
1043+
.create_team("user-test", single_agent_team_request("Active Run State"))
1044+
.await
1045+
.unwrap();
1046+
1047+
let ack = svc.send_message("user-test", &created.id, "hello", None).await.unwrap();
1048+
let state = svc.get_run_state("user-test", &created.id).await.unwrap();
1049+
let active_run = state.active_run.expect("active run state");
1050+
1051+
assert_eq!(active_run.team_id, created.id);
1052+
assert_eq!(active_run.team_run_id, ack.team_run_id);
1053+
assert_eq!(active_run.status, ack.status);
1054+
assert_eq!(active_run.target_slot_id, ack.target_slot_id);
1055+
assert_eq!(active_run.target_role, ack.target_role);
1056+
assert_eq!(active_run.pending_wake_count, 1);
1057+
assert_eq!(active_run.slot_work.len(), 1);
1058+
assert_eq!(active_run.slot_work[0].slot_id, ack.accepted_slot_id);
1059+
}
10051060
}

0 commit comments

Comments
 (0)