diff --git a/README.md b/README.md index e99834f..4e35e63 100644 --- a/README.md +++ b/README.md @@ -256,12 +256,20 @@ rc admin info cluster local rc admin info server local rc admin info disk local --offline -# Heal operations +# Aggregate background heal status rc admin heal status local + +# Root recursive manual heal +rc admin heal start local --scan-mode deep +rc admin heal status local --client-token +rc admin heal stop local --client-token + +# Bucket manual heal rc admin heal start local --bucket mybucket --scan-mode deep rc admin heal status local --bucket mybucket --client-token -rc admin heal start local --dry-run rc admin heal stop local --bucket mybucket --client-token + +# Global force stop rc admin heal stop local # Pool expansion and decommission workflows diff --git a/crates/cli/src/commands/admin/heal.rs b/crates/cli/src/commands/admin/heal.rs index 6126e7f..a69da4b 100644 --- a/crates/cli/src/commands/admin/heal.rs +++ b/crates/cli/src/commands/admin/heal.rs @@ -491,8 +491,8 @@ fn heal_task_request( match (has_target, has_token) { (false, false) => Ok(None), - (true, true) => Ok(Some(HealTaskRequest { - bucket: bucket.expect("bucket is present"), + (_, true) => Ok(Some(HealTaskRequest { + bucket: bucket.unwrap_or_default(), prefix, client_token: client_token.expect("client token is present"), })), @@ -500,10 +500,6 @@ fn heal_task_request( formatter.error("Heal task request requires --client-token when --bucket is set."); Err(ExitCode::UsageError) } - (false, true) => { - formatter.error("Heal task request requires --bucket when --client-token is set."); - Err(ExitCode::UsageError) - } } } @@ -611,10 +607,37 @@ mod tests { } #[test] - fn test_heal_task_request_rejects_token_without_bucket() { + fn test_heal_task_request_accepts_token_without_bucket() { + let formatter = Formatter::default(); + + let request = heal_task_request(None, None, Some("root-token".to_string()), &formatter) + .expect("root token request should be valid") + .expect("root token request should be task scoped"); + + assert!(request.bucket.is_empty()); + assert!(request.prefix.is_none()); + assert_eq!(request.client_token, "root-token"); + } + + #[test] + fn test_heal_task_request_rejects_bucket_without_token() { + let formatter = Formatter::default(); + + let result = heal_task_request(Some("logs".to_string()), None, None, &formatter); + + assert!(matches!(result, Err(ExitCode::UsageError))); + } + + #[test] + fn test_heal_task_request_rejects_prefix_without_bucket() { let formatter = Formatter::default(); - let result = heal_task_request(None, None, Some("root-token".to_string()), &formatter); + let result = heal_task_request( + None, + Some("2026/".to_string()), + Some("root-token".to_string()), + &formatter, + ); assert!(matches!(result, Err(ExitCode::UsageError))); } diff --git a/crates/s3/src/admin.rs b/crates/s3/src/admin.rs index 7d0d9a7..af47dc1 100644 --- a/crates/s3/src/admin.rs +++ b/crates/s3/src/admin.rs @@ -573,24 +573,23 @@ fn rustfs_heal_path(request: &HealStartRequest) -> Result { } fn rustfs_heal_task_path(request: &HealTaskRequest) -> Result { - if request.bucket.is_empty() { - return Err(Error::InvalidPath( - "heal task status requires a bucket target".to_string(), - )); - } - + let bucket = (!request.bucket.is_empty()).then_some(request.bucket.as_str()); let prefix = request .prefix .as_deref() .filter(|prefix| !prefix.is_empty()); - match prefix { - Some(prefix) => Ok(format!( + match (bucket, prefix) { + (None, None) => Ok("/heal/".to_string()), + (Some(bucket), None) => Ok(format!("/heal/{}", urlencoding::encode(bucket))), + (Some(bucket), Some(prefix)) => Ok(format!( "/heal/{}/{}", - urlencoding::encode(&request.bucket), + urlencoding::encode(bucket), urlencoding::encode(prefix) )), - None => Ok(format!("/heal/{}", urlencoding::encode(&request.bucket))), + (None, Some(_)) => Err(Error::InvalidPath( + "heal task prefix requires a bucket target".to_string(), + )), } } @@ -1465,6 +1464,34 @@ mod tests { )); } + #[test] + fn test_rustfs_heal_task_path_supports_root_target() { + let request = HealTaskRequest { + bucket: String::new(), + prefix: None, + client_token: "root-token".to_string(), + }; + + assert_eq!( + rustfs_heal_task_path(&request).expect("root task path"), + "/heal/" + ); + } + + #[test] + fn test_rustfs_heal_task_path_rejects_root_prefix() { + let request = HealTaskRequest { + bucket: String::new(), + prefix: Some("2026/".to_string()), + client_token: "root-token".to_string(), + }; + + assert!(matches!( + rustfs_heal_task_path(&request), + Err(Error::InvalidPath(_)) + )); + } + #[test] fn test_rustfs_heal_body_matches_server_heal_options() { let request = HealStartRequest { @@ -1794,6 +1821,38 @@ mod tests { handle.join().expect("server thread should finish"); } + #[tokio::test] + async fn test_heal_task_status_queries_root_route_with_client_token() { + let (endpoint, receiver, handle) = start_admin_test_server( + "200 OK", + r#"{"summary":"running","detail":"","startTime":"2026-07-15T00:38:07Z","settings":{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":1,"updateParity":false,"nolock":false},"items":[]}"#, + ); + let client = admin_client_for_endpoint(&endpoint); + + let status = client + .heal_task_status(HealTaskRequest { + bucket: String::new(), + prefix: None, + client_token: "root-token".to_string(), + }) + .await + .expect("root heal task status request"); + + assert_eq!(status.heal_id, "root-token"); + assert!(status.healing); + assert!(status.bucket.is_empty()); + assert!(status.object.is_empty()); + + let request = receiver.recv().expect("captured request"); + assert_eq!(request.method, "POST"); + assert_eq!( + request.target, + "/rustfs/admin/v3/heal/?clientToken=root-token" + ); + assert!(request.body.is_empty()); + handle.join().expect("server thread should finish"); + } + #[tokio::test] async fn test_heal_task_status_queries_bucket_route_with_client_token() { let (endpoint, receiver, handle) = start_admin_test_server( @@ -1866,6 +1925,37 @@ mod tests { handle.join().expect("server thread should finish"); } + #[tokio::test] + async fn test_heal_task_stop_posts_root_force_stop_with_client_token() { + let (endpoint, receiver, handle) = start_admin_test_server( + "200 OK", + r#"{"summary":"stopped","detail":"heal task cancelled","startTime":"2026-07-15T00:38:07Z","settings":{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":1,"updateParity":false,"nolock":false},"items":[]}"#, + ); + let client = admin_client_for_endpoint(&endpoint); + + let status = client + .heal_task_stop(HealTaskRequest { + bucket: String::new(), + prefix: None, + client_token: "root-token".to_string(), + }) + .await + .expect("root heal task stop request"); + + assert_eq!(status.heal_id, "root-token"); + assert!(!status.healing); + assert!(status.bucket.is_empty()); + + let request = receiver.recv().expect("captured request"); + assert_eq!(request.method, "POST"); + assert_eq!( + request.target, + "/rustfs/admin/v3/heal/?clientToken=root-token&forceStop=true" + ); + assert!(request.body.is_empty()); + handle.join().expect("server thread should finish"); + } + #[tokio::test] async fn test_heal_task_stop_posts_force_stop_with_client_token() { let (endpoint, receiver, handle) = start_admin_test_server( diff --git a/docs/reference/rc/admin.md b/docs/reference/rc/admin.md index 57fe286..1c02acf 100644 --- a/docs/reference/rc/admin.md +++ b/docs/reference/rc/admin.md @@ -64,6 +64,14 @@ Check global background heal status: rc admin heal status local ``` +Start and inspect a root recursive manual heal: + +```bash +rc admin heal start local +rc admin heal status local --client-token +rc admin heal stop local --client-token +``` + Check a manual bucket heal task using the client token returned by `start`: ```bash @@ -136,7 +144,7 @@ rc admin service restart local Admin operations use the configured alias to create a RustFS admin client. The credentials behind the alias must have permissions for the requested administrative API. The command accepts aliases with or without a trailing slash. -`rc admin heal status ` reports aggregate background heal status. Manual bucket or prefix heals started with `rc admin heal start` are token-scoped tasks; the start output includes a client token, and subsequent task status or stop requests must pass that token with `--bucket`, optional `--prefix`, and `--client-token`. +`rc admin heal status ` reports aggregate background heal status. Manual heals started with `rc admin heal start` are token-scoped tasks; the start output includes a client token. Root recursive tasks are inspected or stopped with `--client-token`, while bucket or prefix tasks additionally pass `--bucket` and optional `--prefix`. ## Heal Workflow @@ -145,9 +153,11 @@ Admin operations use the configured alias to create a RustFS admin client. The c | Command | Description | | --- | --- | | `rc admin heal status ` | Show aggregate background heal status. | +| `rc admin heal status --client-token ` | Show a token-scoped root recursive manual heal task. | | `rc admin heal status --bucket [--prefix ] --client-token ` | Show a token-scoped manual heal task. | | `rc admin heal start [OPTIONS]` | Start a manual heal operation. | | `rc admin heal stop ` | Stop the global background heal operation. | +| `rc admin heal stop --client-token ` | Stop a token-scoped root recursive manual heal task. | | `rc admin heal stop --bucket [--prefix ] --client-token ` | Stop a token-scoped manual heal task. | `heal start` accepts these operation options: @@ -161,7 +171,7 @@ Admin operations use the configured alias to create a RustFS admin client. The c | `--recreate` | Recreate missing data. | | `--dry-run` | Report what would be healed without applying changes. | -Manual bucket and prefix heals are token-scoped. Save the `clientToken` returned by `heal start`; the token is required to inspect or stop that manual task. +All manual heals are token-scoped. Save the `clientToken` returned by `heal start`; the token is required to inspect or stop the task. Root recursive tasks use the token alone, while bucket and prefix tasks also require their original target options. ## Decommission Workflow diff --git a/docs/superpowers/plans/2026-07-15-heal-root-token-task.md b/docs/superpowers/plans/2026-07-15-heal-root-token-task.md new file mode 100644 index 0000000..9206e4c --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-heal-root-token-task.md @@ -0,0 +1,427 @@ +# Root Heal Token Task Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow `rc admin heal status` and `rc admin heal stop` to operate on a root recursive manual heal using only its client token while preserving tokenless global behavior. + +**Architecture:** Keep `HealTaskRequest.bucket` unchanged and represent a root task with an empty bucket. Relax the shared CLI validator for token-only requests, map the empty task target to `/heal/` in the S3 adapter, and leave the existing status/stop transport methods and output mapping intact. + +**Tech Stack:** Rust, clap, async-trait, reqwest, Tokio tests, repository TCP request-capture test helper, Markdown documentation. + +--- + +## File Map + +- Modify `crates/cli/src/commands/admin/heal.rs`: shared task-argument validation and unit tests. +- Modify `crates/s3/src/admin.rs`: root task path mapping and exact HTTP request-contract tests. +- Modify `README.md`: concise root manual-heal workflow. +- Modify `docs/reference/rc/admin.md`: formal root token status/stop contract and examples. +- Reference `docs/superpowers/specs/2026-07-15-heal-root-token-task-design.md`: approved design and acceptance criteria. + +### Task 1: Accept token-only root task arguments in the CLI + +**Files:** +- Modify: `crates/cli/src/commands/admin/heal.rs:474-507` +- Test: `crates/cli/src/commands/admin/heal.rs:614-620` + +- [ ] **Step 1: Replace the rejection test with a failing root-request test** + +Replace `test_heal_task_request_rejects_token_without_bucket` with: + +```rust +#[test] +fn test_heal_task_request_accepts_token_without_bucket() { + let formatter = Formatter::default(); + + let request = heal_task_request(None, None, Some("root-token".to_string()), &formatter) + .expect("root token request should be valid") + .expect("root token request should be task scoped"); + + assert!(request.bucket.is_empty()); + assert!(request.prefix.is_none()); + assert_eq!(request.client_token, "root-token"); +} +``` + +- [ ] **Step 2: Add regression tests for invalid partial targets** + +Add: + +```rust +#[test] +fn test_heal_task_request_rejects_bucket_without_token() { + let formatter = Formatter::default(); + + let result = heal_task_request(Some("logs".to_string()), None, None, &formatter); + + assert!(matches!(result, Err(ExitCode::UsageError))); +} + +#[test] +fn test_heal_task_request_rejects_prefix_without_bucket() { + let formatter = Formatter::default(); + + let result = heal_task_request( + None, + Some("2026/".to_string()), + Some("root-token".to_string()), + &formatter, + ); + + assert!(matches!(result, Err(ExitCode::UsageError))); +} +``` + +- [ ] **Step 3: Run the focused tests and verify the new root test fails** + +Run: + +```bash +cargo test -p rustfs-cli heal_task_request --lib +``` + +Expected: `test_heal_task_request_accepts_token_without_bucket` fails because the current validator returns `UsageError`; the two regression tests pass. + +- [ ] **Step 4: Implement the minimum validator change** + +Change the match in `heal_task_request` to: + +```rust +match (has_target, has_token) { + (false, false) => Ok(None), + (_, true) => Ok(Some(HealTaskRequest { + bucket: bucket.unwrap_or_default(), + prefix, + client_token: client_token.expect("client token is present"), + })), + (true, false) => { + formatter.error("Heal task request requires --client-token when --bucket is set."); + Err(ExitCode::UsageError) + } +} +``` + +The existing prefix-before-bucket validation remains above this match and continues rejecting root prefixes. + +- [ ] **Step 5: Run the focused tests and verify they pass** + +Run: + +```bash +cargo test -p rustfs-cli heal_task_request --lib +``` + +Expected: all `heal_task_request` tests pass. + +### Task 2: Map root task requests to the RustFS root heal route + +**Files:** +- Modify: `crates/s3/src/admin.rs:575-595` +- Test: `crates/s3/src/admin.rs` in the existing heal path and request-contract test module + +- [ ] **Step 1: Add failing unit tests for root task path construction** + +Add near `test_rustfs_heal_path_matches_admin_routes`: + +```rust +#[test] +fn test_rustfs_heal_task_path_supports_root_target() { + let request = HealTaskRequest { + bucket: String::new(), + prefix: None, + client_token: "root-token".to_string(), + }; + + assert_eq!( + rustfs_heal_task_path(&request).expect("root task path"), + "/heal/" + ); +} + +#[test] +fn test_rustfs_heal_task_path_rejects_root_prefix() { + let request = HealTaskRequest { + bucket: String::new(), + prefix: Some("2026/".to_string()), + client_token: "root-token".to_string(), + }; + + assert!(matches!( + rustfs_heal_task_path(&request), + Err(Error::InvalidPath(_)) + )); +} +``` + +- [ ] **Step 2: Run the focused path tests and verify the root test fails** + +Run: + +```bash +cargo test -p rc-s3 rustfs_heal_task_path --lib +``` + +Expected: the root-target test fails with `heal task status requires a bucket target`; the root-prefix test passes. + +- [ ] **Step 3: Implement root-aware task path construction** + +Replace `rustfs_heal_task_path` with: + +```rust +fn rustfs_heal_task_path(request: &HealTaskRequest) -> Result { + let bucket = (!request.bucket.is_empty()).then_some(request.bucket.as_str()); + let prefix = request + .prefix + .as_deref() + .filter(|prefix| !prefix.is_empty()); + + match (bucket, prefix) { + (None, None) => Ok("/heal/".to_string()), + (Some(bucket), None) => Ok(format!("/heal/{}", urlencoding::encode(bucket))), + (Some(bucket), Some(prefix)) => Ok(format!( + "/heal/{}/{}", + urlencoding::encode(bucket), + urlencoding::encode(prefix) + )), + (None, Some(_)) => Err(Error::InvalidPath( + "heal task prefix requires a bucket target".to_string(), + )), + } +} +``` + +- [ ] **Step 4: Run the focused path tests and verify they pass** + +Run: + +```bash +cargo test -p rc-s3 rustfs_heal_task_path --lib +``` + +Expected: both root path tests pass. + +- [ ] **Step 5: Add a root task status request-contract test** + +Add beside the bucket status contract test: + +```rust +#[tokio::test] +async fn test_heal_task_status_queries_root_route_with_client_token() { + let (endpoint, receiver, handle) = start_admin_test_server( + "200 OK", + r#"{"summary":"running","detail":"","startTime":"2026-07-15T00:38:07Z","settings":{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":1,"updateParity":false,"nolock":false},"items":[]}"#, + ); + let client = admin_client_for_endpoint(&endpoint); + + let status = client + .heal_task_status(HealTaskRequest { + bucket: String::new(), + prefix: None, + client_token: "root-token".to_string(), + }) + .await + .expect("root heal task status request"); + + assert_eq!(status.heal_id, "root-token"); + assert!(status.healing); + assert!(status.bucket.is_empty()); + assert!(status.object.is_empty()); + + let request = receiver.recv().expect("captured request"); + assert_eq!(request.method, "POST"); + assert_eq!( + request.target, + "/rustfs/admin/v3/heal/?clientToken=root-token" + ); + assert!(request.body.is_empty()); + handle.join().expect("server thread should finish"); +} +``` + +- [ ] **Step 6: Add a root task stop request-contract test** + +Add beside the bucket task stop contract test: + +```rust +#[tokio::test] +async fn test_heal_task_stop_posts_root_force_stop_with_client_token() { + let (endpoint, receiver, handle) = start_admin_test_server( + "200 OK", + r#"{"summary":"stopped","detail":"heal task cancelled","startTime":"2026-07-15T00:38:07Z","settings":{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":1,"updateParity":false,"nolock":false},"items":[]}"#, + ); + let client = admin_client_for_endpoint(&endpoint); + + let status = client + .heal_task_stop(HealTaskRequest { + bucket: String::new(), + prefix: None, + client_token: "root-token".to_string(), + }) + .await + .expect("root heal task stop request"); + + assert_eq!(status.heal_id, "root-token"); + assert!(!status.healing); + assert!(status.bucket.is_empty()); + + let request = receiver.recv().expect("captured request"); + assert_eq!(request.method, "POST"); + assert_eq!( + request.target, + "/rustfs/admin/v3/heal/?clientToken=root-token&forceStop=true" + ); + assert!(request.body.is_empty()); + handle.join().expect("server thread should finish"); +} +``` + +- [ ] **Step 7: Run all S3 heal tests** + +Run: + +```bash +cargo test -p rc-s3 heal --lib +``` + +Expected: all heal path, status, start, and stop tests pass, including the existing bucket and prefix contracts. + +### Task 3: Document the root manual-heal workflow + +**Files:** +- Modify: `README.md:259-265` +- Modify: `docs/reference/rc/admin.md:55-76,139-164` + +- [ ] **Step 1: Update the README heal examples** + +Replace the Heal operations example block with commands grouped by meaning: + +```bash +# Aggregate background heal status +rc admin heal status local + +# Root recursive manual heal +rc admin heal start local --scan-mode deep +rc admin heal status local --client-token +rc admin heal stop local --client-token + +# Bucket manual heal +rc admin heal start local --bucket mybucket --scan-mode deep +rc admin heal status local --bucket mybucket --client-token +rc admin heal stop local --bucket mybucket --client-token + +# Global force stop +rc admin heal stop local +``` + +- [ ] **Step 2: Add the root task example to the admin reference** + +After the global background status example, add: + +````markdown +Start and inspect a root recursive manual heal: + +```bash +rc admin heal start local +rc admin heal status local --client-token +rc admin heal stop local --client-token +``` +```` + +- [ ] **Step 3: Correct the behavior contract** + +Replace the behavior paragraph with: + +```markdown +`rc admin heal status ` reports aggregate background heal status. Manual heals started with `rc admin heal start` are token-scoped tasks; the start output includes a client token. Root recursive tasks are inspected or stopped with `--client-token`, while bucket or prefix tasks additionally pass `--bucket` and optional `--prefix`. +``` + +- [ ] **Step 4: Extend the Heal Workflow table** + +Add these rows while retaining the existing aggregate, bucket/prefix, start, and global-stop rows: + +```markdown +| `rc admin heal status --client-token ` | Show a token-scoped root recursive manual heal task. | +| `rc admin heal stop --client-token ` | Stop a token-scoped root recursive manual heal task. | +``` + +Replace the final manual-heal note with: + +```markdown +All manual heals are token-scoped. Save the `clientToken` returned by `heal start`; the token is required to inspect or stop the task. Root recursive tasks use the token alone, while bucket and prefix tasks also require their original target options. +``` + +- [ ] **Step 5: Review the protected contract requirements** + +Confirm that the diff does not modify `schemas/output_v1.json`, `schemas/output_v2.json`, `crates/cli/src/exit_code.rs`, or `crates/core/src/config.rs`. Record that the pull request title or description must include `BREAKING` because `docs/reference/rc/admin.md` is protected, while no config migration or output schema bump applies to this additive command support. + +### Task 4: Format, verify, and commit the implementation + +**Files:** +- Verify all modified source and documentation files. + +- [ ] **Step 1: Format the workspace** + +Run: + +```bash +cargo fmt --all +cargo fmt --all --check +``` + +Expected: both commands exit successfully and `--check` produces no diff. + +- [ ] **Step 2: Run static analysis with warnings denied** + +Run: + +```bash +cargo clippy --workspace -- -D warnings +``` + +Expected: exit code 0 with zero warnings. + +- [ ] **Step 3: Run the complete workspace test suite** + +Run: + +```bash +cargo test --workspace +``` + +Expected: every workspace unit, integration, and golden test passes. + +- [ ] **Step 4: Inspect the final diff and protected-file scope** + +Run: + +```bash +git diff --check +git status --short +git diff --stat +git diff --stat origin/main +git diff -- crates/cli/src/commands/admin/heal.rs crates/s3/src/admin.rs README.md docs/reference/rc/admin.md +``` + +Expected: only the planned root token support and documentation changes are present; no credential, output-schema, exit-code, or config changes appear. + +- [ ] **Step 5: Commit only after all checks pass** + +Run: + +```bash +git add crates/cli/src/commands/admin/heal.rs crates/s3/src/admin.rs README.md docs/reference/rc/admin.md +git commit -m "feat(phase-2): support root heal task status and stop" +``` + +Expected: the commit succeeds without bypassing hooks. If any required check fails, do not commit; diagnose and fix the failure first. + +- [ ] **Step 6: Confirm the branch state** + +Run: + +```bash +git status --short --branch +git log -3 --oneline --decorate +``` + +Expected: the working tree is clean and the branch contains the design, plan, and verified implementation commits. diff --git a/docs/superpowers/specs/2026-07-15-heal-root-token-task-design.md b/docs/superpowers/specs/2026-07-15-heal-root-token-task-design.md new file mode 100644 index 0000000..00bcae4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-heal-root-token-task-design.md @@ -0,0 +1,144 @@ +# Root Heal Token Task Support Design + +## Objective + +Allow operators to inspect and stop a manual root recursive heal by supplying the client token returned by `rc admin heal start ` without also supplying a bucket. Preserve the existing tokenless global background-status behavior and all bucket/prefix task workflows. + +## Problem + +The RustFS server supports task-scoped requests against the root heal route: + +```text +POST /rustfs/admin/v3/heal/?clientToken= +POST /rustfs/admin/v3/heal/?clientToken=&forceStop=true +``` + +The CLI can start a root recursive heal and receives a client token, but its validation currently rejects `--client-token` unless `--bucket` is also present. The S3 adapter independently rejects an empty task bucket. As a result, the CLI cannot query or stop the root task it started. + +## Scope + +The change covers both root task status and root task stop so the two operations remain symmetric. + +In scope: + +- Accept `--client-token` without `--bucket` for `heal status` and `heal stop`. +- Map a token-scoped request with an empty bucket and no prefix to `/heal/`. +- Preserve bucket and prefix task behavior. +- Preserve tokenless global status and stop behavior. +- Add CLI validation tests and S3 request-contract tests. +- Update the README and the protected admin command reference. + +Out of scope: + +- Server changes. +- JSON output schema changes. +- Exit code changes. +- Configuration or schema-version changes. +- Historical task persistence beyond the server's existing retention behavior. + +## Command Behavior + +The CLI will use the following parameter matrix for both status and stop: + +| Bucket | Prefix | Client token | Result | +| --- | --- | --- | --- | +| Absent | Absent | Absent | Use the existing global operation. | +| Present | Optional | Present | Use the existing bucket/prefix task operation. | +| Absent | Absent | Present | Use the root token-scoped task operation. | +| Present | Optional | Absent | Return `UsageError`. | +| Absent | Present | Any | Return `UsageError`. | + +Examples: + +```bash +rc admin heal start rustfs +rc admin heal status rustfs --client-token +rc admin heal stop rustfs --client-token +``` + +The existing command remains unchanged: + +```bash +rc admin heal status rustfs +``` + +It continues to query `/background-heal/status` and report aggregate runtime state rather than a specific manual task. + +## Architecture + +### CLI request construction + +`heal_task_request` remains the shared validator for status and stop. A token-only invocation will produce a `HealTaskRequest` with an empty `bucket`, no `prefix`, and the supplied token. Keeping `bucket: String` avoids changing the public core type or its serialization. + +The validator will continue to reject a prefix without a bucket and a bucket without a token. + +### S3 task path construction + +`rustfs_heal_task_path` will support three valid mappings: + +```text +bucket="", prefix=None -> /heal/ +bucket="b", prefix=None -> /heal/b +bucket="b", prefix=Some("p") -> /heal/b/p +``` + +An empty bucket with a non-empty prefix remains invalid as a defensive boundary check even though CLI validation rejects it first. + +The existing `heal_task_status` and `heal_task_stop` methods will continue adding `clientToken` and `forceStop` query parameters. No `AdminApi` trait change is required. + +### Error handling + +- Missing token for a bucket-scoped task remains `UsageError`. +- Prefix without bucket remains `UsageError`. +- Root token requests use existing admin API error mapping for authentication, transport, and server failures. +- Server task summaries such as `notFound`, `finished`, and `stopped` retain their current output and exit-code behavior. + +## Testing Strategy + +Implementation will follow test-driven development. + +CLI unit tests will first assert that: + +- Token-only input creates a root `HealTaskRequest`. +- Bucket plus token remains valid. +- Bucket without token remains invalid. +- Prefix without bucket remains invalid. + +S3 unit and request-contract tests will first assert that: + +- An empty bucket without a prefix maps to `/heal/`. +- An empty bucket with a prefix is rejected. +- Root task status sends `POST /rustfs/admin/v3/heal/?clientToken=`. +- Root task stop sends `POST /rustfs/admin/v3/heal/?clientToken=&forceStop=true`. +- Status and stop responses preserve the client token and existing status mapping. + +After targeted tests pass, the full required validation is: + +```bash +cargo fmt --all --check +cargo clippy --workspace -- -D warnings +cargo test --workspace +``` + +## Documentation + +`README.md` will show the complete root manual-heal workflow and distinguish task-scoped status from tokenless aggregate status. + +`docs/reference/rc/admin.md` will document token-only root task status and stop commands, update the Heal Workflow table, and state that every manual heal is token-scoped. Because this reference path is protected by `AGENTS.md`, the pull request title or description must include `BREAKING`. The change is additive and does not alter configuration or JSON output, so no schema version bump or migration is applicable. + +## Files + +- Modify `crates/cli/src/commands/admin/heal.rs` for validation and CLI unit tests. +- Modify `crates/s3/src/admin.rs` for root path mapping and request-contract tests. +- Modify `README.md` for the root task workflow. +- Modify `docs/reference/rc/admin.md` for the formal command reference. + +## Acceptance Criteria + +- A root recursive heal token can be used with both `heal status` and `heal stop` without a bucket. +- Tokenless global status behavior is unchanged. +- Existing bucket and prefix task behavior is unchanged. +- Invalid bucket/token and prefix/bucket combinations still return usage errors. +- Exact root status and stop HTTP request contracts are covered by tests. +- README and admin reference documentation describe the new supported workflow. +- Formatting, clippy, and workspace tests pass before any implementation commit.