mirror of
https://github.com/akitaonrails/ai-memory.git
synced 2026-10-02 03:24:46 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
Reference in New Issue
Block a user