Skip to content

feat: add WINDOWS_MODE for a vault on a Windows C: drive#164

Merged
aliasunder merged 9 commits into
mainfrom
claude/vault-bootstrap-setup-iaryye
Jun 21, 2026
Merged

feat: add WINDOWS_MODE for a vault on a Windows C: drive#164
aliasunder merged 9 commits into
mainfrom
claude/vault-bootstrap-setup-iaryye

Conversation

@aliasunder

@aliasunder aliasunder commented Jun 21, 2026

Copy link
Copy Markdown
Owner

What & why

Running vault-cortex through Docker Desktop with the vault on a Windows drive (C:\Users\you\Vault) crosses the Docker Desktop ↔ WSL2 filesystem bridge, where two things silently break:

  • Live re-indexing stops — chokidar's native inotify events don't propagate across the bridge, so the FTS5 index goes stale until restart.
  • vault_move_note failsatomicWriteFileExclusive creates the destination with fs.link() (hard link, for atomic no-clobber), which the bridge can't do, so link() throws and the move aborts.

Reads, writes, and search keep working. Until now the only fix was the documented "put your vault in WSL2 ext4" workaround. This makes a plain C: mount work out of the box.

Approach — one explicit flag

A single env var, WINDOWS_MODE (default false), surfaced as windowsBindMount on VaultConfig and threaded through the existing config plumbing (no new wiring). When on, it deterministically switches both affected behaviors to bridge-safe variants:

  • Watcher — chokidar usePolling with a baked-in 300ms interval (re-index latency is already governed by the 2000ms awaitWriteFinish window, so the perceived cost is negligible).
  • MovesatomicWriteFileExclusive takes a hardLinksUnsupported option that switches to a check-then-rename no-clobber write, synthesizing EEXIST so moveNote's existing catch is unchanged. fileExists relocated into vault-filesystem.ts beside the other path helpers for reuse.
  • Startup — logs windows bind-mount mode enabled once.

The user-facing name is deliberately plain so a non-technical Windows user can flip it without knowing what a bind mount is; the precise windowsBindMount name lives internally. It's safe to leave on for any Windows setup (polling and rename work on every filesystem — just marginally more CPU), so the guidance is foolproof.

The move path is now deterministic (the flag picks rename vs. link) rather than attempting link() and sniffing error codes — no fragile error-code set, no per-call fallback.

Scope

  • Docs scoped to the local deploy + the canonical README config table. Remote templates intentionally omit it — the remote deploy uses named volumes, where native inotify and hard links both work.
  • Resynced cli/templates/local/docker-compose.yml (byte-identical bundle) via npm run sync:cli-templates.

Tests

npm run build, npm run lint, and npm test all green — 838 tests (+12 new) across config (WINDOWS_MODE parse + default + fail-fast), watcher (indexes under polling), vault-filesystem (rename-strategy writes + no-clobber + fileExists), and note-mover (windows-mode move + existing-destination refusal). Each new behavior was mutation-checked: disabling the rename-path no-clobber guard fails the EEXIST test for that reason, and since that guard lives only in the rename branch, it also confirms the branch is exercised.

Not reproducible in CI: a real Windows + Docker Desktop + C:-mount smoke test (no WSL2 bridge on Linux) — worth a manual pass before merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_011ZAjKyViw114zXf95QfWcN


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Added Windows Mode support for vaults on C: drives, enabling polling-based file watching and rename-based file operations to work seamlessly with Docker Desktop setups.
  • Documentation

    • Updated setup instructions guiding Windows users to enable Windows Mode instead of placing vaults in WSL2; expanded configuration documentation with the new Windows Mode environment variable.

Running vault-cortex through Docker Desktop with the vault on a Windows
drive crosses the Docker Desktop <-> WSL2 bridge, where two things silently
break: live re-indexing (chokidar's native inotify events don't cross the
bridge) and vault_move_note (atomicWriteFileExclusive's hard-link write is
unsupported). Reads, writes, and search still work.

Add a single explicit env var, WINDOWS_MODE (default false), surfaced on
VaultConfig as windowsBindMount and threaded through the existing config
plumbing. When on, it deterministically switches both affected behaviors to
bridge-safe variants:

- file watcher uses chokidar polling (interval baked at 300ms)
- atomicWriteFileExclusive uses a check-then-rename no-clobber write instead
  of a hard link (preserving the EEXIST contract for moveNote)

The user-facing name is plain so a non-technical Windows user can flip it
without knowing what a bind mount is; it's safe to leave on for any Windows
setup. Relocate fileExists into vault-filesystem (beside the other path
helpers) so the rename strategy can reuse it. Docs scoped to the local
deploy + the canonical README table; remote (named volumes) is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZAjKyViw114zXf95QfWcN
Comment thread src/vault-mcp/vault-operations/vault-filesystem.ts
Comment thread src/vault-mcp/vault-operations/vault-filesystem.ts Outdated
claude added 2 commits June 21, 2026 02:43
…ion)

