diff --git a/CHANGELOG.md b/CHANGELOG.md index 07390195..2e5e74f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -247,6 +247,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 force-killing the wrapped process, and a clean stop/uninstall all held; the service was configured with `StartType Automatic`, but boot-time startup was not independently exercised (#530). +- Added `ai-memory rename-workstream`, a checkout-local rename for managed + workstreams selectable by current name or by the stable id `workstreams` + prints. Names were fixed at `run --new` time and had no correction path, so + a typo outlived the work it labelled. The rename is metadata only: the + ledger, managed runs, and linked native sessions all key on the workstream + id, and `selected_at` and `updated_at` are deliberately left untouched, so + neither the listing order nor the workstream a bare `ai-memory run` resumes + moves as a side effect of relabelling. The destination is validated exactly + like a `--new` name and refused with a named conflict when another + workstream in the same checkout already holds it; renaming a workstream to + the name it already has writes nothing and is not an error. Both selectors + repeat the checkout predicate, so an id belonging to another workspace, + project, or worktree reads as absent rather than renamable. The Docker + wrapper routes the command through its native host client, since repository + identity is a host resource. ## [1.38.0] - 2026-08-30 diff --git a/README.md b/README.md index 57b8ee81..7c1b92c3 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,9 @@ priors are at the [bottom](#influences-and-prior-art). # List the workstreams that can be selected from this checkout. ai-memory workstreams + # Fix a name you regret; the ledger and the current selection stay put. + ai-memory rename-workstream --from typo-nmae --to refactor-db + # List open cross-agent handoffs, oldest first, with the id # `memory_handoff_cancel` needs to clear a stale one. ai-memory handoffs diff --git a/bin/ai-memory b/bin/ai-memory index 04e1364e..b6dc08a7 100755 --- a/bin/ai-memory +++ b/bin/ai-memory @@ -13,6 +13,8 @@ # ai-memory show ... Use that client for host project/harness discovery. # ai-memory continue ... Use that client to resume the newest linked checkout. # ai-memory workstreams Use that client to inspect the host checkout identity. +# ai-memory rename-workstream +# Same: the rename is keyed on host repo identity. # # Env overrides: # AI_MEMORY_IMAGE docker image (default: akitaonrails/ai-memory:latest) @@ -370,7 +372,7 @@ native_host_binary() { Darwin) os="macos" ;; *) echo "ai-memory: the Docker wrapper cannot provide managed host launches on this OS" >&2 - echo "install the native release binary to use ai-memory run/show/continue/workstreams" >&2 + echo "install the native release binary to use ai-memory run/show/continue/workstreams/rename-workstream" >&2 return 1 ;; esac @@ -445,7 +447,7 @@ case "${1:-}" in cmd_upgrade exit 0 ;; - run | show | continue | workstreams) + run | show | continue | workstreams | rename-workstream) NATIVE_HOST_COMMAND=$1 shift NATIVE_HOST_BIN=$(native_host_binary) diff --git a/crates/ai-memory-cli/src/cli.rs b/crates/ai-memory-cli/src/cli.rs index 815903ac..1106c60b 100644 --- a/crates/ai-memory-cli/src/cli.rs +++ b/crates/ai-memory-cli/src/cli.rs @@ -47,6 +47,10 @@ pub enum Command { Handoffs(HandoffsArgs), /// List recent managed workstreams selectable from the current checkout. Workstreams(WorkstreamsArgs), + /// Rename a managed workstream in the current checkout. Metadata only: + /// the ledger, linked harnesses, and which workstream a bare + /// `ai-memory run` resumes are all unchanged. + RenameWorkstream(RenameWorkstreamArgs), /// Search the complete visible event ledger for a managed workstream. WorkstreamSearch(WorkstreamSearchArgs), /// Audit the store for likely cross-project contamination (read-only, @@ -383,6 +387,33 @@ pub struct WorkstreamsArgs { pub json: bool, } +/// Arguments for `rename-workstream`. +#[derive(Debug, Args)] +pub struct RenameWorkstreamArgs { + /// Workspace containing the managed workstream. Defaults to the nearest + /// `.ai-memory.toml` marker's `workspace`, else `default`. + #[arg(long)] + pub workspace: Option, + /// Project override. Defaults to the current repository project. + #[arg(long)] + pub project: Option, + /// Current name of the workstream to rename. Names are unique within one + /// checkout, so this is unambiguous wherever `run --workstream` works. + #[arg(long, conflicts_with = "workstream_id")] + pub from: Option, + /// Stable id of the workstream to rename, as printed by `workstreams`. + /// Useful when the current name is awkward to retype. + #[arg(long, conflicts_with = "from")] + pub workstream_id: Option, + /// New name. Same rules as `run --new`: non-empty, at most 128 + /// characters, no control characters, no slashes. + #[arg(long)] + pub to: String, + /// Emit JSON instead of a readable line. + #[arg(long)] + pub json: bool, +} + /// Arguments for `workstream-search`. #[derive(Debug, Args)] pub struct WorkstreamSearchArgs { diff --git a/crates/ai-memory-cli/src/commands/mod.rs b/crates/ai-memory-cli/src/commands/mod.rs index 6b7fcd7d..1ba8948c 100644 --- a/crates/ai-memory-cli/src/commands/mod.rs +++ b/crates/ai-memory-cli/src/commands/mod.rs @@ -47,6 +47,7 @@ pub mod purge_session; pub mod read_page; pub mod reindex; pub mod rename_project; +pub mod rename_workstream; pub mod render_shared; pub mod reorg; pub mod reset; diff --git a/crates/ai-memory-cli/src/commands/rename_workstream.rs b/crates/ai-memory-cli/src/commands/rename_workstream.rs new file mode 100644 index 00000000..0865c72f --- /dev/null +++ b/crates/ai-memory-cli/src/commands/rename_workstream.rs @@ -0,0 +1,88 @@ +//! Checkout-local rename for managed workstreams. + +use ai_memory_core::{RenameManagedWorkstreamRequest, RenamedManagedWorkstream}; +use ai_memory_workstream::inspect_repository; +use anyhow::{Context as _, Result, bail}; + +use crate::cli::RenameWorkstreamArgs; +use crate::config::Config; +use crate::http_client::{ServerEndpoint, post_json}; + +/// Retitle one workstream selectable from this checkout. +pub async fn run(config: &Config, args: RenameWorkstreamArgs) -> Result<()> { + // clap's `conflicts_with` rejects passing both, but not passing neither: + // the pair is optional on either side, so an empty invocation reaches here + // with nothing to address. + if args.from.is_none() && args.workstream_id.is_none() { + bail!("pass --from NAME or --workstream-id ID to choose the workstream to rename"); + } + let cwd = std::env::current_dir().context("getting managed workstream checkout")?; + let repository = inspect_repository(&cwd)?; + let (workspace, project) = + super::resolve_scope(config, args.workspace.as_deref(), args.project.as_deref())?; + let endpoint = ServerEndpoint::from_config_resolving_auth(config).await; + let renamed: RenamedManagedWorkstream = post_json( + &endpoint, + "/workstream/rename", + &RenameManagedWorkstreamRequest { + workspace, + project, + repo_fingerprint: repository.repo_fingerprint, + worktree_fingerprint: repository.worktree_fingerprint, + from: args.from, + workstream_id: args.workstream_id, + to: args.to, + }, + ) + .await?; + if args.json { + println!("{}", serde_json::to_string_pretty(&renamed)?); + return Ok(()); + } + print!("{}", render_human(&renamed)); + Ok(()) +} + +fn render_human(renamed: &RenamedManagedWorkstream) -> String { + if renamed.from == renamed.to { + // A repeated rename is not a failure, but reporting it as a change + // would be a lie: nothing was written. + return format!("Workstream '{}' already has that name.\n", renamed.to); + } + format!( + "Renamed workstream '{}' to '{}'.\n id: {}\n", + renamed.from, renamed.to, renamed.workstream_id + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use ai_memory_core::WorkstreamId; + + fn renamed(from: &str, to: &str) -> RenamedManagedWorkstream { + RenamedManagedWorkstream { + workstream_id: WorkstreamId::new(), + from: from.to_owned(), + to: to.to_owned(), + } + } + + #[test] + fn human_output_reports_both_names_and_the_stable_id() { + let renamed = renamed("typo-nmae", "refactor-db"); + let rendered = render_human(&renamed); + assert!(rendered.contains("'typo-nmae' to 'refactor-db'")); + // The id is indented far enough that a continuation line cannot be + // mistaken for the start of another record, matching `workstreams`. + assert!(rendered.contains("\n id: ")); + assert!(rendered.ends_with('\n')); + } + + #[test] + fn renaming_to_the_same_name_does_not_claim_a_change() { + let rendered = render_human(&renamed("stable", "stable")); + assert!(rendered.contains("already has that name")); + assert!(!rendered.contains("Renamed")); + } +} diff --git a/crates/ai-memory-cli/src/main.rs b/crates/ai-memory-cli/src/main.rs index cb7a0674..291d8653 100644 --- a/crates/ai-memory-cli/src/main.rs +++ b/crates/ai-memory-cli/src/main.rs @@ -97,6 +97,7 @@ async fn main() -> Result<()> { } Command::Handoffs(args) => commands::handoffs::run(&config, args).await, Command::Workstreams(args) => commands::workstreams::run(&config, args).await, + Command::RenameWorkstream(args) => commands::rename_workstream::run(&config, args).await, Command::WorkstreamSearch(args) => commands::workstream_search::run(&config, args).await, Command::AuditContamination(args) => { commands::audit_contamination::run(&config, args).await diff --git a/crates/ai-memory-cli/tests/packaging.rs b/crates/ai-memory-cli/tests/packaging.rs index 6cb6481c..2072d443 100644 --- a/crates/ai-memory-cli/tests/packaging.rs +++ b/crates/ai-memory-cli/tests/packaging.rs @@ -836,6 +836,7 @@ fn managed_host_commands_use_native_path_and_remote_server_without_docker() { &["show", "--json", "--no-scan"], &["continue", "--workspace", "work", "--yolo"], &["workstreams", "--limit", "5", "--json"], + &["rename-workstream", "--from", "old", "--to", "new"], ]; for args in commands { let output = shell_script_command(&repo_root().join("bin/ai-memory")) diff --git a/crates/ai-memory-core/src/lib.rs b/crates/ai-memory-core/src/lib.rs index 98bdb7af..9a23b72d 100644 --- a/crates/ai-memory-core/src/lib.rs +++ b/crates/ai-memory-core/src/lib.rs @@ -77,6 +77,6 @@ pub use workstream::{ FinishManagedRunRequest, FinishManagedRunResponse, LinkManagedRunRequest, ListManagedWorkstreamsRequest, MANAGED_WORKSTREAM_PACKET_MARKER, ManagedRunContextResponse, ManagedRunStatus, ManagedWorkstreamSummary, NewWorkstreamEvent, PrepareManagedRunRequest, - PrepareManagedRunResponse, UNTRUSTED_MEMORY_NOTICE, WorkstreamCheckpoint, WorkstreamEvent, - WorkstreamEventKind, + PrepareManagedRunResponse, RenameManagedWorkstreamRequest, RenamedManagedWorkstream, + UNTRUSTED_MEMORY_NOTICE, WorkstreamCheckpoint, WorkstreamEvent, WorkstreamEventKind, }; diff --git a/crates/ai-memory-core/src/workstream.rs b/crates/ai-memory-core/src/workstream.rs index 79858ff5..7cf2c29e 100644 --- a/crates/ai-memory-core/src/workstream.rs +++ b/crates/ai-memory-core/src/workstream.rs @@ -284,6 +284,42 @@ pub struct ManagedWorkstreamSummary { pub linked_harnesses: Vec, } +/// Checkout identity and selector for retitling one managed workstream. +/// +/// The two selectors are mutually exclusive and the CLI enforces that before +/// the request is built; the server still rejects a body carrying both or +/// neither, since it cannot assume a well-behaved client. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenameManagedWorkstreamRequest { + /// Workspace name resolved by the host CLI. + pub workspace: String, + /// Project name resolved by the host CLI. + pub project: String, + /// Stable repository identity hash. + pub repo_fingerprint: String, + /// Stable worktree identity hash (distinct across linked worktrees). + pub worktree_fingerprint: String, + /// Current name of the workstream to retitle. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub from: Option, + /// Stable id of the workstream to retitle, as printed by discovery. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workstream_id: Option, + /// Replacement name. + pub to: String, +} + +/// Result of a successful managed-workstream rename. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenamedManagedWorkstream { + /// The workstream that was retitled. + pub workstream_id: WorkstreamId, + /// Name before the rename. + pub from: String, + /// Name after the rename, as stored. + pub to: String, +} + /// Stored workstream event returned by history reads. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkstreamEvent { diff --git a/crates/ai-memory-hooks/src/workstream.rs b/crates/ai-memory-hooks/src/workstream.rs index 62047d01..d3fee8f3 100644 --- a/crates/ai-memory-hooks/src/workstream.rs +++ b/crates/ai-memory-hooks/src/workstream.rs @@ -10,11 +10,13 @@ use ai_memory_core::{ AgentKind, AuthLevel, Capability, FinishManagedRunRequest, FinishManagedRunResponse, LinkManagedRunRequest, ListManagedWorkstreamsRequest, ManagedRunContextResponse, ManagedRunId, ManagedRunStatus, ManagedWorkstreamSummary, NewWorkstreamEvent, PrepareManagedRunRequest, - PrepareManagedRunResponse, Sanitizer, WorkstreamEventKind, WorkstreamId, + PrepareManagedRunResponse, RenameManagedWorkstreamRequest, RenamedManagedWorkstream, Sanitizer, + WorkstreamEventKind, WorkstreamId, }; use ai_memory_store::{ - FinishWorkstreamRun, PrepareWorkstreamRun, ReaderPool, ScopeResolutionError, StoreError, - WorkstreamSelection, WriterHandle, create_explicit_scope, lookup_existing_scope, + FinishWorkstreamRun, PrepareWorkstreamRun, ReaderPool, RenameWorkstream, ScopeResolutionError, + StoreError, WorkstreamSelection, WorkstreamSelector, WriterHandle, create_explicit_scope, + lookup_existing_scope, }; use axum::extract::{Path as AxumPath, Query, State}; use axum::http::StatusCode; @@ -63,6 +65,7 @@ pub fn workstream_router(state: WorkstreamState) -> Router { .route("/workstream/runs/{run_id}/link", post(link_run)) .route("/workstream/runs/{run_id}/finish", post(finish_run)) .route("/workstream/recent", post(list_recent_workstreams)) + .route("/workstream/rename", post(rename_workstream)) .route("/workstream/{workstream_id}/events", get(search_events)) .with_state(state) } @@ -460,6 +463,105 @@ async fn list_recent_workstreams( Json(response).into_response() } +/// Retitle one checkout-local managed workstream. +/// +/// A write surface, so it takes `NormalWrite` rather than the `NormalRead` +/// the sibling discovery read uses. Scope still resolves through +/// `lookup_existing_scope`: a rename never creates the workspace or project +/// it names, and the store repeats the checkout predicate on the id lookup so +/// an id belonging to another checkout reads as absent rather than renamable. +async fn rename_workstream( + State(state): State, + level: Option>, + Json(request): Json, +) -> Response { + if let Err(response) = authorize(level, Capability::NormalWrite) { + return response.into_response(); + } + for (label, value) in [ + ("workspace", request.workspace.as_str()), + ("project", request.project.as_str()), + ("repo_fingerprint", request.repo_fingerprint.as_str()), + ( + "worktree_fingerprint", + request.worktree_fingerprint.as_str(), + ), + ("to", request.to.as_str()), + ] { + if value.trim().is_empty() { + return error(StatusCode::BAD_REQUEST, format!("{label} cannot be empty")); + } + if value.len() > MAX_NAME_BYTES { + return error(StatusCode::BAD_REQUEST, format!("{label} is too long")); + } + } + let selector = match (request.from.as_deref(), request.workstream_id) { + (Some(name), None) => { + let name = name.trim(); + if name.is_empty() { + return error(StatusCode::BAD_REQUEST, "from cannot be empty"); + } + if name.len() > MAX_NAME_BYTES { + return error(StatusCode::BAD_REQUEST, "from is too long"); + } + WorkstreamSelector::Name(name.to_string()) + } + (None, Some(id)) => WorkstreamSelector::Id(id), + (Some(_), Some(_)) => { + return error( + StatusCode::BAD_REQUEST, + "from and workstream_id are mutually exclusive", + ); + } + (None, None) => { + return error( + StatusCode::BAD_REQUEST, + "one of from or workstream_id is required", + ); + } + }; + let scope = match lookup_existing_scope( + &state.reader, + request.workspace.trim(), + request.project.trim(), + ) + .await + { + Ok(scope) => scope, + Err(failure) if failure.is_not_found() => { + return error(StatusCode::NOT_FOUND, failure.to_string()); + } + Err(failure) if failure.is_bad_request() => { + return error(StatusCode::BAD_REQUEST, failure.to_string()); + } + Err(ScopeResolutionError::Store(message)) => { + return error(StatusCode::INTERNAL_SERVER_ERROR, message); + } + Err(failure) => return error(StatusCode::INTERNAL_SERVER_ERROR, failure.to_string()), + }; + let renamed = match state + .writer + .rename_workstream(RenameWorkstream { + workspace_id: scope.workspace_id, + project_id: scope.project_id, + repo_fingerprint: request.repo_fingerprint, + worktree_fingerprint: request.worktree_fingerprint, + selector, + new_name: request.to, + }) + .await + { + Ok(renamed) => renamed, + Err(failure) => return store_error_response(failure), + }; + Json(RenamedManagedWorkstream { + workstream_id: renamed.workstream_id, + from: renamed.from, + to: renamed.to, + }) + .into_response() +} + async fn search_events( State(state): State, level: Option>, @@ -635,7 +737,9 @@ fn parse_run_id(raw: &str) -> Result { fn store_error_response(failure: StoreError) -> Response { let status = match failure { - StoreError::WorkstreamBusy(_) | StoreError::Duplicate(_) => StatusCode::CONFLICT, + StoreError::WorkstreamBusy(_) + | StoreError::Duplicate(_) + | StoreError::WorkstreamNameTaken(_) => StatusCode::CONFLICT, StoreError::NotFound(_) => StatusCode::NOT_FOUND, StoreError::InvalidState(_) => StatusCode::BAD_REQUEST, _ => StatusCode::INTERNAL_SERVER_ERROR, @@ -869,6 +973,98 @@ mod tests { (workspace_id, project_id) } + #[tokio::test] + async fn rename_endpoint_is_scoped_and_reports_selector_misuse() { + let temp = TempDir::new().unwrap(); + let store = Store::open(temp.path()).unwrap(); + let state = test_state(&store, temp.path()); + let (workspace_id, project_id) = seed_scope(&store).await; + let prepared = store + .writer + .prepare_workstream_run(PrepareWorkstreamRun { + selection: WorkstreamSelection::New("typo-nmae".into()), + ..prepare_input(workspace_id, project_id, AgentKind::OpenCode, "launcher") + }) + .await + .unwrap(); + + fn request(from: Option<&str>, to: &str) -> RenameManagedWorkstreamRequest { + RenameManagedWorkstreamRequest { + workspace: "default".into(), + project: "managed".into(), + repo_fingerprint: "repo".into(), + worktree_fingerprint: "worktree".into(), + from: from.map(str::to_owned), + workstream_id: None, + to: to.into(), + } + } + + let ok = rename_workstream( + State(state.clone()), + None, + Json(request(Some("typo-nmae"), "refactor-db")), + ) + .await; + assert_eq!(ok.status(), StatusCode::OK); + let body = to_bytes(ok.into_body(), 64 * 1024).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["from"], "typo-nmae"); + assert_eq!(json["to"], "refactor-db"); + assert_eq!(json["workstream_id"], prepared.workstream_id.to_string()); + // The rename response is metadata like its sibling read: no checkout + // path, and no native session id. + let encoded = String::from_utf8(body.to_vec()).unwrap(); + assert!(!encoded.contains("/repo")); + + // A name that exists, but in another worktree, must not be reachable. + let mut wrong_worktree = request(Some("refactor-db"), "stolen"); + wrong_worktree.worktree_fingerprint = "other-worktree".into(); + let response = rename_workstream(State(state.clone()), None, Json(wrong_worktree)).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // Unknown scopes stay 404 rather than being created by the write. + let mut missing = request(Some("refactor-db"), "stolen"); + missing.workspace = "missing".into(); + let response = rename_workstream(State(state.clone()), None, Json(missing)).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // Neither selector, and both selectors, are caller errors the server + // rejects on its own rather than trusting clap to have done it. + let neither = rename_workstream(State(state.clone()), None, Json(request(None, "x"))).await; + assert_eq!(neither.status(), StatusCode::BAD_REQUEST); + let mut both = request(Some("refactor-db"), "x"); + both.workstream_id = Some(prepared.workstream_id); + let both = rename_workstream(State(state.clone()), None, Json(both)).await; + assert_eq!(both.status(), StatusCode::BAD_REQUEST); + + // An invalid destination name is a 400, not a 500. + let invalid = rename_workstream( + State(state.clone()), + None, + Json(request(Some("refactor-db"), "a/b")), + ) + .await; + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + + // A collision is a 409 naming the taken name. + store + .writer + .prepare_workstream_run(PrepareWorkstreamRun { + selection: WorkstreamSelection::New("taken".into()), + ..prepare_input(workspace_id, project_id, AgentKind::OpenCode, "other") + }) + .await + .unwrap(); + let conflict = rename_workstream( + State(state), + None, + Json(request(Some("refactor-db"), "taken")), + ) + .await; + assert_eq!(conflict.status(), StatusCode::CONFLICT); + } + #[tokio::test] async fn recent_workstream_endpoint_is_checkout_scoped_and_read_only() { let temp = TempDir::new().unwrap(); diff --git a/crates/ai-memory-store/src/error.rs b/crates/ai-memory-store/src/error.rs index cee483d7..1b0302a8 100644 --- a/crates/ai-memory-store/src/error.rs +++ b/crates/ai-memory-store/src/error.rs @@ -156,6 +156,14 @@ pub enum StoreError { /// A live `ai-memory run` lease already owns the selected workstream. #[error("workstream is already active: {0}")] WorkstreamBusy(String), + + /// A workstream rename was rejected because another workstream in the + /// same checkout already holds the destination name. `workstreams` is + /// UNIQUE on (workspace, project, repo, worktree, name), so the rename + /// would violate that constraint; refusing by name gives the caller the + /// collision instead of a bare SQLite error. + #[error("workstream name '{0}' is already taken in this checkout")] + WorkstreamNameTaken(String), } impl StoreError { diff --git a/crates/ai-memory-store/src/lib.rs b/crates/ai-memory-store/src/lib.rs index de4d00a9..b2f8eca7 100644 --- a/crates/ai-memory-store/src/lib.rs +++ b/crates/ai-memory-store/src/lib.rs @@ -70,7 +70,8 @@ pub use session_consolidation::{SESSION_CONSOLIDATION_MAX_ATTEMPTS, SessionConso pub use users::{TOKEN_HASH_LEN, TOKEN_RAW_LEN, TokenPepper, generate_token, hash_token}; pub use workstream::{ FinishWorkstreamRun, FinishedWorkstreamRun, ManagedRunContext, PrepareWorkstreamRun, - PreparedWorkstreamRun, StoredManagedRunStatus, StoredWorkstreamSummary, WorkstreamSelection, + PreparedWorkstreamRun, RenameWorkstream, RenamedWorkstream, StoredManagedRunStatus, + StoredWorkstreamSummary, WorkstreamSelection, WorkstreamSelector, }; pub use writer::{StartupContextAcceptance, WriterHandle}; @@ -170,7 +171,7 @@ mod tests { ActorContext, AgentKind, HandoffAcceptance, HandoffId, HandoffState, LinkTarget, ManagedRunId, NewHandoff, NewObservation, NewPage, NewSession, NewWorkstreamEvent, ObservationId, ObservationKind, PageId, PagePath, ProjectId, Sanitized, Sanitizer, - SessionId, Tier, UserId, WorkspaceId, WorkstreamEventKind, + SessionId, Tier, UserId, WorkspaceId, WorkstreamEventKind, WorkstreamId, }; use rusqlite::{Connection, params}; use sha2::{Digest, Sha256}; @@ -5985,6 +5986,299 @@ mod tests { assert!(visible(&store, mine, theirs_proj).await.is_empty()); } + /// Create `names` as workstreams in one checkout, newest last, and hand + /// back each one's `(workstream, run)` pair. Preparing is the only way to + /// create a workstream, so every seeded row also owns a live run lease. + async fn seed_workstreams( + store: &Store, + ws: WorkspaceId, + proj: ProjectId, + names: &[&str], + ) -> Vec<(WorkstreamId, ManagedRunId)> { + let mut ids = Vec::new(); + for name in names { + let prepared = store + .writer + .prepare_workstream_run(PrepareWorkstreamRun { + selection: WorkstreamSelection::New((*name).into()), + ..managed_prepare_input(ws, proj, name) + }) + .await + .unwrap(); + ids.push((prepared.workstream_id, prepared.run_id)); + } + ids + } + + fn rename_input( + ws: WorkspaceId, + proj: ProjectId, + selector: WorkstreamSelector, + to: &str, + ) -> RenameWorkstream { + RenameWorkstream { + workspace_id: ws, + project_id: proj, + repo_fingerprint: "repo".into(), + worktree_fingerprint: "worktree".into(), + selector, + new_name: to.into(), + } + } + + #[tokio::test] + async fn rename_retitles_by_name_or_id_and_keeps_the_stable_id() { + let tmp = TempDir::new().unwrap(); + let store = Store::open(tmp.path()).unwrap(); + let (ws, proj) = open_managed_scope(&store, "proj").await; + let seeded = seed_workstreams(&store, ws, proj, &["alpha", "beta"]).await; + let ids: Vec = seeded.iter().map(|(id, _)| *id).collect(); + let run_ids: Vec = seeded.iter().map(|(_, run)| *run).collect(); + + let by_name = store + .writer + .rename_workstream(rename_input( + ws, + proj, + WorkstreamSelector::Name("alpha".into()), + "alpha-renamed", + )) + .await + .unwrap(); + assert_eq!(by_name.from, "alpha"); + assert_eq!(by_name.to, "alpha-renamed"); + // The id is what the ledger and `workstream-search` key on, so a + // rename that minted a new one would orphan the history. + assert_eq!(by_name.workstream_id, ids[0]); + + let by_id = store + .writer + .rename_workstream(rename_input( + ws, + proj, + WorkstreamSelector::Id(ids[1]), + "beta-renamed", + )) + .await + .unwrap(); + assert_eq!(by_id.from, "beta"); + assert_eq!(by_id.workstream_id, ids[1]); + + // Both new names are selectable, which is the whole point of the + // rename. Seeding left a live lease on each workstream, so release + // them first: `Named` selection refuses a busy workstream, and that + // refusal would mask whether the name resolved at all. + for run_id in &run_ids { + assert!(store.writer.cancel_managed_run(*run_id).await.unwrap()); + } + for name in ["alpha-renamed", "beta-renamed"] { + store + .writer + .prepare_workstream_run(PrepareWorkstreamRun { + selection: WorkstreamSelection::Named(name.into()), + ..managed_prepare_input(ws, proj, name) + }) + .await + .unwrap(); + } + } + + #[tokio::test] + async fn rename_does_not_reorder_the_discovery_listing() { + // Relabelling is not activity. If a rename bumped `updated_at` the + // renamed row would jump its peers, and if it bumped `selected_at` a + // bare `ai-memory run` would silently resume a different workstream — + // both as a side effect of fixing a typo. + // + // Three rows, not two: `is_current` leads the ORDER BY, so the + // current workstream sorts first whatever its timestamps say. Only a + // pair of non-current rows can show an `updated_at` bump at all. + let tmp = TempDir::new().unwrap(); + let store = Store::open(tmp.path()).unwrap(); + let (ws, proj) = open_managed_scope(&store, "proj").await; + seed_workstreams(&store, ws, proj, &["oldest", "middle", "current"]).await; + + async fn listing(store: &Store, ws: WorkspaceId, proj: ProjectId) -> Vec<(String, bool)> { + store + .reader + .recent_workstreams(ws, proj, "repo".into(), "worktree".into(), 20) + .await + .unwrap() + .into_iter() + .map(|row| (row.name, row.current)) + .collect() + } + let before = listing(&store, ws, proj).await; + assert_eq!( + before, + [ + ("current".to_owned(), true), + ("middle".to_owned(), false), + ("oldest".to_owned(), false), + ] + ); + + store + .writer + .rename_workstream(rename_input( + ws, + proj, + WorkstreamSelector::Name("oldest".into()), + "oldest-renamed", + )) + .await + .unwrap(); + + // `oldest-renamed` must stay behind `middle`: it was renamed, not + // worked on. A bumped `updated_at` would put it second. + assert_eq!( + listing(&store, ws, proj).await, + [ + ("current".to_owned(), true), + ("middle".to_owned(), false), + ("oldest-renamed".to_owned(), false), + ] + ); + + // Renaming the current workstream must not hand `current` to anyone + // else either, which a bumped `selected_at` on the wrong row would do. + store + .writer + .rename_workstream(rename_input( + ws, + proj, + WorkstreamSelector::Name("oldest-renamed".into()), + "oldest-again", + )) + .await + .unwrap(); + let after = listing(&store, ws, proj).await; + assert_eq!(after[0], ("current".to_owned(), true)); + assert_eq!(after[2].0, "oldest-again"); + } + + #[tokio::test] + async fn rename_refuses_a_name_another_workstream_holds() { + let tmp = TempDir::new().unwrap(); + let store = Store::open(tmp.path()).unwrap(); + let (ws, proj) = open_managed_scope(&store, "proj").await; + seed_workstreams(&store, ws, proj, &["alpha", "beta"]).await; + + let failure = store + .writer + .rename_workstream(rename_input( + ws, + proj, + WorkstreamSelector::Name("alpha".into()), + "beta", + )) + .await + .unwrap_err(); + assert!( + matches!(&failure, StoreError::WorkstreamNameTaken(name) if name == "beta"), + "expected a named collision, got {failure:?}" + ); + + // Renaming onto its own current name writes nothing and is not an + // error, so a repeated command stays safe. + let noop = store + .writer + .rename_workstream(rename_input( + ws, + proj, + WorkstreamSelector::Name("alpha".into()), + "alpha", + )) + .await + .unwrap(); + assert_eq!((noop.from.as_str(), noop.to.as_str()), ("alpha", "alpha")); + } + + #[tokio::test] + async fn rename_cannot_reach_a_workstream_outside_the_resolved_scope() { + // `workstreams.id` is globally unique, so the id selector would be a + // cross-scope write primitive without the checkout predicate. + let tmp = TempDir::new().unwrap(); + let store = Store::open(tmp.path()).unwrap(); + let (mine, mine_proj) = open_managed_scope(&store, "shared-name").await; + let theirs = store + .writer + .get_or_create_workspace("other-workspace") + .await + .unwrap(); + let theirs_proj = store + .writer + .get_or_create_project(theirs, "shared-name", None) + .await + .unwrap(); + let theirs_ids = seed_workstreams(&store, theirs, theirs_proj, &["theirs"]).await; + let theirs_workstream = theirs_ids[0].0; + seed_workstreams(&store, mine, mine_proj, &["mine"]).await; + + let by_id = store + .writer + .rename_workstream(rename_input( + mine, + mine_proj, + WorkstreamSelector::Id(theirs_workstream), + "stolen", + )) + .await + .unwrap_err(); + assert!( + matches!(by_id, StoreError::NotFound(_)), + "an id from another workspace must read as absent, got {by_id:?}" + ); + + let by_name = store + .writer + .rename_workstream(rename_input( + mine, + mine_proj, + WorkstreamSelector::Name("theirs".into()), + "stolen", + )) + .await + .unwrap_err(); + assert!(matches!(by_name, StoreError::NotFound(_))); + + // The other workspace's row is untouched by either attempt. + let theirs_names: Vec = store + .reader + .recent_workstreams(theirs, theirs_proj, "repo".into(), "worktree".into(), 20) + .await + .unwrap() + .into_iter() + .map(|row| row.name) + .collect(); + assert_eq!(theirs_names, ["theirs"]); + } + + #[tokio::test] + async fn rename_rejects_a_name_that_run_new_would_reject() { + let tmp = TempDir::new().unwrap(); + let store = Store::open(tmp.path()).unwrap(); + let (ws, proj) = open_managed_scope(&store, "proj").await; + seed_workstreams(&store, ws, proj, &["alpha"]).await; + + for bad in ["", " ", "has/slash", "has\\backslash", "ctrl\u{1}char"] { + let failure = store + .writer + .rename_workstream(rename_input( + ws, + proj, + WorkstreamSelector::Name("alpha".into()), + bad, + )) + .await + .unwrap_err(); + assert!( + matches!(failure, StoreError::InvalidState(_)), + "name {bad:?} should be rejected, got {failure:?}" + ); + } + } + #[tokio::test] async fn prepare_expires_a_stale_lease_and_reopens_the_workstream() { let tmp = TempDir::new().unwrap(); diff --git a/crates/ai-memory-store/src/workstream.rs b/crates/ai-memory-store/src/workstream.rs index f9ad9a63..c45141cc 100644 --- a/crates/ai-memory-store/src/workstream.rs +++ b/crates/ai-memory-store/src/workstream.rs @@ -160,6 +160,48 @@ pub struct StoredWorkstreamSummary { pub linked_harnesses: Vec, } +/// How a rename addresses the workstream it retitles. +/// +/// Both forms stay inside the caller's resolved scope: an id that belongs to +/// another workspace, project, or worktree is treated as absent rather than +/// renamed, so a guessed identifier cannot reach across the scope boundary +/// that `workstreams` is keyed on. +#[derive(Debug, Clone)] +pub enum WorkstreamSelector { + /// Address by current name, unique within one checkout. + Name(String), + /// Address by stable id, as printed by the discovery listing. + Id(WorkstreamId), +} + +/// Store-level input for retitling one managed workstream. +#[derive(Debug, Clone)] +pub struct RenameWorkstream { + /// Workspace holding the workstream. + pub workspace_id: WorkspaceId, + /// Project holding the workstream. + pub project_id: ProjectId, + /// Stable repository identity hash. + pub repo_fingerprint: String, + /// Stable worktree identity hash. + pub worktree_fingerprint: String, + /// Which workstream to retitle. + pub selector: WorkstreamSelector, + /// Replacement name, validated exactly like a `--new` name. + pub new_name: String, +} + +/// Outcome of a successful rename. +#[derive(Debug, Clone)] +pub struct RenamedWorkstream { + /// The workstream that was retitled. + pub workstream_id: WorkstreamId, + /// Name before the rename. + pub from: String, + /// Name after the rename. + pub to: String, +} + struct FinishRunRow { workstream: Vec, agent_wire: String, @@ -879,6 +921,107 @@ pub(crate) fn list_recent( Ok(summaries) } +/// Retitle one checkout-local workstream. +/// +/// Names are metadata: `workstream_events`, `managed_runs`, and +/// `workstream_native_sessions` all key on `workstreams.id`, so a rename is a +/// single-row update with nothing to cascade. `selected_at` and `updated_at` +/// are deliberately left alone — relabelling is not activity, and bumping +/// either would reorder the discovery listing (and, for `selected_at`, change +/// which workstream a bare `ai-memory run` resumes) as a side effect of +/// fixing a typo. +/// +/// A rename onto the workstream's own current name succeeds without writing, +/// so a repeated command is not an error. A rename onto a name another +/// workstream in the same checkout already holds is refused before the +/// update, turning the `UNIQUE` constraint into a named error rather than a +/// bare SQLite failure. +pub(crate) fn rename( + conn: &mut Connection, + input: &RenameWorkstream, +) -> StoreResult { + validate_workstream_name(&input.new_name)?; + let new_name = input.new_name.trim(); + let tx = conn.transaction()?; + let found = match &input.selector { + WorkstreamSelector::Name(name) => tx + .query_row( + "SELECT id, name FROM workstreams \ + WHERE workspace_id = ?1 AND project_id = ?2 \ + AND repo_fingerprint = ?3 AND worktree_fingerprint = ?4 AND name = ?5", + params![ + input.workspace_id.as_bytes(), + input.project_id.as_bytes(), + input.repo_fingerprint, + input.worktree_fingerprint, + name, + ], + |row| Ok((row.get::<_, Vec>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?, + // The scope predicate stays on the id lookup too: `workstreams.id` is + // globally unique, so without it a caller holding an id from another + // checkout could retitle a workstream their request never named. + WorkstreamSelector::Id(id) => tx + .query_row( + "SELECT id, name FROM workstreams \ + WHERE id = ?1 AND workspace_id = ?2 AND project_id = ?3 \ + AND repo_fingerprint = ?4 AND worktree_fingerprint = ?5", + params![ + id.as_bytes(), + input.workspace_id.as_bytes(), + input.project_id.as_bytes(), + input.repo_fingerprint, + input.worktree_fingerprint, + ], + |row| Ok((row.get::<_, Vec>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?, + }; + let (id_bytes, current_name) = found.ok_or_else(|| { + StoreError::NotFound(match &input.selector { + WorkstreamSelector::Name(name) => format!("managed workstream '{name}'"), + WorkstreamSelector::Id(id) => format!("managed workstream {id}"), + }) + })?; + let workstream_id = WorkstreamId::from_slice(&id_bytes)?; + if current_name == new_name { + return Ok(RenamedWorkstream { + workstream_id, + from: current_name.clone(), + to: current_name, + }); + } + let taken: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM workstreams \ + WHERE workspace_id = ?1 AND project_id = ?2 \ + AND repo_fingerprint = ?3 AND worktree_fingerprint = ?4 \ + AND name = ?5 AND id IS NOT ?6)", + params![ + input.workspace_id.as_bytes(), + input.project_id.as_bytes(), + input.repo_fingerprint, + input.worktree_fingerprint, + new_name, + workstream_id.as_bytes(), + ], + |row| row.get(0), + )?; + if taken { + return Err(StoreError::WorkstreamNameTaken(new_name.to_string())); + } + tx.execute( + "UPDATE workstreams SET name = ?1 WHERE id = ?2", + params![new_name, workstream_id.as_bytes()], + )?; + tx.commit()?; + Ok(RenamedWorkstream { + workstream_id, + from: current_name, + to: new_name.to_string(), + }) +} + /// Reader-side context range assigned to one managed run. pub(crate) fn run_context( conn: &Connection, diff --git a/crates/ai-memory-store/src/writer.rs b/crates/ai-memory-store/src/writer.rs index c6da36c9..46feb189 100644 --- a/crates/ai-memory-store/src/writer.rs +++ b/crates/ai-memory-store/src/writer.rs @@ -33,6 +33,7 @@ use crate::session_consolidation::SessionConsolidationJob; use crate::users::{self, TOKEN_HASH_LEN}; use crate::workstream::{ FinishWorkstreamRun, FinishedWorkstreamRun, PrepareWorkstreamRun, PreparedWorkstreamRun, + RenameWorkstream, RenamedWorkstream, }; /// Result of atomically claiming the startup context assembled for one hook. @@ -480,6 +481,10 @@ pub(crate) enum WriteCmd { input: FinishWorkstreamRun, reply: oneshot::Sender>, }, + RenameWorkstream { + input: RenameWorkstream, + reply: oneshot::Sender>, + }, Shutdown, } @@ -1911,6 +1916,18 @@ impl WriterHandle { rx.await.map_err(|_| StoreError::WriterClosed)? } + /// Retitle one checkout-local workstream, leaving its selection and + /// activity timestamps untouched. + pub async fn rename_workstream( + &self, + input: RenameWorkstream, + ) -> StoreResult { + let (tx, rx) = oneshot::channel(); + self.send(WriteCmd::RenameWorkstream { input, reply: tx }) + .await?; + rx.await.map_err(|_| StoreError::WriterClosed)? + } + async fn send(&self, cmd: WriteCmd) -> StoreResult<()> { self.inner .tx @@ -2650,6 +2667,10 @@ fn worker_loop(mut conn: Connection, mut rx: mpsc::Receiver) { let result = crate::workstream::finish_run(&mut conn, &input); send_or_warn(reply, result, "finish_workstream_run"); } + WriteCmd::RenameWorkstream { input, reply } => { + let result = crate::workstream::rename(&mut conn, &input); + send_or_warn(reply, result, "rename_workstream"); + } } } tracing::debug!("writer thread exiting cleanly"); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 707dc362..0b01e61e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -458,6 +458,7 @@ rename-project move-project move-session uninstall auth user completions handoffs purge-session compact +rename-workstream ``` Run `ai-memory --help` for the full tree. diff --git a/docs/install.md b/docs/install.md index 9b58f787..2a545b51 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1796,6 +1796,7 @@ docker run --rm akitaonrails/ai-memory:latest --help # full subcommand tree | `show [--json]` | host wrapper or native binary | Choose a client-local checkout and installed managed harness, or return structured discovery data without launching; remote servers never provide checkout paths | | `continue [--workspace NAME]` | host wrapper or native binary | From any directory, revalidate and resume the newest client-local managed checkout; accepts `--yolo` and `--fresh` but no harness-native arguments | | `workstreams [--workspace NAME] [--project NAME] [--limit N] [--json]` | host wrapper or native binary | List recent workstreams selectable from the current checkout, including current selection and linked harnesses, without exposing paths or native session ids | +| `rename-workstream (--from NAME \| --workstream-id ID) --to NAME [--workspace NAME] [--project NAME] [--json]` | host wrapper or native binary | Retitle one workstream selectable from the current checkout; metadata only, so the stable id, the ledger, the listing order, and the current selection are unaffected | | `workstream-search [query]` | managed child or thin HTTP client | Search the complete visible managed-workstream ledger; the managed child receives its workstream id automatically | | `status` | `docker exec` | Counts, paths, derived-index diagnostics, and passive LLM/embedding provider health | | `search ""` | `docker exec` | Wiki FTS5 search + bounded source authority; use MCP `memory_query` for entity/graph/vector RRF | @@ -2088,14 +2089,14 @@ warns that relabeling system directories such as `/home` can make the host inoperable. Docker documents `label=disable` in the [`docker run` security options](https://docs.docker.com/reference/cli/docker/container/run/#security-opt). -`ai-memory run`, `ai-memory show`, `ai-memory continue`, and -`ai-memory workstreams` are the exceptions: the current wrapper intercepts them -and starts a cached checksum-verified native client on the host, where local -checkouts, harness executables, and session stores exist. It preserves an -explicit remote `AI_MEMORY_SERVER_URL`. If one of these commands logs -`data_dir=/data`, cannot find a checkout, or cannot find `codex`, `claude`, or -another host executable, refresh the stale wrapper with `ai-memory upgrade` on -that client machine. +`ai-memory run`, `ai-memory show`, `ai-memory continue`, +`ai-memory workstreams`, and `ai-memory rename-workstream` are the exceptions: +the current wrapper intercepts them and starts a cached checksum-verified native +client on the host, where local checkouts, harness executables, and session +stores exist. It preserves an explicit remote `AI_MEMORY_SERVER_URL`. If one of +these commands logs `data_dir=/data`, cannot find a checkout, or cannot find +`codex`, `claude`, or another host executable, refresh the stale wrapper with +`ai-memory upgrade` on that client machine. ### Docker compose alternative diff --git a/docs/managed-workstreams.md b/docs/managed-workstreams.md index dc0c8920..9410d814 100644 --- a/docs/managed-workstreams.md +++ b/docs/managed-workstreams.md @@ -64,6 +64,23 @@ activity. Each row includes the linked harnesses and stable workstream id; the response does not expose checkout paths, repository fingerprints, or native session ids. +Names are chosen at `--new` time and can be corrected later: + +```bash +ai-memory rename-workstream --from typo-nmae --to refactor-db +ai-memory rename-workstream --workstream-id 01a04092-… --to refactor-db +``` + +The two selectors are mutually exclusive; the id is the one `workstreams` +prints. The rename is metadata only. Names are unique per checkout, so a +destination another workstream already holds is refused rather than merged, +and the destination is validated exactly like a `--new` name. Because the +ledger, linked harnesses, and managed runs all key on the workstream id rather +than its name, nothing else moves — including which workstream a bare +`ai-memory run` resumes, and the listing order, both of which stay put because +a rename deliberately does not touch `selected_at` or `updated_at`. A run that +is already live keeps displaying the name it launched with until it exits. + ## Project-first launcher `ai-memory show` reverses the usual `cd` then `run` flow: choose a local diff --git a/scripts/check-native-packaging.sh b/scripts/check-native-packaging.sh index 7203618e..cbc68d1e 100755 --- a/scripts/check-native-packaging.sh +++ b/scripts/check-native-packaging.sh @@ -100,10 +100,14 @@ main() { AI_MEMORY_DOCKER="${fake_docker}" AI_MEMORY_NATIVE_BIN="${fake_native}" \ AI_MEMORY_WRAPPER_TEST_LOG="${wrapper_log}" \ bin/ai-memory workstreams --limit 5 --json + AI_MEMORY_DOCKER="${fake_docker}" AI_MEMORY_NATIVE_BIN="${fake_native}" \ + AI_MEMORY_WRAPPER_TEST_LOG="${wrapper_log}" \ + bin/ai-memory rename-workstream --from typo-nmae --to refactor-db assert_contains "${wrapper_log}" "run codex --yolo" assert_contains "${wrapper_log}" "show --json --no-scan" assert_contains "${wrapper_log}" "continue --workspace work --yolo" assert_contains "${wrapper_log}" "workstreams --limit 5 --json" + assert_contains "${wrapper_log}" "rename-workstream --from typo-nmae --to refactor-db" log "Creating temporary alternate root" mkdir -p \ diff --git a/scripts/managed-workstream-acceptance.sh b/scripts/managed-workstream-acceptance.sh index a9f9dcfb..c98b6e10 100755 --- a/scripts/managed-workstream-acceptance.sh +++ b/scripts/managed-workstream-acceptance.sh @@ -1244,6 +1244,89 @@ jq -e 'length == 0' <<<"$other_json" >/dev/null || { exit 1 } +# `ai-memory rename-workstream` must correct a name without disturbing anything +# keyed on the workstream. The id is stable, so the ledger, managed runs, and +# linked native sessions follow the rename, and neither the listing order nor +# the workstream a bare `ai-memory run` resumes may move as a side effect of +# relabelling. +before_rename_json=$workstreams_json +adopt_id=$(jq -r '.[] | select(.name == "edge-adopt") | .workstream_id' \ + <<<"$workstreams_json") +rename_json=$(cd "$REPO" && "$BIN" --data-dir "$DATA" rename-workstream \ + --from edge-adopt --to edge-adopt-fixed --json) +jq -e --arg id "$adopt_id" \ + '.workstream_id == $id and .from == "edge-adopt" and .to == "edge-adopt-fixed"' \ + <<<"$rename_json" >/dev/null || { + printf 'rename-workstream did not report the retitled workstream\n' >&2 + printf '%s\n' "$rename_json" >&2 + exit 1 +} +renamed_json=$(cd "$REPO" && "$BIN" --data-dir "$DATA" workstreams --json) +jq -e --arg id "$adopt_id" \ + '.[0].current and .[0].name == "edge-adopt-fixed" and .[0].workstream_id == $id' \ + <<<"$renamed_json" >/dev/null || { + printf 'rename moved the listing order or the current selection\n' >&2 + printf '%s\n' "$renamed_json" >&2 + exit 1 +} +jq -e '[.[] | select(.name == "edge-adopt")] | length == 0' \ + <<<"$renamed_json" >/dev/null || { + printf 'the name the rename replaced survived in the listing\n' >&2 + printf '%s\n' "$renamed_json" >&2 + exit 1 +} + +# A name another workstream in this checkout already holds is refused, as is a +# name `run --new` would reject. Both must fail before writing anything. +if (cd "$REPO" && "$BIN" --data-dir "$DATA" rename-workstream \ + --from edge-kimi --to edge-adopt-fixed) >/dev/null 2>&1; then + printf 'rename-workstream took a name another workstream already holds\n' >&2 + exit 1 +fi +if (cd "$REPO" && "$BIN" --data-dir "$DATA" rename-workstream \ + --from edge-kimi --to 'nested/name') >/dev/null 2>&1; then + printf 'rename-workstream took a name run --new would reject\n' >&2 + exit 1 +fi + +# The stable id is unique across every checkout, so the scope predicate has to +# repeat on the id selector: an id belonging to another worktree must read as +# absent rather than as a renamable target. +if (cd "$OTHER_REPO" && "$BIN" --data-dir "$DATA" rename-workstream \ + --project "$(basename "$REPO")" --workstream-id "$adopt_id" \ + --to hijacked) >/dev/null 2>&1; then + printf 'rename-workstream reached a workstream outside the calling checkout\n' >&2 + exit 1 +fi + +# Renaming to the name it already carries writes nothing and is not an error. +noop_json=$(cd "$REPO" && "$BIN" --data-dir "$DATA" rename-workstream \ + --from edge-adopt-fixed --to edge-adopt-fixed --json) +jq -e '.from == .to and .to == "edge-adopt-fixed"' <<<"$noop_json" >/dev/null || { + printf 'renaming to the current name was not reported as a no-op\n' >&2 + printf '%s\n' "$noop_json" >&2 + exit 1 +} + +# Renaming back by id has to restore the listing exactly. Anything the refused +# attempts, the no-op, or the rename itself touched -- a bumped `updated_at`, a +# moved selection, a reordered row -- surfaces as a diff here. +rename_back=$(cd "$REPO" && "$BIN" --data-dir "$DATA" rename-workstream \ + --workstream-id "$adopt_id" --to edge-adopt) +grep -q "Renamed workstream 'edge-adopt-fixed' to 'edge-adopt'" \ + <<<"$rename_back" || { + printf 'human rename output did not report both names\n' >&2 + printf '%s\n' "$rename_back" >&2 + exit 1 +} +after_rename_json=$(cd "$REPO" && "$BIN" --data-dir "$DATA" workstreams --json) +[ "$after_rename_json" = "$before_rename_json" ] || { + printf 'a rename round trip left the listing changed\n' >&2 + diff <(printf '%s\n' "$before_rename_json") \ + <(printf '%s\n' "$after_rename_json") >&2 || true + exit 1 +} + if [ "$DETERMINISTIC_ONLY" = 1 ]; then printf 'deterministic managed-workstream acceptance passed\n' exit 0