From d53643e75da2d5ba64ade0657908ecfd19cd3f08 Mon Sep 17 00:00:00 2001 From: Edvane Cristina Santos Daltro Date: Tue, 3 Nov 2026 10:46:16 -0400 Subject: [PATCH] Add reviewed CLI rewind and checked snapshot restoration --- README.md | 2 + ROADMAP.md | 2 + crates/cyber-cli/src/commands/mod.rs | 1 + crates/cyber-cli/src/commands/rewind.rs | 140 +++++ crates/cyber-cli/src/commands/sessions.rs | 21 +- crates/cyber-cli/tests/branches_cli.rs | 143 +++++ crates/cyber-server/src/http/openapi.rs | 6 + crates/cyber-server/src/http/sessions.rs | 21 + crates/cyber-server/src/runtime/checkout.rs | 2 +- crates/cyber-server/src/runtime/host.rs | 40 ++ crates/cyber-server/src/runtime/mod.rs | 4 + crates/cyber-server/src/runtime/rewind.rs | 3 + .../src/runtime/rewind_preview.rs | 328 ++++++++++ crates/cyber-server/tests/rewind_preview.rs | 262 ++++++++ crates/cyber-snapshot/Cargo.toml | 2 +- crates/cyber-snapshot/src/git.rs | 94 +++ crates/cyber-snapshot/src/lib.rs | 75 +++ crates/cyber-snapshot/src/restore.rs | 63 +- crates/cyber-snapshot/tests/snapshots.rs | 110 ++++ docs/cli.md | 19 + docs/features.md | 2 +- docs/rewind.md | 57 ++ .../add-session-transcript-search/design.md | 2 +- .../specs/session-search/spec.md | 6 +- .../add-session-transcript-search/tasks.md | 2 +- .../complete-daily-coding-surfaces/design.md | 26 + .../implementation.md | 84 +++ .../specs/snapshots-checkpoints/spec.md | 10 + .../complete-daily-coding-surfaces/tasks.md | 6 +- .../specs/snapshots-checkpoints/spec.md | 4 +- openspec/specs/exec-mode/spec.md | 2 +- openspec/specs/session-runtime/spec.md | 2 +- openspec/specs/snapshots-checkpoints/spec.md | 6 +- sdk/openapi.json | 572 +++++++++++++----- sdk/typescript/src/generated/operations.ts | 42 ++ sdk/typescript/src/generated/types.ts | 48 ++ sdk/typescript/test/rewind.test.ts | 18 + 37 files changed, 2066 insertions(+), 161 deletions(-) create mode 100644 crates/cyber-cli/src/commands/rewind.rs create mode 100644 crates/cyber-server/src/runtime/rewind_preview.rs create mode 100644 crates/cyber-server/tests/rewind_preview.rs create mode 100644 docs/rewind.md create mode 100644 openspec/changes/complete-daily-coding-surfaces/implementation.md create mode 100644 sdk/typescript/test/rewind.test.ts diff --git a/README.md b/README.md index 34d38da44..fe563405a 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ The server owns execution and stores state in SQLite, so work can outlive a term For independent patch attempts, host-recorded tests and a separate read-only judge, start with the [verified `solve` workflow](docs/verified-workflows.md). Candidate patches remain available for human review; nothing is merged automatically. To combine complementary commits, use the [verified integration queue](docs/verified-integrations.md): submit a dependency manifest, test the combined patch, review independent advice, then explicitly publish a branch and PR. +[Reviewed CLI rewind](docs/rewind.md) previews code, conversation or combined rewind with `cyber sessions rewind --to `. Applying requires the reviewed `--confirm `; changed context is refused, unrelated edits are preserved and later messages remain on an inactive branch. Code rewind currently requires Git snapshots. TUI rewind and the non-Git backend remain in progress under [#93](https://github.com/CyberdyneCorp/cybercode-cli/issues/93). + Experimental [native computer use](docs/computer-use.md) adds macOS 14+ application/display screenshots and input with expiring owner grants, CLI/browser owner controls, private evidence and emergency Stop. Off by default; controlled macOS capture, input and Stop acceptance passed. Other layouts and signed distribution remain release checks. The [binary workspace](docs/binary-editor.md) provides paged hex/ASCII viewing, literal hex/UTF-8 search, exact goto, bookmarks, byte copying and an LE/BE inspector. Open it with `cyber hex `, `/hex `, or `x` on an explorer file, without starting inference. Session read permissions apply. On admitted Linux/macOS workspaces, F2 prepares private staged edits with undo/redo; Ctrl+S reviews the exact byte changes, then explicitly saves. Reviewed Restore Last Save and retained draft recovery are available through the owner API/SDK; `cyber hex --session --recover ` reopens a kept manual draft in view mode with fresh F2 edit admission. Authorized Sessions can review shared saved history, and Session diffs show saved binary references independently of Git. The model-facing `binary` tool supports bounded inspect/read/search and exact reviewed patches in isolated invocation drafts. Separate owner routes and SDK helpers discover and reconcile model-operation receipts. Checkpoint/retention/rewind adapters and remaining acceptance work are tracked in [#94](https://github.com/CyberdyneCorp/cybercode-cli/issues/94). diff --git a/ROADMAP.md b/ROADMAP.md index c9e56080b..8799b8d40 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,6 +97,8 @@ The [current inventory](docs/implementation/spec-doc-reconciliation.md) contains **Usage limit waits (`session-runtime`, `configuration`, `exec-mode`, `tui`, `web-client`, P1; `goals`, `loops-scheduling`, `workflows`, P2): Status: implemented** (change [`add-usage-limit-wait`](openspec/changes/add-usage-limit-wait/proposal.md)). An unattended run (`cyber exec`, a Goal, a loop iteration, a workflow agent, a teammate, or their subagents) that hits a subscription usage limit with a known reset waits for it and retries the Turn instead of failing: within `usage_limit.max_wait` (default 6 h), 10 s after the reset plus per-Session jitter, as one new attempt from the same history. The wait is durable (`session.usage_limit.waiting|resumed|abandoned.1`), cancelled by the normal interrupt, resumed after a restart for top-level Sessions and ended cleanly for owned ones; wall deadlines (Session, run, Goal and loop `max_wall_seconds`, `exec --timeout`) win over waiting. `exec` reports `usage_limit` stream-json lines, the TUI offers `/resume-at-reset` and a footer badge, and the dashboard shows the wait. The model fallback chain, once implemented, runs first. +**Daily coding (P1), in progress:** issue [#93](https://github.com/CyberdyneCorp/cybercode-cli/issues/93) is the current priority. The [first rewind increment](docs/rewind.md) adds typed CLI previews and explicit confirmation, fresh snapshot/Session revalidation and branch preservation. TUI rewind/undo/redo, private non-Git snapshots, path rules/context, root structured output and JSONL input remain unfinished. The retry-count contract is reconciled without claiming structured-output implementation. + **Binary hex editor (P1), in progress:** [`add-binary-hex-editor`](openspec/changes/add-binary-hex-editor/proposal.md) now provides the [live binary workspace](docs/binary-editor.md): paged hex/ASCII inspection, literal search, exact goto, bookmarks, LE/BE interpretation and scoped owner API/SDK access. The user prioritized issue #94 on 2026-11-01 ahead of the unfinished plugins/hooks and daily-coding work. The native core and owner API/SDK now provide durable staged patches, draft undo/redo, byte review and explicit recovery on admitted Linux/macOS workspaces. The terminal now stages edits with draft undo/redo and explicit reviewed save/Restore Last Save through the same owner API. Acknowledged saves feed the native FileChanged producer. Authorized Sessions can review the shared saved-version catalog and Session diffs include binary references. The model-facing `binary` tool supports bounded inspect/read/search and expected-digest patches through separate invocation drafts and the retained native save engine. Model-operation discovery/receipt reconciliation stays separate from editable manual handles. Checkpoint/retention/rewind adapters and remaining acceptance are still open; this is not full editor acceptance. **Untrusted-content defense (`untrusted-content`, `permissions-modes`, `system-context`, P1): Status: implemented** (change [`add-untrusted-content-defense`](openspec/changes/add-untrusted-content-defense/proposal.md)). It carries the `workspace-trust` provenance rule through every tool that returns third-party content. `webfetch`, `websearch`, MCP tool results, browser pages and MCP resources are returned in one escape-safe `` frame that replaces the MCP-resource frame. Hidden characters become visible markers, and a deterministic detector flags instruction-like text with a notice in the TUI, `exec` and the dashboard, without dropping content. After a flagged result, risky actions (network egress, writes outside the Location, classifier-allowed commands, secret reads) ask until the next user prompt, or are denied with no user attached; `bypass` shows the notice or refuses when configured. The base prompts cover the frame (version 3), and a labeled fixture set gates recall and false positives, with scripted end-to-end scenarios for secret reads, permission widening and provider redirection. The TUI and dashboard state each notice's consequence from the Location's effective `taint`/`bypass` settings (task 5.3). What remains: acceptance by hand on the real binary. diff --git a/crates/cyber-cli/src/commands/mod.rs b/crates/cyber-cli/src/commands/mod.rs index 3ef8ff8ab..2a7c547bc 100644 --- a/crates/cyber-cli/src/commands/mod.rs +++ b/crates/cyber-cli/src/commands/mod.rs @@ -31,6 +31,7 @@ pub mod plugins; pub mod pr; pub mod providers; pub mod review; +mod rewind; pub mod sandbox; pub mod serve; pub mod sessions; diff --git a/crates/cyber-cli/src/commands/rewind.rs b/crates/cyber-cli/src/commands/rewind.rs new file mode 100644 index 000000000..1051bb136 --- /dev/null +++ b/crates/cyber-cli/src/commands/rewind.rs @@ -0,0 +1,140 @@ +//! Owner-reviewed rewind. The default is a non-mutating preview; applying requires +//! the exact fingerprint from a prior review, including in non-interactive scripts. +use clap::ValueEnum; +use cyber_client::Client; +use serde_json::json; + +use super::sessions::{api, clean, encode, resolve}; +use crate::{cli::GlobalArgs, error::CliError, output}; + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum Target { + Code, + Conversation, + Both, +} +impl Target { + fn name(self) -> &'static str { + match self { + Self::Code => "code", + Self::Conversation => "conversation", + Self::Both => "both", + } + } +} + +pub fn fingerprint(value: &str) -> Result { + if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + Ok(value.to_ascii_lowercase()) + } else { + Err("expected the 64-character confirmation fingerprint from a rewind preview".into()) + } +} + +pub async fn run( + client: &Client, + session: &str, + message: &str, + target: Target, + confirmation: Option, + global: &GlobalArgs, +) -> Result<(), CliError> { + let found = resolve(client, session).await?; + let id = found["id"] + .as_str() + .ok_or_else(|| CliError::runtime("Missing Session identity"))?; + let directory = found["directory"] + .as_str() + .ok_or_else(|| CliError::runtime("Missing Session Location"))?; + let mut body = + json!({"message_id":message,"target":target.name(),"expected_directory":directory}); + let applying = confirmation.is_some(); + if let Some(confirmation) = confirmation { + body["confirmation"] = json!(confirmation); + } + let route = if applying { "confirm" } else { "preview" }; + let reply = client + .post(&format!("/sessions/{}/rewind/{route}", encode(id)), body) + .await + .map_err(api)?; + let data = &reply["data"]; + if output::is_json(global.format) { + return output::json(data); + } + if applying { + println!( + "rewound {} before {} ({})", + clean(id), + clean(message), + target.name() + ); + if matches!(target, Target::Code) { + println!("The conversation is unchanged."); + } else { + println!("Later messages remain on an inactive branch through sessions tree/checkout."); + } + return Ok(()); + } + println!( + "Rewind preview: {} before {} ({})", + clean(id), + clean(message), + target.name() + ); + println!("Location: {}", clean(directory)); + if let Some(files) = data["code"]["files"].as_array() { + for file in files { + println!( + "{} {}", + clean(file["status"].as_str().unwrap_or("changed")), + clean(file["file"].as_str().unwrap_or("")) + ); + for line in file["patch"].as_str().unwrap_or("").lines() { + println!("{}", clean(line)); + } + } + for path in data["code"]["skipped"].as_array().into_iter().flatten() { + println!( + "excluded from current snapshot: {}", + clean(path.as_str().unwrap_or("")) + ); + } + } + println!( + "{}", + data["warning"] + .as_str() + .unwrap_or("External effects are not undone.") + ); + for effect in data["effects"].as_array().into_iter().flatten() { + println!( + "retained tool effect: {} {} ({})", + clean(effect["call_id"].as_str().unwrap_or("")), + clean(effect["tool"].as_str().unwrap_or("")), + effect["status"] + ); + } + println!( + "No files or conversation path changed. Review this preview, then repeat with --confirm {}", + data["confirmation"].as_str().unwrap_or("") + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use clap::Parser; + #[test] + fn invalid_targets_and_unreviewed_confirmations_fail_before_startup() { + for tail in [ + vec!["--target", "everything"], + vec!["--confirm", "yes"], + vec!["--confirm", &"x".repeat(64)], + ] { + let mut args = vec!["cyber", "sessions", "rewind", "ses_one", "--to", "msg_one"]; + args.extend(tail); + assert!(crate::cli::Cli::try_parse_from(args).is_err()); + } + assert_eq!(super::fingerprint(&"A".repeat(64)).unwrap(), "a".repeat(64)); + } +} diff --git a/crates/cyber-cli/src/commands/sessions.rs b/crates/cyber-cli/src/commands/sessions.rs index 07c79084e..55cea1e10 100644 --- a/crates/cyber-cli/src/commands/sessions.rs +++ b/crates/cyber-cli/src/commands/sessions.rs @@ -63,6 +63,19 @@ pub enum SessionsCmd { #[arg(long)] exact: bool, }, + /// Preview a rewind before a user message; apply only with its reviewed fingerprint. + Rewind { + /// ID, exact title or unique ID prefix. + session: String, + /// Exact active user-message ID; the boundary itself is excluded on conversation rewind. + #[arg(long)] + to: String, + #[arg(long, value_enum, default_value = "both")] + target: super::rewind::Target, + /// Apply the exact previously reviewed preview; changed context requires a new preview. + #[arg(long, value_parser = super::rewind::fingerprint)] + confirm: Option, + }, /// Set or remove a branch label. Label { /// ID, exact title or unique ID prefix. @@ -106,7 +119,7 @@ pub enum SessionsCmd { Other(Vec), } -const LATER: &[&str] = &["fork", "rewind", "repair-context", "share", "unshare"]; +const LATER: &[&str] = &["fork", "repair-context", "share", "unshare"]; pub fn run(cmd: SessionsCmd, ctx: &Context, global: &GlobalArgs) -> Result<(), CliError> { if let SessionsCmd::Other(args) = &cmd { @@ -180,6 +193,12 @@ async fn dispatch(client: &Client, cmd: SessionsCmd, global: &GlobalArgs) -> Res write_export(&document, format, output.as_deref()) } SessionsCmd::Import { file } => import(client, &file, global).await, + SessionsCmd::Rewind { + session, + to, + target, + confirm, + } => super::rewind::run(client, &session, &to, target, confirm, global).await, SessionsCmd::Other(_) => unreachable!("handled before connecting"), branch => dispatch_branch(client, branch, global).await, } diff --git a/crates/cyber-cli/tests/branches_cli.rs b/crates/cyber-cli/tests/branches_cli.rs index 4e1a8e24c..d0c846475 100644 --- a/crates/cyber-cli/tests/branches_cli.rs +++ b/crates/cyber-cli/tests/branches_cli.rs @@ -339,3 +339,146 @@ fn refuses_while_busy(env: &Env, model: &scripted_model::ScriptedModel, id: &str assert!(gated.wait().unwrap().success()); assert_eq!(env.user_ids(id).len(), 2); } + +fn rewind_preview(env: &Env, session: &str, message: &str, target: &str) -> Value { + json(&env.cyber(&[ + "sessions", "rewind", session, "--to", message, "--target", target, "--format", "json", + ])) +} + +fn confirm_rewind( + env: &Env, + session: &str, + message: &str, + target: &str, + preview: &Value, +) -> Output { + env.cyber(&[ + "sessions", + "rewind", + session, + "--to", + message, + "--target", + target, + "--confirm", + preview["confirmation"].as_str().unwrap(), + "--format", + "json", + ]) +} + +#[test] +fn rewind_preview_is_non_mutating_and_stale_files_refuse_before_restore() { + let model = scripted_model::ScriptedModel::start(reply); + let env = Env::new(&model); + let id = env.exec(None, "WRITE-A"); + env.exec(Some(&id), "WRITE-B"); + std::fs::write(env.root.join("repo/owner.txt"), "owner file\n").unwrap(); + let users = env.user_ids(&id); + let preview = rewind_preview(&env, &id, &users[0], "code"); + assert_eq!(env.file("a.txt").as_deref(), Some("alpha\n")); + assert_eq!(env.file("b.txt").as_deref(), Some("beta\n")); + assert_eq!(env.user_ids(&id), users); + assert_eq!(preview["confirmation"].as_str().unwrap().len(), 64); + let files = preview["code"]["files"].as_array().unwrap(); + assert_eq!(files.len(), 2); + assert!( + files.iter().all(|file| file["file"] != "owner.txt"), + "unrelated owner files must not be shown as proposed deletions" + ); + assert_owner_drift_refused(&env, &id, &users, &preview); + let fresh = rewind_preview(&env, &id, &users[0], "code"); + json(&confirm_rewind(&env, &id, &users[0], "code", &fresh)); + assert_eq!(env.file("a.txt"), None); + assert_eq!(env.file("b.txt"), None); + assert_eq!(env.file("owner.txt").as_deref(), Some("owner change\n")); + assert_eq!( + env.user_ids(&id), + users, + "code-only keeps the active conversation" + ); + exits(&confirm_rewind(&env, &id, &users[0], "code", &fresh), 1); +} + +fn assert_owner_drift_refused(env: &Env, id: &str, users: &[String], preview: &Value) { + std::fs::write(env.root.join("repo/owner.txt"), "owner change\n").unwrap(); + let refused = exits(&confirm_rewind(env, id, &users[0], "code", preview), 1); + assert!(refused.contains("preview changed"), "{refused}"); + assert_eq!(env.file("owner.txt").as_deref(), Some("owner change\n")); + assert_eq!(env.file("a.txt").as_deref(), Some("alpha\n")); + assert_eq!(env.file("b.txt").as_deref(), Some("beta\n")); + assert_eq!(env.user_ids(id), users); + std::fs::write(env.root.join("repo/a.txt"), "conflicting owner edit\n").unwrap(); + let refused = exits( + &env.cyber(&[ + "sessions", "rewind", id, "--to", &users[0], "--target", "code", + ]), + 1, + ); + assert!(refused.contains("RewindConflictError"), "{refused}"); + assert_eq!( + env.file("a.txt").as_deref(), + Some("conflicting owner edit\n") + ); + std::fs::write(env.root.join("repo/a.txt"), "alpha\n").unwrap(); +} + +#[test] +fn confirmed_combined_rewind_keeps_an_inactive_branch_and_rejects_cross_session_review() { + let model = scripted_model::ScriptedModel::start(reply); + let env = Env::new(&model); + let id = env.exec(None, "WRITE-A"); + env.exec(Some(&id), "WRITE-B"); + let users = env.user_ids(&id); + let preview = rewind_preview(&env, &id, &users[1], "both"); + let other = env.exec(None, "another session"); + let other_user = env.user_ids(&other)[0].clone(); + exits( + &confirm_rewind(&env, &other, &other_user, "conversation", &preview), + 1, + ); + json(&confirm_rewind(&env, &id, &users[1], "both", &preview)); + assert_eq!(env.file("a.txt").as_deref(), Some("alpha\n")); + assert_eq!(env.file("b.txt"), None); + assert_eq!(env.user_ids(&id), users[..1]); + let tree = env.tree(&id); + assert!( + tree.iter() + .any(|node| node["id"] == users[1] && node["active"] == false) + ); + let exported = json(&env.cyber(&["sessions", "export", &id, "--all-branches"])); + assert!( + exported.to_string().contains(&users[1]), + "inactive messages remain exportable" + ); +} + +#[test] +fn conversation_rewind_works_without_git_and_stale_context_never_applies() { + let model = scripted_model::ScriptedModel::start(reply); + let env = Env::new(&model); + std::fs::remove_dir_all(env.root.join("repo/.git")).unwrap(); + let id = env.exec(None, "first prompt"); + let first = env.user_ids(&id)[0].clone(); + let refused = exits(&env.cyber(&["sessions", "rewind", &id, "--to", &first]), 2); + assert!(refused.contains("Snapshots are unavailable"), "{refused}"); + let preview = rewind_preview(&env, &id, &first, "conversation"); + assert_eq!(preview["code"], Value::Null); + env.exec(Some(&id), "second prompt"); + let before = env.user_ids(&id); + let refused = exits( + &confirm_rewind(&env, &id, &first, "conversation", &preview), + 1, + ); + assert!(refused.contains("preview changed"), "{refused}"); + assert_eq!(env.user_ids(&id), before); + let fresh = rewind_preview(&env, &id, &first, "conversation"); + json(&confirm_rewind(&env, &id, &first, "conversation", &fresh)); + assert!(env.user_ids(&id).is_empty()); + assert!(!env.tree(&id).is_empty()); + exits( + &confirm_rewind(&env, &id, &first, "conversation", &fresh), + 2, + ); +} diff --git a/crates/cyber-server/src/http/openapi.rs b/crates/cyber-server/src/http/openapi.rs index 273e133e7..be925026e 100644 --- a/crates/cyber-server/src/http/openapi.rs +++ b/crates/cyber-server/src/http/openapi.rs @@ -758,6 +758,12 @@ pub fn operations(g: &mut SchemaGenerator) -> Vec { ) .body::(g) .ok::>(g), + op("post", "/sessions/{sessionID}/rewind/preview", "v1.session.rewindPreview", "Review a rewind without changing files or the active conversation") + .body::(g) + .ok::>(g), + op("post", "/sessions/{sessionID}/rewind/confirm", "v1.session.rewindConfirm", "Revalidate a reviewed fingerprint before owned rewind") + .body::(g) + .ok::>(g), op( "post", "/sessions/{sessionID}/revert/stage", diff --git a/crates/cyber-server/src/http/sessions.rs b/crates/cyber-server/src/http/sessions.rs index 6791368e0..50bd133ff 100644 --- a/crates/cyber-server/src/http/sessions.rs +++ b/crates/cyber-server/src/http/sessions.rs @@ -344,6 +344,8 @@ pub fn routes() -> Router { .route(&format!("{s}/compact"), post(compact)) .route(&format!("{s}/recap"), get(recap).post(generate_recap)) .route(&format!("{s}/rewind"), post(rewind)) + .route(&format!("{s}/rewind/preview"), post(rewind_preview)) + .route(&format!("{s}/rewind/confirm"), post(rewind_confirm)) .route(&format!("{s}/revert/stage"), post(stage)) .route(&format!("{s}/revert/clear"), post(clear)) .route(&format!("{s}/revert/commit"), post(commit)) @@ -1186,6 +1188,25 @@ async fn answer_question( Ok(StatusCode::NO_CONTENT) } +async fn rewind_preview( + State(state): State, + Path(id): Path, + Json(selection): Json, +) -> Result>> { + Ok(Json(Data { + data: state.runtime.rewind_preview(&id, &selection).await?, + })) +} +async fn rewind_confirm( + State(state): State, + Path(id): Path, + Json(confirmation): Json, +) -> Result>> { + Ok(Json(Data { + data: state.runtime.rewind_confirm(&id, &confirmation).await?, + })) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cyber-server/src/runtime/checkout.rs b/crates/cyber-server/src/runtime/checkout.rs index a00fef4b8..833add6ab 100644 --- a/crates/cyber-server/src/runtime/checkout.rs +++ b/crates/cyber-server/src/runtime/checkout.rs @@ -128,7 +128,7 @@ impl Runtime { /// A running Job (a process, a Monitor, a subagent or a workflow) or delegation hands /// its result back into the Session's active path when it ends. What started it may /// be leaving that path, so a checkout fails closed until none runs. - fn ensure_no_background(&self, session_id: &str) -> Result<(), RuntimeError> { + pub(super) fn ensure_no_background(&self, session_id: &str) -> Result<(), RuntimeError> { let jobs = self .jobs(Some(session_id))? .iter() diff --git a/crates/cyber-server/src/runtime/host.rs b/crates/cyber-server/src/runtime/host.rs index cd576562d..224f52ffe 100644 --- a/crates/cyber-server/src/runtime/host.rs +++ b/crates/cyber-server/src/runtime/host.rs @@ -1402,6 +1402,8 @@ pub struct FileDiff { #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum RestoreError { + #[error("Working tree changed since the rewind preview; review a fresh preview")] + Stale, #[error("RewindConflictError: changed since the snapshot and cannot be merged: {}", .0.join(", "))] Conflict(Vec), #[error("{0}")] @@ -1428,6 +1430,44 @@ pub trait Snapshots: Send + Sync { to: &str, ) -> BoxFuture<'_, Result, String>>; + fn reviewed_restore_available(&self) -> bool { + false + } + + /// Plan the actual three-way result, rather than a target/current comparison + /// that would incorrectly list unrelated owner files as deletions. + fn preview_restore( + &self, + _directory: &str, + _target: &str, + _recorded: &str, + _expected: &str, + ) -> BoxFuture<'_, Result, RestoreError>> { + Box::pin(async { + Err(RestoreError::Failed( + "Restore planning is unavailable on this snapshot host".into(), + )) + }) + } + + /// Restore only after rechecking the reviewed current tree under the same + /// workspace mutation lock as the effect. Custom hosts fail closed until they + /// implement this contract; ordinary legacy restore remains unchanged. + fn restore_reviewed( + &self, + _directory: &str, + _target: &str, + _recorded: &str, + _expected: &str, + _authorize: std::sync::Arc Result<(), String> + Send + Sync>, + ) -> BoxFuture<'_, Result, RestoreError>> { + Box::pin(async { + Err(RestoreError::Failed( + "Reviewed restore is unavailable on this snapshot host".into(), + )) + }) + } + /// Make the working tree match `target` for every path that differs between `recorded` /// (the last state the system wrote) and `target`, preserving later edits by three-way /// merge. Returns the paths changed. diff --git a/crates/cyber-server/src/runtime/mod.rs b/crates/cyber-server/src/runtime/mod.rs index 4f9b71e9c..a869db889 100644 --- a/crates/cyber-server/src/runtime/mod.rs +++ b/crates/cyber-server/src/runtime/mod.rs @@ -121,6 +121,10 @@ pub mod deferred_tools; mod model; mod requests; mod rewind; +mod rewind_preview; +pub use rewind_preview::{ + RewindCodePreview, RewindConfirmation, RewindEffect, RewindPreview, RewindSelection, +}; mod routing; mod sandbox_events; pub use sandbox_events::SandboxEvent; diff --git a/crates/cyber-server/src/runtime/rewind.rs b/crates/cyber-server/src/runtime/rewind.rs index d961a5d94..3f84f1066 100644 --- a/crates/cyber-server/src/runtime/rewind.rs +++ b/crates/cyber-server/src/runtime/rewind.rs @@ -143,6 +143,9 @@ impl Inner { .await { Ok(_) => Ok(()), + Err(RestoreError::Stale) => Err(RuntimeError::Conflict( + "Rewind preview changed; review a fresh preview".into(), + )), Err(RestoreError::Conflict(paths)) => Err(RuntimeError::RewindConflict(paths)), Err(RestoreError::Failed(message)) => Err(RuntimeError::Invalid(message)), } diff --git a/crates/cyber-server/src/runtime/rewind_preview.rs b/crates/cyber-server/src/runtime/rewind_preview.rs new file mode 100644 index 000000000..917b6ed4e --- /dev/null +++ b/crates/cyber-server/src/runtime/rewind_preview.rs @@ -0,0 +1,328 @@ +//! Non-mutating owner previews. Confirmation recomputes the complete preview under +//! Location ownership; client-supplied snapshot identities are never trusted. +use super::host::FileDiff; +use super::model::{CallStatus, RevertState, RevertTarget, SessionState}; +use super::rewind::revert_event; +use super::{Inner, RevertPhase, Runtime, RuntimeError}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const MAX_PREVIEW_BYTES: usize = 4 * 1024 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RewindSelection { + pub message_id: String, + pub target: RevertTarget, + /// Exact Session Location selected by the owner, including for conversation-only rewind. + pub expected_directory: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RewindConfirmation { + #[serde(flatten)] + pub selection: RewindSelection, + pub confirmation: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +pub struct RewindCodePreview { + pub baseline: String, + pub target: String, + pub recorded: String, + pub files: Vec, + pub skipped: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +pub struct RewindEffect { + pub call_id: String, + pub tool: String, + pub status: CallStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +pub struct RewindPreview { + pub session_id: String, + pub directory: String, + pub sequence: i64, + pub message_id: String, + pub target: RevertTarget, + pub active_tip: Option, + pub code: Option, + /// Includes recovery calls retained outside the visible conversation branch. + pub effects: Vec, + pub warning: String, + /// Fingerprint of the server-derived preview; not an authentication credential. + pub confirmation: String, +} + +fn stale() -> RuntimeError { + RuntimeError::Conflict("Rewind preview changed; request and review a fresh preview".into()) +} + +fn validate(state: &SessionState, selection: &RewindSelection) -> Result<(), RuntimeError> { + if state.revert.is_some() { + return Err(RuntimeError::Invalid( + "A revert is already staged; commit or clear it before a reviewed rewind".into(), + )); + } + if state.pending(super::Delivery::Steer).next().is_some() + || state.pending(super::Delivery::Queue).next().is_some() + { + return Err(RuntimeError::Busy(state.info.id.clone())); + } + if state.info.directory != selection.expected_directory { + return Err(stale()); + } + if !state + .entries + .iter() + .any(|entry| matches!(entry, super::Entry::User { id, .. } if id == &selection.message_id)) + { + return Err(RuntimeError::Invalid( + "Rewind boundary is not an active user message".into(), + )); + } + Ok(()) +} + +impl Inner { + async fn build_rewind_preview( + &self, + state: &SessionState, + selection: &RewindSelection, + ) -> Result { + validate(state, selection)?; + let code = if selection.target.code() { + Some( + self.rewind_code_preview(state, &selection.message_id) + .await?, + ) + } else { + None + }; + let mut preview = RewindPreview { + session_id: state.info.id.clone(), + directory: state.info.directory.clone(), + sequence: state.last_seq, + message_id: selection.message_id.clone(), + target: selection.target, + active_tip: state.entries.last().map(|entry| entry.id().to_owned()), + code, + effects: state + .calls + .values() + .map(|call| RewindEffect { + call_id: call.call_id.clone(), + tool: call.name.clone(), + status: call.status, + }) + .collect(), + warning: format!( + "{} External effects and unresolved tool/native outcomes are not undone or acknowledged.", + if selection.target == RevertTarget::Code { + "Only eligible local files are rewound; the conversation is unchanged." + } else { + "Later messages leave the active path and remain on an inactive branch." + } + ), + confirmation: String::new(), + }; + let encoded = serde_json::to_vec(&preview) + .map_err(|error| RuntimeError::Invalid(error.to_string()))?; + if encoded.len().saturating_add(128) > MAX_PREVIEW_BYTES { + return Err(RuntimeError::Invalid( + "Rewind preview exceeds the 4 MiB limit".into(), + )); + } + preview.confirmation = format!("{:x}", Sha256::digest(encoded)); + Ok(preview) + } + + async fn rewind_code_preview( + &self, + state: &SessionState, + message_id: &str, + ) -> Result { + let unavailable = || { + RuntimeError::Invalid( + "Snapshots are unavailable for this Location; only the conversation can be rewound" + .into(), + ) + }; + if !self.options.snapshots.reviewed_restore_available() { + return Err(unavailable()); + } + let recorded = state + .revert + .as_ref() + .and_then(|r| r.applied.clone()) + .or_else(|| state.steps.iter().rev().find_map(|step| step.post.clone())) + .ok_or_else(unavailable)?; + // Hash the current tree again even on confirmation: metadata is not identity. + let current = self + .options + .snapshots + .track(&state.info.directory) + .await + .map_err(RuntimeError::Invalid)? + .ok_or_else(unavailable)?; + let target = state + .steps + .iter() + .find(|step| step.user_message_id.as_deref() == Some(message_id)) + .and_then(|step| step.pre.clone()) + .ok_or_else(unavailable)?; + let files = match self + .options + .snapshots + .preview_restore(&state.info.directory, &target, &recorded, ¤t.tree) + .await + { + Ok(files) => files, + Err(super::RestoreError::Stale) => return Err(stale()), + Err(super::RestoreError::Conflict(paths)) => { + return Err(RuntimeError::RewindConflict(paths)); + } + Err(super::RestoreError::Failed(message)) => { + return Err(RuntimeError::Invalid(message)); + } + }; + Ok(RewindCodePreview { + baseline: current.tree, + target, + recorded, + files, + skipped: current.skipped, + }) + } +} + +impl Runtime { + /// Build a review without restoring files, staging a revert, or changing the branch. + pub async fn rewind_preview( + &self, + session_id: &str, + selection: &RewindSelection, + ) -> Result { + self.inner.ensure_idle(session_id)?; + let handle = self.inner.handle(session_id).await?; + self.inner + .with_idle_location(&handle, async { + self.ensure_no_background(session_id)?; + let before = handle.state.lock().await.clone(); + let preview = self.inner.build_rewind_preview(&before, selection).await?; + let current = handle.state.lock().await; + self.inner.ensure_idle(session_id)?; + if current.last_seq != before.last_seq || handle.is_compacting() { + return Err(stale()); + } + Ok(preview) + }) + .await + } + + /// Revalidate before any restore. Hold the Session projection through the owned + /// restore and event commit so a new prompt cannot change its branch mid-effect. + pub async fn rewind_confirm( + &self, + session_id: &str, + confirmation: &RewindConfirmation, + ) -> Result { + if confirmation.confirmation.len() != 64 { + return Err(stale()); + } + self.inner.ensure_idle(session_id)?; + let handle = self.inner.handle(session_id).await?; + self.inner + .with_idle_location(&handle, async { + self.ensure_no_background(session_id)?; + let before = handle.state.lock().await.clone(); + let preview = self + .inner + .build_rewind_preview(&before, &confirmation.selection) + .await?; + if preview.confirmation != confirmation.confirmation { + return Err(stale()); + } + let authority = self.capture_child_admission(session_id)?; + let runtime = self.clone(); + let source = session_id.to_owned(); + let authorize = std::sync::Arc::new(move || { + authority + .verify(&runtime, &source) + .map_err(|error| error.to_string()) + }); + let mut state = handle.state.lock().await; + self.inner.ensure_idle(session_id)?; + self.ensure_no_background(session_id)?; + validate(&state, &confirmation.selection)?; + if state.last_seq != before.last_seq || handle.is_compacting() { + return Err(stale()); + } + let (baseline, applied, diff) = if let Some(code) = &preview.code { + match self + .inner + .options + .snapshots + .restore_reviewed( + &state.info.directory, + &code.target, + &code.recorded, + &code.baseline, + authorize, + ) + .await + { + Ok(_) => {} + Err(super::RestoreError::Stale) => return Err(stale()), + Err(super::RestoreError::Conflict(paths)) => { + return Err(RuntimeError::RewindConflict(paths)); + } + Err(super::RestoreError::Failed(message)) => { + // The host may have partially restored files. Retain the + // native activity fence and existing backup evidence. + handle + .location_uncertain + .store(true, std::sync::atomic::Ordering::SeqCst); + return Err(RuntimeError::Invalid(message)); + } + } + ( + Some(code.baseline.clone()), + Some(code.target.clone()), + code.files.iter().map(|file| file.patch.as_str()).collect(), + ) + } else { + authorize().map_err(RuntimeError::Invalid)?; + (None, None, String::new()) + }; + let revert = RevertState { + message_id: preview.message_id, + target: preview.target, + baseline, + applied, + diff, + }; + let committed = self.inner.commit_locked( + &mut state, + vec![ + revert_event(session_id, &revert, RevertPhase::Stage), + revert_event(session_id, &revert, RevertPhase::Commit), + ], + ); + if let Err(error) = committed { + if preview.code.is_some() { + handle + .location_uncertain + .store(true, std::sync::atomic::Ordering::SeqCst); + } + return Err(error); + } + Ok(revert) + }) + .await + } +} diff --git a/crates/cyber-server/tests/rewind_preview.rs b/crates/cyber-server/tests/rewind_preview.rs new file mode 100644 index 000000000..44fbdae9b --- /dev/null +++ b/crates/cyber-server/tests/rewind_preview.rs @@ -0,0 +1,262 @@ +//! Preview ownership and uncertain native settlement, with a scripted provider and +//! an instrumented snapshot port. Real Git files are covered by branches_cli. +mod support; +use cyber_server::runtime::*; +use futures::future::BoxFuture; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; +use support::*; + +#[derive(Default)] +struct Images { + fail: AtomicBool, + supported: AtomicBool, + restored: AtomicUsize, + gate_diff: AtomicBool, + started: tokio::sync::Notify, + release: tokio::sync::Notify, +} +impl Snapshots for Images { + fn track(&self, _: &str) -> BoxFuture<'_, Result, String>> { + Box::pin(async { + Ok(Some(Snapshot { + tree: "tree_current".into(), + skipped: vec![], + })) + }) + } + fn changed(&self, _: &str, _: &str, _: &str) -> BoxFuture<'_, Result, String>> { + Box::pin(async { Ok(vec![]) }) + } + fn diff(&self, _: &str, _: &str, _: &str) -> BoxFuture<'_, Result, String>> { + Box::pin(async move { + if self.gate_diff.swap(false, Ordering::SeqCst) { + self.started.notify_one(); + self.release.notified().await; + } + Ok(vec![]) + }) + } + fn restore( + &self, + _: &str, + _: &str, + _: &str, + ) -> BoxFuture<'_, Result, RestoreError>> { + panic!("confirmed rewind must never fall back to unchecked legacy restore") + } + fn reviewed_restore_available(&self) -> bool { + self.supported.load(Ordering::SeqCst) + } + fn preview_restore( + &self, + directory: &str, + target: &str, + _: &str, + expected: &str, + ) -> BoxFuture<'_, Result, RestoreError>> { + let diff = self.diff(directory, expected, target); + Box::pin(async move { diff.await.map_err(RestoreError::Failed) }) + } + fn restore_reviewed( + &self, + _: &str, + _: &str, + _: &str, + _: &str, + authorize: Arc Result<(), String> + Send + Sync>, + ) -> BoxFuture<'_, Result, RestoreError>> { + Box::pin(async move { + authorize().map_err(RestoreError::Failed)?; + self.restored.fetch_add(1, Ordering::SeqCst); + if self.fail.load(Ordering::SeqCst) { + Err(RestoreError::Failed( + "native effect lost acknowledgement".into(), + )) + } else { + Ok(vec![]) + } + }) + } +} +fn runtime_with_images(h: &Harness, images: Arc) -> Runtime { + Runtime::new(RuntimeOptions { + store: h.store.clone(), + resolver: h.models.clone(), + tools: h.tools.clone(), + global_config_dir: h.repo.join("global"), + shell: "bash".into(), + claude_compat: false, + compaction: CompactionConfig::default(), + retry: cyber_llm::RetryPolicy::default(), + max_steps: None, + today: None, + interactive: true, + snapshots: images, + }) +} +async fn seed(h: &Harness, runtime: &Runtime) -> (String, RewindSelection) { + let id = runtime + .create_session(CreateSession { + directory: h.repo.display().to_string(), + model: "test/main".into(), + ..Default::default() + }) + .await + .unwrap() + .id; + let message_id = runtime + .admit(&id, Admission::text("first", Delivery::Queue)) + .await + .unwrap() + .message_id; + runtime.wait_idle(&id).await; + ( + id, + RewindSelection { + message_id, + target: RevertTarget::Both, + expected_directory: h.repo.display().to_string(), + }, + ) +} +fn harness() -> Harness { + Harness::new(Setup { + scripts: vec![("test/main", vec![text("one"), text("two")])], + ..Default::default() + }) +} + +#[tokio::test] +async fn failed_native_ack_keeps_activity_unknown_without_moving_the_branch() { + let h = harness(); + let images = Arc::new(Images::default()); + images.supported.store(true, Ordering::SeqCst); + images.fail.store(true, Ordering::SeqCst); + let runtime = runtime_with_images(&h, images.clone()); + let (id, selection) = seed(&h, &runtime).await; + let before = runtime.state(&id).await.unwrap(); + let preview = runtime.rewind_preview(&id, &selection).await.unwrap(); + assert!( + runtime + .rewind_confirm( + &id, + &RewindConfirmation { + selection, + confirmation: preview.confirmation + } + ) + .await + .is_err() + ); + assert_eq!(images.restored.load(Ordering::SeqCst), 1); + let after = runtime.state(&id).await.unwrap(); + assert_eq!(after.last_seq, before.last_seq); + assert_eq!(after.entries, before.entries); + let status: String = h.store.read(move |db| { + Ok(db.query_row("SELECT json_extract(data,'$.status') FROM event WHERE type='native.activity.changed.1' AND json_extract(data,'$.session_id')=?1 ORDER BY rowid DESC LIMIT 1", [id], |row| row.get(0))?) + }).unwrap(); + assert_eq!( + status, "unknown", + "possible native effects cannot be settled as failure" + ); +} + +#[tokio::test] +async fn unsupported_checked_restore_is_refused_but_conversation_and_scope_checks_work() { + let h = harness(); + let images = Arc::new(Images::default()); + let runtime = runtime_with_images(&h, images.clone()); + let (id, mut selection) = seed(&h, &runtime).await; + assert!(runtime.rewind_preview(&id, &selection).await.is_err()); + assert_eq!(images.restored.load(Ordering::SeqCst), 0); + selection.target = RevertTarget::Conversation; + let preview = runtime.rewind_preview(&id, &selection).await.unwrap(); + let mut foreign = selection.clone(); + foreign.expected_directory.push_str("/other"); + assert!( + runtime + .rewind_confirm( + &id, + &RewindConfirmation { + selection: foreign, + confirmation: preview.confirmation.clone() + } + ) + .await + .is_err() + ); + runtime + .rewind_confirm( + &id, + &RewindConfirmation { + selection, + confirmation: preview.confirmation, + }, + ) + .await + .unwrap(); + assert!(runtime.state(&id).await.unwrap().entries.is_empty()); + assert_eq!(images.restored.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn input_admitted_while_a_preview_waits_invalidates_the_result() { + let h = harness(); + let images = Arc::new(Images::default()); + images.supported.store(true, Ordering::SeqCst); + let runtime = runtime_with_images(&h, images.clone()); + let (id, selection) = seed(&h, &runtime).await; + images.gate_diff.store(true, Ordering::SeqCst); + let preview = runtime.rewind_preview(&id, &selection); + let race = async { + images.started.notified().await; + let receipt = runtime + .admit(&id, Admission::text("second", Delivery::Queue)) + .await + .unwrap(); + images.release.notify_one(); + receipt.message_id + }; + let (preview, second) = tokio::time::timeout(std::time::Duration::from_secs(10), async { + tokio::join!(preview, race) + }) + .await + .unwrap(); + assert!(preview.is_err()); + runtime.wait_idle(&id).await; + assert!( + runtime + .state(&id) + .await + .unwrap() + .entries + .iter() + .any(|entry| entry.id() == second) + ); + assert_eq!(images.restored.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn reviewed_rewind_does_not_silently_finalize_a_legacy_staged_revert() { + let h = harness(); + let images = Arc::new(Images::default()); + let runtime = runtime_with_images(&h, images); + let (id, mut selection) = seed(&h, &runtime).await; + selection.target = RevertTarget::Conversation; + runtime + .revert_stage(&id, &selection.message_id, selection.target) + .await + .unwrap(); + let before = runtime.state(&id).await.unwrap(); + assert!( + matches!(runtime.rewind_preview(&id, &selection).await, Err(RuntimeError::Invalid(message)) if message.contains("already staged")) + ); + let after = runtime.state(&id).await.unwrap(); + assert_eq!(before.entries, after.entries); + assert_eq!(before.revert, after.revert); + runtime.revert_clear(&id).await.unwrap(); + assert!(runtime.rewind_preview(&id, &selection).await.is_ok()); +} diff --git a/crates/cyber-snapshot/Cargo.toml b/crates/cyber-snapshot/Cargo.toml index 62a304637..beff00fcf 100644 --- a/crates/cyber-snapshot/Cargo.toml +++ b/crates/cyber-snapshot/Cargo.toml @@ -13,7 +13,7 @@ cyber-server = { path = "../cyber-server" } futures = "0.3" serde_json.workspace = true sha1 = "0.10" -tokio = { version = "1", features = ["rt", "process", "time", "sync", "fs", "macros"] } +tokio = { version = "1", features = ["rt", "process", "time", "sync", "fs", "macros", "io-util"] } ulid.workspace = true [dev-dependencies] diff --git a/crates/cyber-snapshot/src/git.rs b/crates/cyber-snapshot/src/git.rs index e697783a7..43e8af77d 100644 --- a/crates/cyber-snapshot/src/git.rs +++ b/crates/cyber-snapshot/src/git.rs @@ -50,6 +50,19 @@ pub(crate) fn git_path(path: &Path) -> PathBuf { path.to_path_buf() } +/// A proposed index entry; `None` removes the path. Used only by read-only restore planning. +pub(crate) struct TreeChange { + pub path: String, + pub image: Option<(String, Vec)>, +} +struct PreviewIndex(PathBuf); +impl Drop for PreviewIndex { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + let _ = std::fs::remove_file(self.0.with_extension("index.lock")); + } +} + impl Shadow { pub fn locate(root: &Path, location: &Path) -> Option { let project = cyber_core::project::identify(location); @@ -279,6 +292,87 @@ impl Shadow { Ok(out) } + /// Construct a proposed tree in a separate index. Neither the source files nor + /// the live shadow index are changed, including when planning is cancelled. + pub(crate) async fn projected_tree( + &self, + current: &str, + changes: Vec, + ) -> Result { + use tokio::io::AsyncWriteExt; + let index = PreviewIndex( + self.git_dir + .join(format!("preview-{}.index", ulid::Ulid::new())), + ); + self.index_command(&index.0, &["read-tree", current]) + .await?; + for change in changes { + match change.image { + None => { + self.index_command( + &index.0, + &["update-index", "--force-remove", "--", &change.path], + ) + .await?; + } + Some((mode, bytes)) => { + let mut child = self + .command(&["hash-object", "-w", "--stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| error.to_string())?; + let mut input = child.stdin.take().ok_or("Missing Git object input")?; + input + .write_all(&bytes) + .await + .map_err(|error| error.to_string())?; + drop(input); + let output = child + .wait_with_output() + .await + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err("Cannot prepare proposed snapshot object".into()); + } + let object = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + self.index_command( + &index.0, + &[ + "update-index", + "--add", + "--cacheinfo", + &mode, + &object, + &change.path, + ], + ) + .await?; + } + } + } + let tree = self.index_command(&index.0, &["write-tree"]).await?; + Ok(String::from_utf8_lossy(&tree).trim().to_owned()) + } + + async fn index_command(&self, index: &Path, args: &[&str]) -> Result, String> { + let output = self + .command(args) + .env("GIT_INDEX_FILE", index) + .output() + .await + .map_err(|error| error.to_string())?; + if output.status.success() { + Ok(output.stdout) + } else { + Err(format!( + "Proposed snapshot index: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) + } + } + /// `(mode, contents)` of a path in a tree, or `None` when absent. pub async fn blob(&self, tree: &str, path: &str) -> Result)>, String> { let listing = self.run(&["ls-tree", "-z", tree, "--", path]).await?; diff --git a/crates/cyber-snapshot/src/lib.rs b/crates/cyber-snapshot/src/lib.rs index c9ff0b187..07727e89b 100644 --- a/crates/cyber-snapshot/src/lib.rs +++ b/crates/cyber-snapshot/src/lib.rs @@ -128,6 +128,81 @@ impl Snapshots for GitSnapshots { }) } + fn reviewed_restore_available(&self) -> bool { + true + } + + fn preview_restore( + &self, + directory: &str, + target: &str, + recorded: &str, + expected: &str, + ) -> BoxFuture<'_, Result, RestoreError>> { + let (directory, target, recorded, expected) = ( + directory.to_owned(), + target.to_owned(), + recorded.to_owned(), + expected.to_owned(), + ); + Box::pin(async move { + let shadow = self + .shadow(&directory) + .ok_or_else(|| RestoreError::Failed("Snapshots are unavailable".into()))?; + let lock = cyber_server::workspace::mutation_lock(&shadow.worktree) + .map_err(|error| RestoreError::Failed(error.to_string()))?; + let _guard = lock.lock().await; + let (ignore, max) = self.options(Path::new(&directory)); + let current = shadow + .track(&ignore, max) + .await + .map_err(RestoreError::Failed)?; + if current.tree != expected { + return Err(RestoreError::Stale); + } + restore::preview(&shadow, &target, &recorded, &expected).await + }) + } + + fn restore_reviewed( + &self, + directory: &str, + target: &str, + recorded: &str, + expected: &str, + authorize: Arc Result<(), String> + Send + Sync>, + ) -> BoxFuture<'_, Result, RestoreError>> { + let (directory, target, recorded, expected) = ( + directory.to_owned(), + target.to_owned(), + recorded.to_owned(), + expected.to_owned(), + ); + Box::pin(async move { + let shadow = self.shadow(&directory).ok_or_else(|| { + RestoreError::Failed("Snapshots are unavailable for this Location".into()) + })?; + let lock = cyber_server::workspace::mutation_lock(&shadow.worktree) + .map_err(|error| RestoreError::Failed(error.to_string()))?; + let _guard = lock.lock().await; + authorize().map_err(RestoreError::Failed)?; + let (ignore, max) = self.options(Path::new(&directory)); + let current = shadow + .track(&ignore, max) + .await + .map_err(RestoreError::Failed)?; + if current.tree != expected { + return Err(RestoreError::Stale); + } + let backups = self + .root() + .join("backups") + .join(ulid::Ulid::new().to_string().to_lowercase()); + restore::restore_authorized(&shadow, &target, &recorded, &backups, authorize.as_ref()) + .await + }) + } + fn restore( &self, directory: &str, diff --git a/crates/cyber-snapshot/src/restore.rs b/crates/cyber-snapshot/src/restore.rs index b380adddb..e57bebda5 100644 --- a/crates/cyber-snapshot/src/restore.rs +++ b/crates/cyber-snapshot/src/restore.rs @@ -33,6 +33,59 @@ pub(crate) async fn restore( recorded: &str, backups: &Path, ) -> Result, RestoreError> { + restore_authorized(shadow, target, recorded, backups, &|| Ok(())).await +} + +pub(crate) async fn restore_authorized( + shadow: &Shadow, + target: &str, + recorded: &str, + backups: &Path, + authorize: &(dyn Fn() -> Result<(), String> + Send + Sync), +) -> Result, RestoreError> { + let plan = plan(shadow, target, recorded, authorize).await?; + apply(shadow, plan, backups, authorize) +} + +pub(crate) async fn preview( + shadow: &Shadow, + target: &str, + recorded: &str, + current: &str, +) -> Result, RestoreError> { + let plan = plan(shadow, target, recorded, &|| Ok(())).await?; + let changes = plan + .into_iter() + .map(|item| crate::git::TreeChange { + path: item.path, + image: match item.next { + Content::Absent => None, + Content::File { bytes, executable } => { + Some((if executable { "100755" } else { "100644" }.into(), bytes)) + } + Content::Link(target) => { + Some((SYMLINK.into(), target.to_string_lossy().as_bytes().to_vec())) + } + }, + }) + .collect(); + let proposed = shadow + .projected_tree(current, changes) + .await + .map_err(RestoreError::Failed)?; + shadow + .diff(current, &proposed) + .await + .map_err(RestoreError::Failed) +} + +async fn plan( + shadow: &Shadow, + target: &str, + recorded: &str, + authorize: &(dyn Fn() -> Result<(), String> + Send + Sync), +) -> Result, RestoreError> { + authorize().map_err(RestoreError::Failed)?; let paths = shadow .changed(recorded, target) .await @@ -56,7 +109,7 @@ pub(crate) async fn restore( if !conflicts.is_empty() { return Err(RestoreError::Conflict(conflicts)); } - apply(shadow, plan, backups) + Ok(plan) } async fn content(shadow: &Shadow, tree: &str, path: &str) -> Result { @@ -195,7 +248,12 @@ async fn merge( Ok((out.status.code() == Some(0)).then_some(out.stdout)) } -fn apply(shadow: &Shadow, plan: Vec, backups: &Path) -> Result, RestoreError> { +fn apply( + shadow: &Shadow, + plan: Vec, + backups: &Path, + authorize: &(dyn Fn() -> Result<(), String> + Send + Sync), +) -> Result, RestoreError> { // Recheck immediately before replacing: a file that moved since planning is a race. let raced: Vec = plan .iter() @@ -207,6 +265,7 @@ fn apply(shadow: &Shadow, plan: Vec, backups: &Path) -> Result - [`delete`](#cyber-sessions-delete): Delete a Session with its child Sessions - [`tree`](#cyber-sessions-tree): The Session's user-message tree: `*` marks the active path - [`checkout`](#cyber-sessions-checkout): Make another branch active; the Session must be idle +- [`rewind`](#cyber-sessions-rewind): Preview a rewind before a user message; apply only with its reviewed fingerprint - [`label`](#cyber-sessions-label): Set or remove a branch label - [`export`](#cyber-sessions-export): Write a Session as JSON (default), Markdown (--format md) or one self-contained HTML page (--format html): its info, messages, tool calls, goals and workflow runs - [`import`](#cyber-sessions-import): Import a JSON export (`cyber sessions export`) as a new Session in this Location, with fresh IDs and its attachments, which are verified before anything is written @@ -404,6 +405,24 @@ cyber sessions checkout [OPTIONS] - `--code`: Also restore files to the branch's last snapshot, keeping your own edits. - `--exact`: Make the message itself the tip; the next prompt branches there. +## `cyber sessions rewind` + +Preview a rewind before a user message; apply only with its reviewed fingerprint + +```text +cyber sessions rewind [OPTIONS] --to +``` + +**Arguments:** + +- ``: ID, exact title or unique ID prefix. + +**Options:** + +- `--to `: Exact active user-message ID; the boundary itself is excluded on conversation rewind. +- `--target `: Values: `code`, `conversation`, `both`. Default: `both`. +- `--confirm `: Apply the exact previously reviewed preview; changed context requires a new preview. + ## `cyber sessions label` Set or remove a branch label diff --git a/docs/features.md b/docs/features.md index f8145f38b..0a6efd86a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -19,7 +19,7 @@ Implemented behavior and remaining scope, reviewed on **2026-10-16** (Cross-sess | Base prompts and output styles | Embedded base prompts chosen by model family (`openai`, `anthropic`, `gemini`, `local`, `default`) from the catalog model, not the provider; built-in styles `default`, `concise`, `explanatory`, `learning` and custom `~/.config/cyber/output-styles/*.md` and `.cyber/output-styles/*.md` (validated names and frontmatter, 32 KiB regular files, project styles held until the checkout is trusted and part of its digest); selection by Session switch, creation, `output_style` config, then `default`, for primary Sessions only; `/output-style` picker, the Settings Config row, `cyber exec --output-style`, `GET /config/output-styles`, `PUT /config/output-style` and `POST /sessions/{id}/output-style`; a family, style or base prompt version change starts a new Context Epoch at the next Turn ([guide](local-core-guide.md#base-prompts-and-output-styles)) | Available; plugin styles, `cyber import` of `.claude/output-styles` and per-agent styles remain | | Safety | Workspace trust, permission rules, protected paths, auto classification with confirmed `/approve`, macOS/Linux sandboxing and Windows AppContainer confinement with named profiles (`deny_read`), the environment policy, credential masking, `Sandbox denied:` reports with one-call escalation, excluded commands, `sandbox.*` events, `cyber sandbox explain/test/run`, the `bypass` eligibility gate (sandbox, container or `--dangerously-bypass-permissions`), org `policy.modes.disable` and the rule dry run (`cyber permissions test`, `GET /permissions/test`) | Available; on Windows, sandboxed Git Bash and PowerShell cannot start in the AppContainer yet ([issue #9](https://github.com/CyberdyneCorp/cybercode-cli/issues/9)), and the Windows proxy and full auto-mode controls remain partial P1 | | Untrusted content | `webfetch`, `websearch`, MCP tool, browser and MCP resource results arrive in one `` frame that content cannot close, with hidden characters shown as `[U+XXXX]` markers and overflow re-reads kept framed; a deterministic injection signal detector (seven kinds, `detector_version` 2) records `untrusted` on `session.tool.settled.1` and admitted messages, with a warning line in the TUI, the web dashboard and `cyber exec` (stderr and stream-json); after a signal the run is tainted until the next user prompt, and egress, external writes, classifier-allowed commands and secret reads ask (or are denied with no user attached, in `dont-ask`, or in `bypass` with `bypass: "deny"`), with taint evidence for the auto classifier; `permissions.untrusted_content` (`taint`, `bypass`, `scan`, `allow_when_tainted`, project layers may only tighten); base prompt guidance (version 3); a labeled fixture set with a recall and false-positive gate and scripted end-to-end scenarios ([guide](local-core-guide.md#untrusted-content)) | Done locally (P1, `add-untrusted-content-defense`); the TUI and dashboard state each notice's consequence from the Location's effective `taint`/`bypass` (`untrusted_content` on `GET /sessions/:id`); hand acceptance on the real binary remains | -| File recovery | Shadow-git snapshots and conflict-aware restore preserving user edits | Available; broader rewind UI remains | +| File recovery | Shadow-git snapshots, conflict-aware restore and [reviewed CLI rewind](rewind.md) with explicit fingerprint confirmation | CLI code/conversation/both; TUI rewind and the private non-Git backend remain | | Project collaboration | Self-hosted Git project hub, verified organization/friends membership, explicit published local agents, human/agent project and private conversations, held receiver consent, fenced shared tasks, CLI `/collab` TUI, the `/agents` Project hub group and typed SDK ([guide](collaboration.md), [evidence](implementation/project-collaboration.md)) | Experimental; off by default. Live Auth provisioning and multi-host deployment acceptance remain operator work | | Clients | TUI, noninteractive `exec`, background service, HTTP/SSE/WebSocket/stdio API and generated TypeScript SDK | Available; complete P1 client surfaces remain | | Installation and upgrade | Signed releases from `.github/workflows/release.yml` on a `v*` tag: 8 targets, `SHA256SUMS`, cosign keyless bundles and a rolling `channel-` manifest on GitHub Releases; `install.sh` (POSIX `sh`, SHA-256 and optional cosign checks, atomic install, marked PATH line, `CYBER_INSTALL_BASE_URL` mirrors); `cyber upgrade` (verified, atomic self-replace or the package manager's command), `cyber uninstall` (sizes, confirmation, no symlink following, keyring cleanup), background update notices and the `min_supported` warning; a `Dockerfile` running as uid 10001 with `CI=true` ([guide](local-core-guide.md#install-upgrade-and-uninstall)) | P0 done locally; Homebrew, npm, Scoop, WinGet, AUR, Nix and container-registry publishing are partial (manifests rendered, not published); no release has been tagged yet | diff --git a/docs/rewind.md b/docs/rewind.md new file mode 100644 index 000000000..59ef7e63b --- /dev/null +++ b/docs/rewind.md @@ -0,0 +1,57 @@ +# Reviewed CLI rewind + +`cyber sessions rewind` previews a rewind before applying it. It does not start +inference or interrupt a busy Session automatically. An existing staged revert +must be explicitly committed or cleared before starting this reviewed flow. + +```sh +cyber sessions rewind ses_example --to msg_example --target both +# Review the proposed patches, exclusions and effect disclosure, then: +cyber sessions rewind ses_example --to msg_example --target both --confirm +``` + +Use `--format json` for the typed preview/result. `--to` takes an exact active +user-message ID; Session selection accepts the usual ID, title or unique prefix. +Every target requires explicit confirmation. `code` restores eligible files and +keeps the conversation; `conversation` moves the active path before that message; +`both` does both. Later conversation messages remain reachable through +`cyber sessions tree`, checkout and `export --all-branches`. + +The fingerprint binds the Session, exact Location, sequence, boundary, active tip, +snapshot identities, proposed patches, current exclusions and retained tool status. +A changed context requires a fresh review. Code planning uses the existing +three-way merge rules; unrelated owner files stay intact and are not shown as +proposed deletions. Conflicts are reported before application. Planning uses a +separate shadow Git index and never installs proposed files into the workspace. + +Confirmation obtains native Location ownership, rechecks the current tree under +the workspace mutation lock and refreshes admission before file changes. A failed +native acknowledgement or event commit after a possible effect retains the native +activity fence and backup evidence; it is not a successful rewind. Do not repeat a +mutation to infer its outcome. Inspect the Session and retained recovery evidence. + +Code rewind currently needs an available Git snapshot with an implementing +reviewed-restore host. Non-Git Locations and disabled snapshots support +conversation-only rewind; their code rewind fails visibly. Current snapshot +exclusions are listed; complete historical exclusion reporting remains acceptance +work. Existing one-step owner `/rewind` and revert stage/clear/commit routes retain +their prior compatibility behavior and do not consume these new fingerprints. + +Owner routes, under the normal authenticated server boundary: + +- `POST /api/v1/sessions/{id}/rewind/preview` with `message_id`, `target`, + `expected_directory` returns the preview and `confirmation` fingerprint. +- `POST /api/v1/sessions/{id}/rewind/confirm` takes those selection fields plus the + reviewed fingerprint. No client-supplied snapshot identity is trusted. + +The response is limited to 4 MiB. This bounds the response, not peak Git planning +memory. The TypeScript SDK exposes both generated operations. Fingerprints are +review identities, not authentication credentials. Rewind does not undo remote +calls, sent messages or unknown tool/native outcomes and does not guarantee atomic +replacement against arbitrary external writers. + +This is the first increment of [#93](https://github.com/CyberdyneCorp/cybercode-cli/issues/93). +TUI menus, Esc-Esc and `/undo`/`/redo`, the private non-Git backend and recoverable +restore journal, path rules/context, root structured output and JSONL input remain +unfinished. The retry-count specification reconciliation does not implement root +structured output. diff --git a/openspec/changes/add-session-transcript-search/design.md b/openspec/changes/add-session-transcript-search/design.md index fdcf7bf17..f24a649f9 100644 --- a/openspec/changes/add-session-transcript-search/design.md +++ b/openspec/changes/add-session-transcript-search/design.md @@ -84,7 +84,7 @@ The migration creates the tables and sets `backfill_target = MAX(event.rowid)` a ### D4. Consistency with deletion, revert, retention and restore - **Session deletion** (`runtime/deletion.rs`) deletes the doc and FTS rows of every doomed Session in its existing transaction, before the event rows go. Retention GC deletes archived Sessions through the same path, so it inherits this. -- **Revert commit** (`snapshots-checkpoints` → Three-phase revert) deletes the rows of the deleted messages in its own transaction. A conversation rewind that only moves the active branch keeps the rows. Such a hit is marked `active_path: false`. +- **Revert commit** (`snapshots-checkpoints` → Three-phase revert) preserves the inactive branch and its index rows. A conversation rewind moves the active branch without deleting transcript rows. Such a hit is marked `active_path: false`. - **Fork and import** write ordinary events, so their copies are indexed like any other. A fork's hits belong to the fork. - **Backup** uses the SQLite online backup API, which copies the FTS shadow tables, so a backup carries the index. - **Restore and upgrades.** On open, a database whose `session_search_state.version` differs from the binary's index version (tokenizer, caps or extraction rules changed) has its index rows dropped and the backfill restarted from `rowid` 0. The same happens on restore. This version is separate from migrations, so a rebuild never needs schema surgery. diff --git a/openspec/changes/add-session-transcript-search/specs/session-search/spec.md b/openspec/changes/add-session-transcript-search/specs/session-search/spec.md index b99f1701e..97051ebb1 100644 --- a/openspec/changes/add-session-transcript-search/specs/session-search/spec.md +++ b/openspec/changes/add-session-transcript-search/specs/session-search/spec.md @@ -68,7 +68,7 @@ Before insertion, all text SHALL pass through the secret redaction rules of `obs ### Requirement: Search index consistency (P1) The system SHALL keep the index consistent with the transcript: - Deleting a Session, whether directly, by cascade from its parent or by retention GC, SHALL delete its index rows in the same transaction as its events. -- Committing a conversation revert SHALL delete the index rows of the deleted messages in the revert's transaction. +- Committing a conversation revert SHALL retain index rows for the inactive branch; its hits SHALL carry `active_path: false`. - A conversation rewind that only changes the active branch SHALL keep the rows, and hits on messages off the active path SHALL carry `active_path: false`. - Forks and imports SHALL be indexed from their own events. - A database backup SHALL contain the index. @@ -84,9 +84,9 @@ Before insertion, all text SHALL pass through the secret redaction rules of `obs - **WHEN** retention GC deletes an archived Session older than `storage.retention.archived_days` - **THEN** its index rows are deleted in the same transaction -#### Scenario: Reverted messages leave the index +#### Scenario: Reverted messages remain on an inactive branch - **WHEN** a staged revert of messages 3 to 5 is committed -- **THEN** search results contain no hit on messages 3 to 5 +- **THEN** hits on messages 3 to 5 remain available with `active_path: false` #### Scenario: Restore from an older index version - **WHEN** a backup written with index version 1 is restored by a binary with index version 2 diff --git a/openspec/changes/add-session-transcript-search/tasks.md b/openspec/changes/add-session-transcript-search/tasks.md index 54ba210fc..dd9b79fc1 100644 --- a/openspec/changes/add-session-transcript-search/tasks.md +++ b/openspec/changes/add-session-transcript-search/tasks.md @@ -34,7 +34,7 @@ Tests: `backfill_resumes_after_restart_without_duplicates`, `backfill_waits_on_busy_writer_queue`, `search_during_backfill_reports_progress`. - [ ] 2.2 Deletion: delete the doc and FTS rows inside `runtime/deletion.rs`'s transaction for every doomed Session, which covers retention GC too. - Regression tests: `deleted_session_disappears_from_search`, `child_sessions_rows_deleted_with_parent`, `retention_gc_removes_index_rows`, `delete_during_backfill_leaves_no_rows`. -- [ ] 2.3 Revert commit: delete the rows of the deleted messages, and mark hits off the active path after a conversation rewind as `active_path: false`. +- [ ] 2.3 Revert commit: retain the inactive branch and its index rows, and mark hits off the active path as `active_path: false`. - Tests: `reverted_messages_leave_the_index`, `rewound_branch_hits_are_inactive`. - [ ] 2.4 Index version check on open and after `cyber db restore`: on a mismatch, delete the rows and restart the backfill. - Tests: `restore_from_older_index_version_rebuilds`; `online_backup_carries_index`, which runs `cyber db backup`, opens the copy and searches it. diff --git a/openspec/changes/complete-daily-coding-surfaces/design.md b/openspec/changes/complete-daily-coding-surfaces/design.md index 838fd6bfb..8980d602e 100644 --- a/openspec/changes/complete-daily-coding-surfaces/design.md +++ b/openspec/changes/complete-daily-coding-surfaces/design.md @@ -41,3 +41,29 @@ Emit schema_version 2, one result per admitted input with stable correlation, op ## Delivery and acceptance Implement in order: revert spec reconciliation and CLI/TUI; non-git snapshots; path rules/context inspection; root structured output; JSONL driver. Each increment includes API/SDK/CLI generation, regressions, docs and meaningful production-path acceptance. Use actual temporary Git and non-git files, scripted provider/server process tests, TUI TestBackend plus real captures, crash/race tests, and native checks for filesystem behavior. Perform code/security review before delivery. Existing stale task headings are reconciled only against demonstrated clauses. On 2026-11-03 the user prioritized #93 after #105, superseding the original Plugins/hooks scheduling prerequisite. Start with the approved rewind slice while preserving unresolved cross-workstream dependencies; this scheduling change does not accept incomplete plugin enforcement or select pending policies. The workstream remains incomplete until all approved scope is implemented and the required checks have recorded outcomes. + +### CLI/server preview increment + +The first increment adds `/sessions/{id}/rewind/preview` and `/rewind/confirm`. +Preview snapshots current eligible files without changing source bytes or staging +a revert. Its bounded response includes exact Session/directory/sequence/boundary, +active tip, target/recorded/current snapshot identities, current exclusions, file +patches, retained tool statuses and an external/unknown-effect disclosure. A SHA-256 +fingerprint binds the server-derived response; it is not an authentication token. +Confirmation recomputes the response, verifies fresh idle/scope/admission state and +holds the Session projection through restore and the stage/commit event batch. + +The snapshot port requires a reviewed-restore implementation. Git rechecks the +current tree after acquiring its workspace mutation lock, then uses the existing +conflict-aware restore with fresh admission checks before file writes. Unsupported +custom hosts refuse code preview; conversation-only remains possible. A failed +native acknowledgement or event commit after file effects retains the native +activity fence and backup evidence. The existing one-step `/rewind` and explicit +revert stage/clear/commit routes retain their compatibility behavior; they do not +consume the new fingerprint. Full TUI/interrupt, crash/restart and non-Git backend +acceptance remain subsequent tasks. This increment does not claim arbitrary +external-writer atomicity or a new recoverable restore-journal implementation. + +The CLI defaults to preview, with explicit `--confirm ` for applying +all targets. There is no automatic confirmation or busy-session interruption. +A changed preview requires review again, including when used by a script. An existing legacy staged revert must be explicitly committed or cleared before using the new flow; it is never silently finalized by another reviewed selection. diff --git a/openspec/changes/complete-daily-coding-surfaces/implementation.md b/openspec/changes/complete-daily-coding-surfaces/implementation.md new file mode 100644 index 000000000..63d46f826 --- /dev/null +++ b/openspec/changes/complete-daily-coding-surfaces/implementation.md @@ -0,0 +1,84 @@ +# Daily coding implementation evidence + +## Baseline and priority, 2026-11-03 + +The user prioritized #93 after authorizing #105's merge, superseding the original +Plugins/hooks scheduling dependency. #92 remains incomplete, including its native +admission and pending policy gates. The manual closure of #94 does not accept its +remaining retention/checkpoint/rewind and UX/performance scope. + +The implementation branch starts from unchanged main `a6165c18`, the merge of +#105. Its tree `05da00a0bdb37288ecf2f55b902550c4a0fdb1bf` is identical to tested +head `6cfb7f2b`; all 19 configured checks passed, including Linux/macOS Rust, +Windows build and native binary proofs. Before edits, the focused server +`branch_checkout` regression passed locally (one test). This establishes the +existing branch race baseline, not completion of daily-coding acceptance. + +| Clause | Baseline | First increment / remaining scope | +| --- | --- | --- | +| Rewind | Server stage/clear/commit and branch preservation exist; CLI command reserved | Add typed reviewed CLI and owner preview/confirmation; TUI, composer undo/redo and broader races remain | +| Non-Git snapshots | `NoSnapshots` outside available Git shadow workspaces | Private BLAKE3 backend, cap/retention and recoverable restore journal remain | +| Rules/context | Existing Epoch assembly and basic context endpoint | Path-scoped rule ownership and effective-source inspection remain | +| Root structured output | Existing child result validation; root schema ownership absent | Root per-input validation/finalization/retry implementation remains | +| JSONL | Exec reads a single prompt | Version-2 persistent driver, replies, correlation and draining remain | + +## Reviewed CLI/server rewind + +The new preview does not restore files, stage a revert, change the active branch +or start inference. It returns Session/directory/sequence/boundary/active-tip and +snapshot identities, actual proposed three-way file patches, current exclusions, +retained tool statuses and external/unknown-effect disclosure. The response is +limited to 4 MiB; that is not a bound on existing Git planning memory. + +Planning constructs the proposed merged result in a separate shadow Git index. +Unrelated owner files are preserved and are not falsely shown as deletions. The +source files and live shadow index are unchanged. Conflicts are reported before +confirmation. The fingerprint binds the complete server-derived preview and is +not an authentication credential. + +Confirmation recomputes the preview under idle Location ownership, revalidates +Session state/admission and holds the Session projection through checked restore +and the stage/commit event batch. Git rechecks the current tree after acquiring +its workspace mutation lock and refreshes admission before file writes. Custom +snapshot hosts must implement reviewed planning/restoration or code preview is +refused. Native failures or post-effect event-commit failures retain the native +activity fence and existing backup evidence; a successful transcript projection +is not fabricated. + +The CLI defaults to preview and requires explicit `--confirm ` for +all targets. Conversation-only works without Git snapshots. Existing one-step +owner `/rewind` and explicit revert stage/clear/commit routes retain their prior +compatibility behavior and do not consume the fingerprint. The new flow does not +claim a new restore journal, complete historical exclusion reporting, root +structured-output implementation or arbitrary external-writer atomicity. + +The canonical and overlapping active revert/search specs now preserve inactive +branches and independent recovery evidence. Root retry wording is reconciled to +one initial submission plus two corrections, separately from child results. +Archived historical artifacts are preserved. + +## Local validation and review + +On macOS, the final rewind increment passed four real CLI/server/scripted-provider +branch and rewind scenarios, four instrumented server ownership/recovery tests, +12 real Git snapshot tests, 79 CLI unit tests including generated-help consistency, +SDK typecheck and 128 SDK tests (10 configured external tests skipped), the current +OpenAPI artifact check, CLI/server/snapshot all-target Clippy, formatting and diff +checks. Strict OpenSpec validation passed 114 items; spec lint reported 0 errors +and 25 warnings. No external model provider was used. Linux and Windows execution +of this increment remains for CI; the main baseline results do not replace it. + +The code-review pass identified and resolved: using effectful stage as a preview; +a target/current comparison falsely describing unrelated owner files as deletions; +the workspace-lock gap between checking the reviewed tree and restoring; false +settlement after a possible native effect; and silent finalization of an existing +legacy stage. Regressions cover each corrected behavior. Planning uses the shared +three-way planner with an isolated proposed index; confirmation uses the snapshot +port rather than duplicating filesystem behavior in the CLI/HTTP adapters. + +This completes the implementation portion of task 2.2. Task 2.4 remains open for +broader native crash/restart/interrupt/recovery acceptance; TUI task 2.3 and all +later workstreams remain unfinished. The original one-step owner API remains a +compatibility path, not a newly reviewed surface. The new flow refuses an existing +stage until the owner explicitly commits or clears it. The retained native fence +is evidence of an unknown effect, not an implemented recovery-journal UI. diff --git a/openspec/changes/complete-daily-coding-surfaces/specs/snapshots-checkpoints/spec.md b/openspec/changes/complete-daily-coding-surfaces/specs/snapshots-checkpoints/spec.md index 06f7a0bdb..dfee00cba 100644 --- a/openspec/changes/complete-daily-coding-surfaces/specs/snapshots-checkpoints/spec.md +++ b/openspec/changes/complete-daily-coding-surfaces/specs/snapshots-checkpoints/spec.md @@ -11,6 +11,16 @@ - **WHEN** a file changes after the owner reviews a code-rewind preview - **THEN** applying the old preview refuses or requires a fresh review rather than overwriting that edit +#### Scenario: Explicit CLI confirmation +- **WHEN** an owner runs `cyber sessions rewind --to --target code|conversation|both` +- **THEN** the command returns a preview and fingerprint without restoring files or changing the active path +- **AND** an explicit `--confirm ` revalidates Session sequence, directory, boundary, current snapshot and target before owned restoration, refusing changed context and preserving independent recovery evidence + +#### Scenario: Snapshot host cannot revalidate +- **WHEN** a custom snapshot host cannot check the reviewed current tree under its workspace mutation lock +- **THEN** confirmed code restoration is refused rather than calling an unchecked legacy restore, while conversation-only rewind remains available when otherwise idle + + ### Requirement: Bounded non-git snapshot storage (P1) Non-git snapshots SHALL use private atomic BLAKE3-addressed file objects and Location-bound manifests, respecting ignores and file-size limits without following out-of-scope symlinks. Matching size/mtime SHALL NOT substitute for verified content identity. The default 200 MiB Location cap SHALL disable unavailable code rewind visibly while retaining conversation rewind. Restoration and retention SHALL preserve staged/branch/unknown-effect evidence and use the same conflict and recovery guarantees as Git snapshots. diff --git a/openspec/changes/complete-daily-coding-surfaces/tasks.md b/openspec/changes/complete-daily-coding-surfaces/tasks.md index a1d549fd9..88d40f765 100644 --- a/openspec/changes/complete-daily-coding-surfaces/tasks.md +++ b/openspec/changes/complete-daily-coding-surfaces/tasks.md @@ -3,12 +3,12 @@ - [x] 1.1 Inspect rewind, non-git snapshot, rule/context and structured-output/JSONL contracts and existing reusable machinery at `097b6278`. - [x] 1.2 Draft proposal/design/scenarios and ordered tasks; identify destructive-revert wording and retry-count inconsistencies. - [x] 1.3 Review artifacts with the user, including platform limits, the additive instruction_limits setting, schema/JSONL bounds, multi-input exits and interrupt/EOF semantics. Approved on 2026-10-27: after PRs #53/#54 merged, the user explicitly requested implementation of both merged plans and then instructed goal resume. Native proof, baseline and acceptance gates remain required. -- [ ] 1.4 Record the 2026-11-03 user-approved priority change, establish relevant unchanged-main baseline checks and current clause coverage, and retain unresolved Plugins/hooks dependencies without claiming acceptance. The scheduling prerequisite is superseded; baseline/coverage verification remains required. +- [x] 1.4 Record the 2026-11-03 user-approved priority change, establish relevant unchanged-main baseline checks and current clause coverage, and retain unresolved Plugins/hooks dependencies without claiming acceptance. The scheduling prerequisite is superseded. Baseline and current clause coverage are recorded in implementation.md; incomplete native/plugin guarantees remain explicit. ## 2. Rewind and undo/redo -- [ ] 2.1 Reconcile Three-phase revert, Rewind targets, Session branches and structured retry wording in canonical specs and overlapping deltas without deleting historical recovery evidence. -- [ ] 2.2 Add typed CLI rewind and server preview/revalidation/confirmation using existing owned revert operations; regenerate command docs and API/SDK where needed. +- [x] 2.1 Reconcile Three-phase revert, Rewind targets, Session branches and structured retry wording in canonical specs and overlapping deltas without deleting historical recovery evidence. +- [x] 2.2 Add typed CLI rewind and server preview/revalidation/confirmation using existing owned revert operations; regenerate command docs and API/SDK where needed. - [ ] 2.3 Implement TUI rewind menu, Esc-Esc, `/undo` and `/redo`, composer restoration, explicit busy interruption, conflict/exclusion/effect preview and stale-context fencing. - [ ] 2.4 Test code/conversation/both, snapshots disabled, inactive branches/export, external edits, busy/interrupt races, restage/clear/restart and retained unknown-effect evidence through actual clients. diff --git a/openspec/changes/implement-m0-4-tools-safety/specs/snapshots-checkpoints/spec.md b/openspec/changes/implement-m0-4-tools-safety/specs/snapshots-checkpoints/spec.md index 5c80bf4a2..fa6a2d40d 100644 --- a/openspec/changes/implement-m0-4-tools-safety/specs/snapshots-checkpoints/spec.md +++ b/openspec/changes/implement-m0-4-tools-safety/specs/snapshots-checkpoints/spec.md @@ -1,7 +1,7 @@ ## MODIFIED Requirements ### Requirement: Three-phase revert -(P0) Rewinds affecting the conversation SHALL be staged first: `stage { message_id, target }` restores files (when the target includes code) to their state before the target message's first Turn and records the revert state, `clear` restores the pre-stage working tree, and `commit` makes the revert final. When the target includes the conversation, `commit` SHALL delete the target user message and every message and inbox row after it, while retaining unresolved tool recovery records independently of conversation projection; a compaction whose retained tail starts after the boundary SHALL be discarded. A `code`-only commit SHALL leave the conversation unchanged. A staged revert SHALL be committed automatically on the next prompt, shell command or compaction. Restores that cannot be applied without overwriting later edits SHALL fail with `RewindConflictError` and leave the revert unstaged. The routes SHALL be `POST /api/v1/sessions/:id/revert/{stage,clear,commit}`. +(P0) Rewinds affecting the conversation SHALL be staged first: `stage { message_id, target }` restores files (when the target includes code) to their state before the target message's first Turn and records the revert state, `clear` restores the pre-stage working tree, and `commit` makes the revert final. When the target includes the conversation, `commit` SHALL move the target user message and later messages, with their derived inbox state, onto a retained inactive branch while retaining unresolved tool recovery records independently of conversation projection; a compaction whose retained tail starts after the boundary SHALL be discarded. A `code`-only commit SHALL leave the conversation unchanged. A staged revert SHALL be committed automatically on the next prompt, shell command or compaction. Restores that cannot be applied without overwriting later edits SHALL fail with `RewindConflictError` and leave the revert unstaged. The routes SHALL be `POST /api/v1/sessions/:id/revert/{stage,clear,commit}`. #### Scenario: Undo a staged revert - **WHEN** a revert is staged and the user calls `clear` @@ -9,7 +9,7 @@ #### Scenario: Commit removes the boundary message - **WHEN** the user stages `both` at message 3 and commits -- **THEN** message 3 and everything after it are gone and files match their state before message 3's Turns +- **THEN** message 3 and everything after it leave the active path and remain on an inactive branch, and files match their state before message 3's Turns #### Scenario: Next prompt commits - **WHEN** a `conversation` revert is staged and the user sends a new prompt diff --git a/openspec/specs/exec-mode/spec.md b/openspec/specs/exec-mode/spec.md index 2209c0734..276dc095e 100644 --- a/openspec/specs/exec-mode/spec.md +++ b/openspec/specs/exec-mode/spec.md @@ -152,7 +152,7 @@ The schema SHALL be versioned by `schema_version` in the `init` event (starting - **THEN** stdout lines are, in order: `system`, `tool_use`, `tool_result`, `assistant_text`, `result` ### Requirement: Structured final output -(P1) The system SHALL accept `--output-schema `, a JSON Schema the final answer must satisfy. It SHALL be enforced through the structured-output mechanism from `session-runtime`. On success, the `result` field SHALL hold the validated JSON object (text mode prints it pretty-printed). After 2 failed validation retries the run SHALL exit with code 1 and a `StructuredOutputError`. +(P1) The system SHALL accept `--output-schema `, a JSON Schema the final answer must satisfy. It SHALL be enforced through the structured-output mechanism from `session-runtime`. On success, the `result` field SHALL hold the validated JSON object (text mode prints it pretty-printed). After one initial submission and at most two failed validation corrections the run SHALL exit with code 1 and a `StructuredOutputError`; it SHALL NOT make a fourth submission. #### Scenario: Valid structured result - **WHEN** a user runs `cyber exec --output-schema findings.schema.json "audit deps"` and the model returns a conforming object diff --git a/openspec/specs/session-runtime/spec.md b/openspec/specs/session-runtime/spec.md index 039d48c91..122d16dce 100644 --- a/openspec/specs/session-runtime/spec.md +++ b/openspec/specs/session-runtime/spec.md @@ -180,7 +180,7 @@ The session runtime records Sessions and their durable input inbox, then runs ad - **THEN** it receives every durable event with sequence 1043 and higher, in order ### Requirement: Structured output -(P1) The system SHALL accept a per-prompt `format: { type: "json_schema", schema }`, add a `structured_output` tool built from the schema, require it as the final call, validate the input against the schema (returning validation errors to the model up to 3 times), store the validated value on the assistant message, and end the Drain. +(P1) The system SHALL accept a per-prompt `format: { type: "json_schema", schema }`, add a `structured_output` tool built from the schema, require it as the final call, validate the input against the schema (one initial submission followed by at most two validation corrections for root prompts, then `StructuredOutputError`; existing child result/SchemaMismatch contracts remain separate), store the validated value on the assistant message, and end the Drain. #### Scenario: Schema enforced - **WHEN** a prompt requests `{ type: "object", required: ["summary"] }` diff --git a/openspec/specs/snapshots-checkpoints/spec.md b/openspec/specs/snapshots-checkpoints/spec.md index 7316309ca..10bd568e1 100644 --- a/openspec/specs/snapshots-checkpoints/spec.md +++ b/openspec/specs/snapshots-checkpoints/spec.md @@ -59,7 +59,7 @@ Records the working tree around every model step in a private git repository sep - **THEN** messages 3 to 5 leave the model's view and remain reachable as an inactive branch through `/tree` and `cyber sessions checkout` ### Requirement: Three-phase revert -(P0) Rewinds affecting the conversation SHALL be staged first: `stage { message_id, target }` restores files (when the target includes code) and records the revert state, `clear` restores the pre-stage working tree, and `commit` deletes the messages and inbox rows after the boundary while retaining unresolved tool recovery records independently of conversation projection. A staged revert SHALL be committed automatically on the next prompt, shell command or compaction. The routes SHALL be `POST /api/v1/sessions/:id/revert/{stage,clear,commit}`. +(P0) Rewinds affecting the conversation SHALL be staged first: `stage { message_id, target }` restores files (when the target includes code) and records the revert state, `clear` restores the pre-stage working tree, and `commit` moves the boundary user message and later messages, together with their derived inbox state, onto a retained inactive branch while keeping unresolved tool recovery records independently of conversation projection. A `code`-only commit SHALL keep the active conversation unchanged. A staged revert SHALL be committed automatically on the next prompt, shell command or compaction. The routes SHALL be `POST /api/v1/sessions/:id/revert/{stage,clear,commit}`. #### Scenario: Undo a staged revert - **WHEN** a revert is staged and the user calls `clear` @@ -120,3 +120,7 @@ Records the working tree around every model step in a private git repository sep #### Scenario: PR survives local rewind - **WHEN** a user rewinds a Turn that created a remote PR - **THEN** the preview states that the PR remains remote and the recovery record is preserved + +#### Scenario: Committed conversation rewind preserves recovery +- **WHEN** the owner commits a conversation rewind across an unresolved tool call +- **THEN** later messages remain on an inactive branch and the independent unknown-outcome record is neither deleted nor acknowledged diff --git a/sdk/openapi.json b/sdk/openapi.json index ca544dc60..c72b52760 100644 --- a/sdk/openapi.json +++ b/sdk/openapi.json @@ -7005,6 +7005,318 @@ "data" ] }, + "RewindSelection": { + "type": "object", + "additionalProperties": false, + "properties": { + "message_id": { + "type": "string" + }, + "target": { + "$ref": "#/components/schemas/RevertTarget" + }, + "expected_directory": { + "type": "string", + "description": "Exact Session Location selected by the owner, including for conversation-only rewind." + } + }, + "required": [ + "message_id", + "target", + "expected_directory" + ] + }, + "Data_RewindPreview": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/RewindPreview" + } + }, + "required": [ + "data" + ] + }, + "RewindPreview": { + "type": "object", + "properties": { + "session_id": { + "type": "string" + }, + "directory": { + "type": "string" + }, + "sequence": { + "type": "integer", + "format": "int64" + }, + "message_id": { + "type": "string" + }, + "target": { + "$ref": "#/components/schemas/RevertTarget" + }, + "active_tip": { + "type": [ + "string", + "null" + ] + }, + "code": { + "anyOf": [ + { + "$ref": "#/components/schemas/RewindCodePreview" + }, + { + "type": "null" + } + ] + }, + "effects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RewindEffect" + }, + "description": "Includes recovery calls retained outside the visible conversation branch." + }, + "warning": { + "type": "string" + }, + "confirmation": { + "type": "string", + "description": "Fingerprint of the server-derived preview; not an authentication credential." + } + }, + "required": [ + "session_id", + "directory", + "sequence", + "message_id", + "target", + "effects", + "warning", + "confirmation" + ] + }, + "RewindCodePreview": { + "type": "object", + "properties": { + "baseline": { + "type": "string" + }, + "target": { + "type": "string" + }, + "recorded": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileDiff" + } + }, + "skipped": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "baseline", + "target", + "recorded", + "files", + "skipped" + ] + }, + "FileDiff": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "status": { + "type": "string", + "description": "`added`, `deleted` or `modified`." + }, + "additions": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "deletions": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "patch": { + "type": "string" + }, + "binary": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/BinaryVersion" + }, + "description": "Acknowledged binary source versions, independent of Git snapshot limits.\nThese are saved references, not a synthesized text patch or net byte diff." + } + }, + "required": [ + "file", + "status", + "additions", + "deletions", + "patch" + ], + "description": "One file of a diff between two snapshots." + }, + "BinaryVersion": { + "type": "object", + "additionalProperties": false, + "properties": { + "version_id": { + "type": "string" + }, + "document_id": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "path": { + "type": "string" + }, + "before_sha256": { + "type": "string" + }, + "after_sha256": { + "type": "string" + }, + "before_length": { + "$ref": "#/components/schemas/Offset" + }, + "after_length": { + "$ref": "#/components/schemas/Offset" + }, + "original_bytes": { + "$ref": "#/components/schemas/Offset" + }, + "proposed_bytes": { + "$ref": "#/components/schemas/Offset" + }, + "range_count": { + "type": "integer", + "format": "uint", + "minimum": 0 + }, + "created_ms": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "version_id", + "document_id", + "operation_id", + "kind", + "path", + "before_sha256", + "after_sha256", + "before_length", + "after_length", + "original_bytes", + "proposed_bytes", + "range_count", + "created_ms" + ], + "description": "Shared saved-source metadata. It never contains unsaved command buffers." + }, + "Offset": { + "type": "string", + "description": "Lossless JSON decimal integer. Never accepts JSON numbers or noncanonical strings." + }, + "RewindEffect": { + "type": "object", + "properties": { + "call_id": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/CallStatus" + } + }, + "required": [ + "call_id", + "tool", + "status" + ] + }, + "CallStatus": { + "oneOf": [ + { + "type": "string", + "enum": [ + "ok", + "error" + ] + }, + { + "type": "string", + "const": "called", + "description": "Recorded from the stream, not yet dispatched." + }, + { + "type": "string", + "const": "dispatched", + "description": "Dispatched; outcome not yet recorded." + }, + { + "type": "string", + "const": "interrupted", + "description": "Known not to have run to completion; safe to try again." + }, + { + "type": "string", + "const": "outcome_unknown", + "description": "Dispatched, and whether its side effects happened is unknown." + } + ] + }, + "RewindConfirmation": { + "type": "object", + "additionalProperties": false, + "required": [ + "message_id", + "target", + "expected_directory", + "confirmation" + ], + "properties": { + "message_id": { + "type": "string" + }, + "target": { + "$ref": "#/components/schemas/RevertTarget" + }, + "expected_directory": { + "type": "string", + "description": "Exact Session Location selected by the owner, including for conversation-only rewind." + }, + "confirmation": { + "type": "string" + } + } + }, "ShellBody": { "type": "object", "properties": { @@ -7932,37 +8244,6 @@ "never" ] }, - "CallStatus": { - "oneOf": [ - { - "type": "string", - "enum": [ - "ok", - "error" - ] - }, - { - "type": "string", - "const": "called", - "description": "Recorded from the stream, not yet dispatched." - }, - { - "type": "string", - "const": "dispatched", - "description": "Dispatched; outcome not yet recorded." - }, - { - "type": "string", - "const": "interrupted", - "description": "Known not to have run to completion; safe to try again." - }, - { - "type": "string", - "const": "outcome_unknown", - "description": "Dispatched, and whether its side effects happened is unknown." - } - ] - }, "Redactions": { "type": "object", "properties": { @@ -8437,117 +8718,6 @@ "data" ] }, - "FileDiff": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "status": { - "type": "string", - "description": "`added`, `deleted` or `modified`." - }, - "additions": { - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "deletions": { - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "patch": { - "type": "string" - }, - "binary": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/components/schemas/BinaryVersion" - }, - "description": "Acknowledged binary source versions, independent of Git snapshot limits.\nThese are saved references, not a synthesized text patch or net byte diff." - } - }, - "required": [ - "file", - "status", - "additions", - "deletions", - "patch" - ], - "description": "One file of a diff between two snapshots." - }, - "BinaryVersion": { - "type": "object", - "additionalProperties": false, - "properties": { - "version_id": { - "type": "string" - }, - "document_id": { - "type": "string" - }, - "operation_id": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "path": { - "type": "string" - }, - "before_sha256": { - "type": "string" - }, - "after_sha256": { - "type": "string" - }, - "before_length": { - "$ref": "#/components/schemas/Offset" - }, - "after_length": { - "$ref": "#/components/schemas/Offset" - }, - "original_bytes": { - "$ref": "#/components/schemas/Offset" - }, - "proposed_bytes": { - "$ref": "#/components/schemas/Offset" - }, - "range_count": { - "type": "integer", - "format": "uint", - "minimum": 0 - }, - "created_ms": { - "type": "integer", - "format": "int64" - } - }, - "required": [ - "version_id", - "document_id", - "operation_id", - "kind", - "path", - "before_sha256", - "after_sha256", - "before_length", - "after_length", - "original_bytes", - "proposed_bytes", - "range_count", - "created_ms" - ], - "description": "Shared saved-source metadata. It never contains unsaved command buffers." - }, - "Offset": { - "type": "string", - "description": "Lossless JSON decimal integer. Never accepts JSON numbers or noncanonical strings." - }, "HistoryPage": { "type": "object", "properties": { @@ -23560,6 +23730,124 @@ } } }, + "/api/v1/sessions/{sessionID}/rewind/preview": { + "post": { + "operationId": "v1.session.rewindPreview", + "summary": "Review a rewind without changing files or the active conversation", + "tags": [ + "session" + ], + "parameters": [ + { + "name": "sessionID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Data_RewindPreview" + } + } + } + }, + "default": { + "description": "Tagged error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RewindSelection" + } + } + } + } + } + }, + "/api/v1/sessions/{sessionID}/rewind/confirm": { + "post": { + "operationId": "v1.session.rewindConfirm", + "summary": "Revalidate a reviewed fingerprint before owned rewind", + "tags": [ + "session" + ], + "parameters": [ + { + "name": "sessionID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Data_RevertState" + } + } + } + }, + "default": { + "description": "Tagged error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RewindConfirmation" + } + } + } + } + } + }, "/api/v1/sessions/{sessionID}/revert/stage": { "post": { "operationId": "v1.session.revertStage", diff --git a/sdk/typescript/src/generated/operations.ts b/sdk/typescript/src/generated/operations.ts index ee98aab5f..b738c3148 100644 --- a/sdk/typescript/src/generated/operations.ts +++ b/sdk/typescript/src/generated/operations.ts @@ -1505,6 +1505,18 @@ export interface Operations { body: S.RevertBody; response: S.RevertState; }; + "v1.session.rewindConfirm": { + path: { sessionID: string }; + query: Record; + body: S.RewindConfirmation; + response: S.RevertState; + }; + "v1.session.rewindPreview": { + path: { sessionID: string }; + query: Record; + body: S.RewindSelection; + response: S.RewindPreview; + }; "v1.session.sentFile": { path: { sessionID: string; fileID: string }; query: Record; @@ -4506,6 +4518,30 @@ export const operations = { unwrap: true, stream: false, }, + "v1.session.rewindConfirm": { + tag: "session", + name: "rewindConfirm", + method: "POST", + path: "/api/v1/sessions/{sessionID}/rewind/confirm", + pathParams: ["sessionID"], + query: [], + body: "required", + located: false, + unwrap: true, + stream: false, + }, + "v1.session.rewindPreview": { + tag: "session", + name: "rewindPreview", + method: "POST", + path: "/api/v1/sessions/{sessionID}/rewind/preview", + pathParams: ["sessionID"], + query: [], + body: "required", + located: false, + unwrap: true, + stream: false, + }, "v1.session.sentFile": { tag: "session", name: "sentFile", @@ -5920,6 +5956,12 @@ export function createGroups(caller: Caller) { /** Stage and commit a rewind (`POST /api/v1/sessions/{sessionID}/rewind`) */ rewind: (sessionID: string, body: Operations["v1.session.rewind"]["body"], options?: RequestOptions): Promise => caller.call("v1.session.rewind", { path: { sessionID }, body }, options), + /** Revalidate a reviewed fingerprint before owned rewind (`POST /api/v1/sessions/{sessionID}/rewind/confirm`) */ + rewindConfirm: (sessionID: string, body: Operations["v1.session.rewindConfirm"]["body"], options?: RequestOptions): Promise => + caller.call("v1.session.rewindConfirm", { path: { sessionID }, body }, options), + /** Review a rewind without changing files or the active conversation (`POST /api/v1/sessions/{sessionID}/rewind/preview`) */ + rewindPreview: (sessionID: string, body: Operations["v1.session.rewindPreview"]["body"], options?: RequestOptions): Promise => + caller.call("v1.session.rewindPreview", { path: { sessionID }, body }, options), /** Download a file the Session delivered with send_file (`GET /api/v1/sessions/{sessionID}/files/{fileID}`) */ sentFile: (sessionID: string, fileID: string, options?: RequestOptions): Promise => caller.call("v1.session.sentFile", { path: { sessionID, fileID } }, options), diff --git a/sdk/typescript/src/generated/types.ts b/sdk/typescript/src/generated/types.ts index af271d5a2..1b71b079b 100644 --- a/sdk/typescript/src/generated/types.ts +++ b/sdk/typescript/src/generated/types.ts @@ -1058,6 +1058,10 @@ export type Data_RevertState = { data: RevertState; }; +export type Data_RewindPreview = { + data: RewindPreview; +}; + export type Data_RunDetail = { data: RunDetail; }; @@ -3965,6 +3969,50 @@ export type ReviewedHook = { last_run?: HookLastRun | null; }; +export type RewindCodePreview = { + baseline: string; + target: string; + recorded: string; + files: FileDiff[]; + skipped: string[]; +}; + +export type RewindConfirmation = { + message_id: string; + target: RevertTarget; + /** Exact Session Location selected by the owner, including for conversation-only rewind. */ + expected_directory: string; + confirmation: string; +}; + +export type RewindEffect = { + call_id: string; + tool: string; + status: CallStatus; +}; + +export type RewindPreview = { + session_id: string; + directory: string; + sequence: number; + message_id: string; + target: RevertTarget; + active_tip?: string | null; + code?: RewindCodePreview | null; + /** Includes recovery calls retained outside the visible conversation branch. */ + effects: RewindEffect[]; + warning: string; + /** Fingerprint of the server-derived preview; not an authentication credential. */ + confirmation: string; +}; + +export type RewindSelection = { + message_id: string; + target: RevertTarget; + /** Exact Session Location selected by the owner, including for conversation-only rewind. */ + expected_directory: string; +}; + export type Role = "lead" | "teammate"; /** Usage of one Workflow agent: its child Session with descendants. */ diff --git a/sdk/typescript/test/rewind.test.ts b/sdk/typescript/test/rewind.test.ts new file mode 100644 index 000000000..36fb9ffac --- /dev/null +++ b/sdk/typescript/test/rewind.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { json, mockClient } from "./support.js"; + +test("reviewed rewind operations preserve the selected scope and fingerprint", async () => { + const selection = { message_id: "msg_boundary", target: "both" as const, expected_directory: "/work/project" }; + const fingerprint = "a".repeat(64); + const { client, calls } = mockClient(req => json(200, { data: req.url.pathname.endsWith("/preview") + ? { ...selection, session_id: "ses_one", directory: selection.expected_directory, sequence: 19, + active_tip: "msg_tip", code: null, effects: [], warning: "External effects remain", confirmation: fingerprint } + : { message_id: selection.message_id, target: selection.target, baseline: null, applied: null, diff: "" } })); + const preview = await client.request("v1.session.rewindPreview", { path: { sessionID: "ses_one" }, body: selection }); + await client.request("v1.session.rewindConfirm", { path: { sessionID: "ses_one" }, body: { ...selection, confirmation: preview.confirmation } }); + assert.equal(calls.length, 2); + assert.equal(calls[0]!.url.pathname, "/api/v1/sessions/ses_one/rewind/preview"); + assert.equal(calls[1]!.url.pathname, "/api/v1/sessions/ses_one/rewind/confirm"); + assert.deepEqual(JSON.parse(calls[1]!.body!), { ...selection, confirmation: fingerprint }); +});