From 7b75978e87bfc43bdc729615b4e98b23229f8837 Mon Sep 17 00:00:00 2001 From: Nishad <133812901+nishu-builder@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:02:49 +0000 Subject: [PATCH] Replace TUI publication commands with agent tools --- README.md | 6 +- SPEC.md | 35 +- design/agent-harness.md | 21 +- design/chat.md | 74 +- rust/crates/caos-cli/src/bin/tui/app.rs | 385 +---- rust/crates/caos-cli/src/bin/tui/mod.rs | 4 +- .../caos-cli/src/bin/tui/publication.rs | 174 --- rust/crates/caos-cli/src/bin/tui/ui.rs | 97 +- rust/crates/caos-cli/src/host_git.rs | 305 +--- rust/crates/caos-cli/src/lib.rs | 1268 ++--------------- rust/crates/caos-cli/src/publication.rs | 74 - rust/crates/caos-cli/src/source_trees.rs | 261 +--- .../src/v3/publication.rs | 2 +- 13 files changed, 163 insertions(+), 2543 deletions(-) delete mode 100644 rust/crates/caos-cli/src/bin/tui/publication.rs delete mode 100644 rust/crates/caos-cli/src/publication.rs diff --git a/README.md b/README.md index 320f108a5..6a8a9eab2 100644 --- a/README.md +++ b/README.md @@ -389,9 +389,9 @@ the object machinery through a one-way dependency. Their difference is the imports the launching checkout's clean HEAD at that conversation path. Add a source and revision to select another repository or commit; Git URLs require a full commit hash. Ctrl+O browses code entries; the agent - organizes them through ordinary file operations. `/import` adds local or - remote repository commits. `/pr [remote-URL]` previews - one PR, and `/checkout [directory]` exports code for local editing; + organizes them through ordinary file operations. `/import` adds local + repository commits; ask the agent to import remote code, publish a branch, + or create a PR/stack. `/checkout [directory]` exports code for local editing; - `talk` / `chat` — agent conversations over the current protocol (`design/chat.md`); `caos talk ""` is the everyday form. A conversation NAMES the two diff --git a/SPEC.md b/SPEC.md index 3e03d069b..5d88cf80a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -377,7 +377,7 @@ with no `@param` tags takes no parameters: the source tree IS its input. - Tools are discovered fresh from the CURRENT source tree on every LLM round and resolved again at INVOCATION time, so an agent that edits a tool sees the change on its next call, within the same turn -- `bash`, `grep`, `read`, `ls`, `write`, `edit`, and `import_source` are reserved. A +- `bash`, `grep`, `read`, `ls`, `write`, `edit`, `import_source`, `publish_source`, and `github` are reserved. A `caos-tools/bash/` is ignored, not registered — the model's primitives, including the repair path for a broken tool edit, stay stable whatever the tree carries @@ -616,24 +616,23 @@ revision (and made the history tools' hashes readable the same way). resolution turn (a leftover marker does not compile) — not by a marker re-scan, which cannot tell a real marker from a bad resolution. - The one place to refuse or loudly warn on a non-empty `.caos/conflicts` or a - remaining marker is PUBLISH (the tui's PR or branch flow) — the moment work + remaining marker is PUBLISH (the server push endpoint) — the moment work actually leaves the conversation. ## Publication -The [conversation publication flow](design/chat.md) publishes one named gitlink -with `/pr [remote-URL]`. Its full path is the PR -branch name. The base is explicit; an omitted URL comes from unambiguous import -provenance. Directory ordering guides review, not publication. Publish earlier -PRs first, then name their remote branches as later PR bases. - -The client previews the exact source commit and destination before confirmation. -If the source does not contain the fetched base tip, it offers to import that -base and send the agent a merge/rebase and test request. This action publishes -nothing; run `/pr` again after integration. Successful pushes and PR operations -are recorded as CAOS transcript entries. No snapshot has a special working or -sealed state. - -Per-mutation commits remain in the published source tree history. Only the -previewed PR tip is checked for unresolved conflicts and reserved state; -intermediate commits may contain conflict markers or fail to build. +The agent's publish_source(source_tree, repository, branch) records the exact +source commit and expected remote head before invoking caos push-git. +POST /git/push validates that code commit and pushes it directly from the +server's bare repository using an explicit lease and fast-forward check. +Publication does not edit the source or squash its history. + +github(repository, args, stdin?) runs the general std/github worker. +Every tool call gets a fresh invocation identity. A Git compare-and-swap claim +prevents a retried worker from repeating a possibly completed command. +An unfinished claim is uncertain; inspect GitHub before proceeding. + +PR and stack operations compose these tools. Stack boundaries remain gitlinks; +publish bottom to top and link existing PR URLs with gh stack link. +The TUI's /pr and /publish-branch are removed; /import remains for local paths. +See [agent publication](design/agent-publish.md) for mechanics and recovery. diff --git a/design/agent-harness.md b/design/agent-harness.md index 4a2503c18..ecbd10dab 100644 --- a/design/agent-harness.md +++ b/design/agent-harness.md @@ -360,22 +360,11 @@ points is the caller's tree's business, not this client's (`design/chat.md`, and freezes redraws for native terminal text selection. `/checkout [directory]` checks out the named code commit as a detached HEAD in a clean local checkout. `Ctrl+H` opens the keyboard and slash-command reference. - `/pr [remote-URL]` previews one PR; Enter - confirms. The URL is explicit or inferred from matching import provenance. - Publication pushes the exact code commit, using the gitlink path as the branch - name, then opens or reuses its PR through `gh`. The agent tests the code before - this client operation. Source and base must share history. If the latest base is not an ancestor, - Enter instead imports the base and sends an integration request to the agent; - the user runs `/pr` again afterward to review publication. Changed content, remote drift, conflict markers, - unrelated histories, and reserved `.caos` state stop publication. Merely - opening, running, switching, or publishing conversations never mutates the - checkout. `/publish-branch` pushes the same full-history branch without PR - creation, while `/load` imports a `remote/caos/` branch or - GitHub PR back into the canonical server-side conversation. Imports preserve - the ID and event spine, allow only first-parent advancement, and reject - divergent ID collisions. Progress remains one completed API round at a time, - and a running turn is not cancellable until the server/runner protocol grows - cancellation. + Publication uses the agent's publish_source and github tools. The + server pushes the selected code commit; the GitHub worker handles PRs and + stacks. See [publication](agent-publish.md) for frozen inputs, leases and + recovery. The TUI's /pr and /publish-branch are removed. Publishing source + commits never mutates the local checkout. ### Superseded protocol detail diff --git a/design/chat.md b/design/chat.md index 176e1426c..86a34af1b 100644 --- a/design/chat.md +++ b/design/chat.md @@ -355,65 +355,15 @@ the user's message. The source path is explicit in both commands. Browser selection does not choose checkout or publication targets, and does not change the agent's execution context. -## Publishing with `/pr` - -The user enters: - -```text -/pr feature/01-change main -``` - -The client: - -1. Reads the commit referenced by `feature/01-change`. That path also supplies - the proposed remote branch name. -2. Determines the destination repository from an explicit optional URL or - unambiguous import provenance matching the oldest sibling's commit. -3. Fetches the requested base branch, here `main`, and checks the source commit. -4. Shows the source path and hash, repository, branch, and base for review. -5. On Enter, rechecks the selected snapshot and remote state, pushes the exact - source commit with its history, and opens or updates the PR using the client's - Git and GitHub credentials. Escape cancels. -6. Records the confirmed push as a `CAOS` message. Successful PR creation or - update adds another message with the PR URL, branch, and base. - -The full syntax is `/pr [remote-URL]`. -The base branch is explicit. The optional remote is a repository URL, not a -local remote name such as `origin`. Missing or ambiguous provenance requires -that URL. The preview shows destination metadata, not a full PR diff. - -For a stack, publish each boundary in order: - -```text -/pr feature/01-change main -/pr feature/02-tests feature/01-change -``` - -The preceding branch must exist remotely before it can serve as the next base. -Publication does not squash source history or change the conversation's gitlinks. -`/publish-branch [remote-URL]` provides the same preview -and branch push without a PR or base-branch requirement. - -### When the PR base needs integrating - -The source and remote base must share Git history. If the source does not contain -the fetched base tip, the preview offers a different action: - -1. On Enter, import that exact base commit under `imports/pr-base-/base`. -2. Send a message asking the agent to merge or rebase it into the named source - and run checks. -3. After integration, the user runs `/pr` again to review the result. - -That confirmation imports and sends the message; it publishes nothing. A failed -import sends no message. The handoff preserves the user's draft and stays in the -original conversation. - -Before integrating, the agent checks the full proposed PR scope. Merging upstream -retains inherited branch changes; moving only a small requested edit onto a new -base requires deciding which changes to carry over. - -Publication rejects changed snapshots, remote drift, unrelated histories, -conflict markers, and any remaining source-tree `.caos` entry. It never cleans -files or rewrites the reviewed commit at push time. Interrupted pushes are -checked against the destination before retrying, and failed PR operations do -not record a successful PR. +## Publishing + +The agent uses publish_source(source_tree, repository, branch) to publish an +exact code commit and github(repository, args, stdin?) for PRs and stacks. +Publication receipts remain in the conversation; source gitlinks do not move. +The TUI's /pr and /publish-branch commands are removed. + +See [agent publication and stacks](agent-publish.md) for the server endpoint, +leases, invocation records and stack updates. The agent checks the complete +PR scope and tests before publication. Integrating upstream retains inherited +changes; transplanting only a small edit onto a different base is a separate +operation. diff --git a/rust/crates/caos-cli/src/bin/tui/app.rs b/rust/crates/caos-cli/src/bin/tui/app.rs index ef1be3252..f902491f9 100644 --- a/rust/crates/caos-cli/src/bin/tui/app.rs +++ b/rust/crates/caos-cli/src/bin/tui/app.rs @@ -2,7 +2,6 @@ use std::cell::Cell; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, Receiver, Sender}; -use std::sync::{atomic::AtomicBool, Arc}; use std::time::{Duration, Instant}; use caos::{GitTransport, Transport}; @@ -32,9 +31,6 @@ mod filesystem; #[path = "ui.rs"] pub(crate) mod ui; use filesystem::Browser; -#[path = "publication.rs"] -mod publication; -use publication::PublishPrompt; #[path = "input_history.rs"] mod input_history; use input_history::InputHistory; @@ -852,8 +848,6 @@ enum AppAction { Invite, Model, Commands, - Publish, - PublishBranch, Reference, Title, UpdateTree, @@ -897,7 +891,7 @@ const MODEL_OPTIONS: [&str; 8] = [ "claude-opus-4-6", ]; -const COMMANDS: [Command; 13] = [ +const COMMANDS: [Command; 11] = [ Command { name: "/from", usage: "/from ", @@ -948,20 +942,6 @@ const COMMANDS: [Command; 13] = [ action: AppAction::Commands, takes_argument: false, }, - Command { - name: "/pr", - usage: "/pr [remote-URL]", - description: "preview a PR for this gitlink against an explicit remote branch", - action: AppAction::Publish, - takes_argument: true, - }, - Command { - name: "/publish-branch", - usage: "/publish-branch [remote-URL]", - description: "preview a branch push without creating a PR", - action: AppAction::PublishBranch, - takes_argument: true, - }, Command { name: "/ref", usage: "/ref", @@ -1040,9 +1020,6 @@ struct ConversationState { reconciling_request: Option, reconcile_after: Option, turn_phase: TurnPhase, - publishing: bool, - publish_plan: Option, - publication_cancel: Option>, forking: bool, source_tree_operation: bool, scroll: ScrollState, @@ -1089,9 +1066,6 @@ impl ConversationState { reconciling_request: None, reconcile_after: None, turn_phase: TurnPhase::System, - publishing: false, - publish_plan: None, - publication_cancel: None, forking: false, source_tree_operation: false, scroll: ScrollState::default(), @@ -1383,7 +1357,7 @@ impl ConversationState { } fn is_busy(&self) -> bool { - self.running || self.publishing || self.forking || self.source_tree_operation + self.running || self.forking || self.source_tree_operation } fn push_error(&mut self, error: impl Into) { @@ -1510,7 +1484,7 @@ impl ConversationState { } fn sidebar_text(&self, max_cells: u16) -> (String, String) { - let detail = if self.running || self.publishing { + let detail = if self.running { self.running_activity() .map(|activity| { format!("{} {}", activity.running_verb(), activity.running_summary()) @@ -1600,23 +1574,10 @@ enum UiMessage { conversation: String, result: Result, }, - PublicationPlanned { - conversation: String, - id: u64, - result: Result, - }, - Published { - conversation: String, - result: Result, - }, SourceTreeUpdated { conversation: String, result: Result<(Result<(), String>, Box), String>, }, - PublicationBaseImported { - conversation: String, - result: Result<(String, Box), String>, - }, Reconciled { conversation: String, request: String, @@ -2035,14 +1996,7 @@ impl App { self.view == View::Chat } - pub(crate) fn publication_visible(&self) -> bool { - self.selected().publish_plan.is_some() - } - pub(crate) fn insert_paste(&mut self, text: &str) { - if self.selected().publish_plan.is_some() { - return; - } if self.browser_visible() { return; } @@ -2053,16 +2007,6 @@ impl App { if self.browser_visible() { return self.browser_mouse(mouse, area); } - if self.selected().publish_plan.is_some() { - if mouse.kind == MouseEventKind::ScrollUp { - self.handle_publication_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - } - if mouse.kind == MouseEventKind::ScrollDown { - self.handle_publication_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - } - return MouseAction::Redraw; - } - if self.palette.is_some() { return MouseAction::Ignored; } @@ -2255,11 +2199,6 @@ impl App { .show_command_error("wait for the source tree operation to finish"); return; } - if self.selected().publishing { - self.selected_mut() - .show_command_error("finish publishing before sending another message"); - return; - } let Some(raw) = self.selected_mut().composer.take_message() else { return; }; @@ -2528,8 +2467,6 @@ impl App { .push_info(format!("Model for future turns: {model}")); } AppAction::From => self.start_from_hash(arguments), - AppAction::Publish => self.run_publication(arguments, false), - AppAction::PublishBranch => self.run_publication(arguments, true), AppAction::Title => self.rename_selected(arguments), AppAction::UpdateTree => unreachable!("message command reached local dispatch"), AppAction::NewConversation @@ -2565,9 +2502,6 @@ impl App { } fn interrupt_selected(&mut self) { - if let Some(cancel) = &self.selected().publication_cancel { - cancel.store(true, std::sync::atomic::Ordering::Relaxed); - } if !self.selected().running || self.selected().interrupting { return; } @@ -2834,58 +2768,6 @@ impl App { self.finish_title_generation(index, result); } } - UiMessage::PublicationPlanned { - conversation, - id, - result, - } => { - if let Some(index) = self.conversation_index(&conversation) { - if let Some(prompt) = self.conversations[index] - .publish_plan - .as_mut() - .filter(|prompt| prompt.id == id) - { - prompt.loading = false; - match result { - Ok(target) => prompt.target = Some(target), - Err(error) => prompt.error = Some(error), - } - } - } - } - UiMessage::Published { - conversation, - result, - } => { - let transport = self.transport(); - let user = self.user.clone(); - if let Some(index) = self.conversation_index(&conversation) { - let state = &mut self.conversations[index]; - state.publishing = false; - state.publication_cancel = None; - state.local_turn = false; - let refreshed = transport - .as_ref() - .ok() - .and_then(|transport| state.reload(transport, &user)); - if refreshed.is_none() { - state.running = false; - state.active_request = None; - state.remote_head = None; - } - match result { - Ok(message) if refreshed.is_none() => state.push_info(message), - Ok(_) => state.status.clear(), - Err(error) => { - state.sidebar_attention = - Some("PR failed — open for details".to_string()); - state.show_command_error_preserving_status(format!( - "PR failed: {error}" - )); - } - } - } - } UiMessage::SourceTreeUpdated { conversation, result, @@ -2917,21 +2799,6 @@ impl App { } } } - UiMessage::PublicationBaseImported { - conversation, - result, - } => { - if let Some(index) = self.conversation_index(&conversation) { - self.conversations[index].source_tree_operation = false; - match result { - Ok((message, load)) => { - self.conversations[index].apply_load(*load, &self.user); - self.send_message(index, message, None, None, None); - } - Err(error) => self.conversations[index].show_command_error(error), - } - } - } UiMessage::Reconciled { conversation, request, @@ -3349,18 +3216,6 @@ impl App { self.handle_browser_key(key); return; } - if self.selected().publish_plan.is_some() { - if key - .modifiers - .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT) - && matches!(key.code, KeyCode::Char('p' | 'P')) - { - self.execute_action(AppAction::Commands); - } else { - self.handle_publication_key(key); - } - return; - } let is_palette = key .modifiers .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT) @@ -3378,7 +3233,7 @@ impl App { if self.view == View::Chat && self.selected_mut().dismiss_command_menu() { return; } - if self.selected().running || self.selected().publishing { + if self.selected().running { self.interrupt_selected(); return; } @@ -3646,9 +3501,6 @@ impl App { self.start_new_conversation(None); self.focus = Focus::Conversation; } - AppAction::Publish | AppAction::PublishBranch => { - unreachable!("publication requires explicit command arguments") - } AppAction::Activity => { self.view = if self.view == View::Activity { View::Chat @@ -3680,7 +3532,6 @@ impl App { AppAction::Archive => self.close_selected(), AppAction::SelectionLock => self.selection_locked = !self.selection_locked, AppAction::Commands => { - self.selected_mut().publish_plan = None; self.palette = self.palette.take().is_none().then(CommandPalette::default); } AppAction::Checkout @@ -3859,7 +3710,6 @@ impl App { } fn select(&mut self, index: usize) { - self.selected_mut().publish_plan = None; self.selected = index; let needs_load = self.selected().source_trees.is_empty() && self.selected().remote_head.is_some(); @@ -4634,6 +4484,20 @@ mod tests { .collect() } + fn rendered_screen(app: &App) -> String { + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| render(app, frame)).unwrap(); + terminal + .backend() + .buffer() + .content + .chunks(100) + .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) + .collect::>() + .join("\n") + } + fn rendered_header(terminal: &Terminal) -> String { let buffer = terminal.backend().buffer(); buffer @@ -5153,8 +5017,6 @@ mod tests { "/checkout", "/import", "/commands", - "/pr", - "/publish-branch", "/ref", "/invite", "/model", @@ -5214,12 +5076,8 @@ mod tests { assert!(parse_command("/load https://github.com/Metta-AI/caos/pull/34").is_none()); - let (command, arguments) = parse_command("/publish-branch feature/01-change").unwrap(); - assert_eq!(command.action, AppAction::PublishBranch); - assert_eq!(arguments, "feature/01-change"); - let (command, arguments) = parse_command("/pr feature/01-change main").unwrap(); - assert_eq!(command.action, AppAction::Publish); - assert_eq!(arguments, "feature/01-change main"); + assert!(parse_command("/pr feature/01-change main").is_none()); + assert!(parse_command("/publish-branch feature/01-change").is_none()); let (command, arguments) = parse_command("/ref").unwrap(); assert_eq!(command.action, AppAction::Reference); @@ -5517,15 +5375,10 @@ mod tests { #[test] fn escape_closes_command_palette_before_interrupting_work() { - for (running, publishing) in [(false, false), (true, false), (false, true)] { + for running in [false, true] { let mut conversation = state("palette-escape"); conversation.running = running; - conversation.publishing = publishing; conversation.composer.insert_str("keep this draft"); - let cancel = Arc::new(AtomicBool::new(false)); - if publishing { - conversation.publication_cancel = Some(cancel.clone()); - } let (mut app, _) = app_with(vec![conversation]); app.repo_dir = std::env::temp_dir().join(format!( "missing-caos-palette-escape-test-repo-{}", @@ -5552,32 +5405,21 @@ mod tests { assert_eq!(app.selected().composer.text, "keep this draft"); assert_eq!(app.selected().status, "ready"); assert_eq!(app.selected().running, running); - assert_eq!(app.selected().publishing, publishing); assert!(!app.selected().interrupting); - assert!(!cancel.load(std::sync::atomic::Ordering::Relaxed)); assert!(!app.should_quit()); app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!(app.selected().interrupting, running); - assert_eq!( - cancel.load(std::sync::atomic::Ordering::Relaxed), - publishing - ); } } #[test] fn escape_dismisses_slash_completions_before_interrupting_work() { - for (running, publishing) in [(true, false), (false, true)] { + for running in [true] { for draft in ["/", "/model son"] { let mut conversation = state("completion-escape"); conversation.running = running; - conversation.publishing = publishing; conversation.composer.insert_str(draft); - let cancel = Arc::new(AtomicBool::new(false)); - if publishing { - conversation.publication_cancel = Some(cancel.clone()); - } let (mut app, _) = app_with(vec![conversation]); app.repo_dir = std::env::temp_dir().join(format!( "missing-caos-completion-escape-test-repo-{}", @@ -5590,14 +5432,9 @@ mod tests { assert_eq!(app.selected().composer.completion_count(), 0); assert_eq!(app.selected().composer.text, draft); assert!(!app.selected().interrupting); - assert!(!cancel.load(std::sync::atomic::Ordering::Relaxed)); app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!(app.selected().interrupting, running); - assert_eq!( - cancel.load(std::sync::atomic::Ordering::Relaxed), - publishing - ); } } } @@ -6723,7 +6560,6 @@ mod tests { app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)); app.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL)); - assert!(app.selected().publish_plan.is_none()); assert_eq!(git(&["rev-parse", "HEAD"]), base); assert!(app.selected().composer.expanded_text().is_empty()); app.run_checkout(&format!( @@ -7236,183 +7072,6 @@ mod tests { ); } - #[test] - fn publishing_gate_keeps_the_draft_and_shows_the_command_error_panel() { - let mut conversation = state("talk-1"); - conversation.publishing = true; - conversation.status = "publishing".to_string(); - conversation.composer.insert_str("do not send yet"); - let (mut app, _) = app_with(vec![conversation]); - - app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); - - assert!(app.selected().publishing); - assert_eq!(app.selected().composer.text, "do not send yet"); - assert!(app.selected().transcript.is_empty()); - assert_eq!( - app.selected().command_error.as_deref(), - Some("finish publishing before sending another message") - ); - let backend = TestBackend::new(100, 30); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|frame| render(&app, frame)).unwrap(); - assert!(rendered_main_pane(&terminal) - .join("\n") - .contains("finish publishing before sending another message")); - } - - fn rendered_screen(app: &App) -> String { - let backend = TestBackend::new(100, 30); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|frame| render(app, frame)).unwrap(); - terminal - .backend() - .buffer() - .content - .chunks(100) - .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) - .collect::>() - .join("\n") - } - - #[test] - fn publication_preview_preserves_drafts_and_cannot_be_retargeted() { - use caos_cli::source_trees::PublicationTarget; - let mut conversation = state("talk-1"); - conversation.composer.text = "preserve this draft".into(); - conversation.publish_plan = Some(PublishPrompt { - id: 1, - loading: false, - error: None, - branch_only: false, - target: Some(PublicationTarget { - source_tree: "feature/02-change".into(), - head: "a".repeat(40), - repository: "https://example.com/repo".into(), - branch: "feature/02-change".into(), - base_branch: "main".into(), - base_commit: Some("b".repeat(40)), - remote_head: None, - base_import: None, - }), - }); - let (mut app, tx) = app_with(vec![conversation]); - let rendered = rendered_screen(&app); - assert!(rendered.contains("Publish PR")); - assert!(rendered.contains("feature/02-change")); - assert!(rendered.contains("PR base: main")); - app.insert_paste("https://example.com/other"); - app.handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); - app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - assert_eq!( - app.selected() - .publish_plan - .as_ref() - .unwrap() - .target - .as_ref() - .unwrap() - .repository, - "https://example.com/repo" - ); - assert_eq!(app.selected().composer.text, "preserve this draft"); - app.selected_mut() - .publish_plan - .as_mut() - .unwrap() - .target - .as_mut() - .unwrap() - .base_import = Some("imports/pr-base-abc/base".into()); - let rendered = rendered_screen(&app); - assert!(rendered.contains("Enter imports the base")); - assert!(rendered.contains("Nothing is published")); - assert!(!rendered.contains("Enter pushes")); - app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(app.selected().publish_plan.is_none()); - tx.send(UiMessage::PublicationPlanned { - conversation: "talk-1".into(), - id: 1, - result: Err("late result".into()), - }) - .unwrap(); - app.drain_messages(); - assert!(app.selected().publish_plan.is_none()); - assert_eq!(app.selected().composer.text, "preserve this draft"); - } - - #[test] - fn publish_stage_statuses_replace_the_generic_publishing_summary() { - let mut conversation = state("talk-1"); - conversation.publishing = true; - conversation.status = "fetching the tip of main".to_string(); - let (mut app, tx) = app_with(vec![conversation]); - - let rendered = rendered_screen(&app); - assert!(rendered.contains("Publishing…")); - assert!(rendered.contains("fetching the tip of main")); - - tx.send(UiMessage::Turn { - conversation: "talk-1".to_string(), - event: TurnEvent::Status("pushing branch caos/talk-1".to_string()), - }) - .unwrap(); - assert!(app.drain_messages()); - assert_eq!(app.selected().status, "pushing branch caos/talk-1"); - let rendered = rendered_screen(&app); - assert!(rendered.contains("Publishing…")); - assert!(rendered.contains("pushing branch caos/talk-1")); - } - - #[test] - fn publishing_shows_a_running_tool_instead_of_the_generic_verb() { - let mut conversation = state("talk-1"); - conversation.publishing = true; - conversation.status = "starting the publication preparation turn".to_string(); - let (mut app, tx) = app_with(vec![conversation]); - - tx.send(UiMessage::Turn { - conversation: "talk-1".to_string(), - event: TurnEvent::ToolCall { - step_commit: "b".repeat(40), - request: "c".repeat(40), - round: 1, - tool_use_id: "call-1".to_string(), - name: "bash".to_string(), - summary: "$ cargo test".to_string(), - }, - }) - .unwrap(); - assert!(app.drain_messages()); - - let rendered = rendered_screen(&app); - assert!(rendered.contains("Running…")); - assert!(rendered.contains("$ cargo test")); - assert!(!rendered.contains("Publishing…")); - let (_, detail) = app.selected().sidebar_text(60); - assert_eq!(detail, "Running $ cargo test"); - - tx.send(UiMessage::Turn { - conversation: "talk-1".to_string(), - event: TurnEvent::ToolResult { - step_commit: "b".repeat(40), - request: "c".repeat(40), - round: 1, - tool_use_id: "call-1".to_string(), - is_error: false, - content: "ok".to_string(), - }, - }) - .unwrap(); - assert!(app.drain_messages()); - - let rendered = rendered_screen(&app); - assert!(rendered.contains("Publishing…")); - assert!(rendered.contains("starting the publication preparation turn")); - let (_, detail) = app.selected().sidebar_text(60); - assert_eq!(detail, "starting the publication preparation turn"); - } - #[test] fn routine_idle_status_is_not_rendered() { let mut conversation = state("talk-1"); diff --git a/rust/crates/caos-cli/src/bin/tui/mod.rs b/rust/crates/caos-cli/src/bin/tui/mod.rs index 6dee28d43..89efae955 100644 --- a/rust/crates/caos-cli/src/bin/tui/mod.rs +++ b/rust/crates/caos-cli/src/bin/tui/mod.rs @@ -93,9 +93,7 @@ fn run_app( changed |= selection_lock_allows_redraw(was_locked, app.selection_locked()); } TerminalEvent::Paste(text) - if (app.view() == View::Chat - || app.browser_visible() - || app.publication_visible()) + if (app.view() == View::Chat || app.browser_visible()) && !app.selection_locked() => { app.clear_copy_notice(); diff --git a/rust/crates/caos-cli/src/bin/tui/publication.rs b/rust/crates/caos-cli/src/bin/tui/publication.rs deleted file mode 100644 index b216e788b..000000000 --- a/rust/crates/caos-cli/src/bin/tui/publication.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! Preview and confirm publication of an explicitly named gitlink. -use super::*; -use caos_cli::source_trees::PublicationTarget; -use std::sync::atomic::{AtomicU64, Ordering}; - -static NEXT_PREVIEW: AtomicU64 = AtomicU64::new(1); - -#[derive(Clone, Debug)] -pub(super) struct PublishPrompt { - pub id: u64, - pub loading: bool, - pub target: Option, - pub error: Option, - pub branch_only: bool, -} - -impl App { - pub(super) fn run_publication(&mut self, arguments: &str, branch_only: bool) { - let usage = if branch_only { - "usage: /publish-branch [remote-URL]" - } else { - "usage: /pr [remote-URL]" - }; - let required = if branch_only { 1 } else { 2 }; - let parts = match shell_words::split(arguments) { - Ok(parts) if (required..=required + 1).contains(&parts.len()) => parts, - _ => { - self.selected_mut().show_command_error(usage); - return; - } - }; - if self.selected().is_busy() { - self.selected_mut() - .show_command_error("finish this conversation's operation before publishing it"); - return; - } - let source = parts[0].clone(); - let base = (!branch_only).then(|| parts[1].clone()); - let repository = parts.get(if branch_only { 1 } else { 2 }).cloned(); - let conversation = self.selected().id.clone(); - let id = NEXT_PREVIEW.fetch_add(1, Ordering::Relaxed); - self.selected_mut().publish_plan = Some(PublishPrompt { - id, - loading: true, - target: None, - error: None, - branch_only, - }); - let finished = conversation.clone(); - spawn( - self.repo_dir.clone(), - self.tx.clone(), - move |transport| { - caos_cli::source_trees::prepare_publication( - transport, - &conversation, - &source, - base.as_deref(), - repository.as_deref(), - ) - }, - move |result| UiMessage::PublicationPlanned { - conversation: finished, - id, - result, - }, - ); - } - - pub(super) fn handle_publication_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => self.selected_mut().publish_plan = None, - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - self.selected_mut().publish_plan = None; - } - KeyCode::Enter => self.confirm_publication(), - _ => {} - } - } - - fn confirm_publication(&mut self) { - let Some(mut prompt) = self.selected_mut().publish_plan.take() else { - return; - }; - if prompt.loading || prompt.error.is_some() || prompt.target.is_none() { - self.selected_mut().publish_plan = Some(prompt); - return; - } - if self.selected().is_busy() { - prompt.error = Some( - "finish the running operation before publishing; run the command again".into(), - ); - self.selected_mut().publish_plan = Some(prompt); - return; - } - let target = prompt.target.take().expect("preview completed"); - if let Some(path) = &target.base_import { - let conversation = self.selected().id.clone(); - self.selected_mut().source_tree_operation = true; - self.selected_mut().status = format!("PR base into {path}"); - let finished = conversation.clone(); - spawn( - self.repo_dir.clone(), - self.tx.clone(), - move |transport| { - let message = caos_cli::source_trees::import_publication_base( - transport, - &conversation, - &target, - )?; - let load = conversation_load(transport, &conversation)? - .ok_or("conversation disappeared after importing PR base")?; - Ok((message, Box::new(load))) - }, - move |result| UiMessage::PublicationBaseImported { - conversation: finished, - result, - }, - ); - return; - } - let conversation = self.selected().id.clone(); - let title = self.selected().title.clone(); - let cancel = Arc::new(AtomicBool::new(false)); - self.selected_mut().publication_cancel = Some(cancel.clone()); - self.selected_mut().publishing = true; - self.selected_mut().status = format!("publishing {}", target.source_tree); - let finished = conversation.clone(); - spawn( - self.repo_dir.clone(), - self.tx.clone(), - move |transport| { - if cancel.load(Ordering::Relaxed) { - return Err("publication cancelled".into()); - } - if prompt.branch_only { - let published = caos_cli::source_trees::publish_branch_target( - transport, - &conversation, - &target, - )?; - if published.status != conversation_protocol::v3::PublicationStatus::Complete { - return Err(format!( - "branch publication is {:?}: {}", - published.status, - caos_cli::publication_diagnostic( - transport, - &conversation, - &published.publication - )? - .unwrap_or_default() - )); - } - Ok(format!( - "Published {} to {}", - published.branch, target.repository - )) - } else { - caos_cli::publication::publish_target( - transport, - &conversation, - &title, - &target, - &cancel, - ) - } - }, - move |result| UiMessage::Published { - conversation: finished, - result, - }, - ); - } -} diff --git a/rust/crates/caos-cli/src/bin/tui/ui.rs b/rust/crates/caos-cli/src/bin/tui/ui.rs index ff1a1a22f..7f8b0cd4c 100644 --- a/rust/crates/caos-cli/src/bin/tui/ui.rs +++ b/rust/crates/caos-cli/src/bin/tui/ui.rs @@ -49,16 +49,12 @@ pub(crate) fn render(app: &App, frame: &mut Frame<'_>) { render_composer( state, app.view, - !app.selection_locked - && app.palette.is_none() - && state.publish_plan.is_none() - && app.focus() == Focus::Conversation, + !app.selection_locked && app.palette.is_none() && app.focus() == Focus::Conversation, frame, areas.composer, ); render_footer(app, frame, areas.footer); render_command_palette(app, frame); - render_publication_plan(app, frame); render_screen_selection(app, frame); } @@ -312,7 +308,7 @@ pub(super) fn conversation_at(app: &App, terminal: Rect, column: u16, row: u16) } fn chat_areas(state: &ConversationState, area: Rect) -> (Rect, Option) { - if !state.running && !state.publishing && !state.source_tree_operation { + if !state.running && !state.source_tree_operation { return (area, None); } let split = Layout::default() @@ -379,14 +375,9 @@ fn render_header(app: &App, state: &ConversationState, frame: &mut Frame<'_>, ar Style::default().fg(Color::Cyan), ); } - if state.running || state.publishing { + if state.running { push_metadata( - if state.publishing { - "publishing" - } else { - "running" - } - .to_string(), + "running".to_string(), Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), @@ -459,8 +450,6 @@ fn render_conversations(app: &App, frame: &mut Frame<'_>, area: Rect) { ("*", Color::Yellow) } else if state.generating_title { ("~", Color::Magenta) - } else if state.publishing { - ("^", Color::Cyan) } else { (" ", Color::DarkGray) }; @@ -548,8 +537,6 @@ fn render_live_activity( ("Importing", state.status.as_str()) } else if let Some(activity) = state.running_activity() { (activity.running_verb(), activity.running_summary()) - } else if state.publishing { - ("Publishing", state.status.as_str()) } else { ( match state.turn_phase { @@ -1643,8 +1630,6 @@ fn render_footer(app: &App, frame: &mut Frame<'_>, area: Rect) { )) } else if app.palette.is_some() { Line::raw(" Command palette: type to filter Up/Dn select Enter runs Esc closes") - } else if app.selected().publish_plan.is_some() { - Line::raw(" Publication: Enter confirms Esc cancels") } else if app.focus() == Focus::List { Line::raw( " Conversations: Up/Dn select Enter opens ^N new ^Shift+P commands ^Up/Dn switch ^C quit", @@ -1667,7 +1652,7 @@ fn render_footer(app: &App, frame: &mut Frame<'_>, area: Rect) { "" }; Line::raw(format!( - " {send_shortcut} send Enter/^J newline ^Shift+P commands /checkout /pr ^O files ^H help{escape} ^C quit" + " {send_shortcut} send Enter/^J newline ^Shift+P commands /checkout ^O files ^H help{escape} ^C quit" )) }; frame.render_widget(Paragraph::new(footer), area); @@ -1703,78 +1688,6 @@ pub(super) fn scroll_offset(line_count: usize, height: u16, scroll: &ScrollState scroll.resolve(line_count.saturating_sub(visible)) } -fn render_publication_plan(app: &App, frame: &mut Frame<'_>) { - let Some(prompt) = &app.selected().publish_plan else { - return; - }; - let area = frame - .area() - .centered(Constraint::Percentage(90), Constraint::Length(17)); - frame.render_widget(Clear, area); - let mut lines = Vec::new(); - if prompt.loading { - lines.push(Line::from("Loading publication preview…")); - } else if let Some(target) = &prompt.target { - lines.push(Line::from(format!( - "Source: {} {}", - target.source_tree, - short_hash(&target.head) - ))); - lines.push(Line::from(format!("Repository: {}", target.repository))); - lines.push(Line::from(format!("Branch: {}", target.branch))); - if !prompt.branch_only { - lines.push(Line::from(format!( - "PR base: {} {}", - target.base_branch, - target - .base_commit - .as_deref() - .map(short_hash) - .unwrap_or_default() - ))); - } - lines.push(Line::from("")); - if let Some(path) = &target.base_import { - lines.push(Line::from("The source does not contain this PR base.")); - lines.push(Line::from(format!("Import to: {path}"))); - lines.push(Line::from( - "Enter imports the base and asks the agent to merge or rebase it and test.", - )); - lines.push(Line::from( - "Nothing is published. Run /pr again after reviewing the result.", - )); - } else { - lines.push(Line::from(if prompt.branch_only { - "Enter pushes this commit without creating a PR." - } else { - "Enter pushes this commit and opens or updates its PR." - })); - } - } - if let Some(error) = &prompt.error { - lines.push(Line::styled( - error.as_str(), - Style::default().fg(Color::Red), - )); - } - lines.push(Line::from( - "Esc cancels. To change the target, run the command again.", - )); - frame.render_widget( - Paragraph::new(lines).wrap(Wrap { trim: false }).block( - Block::default() - .title(if prompt.branch_only { - " Publish branch " - } else { - " Publish PR " - }) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ), - area, - ); -} - #[cfg(test)] mod tests { use super::*; diff --git a/rust/crates/caos-cli/src/host_git.rs b/rust/crates/caos-cli/src/host_git.rs index 107dde36e..edc61b6ce 100644 --- a/rust/crates/caos-cli/src/host_git.rs +++ b/rust/crates/caos-cli/src/host_git.rs @@ -1,4 +1,4 @@ -//! Host Git and GitHub operations shared by clients. +//! Host Git operations shared by clients. use std::path::Path; use std::process::{Command, Output}; @@ -99,125 +99,6 @@ pub fn local_default_branch_tip(cwd: &Path) -> Result<(String, String), String> Ok((branch, commit)) } -/// Accept either a branch name or the familiar `origin/` spelling. -pub fn pr_base_branch(input: &str) -> &str { - input.trim().strip_prefix("origin/").unwrap_or(input.trim()) -} - -pub fn pr_base_is_ancestor(target: &str, head: &str, cwd: &Path) -> Result { - let result = command_output("git", &["merge-base", "--is-ancestor", target, head], cwd)?; - if result.status.code() == Some(1) { - return Ok(false); - } - require_success("git merge-base --is-ancestor", result)?; - Ok(true) -} - -pub fn validate_pr_source_tree(target: &str, head: &str, cwd: &Path) -> Result<(), String> { - let ancestry = command_output("git", &["merge-base", target, head], cwd)?; - if ancestry.status.code() == Some(1) { - return Err("source commit and PR base have no shared history; check the source path, repository, and base branch".into()); - } - require_success("git merge-base", ancestry)?; - let markers = command_output( - "git", - &[ - "grep", - "-I", - "-n", - "-e", - "^<<<<<<< ", - "-e", - "^=======$", - "-e", - "^>>>>>>> ", - head, - "--", - ], - cwd, - )?; - if markers.status.success() { - return Err(format!( - "unresolved merge markers:\n{}", - String::from_utf8_lossy(&markers.stdout).trim_end() - )); - } - if markers.status.code() != Some(1) { - require_success("git grep", markers)?; - } - Ok(()) -} - -pub fn find_or_open_source_tree_pr_in( - repository: &str, - name: &str, - title: &str, - published: &crate::PublishedBranch, - base: &str, - cwd: &Path, -) -> Result { - let repository = crate::normalize_repository_identity(repository)?; - find_or_open_source_tree_pr_with(&repository, name, title, published, base, |args| { - capture_required("gh", args, cwd) - }) -} - -fn lookup_source_tree_pr_with( - repository: &str, - branch: &str, - gh: &mut impl FnMut(&[&str]) -> Result, -) -> Result, String> { - let matches = gh(&[ - "pr", "list", "--repo", repository, "--head", branch, "--state", "open", "--json", "url", - "--jq", ".[].url", - ])?; - let urls = matches - .lines() - .filter(|line| !line.trim().is_empty()) - .collect::>(); - match urls.as_slice() { - [] => Ok(None), - [url] => Ok(Some((*url).to_string())), - _ => Err(format!( - "several open PRs use branch {branch:?}; choose a distinct publication branch" - )), - } -} - -fn find_or_open_source_tree_pr_with( - repository: &str, - name: &str, - title: &str, - published: &crate::PublishedBranch, - base: &str, - mut gh: impl FnMut(&[&str]) -> Result, -) -> Result { - if let Some(existing) = lookup_source_tree_pr_with(repository, &published.branch, &mut gh)? { - gh(&[ - "pr", "edit", &existing, "--repo", repository, "--base", base, - ])?; - return Ok(existing); - } - let body = format!( - "Published source tree `{}` from CAOS conversation `{name}` at `{}`.", - published.source_tree, published.head - ); - gh(&[ - "pr", - "create", - "--repo", - repository, - "--head", - &published.branch, - "--base", - base, - "--title", - title, - "--body", - &body, - ]) -} - pub fn capture_required(program: &str, args: &[&str], cwd: &Path) -> Result { capture_required_bytes(program, args, cwd) .map(|bytes| String::from_utf8_lossy(&bytes).trim().to_string()) @@ -274,190 +155,6 @@ mod tests { capture_required("git", &["rev-parse", "HEAD"], dir).unwrap() } - #[test] - fn pr_preparation_fetches_the_base_without_touching_local_edits() { - let remote = temp_repo("pr-origin"); - capture_required("git", &["branch", "-M", "release/next"], &remote).unwrap(); - let base = commit_file(&remote, "base\n", "base"); - let repo = temp_repo("pr-local"); - let local = commit_file(&repo, "local\n", "local"); - capture_required( - "git", - &["remote", "add", "origin", remote.to_str().unwrap()], - &repo, - ) - .unwrap(); - std::fs::write(repo.join("file.txt"), "staged\n").unwrap(); - capture_required("git", &["add", "file.txt"], &repo).unwrap(); - std::fs::write(repo.join("file.txt"), "unstaged\n").unwrap(); - let index = capture_required("git", &["write-tree"], &repo).unwrap(); - - let transport = caos::GitTransport::discover(&repo).unwrap(); - let remote_url = remote.to_str().unwrap(); - assert_eq!( - crate::source_trees::default_branch(&transport, remote_url).unwrap(), - "release/next" - ); - assert_eq!(pr_base_branch(" origin/release/next "), "release/next"); - assert_eq!(pr_base_branch("release/next"), "release/next"); - assert_eq!( - crate::source_trees::branch_snapshot(&transport, remote_url, "release/next").unwrap(), - base - ); - assert!( - crate::source_trees::branch_snapshot(&transport, remote_url, "missing") - .unwrap_err() - .contains("does not exist") - ); - assert!( - crate::source_trees::branch_snapshot(&transport, remote_url, "../invalid").is_err() - ); - validate_pr_source_tree(&base, &base, &repo).unwrap(); - let tree = - capture_required("git", &["rev-parse", &format!("{base}^{{tree}}")], &repo).unwrap(); - let first = capture_required( - "git", - &["commit-tree", &tree, "-p", &base, "-m", "first branch"], - &repo, - ) - .unwrap(); - let second = capture_required( - "git", - &["commit-tree", &tree, "-p", &base, "-m", "second branch"], - &repo, - ) - .unwrap(); - validate_pr_source_tree(&first, &second, &repo).unwrap(); - assert!(validate_pr_source_tree(&base, &local, &repo) - .unwrap_err() - .contains("no shared history")); - assert!(validate_pr_source_tree("bad-revision", &base, &repo).is_err()); - let conflicted = commit_file( - &remote, - "<<<<<<< ours\nours\n=======\ntheirs\n>>>>>>> theirs\n", - "conflict", - ); - assert_eq!( - crate::source_trees::branch_snapshot(&transport, remote_url, "release/next").unwrap(), - conflicted - ); - assert!(validate_pr_source_tree(&base, &conflicted, &repo) - .unwrap_err() - .contains("unresolved merge markers")); - assert_eq!( - capture_required("git", &["rev-parse", "HEAD"], &repo).unwrap(), - local - ); - assert_eq!( - capture_required("git", &["write-tree"], &repo).unwrap(), - index - ); - assert_eq!( - std::fs::read_to_string(repo.join("file.txt")).unwrap(), - "unstaged\n" - ); - std::fs::remove_dir_all(repo).unwrap(); - std::fs::remove_dir_all(remote).unwrap(); - } - - #[test] - fn pr_publication_creates_or_reuses_the_matching_origin_pr() { - let published = crate::PublishedBranch { - source_tree: "docs".to_string(), - branch: "caos/talk-1".to_string(), - head: "a".repeat(40), - publication: "publication-1".to_string(), - status: conversation_protocol::v3::PublicationStatus::Complete, - observed: Some("a".repeat(40)), - }; - let url = "https://github.com/owner/repo/pull/1"; - for existing in [false, true] { - let mut calls = Vec::new(); - let result = find_or_open_source_tree_pr_with( - "https://github.com/owner/repo", - "talk-1", - "Fix documentation", - &published, - "release/next", - |args| { - calls.push(args.iter().map(|s| s.to_string()).collect::>()); - Ok(if args[1] == "list" && !existing { - "" - } else { - url - } - .to_string()) - }, - ) - .unwrap(); - assert_eq!(result, url); - assert_eq!(calls.len(), 2); - assert_eq!( - calls[0], - [ - "pr", - "list", - "--repo", - "https://github.com/owner/repo", - "--head", - "caos/talk-1", - "--state", - "open", - "--json", - "url", - "--jq", - ".[].url" - ] - ); - assert_eq!(calls[1][1], if existing { "edit" } else { "create" }); - if existing { - assert!( - !calls[1].iter().any(|arg| arg == "--title"), - "preserve a manually edited PR title" - ); - assert!(calls[1] - .windows(2) - .any(|pair| pair == ["--base", "release/next"])); - } else { - assert!(calls[1] - .windows(2) - .any(|pair| pair == ["--title", "Fix documentation"])); - } - assert!(calls[1] - .windows(2) - .any(|pair| pair == ["--repo", "https://github.com/owner/repo"])); - if existing { - assert_eq!(calls[1][2], url); - } else { - assert!(calls[1] - .windows(2) - .any(|pair| pair == ["--head", "caos/talk-1"])); - assert!(calls[1] - .windows(2) - .any(|pair| pair == ["--base", "release/next"])); - assert!(!calls[1].iter().any(|arg| arg == "--draft")); - } - } - let mut calls = 0; - let error = find_or_open_source_tree_pr_with( - "owner/repo", - "talk-1", - "title", - &published, - "main", - |_| { - calls += 1; - Err("GitHub unavailable".to_string()) - }, - ) - .unwrap_err(); - assert_eq!(error, "GitHub unavailable"); - assert_eq!( - calls, 1, - "a failed lookup must not attempt to create another PR" - ); - } - #[test] fn load_requires_a_clean_checkout_and_checks_out_the_conversation_head() { let dir = temp_repo("load-test"); diff --git a/rust/crates/caos-cli/src/lib.rs b/rust/crates/caos-cli/src/lib.rs index 66f871f56..d92361df3 100644 --- a/rust/crates/caos-cli/src/lib.rs +++ b/rust/crates/caos-cli/src/lib.rs @@ -5,11 +5,8 @@ pub use mcp::cli_mcp; pub mod filesystem; pub mod host_git; -pub mod publication; pub mod source_trees; -#[cfg(test)] -use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::io::{IsTerminal, Read, Write}; #[cfg(test)] @@ -27,14 +24,15 @@ use caos::{ use conversation_protocol::v3::apply::{ apply, client_signature, inherited_signature, mint, Transition, }; +#[cfg(test)] use conversation_protocol::v3::ids; use conversation_protocol::v3::oid::{ensure_genesis, G3}; use conversation_protocol::v3::paths; pub use conversation_protocol::v3::records::TurnStatus; use conversation_protocol::v3::records::{ - Block, Descriptor, Identity, IdentityKind, Proposal, PublicationRecord, PublicationStatus, - Role, SourceTreeResolution, ToolResult as ProtocolToolResult, TranscriptEntry, - TurnOutcome as ProtocolTurnOutcome, TurnRecord, + Block, Identity, IdentityKind, Proposal, PublicationStatus, Role, SourceTreeResolution, + ToolResult as ProtocolToolResult, TranscriptEntry, TurnOutcome as ProtocolTurnOutcome, + TurnRecord, }; use conversation_protocol::v3::refs; use conversation_protocol::v3::view::Conversation; @@ -65,11 +63,6 @@ const DEFAULT_SYSTEM: &str = "You are a coding agent operating on a conversation filesystem. Use the \ available tools for file access, builds, tests, and edits. Keep responses concise."; -#[cfg(test)] -std::thread_local! { - static AFTER_PENDING: RefCell>> = RefCell::new(None); -} - #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct TurnOptions { pub base: Option, @@ -238,16 +231,6 @@ pub struct PublicationSummary { pub status: PublicationStatus, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PublishedBranch { - pub source_tree: String, - pub branch: String, - pub head: String, - pub publication: String, - pub status: PublicationStatus, - pub observed: Option, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub struct UserConversationSummary { pub id: String, @@ -458,35 +441,6 @@ fn system_entry(id: &str, message_id: String, text: String) -> TranscriptEntry { } } -fn append_system_notice( - t: &GitTransport, - id: &str, - message_id: &str, - text: &str, -) -> Result<(), String> { - append_transition( - t, - id, - &refs::head_ref(id)?, - "recording client action", - |store, head| { - let view = Conversation::open(store, head)?; - if view - .transcript(0, view.transcript_len()?)? - .iter() - .any(|(_, _, entry)| entry.message_id == message_id) - { - return Ok(Step::Done(head.to_string())); - } - Ok(Step::Mint(Transition::MessageAppend { - entry: system_entry(id, message_id.into(), text.into()), - payloads: Vec::new(), - })) - }, - )?; - Ok(()) -} - fn user_entry( id: &str, username: &str, @@ -1312,11 +1266,6 @@ fn transcript_text_at_path( Ok(Some(text_blocks(&entry.blocks))) } -#[cfg(test)] -fn replay_at(store: &GitStore, head: &Oid) -> Result { - replay_from(store, head, &Conversation::open(store, head)?) -} - fn replay_from( store: &GitStore, head: &Oid, @@ -2184,366 +2133,6 @@ pub fn compare_and_set_conversation_title( pub use conversation_protocol::v3::source_trees::normalize_repository_identity; -pub fn origin_repository(t: &GitTransport) -> Result { - let url = t - .git_capture(&["remote", "get-url", "origin"], None) - .map_err(|error| format!("repository has no origin remote: {error}"))?; - normalize_repository_identity(&url) -} - -fn reject_publish_caos(t: &GitTransport, commit: &Oid) -> Result<(), String> { - let listing = t.git_capture( - &[ - "ls-tree", - "-r", - "--name-only", - commit.as_str(), - "--", - ".caos", - ], - None, - )?; - if listing.lines().any(|path| path == paths::CONFLICTS_LEDGER) { - let contents = t.git_capture( - &["show", &format!("{commit}:{}", paths::CONFLICTS_LEDGER)], - None, - )?; - return Err(if contents.trim().is_empty() { - "the source tree has an empty `.caos/conflicts` file; remove it with bash before publishing (the removal is committed automatically)".into() - } else { - "the source tree has unresolved `.caos/conflicts` entries; resolve the listed paths and clear their ledger entries; saving the resolution removes empty merge metadata".into() - }); - } - let root = t.git_capture( - &["ls-tree", "--name-only", commit.as_str(), "--", ".caos"], - None, - )?; - if root.trim().is_empty() { - Ok(()) - } else { - Err(format!( - "the source tree contains reserved `.caos` content; record a cleaned source-tree edit before publishing:\n{}", - if listing.trim().is_empty() { ".caos/ (empty directory)" } else { listing.trim_end() } - )) - } -} - -fn same_publication_intent(left: &PublicationRecord, right: &PublicationRecord) -> bool { - left.id == right.id - && left.key == right.key - && left.descriptor == right.descriptor - && left.planned_head == right.planned_head - && left.repository == right.repository - && left.refname == right.refname - && left.expected_old == right.expected_old - && left.source_tree_name == right.source_tree_name -} - -fn append_publication_pending( - t: &GitTransport, - id: &str, - refname: &str, - pending: &PublicationRecord, -) -> Result { - let mut result = None; - append_transition( - t, - id, - refname, - "recording publication intent", - |store, head| { - if let Some(existing) = Conversation::open(store, head)?.publication(&pending.id)? { - if !same_publication_intent(&existing, pending) { - return Err(format!( - "publication {:?} exists with a different intent", - pending.id - )); - } - result = Some(existing); - return Ok(Step::Done(head.to_string())); - } - let transitions = vec![Transition::PublicationPending { - record: pending.clone(), - }]; - result = Some(pending.clone()); - Ok(Step::MintMany(transitions)) - }, - )?; - Ok(result.expect("publication append always records a result")) -} - -use conversation_protocol::v3::publication::Outcome as PublicationOutcome; - -fn append_publication_terminal( - t: &GitTransport, - id: &str, - refname: &str, - publication: &str, - outcome: &PublicationOutcome, -) -> Result { - let mut result = None; - append_transition( - t, - id, - refname, - "recording publication outcome", - |store, head| { - let record = Conversation::open(store, head)? - .publication(publication)? - .ok_or_else(|| format!("publication {publication:?} disappeared"))?; - if record.status != PublicationStatus::Pending { - result = Some(record); - return Ok(Step::Done(head.to_string())); - } - let mut terminal = record; - terminal.status = outcome.status; - terminal.evidence = Some(outcome.evidence.clone()); - terminal.observed = outcome.observed.clone(); - let mut transitions = vec![Transition::PublicationTerminal { - publication: publication.to_string(), - status: outcome.status, - evidence: outcome.evidence.clone(), - observed: outcome.observed.clone(), - }]; - if outcome.status == PublicationStatus::Complete { - transitions.push(Transition::MessageAppend { - entry: system_entry( - id, - format!("push-{publication}"), - format!( - "Published {} ({}) to {} branch {}.", - terminal.source_tree_name, - terminal.planned_head, - terminal.repository, - terminal - .refname - .strip_prefix("refs/heads/") - .unwrap_or(&terminal.refname), - ), - ), - payloads: Vec::new(), - }); - } - result = Some(terminal); - Ok(Step::MintMany(transitions)) - }, - )?; - Ok(result.expect("publication append always records a result")) -} - -fn lease_rejection(error: &str) -> bool { - let error = error.to_ascii_lowercase(); - error.contains("stale info") || error.contains("force-with-lease") -} - -fn push_publication(origin: &GitStore, pending: &PublicationRecord) -> PublicationOutcome { - let update = RefUpdate { - refname: pending.refname.clone(), - expected: pending.expected_old.clone(), - new: Some(pending.planned_head.clone()), - }; - for attempt in 0..3 { - let error = match origin.push(std::slice::from_ref(&update)) { - Ok(()) => { - return PublicationOutcome::new( - PublicationStatus::Complete, - "push-success", - None, - Some(pending.planned_head.clone()), - ) - } - Err(error) => error, - }; - match origin.read_ref(&pending.refname) { - Ok(remote) => { - let outcome = PublicationOutcome::from_observation( - pending, - remote, - error.clone(), - lease_rejection(&error), - ); - if outcome.status != PublicationStatus::Uncertain || attempt == 2 { - return outcome; - } - } - Err(read_error) => { - return PublicationOutcome::new( - PublicationStatus::Uncertain, - "ambiguous", - Some(format!( - "{error}; reading {} failed: {read_error}", - pending.refname - )), - None, - ) - } - } - } - unreachable!("the last attempt always returns an outcome") -} - -pub fn publish_source_tree_branch( - t: &GitTransport, - id: &str, - source_tree: Option<&str>, - repository: &str, -) -> Result { - publish_source_tree_branch_inner(t, id, source_tree, None, repository, None) -} - -/// Publish exactly the selected commit. -pub fn publish_prepared_source_tree_branch( - t: &GitTransport, - id: &str, - source_tree: &str, - prepared_head: &str, - repository: &str, -) -> Result { - publish_source_tree_branch_inner( - t, - id, - Some(source_tree), - Some(prepared_head), - repository, - None, - ) -} - -fn publish_source_tree_branch_inner( - t: &GitTransport, - id: &str, - source_tree: Option<&str>, - prepared_head: Option<&str>, - repository_url: &str, - preview: Option<&source_trees::PublicationTarget>, -) -> Result { - refs::validate_conversation_id(id)?; - let mut store = open_store(t)?; - let Some((conversation_ref, head)) = fetch_validated_head(t, &store, id)? else { - return Err(format!("no conversation {id:?}")); - }; - let conversation = Conversation::open(&store, &head)?; - let source_tree = select_source_tree(&conversation, source_tree)?; - let record = conversation - .source_tree(&source_tree)? - .ok_or_else(|| format!("source tree {source_tree:?} disappeared"))?; - let planned_head = record.commit; - if prepared_head.is_some_and(|prepared| prepared != planned_head.as_str()) { - return Err(format!( - "source tree {source_tree:?} changed since the publication preview; review it again before publishing" - )); - } - let initial = conversation.reference_start(&source_tree)?; - let publications = conversation.publications()?; - let branch = preview - .map(|target| target.branch.clone()) - .unwrap_or_else(|| source_tree.clone()); - conversation_protocol::v3::source_trees::validate_repository(repository_url)?; - conversation_protocol::v3::source_trees::validate_branch(&branch)?; - let repository = normalize_repository_identity(repository_url)?; - drop(conversation); - - store.ensure_local(&planned_head)?; - store.read_commit(&planned_head).map_err(String::from)?; - reject_publish_caos(t, &planned_head)?; - ensure_code_commit(t, &mut store, &planned_head)?; - - let branch_ref = format!("refs/heads/{branch}"); - let origin = GitStore::open(t.work_dir(), Some(repository_url))?; - let expected_old = origin.read_ref(&branch_ref)?; - if preview.is_some_and(|target| { - target.remote_head.as_deref() != expected_old.as_ref().map(Oid::as_str) - }) { - return Err("remote branch changed since the preview; review again".into()); - } - for previous in publications { - if previous.status == PublicationStatus::Pending - && previous.repository == repository - && previous.refname == branch_ref - { - // A writer may still be alive: an unchanged ref is uncertain, not - // proof of failure. Observe old intents without replaying old code. - let outcome = PublicationOutcome::from_observation( - &previous, - expected_old.clone(), - "recovered an unfinished publication before retrying".to_string(), - false, - ); - append_publication_terminal(t, id, &conversation_ref, &previous.id, &outcome)?; - } - } - if let Some(old) = &expected_old { - origin.ensure_local(old)?; - if !conversation_protocol::v3::CodeOps::is_ancestor(&origin, old, &planned_head)? { - return Err(format!( - "publishing source tree {source_tree:?} would not fast-forward {branch}: remote {old} is not an ancestor of {planned_head}; merge the remote changes first", - )); - } - } - let descriptor = Descriptor { - source_base: initial.clone(), - source_head: planned_head.clone(), - target_base: initial, - policy: "preserve".to_string(), - implementation: "caos-cli/preserve".to_string(), - commit_policy: "preserve".to_string(), - }; - let projection = ids::projection_id(&descriptor.to_value())?; - let key = caos::fresh_entropy()?; - ids::validate_client_key(&key)?; - let publication = ids::publication_id( - id, - &key, - &projection, - &planned_head, - &repository, - &branch_ref, - expected_old.as_ref(), - )?; - let pending = PublicationRecord { - id: publication.clone(), - key, - descriptor, - planned_head: planned_head.clone(), - repository, - refname: branch_ref.clone(), - expected_old: expected_old.clone(), - source_tree_name: source_tree.clone(), - status: PublicationStatus::Pending, - evidence: None, - observed: None, - }; - let joined = append_publication_pending(t, id, &conversation_ref, &pending)?; - if joined.status != PublicationStatus::Pending { - return Ok(PublishedBranch { - source_tree, - branch, - head: planned_head.to_string(), - publication, - status: joined.status, - observed: joined.observed.map(|oid| oid.to_string()), - }); - } - - #[cfg(test)] - AFTER_PENDING.with(|after| { - if let Some(after) = after.borrow_mut().take() { - after(); - } - }); - - let outcome = push_publication(&origin, &pending); - let terminal = append_publication_terminal(t, id, &conversation_ref, &publication, &outcome)?; - Ok(PublishedBranch { - source_tree, - branch, - head: planned_head.to_string(), - publication, - status: terminal.status, - observed: terminal.observed.map(|oid| oid.to_string()), - }) -} - pub fn publication_diagnostic( t: &GitTransport, id: &str, @@ -3392,10 +2981,6 @@ mod tests { git(transport.work_dir(), &["rev-parse", "HEAD"]) } - fn publishing_repository(transport: &GitTransport) -> String { - git(transport.work_dir(), &["remote", "get-url", "origin"]) - } - fn create_idle_conversation(transport: &GitTransport, id: &str, base: &str) { submit_message_inner_with( transport, @@ -3476,6 +3061,97 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } + #[test] + fn existing_publication_records_remain_readable() { + use conversation_protocol::v3::{Descriptor, Evidence, PublicationRecord}; + let (root, transport, base) = fixture("publication-read"); + create_idle_conversation(&transport, "published", &base); + let store = open_store(&transport).unwrap(); + let (_, head) = fetch_validated_head(&transport, &store, "published") + .unwrap() + .unwrap(); + let identity = Conversation::open(&store, &head) + .unwrap() + .identity() + .unwrap() + .id; + let commit = oid(&base, "commit").unwrap(); + let descriptor = Descriptor { + source_base: commit.clone(), + source_head: commit.clone(), + target_base: commit.clone(), + policy: "preserve".into(), + implementation: "caos/host".into(), + commit_policy: "preserve".into(), + }; + let key = "a".repeat(32); + let id = ids::publication_id( + &identity, + &key, + &ids::projection_id(&descriptor.to_value()).unwrap(), + &commit, + "https://example.com/repo", + "refs/heads/main", + None, + ) + .unwrap(); + let record = PublicationRecord { + id: id.clone(), + key, + descriptor, + planned_head: commit.clone(), + repository: "https://example.com/repo".into(), + refname: "refs/heads/main".into(), + expected_old: None, + source_tree_name: "main".into(), + status: PublicationStatus::Pending, + evidence: None, + observed: None, + }; + let reference = refs::head_ref("published").unwrap(); + append_transition( + &transport, + "published", + &reference, + "fixture publication", + |_, _| { + Ok(Step::Mint(Transition::PublicationPending { + record: record.clone(), + })) + }, + ) + .unwrap(); + append_transition( + &transport, + "published", + &reference, + "fixture result", + |_, _| { + Ok(Step::Mint(Transition::PublicationTerminal { + publication: id.clone(), + status: PublicationStatus::Complete, + evidence: Evidence { + kind: "push-success".into(), + diagnostic: Some("published".into()), + }, + observed: Some(commit.clone()), + })) + }, + ) + .unwrap(); + let loaded = conversation_load(&transport, "published").unwrap().unwrap(); + assert_eq!(loaded.publications.len(), 1); + assert_eq!(loaded.publications[0].id, id); + assert_eq!(loaded.publications[0].status, PublicationStatus::Complete); + assert_eq!( + publication_diagnostic(&transport, "published", &id) + .unwrap() + .as_deref(), + Some("published") + ); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn generated_title_parser_is_strict() { assert_eq!( @@ -3499,78 +3175,6 @@ mod tests { ); } - #[test] - fn publication_retry_recovers_abandoned_pending_attempt() { - for pushed in [false, true] { - let (root, transport, base) = fixture("publish-abandoned"); - create_idle_conversation(&transport, "retry-talk", &base); - let repo = transport.work_dir().to_path_buf(); - let planned = base.clone(); - AFTER_PENDING.with(|after| { - *after.borrow_mut() = Some(Box::new(move || { - if pushed { - git( - &repo, - &[ - "push", - "--quiet", - "origin", - &format!("{planned}:refs/heads/main"), - ], - ); - } - panic!("simulated client exit"); - })); - }); - assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - publish_source_tree_branch( - &transport, - "retry-talk", - None, - &publishing_repository(&transport), - ) - })) - .is_err()); - let retry = publish_source_tree_branch( - &transport, - "retry-talk", - None, - &publishing_repository(&transport), - ) - .unwrap(); - assert_eq!(retry.status, PublicationStatus::Complete); - let head = conversation_head(&transport, "retry-talk") - .unwrap() - .unwrap(); - let store = open_store(&transport).unwrap(); - let records = Conversation::open(&store, &oid(&head, "head").unwrap()) - .unwrap() - .publications() - .unwrap(); - assert_eq!(records.len(), 2); - let recovered = records - .iter() - .find(|record| record.id != retry.publication) - .unwrap(); - assert_eq!( - recovered.status, - if pushed { - PublicationStatus::Complete - } else { - PublicationStatus::Uncertain - } - ); - assert_eq!( - recovered.observed.as_ref().map(Oid::as_str), - pushed.then_some(base.as_str()) - ); - fork_conversation(&transport, "Alice", "after-retry", "fork", &head) - .expect("retry must not leave the conversation unforkable"); - drop(store); - std::fs::remove_dir_all(root).unwrap(); - } - } - #[test] fn empty_conversations_accept_messages_and_later_attachments() { let (root, transport, base) = fixture("empty-launch"); @@ -3963,688 +3567,6 @@ mod tests { std::fs::remove_dir_all(other_root).unwrap(); } - #[test] - fn directory_stack_publishes_previewed_commits_and_rejects_changes() { - let (root, transport, base) = fixture("directory-stack"); - git( - transport.work_dir(), - &[ - "push", - "--quiet", - "origin", - &format!("{base}:refs/heads/main"), - ], - ); - create_idle_conversation(&transport, "stack", &base); - let repository = root.join("origin.git").to_str().unwrap().to_string(); - let one = commit_file(&transport, &base, "first change\n", "first"); - let two = commit_file(&transport, &one, "second change\n", "second"); - fixture_reference(&transport, "stack", "feature/00-base", Some(&base)).unwrap(); - fixture_reference(&transport, "stack", "feature/apples", Some(&one)).unwrap(); - fixture_reference(&transport, "stack", "feature/dirty", Some(&two)).unwrap(); - fixture_reference(&transport, "stack", "imports/repo/base", Some(&base)).unwrap(); - let write_metadata = |path: &str, value: Option>| { - append_transition( - &transport, - "stack", - &refs::head_ref("stack").unwrap(), - "import provenance", - |_, _| { - Ok(Step::Mint(Transition::FilesApply { - files: vec![( - path.into(), - value - .clone() - .map(|bytes| (conversation_protocol::v3::Mode::Blob, bytes)), - )], - })) - }, - ) - .unwrap(); - }; - write_metadata( - "imports/repo/base.source.json", - Some(br#"{"repository":"https://example.com/repo","default_branch":"main"}"#.to_vec()), - ); - assert_eq!( - source_trees::publication_provenance(&transport, "stack", "feature/apples") - .unwrap() - .unwrap(), - "https://example.com/repo" - ); - fixture_reference(&transport, "stack", "imports/other/base", Some(&base)).unwrap(); - write_metadata( - "imports/other/base.source.json", - Some(br#"{"repository":"https://example.com/other","default_branch":"main"}"#.to_vec()), - ); - assert!( - source_trees::publication_provenance(&transport, "stack", "feature/apples") - .unwrap() - .is_none() - ); - // A legacy policy file cannot supply or override an explicit destination. - write_metadata( - "feature/.base-url", - Some(b"https://wrong.example/repo\nwrong\n".to_vec()), - ); - assert!(source_trees::prepare_publication( - &transport, - "stack", - "feature/apples", - Some("main"), - None - ) - .unwrap_err() - .contains("unambiguous")); - assert!(source_trees::prepare_publication( - &transport, - "stack", - "missing", - Some("main"), - Some(&repository) - ) - .is_err()); - // Repository inference is independent of the metadata's default branch. - let remote_url = format!("file://{repository}"); - for (path, branch) in [ - ("imports/repo/base.source.json", "main"), - ("imports/other/base.source.json", "develop"), - ] { - write_metadata( - path, - Some( - serde_json::json!({"repository": remote_url, "default_branch": branch}) - .to_string() - .into_bytes(), - ), - ); - } - let inferred = source_trees::prepare_publication( - &transport, - "stack", - "feature/apples", - Some("main"), - None, - ) - .unwrap(); - assert_eq!(inferred.repository, remote_url); - assert_eq!(inferred.base_branch, "main"); - let content_head = conversation_head(&transport, "stack").unwrap(); - let first = source_trees::prepare_publication( - &transport, - "stack", - "feature/apples", - Some("main"), - Some(&repository), - ) - .unwrap(); - assert_eq!( - conversation_head(&transport, "stack").unwrap(), - content_head - ); - assert_eq!(first.head, one); - assert_eq!(first.repository, repository); - source_trees::publish_target(&transport, "stack", &first, &base).unwrap(); - - // The explicit base wins even for a later sibling; a single PR is not an implicit stack. - let independent = source_trees::prepare_publication( - &transport, - "stack", - "feature/dirty", - Some("main"), - Some(&repository), - ) - .unwrap(); - assert_eq!(independent.base_branch, "main"); - assert_eq!(independent.base_commit.as_deref(), Some(base.as_str())); - let second = source_trees::prepare_publication( - &transport, - "stack", - "feature/dirty", - Some("feature/apples"), - Some(&repository), - ) - .unwrap(); - assert_eq!(second.base_commit.as_deref(), Some(one.as_str())); - source_trees::publish_target(&transport, "stack", &second, &one).unwrap(); - assert_eq!( - git( - &root.join("origin.git"), - &["rev-parse", "refs/heads/feature/apples"] - ), - one - ); - assert_eq!( - git( - &root.join("origin.git"), - &["rev-parse", "refs/heads/feature/dirty"] - ), - two - ); - - // A preview never authorizes different content or a changed remote branch. - assert!(source_trees::publish_target(&transport, "stack", &first, &base).is_err()); - let fresh = source_trees::prepare_publication( - &transport, - "stack", - "feature/apples", - Some("main"), - Some(&repository), - ) - .unwrap(); - fixture_reference(&transport, "stack", "feature/apples", Some(&two)).unwrap(); - assert!(source_trees::publish_target(&transport, "stack", &fresh, &base).is_err()); - - // A moved base needs an explicit import/agent handoff, never a push. - let advanced = commit_file(&transport, &base, "remote advance\n", "remote"); - git( - transport.work_dir(), - &[ - "push", - "--quiet", - "origin", - &format!("{advanced}:refs/heads/main"), - ], - ); - let plan = source_trees::prepare_publication( - &transport, - "stack", - "feature/dirty", - Some("main"), - Some(&repository), - ) - .unwrap(); - assert!(plan.base_import.is_some()); - assert!( - source_trees::publish_target(&transport, "stack", &plan, &advanced) - .unwrap_err() - .contains("does not contain") - ); - let message = source_trees::import_publication_base(&transport, "stack", &plan).unwrap(); - assert!(message.contains(&advanced)); - assert!(message.contains("feature/dirty")); - assert!(message.contains("Do not publish")); - let imported_head = conversation_head(&transport, "stack").unwrap(); - assert_eq!( - source_trees::import_publication_base(&transport, "stack", &plan).unwrap(), - message - ); - assert_eq!( - conversation_head(&transport, "stack").unwrap(), - imported_head - ); - let load = conversation_load(&transport, "stack").unwrap().unwrap(); - assert!(load - .source_trees - .iter() - .any(|entry| Some(&entry.name) == plan.base_import.as_ref() && entry.head == advanced)); - assert_eq!( - git( - &root.join("origin.git"), - &["rev-parse", "refs/heads/feature/dirty"] - ), - two - ); - let next_base = commit_file( - &transport, - &advanced, - "another remote advance\n", - "new remote", - ); - git( - transport.work_dir(), - &[ - "push", - "--quiet", - "origin", - &format!("{next_base}:refs/heads/main"), - ], - ); - assert!( - source_trees::import_publication_base(&transport, "stack", &plan) - .unwrap_err() - .contains("base changed") - ); - assert_eq!( - conversation_head(&transport, "stack").unwrap(), - imported_head - ); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn publication_fast_forwards_but_rejects_divergent_source_trees() { - let (root, transport, base) = fixture("publish-advance"); - create_idle_conversation(&transport, "advance-talk", &base); - publish_source_tree_branch( - &transport, - "advance-talk", - None, - &publishing_repository(&transport), - ) - .unwrap(); - let next = commit_file(&transport, &base, "updated\n", "updated"); - submit_message_inner_with( - &transport, - &options(&transport), - "advance-talk", - "use the edit", - false, - Some(&next), - Some(&base), - |_, _, _, _| Ok("b".repeat(40)), - ) - .unwrap(); - interrupt_request(&transport, "advance-talk").unwrap(); - let published = publish_source_tree_branch( - &transport, - "advance-talk", - None, - &publishing_repository(&transport), - ) - .unwrap(); - assert_eq!(published.status, PublicationStatus::Complete); - assert_eq!(published.head, next); - let source_head = conversation_head(&transport, "advance-talk") - .unwrap() - .unwrap(); - fork_conversation(&transport, "Alice", "forked-talk", "fork", &source_head).unwrap(); - let forked = publish_source_tree_branch( - &transport, - "forked-talk", - None, - &publishing_repository(&transport), - ) - .unwrap(); - assert_eq!(forked.branch, "main"); - assert_eq!( - git(&root.join("origin.git"), &["rev-parse", "refs/heads/main"]), - next - ); - - let side = commit_file(&transport, &base, "side source tree\n", "side"); - fixture_reference(&transport, "advance-talk", "side", Some(&side)).unwrap(); - let side_publication = publish_source_tree_branch( - &transport, - "advance-talk", - Some("side"), - &publishing_repository(&transport), - ) - .unwrap(); - assert_eq!(side_publication.status, PublicationStatus::Complete); - assert_eq!(side_publication.branch, "side"); - // An outside writer can still advance a source tree's own branch. Preserve it. - git( - &root.join("origin.git"), - &["update-ref", "refs/heads/side", &next], - ); - let error = publish_source_tree_branch( - &transport, - "advance-talk", - Some("side"), - &publishing_repository(&transport), - ) - .unwrap_err(); - assert!(error.contains("would not fast-forward"), "{error}"); - assert_eq!( - git(&root.join("origin.git"), &["rev-parse", "refs/heads/main"]), - next - ); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn source_tree_branch_publication_records_each_attempt_and_never_creates_a_local_branch() { - let (root, transport, base) = fixture("publish-branch"); - create_idle_conversation(&transport, "publish-talk", &base); - let branch_ref = "refs/heads/main"; - let local_before = git( - transport.work_dir(), - &[ - "for-each-ref", - "--format=%(refname) %(objectname)", - "refs/heads", - ], - ); - - let before = conversation_head(&transport, "publish-talk").unwrap(); - let error = publish_prepared_source_tree_branch( - &transport, - "publish-talk", - "main", - &"0".repeat(40), - &publishing_repository(&transport), - ) - .unwrap_err(); - assert!( - error.contains("changed since the publication preview"), - "{error}" - ); - assert_eq!( - conversation_head(&transport, "publish-talk").unwrap(), - before - ); - assert!(git(&root.join("origin.git"), &["for-each-ref", branch_ref]).is_empty()); - - let first = publish_prepared_source_tree_branch( - &transport, - "publish-talk", - "main", - &base, - &publishing_repository(&transport), - ) - .unwrap(); - assert_eq!(first.source_tree, "main"); - assert_eq!(first.branch, "main"); - assert_eq!(first.head, base); - assert_eq!(first.status, PublicationStatus::Complete); - assert_eq!(first.observed.as_deref(), Some(base.as_str())); - assert_eq!( - git(&root.join("origin.git"), &["rev-parse", branch_ref]), - base - ); - assert_eq!( - git( - transport.work_dir(), - &[ - "for-each-ref", - "--format=%(refname) %(objectname)", - "refs/heads" - ] - ), - local_before - ); - - let store = open_store(&transport).unwrap(); - let (_, first_head) = fetch_validated_head(&transport, &store, "publish-talk") - .unwrap() - .unwrap(); - let notice = Conversation::open(&store, &first_head).unwrap(); - assert_eq!(notice.kind(), Some(Kind::MessageAppend)); - let replay = replay_at(&store, &first_head).unwrap(); - let last = replay.turns.last().unwrap(); - assert_eq!(last.role, ConversationRole::System); - assert!(last.message.contains("Published main")); - let terminal_head = notice.parent().unwrap().clone(); - let complete = Conversation::open(&store, &terminal_head).unwrap(); - assert_eq!(complete.kind(), Some(Kind::PublicationTerminal)); - let first_record = complete.publication(&first.publication).unwrap().unwrap(); - assert_eq!(first_record.planned_head.as_str(), base); - assert_eq!(first_record.expected_old, None); - assert_eq!(first_record.status, PublicationStatus::Complete); - let pending_head = complete.parent().unwrap().clone(); - let pending = Conversation::open(&store, &pending_head).unwrap(); - assert_eq!(pending.kind(), Some(Kind::PublicationPending)); - assert_eq!( - pending - .publication(&first.publication) - .unwrap() - .unwrap() - .status, - PublicationStatus::Pending - ); - drop(pending); - drop(complete); - drop(store); - - let second = publish_source_tree_branch( - &transport, - "publish-talk", - None, - &publishing_repository(&transport), - ) - .unwrap(); - assert_eq!(second.status, PublicationStatus::Complete); - assert_ne!(second.publication, first.publication); - let store = open_store(&transport).unwrap(); - let (_, second_head) = fetch_validated_head(&transport, &store, "publish-talk") - .unwrap() - .unwrap(); - let conversation = Conversation::open(&store, &second_head).unwrap(); - let second_record = conversation - .publication(&second.publication) - .unwrap() - .unwrap(); - assert_eq!( - second_record.expected_old.as_ref().map(Oid::as_str), - Some(base.as_str()) - ); - assert_eq!(conversation.publications().unwrap().len(), 2); - let summaries = publication_summaries(&conversation).unwrap(); - assert_eq!(summaries[0].id, second.publication); - assert_eq!(summaries[1].id, first.publication); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn publication_preserves_a_remote_commit_present_before_publish() { - let (root, transport, base) = fixture("publish-existing-drift"); - create_idle_conversation(&transport, "existing-drift-talk", &base); - publish_source_tree_branch( - &transport, - "existing-drift-talk", - None, - &publishing_repository(&transport), - ) - .unwrap(); - let teammate = commit_file(&transport, &base, "teammate change\n", "teammate"); - let branch_ref = "refs/heads/main"; - git( - transport.work_dir(), - &[ - "push", - "--quiet", - "origin", - &format!("{teammate}:{branch_ref}"), - ], - ); - let error = publish_source_tree_branch( - &transport, - "existing-drift-talk", - None, - &publishing_repository(&transport), - ) - .unwrap_err(); - assert!(error.contains("would not fast-forward"), "{error}"); - assert_eq!( - git(&root.join("origin.git"), &["rev-parse", branch_ref]), - teammate - ); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn source_tree_branch_publication_records_remote_drift_without_overwriting_it() { - let (root, transport, base) = fixture("publish-conflict"); - create_idle_conversation(&transport, "conflict-talk", &base); - publish_source_tree_branch( - &transport, - "conflict-talk", - None, - &publishing_repository(&transport), - ) - .unwrap(); - let unrelated = commit_file(&transport, &base, "unrelated\n", "unrelated"); - let branch_ref = "refs/heads/main"; - git( - transport.work_dir(), - &[ - "push", - "--quiet", - "origin", - &format!("{unrelated}:refs/heads/intruder"), - ], - ); - // The exact lease rejects a move after expected_old is observed. Move - // origin after the pending record is appended to make that race - // deterministic without relying on Git hooks in the test environment. - let origin = root.join("origin.git"); - let injected = unrelated.clone(); - let expected = base.clone(); - AFTER_PENDING.with(|after| { - *after.borrow_mut() = Some(Box::new(move || { - git( - &origin, - &[ - "update-ref", - branch_ref, - injected.as_str(), - expected.as_str(), - ], - ); - })); - }); - - let published = publish_source_tree_branch( - &transport, - "conflict-talk", - None, - &publishing_repository(&transport), - ) - .unwrap(); - assert_eq!(published.status, PublicationStatus::Conflict); - assert_eq!(published.observed.as_deref(), Some(unrelated.as_str())); - assert_eq!( - git(&root.join("origin.git"), &["rev-parse", branch_ref]), - unrelated - ); - let store = open_store(&transport).unwrap(); - let (_, head) = fetch_validated_head(&transport, &store, "conflict-talk") - .unwrap() - .unwrap(); - let record = Conversation::open(&store, &head) - .unwrap() - .publication(&published.publication) - .unwrap() - .unwrap(); - assert_eq!(record.status, PublicationStatus::Conflict); - assert_eq!( - record.expected_old.as_ref().map(Oid::as_str), - Some(base.as_str()) - ); - assert_eq!( - record.observed.as_ref().map(Oid::as_str), - Some(unrelated.as_str()) - ); - assert_eq!(record.evidence.unwrap().kind, "lease-rejected"); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn source_tree_branch_publication_rejects_reserved_state_before_recording() { - let (root, transport, base) = fixture("publish-conflicts-guard"); - git( - transport.work_dir(), - &["checkout", "--quiet", "--detach", &base], - ); - std::fs::create_dir_all(transport.work_dir().join(".caos")).unwrap(); - std::fs::write( - transport.work_dir().join(".caos/conflicts"), - "100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 2\tsource_tree\n", - ) - .unwrap(); - git(transport.work_dir(), &["add", ".caos/conflicts"]); - git( - transport.work_dir(), - &["commit", "--quiet", "-m", "conflicted source tree"], - ); - let conflicted = git(transport.work_dir(), &["rev-parse", "HEAD"]); - create_idle_conversation(&transport, "guard-talk", &conflicted); - let before = conversation_head(&transport, "guard-talk") - .unwrap() - .unwrap(); - - let error = publish_source_tree_branch( - &transport, - "guard-talk", - None, - &publishing_repository(&transport), - ) - .unwrap_err(); - assert_eq!( - error, - "the source tree has unresolved `.caos/conflicts` entries; resolve the listed paths and clear their ledger entries; saving the resolution removes empty merge metadata" - ); - assert_eq!( - conversation_head(&transport, "guard-talk") - .unwrap() - .unwrap(), - before - ); - let store = open_store(&transport).unwrap(); - let head = oid(&before, "guard head").unwrap(); - assert!(Conversation::open(&store, &head) - .unwrap() - .publications() - .unwrap() - .is_empty()); - assert_eq!( - GitStore::open(transport.work_dir(), Some("origin")) - .unwrap() - .read_ref("refs/heads/caos/guard-talk") - .unwrap(), - None - ); - // Publication also rejects legacy metadata, including an actual empty tree. - use conversation_protocol::v3::tree::TreeBuilder; - use conversation_protocol::v3::Mode; - let mut store = open_store(&transport).unwrap(); - let base_tree = oid( - &git( - transport.work_dir(), - &["rev-parse", &format!("{base}^{{tree}}")], - ), - "tree", - ) - .unwrap(); - let empty = TreeBuilder::from(None).build(&mut store).unwrap(); - for (name, content, expected) in [ - ("empty-ledger", Some(""), "empty `.caos/conflicts` file"), - ("other-state", None, "reserved `.caos` content"), - ("empty-directory", None, ".caos/ (empty directory)"), - ] { - let mut tree = TreeBuilder::from(Some(base_tree.clone())); - tree.put_oid(".caos", Mode::Tree, empty.clone()); - if let Some(content) = content { - tree.put(".caos/conflicts", Mode::Blob, content.as_bytes().to_vec()); - } else if name == "other-state" { - tree.put(".caos/format", Mode::Blob, b"protocol".to_vec()); - } - let tree = if name == "empty-directory" { - // TreeBuilder prunes empty directories; construct the Git entry directly. - let mut raw = b"40000 .caos\0".to_vec(); - raw.extend( - (0..40) - .step_by(2) - .map(|i| u8::from_str_radix(&empty.as_str()[i..i + 2], 16).unwrap()), - ); - oid( - &transport.put_object("tree", &raw).unwrap().to_string(), - "tree", - ) - .unwrap() - } else { - tree.build(&mut store).unwrap() - }; - let commit = git( - transport.work_dir(), - &["commit-tree", tree.as_str(), "-p", &base, "-m", name], - ); - create_idle_conversation(&transport, name, &commit); - fixture_reference(&transport, name, "main", Some(&commit)).unwrap(); - let result = source_trees::prepare_publication( - &transport, - name, - "main", - None, - Some(&publishing_repository(&transport)), - ); - let error = result.unwrap_err(); - assert!(error.contains(expected), "{name}: {error}"); - } - std::fs::remove_dir_all(root).unwrap(); - } - #[test] fn repository_identity_normalizes_scp_and_https_spellings() { assert_eq!( diff --git a/rust/crates/caos-cli/src/publication.rs b/rust/crates/caos-cli/src/publication.rs deleted file mode 100644 index fe50cae13..000000000 --- a/rust/crates/caos-cli/src/publication.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Prepare, validate, publish and open a PR for an explicit source tree plan. -//! Client focus cannot retarget this operation; progress and cancellation are inputs. -use super::*; -use crate::host_git::{find_or_open_source_tree_pr_in, validate_pr_source_tree}; -use crate::source_trees::{branch_snapshot, PublicationTarget}; -use std::sync::atomic::{AtomicBool, Ordering}; - -pub fn publish_target( - transport: &GitTransport, - conversation: &str, - title: &str, - target: &PublicationTarget, - cancel: &AtomicBool, -) -> Result { - if cancel.load(Ordering::Relaxed) { - return Err("publication cancelled".into()); - } - let load = conversation_load(transport, conversation)?.ok_or("conversation disappeared")?; - let source_tree = load - .source_trees - .iter() - .find(|source_tree| source_tree.name == target.source_tree) - .ok_or("source tree disappeared")?; - if source_tree.head != target.head { - return Err(format!( - "source tree {:?} changed since the publication preview; review it again", - target.source_tree - )); - } - let base = branch_snapshot(transport, &target.repository, &target.base_branch)?; - if cancel.load(Ordering::Relaxed) { - return Err("publication cancelled".into()); - } - validate_pr_source_tree(&base, &target.head, transport.work_dir())?; - let published = crate::source_trees::publish_target(transport, conversation, target, &base)?; - if published.status != conversation_protocol::v3::PublicationStatus::Complete { - return Err(format!( - "branch publication is {:?}: {}", - published.status, - publication_diagnostic(transport, conversation, &published.publication)? - .unwrap_or_default() - )); - } - if cancel.load(Ordering::Relaxed) { - return Err(format!( - "branch {} was published; PR creation was cancelled", - published.branch - )); - } - let url = find_or_open_source_tree_pr_in( - &target.repository, - conversation, - &format!("{}: {title}", target.source_tree), - &published, - &target.base_branch, - transport.work_dir(), - )?; - let notice = format!( - "PR: {url}\nBranch: {}; base: {}; commit: {}.", - published.branch, target.base_branch, published.head - ); - append_system_notice( - transport, - conversation, - &format!("pr-{}", published.publication), - ¬ice, - ) - .map_err(|error| { - format!( - "PR is available at {url}, but recording it in conversation history failed: {error}" - ) - })?; - Ok(url) -} diff --git a/rust/crates/caos-cli/src/source_trees.rs b/rust/crates/caos-cli/src/source_trees.rs index 0c4f04cb7..b06578214 100644 --- a/rust/crates/caos-cli/src/source_trees.rs +++ b/rust/crates/caos-cli/src/source_trees.rs @@ -1,4 +1,4 @@ -//! Client imports and publication of explicitly selected commits. +//! Client imports of explicitly selected commits. use super::*; use conversation_protocol::v3::source_trees::{validate_repository, validate_source}; use conversation_protocol::v3::Mode; @@ -254,262 +254,3 @@ pub fn import_source( )?; Ok(commit.to_string()) } - -/// An explicit client choice, never read from conversation publication policy. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct PublicationDestination { - pub repository: String, - pub base_branch: String, -} - -pub fn stack_directory(path: &str) -> &str { - path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("") -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PublicationTarget { - pub source_tree: String, - pub head: String, - pub repository: String, - pub branch: String, - pub base_branch: String, - pub base_commit: Option, - pub remote_head: Option, - pub base_import: Option, -} - -/// Capture exactly the gitlink named by the client, independent of sibling order. -pub fn publication_target( - t: &GitTransport, - id: &str, - source: &str, -) -> Result { - let store = open_store(t)?; - let (_, head) = fetch_validated_head(t, &store, id)?.ok_or("conversation disappeared")?; - let view = Conversation::open(&store, &head)?; - let entry = view - .source_tree(source)? - .ok_or_else(|| format!("no source-tree gitlink at {source:?}"))?; - Ok(PublicationTarget { - source_tree: source.into(), - head: entry.commit.to_string(), - repository: String::new(), - branch: source.into(), - base_branch: String::new(), - base_commit: None, - remote_head: None, - base_import: None, - }) -} - -/// A hint only when the oldest sibling exactly matches an imported gitlink. -/// Multiple origins remain ambiguous; callers present the choice to the user. -pub fn publication_provenance( - t: &GitTransport, - id: &str, - source: &str, -) -> Result, String> { - let store = open_store(t)?; - let (_, head) = fetch_validated_head(t, &store, id)?.ok_or("conversation disappeared")?; - let view = Conversation::open(&store, &head)?; - let names = view.source_tree_names()?; - let Some(first) = names - .iter() - .find(|name| stack_directory(name) == stack_directory(source)) - else { - return Ok(None); - }; - let base = view.source_tree(first)?.ok_or("entry disappeared")?.commit; - let snapshot = conversation_protocol::v3::tree::Snapshot::new( - &store, - store.read_commit(&head).map_err(String::from)?.tree, - ); - let mut choices = Vec::new(); - for name in names { - if view - .source_tree(&name)? - .is_none_or(|entry| entry.commit != base) - { - continue; - } - let Some(bytes) = snapshot.read(&format!("{name}.source.json"))? else { - continue; - }; - let Ok(value) = serde_json::from_slice::(&bytes) else { - continue; - }; - let Some(repository) = value["repository"].as_str() else { - continue; - }; - if validate_repository(repository).is_err() { - continue; - } - let choice = repository.to_string(); - if !choices.contains(&choice) { - choices.push(choice); - } - } - Ok((choices.len() == 1).then(|| choices.remove(0))) -} - -/// Resolve one explicit client command before asking for confirmation. -pub fn prepare_publication( - t: &GitTransport, - id: &str, - source: &str, - base_branch: Option<&str>, - repository: Option<&str>, -) -> Result { - let mut target = publication_target(t, id, source)?; - let repository = match repository { - Some(repository) => repository.to_owned(), - None => publication_provenance(t, id, source)? - .ok_or("no unambiguous import repository; supply the remote URL in the command")?, - }; - if !repository.contains(':') && !repository.starts_with('/') { - return Err("remote must be a repository URL or absolute path, not a local remote name such as origin".into()); - } - let destination = PublicationDestination { - repository, - base_branch: base_branch.unwrap_or("").into(), - }; - resolve_publication_target(t, &mut target, &destination, base_branch.is_none())?; - Ok(target) -} - -/// Freeze remote state for an explicitly supplied destination before confirmation. -pub fn resolve_publication_target( - t: &GitTransport, - target: &mut PublicationTarget, - destination: &PublicationDestination, - branch_only: bool, -) -> Result<(), String> { - crate::reject_publish_caos(t, &oid(&target.head, "publication head")?)?; - validate_repository(&destination.repository)?; - conversation_protocol::v3::source_trees::validate_branch(&target.branch)?; - target.repository = destination.repository.clone(); - target.base_branch = destination.base_branch.clone(); - if !branch_only { - conversation_protocol::v3::source_trees::validate_branch(&destination.base_branch)?; - if target.branch == target.base_branch { - return Err("PR branch and base branch must differ".into()); - } - let base = branch_snapshot(t, &target.repository, &target.base_branch)?; - crate::host_git::validate_pr_source_tree(&base, &target.head, t.work_dir())?; - target.base_import = - (!crate::host_git::pr_base_is_ancestor(&base, &target.head, t.work_dir())?) - .then(|| format!("imports/pr-base-{base}/base")); - target.base_commit = Some(base); - } - target.remote_head = GitStore::open(t.work_dir(), Some(&target.repository))? - .read_ref(&format!("refs/heads/{}", target.branch))? - .map(|oid| oid.to_string()); - Ok(()) -} - -pub fn publish_target( - t: &GitTransport, - id: &str, - target: &PublicationTarget, - base_commit: &str, -) -> Result { - if target.base_commit.as_deref() != Some(base_commit) { - return Err("PR base changed since the preview; review again".into()); - } - if !crate::host_git::pr_base_is_ancestor(base_commit, &target.head, t.work_dir())? { - return Err("source does not contain the PR base; run /pr again to import it and ask the agent to integrate it".into()); - } - let store = open_store(t)?; - let (_, head) = fetch_validated_head(t, &store, id)?.ok_or("conversation disappeared")?; - let view = Conversation::open(&store, &head)?; - if view - .source_tree(&target.source_tree)? - .is_none_or(|entry| entry.commit.as_str() != target.head) - { - return Err("publication contents changed since the preview; review again".into()); - } - let remote = GitStore::open(t.work_dir(), Some(&target.repository))?; - if remote - .read_ref(&format!("refs/heads/{}", target.branch))? - .map(|oid| oid.to_string()) - != target.remote_head - { - return Err("remote branch changed since the preview; review again".into()); - } - publish_source_tree_branch_inner( - t, - id, - Some(&target.source_tree), - Some(&target.head), - &target.repository, - Some(target), - ) -} - -/// Push the exact previewed branch without requiring a PR base. -pub fn publish_branch_target( - t: &GitTransport, - id: &str, - target: &PublicationTarget, -) -> Result { - publish_source_tree_branch_inner( - t, - id, - Some(&target.source_tree), - Some(&target.head), - &target.repository, - Some(target), - ) -} - -/// Import the exact confirmed PR base before requesting agent work. Retrying -/// reuses an identical import and never overwrites existing conversation content. -pub fn import_publication_base( - t: &GitTransport, - id: &str, - target: &PublicationTarget, -) -> Result { - let path = target - .base_import - .as_deref() - .ok_or("PR base is already incorporated")?; - let base = target - .base_commit - .as_deref() - .ok_or("PR preview has no base")?; - if publication_target(t, id, &target.source_tree)?.head != target.head { - return Err("source changed since the preview; run /pr again".into()); - } - if branch_snapshot(t, &target.repository, &target.base_branch)? != base { - return Err("PR base changed since the preview; run /pr again".into()); - } - let load = conversation_load(t, id)?.ok_or("conversation disappeared")?; - match load.source_trees.iter().find(|entry| entry.name == path) { - Some(entry) if entry.head == base => {} - Some(_) => { - return Err(format!( - "import path {path:?} already contains a different commit" - )) - } - None => { - import_source(t, id, path, &target.repository, Some(base))?; - } - } - Ok(format!( - "Prepare {:?} for a PR against branch {:?} in {}. The base commit {} is imported at {:?}. \ - Integrate that base into {:?}, preserving existing changes and other snapshots. \ - First check whether this would publish unrelated inherited changes; if so, explain the scope and ask before proceeding. \ - Merging upstream preserves existing branch changes; transplanting only the requested edit onto a new base is a separate operation. \ - Resolve any conflicts and clear their ledger entries; saving removes empty merge metadata. Run relevant tests. Do not publish. \ - When finished, summarize the changes and suggest /pr {} {} {} for review.", - target.source_tree, - target.base_branch, - target.repository, - base, - path, - target.source_tree, - shell_words::quote(&target.source_tree), - shell_words::quote(&target.base_branch), - shell_words::quote(&target.repository), - )) -} diff --git a/rust/crates/conversation-protocol/src/v3/publication.rs b/rust/crates/conversation-protocol/src/v3/publication.rs index e26a0992d..2b3001bf2 100644 --- a/rust/crates/conversation-protocol/src/v3/publication.rs +++ b/rust/crates/conversation-protocol/src/v3/publication.rs @@ -1,4 +1,4 @@ -//! Publication outcomes shared by the host and agent publishers. +//! Agent publication outcomes and reconciliation. use super::{Evidence, Oid, PublicationRecord, PublicationStatus}; pub struct Outcome {