fix(watcher): park through the brain root, keep a note edited mid-resolve, honour only real fences (#205)
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user