fix(agents): a soft-deleted agent could never be purged

Clearing the fleet's four leftover agents returned 404 on every one. They had
been soft-deleted back in June — correctly invisible in the UI ever since — and
`agents::get` filters `deleted_at IS NULL`, so `workspace_agent` could not find
them. Every route uses it, including `batch-delete`, the one that exists to
HARD-purge. So a soft-deleted agent was unreachable from the application
entirely and its row stayed forever.

`get_any` sees them, and only the purge path uses it: hiding soft-deleted rows
is right for every read, and wrong for the one operation whose whole job is
removing them. Written with `query_as` rather than the checked macro so it does
not force an offline-cache regeneration on every machine that builds this.

`fleet-reset.sh` now uses `batch-delete` for agents rather than
`DELETE /api/claws/{id}`. The latter is a SOFT delete, so pointing a reset
script at it would have quietly added to the pile it was meant to clear.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-09 18:15:56 -07:00
co-authored by Claude Opus 5
parent 16cfc29074
commit f27d2605eb
3 changed files with 131 additions and 8 deletions
+46
View File
@@ -64,6 +64,52 @@ pub async fn insert(pool: &PgPool, agent: &Agent, policy: &AccessPolicy) -> Resu
Ok(())
}
/// Fetch an agent even if it has been soft-deleted.
///
/// For the PURGE path only. `get` hides soft-deleted rows, which is right for
/// every read — but it also meant the hard purge could not see the rows it
/// exists to remove: a soft-deleted agent was unreachable from every route and
/// accumulated forever with no way out of the application. Four of them dated
/// from June before anyone noticed, because the UI correctly never showed them.
pub async fn get_any(pool: &PgPool, agent_id: AgentId) -> Result<Agent, DbError> {
// `sqlx::query_as` rather than the checked macro: this is the same columns
// as `get` minus one predicate, and adding a second compile-time query for
// that would mean regenerating the offline cache on every machine that
// builds this.
let row: Option<(
uuid::Uuid,
uuid::Uuid,
String,
String,
String,
String,
String,
String,
uuid::Uuid,
String,
)> = sqlx::query_as(
"SELECT id, workspace_id, name, job_title, system_prompt, avatar,
accent, wallpaper, managed_by, status
FROM agents WHERE id = $1",
)
.bind(agent_id.as_uuid())
.fetch_optional(pool)
.await?;
let row = row.ok_or(DbError::NotFound)?;
Ok(Agent {
id: AgentId::from(row.0),
workspace_id: WorkspaceId::from(row.1),
name: row.2,
job_title: row.3,
system_prompt: row.4,
avatar: row.5,
accent: row.6,
wallpaper: row.7,
managed_by: UserId::from(row.8),
status: row.9.parse().expect("status CHECK constraint"),
})
}
pub async fn get(pool: &PgPool, agent_id: AgentId) -> Result<Agent, DbError> {
let row = sqlx::query!(
"SELECT id, workspace_id, name, job_title, system_prompt, avatar,