Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/tracedecay-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -658,8 +658,11 @@ pub enum Commands {
#[command(long_about = WIPE_LONG_ABOUT, after_help = WIPE_AFTER_HELP)]
Wipe {
/// Wipe ALL profile database state while preserving identity and config
#[arg(short, long)]
#[arg(short, long, conflicts_with = "stale")]
all: bool,
/// Delete only the stores the running daemon reports as requiring reset
#[arg(long)]
stale: bool,
},
/// List tracedecay projects (current folder, parents, and children)
#[command(long_about = LIST_LONG_ABOUT, after_help = LIST_AFTER_HELP)]
Expand Down
11 changes: 8 additions & 3 deletions crates/tracedecay-cli/src/cli/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,8 @@ Exit status (a completed binary upgrade stays installed in every case):
Code's `/plugins install`); act on the printed step, then rerun. Also
75 when the restored daemon serves a store whose persisted shape this
binary does not open: it names the store and the exact reset command
(`tracedecay wipe --all --yes`), which nothing runs on your behalf
(`tracedecay wipe --stale --yes` for session stores), which nothing
runs on your behalf

Related: tracedecay upgrade (refresh only after a real install),
tracedecay update-plugin (plugins only), tracedecay channel.";
Expand Down Expand Up @@ -653,8 +654,11 @@ pub(crate) const WIPE_LONG_ABOUT: &str = "\
Deletes .tracedecay stores (code graph, memory, sessions) for the current \
folder, its parents, and its children. With --all, deletes the complete \
profile-scoped database state, including global, user memory/session, project, \
legacy, remote, Grafeo WAL, and host-admission stores. Profile identity, \
configuration, and agent integration config remain untouched. \
legacy, remote, Grafeo WAL, and host-admission stores. With --stale, deletes \
exactly the stores the running daemon reports as requiring reset (a persisted \
shape this binary does not open) and nothing else; the daemon recreates each \
one empty. Profile identity, configuration, and agent integration config \
remain untouched. \
Destructive and unrecoverable; re-create indexes with `tracedecay init`. \
Prompts for a `go!` confirmation unless `--yes` is passed. When the managed \
daemon holds the profile (even wedged or hung), wipe stops the installed \
Expand All @@ -666,6 +670,7 @@ Examples:
tracedecay wipe Wipe stores around the cwd
tracedecay wipe --all Wipe all profile database state
tracedecay wipe --all --yes Confirm without the prompt
tracedecay wipe --stale --yes Reset only the stores the daemon refuses

Related: tracedecay list (inspect nearby project stores), tracedecay init
(re-index afterwards), tracedecay uninstall (remove agent config instead).";
Expand Down
2 changes: 2 additions & 0 deletions crates/tracedecay-cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod index;
mod profile_storage;
mod scope;
mod settings;
mod stale_store_reset;
mod storage;

pub(crate) use admin_project::{
Expand All @@ -26,6 +27,7 @@ pub(crate) use settings::{
handle_gitignore, handle_upload_counter, mutate_project_configuration,
project_configuration_set, report_configuration_receipt,
};
pub(crate) use stale_store_reset::handle_wipe_stale;
pub(crate) use storage::{
ProfileOfflineAuthority, annotate_reset_required, handle_list, handle_wipe,
join_outcome_and_restore, process_error_text, take_profile_offline, try_admit_profile_registry,
Expand Down
252 changes: 252 additions & 0 deletions crates/tracedecay-cli/src/commands/stale_store_reset.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
use std::path::{Path, PathBuf};
use std::time::Duration;

use tracedecay_domain::errors::{ResettableStoreV1, Result, StoreResetRequiredV1, TraceDecayError};
use tracedecay_global_db::profile_registry_maintenance::verify_store_path_absent;
use tracedecay_runtime_core::config::ProfileRoot;
use tracedecay_runtime_core::storage::{
SESSIONS_DB_FILENAME, profile_sharded_data_root, validate_project_id,
};
use tracedecay_sessions::runtime::USER_SESSIONS_DB_FILENAME;

use super::storage::{
ProfileOfflineAuthority, join_outcome_and_restore, remove_fixed_profile_path,
take_profile_offline,
};

const OPERATION: &str = "wipe --stale";

/// How long the reset waits for the operator of an unmanaged daemon to stop
/// it. ponytail: a fixed interactive bound; a daemon shutdown request would
/// let the reset stop an unmanaged daemon itself.
const UNMANAGED_DAEMON_STOP_TIMEOUT: Duration = Duration::from_secs(300);

/// Deletes exactly the stores the running daemon holds in their typed
/// reset-required state, nothing else. The daemon is the authority that
/// refused them, so the list is read from it before the profile is taken
/// offline; each deleted store is recreated empty on its next open.
#[hotpath::measure(label = "cli.wipe.stale", future = true)]
pub(crate) async fn handle_wipe_stale(profile: &ProfileRoot, assume_yes: bool) -> Result<()> {
if !assume_yes {
return Err(TraceDecayError::Config {
message: "wipe --stale deletes every store the daemon reports as requiring reset; \
pass --yes to confirm. Nothing was wiped."
.to_owned(),
});
}
if !tracedecay_daemon_control::daemon_reachable(profile) {
return Err(TraceDecayError::Config {
message: "wipe --stale reads the stores that require reset from the running daemon; \
start it (`tracedecay daemon start`) and re-run. Nothing was wiped."
.to_owned(),
});
}
let stores = tracedecay_daemon_control::daemon_reset_required_stores(
profile,
crate::product_runtime::PRODUCT_BUILD_VERSION,
)?;
if stores.is_empty() {
eprintln!("No store requires reset. Nothing was wiped.");
return Ok(());
}
let targets = resettable_targets(&stores)?;
let profile_root = profile.data_dir().to_path_buf();
let profile_offline = take_stale_reset_offline(profile, &profile_root)?;
let outcome = reset_stores(&profile_root, &targets);
let restore = profile_offline.finish();
join_outcome_and_restore(OPERATION, outcome, restore)
}

/// The managed daemon is quiesced and restored afterwards. An unmanaged daemon
/// (`tracedecay daemon run`) has no service to stop and its census ends with
/// it, so the census is read first and the reset waits for its operator to
/// stop it.
fn take_stale_reset_offline(
profile: &ProfileRoot,
profile_root: &Path,
) -> Result<ProfileOfflineAuthority> {
if tracedecay_daemon_control::installed_service_state(profile)?
!= tracedecay_daemon_control::DaemonServiceState::Missing
{
return take_profile_offline(profile, profile_root, OPERATION);
}
eprintln!(
"An unmanaged TraceDecay daemon holds the profile; stop it and wipe --stale continues \
(waiting up to {}s).",
UNMANAGED_DAEMON_STOP_TIMEOUT.as_secs()
);
let lease = tracedecay_runtime_core::lifecycle_lease::acquire_exclusive_with_timeout(
profile_root,
OPERATION,
UNMANAGED_DAEMON_STOP_TIMEOUT,
)?;
Ok(ProfileOfflineAuthority::Lease(lease))
}

/// Every reported store as one this command resets on its own, or the
/// refusal naming the reset a store needs instead. Nothing is deleted unless
/// all of them are resettable.
fn resettable_targets(stores: &[StoreResetRequiredV1]) -> Result<Vec<ResettableStoreV1>> {
stores
.iter()
.map(|store| {
ResettableStoreV1::from_label(&store.store).ok_or_else(|| TraceDecayError::Config {
message: format!(
"{} requires reset ({}) and is not reset on its own; run `{}`. Nothing was \
wiped.",
store.store, store.reason, store.remedy
),
})
})
.collect()
}

fn reset_stores(profile_root: &Path, targets: &[ResettableStoreV1]) -> Result<()> {
for target in targets {
let (directory, database) = store_location(profile_root, target)?;
let removed = remove_store_family(&directory, database)?;
println!(
"reset {} ({removed} entries removed from {}); the daemon recreates it empty",
target.label(),
directory.display()
);
}
Ok(())
}

fn store_location(
profile_root: &Path,
target: &ResettableStoreV1,
) -> Result<(PathBuf, &'static str)> {
match target {
ResettableStoreV1::ProfileSessions => {
Ok((profile_root.to_path_buf(), USER_SESSIONS_DB_FILENAME))
}
ResettableStoreV1::ProjectSessions { project_id } => {
validate_project_id(project_id).map_err(|message| TraceDecayError::Config {
message: format!("daemon reported an invalid project id `{project_id}`: {message}"),
})?;
Ok((
profile_sharded_data_root(profile_root, project_id),
SESSIONS_DB_FILENAME,
))
}
}
}

/// Removes `database` and every entry named after it in `directory`: its
/// SQLite sidecars and spools (`<name>.db…`), its host-admission directory
/// (`.<name>.db…`), and its session relation graph (`<name>.grafeo…`).
fn remove_store_family(directory: &Path, database: &str) -> Result<usize> {
let stem = database.strip_suffix(".db").unwrap_or(database);
let owned_prefix = format!("{stem}.");
let hidden_prefix = format!(".{database}.");
let entries = std::fs::read_dir(directory).map_err(|error| TraceDecayError::Config {
message: format!("failed to list '{}': {error}", directory.display()),
})?;
let mut members = Vec::new();
for entry in entries {
let entry = entry.map_err(|error| TraceDecayError::Config {
message: format!("failed to list '{}': {error}", directory.display()),
})?;
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with(&owned_prefix) || name.starts_with(&hidden_prefix) {
members.push(name);
}
}
let mut removed = 0;
for name in &members {
removed += usize::from(remove_fixed_profile_path(directory, name)?);
verify_store_path_absent(&directory.join(name))?;
}
Ok(removed)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn store_family_removal_keeps_every_other_store_in_the_directory() {
let directory = tempfile::TempDir::new().expect("store directory");
let root = directory.path();
for file in [
"sessions.db",
"sessions.db-wal",
"sessions.db-shm",
"sessions.grafeo",
"tracedecay.db",
"tracedecay.db-wal",
"tracedecay.grafeo",
"store_manifest.json",
"user-sessions.db",
] {
std::fs::write(root.join(file), file).expect("write store file");
}
for dir in [
".sessions.db.host-admission",
"sessions.db.delivery-settlement-spool-v1",
"code-index-v1",
] {
std::fs::create_dir(root.join(dir)).expect("create store directory");
std::fs::write(root.join(dir).join("entry"), dir).expect("write store entry");
}

assert_eq!(remove_store_family(root, "sessions.db").unwrap(), 6);

let mut kept: Vec<String> = std::fs::read_dir(root)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
kept.sort();
assert_eq!(
kept,
[
"code-index-v1",
"store_manifest.json",
"tracedecay.db",
"tracedecay.db-wal",
"tracedecay.grafeo",
"user-sessions.db",
]
);
}

#[test]
fn a_store_that_is_not_resettable_on_its_own_refuses_the_whole_reset() {
let store = |label: &str, remedy: &str| StoreResetRequiredV1 {
store: label.to_owned(),
authority: "observations".to_owned(),
found_version: None,
required_version: None,
reason: "rows predate the unified identity".to_owned(),
remedy: remedy.to_owned(),
};
assert_eq!(
resettable_targets(&[
store("profile sessions", "tracedecay wipe --stale --yes"),
store(
"project sessions proj_a5b3d7e3ebe14ca7",
"tracedecay wipe --stale --yes"
),
])
.unwrap(),
[
ResettableStoreV1::ProfileSessions,
ResettableStoreV1::ProjectSessions {
project_id: "proj_a5b3d7e3ebe14ca7".to_owned()
},
]
);
assert_eq!(
resettable_targets(&[
store("profile sessions", "tracedecay wipe --stale --yes"),
store("profile authority", "tracedecay wipe --all --yes"),
])
.unwrap_err()
.to_string(),
"config error: profile authority requires reset (rows predate the unified identity) \
and is not reset on its own; run `tracedecay wipe --all --yes`. Nothing was wiped."
);
}
}
4 changes: 2 additions & 2 deletions crates/tracedecay-cli/src/commands/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ fn validate_complete_wipe_profile_root(
Ok(())
}

fn remove_fixed_profile_path(
pub(super) fn remove_fixed_profile_path(
profile_root: &Path,
name: &str,
) -> tracedecay_domain::errors::Result<bool> {
Expand Down Expand Up @@ -330,7 +330,7 @@ mod wipe_safety_tests {
assert!(profile.contains("session temporal persisted shape requires reset"));
assert!(profile.contains("refused authority: session temporal"));
assert!(
profile.contains("\n tracedecay wipe --all --yes"),
profile.contains("\n tracedecay wipe --stale --yes"),
"{profile}"
);

Expand Down
5 changes: 4 additions & 1 deletion crates/tracedecay-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1309,7 +1309,10 @@ async fn dispatch_project_command(
Commands::Storage { action } => {
commands::handle_profile_storage_action(profile, action, assume_yes).await?;
}
Commands::Wipe { all } => {
Commands::Wipe { stale: true, .. } => {
commands::handle_wipe_stale(profile, assume_yes).await?;
}
Commands::Wipe { all, stale: false } => {
commands::handle_wipe(profile, all, assume_yes).await?;
}
Commands::List { all } => {
Expand Down
7 changes: 5 additions & 2 deletions crates/tracedecay-cli/src/update_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -840,7 +840,10 @@ mod tests {
found_version: Some(5),
required_version: 6,
}
.store_reset_required("profile sessions")
.store_reset_required(
"profile sessions",
tracedecay_domain::errors::STALE_STORE_RESET_COMMAND,
)
.expect("a versioned profile refusal is a reset-required store")
}

Expand All @@ -866,7 +869,7 @@ mod tests {
pending_reset_line(&reset),
" \x1b[33mpending operator action:\x1b[0m profile sessions requires reset \
(git correlation profile schema 5 is incompatible with required schema 6; reset \
the profile); run `tracedecay wipe --all --yes`"
the profile); run `tracedecay wipe --stale --yes`"
);
let failed = update_completion(Some(PluginRefreshOutcome::Failed), &[reset])
.expect_err("a failed refresh still fails `update`")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ fn restore_check_accepts_a_reset_required_daemon_and_returns_its_pending_reset()
"found_version": 5,
"required_version": 6,
"reason": "git correlation profile schema 5 is incompatible with required schema 6; reset the profile",
"remedy": "tracedecay wipe --all --yes",
"remedy": "tracedecay wipe --stale --yes",
}],
},
}),
Expand Down Expand Up @@ -317,7 +317,7 @@ fn restore_check_accepts_a_reset_required_daemon_and_returns_its_pending_reset()
reason: "git correlation profile schema 5 is incompatible with required schema 6; \
reset the profile"
.to_owned(),
remedy: "tracedecay wipe --all --yes".to_owned(),
remedy: "tracedecay wipe --stale --yes".to_owned(),
}],
}
);
Expand Down
6 changes: 5 additions & 1 deletion crates/tracedecay-daemon-service/src/invocation/retained.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ pub(super) fn missing_retained_runtime_problem(
) -> DaemonInvocationResponse {
if matches!(
publication,
Some(ProjectRuntimePublicationStateV1::Warming | ProjectRuntimePublicationStateV1::Failed)
Some(
ProjectRuntimePublicationStateV1::Warming
| ProjectRuntimePublicationStateV1::Failed
| ProjectRuntimePublicationStateV1::ResetRequired(_)
)
) {
return missing_registered_owner_problem(publication, request_id);
}
Expand Down
Loading
Loading