Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ packages/ per-platform npm packages — binaries injected by CI
wash-win32-x64/
fixtures/corpus/ recorded sessions for burn-compare
docs/ including compaction-attribution.md
legacy-ts/ original Node implementation, retired but preserved for diff/blame
```

## Distribution
Expand Down Expand Up @@ -133,7 +132,7 @@ node scripts/copy-binary.mjs # stage the local binary into the host platform pac

Pre-commit safety: `cargo test --release` exercises the parsers, the MCP framing, and a stdio integration test that spawns the binary and lists tools. CI (`.github/workflows/ci.yml`) runs the same plus a layout sanity check on each platform package's `package.json`.

The retired Node implementation lives under `legacy-ts/`, including the original hook scripts and the JS burn SDK stub. Hooks are now Rust subcommands invoked through the launcher: `node bin/wash.mjs hook <kind>` (where kind ∈ `builtin-block`, `tool-redirect`, `edit-batching-nudge`, `post-tool-observe`, `session-start`, `session-stop`). The `/relaywash-savings` slash command calls `wash savings --session …` directly.
Hooks are Rust subcommands invoked through the launcher: `node bin/wash.mjs hook <kind>` (where kind ∈ `builtin-block`, `tool-redirect`, `edit-batching-nudge`, `post-tool-observe`, `session-start`, `session-stop`). The `/relaywash-savings` slash command calls `wash savings --session …` directly.

## Adaptive layer substrate ([wash#13](https://github.com/AgentWorkforce/wash/issues/13))

Expand Down
4 changes: 2 additions & 2 deletions crates/wash/src/ast/line_regex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,9 @@ fn symbol_from_header(line: &str) -> Option<String> {
}

fn strip_inline_comments(s: &str) -> String {
static BLOCK_RE: OnceLock<Regex> = OnceLock::new();
let no_line = if let Some(i) = s.find("//") { &s[..i] } else { s };
// Remove block comments on a single line.
let block_re = Regex::new(r"/\*.*?\*/").unwrap();
let block_re = BLOCK_RE.get_or_init(|| Regex::new(r"/\*.*?\*/").unwrap());
block_re.replace_all(no_line, "").into_owned()
}

Expand Down
7 changes: 4 additions & 3 deletions crates/wash/src/mcp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,10 @@ impl McpServer {
}

fn format_tool_result(r: &ToolResult) -> Value {
// Tools return a structured object. We emit it as a single text block of JSON so models
// see the data, plus mirror it under `structuredContent` for hosts that read it.
let text = serde_json::to_string_pretty(&r.value).unwrap_or_else(|_| "{}".into());
// The model reads `content[].text`. Use compact JSON — pretty-printing roughly
// doubles the whitespace tokens for nested results, which defeats the whole point
// of this server. Hosts that prefer a parsed view read `structuredContent`.
let text = serde_json::to_string(&r.value).unwrap_or_else(|_| "{}".into());
json!({
"content": [{"type": "text", "text": text}],
"structuredContent": r.value,
Expand Down
13 changes: 9 additions & 4 deletions crates/wash/src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,14 @@ pub fn run(opts: SearchOpts) -> Result<Vec<SearchHit>> {
if searcher.search_path(&matcher, abs, &mut sink).is_err() {
continue;
}
for snippet in sink.into_snippets(opts.context_lines as u32) {
let snippets = sink.into_snippets(opts.context_lines as u32);
if snippets.is_empty() {
continue;
}
let rel = relativize(&opts.cwd, abs);
for snippet in snippets {
hits.push(SearchHit {
path: relativize(&opts.cwd, abs),
path: rel.clone(),
line_start: snippet.line_start,
line_end: snippet.line_end,
snippet: snippet.text,
Expand Down Expand Up @@ -102,8 +107,8 @@ impl HitSink {
fn into_snippets(self, context_lines: u32) -> Vec<GroupedSnippet> {
let mut snippets = Vec::new();
let mut group: Vec<u32> = Vec::new();
let mut iter = self.lines.keys().copied().collect::<Vec<_>>();
iter.sort_unstable();
// BTreeMap iterates keys in sorted order — no extra sort needed.
let iter: Vec<u32> = self.lines.keys().copied().collect();

let flush = |group: &mut Vec<u32>, snippets: &mut Vec<GroupedSnippet>, lines: &BTreeMap<u32, String>, match_lines: &HashSet<u32>| {
if group.is_empty() {
Expand Down
73 changes: 72 additions & 1 deletion crates/wash/src/tools/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ fn apply_to_file(path: &str, edits: Vec<EditSpec>) -> Vec<(usize, EditResult)> {
);
}

if let Err(e) = std::fs::write(path, &current) {
if let Err(e) = atomic_write(path, &current) {
let reason = format!("write failed: {e}");
return rollback(path, edits, partial, usize::MAX, Some(reason));
}
Expand Down Expand Up @@ -288,6 +288,55 @@ fn locate(text: &str, edit: &EditSpec) -> Vec<(usize, usize)> {
fuzzy_find_all(text, &edit.old_text)
}

/// Write atomically: stage the new contents in a sibling temp file, then rename over the
/// target. A crash mid-write leaves the original file untouched — `std::fs::write` would
/// truncate first and could leave a half-written file behind.
///
/// Preserves the target's existing permissions across the replace. `rename` swaps the
/// inode for our fresh temp file, which would otherwise drop the original mode bits
/// (e.g., a `0755` script becoming non-executable). On Windows, MoveFileEx assigns the
/// destination directory's default ACL on rename — copying permissions onto the temp
/// file before the rename gives the same effective behavior on both platforms.
fn atomic_write(path: &str, contents: &str) -> std::io::Result<()> {
use std::io::Write;
let target = Path::new(path);
let dir = target.parent().unwrap_or_else(|| Path::new("."));
let file_name = target
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("file");
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp = dir.join(format!(".{file_name}.wash-{pid}-{nanos}.tmp"));
let original_perms = std::fs::metadata(target).ok().map(|m| m.permissions());

let write_result = (|| -> std::io::Result<()> {
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)?;
if let Some(perms) = &original_perms {
std::fs::set_permissions(&tmp, perms.clone())?;
}
f.write_all(contents.as_bytes())?;
f.sync_all()?;
Ok(())
})();
if let Err(e) = write_result {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}

if let Err(e) = std::fs::rename(&tmp, target) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Ok(())
}

fn find_all_exact(text: &str, needle: &str) -> Vec<(usize, usize)> {
if needle.is_empty() {
return Vec::new();
Expand Down Expand Up @@ -406,6 +455,28 @@ mod tests {
assert_eq!(fs::read_to_string(&path).unwrap(), "a = 1\nb = 2\n");
}

#[cfg(unix)]
#[test]
fn atomic_write_preserves_executable_bit() {
use std::os::unix::fs::PermissionsExt;
let (_dir, path) = tmp_file("#!/bin/sh\necho original\n", ".sh");
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms).unwrap();

let v = call(json!([{
"path": &path,
"oldText": "echo original",
"newText": "echo edited"
}]))
.unwrap();
assert_eq!(v["results"][0]["ok"], true);

let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o755, "executable bit must survive atomic rename");
assert!(fs::read_to_string(&path).unwrap().contains("echo edited"));
}

#[test]
fn third_edit_after_failure_marked_rolled_back() {
let (_dir, path) = tmp_file("a\nb\nc\n", ".ts");
Expand Down
8 changes: 2 additions & 6 deletions crates/wash/src/tools/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,9 @@ fn run(args: &Value) -> Result<ToolResult> {
let value = json!({
"results": results,
"truncated": truncated,
"_meta": meta,
"_meta": meta.clone(),
});
Ok(ToolResult::new("relaywash__Search", value, Some(meta_for_tool(&replaces, collapsed))))
}

fn meta_for_tool(replaces: &[&str], collapsed: u32) -> Meta {
Meta::new(replaces.iter().map(|s| s.to_string()), collapsed)
Ok(ToolResult::new("relaywash__Search", value, Some(meta)))
}

fn rank_results(mut results: Vec<SearchHit>, mode: &str, cwd: &std::path::Path) -> Vec<SearchHit> {
Expand Down
23 changes: 0 additions & 23 deletions legacy-ts/build/build.mjs

This file was deleted.

37 changes: 0 additions & 37 deletions legacy-ts/scripts/builtin-block-hook.js

This file was deleted.

Loading
Loading