refactor: remove Matrix bot, make agent-fleet platform-agnostic API service

- Remove src/integrations/matrix/ (bot connection, command parsing, notification formatting)
- Remove matrix-sdk dependency from Cargo.toml
- Remove MatrixConfig from config.rs and [matrix] from config.example.toml
- Add GET /api/v1/tasks (list with status/agent_id filter)
- Add POST /api/v1/tasks/{task_id}/retry (Failed/AgentLost → Assigned)
- Add EventStore::list_tasks() with parameterized query
- 29/29 tests pass

Platform integration (Telegram, Matrix, Feishu) is Agent-side responsibility.
agent-fleet is now a pure HTTP API orchestration engine.
This commit is contained in:
Zer4tul 2026-05-12 10:59:19 +08:00
parent 6efca09018
commit 1bc7580ecc
15 changed files with 435 additions and 2367 deletions

View file

@ -379,6 +379,58 @@ fn parse_task_source(source: &str) -> Option<(String, u64)> {
Some((repo.to_string(), issue_number))
}
#[derive(Debug, Deserialize)]
pub struct ListTasksQuery {
pub status: Option<String>,
pub agent_id: Option<String>,
}
pub async fn list_tasks(
State(state): State<AppState>,
Query(query): Query<ListTasksQuery>,
) -> Result<Json<Vec<Task>>, ApiError> {
let store = state.store.clone();
tokio::task::spawn_blocking(move || -> Result<Json<Vec<Task>>, ApiError> {
let store = store.lock().map_err(|e| ApiError::Poisoned(e.to_string()))?;
let tasks = store.list_tasks(query.status.as_deref(), query.agent_id.as_deref())?;
Ok(Json(tasks))
})
.await?
}
pub async fn retry_task(
State(state): State<AppState>,
axum::extract::Path(task_id): axum::extract::Path<String>,
) -> Result<Json<Task>, ApiError> {
let store = state.store.clone();
let sm = StateMachine::new(store.clone());
let task_id_for_check = task_id.clone();
let current = tokio::task::spawn_blocking(move || -> Result<Option<Task>, ApiError> {
let store = store.lock().map_err(|e| ApiError::Poisoned(e.to_string()))?;
Ok(store.read_task(&task_id_for_check)?)
})
.await??;
let task = current.ok_or_else(|| ApiError::NotFound(format!("task {}", task_id)))?;
if !matches!(task.status, TaskStatus::Failed | TaskStatus::AgentLost) {
return Err(ApiError::BadRequest(format!(
"task {} is not retryable (current status: {})",
task.task_id,
task.status.as_str()
)));
}
let updated = sm
.transition(&task_id, TaskStatus::Assigned, None, "retry")
.await
.map_err(|e| ApiError::BadRequest(e.to_string()))?;
Ok(Json(updated))
}
#[cfg(test)]
mod tests {
use super::*;
@ -734,4 +786,149 @@ mod tests {
};
assert_eq!(task.status, TaskStatus::Running);
}
// ─── Task API tests ─────────────────────────────────────────
fn sample_task_variant(task_id: &str, status: TaskStatus, agent_id: Option<&str>) -> Task {
Task {
task_id: task_id.to_string(),
source: format!("forgejo:org/repo#{task_id}"),
task_type: "code".into(),
priority: Priority::High,
status,
assigned_agent_id: agent_id.map(String::from),
requirements: "do something".into(),
labels: vec!["agent:code".into(), "priority:high".into()],
created_at: Utc::now(),
assigned_at: None,
started_at: None,
completed_at: None,
retry_count: 0,
max_retries: 2,
timeout_seconds: 1800,
}
}
#[tokio::test]
async fn list_tasks_returns_all_tasks() {
let (_dir, state) = test_store();
{
let store = state.store.lock().unwrap();
store.insert_task(&sample_task_variant("task-1", TaskStatus::Created, None)).unwrap();
store.insert_task(&sample_task_variant("task-2", TaskStatus::Running, Some("worker-01"))).unwrap();
}
let tasks = list_tasks(
State(state),
Query(ListTasksQuery { status: None, agent_id: None }),
)
.await
.unwrap();
assert_eq!(tasks.0.len(), 2);
}
#[tokio::test]
async fn list_tasks_filters_by_status() {
let (_dir, state) = test_store();
{
let store = state.store.lock().unwrap();
store.insert_task(&sample_task_variant("task-1", TaskStatus::Created, None)).unwrap();
store.insert_task(&sample_task_variant("task-2", TaskStatus::Running, Some("worker-01"))).unwrap();
}
let tasks = list_tasks(
State(state),
Query(ListTasksQuery { status: Some("running".into()), agent_id: None }),
)
.await
.unwrap();
assert_eq!(tasks.0.len(), 1);
assert_eq!(tasks.0[0].task_id, "task-2");
assert_eq!(tasks.0[0].status, TaskStatus::Running);
}
#[tokio::test]
async fn list_tasks_filters_by_agent() {
let (_dir, state) = test_store();
{
let store = state.store.lock().unwrap();
store.insert_task(&sample_task_variant("task-1", TaskStatus::Running, Some("worker-01"))).unwrap();
store.insert_task(&sample_task_variant("task-2", TaskStatus::Running, Some("worker-02"))).unwrap();
}
let tasks = list_tasks(
State(state),
Query(ListTasksQuery { status: None, agent_id: Some("worker-01".into()) }),
)
.await
.unwrap();
assert_eq!(tasks.0.len(), 1);
assert_eq!(tasks.0[0].task_id, "task-1");
}
#[tokio::test]
async fn retry_task_succeeds_for_failed_task() {
let (_dir, state) = test_store();
{
let store = state.store.lock().unwrap();
store.insert_task(&sample_task_variant("task-failed", TaskStatus::Failed, Some("worker-01"))).unwrap();
}
let updated = retry_task(State(state.clone()), axum::extract::Path("task-failed".to_string()))
.await
.unwrap();
assert_eq!(updated.0.status, TaskStatus::Assigned);
// Verify in DB
let task = {
let store = state.store.lock().unwrap();
store.read_task("task-failed").unwrap().unwrap()
};
assert_eq!(task.status, TaskStatus::Assigned);
}
#[tokio::test]
async fn retry_task_succeeds_for_agent_lost_task() {
let (_dir, state) = test_store();
{
let store = state.store.lock().unwrap();
store.insert_task(&sample_task_variant("task-lost", TaskStatus::AgentLost, Some("worker-01"))).unwrap();
}
let updated = retry_task(State(state.clone()), axum::extract::Path("task-lost".to_string()))
.await
.unwrap();
assert_eq!(updated.0.status, TaskStatus::Assigned);
}
#[tokio::test]
async fn retry_task_rejects_non_retryable_status() {
let (_dir, state) = test_store();
{
let store = state.store.lock().unwrap();
store.insert_task(&sample_task_variant("task-running", TaskStatus::Running, Some("worker-01"))).unwrap();
}
let err = retry_task(State(state.clone()), axum::extract::Path("task-running".to_string()))
.await
.unwrap_err();
assert!(matches!(err, ApiError::BadRequest(_)));
}
#[tokio::test]
async fn retry_task_returns_not_found_for_missing_task() {
let (_dir, state) = test_store();
let err = retry_task(State(state), axum::extract::Path("nonexistent".to_string()))
.await
.unwrap_err();
assert!(matches!(err, ApiError::NotFound(_)));
}
}