Directory Sync / Watch Lifecycle
Overview
A lifecycle pattern for synchronizing an in-memory file tree with the filesystem. The coordinator drives four sequential phases: cache load (instant display) followed by full sync (accurate rebuild) followed by watch (live updates) followed by surgical update (efficient patching). This ensures the UI displays a file tree immediately on launch while converging to an accurate, live-updated representation as quickly as possible.
Terminology
| Term | Definition |
|---|---|
| Coordinator | The orchestrator (DirectoryWatchCoordinator) that owns the lifecycle and drives all four phases |
| File tree node | An in-memory representation of a single file or directory: path, name, metadata, and children |
| Cache | A JSON file (file-tree-cache.json) containing a flattened array of FileTreeCacheEntry values |
| Full sync | A complete traversal of the directory subtree that rebuilds the in-memory tree from scratch |
| Surgical update | A targeted reload that only rescans the directories affected by a filesystem change event |
| FSEvents | The macOS kernel subsystem that delivers file-level change notifications |
| Package | A directory that the OS treats as a single opaque file (e.g., .app, .playground, .catnip-proj) |
| Workspace | A collection of directory entries, each managed by its own coordinator via WorkspaceDirectoryManager |
Behavioral Requirements
Phase 1 — Cache Load
- load-cached-tree: On startup, the coordinator MUST attempt to load a cached tree from the JSON file
file-tree-cache.jsonfor instant display. - background-cache-load: The cache MUST be loaded synchronously on a background queue so the main thread is never blocked.
- handle-missing-cache: If no cache file exists or the file cannot be read, the coordinator MUST present an empty or loading state. It MUST NOT crash or block.
- publish-before-sync: The loaded cache MUST be published to the UI before Phase 2 begins, so users see an instant tree.
Phase 2 — Full Sync
- rebuild-from-filesystem: The coordinator MUST rebuild the entire file tree from the filesystem on a background queue.
- parallel-top-level-scan: Top-level directories MUST be scanned in parallel via an
OperationQueue. - configurable-scan-workers: Parallel scan concurrency MUST be controlled by a configurable
maxScanWorkersproperty. The default value MUST be3. Valid range MUST be1to8inclusive. Values outside this range MUST be clamped. - file-tree-node-fields: Each file tree node MUST contain the following fields:
path— absolute filesystem path (String)name— display name (String)isDirectory— whether the node is a directory (Bool)isPackage— whether the node is a package directory (Bool)fileSize— size in bytes (Int?, nil for directories)modificationDate— last modification timestamp (Date?)children— ordered child nodes ([FileTreeNode]?, nil for files)
- save-cache-after-sync: After the full sync completes, the coordinator MUST save the updated cache to disk as a fire-and-forget operation on a background queue. A save failure MUST NOT block or crash the coordinator.
- publish-syncing-state: The coordinator MUST publish an
isSyncingboolean state that istrueduring Phase 2 andfalseafter it completes. The UI SHOULD use this to display a status bar indicator.
Phase 3 — Watch
- start-fsevents-watch: After full sync completes, the coordinator MUST start filesystem monitoring using FSEvents (macOS) with file-level granularity.
- debounce-latency: The FSEvents stream MUST use a debounce latency of
0.5seconds to coalesce rapid changes. - exclude-path-prefixes: The coordinator MUST exclude paths matching configurable prefixes from change processing. The default excluded prefixes MUST include
.gitand package directories. - dispatch-to-main-thread: Change events from the FSEvents callback MUST be dispatched to the main thread for UI updates.
Phase 4 — Surgical Update
- surgical-reload-affected: On receiving filesystem change events, the coordinator MUST only reload the children of the affected directories — not rebuild the full tree.
- build-path-index: The coordinator MUST build a path index from the changed file paths to identify the set of affected parent directories.
- background-load-children: New children for affected directories MUST be loaded on a background queue.
- apply-on-main-thread: The updated children MUST be applied to the in-memory tree on the main thread.
- save-cache-after-update: After a surgical update, the coordinator MUST save the updated cache to disk (fire-and-forget on a background queue).
Cache Format
- json-cache-format: The cache MUST be stored as a JSON file using the following entry structure:
FileTreeCacheEntry { path: String parentPath: String? // nil for root name: String isDirectory: Bool isPackage: Bool fileSize: Int? modificationDate: Date? // ISO 8601 encoded } - flattened-cache-array: The cache MUST be a flattened array of
FileTreeCacheEntryvalues. Parent-child relationships MUST be reconstructed frompath/parentPathon load. - atomic-cache-writes: Cache writes MUST be atomic — write to a temporary file first, then rename into place. This prevents corruption from interrupted writes.
FSEvents Configuration (macOS)
- file-level-granularity: The FSEvents stream MUST be created with
kFSEventStreamCreateFlagFileEventsfor file-level granularity. - utility-qos-queue: The FSEvents dispatch queue MUST use utility QoS.
- configurable-exclusions: Excluded path prefixes MUST be configurable.
- filter-excluded-paths: The FSEvents callback MUST filter changed paths against the excluded prefixes before dispatching.
Workspace Variant
- coordinator-per-entry:
WorkspaceDirectoryManagerMUST manage a pool of coordinators, one per workspace directory entry. - aggregate-syncing-state:
WorkspaceDirectoryManagerMUST aggregate theisSyncingstate across all coordinators. The workspace-levelisSyncingMUST betrueif any coordinator is syncing. - auto-discover-packages:
WorkspaceDirectoryManagerMUST additionally scan for.catnip-projpackages for auto-discovery of projects. - dedicated-cache-directory: Each coordinator in the workspace MUST use a dedicated cache directory named
cache-{entryID}.
States
| State | Behavior |
|---|---|
| No cache, first launch | Coordinator loads empty state, begins full sync immediately |
| Cache available | Coordinator displays cached tree instantly, then begins full sync in background |
| Full sync in progress | isSyncing is true, UI shows sync indicator |
| Full sync complete | isSyncing is false, watch phase starts, cache saved |
| Watch active, no changes | Coordinator idle, FSEvents stream listening |
| Filesystem change detected | Surgical update runs on affected directories only |
| Surgical update in progress | Affected directory children reloaded, tree patched, cache saved |
| Watch stopped (e.g., directory deleted) | Coordinator publishes empty tree, stops FSEvents stream |
Accessibility
Not applicable — directory sync is an infrastructure component with no direct user-facing UI. UI presentation of the synced directory tree is handled by the file-tree-browser recipe.
Conformance Test Vectors
| ID | Requirements | Input | Expected |
|---|---|---|---|
| dirsync-001 | load-cached-tree, publish-before-sync | Launch with valid file-tree-cache.json on disk |
Cached tree is published to UI before full sync begins |
| dirsync-002 | handle-missing-cache | Launch with no cache file on disk | Empty/loading state shown, full sync begins without error |
| dirsync-003 | handle-missing-cache | Launch with corrupt (invalid JSON) cache file | Empty/loading state shown, full sync begins without error |
| dirsync-004 | rebuild-from-filesystem, parallel-top-level-scan | Full sync on directory with 5 top-level subdirectories | All 5 subdirectories scanned, tree matches filesystem |
| dirsync-005 | configurable-scan-workers | Set maxScanWorkers to 0 |
Value clamped to 1, scan proceeds with 1 worker |
| dirsync-006 | configurable-scan-workers | Set maxScanWorkers to 10 |
Value clamped to 8, scan proceeds with 8 workers |
| dirsync-007 | file-tree-node-fields | Scan a directory containing a file (100 bytes, modified 2026-01-15) and a subdirectory | File node has correct fileSize, modificationDate, isDirectory: false; directory node has isDirectory: true, children populated |
| dirsync-008 | save-cache-after-sync | Full sync completes | file-tree-cache.json exists on disk with valid JSON content |
| dirsync-009 | publish-syncing-state | Observe isSyncing during full sync |
Value is true during scan, false after completion |
| dirsync-010 | debounce-latency | Create 10 files within 0.3 seconds | Single coalesced change event delivered after 0.5s debounce |
| dirsync-011 | exclude-path-prefixes | Create a file inside .git/ |
No surgical update triggered, tree unchanged |
| dirsync-012 | surgical-reload-affected, build-path-index | Create a new file in subdirectory src/ |
Only src/ children are reloaded; sibling directories untouched |
| dirsync-013 | apply-on-main-thread | Surgical update completes | Updated nodes visible in UI on main thread |
| dirsync-014 | save-cache-after-update | Surgical update completes | Cache file on disk reflects the new file |
| dirsync-015 | atomic-cache-writes | Kill process during cache write | On next launch, cache file is either the old valid version or the new valid version — never partial/corrupt |
| dirsync-016 | coordinator-per-entry | Workspace with 3 directory entries | 3 coordinators created, one per entry |
| dirsync-017 | aggregate-syncing-state | 1 of 3 workspace coordinators is syncing | Workspace-level isSyncing is true |
| dirsync-018 | aggregate-syncing-state | All 3 workspace coordinators finish syncing | Workspace-level isSyncing is false |
| dirsync-019 | auto-discover-packages | Workspace directory contains a .catnip-proj package |
Package is auto-discovered and reported |
| dirsync-020 | dedicated-cache-directory | Two workspace entries with IDs "abc" and "def" | Cache directories are cache-abc and cache-def respectively |
| dirsync-021 | flattened-cache-array | Load cache with 100 entries, verify parent-child wiring | All entries with parentPath matching another entry's path are wired as children |
Edge Cases
- Large repository (100k+ files): Full sync SHOULD complete within a reasonable time. Parallel scanning (parallel-top-level-scan) and configurable concurrency (configurable-scan-workers) mitigate this. The UI MUST remain responsive during sync — all scanning is off the main thread.
- Rapid filesystem changes: The 0.5s debounce (debounce-latency) coalesces rapid changes into a single surgical update. If changes arrive faster than the update cycle, the coordinator SHOULD batch them rather than queueing unbounded updates.
- Corrupt cache file: The coordinator MUST handle malformed JSON gracefully (handle-missing-cache) — log a warning and proceed with full sync as if no cache exists.
- Cache file missing or unreadable: Same behavior as corrupt cache — empty/loading state, then full sync.
- Network/remote drives: FSEvents may not work reliably on network-mounted volumes. The coordinator SHOULD fall back to periodic polling or disable watch mode for non-local filesystems. Implementors SHOULD detect volume type and adapt.
- Directory deleted while watching: The coordinator MUST handle the root directory being deleted or unmounted. It SHOULD publish an empty tree and stop the FSEvents stream.
- Permission denied on subdirectory: The coordinator MUST skip inaccessible directories during scan and log a warning. It MUST NOT crash or abort the entire sync.
- Symlink cycles: The coordinator MUST NOT follow symlinks recursively into cycles. It SHOULD detect symlinks and either skip or represent them as leaf nodes.
- Package directories: Directories identified as packages (file-tree-node-fields
isPackage) SHOULD NOT have their children scanned by default. They are treated as opaque files. - Concurrent cache writes: If a surgical update triggers a cache save while a previous save is still in progress, the coordinator SHOULD coalesce or serialize writes to avoid conflicts.
- Empty directory: A directory with no children MUST be represented as a node with an empty
childrenarray, notnil.
Logging
Subsystem: {{bundle_id}} | Category: DirectorySync
| Event | Level | Message |
|---|---|---|
| Cache load started | debug | DirectorySync: loading cache from "{{path}}" |
| Cache load succeeded | debug | DirectorySync: cache loaded, {{count}} entries |
| Cache load failed | warning | DirectorySync: cache load failed: {{error}} |
| Cache not found | debug | DirectorySync: no cache file found, starting fresh |
| Full sync started | info | DirectorySync: full sync started for "{{rootPath}}" |
| Full sync completed | info | DirectorySync: full sync completed, {{nodeCount}} nodes in {{duration}}s |
| Cache save started | debug | DirectorySync: saving cache ({{count}} entries) |
| Cache save succeeded | debug | DirectorySync: cache saved to "{{path}}" |
| Cache save failed | warning | DirectorySync: cache save failed: {{error}} |
| Watch started | info | DirectorySync: FSEvents watch started for "{{rootPath}}" |
| Watch stopped | info | DirectorySync: FSEvents watch stopped |
| Change event received | debug | DirectorySync: {{changeCount}} changes received, {{affectedDirCount}} directories affected |
| Surgical update started | debug | DirectorySync: surgical update for {{dirCount}} directories |
| Surgical update completed | debug | DirectorySync: surgical update completed in {{duration}}s |
| Directory skipped (permission) | warning | DirectorySync: skipped "{{path}}" — permission denied |
| Excluded path filtered | debug | DirectorySync: filtered {{count}} excluded paths |
| Workspace coordinator created | debug | DirectorySync: workspace coordinator created for entry "{{entryID}}" |
| Workspace syncing state changed | debug | DirectorySync: workspace isSyncing={{value}} |
| Project auto-discovered | info | DirectorySync: discovered .catnip-proj at "{{path}}" |
| Scan worker count | debug | DirectorySync: maxScanWorkers={{count}} |
Platform Notes
- SwiftUI (macOS): Use
FSEventStreamCreatewithkFSEventStreamCreateFlagFileEventsandkFSEventStreamCreateFlagUseCFTypes. Schedule on aDispatchQueuewith.utilityQoS. UseFileManagerfor directory enumeration. PublishisSyncingvia@Publishedon an@ObservableorObservableObjectcoordinator. For atomic cache writes, write to a.tmpfile in the same directory then useFileManager.moveItem(at:to:)which is atomic on APFS/HFS+. UseOperationQueuewithmaxConcurrentOperationCountfor parallel scanning. - SwiftUI (iOS / visionOS): FSEvents is not available on iOS or visionOS. Use
DispatchSource.makeFileSystemObjectSourcefor directory-level monitoring on individual directories, or poll on a timer. File-level granularity is limited — surgical updates may need to rescan entire directories. Consider usingNSFilePresenter/NSFileCoordinatorfor coordinated file access. On visionOS, the same iOS limitations apply. Cache loading and saving work identically viaFileManager.
Design Decisions
None yet — decisions made during implementation should be recorded here.