Cross-Process Safety
The macOS/iOS app and the minuta CLI operate on the same .automerge files. This page explains the mechanics that keep concurrent writers from losing data and keep each process’s view fresh.
Concurrency model
- Same host, multiple writers (e.g., app running while you run a CLI command in Terminal): protected by an advisory file lock.
- Multiple hosts (e.g., laptop + desktop both open while Dropbox syncs): protected by Automerge CRDT merge semantics. File-lock state does not cross hosts; Dropbox/iCloud copy content, not kernel locks.
The storage layer treats these as different problems. The file lock ensures single-host atomicity. The CRDT format ensures cross-host merging is safe eventually.
Single-host write lock
Filename: <storage>/.minuta.writelock.nosync
- Created on first write. The
.nosyncsuffix keeps iCloud Drive from syncing it (Apple convention). - Any process that writes takes
flock(LOCK_EX)around its critical section and releases it before returning. - Both the app (
AutomergeStorageService) and the CLI (same shared code) take the same lock.
Implementation: Shared/Sources/MinutaShared/Services/StorageFileLock.swift — a thin wrapper around open(2) + flock(2).
Save path under the lock
1. Acquire flock(LOCK_EX) on .minuta.writelock.nosync
2. Invalidate the relevant in-memory cache slice (tags or month doc)
3. Reload the doc from disk (fresh bytes under lock)
4. Apply the mutation to the doc
5. commitWith(timestamp:) + save().write(atomically:)
6. Record new mtime in cache
7. Release flock Step 2 is essential when the caller of mutateTags comes in holding a stale cached doc — without invalidation, a subsequent diff-based write (e.g., “delete all keys the caller didn’t include”) would clobber keys another writer added.
Why the in-save merge was removed
An earlier design did inMemory.merge(with: onDisk) inside the save path. That triggered an upstream Automerge Rust panic (PatchLogMismatch) when merging documents with overlapping histories. Since the flock guarantees exclusive disk access for the read-mutate-write window, the merge was unnecessary for correctness on a single host. Cross-host merge still happens — via mtime-triggered reloads — just on the read path.
mtime-based cache invalidation
AutomergeStorageService remembers the mtime of each file when it last loaded it:
tagsDocumentMtime: Date?recordDocumentMtimes: [String: Date]keyed byYYYY-MM
On each load, it stats the file and compares. If the mtime differs from last-seen, the cache is dropped and the doc re-read from disk. This is how the app picks up CLI writes and sync-service drops without an explicit refresh.
External change polling (app side)
The app also actively polls for external writes rather than waiting for the next user action. Without this, the UI could show stale data for minutes while the CLI or Dropbox quietly updates the folder.
Two pieces:
AutomergeStorageService.hasExternalChanges()
A read-only probe. Stats tags.automerge and every cached month file; also scans for new month files that appeared since last time. Returns true if:
- any mtime differs from the cached value
- a new month file exists the service has never seen
- a cached file vanished from disk
No I/O beyond stat. Safe to call many times per minute.
ExternalChangeMonitor (app only)
Minuta/Sources/Services/ExternalChangeMonitor.swift. A @MainActor timer that calls the probe on a cadence:
| Scene phase | Cadence |
|---|---|
.active | 5 seconds |
.inactive | 60 seconds |
.background | 60 seconds |
When the phase transitions to .active, an immediate probe fires — that’s the “on window focus” trigger. Overlapping ticks are coalesced via an inFlight flag.
On a positive result, the monitor calls AppState.reloadFromExternalChanges():
await storage.invalidateCache()await resolveConflictsIfNeeded()— handles(conflicted copy)files from Dropbox/iCloudawait loadData()— reloads tags, today’s records, running timers
Is invalidation safe?
Yes. All user writes go through await trackingService.updateRecord(...) (or equivalent), which doesn’t return until AutomergeStorageService has written to disk under the flock. There is no “dirty in-memory state” that invalidation could destroy — the cache is read-through, not write-back. At worst, invalidation forces the next load to re-read bytes we just wrote.
The CLI does not run a monitor: it’s a one-shot process, exits after each command, and rebuilds its cache on the next invocation.
Version marker
Filename: <storage>/.minuta-version
Contents: a single integer (currently 1). Identifies a folder as initialized by a Phase-0-or-newer build (one that participates in the flock discipline).
- The app writes the marker on launch and whenever storage location changes (
StorageLocationManager.publishResolvedStoragePath). - The CLI auto-writes the marker on its first successful write if missing. This lets a CLI-only workflow (no app touched the folder yet) self-heal.
- A stricter gate exists (
strictRequireVersionMarkerreturns exit code5when absent) but isn’t currently used. It’s there for the future rollout scenario where an older app (without the flock discipline) could silently stomp on CLI writes.
Storage-path discovery
The app and CLI need to agree on which folder to operate on, even though the app is sandboxed (Mac Catalyst) and the CLI is a plain Unix binary.
The Catalyst container trap
Mac Catalyst apps are always containerized, regardless of sandbox entitlement. Inside the app:
NSHomeDirectory()resolves to~/Library/Containers/tools.minuta.app/Data/FileManager.default.urls(for: .applicationSupportDirectory, ...)also lives inside the container
That path is invisible to the CLI (a non-Catalyst binary that sees the real ~).
The bypass
CLIConfigService.configDirectory() calls getpwuid(getuid())->pw_dir to get the real user home directly from the OS, bypassing the Catalyst redirect. Both the app and CLI resolve the same physical path:
<realHome>/Library/Application Support/minuta/storage-path.txt The file contains the absolute path of the user-selected storage folder, written as plain UTF-8.
Write triggers (app)
StorageLocationManager.publishResolvedStoragePath() writes the file on:
init()— app launchsaveBookmark(for:)— user picks a new folderuseDefaultLocation()— user reverts to the default
Read order (CLI)
StorageResolver.resolve(...) — first match wins:
--storage <path>flagMINUTA_STORAGEenv varstorage-path.txtpointer (from the app)~/Documents/Minutadefault
If step 3 points at an inaccessible folder (unmounted drive, stale Dropbox path), the CLI fails fast with exit code 2 rather than silently writing to the default — otherwise the two processes would diverge.
Cross-host (sync services)
flock(2) is kernel-local. Two machines editing the same Dropbox-synced folder do not see each other’s locks. That’s fine — Automerge handles it:
- Each save is atomic locally (flock + atomic rename).
- The sync service eventually propagates the bytes.
- The next reader on the other machine detects the mtime change, reloads, and the CRDT format means the document already represents the merge of all histories seen so far.
- If the sync service itself detects a write-write conflict, it produces a
(conflicted copy)file.ConflictResolutionServicescans for these on app launch and merges them into the base document.
Sync-service-specific notes:
- iCloud Drive:
.nosyncsuffix on the lock file keeps it from syncing. - Dropbox: no per-file exclude. The lock file syncs harmlessly (it’s usually 0 bytes).
- Google Drive for Desktop: same as Dropbox — harmless noise.
Related files
Shared/Sources/MinutaShared/Services/AutomergeStorageService.swift— the lock-protected save path, mtime cache,hasExternalChanges().Shared/Sources/MinutaShared/Services/StorageFileLock.swift— the flock wrapper.Shared/Sources/MinutaShared/Services/StorageVersionMarker.swift— the.minuta-versionhelper.Shared/Sources/MinutaShared/Services/CLIConfigService.swift— thestorage-path.txthelper andgetpwuidresolution.Minuta/Sources/Services/ExternalChangeMonitor.swift— app-side polling.Minuta/Sources/Services/StorageLocationManager.swift— bookmark persistence and thepublishResolvedStoragePathhook.Shared/Sources/MinutaCLI/Runtime/StorageResolver.swift— CLI-side path discovery.