feat(admin): delete-workspace endpoint to remove orphan workspaces

A cross-workspace move can leave behind empty workspace rows (e.g. a stray
`_perftmp`) that nothing could remove. Adds the missing primitive:

- store `delete_workspace(force)` — refuses a workspace that still holds
  projects unless `force`, then a single `DELETE FROM workspaces` whose
  `workspace_id` FKs cascade projects/pages/sessions/observations/handoffs;
  returns the removed counts. New `StoreError::WorkspaceNotEmpty`.
- writer command + async wrapper (single-writer actor).
- best-effort `Wiki::remove_workspace_dir` (missing dir is not an error).
- `POST /admin/delete-workspace {workspace, force?}`: 409 when non-empty
  without force, 404 when unknown, 200 with the cascade counts + a mirror
  checkpoint.

Store unit tests (guard / force-cascade / empty / not-found) and admin
integration tests (409 / 200 / 404) cover it.
This commit is contained in:
Djalma Júnior
2026-07-10 21:22:50 -03:00
parent a1acaa77ca
commit c053caf151
7 changed files with 316 additions and 2 deletions
+81
View File
@@ -515,6 +515,7 @@ pub fn admin_router(state: AdminState) -> Router {
.route("/admin/purge-project", post(handle_purge_project))
.route("/admin/rename-project", post(handle_rename_project))
.route("/admin/move-project", post(handle_move_project))
.route("/admin/delete-workspace", post(handle_delete_workspace))
.route("/admin/write-page", post(handle_write_page))
.route("/admin/delete-page", post(handle_delete_page));
let users = Router::new()
@@ -2953,6 +2954,86 @@ async fn handle_purge_project(
// rename-project
// ---------------------------------------------------------------------
/// JSON request body for `POST /admin/delete-workspace`.
#[derive(Deserialize)]
struct DeleteWorkspaceRequest {
/// Workspace name to delete (must exist).
workspace: String,
/// Delete even when the workspace still holds projects. Without it, a
/// non-empty workspace is refused so a typo can't wipe live data.
#[serde(default)]
force: bool,
}
/// Wire-format summary returned by `POST /admin/delete-workspace`.
#[derive(Debug, Serialize)]
pub struct DeleteWorkspaceResult {
/// Workspace name that was deleted.
pub workspace: String,
/// Projects removed via the `workspace_id` cascade.
pub projects_deleted: u64,
/// `pages` rows removed via cascade (all versions).
pub pages_deleted: u64,
/// Post-delete mirror checkpoint, if one was taken.
#[serde(skip_serializing_if = "Option::is_none")]
pub checkpoint: Option<String>,
}
/// `POST /admin/delete-workspace` — remove a workspace and, via cascade, every
/// project/page under it. Guarded: refuses a non-empty workspace unless
/// `force` (a typo shouldn't wipe live data). Orphan-workspace cleanup.
async fn handle_delete_workspace(
State(state): State<Arc<AdminState>>,
Json(req): Json<DeleteWorkspaceRequest>,
) -> impl IntoResponse {
let ws_id = match state.reader.find_workspace(req.workspace.clone()).await {
Ok(Some(id)) => id,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("workspace '{}' not found", req.workspace)
})),
);
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
);
}
};
let summary = match state.writer.delete_workspace(ws_id, req.force).await {
Ok(s) => s,
Err(e) => {
let status = match &e {
StoreError::WorkspaceNotEmpty(_) => StatusCode::CONFLICT,
StoreError::NotFound(_) => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
return (status, Json(serde_json::json!({ "error": e.to_string() })));
}
};
// DB rows are gone (cascade); drop the on-disk dir best-effort so a
// filesystem hiccup doesn't fail an already-durable delete.
if let Err(e) = state.wiki.remove_workspace_dir(ws_id).await {
warn!(error = %e, workspace = %req.workspace, "delete-workspace: on-disk dir removal failed");
}
let result = DeleteWorkspaceResult {
workspace: req.workspace.clone(),
projects_deleted: summary.projects_deleted,
pages_deleted: summary.pages_deleted,
checkpoint: checkpoint_or_warn(&state.wiki, format!("delete-workspace {}", req.workspace)),
};
(
StatusCode::OK,
Json(serde_json::to_value(&result).unwrap_or(serde_json::Value::Null)),
)
}
/// JSON request body for `POST /admin/rename-project`.
#[derive(Deserialize)]
struct RenameProjectRequest {
+68
View File
@@ -1565,3 +1565,71 @@ async fn copy_purge_rerun_is_idempotent() {
paths.sort();
assert_eq!(paths, vec!["notes/a.md", "notes/keep.md"]);
}
#[tokio::test]
async fn delete_workspace_refuses_non_empty_without_force() {
let tmp = TempDir::new().unwrap();
let (state, store) = make_state(&tmp).await;
seed_page(&store, &state.wiki, "victim", "proj", "notes/a.md", "body").await;
let resp = post(
state,
"/admin/delete-workspace",
json!({ "workspace": "victim" }),
)
.await;
assert_eq!(
resp.status(),
StatusCode::CONFLICT,
"non-empty delete must be refused without force"
);
assert!(
store
.reader
.find_workspace("victim".into())
.await
.unwrap()
.is_some(),
"a refused delete must not remove the workspace"
);
}
#[tokio::test]
async fn delete_workspace_force_removes_everything() {
let tmp = TempDir::new().unwrap();
let (state, store) = make_state(&tmp).await;
seed_page(&store, &state.wiki, "victim", "proj", "notes/a.md", "body").await;
let resp = post(
state,
"/admin/delete-workspace",
json!({ "workspace": "victim", "force": true }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["projects_deleted"].as_u64().unwrap_or(0), 1, "{body}");
assert!(body["pages_deleted"].as_u64().unwrap_or(0) >= 1, "{body}");
assert!(
store
.reader
.find_workspace("victim".into())
.await
.unwrap()
.is_none(),
"workspace must be gone after a force delete"
);
}
#[tokio::test]
async fn delete_workspace_unknown_is_404() {
let tmp = TempDir::new().unwrap();
let (state, _store) = make_state(&tmp).await;
let resp = post(
state,
"/admin/delete-workspace",
json!({ "workspace": "ghost" }),
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
+6
View File
@@ -54,6 +54,12 @@ pub enum StoreError {
#[error("not found: {0}")]
NotFound(String),
/// A workspace delete was refused because it still holds projects and the
/// caller did not pass `force`. Carries the project count so the admin
/// endpoint / CLI can report it before the operator retries with force.
#[error("workspace still has {0} project(s); pass force to delete anyway")]
WorkspaceNotEmpty(u64),
/// A UNIQUE constraint was violated by an insert (e.g. duplicate
/// `users.username` / `users.email`). The string carries a
/// human-readable explanation the CLI / admin endpoint surfaces
+1 -1
View File
@@ -34,7 +34,7 @@ pub use auto_improve::{
};
pub use decay::{DecayParams, retention_score};
pub use error::{StoreError, StoreResult};
pub use ops::{EmbeddingWrite, MoveSummary, PurgeSummary, ReorgSummary};
pub use ops::{DeleteWorkspaceSummary, EmbeddingWrite, MoveSummary, PurgeSummary, ReorgSummary};
pub use reader::{
ActivityWindow, AutoImproveCandidateSession, BriefingPage, BriefingSnapshot,
ContaminationFinding, ContaminationReport, ContaminationSummary, DecayCandidate,
+104
View File
@@ -1345,6 +1345,59 @@ pub fn purge_project(
})
}
/// Summary returned by [`delete_workspace`].
#[derive(Debug, Default, Clone)]
pub struct DeleteWorkspaceSummary {
/// Projects removed (0 when the workspace was already empty).
pub projects_deleted: u64,
/// `pages` rows removed via cascade (all versions).
pub pages_deleted: u64,
}
/// Delete a workspace row. Refuses a workspace that still holds projects
/// unless `force` is set (the guard exists so a stray typo can't wipe a live
/// workspace). The `workspace_id` FKs are `ON DELETE CASCADE`, so a single
/// `DELETE FROM workspaces` also removes its projects / pages / sessions /
/// observations / handoffs. The caller removes the on-disk workspace dir
/// afterwards.
///
/// # Errors
/// [`StoreError::WorkspaceNotEmpty`] when it still holds projects and `force`
/// is false; [`StoreError::NotFound`] when the workspace does not exist.
pub fn delete_workspace(
conn: &mut Connection,
workspace_id: &WorkspaceId,
force: bool,
) -> StoreResult<DeleteWorkspaceSummary> {
let tx = conn.transaction()?;
let wid = workspace_id.as_bytes();
let count = |sql: &str| -> StoreResult<u64> {
let n: Option<i64> = tx
.query_row(sql, rusqlite::params![&wid[..]], |row| row.get(0))
.optional()?;
Ok(u64::try_from(n.unwrap_or(0)).unwrap_or(0))
};
let projects_deleted = count("SELECT COUNT(*) FROM projects WHERE workspace_id = ?1")?;
if projects_deleted > 0 && !force {
return Err(StoreError::WorkspaceNotEmpty(projects_deleted));
}
let pages_deleted = count("SELECT COUNT(*) FROM pages WHERE workspace_id = ?1")?;
let removed = tx.execute(
"DELETE FROM workspaces WHERE id = ?1",
rusqlite::params![&wid[..]],
)?;
if removed == 0 {
return Err(StoreError::NotFound("workspace".into()));
}
tx.commit()?;
Ok(DeleteWorkspaceSummary {
projects_deleted,
pages_deleted,
})
}
/// Summary returned by [`move_project_workspace`] and exposed via
/// [`crate::writer::WriterHandle::move_project_workspace`].
#[derive(Debug, Default, Clone)]
@@ -1718,6 +1771,57 @@ mod tests {
);
}
#[test]
fn delete_workspace_refuses_non_empty_then_cascades_with_force() {
let (_tmp, mut conn, ws, proj) = fresh_db();
upsert_page(&mut conn, &page(ws, proj, "notes/a.md", "body")).unwrap();
// Non-empty (holds the "scratch" project + a page) → refused w/o force.
let err = delete_workspace(&mut conn, &ws, false).unwrap_err();
assert!(
matches!(err, StoreError::WorkspaceNotEmpty(n) if n >= 1),
"expected WorkspaceNotEmpty, got {err:?}"
);
let ws_still: i64 = conn
.query_row(
"SELECT COUNT(*) FROM workspaces WHERE id = ?1",
rusqlite::params![ws.as_bytes()],
|r| r.get(0),
)
.unwrap();
assert_eq!(ws_still, 1, "refused delete must not touch the row");
// Force cascades: project + page gone, workspace row gone.
let summary = delete_workspace(&mut conn, &ws, true).unwrap();
assert!(
summary.projects_deleted >= 1 && summary.pages_deleted >= 1,
"{summary:?}"
);
let proj_left: i64 = conn
.query_row(
"SELECT COUNT(*) FROM projects WHERE workspace_id = ?1",
rusqlite::params![ws.as_bytes()],
|r| r.get(0),
)
.unwrap();
assert_eq!(proj_left, 0, "projects must cascade on workspace delete");
// Deleting again → NotFound.
assert!(matches!(
delete_workspace(&mut conn, &ws, true).unwrap_err(),
StoreError::NotFound(_)
));
}
#[test]
fn delete_workspace_empty_succeeds_without_force() {
let (_tmp, mut conn, _ws, _proj) = fresh_db();
let empty = get_or_create_workspace(&mut conn, "orphan-ws").unwrap();
let summary = delete_workspace(&mut conn, &empty, false).unwrap();
assert_eq!(summary.projects_deleted, 0);
assert_eq!(summary.pages_deleted, 0);
}
fn fresh_db() -> (
TempDir,
Connection,
+40 -1
View File
@@ -22,7 +22,9 @@ use crate::auto_improve::{
RejectAutoImproveProposal, StageAutoImproveRun, StagedAutoImproveRun,
};
use crate::error::{StoreError, StoreResult};
use crate::ops::{self, EmbeddingWrite, MoveSummary, PurgeSummary, ReorgSummary};
use crate::ops::{
self, DeleteWorkspaceSummary, EmbeddingWrite, MoveSummary, PurgeSummary, ReorgSummary,
};
use crate::users::{self, TOKEN_HASH_LEN};
/// Commands accepted by the writer thread.
@@ -154,6 +156,13 @@ pub(crate) enum WriteCmd {
label: String,
reply: oneshot::Sender<StoreResult<PurgeSummary>>,
},
/// Delete a workspace row (its `workspace_id` FKs cascade projects/pages/
/// sessions/…). Refused when non-empty unless `force`.
DeleteWorkspace {
workspace_id: WorkspaceId,
force: bool,
reply: oneshot::Sender<StoreResult<DeleteWorkspaceSummary>>,
},
/// Re-stamp a project's `workspace_id` across every domain table in one
/// transaction, keeping the same `project_id` (a lossless cross-workspace
/// "true move"). The caller renames the on-disk dir and ensures the
@@ -624,6 +633,28 @@ impl WriterHandle {
rx.await.map_err(|_| StoreError::WriterClosed)?
}
/// Delete a workspace and, via the `workspace_id` cascade, every project /
/// page / session under it. Refuses a non-empty workspace unless `force`.
///
/// # Errors
/// [`StoreError::WorkspaceNotEmpty`] when it still holds projects and
/// `force` is false; [`StoreError::NotFound`] when the workspace is absent;
/// [`StoreError::WriterClosed`] if the actor has shut down.
pub async fn delete_workspace(
&self,
workspace_id: WorkspaceId,
force: bool,
) -> StoreResult<DeleteWorkspaceSummary> {
let (tx, rx) = oneshot::channel();
self.send(WriteCmd::DeleteWorkspace {
workspace_id,
force,
reply: tx,
})
.await?;
rx.await.map_err(|_| StoreError::WriterClosed)?
}
/// Re-stamp a project's `workspace_id` to `to_workspace` across every
/// domain table in one transaction, keeping the same `project_id`. This is
/// the lossless cross-workspace move: pages, sessions, observations and
@@ -1162,6 +1193,14 @@ fn worker_loop(mut conn: Connection, mut rx: mpsc::Receiver<WriteCmd>) {
let result = ops::purge_project(&mut conn, &workspace_id, &project_id, &label);
send_or_warn(reply, result, "purge_project");
}
WriteCmd::DeleteWorkspace {
workspace_id,
force,
reply,
} => {
let result = ops::delete_workspace(&mut conn, &workspace_id, force);
send_or_warn(reply, result, "delete_workspace");
}
WriteCmd::MoveProjectWorkspace {
project_id,
from_workspace,
+16
View File
@@ -561,6 +561,22 @@ impl Wiki {
}
}
/// Remove a workspace's on-disk directory (`<wiki_root>/<ws>`), including
/// every project under it, without running admission. Best-effort: a
/// missing dir is not an error.
///
/// # Errors
/// Returns [`WikiError::Io`] on filesystem errors other than NotFound.
pub async fn remove_workspace_dir(&self, workspace_id: WorkspaceId) -> WikiResult<()> {
let _guard = self.mutation_lock.write().await;
let root = self.root.join(workspace_id.to_string());
match std::fs::remove_dir_all(&root) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(crate::WikiError::Io(e)),
}
}
/// Dispatch non-blocking purge webhooks after the caller's purge has
/// completed its durable DB/filesystem work.
pub fn dispatch_purge_project(&self, admission_ctx: Option<&AdmissionContext>) {