mirror of
https://github.com/akitaonrails/ai-memory.git
synced 2026-10-02 03:24:46 +08:00
feat(cli): add ai-memory rename-workstream for checkout-local renames (#538)
Workstream names were fixed at `run --new` time and had no correction path, so a typo outlived the work it labelled and the only escape was starting a new workstream and abandoning the ledger attached to the old one. The rename selects by current name or by the stable id `workstreams` prints, resolving the same (workspace, project, repository, worktree) identity `run` selects with. Both selectors repeat the checkout predicate: `workstreams.id` is globally unique, so without it a caller holding an id from another checkout could retitle a workstream their request never named. An id outside the resolved scope reads as absent rather than renamable. It is metadata only. `workstream_events`, `managed_runs`, and `workstream_native_sessions` all key on `workstreams.id`, so the rename is a single-row update with nothing to cascade, and `selected_at` and `updated_at` are deliberately left untouched — relabelling is not activity. Neither the discovery listing order nor the workstream a bare `ai-memory run` resumes moves as a side effect of fixing a typo. 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, turning the UNIQUE constraint into a typed error instead of a bare SQLite failure. Renaming a workstream to the name it already has writes nothing and is not an error. The new `/workstream/rename` route requires NormalWrite rather than the NormalRead its sibling discovery read uses, and resolves scope through `lookup_existing_scope` so a rename never creates the workspace or project it names. The Docker shell wrapper routes the command through its native host client alongside `run`, `show`, `continue`, and `workstreams`, since repository identity is a host resource the helper container cannot see. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: AkitaOnRails <fabioakita@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5
AkitaOnRails
parent
b3dfab8ad7
commit
ff4f260267
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-2
@@ -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)
|
||||
|
||||
@@ -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<String>,
|
||||
/// Project override. Defaults to the current repository project.
|
||||
#[arg(long)]
|
||||
pub project: Option<String>,
|
||||
/// 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<String>,
|
||||
/// 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<ai_memory_core::WorkstreamId>,
|
||||
/// 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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -284,6 +284,42 @@ pub struct ManagedWorkstreamSummary {
|
||||
pub linked_harnesses: Vec<AgentKind>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Stable id of the workstream to retitle, as printed by discovery.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workstream_id: Option<WorkstreamId>,
|
||||
/// 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 {
|
||||
|
||||
@@ -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<WorkstreamState>,
|
||||
level: Option<Extension<AuthLevel>>,
|
||||
Json(request): Json<RenameManagedWorkstreamRequest>,
|
||||
) -> 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<WorkstreamState>,
|
||||
level: Option<Extension<AuthLevel>>,
|
||||
@@ -635,7 +737,9 @@ fn parse_run_id(raw: &str) -> Result<ManagedRunId, ApiFailure> {
|
||||
|
||||
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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<WorkstreamId> = seeded.iter().map(|(id, _)| *id).collect();
|
||||
let run_ids: Vec<ManagedRunId> = 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<String> = 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();
|
||||
|
||||
@@ -160,6 +160,48 @@ pub struct StoredWorkstreamSummary {
|
||||
pub linked_harnesses: Vec<AgentKind>,
|
||||
}
|
||||
|
||||
/// 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<u8>,
|
||||
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<RenamedWorkstream> {
|
||||
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<u8>>(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<u8>>(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,
|
||||
|
||||
@@ -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<StoreResult<FinishedWorkstreamRun>>,
|
||||
},
|
||||
RenameWorkstream {
|
||||
input: RenameWorkstream,
|
||||
reply: oneshot::Sender<StoreResult<RenamedWorkstream>>,
|
||||
},
|
||||
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<RenamedWorkstream> {
|
||||
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<WriteCmd>) {
|
||||
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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
+9
-8
@@ -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 "<query>"` | `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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user