From ff2935f2f3bf03d1b85093a0b23013b7dc22dccf Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 20 Apr 2026 23:11:18 -0400 Subject: [PATCH 1/7] Swap chokidar for @parcel/watcher in local-mount Replace chokidar with @parcel/watcher in packages/local-mount. Rework auto-sync to use parcel's subscribe API, add per-path debounce (default 50ms) instead of chokidar's awaitWriteFinish, and introduce buildIgnoreGlobs to precompute watcher ignore patterns. Ensure proper unsubscribe handling and drain pending debounces on stop(), update docs/tests/package.json and lockfile, and bump package versions to 0.2.2. --- .../completed/2026-04/traj_iuzm83ogm43k.json | 53 +++ .../completed/2026-04/traj_iuzm83ogm43k.md | 31 ++ .trajectories/index.json | 12 +- package-lock.json | 374 ++++++++++++++++-- packages/local-mount/README.md | 4 +- packages/local-mount/package.json | 2 +- packages/local-mount/src/auto-sync.test.ts | 2 +- packages/local-mount/src/auto-sync.ts | 129 +++--- packages/local-mount/src/symlink-mount.ts | 4 +- 9 files changed, 514 insertions(+), 97 deletions(-) create mode 100644 .trajectories/completed/2026-04/traj_iuzm83ogm43k.json create mode 100644 .trajectories/completed/2026-04/traj_iuzm83ogm43k.md diff --git a/.trajectories/completed/2026-04/traj_iuzm83ogm43k.json b/.trajectories/completed/2026-04/traj_iuzm83ogm43k.json new file mode 100644 index 0000000..bf83ddc --- /dev/null +++ b/.trajectories/completed/2026-04/traj_iuzm83ogm43k.json @@ -0,0 +1,53 @@ +{ + "id": "traj_iuzm83ogm43k", + "version": 1, + "task": { + "title": "Replace chokidar with @parcel/watcher in local-mount" + }, + "status": "completed", + "startedAt": "2026-04-20T20:35:15.759Z", + "completedAt": "2026-04-20T20:58:15.412Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-04-20T20:58:15.094Z" + } + ], + "chapters": [ + { + "id": "chap_2g7q1zwb1j1e", + "title": "Work", + "agentName": "default", + "startedAt": "2026-04-20T20:58:15.094Z", + "endedAt": "2026-04-20T20:58:15.412Z", + "events": [ + { + "ts": 1776718695095, + "type": "decision", + "content": "Switched watcher from chokidar v4 to @parcel/watcher 2.5.6: Switched watcher from chokidar v4 to @parcel/watcher 2.5.6", + "raw": { + "question": "Switched watcher from chokidar v4 to @parcel/watcher 2.5.6", + "chosen": "Switched watcher from chokidar v4 to @parcel/watcher 2.5.6", + "alternatives": [], + "reasoning": "Chokidar v4's awaitWriteFinish polling + fs.watch teardown caused noticeable hangs on exit. @parcel/watcher uses native FSEvents/inotify/ReadDirectoryChangesW with clean async unsubscribe. Replaced awaitWriteFinish with a small per-path setTimeout debounce; reconcile loop + mtime/content check absorb the looser write semantics." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Replaced chokidar with @parcel/watcher in packages/local-mount. All 22 tests pass. Exit-hang should be gone because unsubscribe() on native-backed watchers resolves promptly instead of draining stability-poll timers.", + "approach": "Standard approach", + "confidence": 0.85 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/will/Projects/relayfile", + "tags": [], + "_trace": { + "startRef": "81a329d005404a6f156c837829d5c5d8d47aa05d", + "endRef": "81a329d005404a6f156c837829d5c5d8d47aa05d" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-04/traj_iuzm83ogm43k.md b/.trajectories/completed/2026-04/traj_iuzm83ogm43k.md new file mode 100644 index 0000000..f8aa624 --- /dev/null +++ b/.trajectories/completed/2026-04/traj_iuzm83ogm43k.md @@ -0,0 +1,31 @@ +# Trajectory: Replace chokidar with @parcel/watcher in local-mount + +> **Status:** ✅ Completed +> **Confidence:** 85% +> **Started:** April 20, 2026 at 04:35 PM +> **Completed:** April 20, 2026 at 04:58 PM + +--- + +## Summary + +Replaced chokidar with @parcel/watcher in packages/local-mount. All 22 tests pass. Exit-hang should be gone because unsubscribe() on native-backed watchers resolves promptly instead of draining stability-poll timers. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Switched watcher from chokidar v4 to @parcel/watcher 2.5.6 +- **Chose:** Switched watcher from chokidar v4 to @parcel/watcher 2.5.6 +- **Reasoning:** Chokidar v4's awaitWriteFinish polling + fs.watch teardown caused noticeable hangs on exit. @parcel/watcher uses native FSEvents/inotify/ReadDirectoryChangesW with clean async unsubscribe. Replaced awaitWriteFinish with a small per-path setTimeout debounce; reconcile loop + mtime/content check absorb the looser write semantics. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Switched watcher from chokidar v4 to @parcel/watcher 2.5.6: Switched watcher from chokidar v4 to @parcel/watcher 2.5.6 diff --git a/.trajectories/index.json b/.trajectories/index.json index b559df8..96fd1a1 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,5 +1,13 @@ { "version": 1, - "lastUpdated": "2026-04-20T10:49:34.958Z", - "trajectories": {} + "lastUpdated": "2026-04-20T20:58:15.543Z", + "trajectories": { + "traj_iuzm83ogm43k": { + "title": "Replace chokidar with @parcel/watcher in local-mount", + "status": "completed", + "startedAt": "2026-04-20T20:35:15.759Z", + "completedAt": "2026-04-20T20:58:15.412Z", + "path": "/Users/will/Projects/relayfile/.trajectories/completed/2026-04/traj_iuzm83ogm43k.json" + } + } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cedc52a..cb3cae1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { "name": "relayfile", - "version": "0.2.0", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { - "version": "0.2.0", + "version": "0.2.2", "workspaces": [ "packages/core", "packages/sdk/typescript", @@ -600,6 +600,301 @@ "dev": true, "license": "MIT" }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@relaycast/sdk": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@relaycast/sdk/-/sdk-1.1.0.tgz", @@ -1275,21 +1570,6 @@ "node": ">= 16" } }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -1383,6 +1663,15 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/emoji-regex": { "version": "10.6.0", "dev": true, @@ -1547,6 +1836,15 @@ "node": ">= 4" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "5.1.0", "dev": true, @@ -1561,6 +1859,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1736,6 +2046,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/onetime": { "version": "7.0.0", "dev": true, @@ -1778,7 +2094,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1816,19 +2131,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/relayfile": { "resolved": "packages/cli", "link": true @@ -2434,7 +2736,7 @@ }, "packages/cli": { "name": "relayfile", - "version": "0.2.0", + "version": "0.2.2", "hasInstallScript": true, "license": "MIT", "bin": { @@ -2446,7 +2748,7 @@ }, "packages/core": { "name": "@relayfile/core", - "version": "0.2.0", + "version": "0.2.2", "license": "MIT", "devDependencies": { "typescript": "^5.7.3", @@ -2458,10 +2760,10 @@ }, "packages/local-mount": { "name": "@relayfile/local-mount", - "version": "0.2.0", + "version": "0.2.2", "license": "MIT", "dependencies": { - "chokidar": "^4.0.3", + "@parcel/watcher": "^2.5.6", "ignore": "^7.0.5" }, "devDependencies": { @@ -2487,7 +2789,7 @@ }, "packages/sdk/typescript": { "name": "@relayfile/sdk", - "version": "0.2.0", + "version": "0.2.2", "license": "MIT", "devDependencies": { "typescript": "^5.7.3", diff --git a/packages/local-mount/README.md b/packages/local-mount/README.md index a840931..fecf290 100644 --- a/packages/local-mount/README.md +++ b/packages/local-mount/README.md @@ -70,7 +70,7 @@ By default, `launchOnMount` keeps the mount and project directory in sync contin interface AutoSyncOptions { /** Full-reconcile interval as a safety net. Default: 10_000 ms. */ scanIntervalMs?: number; - /** chokidar `awaitWriteFinish` stability threshold. Default: 200 ms. */ + /** Per-path event debounce in ms. Default: 50 ms. */ writeFinishMs?: number; /** Invoked on sync errors. Defaults to swallowing them. */ onError?: (err: Error) => void; @@ -95,7 +95,7 @@ launchOnMount({ /* ... */, autoSync: { scanIntervalMs: 5_000, writeFinishMs: 100 ``` How it works: -- chokidar watches both the mount and the project tree +- [@parcel/watcher](https://www.npmjs.com/package/@parcel/watcher) watches both the mount and the project tree using native FSEvents/inotify/ReadDirectoryChangesW - every `scanIntervalMs`, a full reconcile walks both trees as a safety net for missed events - per-file `mtime` is tracked at the last sync, so the scan skips files that haven't changed diff --git a/packages/local-mount/package.json b/packages/local-mount/package.json index 1fdedde..0646378 100644 --- a/packages/local-mount/package.json +++ b/packages/local-mount/package.json @@ -15,7 +15,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "chokidar": "^4.0.3", + "@parcel/watcher": "^2.5.6", "ignore": "^7.0.5" }, "devDependencies": { diff --git a/packages/local-mount/src/auto-sync.test.ts b/packages/local-mount/src/auto-sync.test.ts index 269c1b4..2b5495b 100644 --- a/packages/local-mount/src/auto-sync.test.ts +++ b/packages/local-mount/src/auto-sync.test.ts @@ -23,7 +23,7 @@ function write(file: string, body: string): void { /** * Wait up to `timeoutMs` for `check` to return true. Useful for letting - * chokidar + awaitWriteFinish observe a write and propagate it. + * the watcher observe a write and propagate it. */ async function waitFor(check: () => boolean, timeoutMs = 3000): Promise { const start = Date.now(); diff --git a/packages/local-mount/src/auto-sync.ts b/packages/local-mount/src/auto-sync.ts index 1cf5e08..0f4aaee 100644 --- a/packages/local-mount/src/auto-sync.ts +++ b/packages/local-mount/src/auto-sync.ts @@ -12,7 +12,7 @@ import { } from 'node:fs'; import type { Stats } from 'node:fs'; import path from 'node:path'; -import chokidar, { type FSWatcher } from 'chokidar'; +import watcher, { type AsyncSubscription } from '@parcel/watcher'; export interface AutoSyncContext { realMountDir: string; @@ -21,7 +21,8 @@ export interface AutoSyncContext { /** * Directory-only ignore patterns (ending in `/`) must only match when the * path is a directory. Callers that know the path's type pass `isDirectory`; - * callers that don't (chokidar's prune filter) should check both forms. + * callers that don't should omit the second argument and fall back to the + * file-form check. */ isIgnored: (relPosix: string, isDirectory?: boolean) => boolean; isReadonly: (relPosix: string) => boolean; @@ -31,7 +32,10 @@ export interface AutoSyncContext { export interface AutoSyncOptions { /** Full-reconcile interval as a safety net. Default: 10_000ms. */ scanIntervalMs?: number; - /** chokidar awaitWriteFinish stabilityThreshold in ms. Default: 200. */ + /** + * Per-path event debounce in ms. Rapid watcher events for the same path + * are coalesced into a single sync. Default: 50. + */ writeFinishMs?: number; /** Invoked on errors during sync — logged by default consumer. */ onError?: (err: Error) => void; @@ -57,7 +61,7 @@ export function startAutoSync( opts: AutoSyncOptions = {} ): AutoSyncHandle { const scanIntervalMs = opts.scanIntervalMs ?? 10_000; - const writeFinishMs = opts.writeFinishMs ?? 200; + const debounceMs = opts.writeFinishMs ?? 50; const onError = opts.onError ?? (() => { /* ignore by default */ }); const state = new Map(); @@ -67,6 +71,7 @@ export function startAutoSync( let syncing = false; let pending = false; let totalChanges = 0; + const pendingDebounces = new Map(); const runReconcile = async (): Promise => { if (syncing) { @@ -107,34 +112,46 @@ export function startAutoSync( } }; - const makeWatcher = (root: string): { watcher: FSWatcher; ready: Promise } => { - const watcher = chokidar.watch(root, { - ignoreInitial: true, - persistent: true, - followSymlinks: false, - awaitWriteFinish: { - stabilityThreshold: writeFinishMs, - pollInterval: 50, - }, - ignored: (candidate: string, stats?: Stats) => - shouldChokidarIgnore(candidate, root, ctx, stats), - }); - const onEvent = (p: string) => syncPathFromRoot(root, p); - watcher.on('add', onEvent); - watcher.on('change', onEvent); - watcher.on('unlink', onEvent); - watcher.on('error', (err) => onError(err as Error)); - const ready = new Promise((resolve) => { - watcher.once('ready', () => resolve()); - }); - return { watcher, ready }; + const schedulePathSync = (root: string, absPath: string): void => { + // Coalesce bursts of events for the same path. The reconcile path + // re-checks content via mtime+bytes, so a partial-write event that + // races a later write is harmless. + const existing = pendingDebounces.get(absPath); + if (existing) clearTimeout(existing); + const t = setTimeout(() => { + pendingDebounces.delete(absPath); + syncPathFromRoot(root, absPath); + }, debounceMs); + pendingDebounces.set(absPath, t); }; - const mount = makeWatcher(ctx.realMountDir); - const project = makeWatcher(ctx.realProjectDir); - const mountWatcher = mount.watcher; - const projectWatcher = project.watcher; - const watchersReady = Promise.all([mount.ready, project.ready]); + const ignoreGlobs = buildIgnoreGlobs(ctx); + + const subscribeTo = (root: string): Promise => + watcher.subscribe( + root, + (err, events) => { + if (err) { onError(err); return; } + for (const ev of events) { + schedulePathSync(root, ev.path); + } + }, + { ignore: ignoreGlobs } + ); + + let mountSub: AsyncSubscription | undefined; + let projectSub: AsyncSubscription | undefined; + const watchersReady = (async () => { + const [m, p] = await Promise.all([ + subscribeTo(ctx.realMountDir), + subscribeTo(ctx.realProjectDir), + ]); + mountSub = m; + projectSub = p; + })(); + // If subscription setup fails, surface via onError rather than an unhandled + // rejection. stop() will still await the same promise. + watchersReady.catch((err) => onError(err as Error)); const interval = setInterval(() => { void runReconcile(); @@ -145,7 +162,17 @@ export function startAutoSync( return { async stop() { clearInterval(interval); - await Promise.all([mountWatcher.close(), projectWatcher.close()]); + for (const t of pendingDebounces.values()) clearTimeout(t); + pendingDebounces.clear(); + try { + await watchersReady; + } catch { + // Already surfaced via onError; nothing to unsubscribe from. + } + await Promise.all([ + mountSub?.unsubscribe(), + projectSub?.unsubscribe(), + ]); // Drain any pending work so callers can rely on "stopped means quiesced". await runReconcile(); }, @@ -157,6 +184,24 @@ export function startAutoSync( }; } +function buildIgnoreGlobs(ctx: AutoSyncContext): string[] { + // @parcel/watcher matches globs against absolute paths via globset. For each + // excluded directory name, ignore both the directory itself and everything + // beneath it, anywhere under the watched root. The `isExcluded` predicate is + // driven by a Set of directory names, so we probe a small set of common + // exclusions rather than introspecting it. The in-handler `isSyncCandidate` + // filter is authoritative — this is just a perf hint so the watcher doesn't + // recurse into heavy trees like node_modules or .git. + const globs: string[] = []; + const candidates = ['.git', 'node_modules', 'dist', 'build', '.next', '.cache']; + for (const name of candidates) { + if (ctx.isExcluded(name)) { + globs.push(`**/${name}`, `**/${name}/**`); + } + } + return globs; +} + function primeState(state: Map, ctx: AutoSyncContext): void { // Record current mtimes for every file that exists in both trees with the // same content. Files that differ are left out so the first reconcile sees @@ -527,25 +572,3 @@ function walk( } } -function shouldChokidarIgnore( - candidate: string, - root: string, - ctx: AutoSyncContext, - stats?: Stats -): boolean { - if (candidate === root) return false; - const rel = path.relative(root, candidate); - if (rel === '' || rel.startsWith('..')) return false; - const relPosix = rel.split(path.sep).join('/'); - if (ctx.isExcluded(relPosix)) return true; - if (ctx.isReservedFile(relPosix)) return true; - // chokidar calls this filter twice: first without stats (pre-stat prune), - // then again with stats once it knows the entry type. Only apply the - // directory-form match when we have stats confirming it's a directory, - // otherwise a directory-only pattern like `cache/` would wrongly prune a - // same-named file. - if (stats) { - return ctx.isIgnored(relPosix, stats.isDirectory()); - } - return ctx.isIgnored(relPosix); -} diff --git a/packages/local-mount/src/symlink-mount.ts b/packages/local-mount/src/symlink-mount.ts index 2d19509..96102c0 100644 --- a/packages/local-mount/src/symlink-mount.ts +++ b/packages/local-mount/src/symlink-mount.ts @@ -37,8 +37,8 @@ export interface SymlinkMountHandle { syncBack(): Promise; /** * Start bidirectional auto-sync: watches both the mount and project trees - * with chokidar and runs a full reconcile every `scanIntervalMs` as a - * safety net. Returns a handle you must `stop()` before teardown. + * via @parcel/watcher and runs a full reconcile every `scanIntervalMs` + * as a safety net. Returns a handle you must `stop()` before teardown. */ startAutoSync(opts?: AutoSyncOptions): AutoSyncHandle; cleanup(): void; From b2e90df2cf476ac2f22b26fe25615b98fbe52b8d Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 21 Apr 2026 06:55:13 -0400 Subject: [PATCH 2/7] Rename writeFinishMs option to debounceMs Rename the AutoSync option from writeFinishMs to debounceMs across the package. Updated the public interface, implementation (default still 50ms), README examples, and all tests to use the new option name to keep semantics unchanged. --- packages/local-mount/README.md | 4 ++-- packages/local-mount/src/auto-sync.test.ts | 26 +++++++++++----------- packages/local-mount/src/auto-sync.ts | 4 ++-- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/local-mount/README.md b/packages/local-mount/README.md index fecf290..6370dce 100644 --- a/packages/local-mount/README.md +++ b/packages/local-mount/README.md @@ -71,7 +71,7 @@ interface AutoSyncOptions { /** Full-reconcile interval as a safety net. Default: 10_000 ms. */ scanIntervalMs?: number; /** Per-path event debounce in ms. Default: 50 ms. */ - writeFinishMs?: number; + debounceMs?: number; /** Invoked on sync errors. Defaults to swallowing them. */ onError?: (err: Error) => void; } @@ -91,7 +91,7 @@ Control it from `launchOnMount`: launchOnMount({ /* ... */, autoSync: false }); // Tune it. -launchOnMount({ /* ... */, autoSync: { scanIntervalMs: 5_000, writeFinishMs: 100 } }); +launchOnMount({ /* ... */, autoSync: { scanIntervalMs: 5_000, debounceMs: 100 } }); ``` How it works: diff --git a/packages/local-mount/src/auto-sync.test.ts b/packages/local-mount/src/auto-sync.test.ts index 2b5495b..168f01f 100644 --- a/packages/local-mount/src/auto-sync.test.ts +++ b/packages/local-mount/src/auto-sync.test.ts @@ -59,9 +59,9 @@ describe('startAutoSync', () => { excludeDirs: [], }); - // Use a short writeFinish so the test runs quickly; still long enough + // Use a short debounce so the test runs quickly; still long enough // to coalesce a single write. - const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + const auto = handle.startAutoSync({ debounceMs: 50, scanIntervalMs: 10_000 }); await auto.ready(); try { writeFileSync(path.join(handle.mountDir, 'file.txt'), 'edited-in-mount', 'utf8'); @@ -81,7 +81,7 @@ describe('startAutoSync', () => { excludeDirs: [], }); - const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + const auto = handle.startAutoSync({ debounceMs: 50, scanIntervalMs: 10_000 }); await auto.ready(); try { writeFileSync(path.join(projectDir, 'file.txt'), 'edited-externally', 'utf8'); @@ -103,7 +103,7 @@ describe('startAutoSync', () => { excludeDirs: [], }); - const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + const auto = handle.startAutoSync({ debounceMs: 50, scanIntervalMs: 10_000 }); await auto.ready(); try { rmSync(path.join(handle.mountDir, 'file.txt')); @@ -123,7 +123,7 @@ describe('startAutoSync', () => { excludeDirs: [], }); - const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + const auto = handle.startAutoSync({ debounceMs: 50, scanIntervalMs: 10_000 }); await auto.ready(); try { rmSync(path.join(projectDir, 'file.txt')); @@ -143,7 +143,7 @@ describe('startAutoSync', () => { excludeDirs: [], }); - const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + const auto = handle.startAutoSync({ debounceMs: 50, scanIntervalMs: 10_000 }); await auto.ready(); try { // Bypass the 0o444 permission for the test. @@ -171,7 +171,7 @@ describe('startAutoSync', () => { excludeDirs: [], }); - const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + const auto = handle.startAutoSync({ debounceMs: 50, scanIntervalMs: 10_000 }); await auto.ready(); try { writeFileSync(path.join(projectDir, 'locked.txt'), 'updated-externally', 'utf8'); @@ -195,11 +195,11 @@ describe('startAutoSync', () => { // Don't start autosync yet — set up the conflict state first, then // trigger a single reconcile so we exercise the resolution rule. - const auto = handle.startAutoSync({ writeFinishMs: 10, scanIntervalMs: 10_000 }); + const auto = handle.startAutoSync({ debounceMs: 10, scanIntervalMs: 10_000 }); // Stop immediately to drain priming; then mutate and reconcile manually. await auto.stop(); - const auto2 = handle.startAutoSync({ writeFinishMs: 10, scanIntervalMs: 10_000 }); + const auto2 = handle.startAutoSync({ debounceMs: 10, scanIntervalMs: 10_000 }); await auto2.ready(); try { writeFileSync(path.join(projectDir, 'file.txt'), 'project-side', 'utf8'); @@ -224,7 +224,7 @@ describe('startAutoSync', () => { excludeDirs: [], }); - const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + const auto = handle.startAutoSync({ debounceMs: 50, scanIntervalMs: 10_000 }); await auto.ready(); try { // File appearing in project under an ignored path — must NOT appear in mount. @@ -254,7 +254,7 @@ describe('startAutoSync', () => { excludeDirs: [], }); - const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + const auto = handle.startAutoSync({ debounceMs: 50, scanIntervalMs: 10_000 }); await auto.ready(); try { writeFileSync(path.join(handle.mountDir, 'docs/cache'), 'edited', 'utf8'); @@ -278,7 +278,7 @@ describe('startAutoSync', () => { // Start autosync but we'll rely on the explicit reconcile() call rather // than waiting for the watcher, to simulate a missed event. - const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 100 }); + const auto = handle.startAutoSync({ debounceMs: 50, scanIntervalMs: 100 }); try { writeFileSync(path.join(handle.mountDir, 'file.txt'), 'edited', 'utf8'); // Forcing a reconcile should find the change regardless of whether @@ -298,7 +298,7 @@ describe('startAutoSync', () => { excludeDirs: [], }); - const auto = handle.startAutoSync({ writeFinishMs: 50, scanIntervalMs: 10_000 }); + const auto = handle.startAutoSync({ debounceMs: 50, scanIntervalMs: 10_000 }); await auto.ready(); try { writeFileSync(path.join(handle.mountDir, '_MOUNT_README.md'), 'mutated', 'utf8'); diff --git a/packages/local-mount/src/auto-sync.ts b/packages/local-mount/src/auto-sync.ts index 0f4aaee..91e8791 100644 --- a/packages/local-mount/src/auto-sync.ts +++ b/packages/local-mount/src/auto-sync.ts @@ -36,7 +36,7 @@ export interface AutoSyncOptions { * Per-path event debounce in ms. Rapid watcher events for the same path * are coalesced into a single sync. Default: 50. */ - writeFinishMs?: number; + debounceMs?: number; /** Invoked on errors during sync — logged by default consumer. */ onError?: (err: Error) => void; } @@ -61,7 +61,7 @@ export function startAutoSync( opts: AutoSyncOptions = {} ): AutoSyncHandle { const scanIntervalMs = opts.scanIntervalMs ?? 10_000; - const debounceMs = opts.writeFinishMs ?? 50; + const debounceMs = opts.debounceMs ?? 50; const onError = opts.onError ?? (() => { /* ignore by default */ }); const state = new Map(); From 6a1457c2f2c374fb23c3a36562eed7734829a9a4 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 21 Apr 2026 07:05:10 -0400 Subject: [PATCH 3/7] Update package-lock.json --- package-lock.json | 376 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 339 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6c8189f..224fee4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { "name": "relayfile", - "version": "0.3.0", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { - "version": "0.3.0", + "version": "0.3.2", "workspaces": [ "packages/core", "packages/sdk/typescript", @@ -600,6 +600,301 @@ "dev": true, "license": "MIT" }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@relaycast/sdk": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@relaycast/sdk/-/sdk-1.1.0.tgz", @@ -1275,21 +1570,6 @@ "node": ">= 16" } }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -1383,6 +1663,15 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/emoji-regex": { "version": "10.6.0", "dev": true, @@ -1547,6 +1836,15 @@ "node": ">= 4" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "5.1.0", "dev": true, @@ -1561,6 +1859,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1736,6 +2046,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/onetime": { "version": "7.0.0", "dev": true, @@ -1778,7 +2094,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1816,19 +2131,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/relayfile": { "resolved": "packages/cli", "link": true @@ -2434,7 +2736,7 @@ }, "packages/cli": { "name": "relayfile", - "version": "0.3.0", + "version": "0.3.2", "hasInstallScript": true, "license": "MIT", "bin": { @@ -2446,7 +2748,7 @@ }, "packages/core": { "name": "@relayfile/core", - "version": "0.5.0", + "version": "0.3.2", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", @@ -2459,10 +2761,10 @@ }, "packages/local-mount": { "name": "@relayfile/local-mount", - "version": "0.3.0", + "version": "0.3.2", "license": "MIT", "dependencies": { - "chokidar": "^4.0.3", + "@parcel/watcher": "^2.5.6", "ignore": "^7.0.5" }, "devDependencies": { @@ -2488,10 +2790,10 @@ }, "packages/sdk/typescript": { "name": "@relayfile/sdk", - "version": "0.4.0", + "version": "0.3.2", "license": "MIT", "dependencies": { - "@relayfile/core": "^0.5.0" + "@relayfile/core": "0.3.2" }, "devDependencies": { "typescript": "^5.7.3", From 94513ded8aacc20b986792f35c8e4e5ae3371bc6 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 21 Apr 2026 07:11:05 -0400 Subject: [PATCH 4/7] Add per-package CHANGELOGs and README note Add initial CHANGELOG.md files for CLI, core, SDK (typescript), local-mount, and file-observer packages and populate them with recent release history and an Unreleased section. Update README to document where package changelogs live and add a short process for how PRs should add entries and how releases should be cut (Keep a Changelog format and compare-link guidance). --- README.md | 16 +++++++ packages/cli/CHANGELOG.md | 66 ++++++++++++++++++++++++++++ packages/core/CHANGELOG.md | 25 +++++++++++ packages/file-observer/CHANGELOG.md | 30 +++++++++++++ packages/local-mount/CHANGELOG.md | 43 ++++++++++++++++++ packages/sdk/typescript/CHANGELOG.md | 32 ++++++++++++++ 6 files changed, 212 insertions(+) create mode 100644 packages/cli/CHANGELOG.md create mode 100644 packages/core/CHANGELOG.md create mode 100644 packages/file-observer/CHANGELOG.md create mode 100644 packages/local-mount/CHANGELOG.md create mode 100644 packages/sdk/typescript/CHANGELOG.md diff --git a/README.md b/README.md index 9c7797b..eebd189 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,22 @@ relayfile observer relayfile observer ws_123 --no-open ``` +## Changelogs + +Each publishable package keeps its own `CHANGELOG.md`: + +- [`relayfile`](packages/cli/CHANGELOG.md) — CLI +- [`@relayfile/core`](packages/core/CHANGELOG.md) +- [`@relayfile/sdk`](packages/sdk/typescript/CHANGELOG.md) +- [`@relayfile/local-mount`](packages/local-mount/CHANGELOG.md) +- [`@relayfile/file-observer`](packages/file-observer/CHANGELOG.md) + +**Process** — landed in every PR, finalized at release: + +1. PRs that touch a package add an entry under its `## [Unreleased]` section (Keep a Changelog format: `Added` / `Changed` / `Deprecated` / `Removed` / `Fixed` / `Security`). Include the PR number as a link reference at the bottom of the file. +2. When cutting a release, rename `[Unreleased]` to `[x.y.z] - YYYY-MM-DD` and open a fresh empty `[Unreleased]` section above it. Update the compare-link references accordingly. +3. Packages without user-visible changes in a given release leave `[Unreleased]` as `_No unreleased changes._` through the version bump. + ## License MIT diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md new file mode 100644 index 0000000..85828fd --- /dev/null +++ b/packages/cli/CHANGELOG.md @@ -0,0 +1,66 @@ +# Changelog + +All notable changes to `relayfile` (CLI) are documented here. +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +_No unreleased changes._ + +## [0.2.2] - 2026-04-20 + +### Fixed +- Publish workflow now attaches `relayfile-mount` binaries to GitHub releases alongside `relayfile`. ([#51]) + +## [0.2.0] - 2026-04-19 + +### Added +- Ship the `@relayfile/local-mount` package via the monorepo publish workflow. ([#47]) + +## [0.1.13] - 2026-04-17 + +### Added +- `relayfile observer` — launch the file-observer dashboard from the CLI. ([#46]) + +### Fixed +- Default the observer to the live router path. +- Reduce WebSocket polling overhead in mount mode. ([#45]) + +## [0.1.12] - 2026-04-17 + +### Fixed +- Default to the hosted relayfile API so first-run no longer requires `--host`. ([#44]) +- Speed up mount bootstrap by seeding from an export snapshot. ([#44]) + +## [0.1.11] - 2026-04-17 + +### Added +- Support a default workspace selection so repeat invocations skip the picker. ([#43]) + +## [0.1.10] - 2026-04-17 + +### Fixed +- Package native Go binaries into the npm distribution. ([#42]) + +## [0.1.7] - 2026-04-11 + +### Fixed +- Upload raw binaries to GitHub releases. ([#34]) + +[Unreleased]: https://github.com/AgentWorkforce/relayfile/compare/v0.3.2...HEAD +[0.2.2]: https://github.com/AgentWorkforce/relayfile/releases/tag/v0.2.2 +[0.2.0]: https://github.com/AgentWorkforce/relayfile/releases/tag/v0.2.0 +[0.1.13]: https://github.com/AgentWorkforce/relayfile/releases/tag/v0.1.13 +[0.1.12]: https://github.com/AgentWorkforce/relayfile/releases/tag/v0.1.12 +[0.1.11]: https://github.com/AgentWorkforce/relayfile/releases/tag/v0.1.11 +[0.1.10]: https://github.com/AgentWorkforce/relayfile/releases/tag/v0.1.10 +[0.1.7]: https://github.com/AgentWorkforce/relayfile/releases/tag/v0.1.7 +[#34]: https://github.com/AgentWorkforce/relayfile/pull/34 +[#42]: https://github.com/AgentWorkforce/relayfile/pull/42 +[#43]: https://github.com/AgentWorkforce/relayfile/pull/43 +[#44]: https://github.com/AgentWorkforce/relayfile/pull/44 +[#45]: https://github.com/AgentWorkforce/relayfile/pull/45 +[#46]: https://github.com/AgentWorkforce/relayfile/pull/46 +[#47]: https://github.com/AgentWorkforce/relayfile/pull/47 +[#51]: https://github.com/AgentWorkforce/relayfile/pull/51 diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md new file mode 100644 index 0000000..e2f5283 --- /dev/null +++ b/packages/core/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to `@relayfile/core` are documented here. +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +_No unreleased changes._ + +## [0.3.2] - 2026-04-21 + +### Added +- `relayfile.fork` API — fork a workspace at a specific commit. ([#58]) + +## [0.3.0] - 2026-04-20 + +### Added +- Optional `contentIdentity` on write operations, enabling server-side deduplication of identical payloads. ([#54]) + +[Unreleased]: https://github.com/AgentWorkforce/relayfile/compare/v0.3.2...HEAD +[0.3.2]: https://github.com/AgentWorkforce/relayfile/releases/tag/v0.3.2 +[0.3.0]: https://github.com/AgentWorkforce/relayfile/releases/tag/v0.3.0 +[#54]: https://github.com/AgentWorkforce/relayfile/pull/54 +[#58]: https://github.com/AgentWorkforce/relayfile/pull/58 diff --git a/packages/file-observer/CHANGELOG.md b/packages/file-observer/CHANGELOG.md new file mode 100644 index 0000000..86ee0d9 --- /dev/null +++ b/packages/file-observer/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to `@relayfile/file-observer` are documented here. +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +_No unreleased changes._ + +## [0.1.13] - 2026-04-17 + +### Added +- Launchable from the `relayfile` CLI via `relayfile observer`. ([#46]) + +### Fixed +- Default the observer to the live router path instead of a local stub. + +## [0.1.9] - 2026-04-17 + +Initial release. + +### Added +- Dashboard workflow for visualizing relayfile filesystem activity in real time. ([#40]) + +[Unreleased]: https://github.com/AgentWorkforce/relayfile/compare/v0.3.2...HEAD +[0.1.13]: https://github.com/AgentWorkforce/relayfile/releases/tag/v0.1.13 +[0.1.9]: https://github.com/AgentWorkforce/relayfile/releases/tag/v0.1.9 +[#40]: https://github.com/AgentWorkforce/relayfile/pull/40 +[#46]: https://github.com/AgentWorkforce/relayfile/pull/46 diff --git a/packages/local-mount/CHANGELOG.md b/packages/local-mount/CHANGELOG.md new file mode 100644 index 0000000..9190d24 --- /dev/null +++ b/packages/local-mount/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog + +All notable changes to `@relayfile/local-mount` are documented here. +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed +- **BREAKING**: Renamed `AutoSyncOptions.writeFinishMs` → `AutoSyncOptions.debounceMs`. The semantics also shifted — it is now a per-path event coalescing debounce (default `50ms`), not a file-stability threshold (previously `200ms`). Any caller passing `writeFinishMs` will now be ignored silently under TypeScript's structural typing; update the field name. +- Replaced `chokidar` with [`@parcel/watcher`](https://www.npmjs.com/package/@parcel/watcher) for file watching. `autoSync.stop()` no longer hangs on teardown — native FSEvents/inotify/ReadDirectoryChangesW subscriptions unsubscribe promptly instead of draining per-file `awaitWriteFinish` polling timers. + +## [0.3.0] - 2026-04-20 + +### Fixed +- Ignore mount-watcher echo writes so a mount→project propagation does not fire a spurious project→mount event. ([#52]) + +## [0.2.1] - 2026-04-20 + +### Fixed +- Preserve the local mount copy when the server denies a mount-sync write, instead of dropping it. ([#50]) + +## [0.2.0] - 2026-04-19 + +Initial release. + +### Added +- `createSymlinkMount(projectDir, mountDir, options)` — copies files into a mount directory, honors `.agentignore` and `.agentreadonly` dotfiles, enforces mode `0o444` on readonly matches, and writes `_MOUNT_README.md` / `.relayfile-local-mount` markers. ([#47]) +- `readAgentDotfiles(projectDir, options?)` — reads project-local `.agentignore`, `.agentreadonly`, and per-agent variants. +- `launchOnMount(options)` — creates a mount, spawns a CLI inside it, forwards `SIGINT` / `SIGTERM`, runs a final sync-back pass, and tears the mount down. +- `startAutoSync()` — bidirectional mount↔project sync with mount-wins conflict resolution, delete propagation, and a periodic full-reconcile safety net. ([#49]) +- Directory-only ignore patterns (e.g. `cache/`) match directories without swallowing like-named files. +- README documenting the mount lifecycle, dotfile semantics, and auto-sync behavior. ([#48]) + +[Unreleased]: https://github.com/AgentWorkforce/relayfile/compare/v0.3.2...HEAD +[0.3.0]: https://github.com/AgentWorkforce/relayfile/releases/tag/v0.3.0 +[0.2.1]: https://github.com/AgentWorkforce/relayfile/releases/tag/v0.2.1 +[0.2.0]: https://github.com/AgentWorkforce/relayfile/releases/tag/v0.2.0 +[#47]: https://github.com/AgentWorkforce/relayfile/pull/47 +[#48]: https://github.com/AgentWorkforce/relayfile/pull/48 +[#49]: https://github.com/AgentWorkforce/relayfile/pull/49 +[#50]: https://github.com/AgentWorkforce/relayfile/pull/50 +[#52]: https://github.com/AgentWorkforce/relayfile/pull/52 diff --git a/packages/sdk/typescript/CHANGELOG.md b/packages/sdk/typescript/CHANGELOG.md new file mode 100644 index 0000000..78f29d9 --- /dev/null +++ b/packages/sdk/typescript/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to `@relayfile/sdk` are documented here. +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +_No unreleased changes._ + +## [0.3.2] - 2026-04-21 + +### Added +- `relayfile.fork` client method — fork a workspace at a specific commit. ([#58]) + +## [0.3.1] - 2026-04-21 + +### Added +- Expose `ContentIdentity` on write types so callers can opt into server-side deduplication. ([#57]) + +## [0.1.8] - 2026-04-16 + +### Fixed +- Bind `fetch` to `globalThis` so the SDK runs on Cloudflare Workers without `TypeError: Illegal invocation`. ([#41]) + +[Unreleased]: https://github.com/AgentWorkforce/relayfile/compare/v0.3.2...HEAD +[0.3.2]: https://github.com/AgentWorkforce/relayfile/releases/tag/v0.3.2 +[0.3.1]: https://github.com/AgentWorkforce/relayfile/releases/tag/v0.3.1 +[0.1.8]: https://github.com/AgentWorkforce/relayfile/releases/tag/v0.1.8 +[#41]: https://github.com/AgentWorkforce/relayfile/pull/41 +[#57]: https://github.com/AgentWorkforce/relayfile/pull/57 +[#58]: https://github.com/AgentWorkforce/relayfile/pull/58 From f291a1720c6ee4b4e47c3fc19c3e63634fcff252 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 21 Apr 2026 07:21:05 -0400 Subject: [PATCH 5/7] Finalize per-package CHANGELOGs on release Add a finalize-changelogs script and wire it into the publish workflow so releases rename each package's `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, insert a fresh `## [Unreleased]` section, update compare links and append a release link. The workflow skips finalization for prereleases and dry runs, and now includes CHANGELOG.md files in the build artifact list and in the release commit. Update README to document the automated finalization behavior and the script's role. --- .github/workflows/publish.yml | 25 ++++++++++- README.md | 4 +- scripts/finalize-changelogs.mjs | 73 +++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 scripts/finalize-changelogs.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 84a43ed..66b8e13 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -156,6 +156,18 @@ jobs: } " + # Rename each package's `## [Unreleased]` section to `## [X.Y.Z] - ` + # and reopen an empty Unreleased section. Skipped on prereleases and dry + # runs — prereleases would fragment a single logical release into many + # changelog entries, and dry runs must not mutate files. + - name: Finalize CHANGELOGs + if: | + steps.bump.outputs.is_prerelease == 'false' && + github.event.inputs.dry_run != 'true' + env: + GITHUB_REPOSITORY: ${{ github.repository }} + run: node scripts/finalize-changelogs.mjs "${{ steps.bump.outputs.new_version }}" + - name: Build packages run: | npm run build --workspace=packages/core @@ -173,13 +185,18 @@ jobs: package.json packages/core/package.json packages/core/dist/ + packages/core/CHANGELOG.md packages/sdk/typescript/package.json packages/sdk/typescript/dist/ + packages/sdk/typescript/CHANGELOG.md packages/cli/package.json packages/cli/bin/ + packages/cli/CHANGELOG.md packages/file-observer/package.json + packages/file-observer/CHANGELOG.md packages/local-mount/package.json packages/local-mount/dist/ + packages/local-mount/CHANGELOG.md retention-days: 1 # Cross-compile relayfile-mount for every consumer-supported platform. @@ -420,7 +437,13 @@ jobs: git config user.name "GitHub Actions" git config user.email "actions@github.com" - git add package.json packages/core/package.json packages/sdk/typescript/package.json packages/cli/package.json packages/file-observer/package.json packages/local-mount/package.json + git add \ + package.json \ + packages/core/package.json packages/core/CHANGELOG.md \ + packages/sdk/typescript/package.json packages/sdk/typescript/CHANGELOG.md \ + packages/cli/package.json packages/cli/CHANGELOG.md \ + packages/file-observer/package.json packages/file-observer/CHANGELOG.md \ + packages/local-mount/package.json packages/local-mount/CHANGELOG.md if ! git diff --staged --quiet; then git commit -m "chore(release): v${NEW_VERSION}" git push diff --git a/README.md b/README.md index eebd189..d9cebdc 100644 --- a/README.md +++ b/README.md @@ -207,8 +207,8 @@ Each publishable package keeps its own `CHANGELOG.md`: **Process** — landed in every PR, finalized at release: 1. PRs that touch a package add an entry under its `## [Unreleased]` section (Keep a Changelog format: `Added` / `Changed` / `Deprecated` / `Removed` / `Fixed` / `Security`). Include the PR number as a link reference at the bottom of the file. -2. When cutting a release, rename `[Unreleased]` to `[x.y.z] - YYYY-MM-DD` and open a fresh empty `[Unreleased]` section above it. Update the compare-link references accordingly. -3. Packages without user-visible changes in a given release leave `[Unreleased]` as `_No unreleased changes._` through the version bump. +2. At release, the `Publish Package` workflow runs [`scripts/finalize-changelogs.mjs`](scripts/finalize-changelogs.mjs), which renames `[Unreleased]` to `[x.y.z] - YYYY-MM-DD`, opens a fresh empty `[Unreleased]` above, and rewrites the compare-link references. Prereleases skip this step so their entries accumulate until the final release. +3. Packages without user-visible changes in a given release leave `[Unreleased]` as `_No unreleased changes._`. The finalizer rewrites the dated section's body to `_No user-visible changes in this release._` so the release heading still reads naturally. ## License diff --git a/scripts/finalize-changelogs.mjs b/scripts/finalize-changelogs.mjs new file mode 100644 index 0000000..482069a --- /dev/null +++ b/scripts/finalize-changelogs.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// Finalize each package's CHANGELOG.md for a release: +// - rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` +// - insert a fresh empty `## [Unreleased]` section above it +// - rewrite the `[Unreleased]: ...compare/...HEAD` link to start from the new tag +// - append a `[X.Y.Z]: ...releases/tag/vX.Y.Z` link reference +// +// Usage: node scripts/finalize-changelogs.mjs +// e.g. node scripts/finalize-changelogs.mjs 0.3.3 + +import fs from 'node:fs'; + +const version = process.argv[2]; +if (!version) { + console.error('Usage: finalize-changelogs.mjs '); + process.exit(1); +} + +const today = new Date().toISOString().slice(0, 10); +const repoSlug = process.env.GITHUB_REPOSITORY || 'AgentWorkforce/relayfile'; + +const changelogs = [ + 'packages/core/CHANGELOG.md', + 'packages/sdk/typescript/CHANGELOG.md', + 'packages/cli/CHANGELOG.md', + 'packages/file-observer/CHANGELOG.md', + 'packages/local-mount/CHANGELOG.md', +]; + +const placeholder = '_No unreleased changes._'; +const unreleasedHeadingRe = /^## \[Unreleased\][^\n]*$/m; +const unreleasedLinkRe = + /^\[Unreleased\]:\s*https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/compare\/\S+?\.\.\.HEAD\s*$/m; + +for (const clPath of changelogs) { + if (!fs.existsSync(clPath)) { + console.log(`skip ${clPath}: missing`); + continue; + } + let text = fs.readFileSync(clPath, 'utf8'); + + if (!unreleasedHeadingRe.test(text)) { + console.log(`skip ${clPath}: no [Unreleased] heading`); + continue; + } + + const versionHeading = `## [${version}] - ${today}`; + text = text.replace( + unreleasedHeadingRe, + ['## [Unreleased]', '', placeholder, '', versionHeading].join('\n') + ); + + // If the newly-created version block inherited only the placeholder, reword + // it so the release entry reads naturally instead of "No unreleased changes" + // under a dated heading. + const inheritedEmpty = `${versionHeading}\n\n${placeholder}`; + const rewritten = `${versionHeading}\n\n_No user-visible changes in this release._`; + if (text.includes(inheritedEmpty)) { + text = text.replace(inheritedEmpty, rewritten); + } + + const newCompare = `[Unreleased]: https://github.com/${repoSlug}/compare/v${version}...HEAD`; + const releaseLink = `[${version}]: https://github.com/${repoSlug}/releases/tag/v${version}`; + + if (unreleasedLinkRe.test(text)) { + text = text.replace(unreleasedLinkRe, `${newCompare}\n${releaseLink}`); + } else { + text = text.replace(/\s*$/, `\n\n${newCompare}\n${releaseLink}\n`); + } + + fs.writeFileSync(clPath, text); + console.log(`updated ${clPath} -> [${version}] ${today}`); +} From 9195511fafae1bec5cfe426d7e50d0985150b465 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 21 Apr 2026 08:33:23 -0400 Subject: [PATCH 6/7] Prevent subscription leak on subscribe failure Use Promise.allSettled when subscribing to mount/project watchers in startAutoSync so each subscribe outcome is tracked independently. If one subscribe rejects, the fulfilled subscription is unsubscribed and cleared before re-throwing the original error. Also switch teardown to Promise.allSettled to avoid unhandled rejections and add a CHANGELOG entry documenting the fix. --- packages/local-mount/CHANGELOG.md | 3 +++ packages/local-mount/src/auto-sync.ts | 30 +++++++++++++++++++++------ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/local-mount/CHANGELOG.md b/packages/local-mount/CHANGELOG.md index 9190d24..e6177d0 100644 --- a/packages/local-mount/CHANGELOG.md +++ b/packages/local-mount/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING**: Renamed `AutoSyncOptions.writeFinishMs` → `AutoSyncOptions.debounceMs`. The semantics also shifted — it is now a per-path event coalescing debounce (default `50ms`), not a file-stability threshold (previously `200ms`). Any caller passing `writeFinishMs` will now be ignored silently under TypeScript's structural typing; update the field name. - Replaced `chokidar` with [`@parcel/watcher`](https://www.npmjs.com/package/@parcel/watcher) for file watching. `autoSync.stop()` no longer hangs on teardown — native FSEvents/inotify/ReadDirectoryChangesW subscriptions unsubscribe promptly instead of draining per-file `awaitWriteFinish` polling timers. +### Fixed +- `startAutoSync` no longer leaks a subscription when one of the two `@parcel/watcher` subscribes rejects. If mount- or project-side setup fails, the successful side is now unsubscribed before the error surfaces. + ## [0.3.0] - 2026-04-20 ### Fixed diff --git a/packages/local-mount/src/auto-sync.ts b/packages/local-mount/src/auto-sync.ts index 91e8791..7a93df9 100644 --- a/packages/local-mount/src/auto-sync.ts +++ b/packages/local-mount/src/auto-sync.ts @@ -141,16 +141,33 @@ export function startAutoSync( let mountSub: AsyncSubscription | undefined; let projectSub: AsyncSubscription | undefined; + // Subscribe in parallel but track each outcome independently. With + // Promise.all, a failure on one side would reject before the other's + // assignment ran and leak the succeeded subscription. allSettled lets us + // tear down whichever fulfilled before re-throwing the first failure. const watchersReady = (async () => { - const [m, p] = await Promise.all([ + const [mountResult, projectResult] = await Promise.allSettled([ subscribeTo(ctx.realMountDir), subscribeTo(ctx.realProjectDir), ]); - mountSub = m; - projectSub = p; + if (mountResult.status === 'fulfilled') mountSub = mountResult.value; + if (projectResult.status === 'fulfilled') projectSub = projectResult.value; + if (mountResult.status === 'fulfilled' && projectResult.status === 'fulfilled') { + return; + } + await Promise.allSettled([ + mountSub?.unsubscribe(), + projectSub?.unsubscribe(), + ]); + mountSub = undefined; + projectSub = undefined; + throw mountResult.status === 'rejected' + ? mountResult.reason + : (projectResult as PromiseRejectedResult).reason; })(); // If subscription setup fails, surface via onError rather than an unhandled - // rejection. stop() will still await the same promise. + // rejection. stop() still awaits the same promise and will observe the + // rejection after the cleanup above has already run. watchersReady.catch((err) => onError(err as Error)); const interval = setInterval(() => { @@ -167,9 +184,10 @@ export function startAutoSync( try { await watchersReady; } catch { - // Already surfaced via onError; nothing to unsubscribe from. + // Setup failed and already cleaned up any partial subscription; + // mountSub / projectSub were reset to undefined before the throw. } - await Promise.all([ + await Promise.allSettled([ mountSub?.unsubscribe(), projectSub?.unsubscribe(), ]); From 62e5ba680771b9eb9f89c4a3d43897d7f6793c0f Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 21 Apr 2026 08:36:32 -0400 Subject: [PATCH 7/7] Make AutoSync.stop quiesce pending debounces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent timers from running after AutoSync is stopped by adding a `stopped` flag and refusing to schedule new debounces once `stop()` begins. Move clearing of `pendingDebounces` until after watcher unsubscribes resolve so any events delivered during the unsubscribe await are collected and cancelled before `stop()` returns. Also update CHANGELOG with the behavior/fix so callers can rely on “stopped means quiesced.” --- packages/local-mount/CHANGELOG.md | 1 + packages/local-mount/src/auto-sync.ts | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/local-mount/CHANGELOG.md b/packages/local-mount/CHANGELOG.md index e6177d0..e0857ec 100644 --- a/packages/local-mount/CHANGELOG.md +++ b/packages/local-mount/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `startAutoSync` no longer leaks a subscription when one of the two `@parcel/watcher` subscribes rejects. If mount- or project-side setup fails, the successful side is now unsubscribed before the error surfaces. +- `AutoSyncHandle.stop()` now honors its "stopped means quiesced" contract. A `stopped` flag blocks new debounces from scheduling the moment `stop()` is called, and the `pendingDebounces` map is cleared *after* the watcher unsubscribes resolve. Previously, events delivered during the unsubscribe await could create timers that fired after `stop()` returned, running file ops against a mount `launchOnMount`'s `cleanup()` had already deleted. ## [0.3.0] - 2026-04-20 diff --git a/packages/local-mount/src/auto-sync.ts b/packages/local-mount/src/auto-sync.ts index 7a93df9..9739234 100644 --- a/packages/local-mount/src/auto-sync.ts +++ b/packages/local-mount/src/auto-sync.ts @@ -70,6 +70,7 @@ export function startAutoSync( let syncing = false; let pending = false; + let stopped = false; let totalChanges = 0; const pendingDebounces = new Map(); @@ -113,6 +114,12 @@ export function startAutoSync( }; const schedulePathSync = (root: string, absPath: string): void => { + // Once stop() has begun, refuse to schedule new timers. Watcher + // callbacks can still fire during the unsubscribe await (native + // backends deliver queued events asynchronously); without this guard + // those events would create timers that outlive stop() and do file + // work against a mount the caller may have already cleaned up. + if (stopped) return; // Coalesce bursts of events for the same path. The reconcile path // re-checks content via mtime+bytes, so a partial-write event that // races a later write is harmless. @@ -178,9 +185,10 @@ export function startAutoSync( return { async stop() { + // Flip the flag first so any watcher callbacks delivered during the + // awaits below refuse to schedule new timers. + stopped = true; clearInterval(interval); - for (const t of pendingDebounces.values()) clearTimeout(t); - pendingDebounces.clear(); try { await watchersReady; } catch { @@ -191,6 +199,11 @@ export function startAutoSync( mountSub?.unsubscribe(), projectSub?.unsubscribe(), ]); + // Clear debounces *after* unsubscribe resolves: any timer scheduled + // between stop() being called and the watcher actually quiescing is + // gathered here, so none fire after stop() returns. + for (const t of pendingDebounces.values()) clearTimeout(t); + pendingDebounces.clear(); // Drain any pending work so callers can rely on "stopped means quiesced". await runReconcile(); },