persist human review notes to a JSON sidecar via --store-notes#473
persist human review notes to a JSON sidecar via --store-notes#473muratovv wants to merge 2 commits into
Conversation
Opt-in `--store-notes <path>` (cwd-relative) mirrors human "c" review notes to a JSON sidecar so they survive closing the TUI and can be read off disk by an agent. Notes are seeded on launch and written through on every change; omitting the flag keeps notes in-memory only (current behavior preserved). - cli/config/loaders thread storeNotes into bootstrap.userNotesSidecarPath - useReviewController persists write-through at each mutation (no mirror effect) - userNotesStore: best-effort read/write + startup writability warning - tests: userNotesStore unit + useReviewController persistence; docs; changeset
Greptile SummaryAdds an opt-in
Confidence Score: 3/5The change is opt-in and leaves the default path entirely unchanged, but the new sidecar write is non-atomic: a kill during the write truncates the file and the next startup silently discards all previously saved notes, which directly contradicts the feature's durability promise. The implementation is clean and well-tested, but src/core/userNotesStore.ts — the Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant CLI
participant loaders as loaders.ts
participant startup as startup.ts
participant App as App.tsx
participant hook as useReviewController
participant store as userNotesStore
participant disk as JSON sidecar
CLI->>loaders: --store-notes .hunk/notes.json
loaders->>loaders: resolvePath(cwd, storeNotes) → absolute path
loaders-->>startup: "AppBootstrap { userNotesSidecarPath }"
startup->>store: userNotesWriteWarning(path)
store->>disk: accessSync(target, W_OK)
disk-->>store: ok / EACCES
store-->>startup: undefined / warning string
startup->>startup: write warning to stderr if present
startup-->>App: bootstrap (with userNotesSidecarPath)
App->>hook: "useReviewController({ userNotesSidecarPath })"
hook->>store: readUserNotes(path) [lazy useState init]
store->>disk: readFileSync + JSON.parse
disk-->>store: "UserNotesMap | error → {}"
store-->>hook: seeded userNotesByFileId
Note over hook,disk: Every mutation (save / remove / clear)
hook->>store: writeUserNotes(path, next)
store->>disk: mkdirSync + writeFileSync (best-effort)
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant CLI
participant loaders as loaders.ts
participant startup as startup.ts
participant App as App.tsx
participant hook as useReviewController
participant store as userNotesStore
participant disk as JSON sidecar
CLI->>loaders: --store-notes .hunk/notes.json
loaders->>loaders: resolvePath(cwd, storeNotes) → absolute path
loaders-->>startup: "AppBootstrap { userNotesSidecarPath }"
startup->>store: userNotesWriteWarning(path)
store->>disk: accessSync(target, W_OK)
disk-->>store: ok / EACCES
store-->>startup: undefined / warning string
startup->>startup: write warning to stderr if present
startup-->>App: bootstrap (with userNotesSidecarPath)
App->>hook: "useReviewController({ userNotesSidecarPath })"
hook->>store: readUserNotes(path) [lazy useState init]
store->>disk: readFileSync + JSON.parse
disk-->>store: "UserNotesMap | error → {}"
store-->>hook: seeded userNotesByFileId
Note over hook,disk: Every mutation (save / remove / clear)
hook->>store: writeUserNotes(path, next)
store->>disk: mkdirSync + writeFileSync (best-effort)
|
| export function writeUserNotes(path: string, map: UserNotesMap): void { | ||
| try { | ||
| mkdirSync(dirname(path), { recursive: true }); | ||
| writeFileSync(path, JSON.stringify(map, null, 2), { encoding: "utf8" }); | ||
| } catch (error) { | ||
| // Best-effort: a write failure must never crash the review UI. | ||
| debugUserNotesError("write", path, error); | ||
| } | ||
| } |
There was a problem hiding this comment.
Non-atomic write can silently corrupt the sidecar
writeFileSync truncates the file before writing, so a SIGTERM, OOM kill, or power-loss mid-write leaves a partial JSON blob. On the next startup, readUserNotes catches the parse error and returns {}, silently discarding every note the user wrote in previous sessions — exactly the failure mode the feature promises to prevent.
The safe pattern is: write to a sibling temp file (e.g. path + ".tmp"), then renameSync into place. rename(2) is atomic on POSIX, so a kill between write and rename leaves the old sidecar intact rather than an empty one.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/userNotesStore.ts
Line: 89-97
Comment:
**Non-atomic write can silently corrupt the sidecar**
`writeFileSync` truncates the file before writing, so a SIGTERM, OOM kill, or power-loss mid-write leaves a partial JSON blob. On the next startup, `readUserNotes` catches the parse error and returns `{}`, silently discarding every note the user wrote in previous sessions — exactly the failure mode the feature promises to prevent.
The safe pattern is: write to a sibling temp file (e.g. `path + ".tmp"`), then `renameSync` into place. `rename(2)` is atomic on POSIX, so a kill between write and rename leaves the old sidecar intact rather than an empty one.
How can I resolve this? If you propose a fix, please make it concise.Address Greptile review on modem-dev#473: - writeUserNotes writes a temp sibling then renameSync()s into place, so an interrupted write (SIGTERM/OOM/power loss) can't truncate an existing sidecar and silently drop prior notes on the next read. - saveDraftNote commits via a functional updater (mirrored through userNotesRef) so two appends batched before a re-render can't drop the first.
What
Opt-in
--store-notes <path>(cwd-relative) persists humancreview notes to a JSON sidecar keyed by file id, readable directly off disk.Why
Human notes currently die with the session, so an agent can't pick up your review after you quit — the reverse of
--agent-context. Addresses #113 (persist notes past quit; suggests a.hunk/sidecar).Behavior
Opt-in; default behavior unchanged. Disk I/O is best-effort and never crashes the UI; startup warning if the path isn't writable. Includes tests,
docs/agent-workflows.md, and aminorchangeset.