mirror of
https://github.com/akitaonrails/ai-memory.git
synced 2026-10-02 03:24:46 +08:00
feat(cli): purge-project previews what it would delete before --confirm
Without --confirm, purge-project only refused with a generic
"destructive and irreversible" message; the counts of what would be
deleted appeared only after a confirmed run actually deleted them. An
operator purged a project believing it held 0 sessions / 0 pages, and
it actually held 1,063 observations reachable only through a different
project's session (a pre-#871 Windows path-casing split) -- recovered
only from a backup taken a minute earlier.
POST /admin/purge-project now accepts an optional "dry_run": true,
which always wins over "confirm" -- {"confirm": true, "dry_run": true}
still only previews, the same way reclaim-ledger-versions treats its
own dry_run field, so a preview request can never become destructive.
The preview runs the same lookups and counts a confirmed purge uses to
decide what to delete (same 404/409), including two new counts for
rows a purge collaterally deletes or orphans in a DIFFERENT project via
its sessions cascading (collateral_observations_deleted,
collateral_handoffs_denulled -- observations.session_id is ON DELETE
CASCADE and handoffs.from_session_id/accepted_by_session are ON DELETE
SET NULL, neither scoped to the purged project_id). It never issues the
DELETE: PurgeMode::{Commit, Preview} replaces the earlier commit: bool,
and Preview returns right after counting instead of running the delete
and rolling it back, so a preview of a large project doesn't hold the
single-writer actor for as long as a real purge would and starve every
hook capture queued behind it. Preview also skips wiki file removal,
admission webhook dispatch, and both checkpoints; a 200 preview is
therefore not a guarantee the confirmed purge will succeed, since
admission only runs on the confirmed path.
The CLI asks for this preview (bounded to a few seconds, including auth
refresh) before refusing, and prints "Would purge <ws>/<proj>: N pages,
..." plus any collateral-damage counts. A 404/409/403 (or anything else
unexpected) prints the server's own error before the refusal; a plain
400 (older server), a timeout, or an unreachable server fall back
silently to the existing refusal, so no existing script's behavior
changes except gaining information.
Store-level tests cover: preview reports the same counts a confirmed
purge then produces; preview changes nothing (every project-scoped
table's row count, including purged_scopes and audit_log); the incident
shape (an observation stamped into the purged project from a session
that lives elsewhere); and the mirror shape (purging a project
collaterally deletes an observation, and orphans a handoff's session
reference, in a different project). Admin-level tests add the HTTP
equivalents, confirm {"confirm": true, "dry_run": true} deletes
nothing, no webhook dispatch on preview, 404 parity, a 409 on a live
managed-run lease without --force, and root-vs-DB-user-vs-anonymous
auth on the preview payload. CLI tests cover the summary-line wording,
the pure outcome-to-message mapping, and run_preview's HTTP-status
classification against a real local server (400/404/200/connection
failure).
This commit is contained in:
@@ -223,6 +223,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
`session-end` cannot double-close a session. (#623 follow-up, #933)
|
||||
|
||||
### Changed
|
||||
- `ai-memory purge-project` without `--confirm` now previews what a confirmed
|
||||
purge would delete before refusing: `Would purge <ws>/<proj>: N pages, N
|
||||
sessions, N observations, …`, with the same 404/409 a real purge gives.
|
||||
Before, the counts only appeared after the rows were gone. The server takes
|
||||
a new `dry_run` field on `POST /admin/purge-project` that always wins over
|
||||
`confirm`. It only counts; no delete, wiki removal, webhook, audit row or
|
||||
checkpoint runs. The preview and the confirmed report also count what the
|
||||
purge cascades into *other* projects through this project's sessions
|
||||
(`collateral_observations_deleted`, `collateral_handoffs_denulled`). The
|
||||
CLI still exits non-zero without `--confirm`; against an older server, or
|
||||
if the preview times out, it prints only the existing refusal. (#945)
|
||||
- A capture into a project its author may not write is dropped server-side
|
||||
and counted as `dropped_unauthorized` in status, never stored. The native
|
||||
hook client treats a 403 from the server as final and drops the event
|
||||
|
||||
@@ -1202,6 +1202,18 @@ pub struct PurgeProjectArgs {
|
||||
pub project: Option<String>,
|
||||
/// REQUIRED for the purge to run. Without this flag the CLI errors
|
||||
/// out — purging is destructive and irreversible.
|
||||
///
|
||||
/// Before erroring, the CLI asks the server for a preview (bounded to a
|
||||
/// few seconds, auth refresh included): the reported counts (pages,
|
||||
/// sessions, observations, handoffs, embeddings, workstreams, managed
|
||||
/// runs, plus any collateral rows a purge of this project would delete
|
||||
/// or orphan in *another* project) come from the same queries a
|
||||
/// confirmed purge itself uses to decide what to delete. The preview is
|
||||
/// best-effort and never changes the outcome, only what gets printed
|
||||
/// before it: a 404/409/403 (or anything else unexpected) prints the
|
||||
/// server's own error first; a timeout, an unreachable server, or an
|
||||
/// older server that predates this preview just gets the plain refusal,
|
||||
/// same as before this existed.
|
||||
#[arg(long)]
|
||||
pub confirm: bool,
|
||||
/// Also reclaim the freed bytes: rebuild the FTS indexes and VACUUM the
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! `ai-memory purge-project` — thin HTTP client for project purge.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::cli::PurgeProjectArgs;
|
||||
use crate::config::Config;
|
||||
use crate::http_client::{ServerEndpoint, post_json};
|
||||
use crate::http_client::{ServerEndpoint, ServerResponseError, post_json};
|
||||
|
||||
/// Request sent to `POST /admin/purge-project`.
|
||||
#[derive(Serialize)]
|
||||
@@ -17,6 +19,146 @@ struct PurgeProjectRequest {
|
||||
force: bool,
|
||||
/// Rebuild the FTS indexes and VACUUM after the delete commits.
|
||||
compact: bool,
|
||||
/// Preview only: wins over `confirm` on the server (mirrors
|
||||
/// `reclaim-ledger-versions`), so this is always sent alongside
|
||||
/// `confirm: false` here — never both true. Older servers that predate
|
||||
/// this field simply never look at it (an unknown JSON field is not a
|
||||
/// deserialize error), which is why the fallback below only has to
|
||||
/// handle the request failing outright, never the field being silently
|
||||
/// misread.
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
/// How long the preview request (auth resolution plus the HTTP round trip)
|
||||
/// is allowed to take before this falls back to the plain refusal. The
|
||||
/// preview is optional information layered on top of a refusal that must
|
||||
/// still happen either way, so it must never be the reason `--confirm` takes
|
||||
/// noticeably longer than it used to.
|
||||
const PREVIEW_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// One line naming what a purge (real or previewed) removed, in the fixed
|
||||
/// order the operator can grep for: pages, sessions, observations, handoffs,
|
||||
/// embeddings, workstreams, managed runs. `verb` is `"Purged"` for a
|
||||
/// confirmed run or `"Would purge"` for a preview — everything else about
|
||||
/// the line is identical, so a script matching one also matches the other.
|
||||
fn purge_summary_line(verb: &str, label: &str, report: &serde_json::Value) -> String {
|
||||
let pages = report["pages_deleted"].as_u64().unwrap_or(0);
|
||||
let sessions = report["sessions_deleted"].as_u64().unwrap_or(0);
|
||||
let observations = report["observations_deleted"].as_u64().unwrap_or(0);
|
||||
let handoffs = report["handoffs_deleted"].as_u64().unwrap_or(0);
|
||||
let embeddings = report["embeddings_deleted"].as_u64().unwrap_or(0);
|
||||
// Workstreams cascade out of the project row, so a scope that looks empty
|
||||
// by every other counter can still be carrying a managed workstream and
|
||||
// its portable event ledger. Always name them.
|
||||
let workstreams = report["workstreams_deleted"].as_u64().unwrap_or(0);
|
||||
let managed_runs = report["managed_runs_deleted"].as_u64().unwrap_or(0);
|
||||
let mut line = format!(
|
||||
"{verb} {label}: {pages} pages, {sessions} sessions, \
|
||||
{observations} observations, {handoffs} handoffs, {embeddings} embeddings, \
|
||||
{workstreams} workstreams, {managed_runs} managed runs."
|
||||
);
|
||||
// The mirror of the incident this command guards against: purging this
|
||||
// scope also collaterally deletes/orphans rows that live in *other*
|
||||
// projects, via `sessions` cascading out of this one. Silent when zero
|
||||
// so the common case reads exactly as before.
|
||||
let collateral_observations = report["collateral_observations_deleted"]
|
||||
.as_u64()
|
||||
.unwrap_or(0);
|
||||
if collateral_observations > 0 {
|
||||
line.push_str(&format!(
|
||||
" Plus {collateral_observations} observations in other projects via their sessions."
|
||||
));
|
||||
}
|
||||
let collateral_handoffs = report["collateral_handoffs_denulled"].as_u64().unwrap_or(0);
|
||||
if collateral_handoffs > 0 {
|
||||
line.push_str(&format!(
|
||||
" Plus {collateral_handoffs} handoffs in other projects that will lose their \
|
||||
session reference (set to NULL, not deleted)."
|
||||
));
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
/// What became of the best-effort preview request, reduced to what deciding
|
||||
/// whether — and what — to print needs. Kept separate from the network call
|
||||
/// itself so the printing decision is a pure function and testable without a
|
||||
/// server.
|
||||
enum PreviewOutcome {
|
||||
/// A 200 with `"dry_run": true`: the server understood the request and
|
||||
/// ran the preview.
|
||||
Previewed(serde_json::Value),
|
||||
/// A 200 without `dry_run` set: an old-enough server both predates the
|
||||
/// field AND happens to 200 an unrecognized shape. Treated the same as
|
||||
/// not getting a preview at all.
|
||||
Ignored,
|
||||
/// A non-2xx response with a body worth showing: the scope resolved to
|
||||
/// something the operator should know about before the refusal (a 404
|
||||
/// naming the missing project, a 409 naming the live managed run, a 403
|
||||
/// naming the auth problem), or an unexpected status this command has no
|
||||
/// specific handling for.
|
||||
Refused { status: u16, body: String },
|
||||
/// The request predates `dry_run` support (400, the pre-existing
|
||||
/// "confirm=true" refusal body) — not worth repeating, since `run` below
|
||||
/// prints its own version of exactly that message next regardless.
|
||||
OlderServer,
|
||||
/// Timed out or never reached a server at all (DNS/connect failure,
|
||||
/// auth-refresh hang, etc). Indistinguishable from the operator's
|
||||
/// perspective, and neither is this command's business to diagnose.
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
/// Decide what to print, if anything, before the refusal — pure, so it is
|
||||
/// unit-tested without a server. `fallback_label` is used only when the
|
||||
/// server's own report has no `label` field.
|
||||
fn preview_message(outcome: &PreviewOutcome, fallback_label: &str) -> Option<String> {
|
||||
match outcome {
|
||||
PreviewOutcome::Previewed(report) => {
|
||||
let label = report["label"].as_str().unwrap_or(fallback_label);
|
||||
Some(purge_summary_line("Would purge", label, report))
|
||||
}
|
||||
PreviewOutcome::Refused { status, body } => {
|
||||
let message = serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_string))
|
||||
.unwrap_or_else(|| body.clone());
|
||||
Some(format!("Preview refused ({status}): {message}"))
|
||||
}
|
||||
PreviewOutcome::Ignored | PreviewOutcome::OlderServer | PreviewOutcome::Unreachable => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the preview request under [`PREVIEW_TIMEOUT`] and classify the
|
||||
/// result. Auth resolution (`ServerEndpoint::from_config_resolving_auth`,
|
||||
/// which can itself refresh an OIDC token over the network) runs inside the
|
||||
/// same timeout so a hung refresh cannot silently make `--confirm`-less
|
||||
/// purge-project block far longer than the rest of this command ever has.
|
||||
async fn run_preview(config: &Config, request: &PurgeProjectRequest) -> PreviewOutcome {
|
||||
let attempt = tokio::time::timeout(PREVIEW_TIMEOUT, async {
|
||||
let endpoint = ServerEndpoint::from_config_resolving_auth(config).await;
|
||||
post_json::<_, serde_json::Value>(&endpoint, "/admin/purge-project", request).await
|
||||
})
|
||||
.await;
|
||||
|
||||
let Ok(result) = attempt else {
|
||||
return PreviewOutcome::Unreachable;
|
||||
};
|
||||
match result {
|
||||
Ok(report) if report["dry_run"].as_bool().unwrap_or(false) => {
|
||||
PreviewOutcome::Previewed(report)
|
||||
}
|
||||
Ok(_) => PreviewOutcome::Ignored,
|
||||
Err(e) => match e.downcast_ref::<ServerResponseError>() {
|
||||
Some(resp) if resp.status().as_u16() == 400 => PreviewOutcome::OlderServer,
|
||||
Some(resp) => PreviewOutcome::Refused {
|
||||
status: resp.status().as_u16(),
|
||||
body: resp.body().to_string(),
|
||||
},
|
||||
// Not an HTTP response at all: connect/DNS failure, request
|
||||
// timeout already handled above, or a body that failed to
|
||||
// deserialize as JSON.
|
||||
None => PreviewOutcome::Unreachable,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the `purge-project` subcommand.
|
||||
@@ -25,14 +167,41 @@ struct PurgeProjectRequest {
|
||||
/// `--project` is omitted), requires `--confirm` before sending the
|
||||
/// destructive request, then prints the JSON summary.
|
||||
///
|
||||
/// Without `--confirm`, first asks the server for a preview (`dry_run:
|
||||
/// true`, which wins over `confirm` server-side): the server reports the
|
||||
/// counts a confirmed purge would produce without deleting anything. That
|
||||
/// preview is best-effort, bounded by [`PREVIEW_TIMEOUT`], and never changes
|
||||
/// the outcome — only what gets printed before it:
|
||||
/// - a successful preview prints the "Would purge ..." line;
|
||||
/// - a 404/409/403 (or any other unexpected status) prints the server's own
|
||||
/// error first, since the operator asked what would happen and the server
|
||||
/// has an answer, just not the one this command expected;
|
||||
/// - a plain 400 (an older server that predates `dry_run`), a timeout, or an
|
||||
/// unreachable server print nothing extra — the refusal below already
|
||||
/// says everything a 400 would.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error when `--confirm` is absent, the server is unreachable,
|
||||
/// or the server returns a non-2xx response.
|
||||
/// Returns an error when `--confirm` is absent (after printing whatever the
|
||||
/// preview surfaced), the server is unreachable, or the server returns a
|
||||
/// non-2xx response.
|
||||
pub async fn run(config: &Config, args: PurgeProjectArgs) -> Result<()> {
|
||||
let (workspace, project) =
|
||||
super::resolve_scope(config, args.workspace.as_deref(), args.project.as_deref())?;
|
||||
|
||||
if !args.confirm {
|
||||
let request = PurgeProjectRequest {
|
||||
workspace: workspace.clone(),
|
||||
project: project.clone(),
|
||||
confirm: false,
|
||||
force: args.force,
|
||||
compact: args.compact,
|
||||
dry_run: true,
|
||||
};
|
||||
let outcome = run_preview(config, &request).await;
|
||||
let fallback_label = format!("{}/{}", workspace, project);
|
||||
if let Some(line) = preview_message(&outcome, &fallback_label) {
|
||||
println!("{line}");
|
||||
}
|
||||
bail!(
|
||||
"purge-project is destructive and irreversible.\n\
|
||||
Re-run with --confirm to proceed:\n\n \
|
||||
@@ -52,6 +221,7 @@ pub async fn run(config: &Config, args: PurgeProjectArgs) -> Result<()> {
|
||||
confirm: true,
|
||||
force: args.force,
|
||||
compact: args.compact,
|
||||
dry_run: false,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -59,21 +229,7 @@ pub async fn run(config: &Config, args: PurgeProjectArgs) -> Result<()> {
|
||||
// Human-friendly one-liner followed by the raw JSON for scripting.
|
||||
let fallback_label = format!("{}/{}", workspace, project);
|
||||
let label = report["label"].as_str().unwrap_or(&fallback_label);
|
||||
let pages = report["pages_deleted"].as_u64().unwrap_or(0);
|
||||
let sessions = report["sessions_deleted"].as_u64().unwrap_or(0);
|
||||
let observations = report["observations_deleted"].as_u64().unwrap_or(0);
|
||||
let handoffs = report["handoffs_deleted"].as_u64().unwrap_or(0);
|
||||
let embeddings = report["embeddings_deleted"].as_u64().unwrap_or(0);
|
||||
// Workstreams cascade out of the project row, so a scope that looks empty
|
||||
// by every other counter can still be carrying a managed workstream and
|
||||
// its portable event ledger. Always name them.
|
||||
let workstreams = report["workstreams_deleted"].as_u64().unwrap_or(0);
|
||||
let managed_runs = report["managed_runs_deleted"].as_u64().unwrap_or(0);
|
||||
println!(
|
||||
"Purged {label}: {pages} pages, {sessions} sessions, \
|
||||
{observations} observations, {handoffs} handoffs, {embeddings} embeddings, \
|
||||
{workstreams} workstreams, {managed_runs} managed runs."
|
||||
);
|
||||
println!("{}", purge_summary_line("Purged", label, &report));
|
||||
if let Some(ids) = report["workstream_ids"].as_array()
|
||||
&& !ids.is_empty()
|
||||
{
|
||||
@@ -105,3 +261,265 @@ pub async fn run(config: &Config, args: PurgeProjectArgs) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn report(dry_run: bool) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"label": "default/my-project",
|
||||
"pages_deleted": 3,
|
||||
"sessions_deleted": 1,
|
||||
"observations_deleted": 1063,
|
||||
"handoffs_deleted": 0,
|
||||
"embeddings_deleted": 3,
|
||||
"collateral_observations_deleted": 0,
|
||||
"collateral_handoffs_denulled": 0,
|
||||
"workstreams_deleted": 0,
|
||||
"managed_runs_deleted": 0,
|
||||
"workstream_ids": [],
|
||||
"files_deleted": [],
|
||||
"files_failed": [],
|
||||
"compacted": false,
|
||||
"dry_run": dry_run,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pins the exact wording and field order a script would grep for.
|
||||
/// `purge_summary_line` is the single source for both the confirmed
|
||||
/// "Purged" line and the preview's "Would purge" line, so this also
|
||||
/// proves the two can never drift apart.
|
||||
#[test]
|
||||
fn purge_summary_line_matches_the_documented_wording() {
|
||||
let confirmed = report(false);
|
||||
assert_eq!(
|
||||
purge_summary_line("Purged", "default/my-project", &confirmed),
|
||||
"Purged default/my-project: 3 pages, 1 sessions, 1063 observations, \
|
||||
0 handoffs, 3 embeddings, 0 workstreams, 0 managed runs."
|
||||
);
|
||||
|
||||
let preview = report(true);
|
||||
assert_eq!(
|
||||
purge_summary_line("Would purge", "default/my-project", &preview),
|
||||
"Would purge default/my-project: 3 pages, 1 sessions, 1063 observations, \
|
||||
0 handoffs, 3 embeddings, 0 workstreams, 0 managed runs."
|
||||
);
|
||||
}
|
||||
|
||||
/// Missing counters must not panic and must not silently show as
|
||||
/// non-zero: a malformed or truncated reply reads as all-zero, which the
|
||||
/// operator can visibly tell apart from a real "0 pages, 0 sessions".
|
||||
#[test]
|
||||
fn purge_summary_line_defaults_missing_counters_to_zero() {
|
||||
let empty = serde_json::json!({});
|
||||
assert_eq!(
|
||||
purge_summary_line("Would purge", "default/x", &empty),
|
||||
"Would purge default/x: 0 pages, 0 sessions, 0 observations, \
|
||||
0 handoffs, 0 embeddings, 0 workstreams, 0 managed runs."
|
||||
);
|
||||
}
|
||||
|
||||
/// The mirror-of-the-incident collateral counts, when present, must be
|
||||
/// visible in the same line the operator already reads — silent when
|
||||
/// zero (the common case), spelled out when not.
|
||||
#[test]
|
||||
fn purge_summary_line_calls_out_collateral_damage_when_present() {
|
||||
let mut r = report(true);
|
||||
r["collateral_observations_deleted"] = serde_json::json!(7);
|
||||
r["collateral_handoffs_denulled"] = serde_json::json!(2);
|
||||
let line = purge_summary_line("Would purge", "default/looks-empty", &r);
|
||||
assert!(
|
||||
line.contains("Plus 7 observations in other projects via their sessions."),
|
||||
"collateral observations must be called out: {line}"
|
||||
);
|
||||
assert!(
|
||||
line.contains("Plus 2 handoffs in other projects"),
|
||||
"collateral handoffs must be called out: {line}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A successful preview prints the "Would purge" line.
|
||||
#[test]
|
||||
fn preview_message_prints_the_would_purge_line_on_success() {
|
||||
let outcome = PreviewOutcome::Previewed(report(true));
|
||||
let msg = preview_message(&outcome, "default/fallback").expect("must print a line");
|
||||
assert!(msg.starts_with("Would purge default/my-project:"));
|
||||
}
|
||||
|
||||
/// A 404 (unknown scope) is worth showing before the refusal: the
|
||||
/// operator asked what would happen, and 404 is the server's answer.
|
||||
#[test]
|
||||
fn preview_message_surfaces_a_404_before_the_refusal() {
|
||||
let outcome = PreviewOutcome::Refused {
|
||||
status: 404,
|
||||
body: r#"{"error":"project 'ghost' not found in workspace 'default'"}"#.to_string(),
|
||||
};
|
||||
let msg = preview_message(&outcome, "default/ghost").expect("must print a line");
|
||||
assert_eq!(
|
||||
msg,
|
||||
"Preview refused (404): project 'ghost' not found in workspace 'default'"
|
||||
);
|
||||
}
|
||||
|
||||
/// A 409 (live managed run) is the same: surfaced, not swallowed.
|
||||
#[test]
|
||||
fn preview_message_surfaces_a_409_before_the_refusal() {
|
||||
let outcome = PreviewOutcome::Refused {
|
||||
status: 409,
|
||||
body: r#"{"error":"managed run lease is active for 'main' (claude-code)"}"#.to_string(),
|
||||
};
|
||||
let msg = preview_message(&outcome, "default/x").expect("must print a line");
|
||||
assert_eq!(
|
||||
msg,
|
||||
"Preview refused (409): managed run lease is active for 'main' (claude-code)"
|
||||
);
|
||||
}
|
||||
|
||||
/// A body that isn't the expected `{"error": ...}` shape still prints
|
||||
/// something rather than nothing — the raw body, verbatim.
|
||||
#[test]
|
||||
fn preview_message_falls_back_to_the_raw_body_when_not_json() {
|
||||
let outcome = PreviewOutcome::Refused {
|
||||
status: 403,
|
||||
body: "Forbidden".to_string(),
|
||||
};
|
||||
let msg = preview_message(&outcome, "default/x").expect("must print a line");
|
||||
assert_eq!(msg, "Preview refused (403): Forbidden");
|
||||
}
|
||||
|
||||
/// The three silent-fallback cases: an older server's plain 400, a
|
||||
/// timeout/connect failure, and a 200 that oddly never set `dry_run`.
|
||||
/// None of these should print anything — the refusal that follows in
|
||||
/// `run` already says everything a 400 would, and there is nothing
|
||||
/// useful to say about a request that never got an answer.
|
||||
#[test]
|
||||
fn preview_message_is_silent_for_older_server_unreachable_and_ignored() {
|
||||
assert!(preview_message(&PreviewOutcome::OlderServer, "default/x").is_none());
|
||||
assert!(preview_message(&PreviewOutcome::Unreachable, "default/x").is_none());
|
||||
assert!(preview_message(&PreviewOutcome::Ignored, "default/x").is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// `run_preview` against a real (local) server: proves the HTTP-status
|
||||
// classification end to end, not just the pure `preview_message` mapping
|
||||
// above.
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
fn config_for(tmp: &tempfile::TempDir, server_url: String) -> Config {
|
||||
Config {
|
||||
data_dir: tmp.path().to_path_buf(),
|
||||
server_url,
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_fixed_response(status: u16, body: &'static str) -> String {
|
||||
let app = axum::Router::new().route(
|
||||
"/admin/purge-project",
|
||||
axum::routing::post(move || async move {
|
||||
(
|
||||
axum::http::StatusCode::from_u16(status).unwrap(),
|
||||
axum::Json(serde_json::from_str::<serde_json::Value>(body).unwrap()),
|
||||
)
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
fn preview_request() -> PurgeProjectRequest {
|
||||
PurgeProjectRequest {
|
||||
workspace: "default".into(),
|
||||
project: "scratch".into(),
|
||||
confirm: false,
|
||||
force: false,
|
||||
compact: false,
|
||||
dry_run: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// An older server that predates `dry_run` answers the plain 400
|
||||
/// "confirm=true" refusal it always has — the CLI must fall back
|
||||
/// silently, not print anything extra.
|
||||
#[tokio::test]
|
||||
async fn run_preview_classifies_a_400_as_older_server() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let url = spawn_fixed_response(
|
||||
400,
|
||||
r#"{"error": "destructive operation requires confirm=true"}"#,
|
||||
)
|
||||
.await;
|
||||
let config = config_for(&tmp, url);
|
||||
let outcome = run_preview(&config, &preview_request()).await;
|
||||
assert!(matches!(outcome, PreviewOutcome::OlderServer));
|
||||
assert!(preview_message(&outcome, "default/scratch").is_none());
|
||||
}
|
||||
|
||||
/// A 404 is surfaced: the operator asked what a purge of this scope
|
||||
/// would do, and "no such scope" is a real, useful answer.
|
||||
#[tokio::test]
|
||||
async fn run_preview_classifies_a_404_as_refused_and_surfaces_the_message() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let url = spawn_fixed_response(
|
||||
404,
|
||||
r#"{"error": "project 'scratch' not found in workspace 'default'"}"#,
|
||||
)
|
||||
.await;
|
||||
let config = config_for(&tmp, url);
|
||||
let outcome = run_preview(&config, &preview_request()).await;
|
||||
match &outcome {
|
||||
PreviewOutcome::Refused { status, body } => {
|
||||
assert_eq!(*status, 404);
|
||||
assert!(body.contains("not found"));
|
||||
}
|
||||
_ => panic!("expected Refused, got a different outcome"),
|
||||
}
|
||||
let msg = preview_message(&outcome, "default/scratch").expect("must print a line");
|
||||
assert_eq!(
|
||||
msg,
|
||||
"Preview refused (404): project 'scratch' not found in workspace 'default'"
|
||||
);
|
||||
}
|
||||
|
||||
/// A successful preview (200, `dry_run: true`) is a `Previewed` outcome
|
||||
/// carrying the report through untouched.
|
||||
#[tokio::test]
|
||||
async fn run_preview_classifies_a_successful_preview() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let url = spawn_fixed_response(
|
||||
200,
|
||||
r#"{"label": "default/scratch", "pages_deleted": 3, "sessions_deleted": 1,
|
||||
"observations_deleted": 1063, "handoffs_deleted": 0, "embeddings_deleted": 3,
|
||||
"collateral_observations_deleted": 0, "collateral_handoffs_denulled": 0,
|
||||
"workstreams_deleted": 0, "managed_runs_deleted": 0, "workstream_ids": [],
|
||||
"files_deleted": [], "files_failed": [], "compacted": false, "dry_run": true}"#,
|
||||
)
|
||||
.await;
|
||||
let config = config_for(&tmp, url);
|
||||
let outcome = run_preview(&config, &preview_request()).await;
|
||||
let msg = preview_message(&outcome, "default/scratch").expect("must print a line");
|
||||
assert!(msg.starts_with("Would purge default/scratch: 3 pages, 1 sessions, 1063"));
|
||||
}
|
||||
|
||||
/// Nothing listening at all (connection refused) must classify as
|
||||
/// `Unreachable`, the same as a timeout — both are silent fallbacks.
|
||||
#[tokio::test]
|
||||
async fn run_preview_classifies_a_connection_failure_as_unreachable() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
// Bind then drop immediately: the port is very likely free again by
|
||||
// the time the request lands, and nothing else can be listening on
|
||||
// it inside this test's short lifetime.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
drop(listener);
|
||||
let config = config_for(&tmp, format!("http://{addr}"));
|
||||
let outcome = run_preview(&config, &preview_request()).await;
|
||||
assert!(matches!(outcome, PreviewOutcome::Unreachable));
|
||||
assert!(preview_message(&outcome, "default/scratch").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7888,6 +7888,7 @@ mod tests {
|
||||
None,
|
||||
false,
|
||||
ai_memory_store::Compaction::Skip,
|
||||
ai_memory_store::PurgeMode::Commit,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -8710,6 +8711,7 @@ mod tests {
|
||||
None,
|
||||
false,
|
||||
ai_memory_store::Compaction::Skip,
|
||||
ai_memory_store::PurgeMode::Commit,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -4142,6 +4142,21 @@ struct PurgeProjectRequest {
|
||||
/// the cost and for what it does not guarantee.
|
||||
#[serde(default)]
|
||||
compact: bool,
|
||||
/// Preview only: report the counts a purge of this scope would produce
|
||||
/// without deleting anything. Wins over `confirm` — `{"confirm": true,
|
||||
/// "dry_run": true}` still only previews, exactly like
|
||||
/// `reclaim-ledger-versions` treats its own `dry_run` field — so a
|
||||
/// preview request can never accidentally become destructive.
|
||||
/// `#[serde(default)]` here is what keeps an *old* CLI (built before this
|
||||
/// field existed, so it never sends `dry_run` at all) parsing the same
|
||||
/// request it always sent. The other direction — a *new* CLI talking to
|
||||
/// an *old* server that doesn't know this field yet — needs nothing on
|
||||
/// this struct at all: an unknown JSON field is simply not a
|
||||
/// `Deserialize` error on the server, so the old server ignores it and
|
||||
/// the new CLI's fallback (see `commands/purge_project.rs`) handles the
|
||||
/// resulting plain 400.
|
||||
#[serde(default)]
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
/// Wire-format summary returned by `POST /admin/purge-project`.
|
||||
@@ -4159,6 +4174,19 @@ pub struct PurgeProjectReport {
|
||||
pub handoffs_deleted: u64,
|
||||
/// Number of `page_embeddings` rows deleted.
|
||||
pub embeddings_deleted: u64,
|
||||
/// `observations` rows in a **different** project, deleted collaterally
|
||||
/// because their `session_id` belongs to a session that lives in this
|
||||
/// one (`observations.session_id` is `ON DELETE CASCADE`, without regard
|
||||
/// to the observation's own `project_id`) — the mirror of the incident
|
||||
/// this preview guards against. Always present, zero when there is none,
|
||||
/// so a caller can tell "no collateral damage" apart from "field absent
|
||||
/// on an older server".
|
||||
pub collateral_observations_deleted: u64,
|
||||
/// `handoffs` rows in a different project whose `from_session_id` or
|
||||
/// `accepted_by_session` is set to `NULL` (not deleted — those columns
|
||||
/// are `ON DELETE SET NULL`) because they referenced a session that
|
||||
/// lives in this project.
|
||||
pub collateral_handoffs_denulled: u64,
|
||||
/// Number of managed `workstreams` rows deleted via cascade.
|
||||
pub workstreams_deleted: u64,
|
||||
/// Number of `managed_runs` rows deleted via cascade.
|
||||
@@ -4180,6 +4208,16 @@ pub struct PurgeProjectReport {
|
||||
/// Post-purge checkpoint, if the purge changed the tree.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub checkpoint: Option<String>,
|
||||
/// `true` for a preview: `dry_run: true` in the request always wins over
|
||||
/// `confirm`, so this is only ever `false` on a run that actually
|
||||
/// deleted rows. The counts above are read directly by the same queries
|
||||
/// a confirmed purge uses to decide what to delete — never by running
|
||||
/// the delete and rolling it back — so `files_deleted`/`files_failed`
|
||||
/// are always empty and `pre_checkpoint`/`checkpoint` always absent: no
|
||||
/// wiki file was touched, no admission webhook ran, and no audit row was
|
||||
/// written.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
async fn remove_workstream_segment_storage(
|
||||
@@ -4259,6 +4297,13 @@ async fn handle_purge_project(
|
||||
Json(req): Json<PurgeProjectRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let author_id = author_ext.map(|axum::Extension(u)| u);
|
||||
// `dry_run` always wins, exactly like `reclaim-ledger-versions` (its
|
||||
// handler passes `req.dry_run` straight into the op regardless of any
|
||||
// other field): `{"confirm": true, "dry_run": true}` must never run the
|
||||
// destructive path just because `confirm` also happened to be set.
|
||||
if req.dry_run {
|
||||
return purge_project_preview(&state, &req, author_id).await;
|
||||
}
|
||||
if !req.confirm {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -4316,7 +4361,15 @@ async fn handle_purge_project(
|
||||
|
||||
let summary = match state
|
||||
.writer
|
||||
.purge_project(ws_id, proj_id, &label, author_id, req.force, compaction)
|
||||
.purge_project(
|
||||
ws_id,
|
||||
proj_id,
|
||||
&label,
|
||||
author_id,
|
||||
req.force,
|
||||
compaction,
|
||||
ai_memory_store::PurgeMode::Commit,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(s) => s,
|
||||
@@ -4369,6 +4422,8 @@ async fn handle_purge_project(
|
||||
observations_deleted: summary.observations_deleted,
|
||||
handoffs_deleted: summary.handoffs_deleted,
|
||||
embeddings_deleted: summary.embeddings_deleted,
|
||||
collateral_observations_deleted: summary.collateral_observations_deleted,
|
||||
collateral_handoffs_denulled: summary.collateral_handoffs_denulled,
|
||||
workstreams_deleted: summary.workstreams_deleted,
|
||||
managed_runs_deleted: summary.managed_runs_deleted,
|
||||
workstream_ids: summary.workstream_ids,
|
||||
@@ -4377,6 +4432,102 @@ async fn handle_purge_project(
|
||||
files_failed,
|
||||
pre_checkpoint,
|
||||
checkpoint,
|
||||
dry_run: false,
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::to_value(&report).unwrap_or_else(|_| serde_json::json!({}))),
|
||||
)
|
||||
}
|
||||
|
||||
/// Preview branch of `POST /admin/purge-project`: reached whenever `dry_run`
|
||||
/// is true, regardless of `confirm` (see the `dry_run`-always-wins check in
|
||||
/// `handle_purge_project`). `ai_memory_store::purge_project` under
|
||||
/// `PurgeMode::Preview` counts every row a confirmed purge would delete —
|
||||
/// via the same `SELECT`s the confirmed path itself uses to decide what to
|
||||
/// delete, not a separately-maintained estimate — and returns without ever
|
||||
/// issuing the `DELETE`, so there is no cascade to roll back, unlike
|
||||
/// `move-session`'s dry run (which does run its write and rolls it back: a
|
||||
/// session's rows are cheap; a whole project's are not, and this preview is
|
||||
/// now the default no-`--confirm` behavior of a command a hook can also
|
||||
/// race against).
|
||||
///
|
||||
/// Deliberately skipped, unlike the confirmed path above: it never opens the
|
||||
/// blocking admission call (`admit_purge_project`) at all — nothing was
|
||||
/// decided yet, so there is nothing for a mirror to act on — plus wiki file
|
||||
/// removal (nothing was deleted) and both checkpoints (the git tree does not
|
||||
/// change). Because admission never runs, a `200` here is not a guarantee:
|
||||
/// a `Reject`-policy or scope-guard admission webhook only runs on the
|
||||
/// confirmed path and can still refuse the real purge afterward.
|
||||
///
|
||||
/// Because admission never runs here, a `200` preview is not a promise the
|
||||
/// confirmed purge will succeed: a `Reject`-policy or scope-guard admission
|
||||
/// webhook only runs on the confirmed path and can still refuse it after the
|
||||
/// operator has already seen this preview.
|
||||
///
|
||||
/// Scope resolution and its 404 run exactly as the real purge's do, so an
|
||||
/// unknown workspace/project answers identically either way.
|
||||
async fn purge_project_preview(
|
||||
state: &Arc<AdminState>,
|
||||
req: &PurgeProjectRequest,
|
||||
author_id: Option<ai_memory_core::UserId>,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
let (ws_id, proj_id) = match lookup_ws_proj_no_create(state, &req.workspace, &req.project).await
|
||||
{
|
||||
Ok(ids) => ids,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let label = format!("{}/{}", req.workspace, req.project);
|
||||
|
||||
let summary = match state
|
||||
.writer
|
||||
.purge_project(
|
||||
ws_id,
|
||||
proj_id,
|
||||
&label,
|
||||
author_id,
|
||||
req.force,
|
||||
// A preview never deletes anything, so there is nothing to
|
||||
// reclaim; `ops::purge_project` also forces `compacted: false`
|
||||
// for `PurgeMode::Preview` regardless of this value.
|
||||
ai_memory_store::Compaction::Skip,
|
||||
ai_memory_store::PurgeMode::Preview,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(s) => s,
|
||||
// Same conflict a confirmed purge would hit, reported the same way:
|
||||
// the preview is a promise of what a real purge would do, and a real
|
||||
// purge would refuse here too.
|
||||
Err(e @ StoreError::ManagedRunActive { .. }) => {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(serde_json::json!({ "error": e.to_string() })),
|
||||
);
|
||||
}
|
||||
Err(e) => return internal_err(e.to_string()),
|
||||
};
|
||||
|
||||
let report = PurgeProjectReport {
|
||||
label: summary.label,
|
||||
pages_deleted: summary.pages_deleted,
|
||||
sessions_deleted: summary.sessions_deleted,
|
||||
observations_deleted: summary.observations_deleted,
|
||||
handoffs_deleted: summary.handoffs_deleted,
|
||||
embeddings_deleted: summary.embeddings_deleted,
|
||||
collateral_observations_deleted: summary.collateral_observations_deleted,
|
||||
collateral_handoffs_denulled: summary.collateral_handoffs_denulled,
|
||||
workstreams_deleted: summary.workstreams_deleted,
|
||||
managed_runs_deleted: summary.managed_runs_deleted,
|
||||
workstream_ids: summary.workstream_ids,
|
||||
compacted: false,
|
||||
files_deleted: Vec::new(),
|
||||
files_failed: Vec::new(),
|
||||
pre_checkpoint: None,
|
||||
checkpoint: None,
|
||||
dry_run: true,
|
||||
};
|
||||
|
||||
(
|
||||
@@ -6629,6 +6780,7 @@ async fn copy_purge_merge(
|
||||
None,
|
||||
false,
|
||||
ai_memory_store::Compaction::Skip,
|
||||
ai_memory_store::PurgeMode::Commit,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -12108,6 +12260,95 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The preview branch (no `confirm`, `dry_run: true`) sits behind the
|
||||
/// exact same root-only gate as the rest of `/admin/purge-project` — it
|
||||
/// is reached through the same handler and route, so there is no
|
||||
/// separate check to forget. Mirrors
|
||||
/// `multiuser_admin_routes_reject_db_user_tier` /
|
||||
/// `multiuser_admin_routes_reject_anonymous` above, but with a payload
|
||||
/// shaped like a preview request instead of `admin_route_samples()`'s
|
||||
/// confirmed one. The root control case proves the gate — not the
|
||||
/// route — is what is being tested: root reaches the handler (answered
|
||||
/// with `404`, since this router's store has no `default/scratch`
|
||||
/// project), while the DB user and anonymous requests below never get
|
||||
/// that far.
|
||||
#[tokio::test]
|
||||
async fn multiuser_purge_project_dry_run_rejects_db_user_and_anonymous() {
|
||||
let (_tmp, router) = user_admin_test_router("root-token");
|
||||
post_create_user(
|
||||
&router,
|
||||
"root-token",
|
||||
serde_json::json!({"username": "alice"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let preview_body = serde_json::json!({
|
||||
"workspace": "default",
|
||||
"project": "scratch",
|
||||
"confirm": false,
|
||||
"dry_run": true
|
||||
});
|
||||
|
||||
// Root control case: reaches the handler (proven by getting past
|
||||
// both auth statuses below into the route's own 404 for an unknown
|
||||
// project), unlike the DB-user and anonymous requests.
|
||||
let root_resp = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/purge-project")
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer root-token")
|
||||
.body(Body::from(serde_json::to_vec(&preview_body).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
root_resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"root must reach the preview handler, not be blocked by the auth gate"
|
||||
);
|
||||
|
||||
let db_user_resp = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/purge-project")
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer db-user-token")
|
||||
.body(Body::from(serde_json::to_vec(&preview_body).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db_user_resp.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"a dry-run preview must stay root-only for DB users in multi-user mode"
|
||||
);
|
||||
|
||||
let anon_resp = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/purge-project")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&preview_body).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
anon_resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"a dry-run preview must require authentication in multi-user mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiuser_operational_admin_routes_allow_root() {
|
||||
let (_tmp, router) = user_admin_test_router("root-token");
|
||||
|
||||
@@ -803,3 +803,462 @@ async fn purge_project_idempotent_second_call_is_404() {
|
||||
"second purge must 404 because project is already gone"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dry-run preview (no `confirm`, `dry_run: true`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The preview must report the exact counts a confirmed purge would produce
|
||||
/// — not an estimate — and it must not touch anything: the confirmed purge
|
||||
/// run right after it must succeed with identical counts, and the project's
|
||||
/// files and DB rows must still be intact in between.
|
||||
#[tokio::test]
|
||||
async fn purge_project_dry_run_reports_the_same_counts_the_confirmed_purge_will() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (state, store) = make_state(&tmp).await;
|
||||
|
||||
let (ws, _keep, doomed) = seed_two_projects(&store, &state.wiki).await;
|
||||
let proj_dir = state.wiki.project_root(ws, doomed);
|
||||
|
||||
let preview = post(
|
||||
state.clone(),
|
||||
"/admin/purge-project",
|
||||
json!({ "workspace": "default", "project": "doomed", "confirm": false, "dry_run": true }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(preview.status(), StatusCode::OK, "a preview must succeed");
|
||||
let preview_body = body_json(preview).await;
|
||||
assert_eq!(preview_body["dry_run"], true);
|
||||
assert_eq!(preview_body["pages_deleted"], 1);
|
||||
assert_eq!(preview_body["sessions_deleted"], 1);
|
||||
assert_eq!(preview_body["observations_deleted"], 3);
|
||||
assert_eq!(preview_body["handoffs_deleted"], 1);
|
||||
assert_eq!(preview_body["files_deleted"], json!([]));
|
||||
assert_eq!(preview_body["files_failed"], json!([]));
|
||||
assert_eq!(preview_body["compacted"], false);
|
||||
assert!(
|
||||
preview_body.get("pre_checkpoint").is_none(),
|
||||
"a preview must not checkpoint the wiki tree"
|
||||
);
|
||||
assert!(
|
||||
preview_body.get("checkpoint").is_none(),
|
||||
"a preview must not checkpoint the wiki tree"
|
||||
);
|
||||
|
||||
// Nothing was touched: the project directory and its row are still there.
|
||||
assert!(
|
||||
proj_dir.exists(),
|
||||
"a preview must not remove the project directory"
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.reader
|
||||
.find_project(ws, "doomed".to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"a preview must not delete the project row"
|
||||
);
|
||||
|
||||
// The confirmed purge right after it must succeed with the same counts.
|
||||
let confirmed = post(
|
||||
state,
|
||||
"/admin/purge-project",
|
||||
json!({ "workspace": "default", "project": "doomed", "confirm": true }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(confirmed.status(), StatusCode::OK);
|
||||
let confirmed_body = body_json(confirmed).await;
|
||||
assert_eq!(
|
||||
confirmed_body.get("dry_run"),
|
||||
None,
|
||||
"a confirmed purge report has no dry_run key"
|
||||
);
|
||||
for field in [
|
||||
"pages_deleted",
|
||||
"sessions_deleted",
|
||||
"observations_deleted",
|
||||
"handoffs_deleted",
|
||||
] {
|
||||
assert_eq!(
|
||||
confirmed_body[field], preview_body[field],
|
||||
"the confirmed purge's {field} must match what the preview reported"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!proj_dir.exists(),
|
||||
"the confirmed purge must remove the project directory"
|
||||
);
|
||||
}
|
||||
|
||||
/// A dry run must not dispatch the admission webhook: nothing was decided
|
||||
/// yet, so there is nothing for a mirror to act on. Uses the same
|
||||
/// `Ignore`/non-blocking capture-hook pattern as
|
||||
/// `purge_session_reports_file_cleanup_failure_after_db_commit` above, but
|
||||
/// asserts the opposite — that no payload ever arrives — within a short
|
||||
/// timeout.
|
||||
#[tokio::test]
|
||||
async fn purge_project_dry_run_does_not_dispatch_admission_webhook() {
|
||||
let (url, rx) = spawn_capture_hook().await;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let chain = AdmissionChain::new(vec![WebhookConfig {
|
||||
name: "async-mirror".into(),
|
||||
url,
|
||||
timeout_ms: 2_000,
|
||||
failure_policy: FailurePolicy::Ignore,
|
||||
events: vec![AdmissionOp::PurgeProject],
|
||||
blocking: false,
|
||||
}])
|
||||
.unwrap();
|
||||
let (state, store) = make_state_with_chain(&tmp, Some(chain)).await;
|
||||
seed_two_projects(&store, &state.wiki).await;
|
||||
|
||||
let resp = post(
|
||||
state,
|
||||
"/admin/purge-project",
|
||||
json!({ "workspace": "default", "project": "doomed", "confirm": false, "dry_run": true }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let outcome = tokio::time::timeout(std::time::Duration::from_millis(300), rx).await;
|
||||
assert!(
|
||||
outcome.is_err(),
|
||||
"a dry run must not dispatch the purge-project admission webhook"
|
||||
);
|
||||
}
|
||||
|
||||
/// A non-existent project must still 404 on a preview, exactly like the
|
||||
/// confirmed purge does.
|
||||
#[tokio::test]
|
||||
async fn purge_project_dry_run_nonexistent_returns_404() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (state, store) = make_state(&tmp).await;
|
||||
store
|
||||
.writer
|
||||
.get_or_create_workspace("default")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = post(
|
||||
state,
|
||||
"/admin/purge-project",
|
||||
json!({
|
||||
"workspace": "default",
|
||||
"project": "nonexistent",
|
||||
"confirm": false,
|
||||
"dry_run": true
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
let body = body_json(resp).await;
|
||||
assert!(body["error"].as_str().unwrap_or("").contains("not found"));
|
||||
}
|
||||
|
||||
/// A non-existent workspace must also 404 on a preview.
|
||||
#[tokio::test]
|
||||
async fn purge_project_dry_run_nonexistent_workspace_returns_404() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (state, _store) = make_state(&tmp).await;
|
||||
|
||||
let resp = post(
|
||||
state,
|
||||
"/admin/purge-project",
|
||||
json!({
|
||||
"workspace": "ghost-workspace",
|
||||
"project": "x",
|
||||
"confirm": false,
|
||||
"dry_run": true
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// The incident this feature guards against, exercised through the HTTP
|
||||
/// route rather than the store function directly: a session lives in one
|
||||
/// project but one of its observations is stamped into another (the
|
||||
/// pre-#871 Windows path-casing split). The preview of the *other* project
|
||||
/// must count that observation even though no session row lives there —
|
||||
/// exactly the number a naive "0 sessions, 0 pages" glance would miss.
|
||||
#[tokio::test]
|
||||
async fn purge_project_dry_run_counts_observations_stamped_from_another_projects_session() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (state, store) = make_state(&tmp).await;
|
||||
|
||||
let ws = store
|
||||
.writer
|
||||
.get_or_create_workspace("default")
|
||||
.await
|
||||
.unwrap();
|
||||
let owner = store
|
||||
.writer
|
||||
.get_or_create_project(ws, "owner", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let doomed = store
|
||||
.writer
|
||||
.get_or_create_project(ws, "looks-empty", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sid = SessionId::new();
|
||||
store
|
||||
.writer
|
||||
.begin_session(NewSession {
|
||||
id: sid,
|
||||
workspace_id: ws,
|
||||
project_id: owner,
|
||||
agent_kind: AgentKind::ClaudeCode,
|
||||
cwd: None,
|
||||
actor_user: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
// The stray observation: same session, different project_id.
|
||||
store
|
||||
.writer
|
||||
.insert_observation(Sanitized::new(
|
||||
NewObservation {
|
||||
session_id: sid,
|
||||
workspace_id: ws,
|
||||
project_id: doomed,
|
||||
kind: ObservationKind::UserPrompt,
|
||||
extension: None,
|
||||
source_event: None,
|
||||
title: "stray".into(),
|
||||
body: "stray body".into(),
|
||||
importance: 5,
|
||||
},
|
||||
&Sanitizer::builtin(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = post(
|
||||
state,
|
||||
"/admin/purge-project",
|
||||
json!({
|
||||
"workspace": "default",
|
||||
"project": "looks-empty",
|
||||
"confirm": false,
|
||||
"dry_run": true
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(
|
||||
body["sessions_deleted"], 0,
|
||||
"the session row itself lives in the owner project"
|
||||
);
|
||||
assert_eq!(
|
||||
body["observations_deleted"], 1,
|
||||
"the stray observation stamped into the previewed project must be counted"
|
||||
);
|
||||
}
|
||||
|
||||
/// BLOCKING fix: `dry_run` must always win over `confirm`. Before this test
|
||||
/// existed, `{"confirm": true, "dry_run": true}` ran the real destructive
|
||||
/// purge — `handle_purge_project` only checked `dry_run` inside the
|
||||
/// `!confirm` branch, so a caller that (accidentally or not) sent both
|
||||
/// `true` got the worst of both: a request that reads like a preview and
|
||||
/// behaves like a purge. `reclaim-ledger-versions` never had this hole
|
||||
/// because its handler passes `req.dry_run` straight into the op regardless
|
||||
/// of `confirm`; `purge-project` now checks `dry_run` first, unconditionally,
|
||||
/// exactly the same way.
|
||||
#[tokio::test]
|
||||
async fn purge_project_confirm_true_and_dry_run_true_still_only_previews() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (state, store) = make_state(&tmp).await;
|
||||
let (ws, _keep, doomed) = seed_two_projects(&store, &state.wiki).await;
|
||||
let proj_dir = state.wiki.project_root(ws, doomed);
|
||||
|
||||
let resp = post(
|
||||
state,
|
||||
"/admin/purge-project",
|
||||
json!({
|
||||
"workspace": "default",
|
||||
"project": "doomed",
|
||||
"confirm": true,
|
||||
"dry_run": true
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(
|
||||
body["dry_run"], true,
|
||||
"confirm: true must not defeat dry_run: true"
|
||||
);
|
||||
assert_eq!(body["pages_deleted"], 1);
|
||||
assert_eq!(body["sessions_deleted"], 1);
|
||||
assert_eq!(body["observations_deleted"], 3);
|
||||
|
||||
// The only proof that matters: nothing was actually deleted.
|
||||
assert!(
|
||||
proj_dir.exists(),
|
||||
"{{confirm: true, dry_run: true}} must not delete the project directory"
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.reader
|
||||
.find_project(ws, "doomed".to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"{{confirm: true, dry_run: true}} must not delete the project row"
|
||||
);
|
||||
}
|
||||
|
||||
/// The mirror of the incident, exercised through the HTTP route: purging a
|
||||
/// project P also collaterally deletes an observation stamped into a
|
||||
/// *different* project Q (because `observations.session_id` cascades
|
||||
/// regardless of the observation's own `project_id`), and orphans (nulls the
|
||||
/// session reference of, without deleting) a handoff that lives in Q too.
|
||||
/// Neither shows up in the plain `observations_deleted`/`handoffs_deleted`
|
||||
/// counts, which is exactly why the two `collateral_*` fields exist.
|
||||
#[tokio::test]
|
||||
async fn purge_project_dry_run_and_confirmed_purge_both_report_collateral_damage_in_another_project()
|
||||
{
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (state, store) = make_state(&tmp).await;
|
||||
|
||||
let ws = store
|
||||
.writer
|
||||
.get_or_create_workspace("default")
|
||||
.await
|
||||
.unwrap();
|
||||
let doomed = store
|
||||
.writer
|
||||
.get_or_create_project(ws, "doomed", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let other = store
|
||||
.writer
|
||||
.get_or_create_project(ws, "other", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sid = SessionId::new();
|
||||
store
|
||||
.writer
|
||||
.begin_session(NewSession {
|
||||
id: sid,
|
||||
workspace_id: ws,
|
||||
project_id: doomed,
|
||||
agent_kind: AgentKind::ClaudeCode,
|
||||
cwd: None,
|
||||
actor_user: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
// Collateral observation: session lives in `doomed`, observation is
|
||||
// stamped into `other`.
|
||||
store
|
||||
.writer
|
||||
.insert_observation(Sanitized::new(
|
||||
NewObservation {
|
||||
session_id: sid,
|
||||
workspace_id: ws,
|
||||
project_id: other,
|
||||
kind: ObservationKind::UserPrompt,
|
||||
extension: None,
|
||||
source_event: None,
|
||||
title: "collateral".into(),
|
||||
body: "collateral body".into(),
|
||||
importance: 5,
|
||||
},
|
||||
&Sanitizer::builtin(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
// Collateral handoff: lives in `other`, authored by the `doomed` session.
|
||||
store
|
||||
.writer
|
||||
.insert_handoff(NewHandoff {
|
||||
workspace_id: ws,
|
||||
project_id: other,
|
||||
from_session_id: Some(sid),
|
||||
from_agent: AgentKind::ClaudeCode,
|
||||
to_agent: None,
|
||||
cwd: None,
|
||||
summary: "collateral handoff".into(),
|
||||
open_questions: vec![],
|
||||
next_steps: vec![],
|
||||
files_touched: vec![],
|
||||
owner_user: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let preview = post(
|
||||
state.clone(),
|
||||
"/admin/purge-project",
|
||||
json!({ "workspace": "default", "project": "doomed", "confirm": false, "dry_run": true }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(preview.status(), StatusCode::OK);
|
||||
let preview_body = body_json(preview).await;
|
||||
assert_eq!(preview_body["collateral_observations_deleted"], 1);
|
||||
assert_eq!(preview_body["collateral_handoffs_denulled"], 1);
|
||||
|
||||
let confirmed = post(
|
||||
state,
|
||||
"/admin/purge-project",
|
||||
json!({ "workspace": "default", "project": "doomed", "confirm": true }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(confirmed.status(), StatusCode::OK);
|
||||
let confirmed_body = body_json(confirmed).await;
|
||||
assert_eq!(confirmed_body["collateral_observations_deleted"], 1);
|
||||
assert_eq!(confirmed_body["collateral_handoffs_denulled"], 1);
|
||||
|
||||
// `other` survives as a project; only the collateral rows are affected.
|
||||
assert_eq!(
|
||||
store.reader.status_counts().await.unwrap().observations,
|
||||
0,
|
||||
"the collateral observation in `other` must actually be gone"
|
||||
);
|
||||
}
|
||||
|
||||
/// A live managed-run lease under the project must still refuse the preview
|
||||
/// with the same `409` a confirmed purge would, unless `force` overrides it
|
||||
/// — the preview promises to describe what a confirmed call would do, and a
|
||||
/// confirmed call would refuse here too.
|
||||
#[tokio::test]
|
||||
async fn purge_project_dry_run_conflicts_on_a_live_managed_run_without_force() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (state, store) = make_state(&tmp).await;
|
||||
let (workspace_id, _keep, project_id) = seed_two_projects(&store, &state.wiki).await;
|
||||
store
|
||||
.writer
|
||||
.prepare_workstream_run(PrepareWorkstreamRun {
|
||||
workspace_id,
|
||||
project_id,
|
||||
repo_fingerprint: "repo".into(),
|
||||
worktree_fingerprint: "worktree".into(),
|
||||
cwd: "/repo".into(),
|
||||
agent: AgentKind::Codex,
|
||||
automatic_harness: false,
|
||||
available_agents: vec![AgentKind::Codex],
|
||||
selection: WorkstreamSelection::Current,
|
||||
lease_owner: "test".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = post(
|
||||
state,
|
||||
"/admin/purge-project",
|
||||
json!({ "workspace": "default", "project": "doomed", "confirm": false, "dry_run": true }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::CONFLICT,
|
||||
"a live managed run must still 409 a preview without --force"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -878,6 +878,7 @@ mod tests {
|
||||
None,
|
||||
false,
|
||||
crate::Compaction::Skip,
|
||||
crate::PurgeMode::Commit,
|
||||
)
|
||||
.await
|
||||
.expect("a grant in force does not stand in a purge's way");
|
||||
|
||||
@@ -61,8 +61,8 @@ pub use ops::{
|
||||
EmbedOutcome, EmbeddingWrite, EntityBackfillSummary, HookSessionAdmission,
|
||||
IngestObservationOutcome, LifecycleOnlyEndOutcome, MAX_PENDING_INBOX_MESSAGES,
|
||||
MoveSessionSummary, MoveSummary, ObservationPruneOutcome, OkfMigratedPage,
|
||||
PAGE_WINDOW_BACKFILL_BATCH, PageWindowBackfillSummary, PagesMode, PurgeSessionSummary,
|
||||
PurgeSummary, ReorgSummary, backfill_entity_index, backfill_page_windows,
|
||||
PAGE_WINDOW_BACKFILL_BATCH, PageWindowBackfillSummary, PagesMode, PurgeMode,
|
||||
PurgeSessionSummary, PurgeSummary, ReorgSummary, backfill_entity_index, backfill_page_windows,
|
||||
backfill_page_windows_in_batches, purge_session, record_embed_failure,
|
||||
};
|
||||
pub use project_authz::{
|
||||
|
||||
@@ -139,6 +139,22 @@ pub struct PurgeSummary {
|
||||
pub handoffs_deleted: u64,
|
||||
/// Number of `page_embeddings` rows deleted (cascades through pages).
|
||||
pub embeddings_deleted: u64,
|
||||
/// `observations` rows belonging to a **different** project that are
|
||||
/// deleted anyway, collaterally, because `observations.session_id`
|
||||
/// carries `ON DELETE CASCADE` to `sessions` — a project's cascade does
|
||||
/// not check the observation's own `project_id`. This is the mirror of
|
||||
/// the incident this preview guards against: not "this scope holds more
|
||||
/// than it looks like", but "purging this scope also reaches into
|
||||
/// another one" (V01 `observations.session_id REFERENCES sessions(id)
|
||||
/// ON DELETE CASCADE`).
|
||||
pub collateral_observations_deleted: u64,
|
||||
/// `handoffs` rows belonging to a different project whose
|
||||
/// `from_session_id` or `accepted_by_session` is about to be set to
|
||||
/// `NULL` — not deleted, just orphaned from the session that authored or
|
||||
/// accepted them — because those columns are `ON DELETE SET NULL` to
|
||||
/// `sessions` (V02). The row and its text survive; only the link back to
|
||||
/// the purged project's session does not.
|
||||
pub collateral_handoffs_denulled: u64,
|
||||
/// Number of `workstreams` rows deleted. These cascade from `projects`,
|
||||
/// so a project that looks empty by page/session/observation count can
|
||||
/// still take a managed workstream — and its portable event ledger — down
|
||||
@@ -4151,6 +4167,22 @@ pub enum Compaction {
|
||||
Reclaim,
|
||||
}
|
||||
|
||||
/// What [`purge_project`] does once it has finished counting.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PurgeMode {
|
||||
/// Run the delete for real and commit it.
|
||||
Commit,
|
||||
/// Stop right after the counts and roll the (read-only, so far)
|
||||
/// transaction back. No `DELETE`, no cascade, no tombstone insert, no
|
||||
/// audit row — the whole point is to never pay for a real purge's cost
|
||||
/// just to undo it: on a project with a lot of rows, running and then
|
||||
/// rolling back the actual `DELETE FROM projects` cascade would hold the
|
||||
/// single writer actor (invariant #2) for as long as a real purge does,
|
||||
/// and every hook capture queued behind it pays for that with a 429 or a
|
||||
/// dropped observation. A preview only ever issues `SELECT`s.
|
||||
Preview,
|
||||
}
|
||||
|
||||
/// Every FTS5 index in the schema, which is what [`reclaim_freed_pages`]
|
||||
/// rebuilds.
|
||||
///
|
||||
@@ -4928,10 +4960,23 @@ pub fn clear_bootstrap_progress(conn: &Connection, fingerprint: &str) -> StoreRe
|
||||
/// cascades out of `projects`, so purging a scope whose lease is still live
|
||||
/// would delete the lease row out from under a running agent.
|
||||
///
|
||||
/// `mode = `[`PurgeMode::Preview`] runs every count above — including the two
|
||||
/// collateral ones — and then returns without ever issuing the `DELETE`, the
|
||||
/// tombstone insert, or the audit row: the transaction so far has only ever
|
||||
/// read, so rolling it back is free. This intentionally does *not* run the
|
||||
/// delete and roll it back (as [`move_session`]'s dry run does): on a large
|
||||
/// project that would hold the single writer actor (invariant #2) for as
|
||||
/// long as a real purge takes, and every hook capture queued behind it pays
|
||||
/// for that. The counts are what the confirmed call *would* delete, read the
|
||||
/// same way the confirmed call itself decides what to delete — not a
|
||||
/// separately-maintained estimate — but they are a snapshot, not a promise:
|
||||
/// a write between the preview and a later `--confirm` can change them.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`StoreError::ManagedRunActive`] when a managed run's lease is
|
||||
/// still live and `force` is false, or [`StoreError`] if any SQL statement
|
||||
/// fails. The transaction is rolled back automatically on error.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn purge_project(
|
||||
conn: &mut Connection,
|
||||
workspace_id: &WorkspaceId,
|
||||
@@ -4940,6 +4985,7 @@ pub fn purge_project(
|
||||
author_id: Option<ai_memory_core::UserId>,
|
||||
force: bool,
|
||||
compaction: Compaction,
|
||||
mode: PurgeMode,
|
||||
) -> StoreResult<PurgeSummary> {
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
@@ -5019,6 +5065,33 @@ pub fn purge_project(
|
||||
&pid[..],
|
||||
)?;
|
||||
|
||||
// Collateral damage in OTHER projects, via `sessions` cascading out of
|
||||
// this one. `observations.session_id` is `ON DELETE CASCADE` (V01) with
|
||||
// no regard for the observation's own `project_id`, so an observation
|
||||
// stamped into a sibling project — the same split the incident this
|
||||
// preview guards against turns on — is deleted right along with the
|
||||
// session that wrote it, even though nothing in that sibling project's
|
||||
// own row count says so.
|
||||
let collateral_observations_deleted = count(
|
||||
"SELECT COUNT(*) FROM observations \
|
||||
WHERE project_id != ?1 \
|
||||
AND session_id IN (SELECT id FROM sessions WHERE project_id = ?1)",
|
||||
&pid[..],
|
||||
)?;
|
||||
// `handoffs.from_session_id` / `accepted_by_session` are `ON DELETE SET
|
||||
// NULL` (V02): a handoff living in another project is not deleted, but
|
||||
// loses the link back to whichever of this project's sessions authored
|
||||
// or accepted it.
|
||||
let collateral_handoffs_denulled = count(
|
||||
"SELECT COUNT(*) FROM handoffs \
|
||||
WHERE project_id != ?1 \
|
||||
AND ( \
|
||||
from_session_id IN (SELECT id FROM sessions WHERE project_id = ?1) \
|
||||
OR accepted_by_session IN (SELECT id FROM sessions WHERE project_id = ?1) \
|
||||
)",
|
||||
&pid[..],
|
||||
)?;
|
||||
|
||||
// Collect all distinct on-disk paths for the caller to clean up.
|
||||
// We use DISTINCT because multiple versions of the same logical page
|
||||
// share a path; the file only exists once. The statement must be
|
||||
@@ -5046,6 +5119,29 @@ pub fn purge_project(
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
|
||||
if mode == PurgeMode::Preview {
|
||||
// Nothing was written — every statement above was a `SELECT` — so
|
||||
// rolling back here is immediate; it never had to pay for the
|
||||
// `DELETE FROM projects` cascade this function's `Commit` mode runs
|
||||
// below, which is the whole point (see `PurgeMode::Preview`'s doc).
|
||||
tx.rollback()?;
|
||||
return Ok(PurgeSummary {
|
||||
label: workspace_project_label.to_string(),
|
||||
page_paths,
|
||||
pages_deleted,
|
||||
sessions_deleted,
|
||||
observations_deleted,
|
||||
handoffs_deleted,
|
||||
embeddings_deleted,
|
||||
collateral_observations_deleted,
|
||||
collateral_handoffs_denulled,
|
||||
workstreams_deleted,
|
||||
managed_runs_deleted,
|
||||
workstream_ids,
|
||||
compacted: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Cascade handles pages / sessions / observations / handoffs /
|
||||
// page_embeddings. The workspace row is intentionally left intact —
|
||||
// other projects may still live there.
|
||||
@@ -5080,7 +5176,8 @@ pub fn purge_project(
|
||||
|
||||
tx.commit()?;
|
||||
|
||||
if compaction == Compaction::Reclaim {
|
||||
let compacted = compaction == Compaction::Reclaim;
|
||||
if compacted {
|
||||
reclaim_freed_pages(conn)?;
|
||||
}
|
||||
|
||||
@@ -5092,10 +5189,12 @@ pub fn purge_project(
|
||||
observations_deleted,
|
||||
handoffs_deleted,
|
||||
embeddings_deleted,
|
||||
collateral_observations_deleted,
|
||||
collateral_handoffs_denulled,
|
||||
workstreams_deleted,
|
||||
managed_runs_deleted,
|
||||
workstream_ids,
|
||||
compacted: compaction == Compaction::Reclaim,
|
||||
compacted,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6440,6 +6539,7 @@ pub(crate) mod tests {
|
||||
None,
|
||||
false,
|
||||
Compaction::Skip,
|
||||
PurgeMode::Commit,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -6482,6 +6582,7 @@ pub(crate) mod tests {
|
||||
None,
|
||||
false,
|
||||
Compaction::Skip,
|
||||
PurgeMode::Commit,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!summary.compacted, "the default does not VACUUM");
|
||||
@@ -6516,6 +6617,7 @@ pub(crate) mod tests {
|
||||
None,
|
||||
false,
|
||||
Compaction::Reclaim,
|
||||
PurgeMode::Commit,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(summary.compacted, "the summary reports that VACUUM ran");
|
||||
@@ -6532,6 +6634,320 @@ pub(crate) mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A preview's counts come from the same `SELECT`s the confirmed path
|
||||
/// uses to decide what to delete, not a separately-maintained estimate,
|
||||
/// so a preview and the confirmed run right after it must agree exactly
|
||||
/// (barring a write landing in between, which neither this nor a real
|
||||
/// `--confirm`-less-then-`--confirm` operator workflow can rule out).
|
||||
#[test]
|
||||
fn purge_project_dry_run_reports_the_same_counts_a_real_purge_would() {
|
||||
let (_tmp, mut conn, ws, proj) = fresh_db();
|
||||
seed_session(&mut conn, ws, proj, "canaryproj");
|
||||
|
||||
let preview = purge_project(
|
||||
&mut conn,
|
||||
&ws,
|
||||
&proj,
|
||||
"default/scratch",
|
||||
None,
|
||||
false,
|
||||
Compaction::Skip,
|
||||
PurgeMode::Preview,
|
||||
)
|
||||
.expect("a dry run must not error");
|
||||
assert_eq!(preview.pages_deleted, 1);
|
||||
assert_eq!(preview.sessions_deleted, 1);
|
||||
assert_eq!(preview.observations_deleted, 1);
|
||||
assert!(!preview.compacted, "a rolled-back run never reclaims bytes");
|
||||
|
||||
let real = purge_project(
|
||||
&mut conn,
|
||||
&ws,
|
||||
&proj,
|
||||
"default/scratch",
|
||||
None,
|
||||
false,
|
||||
Compaction::Skip,
|
||||
PurgeMode::Commit,
|
||||
)
|
||||
.expect("the confirmed purge must still succeed after the preview");
|
||||
assert_eq!(
|
||||
(
|
||||
real.pages_deleted,
|
||||
real.sessions_deleted,
|
||||
real.observations_deleted
|
||||
),
|
||||
(
|
||||
preview.pages_deleted,
|
||||
preview.sessions_deleted,
|
||||
preview.observations_deleted
|
||||
),
|
||||
"the dry run's counts must match what the confirmed run actually deletes"
|
||||
);
|
||||
}
|
||||
|
||||
/// The incident this feature guards against: an operator purged a
|
||||
/// project believing it held 0 sessions / 0 pages, and it actually held
|
||||
/// over a thousand observations whose `project_id` pointed at the purged
|
||||
/// project even though their `session_id` belonged to a session that
|
||||
/// lived in a *different* project (a pre-#871 Windows path-casing
|
||||
/// split). `purge_project` counts `observations` directly by
|
||||
/// `project_id`, not by joining through `sessions`, so this must still
|
||||
/// show up in a dry run's `observations_deleted` — the exact number a
|
||||
/// naive "count sessions, look empty" check would miss.
|
||||
#[test]
|
||||
fn purge_project_dry_run_counts_observations_whose_session_lives_in_another_project() {
|
||||
let (_tmp, mut conn, ws, proj) = fresh_db();
|
||||
let other = get_or_create_project(&mut conn, &ws, "elsewhere", None).unwrap();
|
||||
|
||||
// The session (and its own observation) live in `other`, not `proj`.
|
||||
let (sid, _page) = seed_session(&mut conn, ws, other, "elsewhere-owner");
|
||||
|
||||
// A second observation of that same session is stamped into `proj` —
|
||||
// the split this test pins.
|
||||
let stray = NewObservation {
|
||||
session_id: sid,
|
||||
workspace_id: ws,
|
||||
project_id: proj,
|
||||
kind: ObservationKind::UserPrompt,
|
||||
extension: None,
|
||||
source_event: None,
|
||||
title: "stray".into(),
|
||||
body: "obs-stray-in-doomed-project".into(),
|
||||
importance: 5,
|
||||
};
|
||||
insert_observation(&mut conn, &stray).unwrap();
|
||||
|
||||
let preview = purge_project(
|
||||
&mut conn,
|
||||
&ws,
|
||||
&proj,
|
||||
"default/scratch",
|
||||
None,
|
||||
false,
|
||||
Compaction::Skip,
|
||||
PurgeMode::Preview,
|
||||
)
|
||||
.expect("a dry run must not error");
|
||||
assert_eq!(
|
||||
preview.sessions_deleted, 0,
|
||||
"the session row itself lives in `other`, not the previewed project"
|
||||
);
|
||||
assert_eq!(
|
||||
preview.observations_deleted, 1,
|
||||
"the stray observation stamped into the previewed project must still be counted"
|
||||
);
|
||||
|
||||
// Nothing was actually touched: the session and its own observation
|
||||
// in `other` are both still there, dry run or not.
|
||||
let survived: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sessions WHERE id = ?1",
|
||||
rusqlite::params![&sid.as_bytes()[..]],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
survived, 1,
|
||||
"the session in the other project must survive the dry run"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bite check: every project-scoped row count, the purge tombstone, and
|
||||
/// the audit trail must all be identical before and after a
|
||||
/// [`PurgeMode::Preview`] run. If `Preview` ever fell through to the
|
||||
/// `Commit` path's `DELETE` / tombstone insert / audit insert, this is
|
||||
/// the test that would catch it.
|
||||
#[test]
|
||||
fn purge_project_dry_run_changes_nothing() {
|
||||
let (_tmp, mut conn, ws, proj) = fresh_db();
|
||||
let prepared = open_managed_run(&mut conn, &ws, &proj);
|
||||
seed_workstream_event(&conn, &prepared.workstream_id, "canaryevt");
|
||||
seed_session(&mut conn, ws, proj, "canaryproj");
|
||||
|
||||
let before = row_snapshot(&conn);
|
||||
|
||||
let preview = purge_project(
|
||||
&mut conn,
|
||||
&ws,
|
||||
&proj,
|
||||
"default/scratch",
|
||||
None,
|
||||
true, // force: a live managed run sits under this project
|
||||
Compaction::Skip,
|
||||
PurgeMode::Preview,
|
||||
)
|
||||
.expect("a dry run must not error even with a live managed run");
|
||||
assert!(preview.pages_deleted >= 1);
|
||||
assert!(preview.observations_deleted >= 1);
|
||||
assert_eq!(preview.workstreams_deleted, 1);
|
||||
assert_eq!(preview.managed_runs_deleted, 1);
|
||||
|
||||
let after = row_snapshot(&conn);
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"a dry run must leave every project-scoped table's row count unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
/// The mirror of the incident this whole feature guards against: instead
|
||||
/// of the purged project holding more than it looks like (rows counted
|
||||
/// in `observations_deleted`), purging it reaches OUT and takes rows
|
||||
/// from a project the operator never named. `observations.session_id`
|
||||
/// is `ON DELETE CASCADE` (V01) with no regard for the observation's own
|
||||
/// `project_id`, and `handoffs.from_session_id` /
|
||||
/// `accepted_by_session` are `ON DELETE SET NULL` (V02) — neither of
|
||||
/// which the plain `observations_deleted`/`handoffs_deleted` counts (by
|
||||
/// `project_id = P`) can see, because these rows belong to a different
|
||||
/// project.
|
||||
#[test]
|
||||
fn purge_project_counts_collateral_damage_in_another_project() {
|
||||
let (_tmp, mut conn, ws, proj) = fresh_db();
|
||||
let other = get_or_create_project(&mut conn, &ws, "other-project", None).unwrap();
|
||||
|
||||
// A session (and its own observation) rooted in the project being
|
||||
// purged.
|
||||
let (sid, _page) = seed_session(&mut conn, ws, proj, "owner");
|
||||
|
||||
// An observation in the OTHER project, stamped by the same session.
|
||||
let stray_observation = NewObservation {
|
||||
session_id: sid,
|
||||
workspace_id: ws,
|
||||
project_id: other,
|
||||
kind: ObservationKind::UserPrompt,
|
||||
extension: None,
|
||||
source_event: None,
|
||||
title: "stray".into(),
|
||||
body: "obs-in-other-project".into(),
|
||||
importance: 5,
|
||||
};
|
||||
insert_observation(&mut conn, &stray_observation).unwrap();
|
||||
|
||||
// A handoff in the OTHER project, authored by that same session.
|
||||
insert_handoff(
|
||||
&mut conn,
|
||||
&NewHandoff {
|
||||
workspace_id: ws,
|
||||
project_id: other,
|
||||
from_session_id: Some(sid),
|
||||
from_agent: ai_memory_core::AgentKind::ClaudeCode,
|
||||
to_agent: None,
|
||||
cwd: None,
|
||||
summary: "handoff in other project".into(),
|
||||
open_questions: vec![],
|
||||
next_steps: vec![],
|
||||
files_touched: vec![],
|
||||
owner_user: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let preview = purge_project(
|
||||
&mut conn,
|
||||
&ws,
|
||||
&proj,
|
||||
"default/scratch",
|
||||
None,
|
||||
false,
|
||||
Compaction::Skip,
|
||||
PurgeMode::Preview,
|
||||
)
|
||||
.expect("a preview must not error");
|
||||
assert_eq!(
|
||||
preview.collateral_observations_deleted, 1,
|
||||
"the observation in the other project must be counted as collateral"
|
||||
);
|
||||
assert_eq!(
|
||||
preview.collateral_handoffs_denulled, 1,
|
||||
"the handoff in the other project must be counted as collateral"
|
||||
);
|
||||
// The preview changed nothing: both rows are still exactly as seeded.
|
||||
let obs_in_other: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM observations WHERE project_id = ?1",
|
||||
rusqlite::params![&other.as_bytes()[..]],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(obs_in_other, 1);
|
||||
|
||||
let real = purge_project(
|
||||
&mut conn,
|
||||
&ws,
|
||||
&proj,
|
||||
"default/scratch",
|
||||
None,
|
||||
false,
|
||||
Compaction::Skip,
|
||||
PurgeMode::Commit,
|
||||
)
|
||||
.expect("the confirmed purge must succeed");
|
||||
assert_eq!(real.collateral_observations_deleted, 1);
|
||||
assert_eq!(real.collateral_handoffs_denulled, 1);
|
||||
|
||||
// The prediction must match what the cascade actually did: the
|
||||
// collateral observation is really gone from the other project (its
|
||||
// own project row was never touched)...
|
||||
let obs_in_other_after: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM observations WHERE project_id = ?1",
|
||||
rusqlite::params![&other.as_bytes()[..]],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
obs_in_other_after, 0,
|
||||
"the collaterally-cascaded observation must actually be gone"
|
||||
);
|
||||
// ...and the handoff row itself survives (it belongs to `other`,
|
||||
// which was never purged) but its session reference is nulled, not
|
||||
// the row.
|
||||
let (handoffs_in_other, from_session_id): (i64, Option<Vec<u8>>) = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*), MAX(from_session_id) FROM handoffs WHERE project_id = ?1",
|
||||
rusqlite::params![&other.as_bytes()[..]],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
handoffs_in_other, 1,
|
||||
"the handoff row in the other project must survive"
|
||||
);
|
||||
assert!(
|
||||
from_session_id.is_none(),
|
||||
"the handoff's from_session_id must be nulled, not the row deleted"
|
||||
);
|
||||
}
|
||||
|
||||
/// Row counts of every table a real purge touches, used to prove a dry
|
||||
/// run changed nothing. `purged_scopes` and `audit_log` are included
|
||||
/// deliberately: both are written inside the same transaction as the
|
||||
/// delete, so a rollback must take them back out too, not just the
|
||||
/// cascade.
|
||||
fn row_snapshot(conn: &Connection) -> Vec<(&'static str, i64)> {
|
||||
[
|
||||
"pages",
|
||||
"sessions",
|
||||
"observations",
|
||||
"handoffs",
|
||||
"page_embeddings",
|
||||
"workstreams",
|
||||
"managed_runs",
|
||||
"workstream_events",
|
||||
"projects",
|
||||
"purged_scopes",
|
||||
"audit_log",
|
||||
]
|
||||
.iter()
|
||||
.map(|table| {
|
||||
(
|
||||
*table,
|
||||
count(conn, &format!("SELECT COUNT(*) FROM {table}")),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The reason `reclaim_freed_pages` rebuilds all three FTS indexes rather
|
||||
/// than the two a session purge needs.
|
||||
///
|
||||
@@ -6569,6 +6985,7 @@ pub(crate) mod tests {
|
||||
None,
|
||||
true,
|
||||
Compaction::Reclaim,
|
||||
PurgeMode::Commit,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -11418,6 +11835,7 @@ pub(crate) mod tests {
|
||||
None,
|
||||
false,
|
||||
Compaction::Skip,
|
||||
PurgeMode::Commit,
|
||||
)
|
||||
.expect("purge of fresh project should succeed");
|
||||
// Now try to rename the project that no longer exists. The
|
||||
@@ -11483,6 +11901,7 @@ pub(crate) mod tests {
|
||||
Some(author),
|
||||
false,
|
||||
Compaction::Skip,
|
||||
PurgeMode::Commit,
|
||||
)
|
||||
.expect("purge should succeed");
|
||||
|
||||
@@ -11538,6 +11957,7 @@ pub(crate) mod tests {
|
||||
None,
|
||||
false,
|
||||
Compaction::Skip,
|
||||
PurgeMode::Commit,
|
||||
)
|
||||
.expect_err("an active managed run must block the purge");
|
||||
|
||||
@@ -11579,6 +11999,7 @@ pub(crate) mod tests {
|
||||
None,
|
||||
true,
|
||||
Compaction::Skip,
|
||||
PurgeMode::Commit,
|
||||
)
|
||||
.expect("force purges regardless of the live lease");
|
||||
|
||||
@@ -11615,6 +12036,7 @@ pub(crate) mod tests {
|
||||
None,
|
||||
false,
|
||||
Compaction::Skip,
|
||||
PurgeMode::Commit,
|
||||
)
|
||||
.expect("a lapsed lease is not a running agent");
|
||||
|
||||
|
||||
@@ -394,6 +394,13 @@ pub(crate) enum WriteCmd {
|
||||
force: bool,
|
||||
/// Whether to reclaim the freed bytes afterwards (`VACUUM`).
|
||||
compaction: crate::ops::Compaction,
|
||||
/// [`crate::ops::PurgeMode::Preview`] stops right after counting and
|
||||
/// never issues the delete — unlike [`WriteCmd::MoveSession`]'s dry
|
||||
/// run, which runs the real write and rolls it back. See
|
||||
/// [`crate::ops::PurgeMode`]'s doc for why: a rolled-back delete on a
|
||||
/// large project would still hold the writer actor for as long as a
|
||||
/// real purge does.
|
||||
mode: crate::ops::PurgeMode,
|
||||
reply: oneshot::Sender<StoreResult<PurgeSummary>>,
|
||||
},
|
||||
/// Delete one session and everything derived from it, inside a single
|
||||
@@ -1844,9 +1851,15 @@ impl WriterHandle {
|
||||
/// returned [`PurgeSummary`] includes pre-delete row counts and
|
||||
/// the distinct page paths that the caller must remove from disk.
|
||||
///
|
||||
/// `mode = `[`ops::PurgeMode::Preview`] stops right after counting and
|
||||
/// never issues the delete — see [`ops::purge_project`] for why, and for
|
||||
/// the two collateral counts (`collateral_observations_deleted`,
|
||||
/// `collateral_handoffs_denulled`) either mode reports.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`StoreError::WriterClosed`] if the actor has shut down, or
|
||||
/// propagates the SQL error from the purge transaction.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn purge_project(
|
||||
&self,
|
||||
workspace_id: WorkspaceId,
|
||||
@@ -1855,6 +1868,7 @@ impl WriterHandle {
|
||||
author_id: Option<ai_memory_core::UserId>,
|
||||
force: bool,
|
||||
compaction: crate::ops::Compaction,
|
||||
mode: crate::ops::PurgeMode,
|
||||
) -> StoreResult<PurgeSummary> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.send(WriteCmd::PurgeProject {
|
||||
@@ -1864,6 +1878,7 @@ impl WriterHandle {
|
||||
author_id,
|
||||
force,
|
||||
compaction,
|
||||
mode,
|
||||
reply: tx,
|
||||
})
|
||||
.await?;
|
||||
@@ -3523,6 +3538,7 @@ fn worker_loop(mut conn: Connection, mut rx: mpsc::Receiver<WriteCmd>) {
|
||||
author_id,
|
||||
force,
|
||||
compaction,
|
||||
mode,
|
||||
reply,
|
||||
} => {
|
||||
let result = ops::purge_project(
|
||||
@@ -3533,6 +3549,7 @@ fn worker_loop(mut conn: Connection, mut rx: mpsc::Receiver<WriteCmd>) {
|
||||
author_id,
|
||||
force,
|
||||
compaction,
|
||||
mode,
|
||||
);
|
||||
send_or_warn(reply, result, "purge_project");
|
||||
}
|
||||
|
||||
@@ -4890,6 +4890,7 @@ mod tests {
|
||||
None,
|
||||
false,
|
||||
ai_memory_store::Compaction::Skip,
|
||||
ai_memory_store::PurgeMode::Commit,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
+47
-1
@@ -8,7 +8,7 @@ on a homelab box where mistakes are harder to undo.
|
||||
|
||||
| Command | Safe with server **running**? | Wipes data? | Reversible? | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `purge-project --confirm` | ✅ yes | the one project's data | no | Deletes the UUID-namespaced wiki root and raw workstream segments; sibling projects remain untouched. Refuses with `409` while a managed workstream under the project holds a live run lease — `--force` overrides. Logical delete by default; `--compact` additionally rebuilds the FTS indexes and `VACUUM`s (see below). |
|
||||
| `purge-project --confirm` | ✅ yes | the one project's data, **plus** any observation stamped into a different project by one of this project's sessions (cascades regardless of the observation's own `project_id`), and it nulls (does not delete) the session reference on any handoff in a different project that this project's sessions authored or accepted | no | Deletes the UUID-namespaced wiki root and raw workstream segments. Refuses with `409` while a managed workstream under the project holds a live run lease — `--force` overrides. Logical delete by default; `--compact` additionally rebuilds the FTS indexes and `VACUUM`s (see below). Without `--confirm` it previews the same counts, including the cross-project ones, before refusing — see below. |
|
||||
| `purge-session --session-id --confirm` | ✅ yes | the one session's data | no | Deletes one session by UUID: its row, its observations, the handoffs it **authored**, its `sessions/<id>.md` page and every superseded version, their embeddings, and its auto-improve runs. Strictly scoped — a session that does not belong to the named workspace/project is a `404` and nothing is deleted. Handoffs the session only *accepted* are kept: that text belongs to the session that wrote it. Logical delete by default; `--compact` additionally rebuilds the FTS indexes and `VACUUM`s (see below). |
|
||||
| `handoffs --expire-all --confirm` | ✅ yes | no (state change only) | no (but nothing is destroyed) | Marks every **open** handoff in the scope `expired` so it stops being offered to an agent. Rows, summaries and provenance are kept and stay visible in the audit log. Unlike the automatic sweep it does **not** spare manual handoffs or ones from another directory — those exemptions are exactly what a leftover backlog is made of, so honouring them would clear nothing. `--older-than-days N` keeps recent batons. Owner-scoped: never touches another user's baton. |
|
||||
| `rename-project --from --to` | ✅ yes | no | yes (rename back) | Column-only update on `projects.name`. The on-disk dir is keyed by `project_id` (UUID), so the rename never moves a file. |
|
||||
@@ -209,6 +209,52 @@ raw segment directory is removed on the server and appears in
|
||||
`files_deleted`; a failed removal appears in `files_failed` alongside wiki
|
||||
cleanup failures.
|
||||
|
||||
#### Preview without `--confirm`
|
||||
|
||||
Without `--confirm`, the CLI first asks the server for a preview
|
||||
(`"dry_run": true` in the request). `dry_run` always wins over `confirm` —
|
||||
`{"confirm": true, "dry_run": true}` still only previews, the same way
|
||||
`reclaim-ledger-versions` treats its own `dry_run` field — so a preview
|
||||
request can never become destructive by accident.
|
||||
|
||||
The preview runs the same lookups and counts steps 1-3 above use to decide
|
||||
what a confirmed purge would delete — same 404 on an unknown scope, same
|
||||
`409` on a live managed-run lease without `--force` — and returns those
|
||||
counts, including the two cross-project ones (an observation deleted, or a
|
||||
handoff's session reference nulled, in a project other than the one named;
|
||||
see the matrix row above), without ever issuing the `DELETE` in step 4. It
|
||||
does not run the delete and roll it back: on a large project that would cost
|
||||
as much writer-actor time as a real purge (every hook capture queued behind
|
||||
it pays for that), for no benefit over just counting. Because step 4 never
|
||||
runs, step 5's filesystem cleanup never runs either (`files_deleted` /
|
||||
`files_failed` are always empty), and neither the `purged_scopes` tombstone
|
||||
nor the `audit_log` row from step 4's transaction is written; neither
|
||||
checkpoint is taken. The reply carries `"dry_run": true`. The CLI prints:
|
||||
|
||||
```
|
||||
Would purge default/my-project: 3 pages, 1 sessions, 1063 observations, 0 handoffs, 3 embeddings, 0 workstreams, 0 managed runs.
|
||||
```
|
||||
|
||||
(with a trailing "Plus N observations in other projects via their sessions"
|
||||
/ "Plus N handoffs ..." clause when either cross-project count is non-zero),
|
||||
then still refuses with the existing "destructive and irreversible" message
|
||||
and a non-zero exit — the preview is information layered on top of the
|
||||
refusal, never a substitute for `--confirm`.
|
||||
|
||||
A preview also skips the blocking admission call a confirmed purge makes
|
||||
before deleting anything (`admit_purge_project`): nothing was decided yet,
|
||||
so there is nothing for a `Reject`-policy or scope-guard webhook to act on.
|
||||
This means a `200` preview is not a guarantee — that same webhook only runs
|
||||
on the confirmed path and can still refuse the real purge afterward.
|
||||
|
||||
If the server is unreachable, times out (a few seconds, auth-token refresh
|
||||
included), or predates this field (a plain `400`), the CLI falls back
|
||||
silently to the plain refusal with no preview line, so no existing script's
|
||||
exit code or error shape changes — only a confirmed purge is ever
|
||||
destructive. A `404`/`409`/`403` (or any other unexpected status) prints the
|
||||
server's own error before the refusal instead, since the operator asked what
|
||||
would happen and the server has a real answer.
|
||||
|
||||
Failure modes:
|
||||
|
||||
- **Workspace or project name not found** → 404, no mutation.
|
||||
|
||||
@@ -41,7 +41,7 @@ boundary not yet built.
|
||||
| 8c | Messaging: pop-exactly-once | `ai-memory-store/src/ops.rs` `pop_message_in_transaction` atomic CAS `WHERE state='pending'` | `agent_messages.rs` — sequential **and** `tokio::join!` concurrent double-pop yields exactly one `Some` | STRONG |
|
||||
| 8d | Messaging: inferred-scope read is diagnosed (#854) | `scope.rs` `is_inferred`; `server.rs` `inferred_scope_hint` on empty pop/list | `agent_messages_briefing.rs` `no_scope_pop_that_misses_the_mail_is_diagnosed_not_a_silent_null` | STRONG |
|
||||
| 9 | Scope resolution fail-closed | `ai-memory-store/src/scope.rs` no-create `lookup_existing_*`; create only via `create_explicit_scope` | `scope.rs` no-auto-create + `unscoped_write_with_unresolvable_coordinate_errors` | STRONG |
|
||||
| 10 | Destructive-op live-process refusal + confirm flags (invariant #9) | `ai-memory-cli/src/commands/process_guard.rs` `sibling_processes` + confirm flags in `reset`/`restore`/`reindex`/`uninstall --purge-data`/`purge_project` | `admin_purge.rs` confirm→400; `removal.rs` — injected live-sibling makes each destructive command bail before touching the data dir | STRONG |
|
||||
| 10 | Destructive-op live-process refusal + confirm flags (invariant #9); `purge-project`'s `dry_run` always wins over `confirm` so a preview request can never become destructive | `ai-memory-cli/src/commands/process_guard.rs` `sibling_processes` + confirm flags in `reset`/`restore`/`reindex`/`uninstall --purge-data`/`purge_project`; `admin.rs` `handle_purge_project` checks `req.dry_run` unconditionally, before `req.confirm`, mirroring `reclaim-ledger-versions` | `admin_purge.rs` confirm→400; `removal.rs` — injected live-sibling makes each destructive command bail before touching the data dir; `admin_purge.rs` `purge_project_confirm_true_and_dry_run_true_still_only_previews` — `{"confirm": true, "dry_run": true}` deletes nothing (the regression this row now also covers); `ops.rs` `purge_project_dry_run_changes_nothing` — every project-scoped row count, the `purged_scopes` tombstone, and the `audit_log` row are identical before/after a `PurgeMode::Preview` run; `ops.rs` `purge_project_counts_collateral_damage_in_another_project` and `admin_purge.rs` `purge_project_dry_run_and_confirmed_purge_both_report_collateral_damage_in_another_project` — a purge of project P collaterally deletes an observation, and orphans a handoff's session reference, in a *different* project Q (via `sessions` cascading out of P), and both the preview and the confirmed report count it | STRONG |
|
||||
| 10b | Session purge is scope+owner-bound (no cross-session/project over-delete) | `ai-memory-store/src/ops.rs` `purge_session` — selection scoped to `(workspace_id, project_id)` and keyed on this session's own `summary_page_id` **or** `path='sessions/<sid>.md'` + `json_extract(frontmatter_json,'$.session_id')=<sid>` (frontmatter owner, not the recursive latest-chain); `in_scope==0 → NotFound` fail-closed; whole op in one transaction | `ops.rs` `purge_session_leaves_a_sibling_session_in_the_same_project_intact`, `…refuses_a_session_from_another_project_and_deletes_nothing`, `…refuses_a_session_from_another_workspace`, `…does_not_delete_an_identically_pathed_page_in_another_project`, `…removes_older_summary_versions_without_deleting_prior_manual_page` (#862) | STRONG |
|
||||
| 10c | Ledger reclaim is content-gated, not filename-gated (invariant #16) | `ai-memory-store/src/ops.rs` `reclaim_ledger_versions` — a candidate row must pass `ai_memory_core::log_ledger::body_opens_with_log_ledger` on its own `body`, and only `is_latest=0 AND superseded_at IS NULL` rows are eligible, so decay-owned rows stay with `forget-sweep`; confirm gate in `admin.rs` `handle_reclaim_ledger_versions` | `reclaim_ledger_versions.rs` `a_prose_page_wearing_the_ledger_name_keeps_its_whole_version_chain`, `an_ordinary_page_chain_in_another_project_is_untouched`, `a_decay_tombstone_stays_with_the_sweep_that_owns_it`; `admin_reclaim_ledger_versions.rs` `deleting_without_confirm_is_refused_and_changes_nothing` (with dry-run and confirmed controls) | STRONG |
|
||||
| 11a | Hook backpressure (202/429) + bounded fan-out (invariant #5) | `ai-memory-hooks/src/router.rs` semaphore→429, 202 immediately, `MAX_HOOK_BATCH_ITEMS`, bounded LRU limiter | `router.rs` `handle_hook_returns_429_when_ingest_saturated`, `ingest_rate_limiter_is_bounded` | STRONG |
|
||||
|
||||
Reference in New Issue
Block a user