The hardLinksUnsupported fallback used check-then-rename, which has a TOCTOU
window: a file created between the existence check and rename() could be
clobbered, losing data under concurrent vault_move_note calls. Reserve the
destination atomically with an O_EXCL create (the 'wx' flag) instead — which
throws EEXIST race-free and is far more portable than hard links (it's link()
specifically that the bridge can't do) — then rename the staged temp over the
empty placeholder so the content still lands atomically, cleaning up the
placeholder if the swap fails.

This also drops the synthesized EEXIST (the real one now propagates) and the
fallback's need for fileExists, so revert that helper back to private in
note-mover rather than exporting it from vault-filesystem.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZAjKyViw114zXf95QfWcN
Sweep of the PR's new tests against the test conventions:

- config: the fail-fast test used a bare toThrow(); pin it to /WINDOWS_MODE/
  so it asserts the env-var parse rejected, not just that something threw.
- note-mover: the Windows-mode move test asserted two result fields; switch to
  a whole-object toEqual (matching the sibling test). Rename both Windows-mode
  tests to describe the configured mode rather than imply the assertion proves
  the rename code path — the no-clobber refusal is enforced by moveNote's
  preflight here, while the rename-path O_EXCL/wx guard is unit-tested in
  vault-filesystem.test.ts. Note that in the describe comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZAjKyViw114zXf95QfWcN
@aliasunder

Copy link
Copy Markdown
Owner Author

/@CodeRabbit

@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown

@aliasunder Sure! I'll kick off a review of this PR now.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fb620de3-d8ba-4cad-a4f4-ceb87c5ad9d7

📥 Commits

Reviewing files that changed from the base of the PR and between 69ef827 and b2c33e6.

📒 Files selected for processing (16)
  • README.md
  • cli/templates/local/docker-compose.yml
  • deploy/local/.env.example
  • deploy/local/README.md
  • deploy/local/docker-compose.yml
  • docker-compose.local.yml
  • src/vault-mcp/__tests__/config.test.ts
  • src/vault-mcp/config.ts
  • src/vault-mcp/search/__tests__/file-watcher.test.ts
  • src/vault-mcp/search/file-watcher.ts
  • src/vault-mcp/server.ts
  • src/vault-mcp/tool-definitions.ts
  • src/vault-mcp/vault-operations/__tests__/note-mover.test.ts
  • src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts
  • src/vault-mcp/vault-operations/note-mover.ts
  • src/vault-mcp/vault-operations/vault-filesystem.ts

📝 Walkthrough

Walkthrough

Introduces WINDOWS_MODE=true support so vaults stored on a Windows C: drive work with Docker Desktop. The implementation adds a rename-based fallback to atomicWriteFileExclusive when hard links are unavailable, adds optional polling to the chokidar file watcher, reads WINDOWS_MODE into VaultConfig.windowsBindMount, and wires it through server startup and the moveNote tool. Deployment configs and documentation are updated to match.

Changes

WINDOWS_MODE feature

Layer / File(s) Summary
atomicWriteFileExclusive rename fallback
src/vault-mcp/vault-operations/vault-filesystem.ts, src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts
Adds hardLinksUnsupported flag that switches the atomic no-clobber write from link to an exclusive-create placeholder + rename, with cleanup on failure. Tests cover success, EEXIST rejection, and no leftover .tmp file.
VaultConfig.windowsBindMount config parsing
src/vault-mcp/config.ts, src/vault-mcp/__tests__/config.test.ts
Adds env-var import, extends VaultConfig with windowsBindMount: boolean, and updates loadConfig to parse WINDOWS_MODE as a boolean defaulting to false. Tests verify default, "true"/"false" parsing, and rejection of non-boolean input.
File watcher polling option
src/vault-mcp/search/file-watcher.ts, src/vault-mcp/search/__tests__/file-watcher.test.ts
Adds POLLING_INTERVAL_MS constant and usePolling?: boolean to FileWatcherOptions, wires both into the chokidar watch call. Test verifies polling mode still indexes new .md files.
moveNote Windows mode wiring
src/vault-mcp/vault-operations/note-mover.ts, src/vault-mcp/vault-operations/__tests__/note-mover.test.ts
Adds windowsBindMount to moveNote params and passes hardLinksUnsupported: windowsBindMount to atomicWriteFileExclusive at the destination write step. Tests add a Windows-mode suite covering backlink rewriting and EEXIST rejection.
Server and tool entry-point wiring
src/vault-mcp/server.ts, src/vault-mcp/tool-definitions.ts
server.ts conditionally starts the file watcher with usePolling from config.windowsBindMount. tool-definitions.ts forwards config.windowsBindMount to noteMover.moveNote.
Docs and deployment configuration
README.md, deploy/local/README.md, deploy/local/.env.example, deploy/local/docker-compose.yml, docker-compose.local.yml, cli/templates/local/docker-compose.yml
Quick-start and Windows section rewritten to describe WINDOWS_MODE=true; WINDOWS_MODE variable added to three Docker Compose files and .env.example.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • aliasunder/vault-cortex#160: Introduces vault_move_note and the core moveNote link-rewriting logic that this PR extends with the windowsBindMount/rename-based write path.
  • aliasunder/vault-cortex#163: Also extends noteMover.moveNote parameters and tool-definitions.ts plumbing (via pruneEmptyFolders), making it directly adjacent to the windowsBindMount parameter additions here.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature: adding a WINDOWS_MODE variable to support running vaults on Windows C: drives, which is the primary objective of this PR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/vault-bootstrap-setup-iaryye

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts Outdated
Comment thread src/vault-mcp/vault-operations/vault-filesystem.ts
Address review: the exclusive-write fallback flag reads clearer named for
its positive state. Rename atomicWriteFileExclusive's option from
`hardLinksUnsupported` to `hardLinksSupported` (default true), and flip the
branch so the link fast-path uses a positive condition and an early return,
with the rename fallback flowing beneath it — no `else`. note-mover passes
`hardLinksSupported: !windowsBindMount`; tests updated to match.

No behavior change. Also codify the affirmative-boolean-naming convention in
AGENTS.md alongside the early-return rule it pairs with.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZAjKyViw114zXf95QfWcN
Comment thread src/vault-mcp/search/file-watcher.ts Outdated
claude added 5 commits June 21, 2026 18:37
Gate the `interval` watch option on `usePolling` instead of always passing
it. interval only drives chokidar's polling backend; passing it
unconditionally relied on chokidar ignoring it for native events, an
implementation detail a future release needn't preserve. Now interval is
present only when we've explicitly opted into polling, so a change to how
chokidar treats an interval set without usePolling can't affect us.

No behavior change on either path today.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZAjKyViw114zXf95QfWcN
Add a focused unit suite that mocks chokidar's watch() and asserts the exact
options startFileWatcher builds: interval is omitted unless usePolling is true,
and is 300ms when polling. Kept separate from file-watcher.test.ts so the
real-chokidar integration tests there are unaffected. Both directions are
mutation-verified (always-pass fails the omit tests; never-pass fails the
polling test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZAjKyViw114zXf95QfWcN
tsc (which the CI `checks` job and the Docker build:server both run over
src/**, tests included) rejected the stub's return-type annotation:
`{ on: (event: string) => unknown }` declares a 1-arg `on`, but the method
takes two (event + handler), so the object wasn't assignable (TS2322). Give
it a proper recursive `FakeWatcher` type so `.on` stays chainable. This also
clears trivy-pr, which failed only because the image build cascaded from the
same tsc error.

Runtime behavior unchanged; vitest (esbuild) didn't catch it — re-ran
`npm run build` to confirm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZAjKyViw114zXf95QfWcN
A separate test file with no matching source file was a smell. Merge the
chokidar-options assertions back into file-watcher.test.ts using
vi.mock("chokidar", { spy: true }): spy mode keeps the real implementation so
the existing integration tests still watch real temp dirs, while the new
"watch options" suite overrides watch() per-test (vi.mocked + mockImplementation)
to inspect the options object, then mockRestore hands the real, spied watch
back. Gating is still mutation-verified through the spied tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZAjKyViw114zXf95QfWcN
watchOptionsFor was ambiguous — "options" meant both the FileWatcherOptions
input and the chokidar watch options output. Rename to chokidarOptionsFrom,
the param to watcherOptions, and the locals to chokidarOptions, so input and
output read distinctly. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZAjKyViw114zXf95QfWcN
@aliasunder
aliasunder merged commit af07f58 into main Jun 21, 2026
15 checks passed
@aliasunder
aliasunder deleted the claude/vault-bootstrap-setup-iaryye branch June 21, 2026 19:08
aliasunder pushed a commit that referenced this pull request Jun 21, 2026
Resolve server.json: keep this branch's "25 tools" description with
main's 0.20.2 version. Everything else auto-merged — #167 patchNote
duplicate-heading rejection, #165 link-domain module, and #164
WINDOWS_MODE integrate cleanly alongside vault_delete_span. Tool-count
references are a consistent 25; full suite green (898 tests), lint,
prettier, and build all pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants