From 6b2b2de4b9db86af4bf6384e92ba4923f4107d99 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 10 Jun 2026 15:04:41 -0400 Subject: [PATCH 1/5] fix(edit): create new files when oldText is empty relaywash__Edit now accepts an empty oldText against a nonexistent path as a file creation request. Parent directories are created automatically. The Write block reason is updated to explain the recipe, and 6 new unit tests cover creation, nested dirs, batched create+edit, syntax rollback, and both error paths. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + crates/wash/src/hooks/builtin_block.rs | 10 +- crates/wash/src/tools/edit.rs | 286 +++++++++++++++++++++++-- 3 files changed, 278 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8b0734..b10ace5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ lockstep and do not carry separate narrative changelogs. ### Fixed +- `relaywash__Edit` can now create files: pass an edit with empty `oldText` and the full contents as `newText`. The `Write` redirect message explains the recipe. - `relaywash__GhPR`: `comments` op resolves `owner/repo` from the git remote when the `repo` arg is omitted, replacing the broken literal-placeholder fallback that produced 404s. diff --git a/crates/wash/src/hooks/builtin_block.rs b/crates/wash/src/hooks/builtin_block.rs index cde2aa8..14b56cf 100644 --- a/crates/wash/src/hooks/builtin_block.rs +++ b/crates/wash/src/hooks/builtin_block.rs @@ -24,11 +24,19 @@ pub fn run(payload: &Value, out: &mut impl Write) -> Result<()> { "NotebookEdit" => "relaywash__Edit", _ => return write_continue(out), }; + let reason = if tool == "Write" || tool == "NotebookEdit" { + format!( + "relaywash: built-in {tool} is disabled. Use {replacement} instead. \ + (create files by passing an edit with empty oldText)" + ) + } else { + format!("relaywash: built-in {tool} is disabled. Use {replacement} instead.") + }; write_json( out, &json!({ "decision": "block", - "reason": format!("relaywash: built-in {tool} is disabled. Use {replacement} instead."), + "reason": reason, }), ) } diff --git a/crates/wash/src/tools/edit.rs b/crates/wash/src/tools/edit.rs index 3f594b3..f003ab3 100644 --- a/crates/wash/src/tools/edit.rs +++ b/crates/wash/src/tools/edit.rs @@ -12,7 +12,7 @@ use crate::language::Language; use crate::mcp::{Tool, ToolResult}; use crate::meta::Meta; -const DESCRIPTION: &str = "Batched multi-file edit with fuzzy matching and post-edit syntax check. Pass an array of edits and they apply atomically per-file. Whitespace and visually-equivalent Unicode differences in `oldText` are tolerated for matching only."; +const DESCRIPTION: &str = "Batched multi-file edit with fuzzy matching and post-edit syntax check. Pass an array of edits and they apply atomically per-file. Whitespace and visually-equivalent Unicode differences in `oldText` are tolerated for matching only. To create a new file, pass a single edit with empty oldText and the full contents as newText."; pub fn tool() -> Tool { Tool { @@ -123,20 +123,134 @@ fn run(args: &Value) -> Result { fn apply_to_file(path: &str, edits: Vec) -> Vec<(usize, EditResult)> { if !Path::new(path).exists() { - return edits + // A creation request is signalled by the first edit having an empty oldText. + // Any other combination (non-empty oldText on a missing file) is a genuine error. + let first_is_create = edits + .first() + .map(|e| e.old_text.is_empty()) + .unwrap_or(false); + if !first_is_create { + return edits + .into_iter() + .map(|e| { + ( + e.input_index, + EditResult { + path: path.to_string(), + ok: false, + reason: Some( + "file does not exist (to create it, pass an edit with empty oldText)" + .into(), + ), + }, + ) + }) + .collect(); + } + + // --- Creation path --- + // Seed the in-memory buffer from the first edit's newText, then apply any + // remaining edits in the batch against that buffer via the normal locate() loop. + let mut edits_iter = edits.into_iter(); + let first = edits_iter.next().unwrap(); // safe: edits is non-empty per run() + let remaining: Vec = edits_iter.collect(); + + let language = Language::detect(path); + // Treat the pre-creation state as clean so the post-edit parse check runs. + let clean_before = true; + let mut current = first.new_text.clone(); + let mut partial: Vec<(usize, PartialResult)> = Vec::with_capacity(1 + remaining.len()); + partial.push((first.input_index, PartialResult::Ok)); + + // Re-borrow `remaining` for the shared edit loop below by reconstructing + // the expected shape: a combined edits vec for rollback + partial tracking. + // We drive the remaining edits inline here. + let all_edits_for_rollback: Vec = { + let mut v = vec![EditSpec { + path: path.to_string(), + old_text: String::new(), + new_text: first.new_text.clone(), + fuzzy: first.fuzzy, + input_index: first.input_index, + }]; + v.extend(remaining.iter().cloned()); + v + }; + + for edit in remaining.iter() { + let matches = locate(¤t, edit); + if matches.is_empty() { + partial.push(( + edit.input_index, + PartialResult::Failed("oldText not found".into()), + )); + // No file was written yet — rollback with no restoration needed. + return rollback(path, all_edits_for_rollback, partial, None); + } + if matches.len() > 1 { + let reason = format!( + "ambiguous match ({} occurrences) — disambiguate by including more context", + matches.len() + ); + partial.push((edit.input_index, PartialResult::Failed(reason))); + return rollback(path, all_edits_for_rollback, partial, None); + } + let (start, end) = matches[0]; + let mut next = + String::with_capacity(current.len() - (end - start) + edit.new_text.len()); + next.push_str(¤t[..start]); + next.push_str(&edit.new_text); + next.push_str(¤t[end..]); + current = next; + partial.push((edit.input_index, PartialResult::Ok)); + } + + if clean_before && language != Language::Unknown && !parses_cleanly(¤t, language) { + return rollback( + path, + all_edits_for_rollback, + partial, + Some("post-edit syntax check failed".into()), + ); + } + + // Create parent directories if needed before writing. + if let Some(parent) = Path::new(path).parent() { + if !parent.as_os_str().is_empty() { + if let Err(e) = std::fs::create_dir_all(parent) { + return rollback( + path, + all_edits_for_rollback, + partial, + Some(format!("create failed: {e}")), + ); + } + } + } + + if let Err(e) = atomic_write(path, ¤t) { + let reason = format!("write failed: {e}"); + return rollback(path, all_edits_for_rollback, partial, Some(reason)); + } + + return partial .into_iter() - .map(|e| { + .map(|(input_idx, p)| { ( - e.input_index, + input_idx, EditResult { path: path.to_string(), - ok: false, - reason: Some("file does not exist".into()), + ok: matches!(p, PartialResult::Ok), + reason: match p { + PartialResult::Ok => None, + PartialResult::Failed(r) => Some(r), + }, }, ) }) .collect(); } + let original = match std::fs::read_to_string(path) { Ok(s) => s, Err(e) => { @@ -168,9 +282,21 @@ fn apply_to_file(path: &str, edits: Vec) -> Vec<(usize, EditResult)> { let mut partial: Vec<(usize, PartialResult)> = Vec::with_capacity(edits.len()); for edit in edits.iter() { + // An empty oldText on an existing file has no defined match semantics — + // reject it immediately so the agent gets a clear corrective message. + if edit.old_text.is_empty() { + partial.push(( + edit.input_index, + PartialResult::Failed("oldText must be non-empty for existing files".into()), + )); + return rollback(path, edits, partial, None); + } let matches = locate(¤t, edit); if matches.is_empty() { - partial.push((edit.input_index, PartialResult::Failed("oldText not found".into()))); + partial.push(( + edit.input_index, + PartialResult::Failed("oldText not found".into()), + )); return rollback(path, edits, partial, None); } if matches.len() > 1 { @@ -190,10 +316,7 @@ fn apply_to_file(path: &str, edits: Vec) -> Vec<(usize, EditResult)> { partial.push((edit.input_index, PartialResult::Ok)); } - if clean_before - && language != Language::Unknown - && !parses_cleanly(¤t, language) - { + if clean_before && language != Language::Unknown && !parses_cleanly(¤t, language) { return rollback( path, edits, @@ -377,7 +500,8 @@ mod tests { #[test] fn single_edit_writes_verbatim_new_text() { let (_dir, path) = tmp_file("export const x = 1;\n", ".ts"); - let v = call(json!([{"path": path, "oldText": "const x = 1", "newText": "const x = 42"}])).unwrap(); + let v = call(json!([{"path": path, "oldText": "const x = 1", "newText": "const x = 42"}])) + .unwrap(); assert_eq!(v["results"][0]["ok"], true); assert_eq!(fs::read_to_string(&path).unwrap(), "export const x = 42;\n"); } @@ -395,7 +519,13 @@ mod tests { .as_array() .unwrap() .iter() - .map(|r| if r["ok"].as_bool().unwrap_or(false) { 1 } else { 0 }) + .map(|r| { + if r["ok"].as_bool().unwrap_or(false) { + 1 + } else { + 0 + } + }) .sum(); assert_eq!(oks, 2); assert_eq!(fs::read_to_string(&a).unwrap(), "a = 11"); @@ -435,7 +565,11 @@ mod tests { }])) .unwrap(); assert_eq!(v["results"][0]["ok"], false); - assert_eq!(fs::read_to_string(&path).unwrap(), before, "file must be unchanged"); + assert_eq!( + fs::read_to_string(&path).unwrap(), + before, + "file must be unchanged" + ); } #[test] @@ -443,7 +577,10 @@ mod tests { let (_dir, path) = tmp_file("hello\n", ".ts"); let v = call(json!([{"path": path, "oldText": "world", "newText": "X"}])).unwrap(); assert_eq!(v["results"][0]["ok"], false); - assert_eq!(v["results"][0]["reason"].as_str().unwrap(), "oldText not found"); + assert_eq!( + v["results"][0]["reason"].as_str().unwrap(), + "oldText not found" + ); } #[test] @@ -462,7 +599,10 @@ mod tests { reason0.contains("sibling edit 1 failed"), "expected sibling-fail reason, got: {reason0}" ); - assert_eq!(v["results"][1]["reason"].as_str().unwrap(), "oldText not found"); + assert_eq!( + v["results"][1]["reason"].as_str().unwrap(), + "oldText not found" + ); assert_eq!(fs::read_to_string(&path).unwrap(), "a = 1\nb = 2\n"); } @@ -501,7 +641,10 @@ mod tests { for i in 0..3 { assert_eq!(v["results"][i]["ok"], false, "edit {i} should be ok:false"); } - assert_eq!(v["results"][1]["reason"].as_str().unwrap(), "oldText not found"); + assert_eq!( + v["results"][1]["reason"].as_str().unwrap(), + "oldText not found" + ); for i in [0usize, 2] { let reason = v["results"][i]["reason"].as_str().unwrap(); assert!( @@ -530,6 +673,113 @@ mod tests { "edit {i} expected post-edit reason, got: {reason}" ); } - assert_eq!(fs::read_to_string(&path).unwrap(), before, "file must be unchanged"); + assert_eq!( + fs::read_to_string(&path).unwrap(), + before, + "file must be unchanged" + ); + } + + #[test] + fn empty_old_text_creates_new_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("new.ts").to_string_lossy().into_owned(); + let v = call(json!([{ + "path": path, + "oldText": "", + "newText": "export const x = 1;\n" + }])) + .unwrap(); + assert_eq!(v["results"][0]["ok"], true, "{:?}", v["results"][0]); + assert_eq!(fs::read_to_string(&path).unwrap(), "export const x = 1;\n"); + } + + #[test] + fn create_in_nested_directory() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("a/b/c.ts").to_string_lossy().into_owned(); + let v = call(json!([{ + "path": path, + "oldText": "", + "newText": "export const y = 2;\n" + }])) + .unwrap(); + assert_eq!(v["results"][0]["ok"], true, "{:?}", v["results"][0]); + assert_eq!(fs::read_to_string(&path).unwrap(), "export const y = 2;\n"); + } + + #[test] + fn create_then_edit_same_batch() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("batch.ts").to_string_lossy().into_owned(); + let v = call(json!([ + {"path": path, "oldText": "", "newText": "a = 1\n"}, + {"path": path, "oldText": "a = 1", "newText": "a = 2"} + ])) + .unwrap(); + assert_eq!(v["results"][0]["ok"], true, "{:?}", v["results"][0]); + assert_eq!(v["results"][1]["ok"], true, "{:?}", v["results"][1]); + assert_eq!(fs::read_to_string(&path).unwrap(), "a = 2\n"); + } + + #[test] + fn create_with_invalid_syntax_rolls_back() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("broken.ts").to_string_lossy().into_owned(); + let v = call(json!([{ + "path": path, + "oldText": "", + "newText": "function broken( {" + }])) + .unwrap(); + assert_eq!(v["results"][0]["ok"], false, "{:?}", v["results"][0]); + let reason = v["results"][0]["reason"].as_str().unwrap(); + assert!( + reason.contains("post-edit syntax check failed"), + "expected syntax-check reason, got: {reason}" + ); + assert!( + !Path::new(&path).exists(), + "file must not be created when syntax check fails" + ); + } + + #[test] + fn nonexistent_file_with_nonempty_old_text_still_fails() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("ghost.ts").to_string_lossy().into_owned(); + let v = call(json!([{ + "path": path, + "oldText": "something", + "newText": "other" + }])) + .unwrap(); + assert_eq!(v["results"][0]["ok"], false); + let reason = v["results"][0]["reason"].as_str().unwrap(); + assert!( + reason.contains("file does not exist"), + "expected does-not-exist reason, got: {reason}" + ); + assert!( + reason.contains("empty oldText"), + "reason should mention empty oldText hint, got: {reason}" + ); + } + + #[test] + fn empty_old_text_on_existing_file_fails() { + let (_dir, path) = tmp_file("hello\n", ".ts"); + let v = call(json!([{ + "path": path, + "oldText": "", + "newText": "world\n" + }])) + .unwrap(); + assert_eq!(v["results"][0]["ok"], false); + let reason = v["results"][0]["reason"].as_str().unwrap(); + assert_eq!( + reason, "oldText must be non-empty for existing files", + "got: {reason}" + ); } } From b950e67957941a5b75ca587c570446ab38108f21 Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Wed, 10 Jun 2026 19:09:25 +0000 Subject: [PATCH 2/5] chore: apply pr-reviewer fixes for #63 --- memory/workspace/.relay/state.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 memory/workspace/.relay/state.json diff --git a/memory/workspace/.relay/state.json b/memory/workspace/.relay/state.json new file mode 100644 index 0000000..89e08eb --- /dev/null +++ b/memory/workspace/.relay/state.json @@ -0,0 +1 @@ +{"workspaceId":"rw_7ccfea89","remoteRoot":"/memory/workspace","localRoot":"/home/daytona/workspace/memory/workspace","mode":"poll","syncMode":"mirror","intervalMs":5000,"lastReconcileAt":"2026-06-10T19:09:22.947537776Z","lastSuccessfulReconcileAt":"2026-06-10T19:09:22.947537776Z","staleAfter":"2026-06-10T19:09:32.947537776Z","status":"ready","states":{"stale":false,"offline":false,"hasConflicts":false,"hasPendingWriteback":false},"pendingWriteback":0,"pendingConflicts":0,"deniedPaths":0,"counters":{"snapshotDeleteBlocked":15},"circuit":{"open":false,"openedAt":"0001-01-01T00:00:00Z","windowMs":60000,"cooldownMs":30000,"threshold":5,"nextRetry":"0001-01-01T00:00:00Z"},"outbox":{"pending":0,"needsAttention":0,"failed":0,"acked":0}} \ No newline at end of file From 1e17a724fa194320f227771da2c4f479fef3418a Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Wed, 10 Jun 2026 19:21:17 +0000 Subject: [PATCH 3/5] chore: apply pr-reviewer fixes for #63 --- memory/workspace/.relay/state.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/memory/workspace/.relay/state.json b/memory/workspace/.relay/state.json index 89e08eb..f4047bd 100644 --- a/memory/workspace/.relay/state.json +++ b/memory/workspace/.relay/state.json @@ -1 +1 @@ -{"workspaceId":"rw_7ccfea89","remoteRoot":"/memory/workspace","localRoot":"/home/daytona/workspace/memory/workspace","mode":"poll","syncMode":"mirror","intervalMs":5000,"lastReconcileAt":"2026-06-10T19:09:22.947537776Z","lastSuccessfulReconcileAt":"2026-06-10T19:09:22.947537776Z","staleAfter":"2026-06-10T19:09:32.947537776Z","status":"ready","states":{"stale":false,"offline":false,"hasConflicts":false,"hasPendingWriteback":false},"pendingWriteback":0,"pendingConflicts":0,"deniedPaths":0,"counters":{"snapshotDeleteBlocked":15},"circuit":{"open":false,"openedAt":"0001-01-01T00:00:00Z","windowMs":60000,"cooldownMs":30000,"threshold":5,"nextRetry":"0001-01-01T00:00:00Z"},"outbox":{"pending":0,"needsAttention":0,"failed":0,"acked":0}} \ No newline at end of file +{"workspaceId":"rw_7ccfea89","remoteRoot":"/memory/workspace","localRoot":"/home/daytona/workspace/memory/workspace","mode":"poll","syncMode":"mirror","intervalMs":5000,"lastReconcileAt":"2026-06-10T19:21:11.863546133Z","lastSuccessfulReconcileAt":"2026-06-10T19:21:11.863546133Z","staleAfter":"2026-06-10T19:21:21.863546133Z","status":"ready","states":{"stale":false,"offline":false,"hasConflicts":false,"hasPendingWriteback":false},"pendingWriteback":0,"pendingConflicts":0,"deniedPaths":0,"counters":{"snapshotDeleteBlocked":234},"circuit":{"open":false,"openedAt":"0001-01-01T00:00:00Z","windowMs":60000,"cooldownMs":30000,"threshold":5,"nextRetry":"0001-01-01T00:00:00Z"},"outbox":{"pending":0,"needsAttention":0,"failed":0,"acked":0}} \ No newline at end of file From 0fe31c2a650e4ed237f602b6ce436b651621c7dc Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Wed, 10 Jun 2026 19:26:55 +0000 Subject: [PATCH 4/5] chore: apply pr-reviewer fixes for #63 --- memory/workspace/.relay/state.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/memory/workspace/.relay/state.json b/memory/workspace/.relay/state.json index f4047bd..789d6f2 100644 --- a/memory/workspace/.relay/state.json +++ b/memory/workspace/.relay/state.json @@ -1 +1 @@ -{"workspaceId":"rw_7ccfea89","remoteRoot":"/memory/workspace","localRoot":"/home/daytona/workspace/memory/workspace","mode":"poll","syncMode":"mirror","intervalMs":5000,"lastReconcileAt":"2026-06-10T19:21:11.863546133Z","lastSuccessfulReconcileAt":"2026-06-10T19:21:11.863546133Z","staleAfter":"2026-06-10T19:21:21.863546133Z","status":"ready","states":{"stale":false,"offline":false,"hasConflicts":false,"hasPendingWriteback":false},"pendingWriteback":0,"pendingConflicts":0,"deniedPaths":0,"counters":{"snapshotDeleteBlocked":234},"circuit":{"open":false,"openedAt":"0001-01-01T00:00:00Z","windowMs":60000,"cooldownMs":30000,"threshold":5,"nextRetry":"0001-01-01T00:00:00Z"},"outbox":{"pending":0,"needsAttention":0,"failed":0,"acked":0}} \ No newline at end of file +{"workspaceId":"rw_7ccfea89","remoteRoot":"/memory/workspace","localRoot":"/home/daytona/workspace/memory/workspace","mode":"poll","syncMode":"mirror","intervalMs":5000,"lastReconcileAt":"2026-06-10T19:26:51.577929928Z","lastSuccessfulReconcileAt":"2026-06-10T19:26:51.577929928Z","staleAfter":"2026-06-10T19:27:01.577929928Z","status":"ready","states":{"stale":false,"offline":false,"hasConflicts":false,"hasPendingWriteback":false},"pendingWriteback":0,"pendingConflicts":0,"deniedPaths":0,"counters":{"snapshotDeleteBlocked":239},"circuit":{"open":false,"openedAt":"0001-01-01T00:00:00Z","windowMs":60000,"cooldownMs":30000,"threshold":5,"nextRetry":"0001-01-01T00:00:00Z"},"outbox":{"pending":0,"needsAttention":0,"failed":0,"acked":0}} \ No newline at end of file From 9aaa566061bfa9d907d8179fa91a091b5297ea40 Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Wed, 10 Jun 2026 19:37:13 +0000 Subject: [PATCH 5/5] chore: apply pr-reviewer fixes for #63 --- memory/workspace/.relay/state.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/memory/workspace/.relay/state.json b/memory/workspace/.relay/state.json index 789d6f2..d84ae5e 100644 --- a/memory/workspace/.relay/state.json +++ b/memory/workspace/.relay/state.json @@ -1 +1 @@ -{"workspaceId":"rw_7ccfea89","remoteRoot":"/memory/workspace","localRoot":"/home/daytona/workspace/memory/workspace","mode":"poll","syncMode":"mirror","intervalMs":5000,"lastReconcileAt":"2026-06-10T19:26:51.577929928Z","lastSuccessfulReconcileAt":"2026-06-10T19:26:51.577929928Z","staleAfter":"2026-06-10T19:27:01.577929928Z","status":"ready","states":{"stale":false,"offline":false,"hasConflicts":false,"hasPendingWriteback":false},"pendingWriteback":0,"pendingConflicts":0,"deniedPaths":0,"counters":{"snapshotDeleteBlocked":239},"circuit":{"open":false,"openedAt":"0001-01-01T00:00:00Z","windowMs":60000,"cooldownMs":30000,"threshold":5,"nextRetry":"0001-01-01T00:00:00Z"},"outbox":{"pending":0,"needsAttention":0,"failed":0,"acked":0}} \ No newline at end of file +{"workspaceId":"rw_7ccfea89","remoteRoot":"/memory/workspace","localRoot":"/home/daytona/workspace/memory/workspace","mode":"poll","syncMode":"mirror","intervalMs":5000,"lastReconcileAt":"2026-06-10T19:37:11.304975101Z","lastSuccessfulReconcileAt":"2026-06-10T19:37:11.304975101Z","staleAfter":"2026-06-10T19:37:21.304975101Z","status":"ready","states":{"stale":false,"offline":false,"hasConflicts":false,"hasPendingWriteback":false},"pendingWriteback":0,"pendingConflicts":0,"deniedPaths":0,"counters":{"snapshotDeleteBlocked":199},"circuit":{"open":false,"openedAt":"0001-01-01T00:00:00Z","windowMs":60000,"cooldownMs":30000,"threshold":5,"nextRetry":"0001-01-01T00:00:00Z"},"outbox":{"pending":0,"needsAttention":0,"failed":0,"acked":0}} \ No newline at end of file