fix(watcher): park through the brain root, keep a note edited mid-resolve, honour only real fences (#205)
CI / Lint / Test / Vet (pull_request) Successful in 1m7s
CI / Mirror to GitHub (pull_request) Skipped

From the second independent review of #207:
- parkForReview now skips the delete when the note changed since it was read
  (an LLM call sits in that window; Syncthing can deliver a newer version)
- parked copies are created via brainfs (real dirs only, O_EXCL, no symlink
  following), so a planted raw/needs-review symlink cannot redirect or hide a
  note; this also removes the need for the guard allowlist entry added in
  1e14c56, which is reverted
- a '----' line no longer closes the proposal block
Each has a test that failed first and a mutant that fails it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 06:21:25 +02:00
co-authored by Claude Sonnet 5.5
parent 1e14c56a49
commit bd33a0a4e8
4 changed files with 141 additions and 47 deletions
+39 -44
View File
@@ -3,10 +3,8 @@ package watcher
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
@@ -178,7 +176,7 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
if cfg.Resolver != nil {
d := cfg.Resolver.Resolve(ctx, wing.Note{Dir: topDir(rel), FrontmatterWing: frontmatterWing(content), Text: content})
if d.Source == wing.Exception {
return parkForReview(cfg, root, rawDir, rel, path, filename, content, d, date)
return parkForReview(cfg, root, rel, path, filename, content, d, date, before)
}
pcfg.Wing = d.Wing
slog.Info("watcher: wing resolved", "file", filename, "wing", d.Wing, "how", d.Source, "reason", d.Reason)
@@ -352,60 +350,45 @@ func frontmatterWing(content string) string {
// parkForReview moves a note the resolver could not place into
// raw/needs-review/, recording the proposal in its frontmatter so a human can
// approve it (brain_promote). Nothing is extracted or written to the wiki. The
// original is removed only after the parked copy is written.
func parkForReview(cfg Config, root *os.Root, rawDir, rel, path, filename, content string, d wing.Decision, date string) error {
dir := filepath.Join(rawDir, "needs-review")
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir needs-review: %w", err)
}
dest, err := reserveParkedName(dir, filename)
if err != nil {
return fmt.Errorf("park note: %w", err)
}
// approve it by moving it into raw/<wing>/. Nothing is extracted or written to
// the wiki.
//
// The parked copy is created through the brain root (real directories only,
// O_EXCL, never overwriting) and is the only copy once the original is retired,
// so the original is removed only after that write succeeds and only if it has
// not changed since it was read: a newer version delivered while the resolver
// ran stays in raw/ for the next poll.
func parkForReview(cfg Config, root *os.Root, rel, path, filename, content string, d wing.Decision, date string, before os.FileInfo) error {
var data []byte
if strings.ToLower(filepath.Ext(filename)) == ".pdf" {
// Binary: park the bytes untouched; the proposal is in log.md only.
if err := copyFromRoot(root, rel, dest); err != nil {
_ = os.Remove(dest)
in, _, err := extract.OpenRegular(root, rel)
if err != nil {
return fmt.Errorf("park pdf: %w", err)
}
} else if err := os.WriteFile(dest, []byte(withProposal(content, d)), 0o644); err != nil {
_ = os.Remove(dest)
data, err = io.ReadAll(in)
_ = in.Close()
if err != nil {
return fmt.Errorf("park pdf: %w", err)
}
} else {
data = []byte(withProposal(content, d))
}
if err := createParked(cfg.BrainDir, filename, data); err != nil {
return fmt.Errorf("park note: %w", err)
}
if err := appendWatcherLog(cfg.BrainDir, filename, fmt.Errorf("needs review (proposed wing %q): %s", d.Wing, d.Reason), date); err != nil {
slog.Error("watcher: failed to write log entry", "error", err)
}
slog.Info("watcher: parked for review", "file", filename, "proposed_wing", d.Wing, "reason", d.Reason)
if changedSince(root, rel, before) {
slog.Warn("watcher: note changed while it was being resolved; leaving the newer version in raw/", "file", filename)
return nil
}
retire(cfg, root, rel, path, ".processed", date)
return nil
}
// reserveParkedName claims a free name in dir for filename, never reusing one:
// the parked copy is the only copy once the original is retired, so a note
// that shares a name with an earlier parked note must not replace it. The name
// is created with O_EXCL; -2, -3, ... is appended until one is free.
func reserveParkedName(dir, filename string) (string, error) {
ext := filepath.Ext(filename)
stem := strings.TrimSuffix(filename, ext)
for n := 1; n < 10000; n++ {
name := filename
if n > 1 {
name = fmt.Sprintf("%s-%d%s", stem, n, ext)
}
dest := filepath.Join(dir, name)
f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err == nil {
_ = f.Close()
return dest, nil
}
if !errors.Is(err, fs.ErrExist) {
return "", err
}
}
return "", fmt.Errorf("no free name for %q in %s", filename, dir)
}
// proposalKeys are the frontmatter keys the resolver owns in a parked note.
var proposalKeys = map[string]bool{"proposed_wing": true, "proposal_confidence": true, "proposal_reason": true}
@@ -429,7 +412,7 @@ func withProposal(content string, d wing.Decision) string {
if !strings.HasPrefix(content, "---\n") {
return "---\n" + kv.String() + "---\n" + content
}
block, rest, found := strings.Cut(content[4:], "\n---")
block, rest, found := cutFence(content[4:])
if !found {
return "---\n" + kv.String() + "---\n" + content
}
@@ -442,3 +425,15 @@ func withProposal(content string, d wing.Decision) string {
}
return "---\n" + kv.String() + strings.Join(kept, "\n") + "\n---" + rest
}
// cutFence splits s at the first closing fence: a line that is exactly "---".
// "----" or "---x" is not a fence, matching how the frontmatter readers parse.
func cutFence(s string) (block, rest string, found bool) {
if i := strings.Index(s, "\n---\n"); i >= 0 {
return s[:i], s[i+4:], true
}
if strings.HasSuffix(s, "\n---") {
return s[:len(s)-4], "", true
}
return "", "", false
}