// ingestion/internal/watcher/watcher_test.go package watcher import ( "context" "encoding/json" "errors" "fmt" "log/slog" "os" "path/filepath" "strings" "sync" "sync/atomic" "syscall" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" ) // successComplete returns a valid JSON-encoded RawPage array for any call. func successComplete(raw pipeline.RawPage) pipeline.CompleteFunc { return func(ctx context.Context, system, user string) (string, error) { b, err := json.Marshal([]pipeline.RawPage{raw}) if err != nil { return "", err } return string(b), nil } } // errorComplete always returns an error simulating an LLM failure. func errorComplete(_ context.Context, _, _ string) (string, error) { return "", fmt.Errorf("LLM unavailable") } func setupBrainDir(t *testing.T) string { t.Helper() brainDir := t.TempDir() for _, sub := range []string{"wiki/concepts", "wiki/entities", "wiki/sources", "raw"} { require.NoError(t, os.MkdirAll(filepath.Join(brainDir, sub), 0o755)) } return brainDir } func TestStart_ProcessesFile(t *testing.T) { brainDir := setupBrainDir(t) // Place a .md file in raw/. rawFile := filepath.Join(brainDir, "raw", "shape-up-book.md") require.NoError(t, os.WriteFile(rawFile, []byte("Content about Shape Up."), 0o644)) date := time.Now().UTC().Format("2006-01-02") rawPage := pipeline.RawPage{ Title: "Shape Up Book", Type: "source", Subtype: "article", Domain: "product-management", Content: "## Summary\n\nA book about Shape Up.\n", } cfg := Config{ BrainDir: brainDir, Interval: 50 * time.Millisecond, Pipeline: pipeline.Config{ Complete: successComplete(rawPage), ChunkSize: 0, Schema: "# Schema\nThree page types.", }, } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() Start(ctx, cfg) // Poll until the file is moved to processed/. processedPath := filepath.Join(brainDir, "raw", "processed", date, "shape-up-book.md") var found bool deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if _, err := os.Stat(processedPath); err == nil { found = true break } time.Sleep(20 * time.Millisecond) } require.True(t, found, "file should be copied to processed/") // Inbox zero: once archived, the original leaves raw/ and no marker is // left behind, so what remains in raw/ is exactly the unprocessed set. _, err := os.Stat(rawFile) assert.ErrorIs(t, err, os.ErrNotExist, "original should be removed from raw/ once archived") _, err = os.Stat(rawFile + ".processed") assert.ErrorIs(t, err, os.ErrNotExist, "no .processed marker: the archive copy is the record") // Wiki page should exist. wikiPath := filepath.Join(brainDir, "wiki", "sources", "shape-up-book.md") _, err = os.Stat(wikiPath) assert.NoError(t, err, "wiki page should be written") // log.md should contain an ingest record. logContent, err := os.ReadFile(filepath.Join(brainDir, "log.md")) require.NoError(t, err) assert.Contains(t, string(logContent), "— ingest") } func TestStart_MovesToFailedOnError(t *testing.T) { brainDir := setupBrainDir(t) rawFile := filepath.Join(brainDir, "raw", "bad-file.md") require.NoError(t, os.WriteFile(rawFile, []byte("Some content."), 0o644)) cfg := Config{ BrainDir: brainDir, Interval: 50 * time.Millisecond, Pipeline: pipeline.Config{ Complete: errorComplete, ChunkSize: 0, Schema: "# Schema\nThree page types.", }, } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() Start(ctx, cfg) // Poll until the file is moved to failed/. failedPath := filepath.Join(brainDir, "raw", "failed", "bad-file.md") var found bool deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if _, err := os.Stat(failedPath); err == nil { found = true break } time.Sleep(20 * time.Millisecond) } require.True(t, found, "file should be copied to failed/") // Inbox zero: a quarantined original leaves raw/ too, no marker beside it. _, err := os.Stat(rawFile) assert.ErrorIs(t, err, os.ErrNotExist, "original should be removed from raw/ once quarantined") _, err = os.Stat(rawFile + ".failed") assert.ErrorIs(t, err, os.ErrNotExist, "no .failed marker: failed/ is the record") // log.md should contain a watcher error entry. logContent, err := os.ReadFile(filepath.Join(brainDir, "log.md")) require.NoError(t, err) assert.Contains(t, string(logContent), "— watcher error") assert.Contains(t, string(logContent), "bad-file.md") } func TestDeriveSource(t *testing.T) { tests := []struct { filename string want string }{ {"shape-up-book.md", "Shape Up Book"}, {"raft-consensus.txt", "Raft Consensus"}, {"my-note.md", "My Note"}, {"single.md", "Single"}, {"no-extension", "No Extension"}, } for _, tc := range tests { t.Run(tc.filename, func(t *testing.T) { got := deriveSource(tc.filename) assert.Equal(t, tc.want, got) }) } } func TestProcessDir_SkipsSubdirs(t *testing.T) { brainDir := setupBrainDir(t) // Create processed/, failed/, and rejected/ subdirs with files inside. for _, sub := range []string{"processed/2026-04-22", "failed", "rejected"} { require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "raw", sub), 0o755)) } processedFile := filepath.Join(brainDir, "raw", "processed", "2026-04-22", "old-file.md") failedFile := filepath.Join(brainDir, "raw", "failed", "broken-file.md") // hyperguild#81/#133: a human already declined this note via brain_reject. // If the watcher walks into rejected/, it re-ingests and publishes it to // the wiki — the exact opposite of the human's decision. rejectedFile := filepath.Join(brainDir, "raw", "rejected", "declined-file.md") require.NoError(t, os.WriteFile(processedFile, []byte("old"), 0o644)) require.NoError(t, os.WriteFile(failedFile, []byte("broken"), 0o644)) require.NoError(t, os.WriteFile(rejectedFile, []byte("---\nrejected_at: 2026-09-23T00:00:00Z\n---\ndeclined"), 0o644)) // Also place a valid file in raw/ root that should be processed. validFile := filepath.Join(brainDir, "raw", "valid.md") require.NoError(t, os.WriteFile(validFile, []byte("valid content"), 0o644)) date := time.Now().UTC().Format("2006-01-02") // Track which sources were passed to Complete. var processedSources []string completeFn := func(ctx context.Context, system, user string) (string, error) { // Record that this was called; return a minimal valid RawPage. raw := pipeline.RawPage{ Title: "Valid", Type: "source", Subtype: "article", Content: "## Summary\n\nValid.\n", } b, _ := json.Marshal([]pipeline.RawPage{raw}) processedSources = append(processedSources, "called") return string(b), nil } cfg := Config{ BrainDir: brainDir, Interval: time.Hour, // not used; we call processDir directly Pipeline: pipeline.Config{ Complete: completeFn, ChunkSize: 0, Schema: "# Schema\nThree page types.", }, } errs := processDir(context.Background(), cfg, date) assert.Empty(t, errs, "no errors expected") // Complete should have been called exactly once (for valid.md, not for files in subdirs). assert.Len(t, processedSources, 1, "only the file in raw/ root should be processed") // Files in processed/, failed/, and rejected/ must remain untouched. _, err := os.Stat(processedFile) assert.NoError(t, err, "processed subdir file should be untouched") _, err = os.Stat(failedFile) assert.NoError(t, err, "failed subdir file should be untouched") _, err = os.Stat(rejectedFile) assert.NoError(t, err, "rejected subdir file should be untouched, never re-ingested") } // TestProcessDir_SkipsTunnelCandidateFiles guards against hyperguild#88: // AutoTunnel's own human-review queue (brain/raw/tunnel-candidates-*.md) // must never be re-ingested as if it were external source material — doing // so fed the raw "(term: X)" log entries back through the LLM extraction // pipeline, which then legitimately (from its own perspective) surfaced // [[X]] as a wikilink for any term that happened to match a real page // title, however generic (e.g. "mission"). func TestProcessDir_SkipsTunnelCandidateFiles(t *testing.T) { brainDir := setupBrainDir(t) tunnelFile := filepath.Join(brainDir, "raw", "tunnel-candidates-2026-07-19.md") require.NoError(t, os.WriteFile(tunnelFile, []byte( "# Tunnel candidates 2026-07-19\n\n- `wiki/homelab/decisions/foo.md` ↔ `wiki/telos/decisions/mission.md` (term: \"mission\")\n", ), 0o644)) var completeCalls int completeFn := func(ctx context.Context, system, user string) (string, error) { completeCalls++ raw := pipeline.RawPage{Title: "Should not be written", Type: "source", Subtype: "article", Content: "## Summary\n\nx.\n"} b, _ := json.Marshal([]pipeline.RawPage{raw}) return string(b), nil } cfg := Config{ BrainDir: brainDir, Interval: time.Hour, // not used; we call processDir directly Pipeline: pipeline.Config{ Complete: completeFn, ChunkSize: 0, Schema: "# Schema\nThree page types.", }, } date := time.Now().UTC().Format("2006-01-02") errs := processDir(context.Background(), cfg, date) assert.Empty(t, errs) assert.Zero(t, completeCalls, "tunnel-candidates file must never reach the LLM extraction pipeline") // File must be left alone in raw/ — not moved to processed/, no marker written. _, err := os.Stat(tunnelFile + ".processed") assert.True(t, os.IsNotExist(err), "tunnel-candidates file should not get a .processed marker") _, err = os.Stat(filepath.Join(brainDir, "raw", "processed", date, "tunnel-candidates-2026-07-19.md")) assert.True(t, os.IsNotExist(err), "tunnel-candidates file should not be copied to processed/") } // hyperguild#153: raw/ is writable via Syncthing, and the watcher ingests // whatever lands there with no API call at all. WalkDir yields symlinked // files, and extract.Text followed them, so a symlink to any pod-readable // file would be extracted into the brain. The watcher now ingests only // regular files, read through an os.Root on raw/. func TestProcessDir_IngestsTheRealNoteAndSkipsTheSymlinkBesideIt(t *testing.T) { brainDir := setupBrainDir(t) outside := filepath.Join(t.TempDir(), "secret.md") require.NoError(t, os.WriteFile(outside, []byte("SENTINEL-153-WATCHER"), 0o644)) // "a-" sorts before "real": a walk that stopped at the first symlink // (filepath.SkipAll — one synced link halting all ingestion) would fail // the call count below (PR #156 review). require.NoError(t, os.Symlink(outside, filepath.Join(brainDir, "raw", "a-leak.md"))) require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "real.md"), []byte("real note"), 0o644)) var sawSentinel bool calls := 0 cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{ Complete: func(_ context.Context, _, user string) (string, error) { calls++ if strings.Contains(user, "SENTINEL-153-WATCHER") { sawSentinel = true } b, _ := json.Marshal([]pipeline.RawPage{{Title: "X", Type: "source", Subtype: "article", Content: "## S\n\nx\n"}}) return string(b), nil }, Schema: "# Schema", }} errs := processDir(context.Background(), cfg, time.Now().UTC().Format("2006-01-02")) assert.Empty(t, errs) assert.False(t, sawSentinel, "the symlink target must never reach the pipeline") assert.Equal(t, 1, calls, "exactly the real note is ingested; the symlink is not a note") } // A symlinked raw/ is refused outright: os.OpenRoot would otherwise follow // it and confine reads to wherever it points. func TestProcessDir_RefusesASymlinkedRawDir(t *testing.T) { brainDir := t.TempDir() elsewhere := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(elsewhere, "x.md"), []byte("SENTINEL-153-RAWLINK"), 0o644)) require.NoError(t, os.Symlink(elsewhere, filepath.Join(brainDir, "raw"))) calls := 0 cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{ Complete: func(context.Context, string, string) (string, error) { calls++; return "[]", nil }, Schema: "# Schema", }} errs := processDir(context.Background(), cfg, time.Now().UTC().Format("2006-01-02")) assert.NotEmpty(t, errs, "a symlinked raw/ must be reported, not silently ingested") assert.Zero(t, calls) } // PR #156: a FIFO swapped into raw/ between the watcher's checks and its // read or archive copy must not hang the watcher, which runs unattended. func TestProcessDir_DoesNotHangUnderAFIFOSwapRace(t *testing.T) { brainDir := setupBrainDir(t) raw := filepath.Join(brainDir, "raw") var stop atomic.Bool var wg sync.WaitGroup wg.Add(1) // Awaited before t.TempDir's cleanup (hyperguild#162). t.Cleanup(func() { stop.Store(true); wg.Wait() }) go func() { defer wg.Done() for i := 0; !stop.Load(); i++ { tmp := filepath.Join(raw, ".swap") _ = os.Remove(tmp) if i%2 == 0 { _ = os.WriteFile(tmp, []byte("x"), 0o644) } else { _ = syscall.Mkfifo(tmp, 0o644) } _ = os.Rename(tmp, filepath.Join(raw, "r.md")) } }() cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{ Complete: func(context.Context, string, string) (string, error) { b, _ := json.Marshal([]pipeline.RawPage{{Title: "R", Type: "source", Subtype: "article", Content: "## S\n\nx\n"}}) return string(b), nil }, Schema: "# Schema", }} var iters atomic.Int64 done := make(chan struct{}) go func() { for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); { _ = os.Remove(filepath.Join(raw, "r.md.processed")) _ = os.Remove(filepath.Join(raw, "r.md.failed")) _ = processDir(context.Background(), cfg, "2026-10-03") iters.Add(1) } close(done) }() select { case <-done: stop.Store(true) case <-time.After(8 * time.Second): stop.Store(true) if f, err := os.OpenFile(filepath.Join(raw, "r.md"), os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil { _ = f.Close() } t.Fatalf("the watcher hung on a swapped-in FIFO after %d iterations", iters.Load()) } } // A note whose removal AND marker write both fail (read-only raw/) must not be // re-ingested on every poll: each pass would write another archive copy. func TestProcessDir_DoesNotReingestANoteItCannotRetire(t *testing.T) { brainDir := setupBrainDir(t) rawDir := filepath.Join(brainDir, "raw") require.NoError(t, os.WriteFile(filepath.Join(rawDir, "stuck.md"), []byte("Content."), 0o644)) // processed/ must exist and stay writable; only raw/ itself is locked. require.NoError(t, os.MkdirAll(filepath.Join(rawDir, "processed"), 0o755)) require.NoError(t, os.Chmod(rawDir, 0o555)) t.Cleanup(func() { _ = os.Chmod(rawDir, 0o755) }) var calls atomic.Int32 cfg := Config{ BrainDir: brainDir, Pipeline: pipeline.Config{ Complete: func(ctx context.Context, s, u string) (string, error) { calls.Add(1) return successComplete(pipeline.RawPage{Title: "Stuck", Type: "source", Subtype: "article", Content: "x\n"})(ctx, s, u) }, Schema: "# Schema", }, } for i := 0; i < 3; i++ { processDir(context.Background(), cfg, "2026-10-19") } // The LLM count alone proves nothing: pipeline.Run's source-hash check skips // extraction on an unchanged note, so a re-run is invisible there. What a // re-run does leave behind is another archive copy, every poll. archived, err := filepath.Glob(filepath.Join(rawDir, "processed", "2026-10-19", "*")) require.NoError(t, err) assert.Len(t, archived, 1, "a note that cannot be retired is archived once, not once per poll") assert.Equal(t, int32(1), calls.Load()) } // If the archive copy cannot be written, the original is the only copy left: // it must stay in raw/. func TestProcessDir_KeepsTheOriginalWhenTheArchiveCopyFails(t *testing.T) { brainDir := setupBrainDir(t) rawDir := filepath.Join(brainDir, "raw") note := filepath.Join(rawDir, "keep-me.md") require.NoError(t, os.WriteFile(note, []byte("Content."), 0o644)) // The dated archive dir exists but is read-only: MkdirAll succeeds, the // copy itself (os.Create) fails. dated := filepath.Join(rawDir, "processed", "2026-10-19") require.NoError(t, os.MkdirAll(dated, 0o755)) require.NoError(t, os.Chmod(dated, 0o555)) t.Cleanup(func() { _ = os.Chmod(dated, 0o755) }) cfg := Config{ BrainDir: brainDir, Pipeline: pipeline.Config{ Complete: successComplete(pipeline.RawPage{Title: "Keep Me", Type: "source", Subtype: "article", Content: "x\n"}), Schema: "# Schema", }, } errs := processDir(context.Background(), cfg, "2026-10-19") assert.NotEmpty(t, errs, "the failed archive is reported") _, err := os.Stat(note) assert.NoError(t, err, "original stays in raw/ when it could not be archived") } // If the note is replaced (e.g. by Syncthing) while it is being ingested, the // wiki page was extracted from the old content; removing the original would // leave the new content only in the archive and never re-ingest it. Observes // size/mtime change only: a same-size rewrite with a restored mtime is not caught. func TestProcessDir_KeepsANoteThatChangedWhileItWasBeingIngested(t *testing.T) { brainDir := setupBrainDir(t) note := filepath.Join(brainDir, "raw", "edited.md") require.NoError(t, os.WriteFile(note, []byte("old"), 0o644)) cfg := Config{ BrainDir: brainDir, Pipeline: pipeline.Config{ Complete: func(ctx context.Context, s, u string) (string, error) { require.NoError(t, os.WriteFile(note, []byte("newer and longer content"), 0o644)) return successComplete(pipeline.RawPage{Title: "Edited", Type: "source", Subtype: "article", Content: "x\n"})(ctx, s, u) }, Schema: "# Schema", }, } processDir(context.Background(), cfg, "2026-10-19") got, err := os.ReadFile(note) require.NoError(t, err, "a note that changed during ingest must stay in raw/") assert.Equal(t, "newer and longer content", string(got)) } // hyperguild#198: an oversized note must not be read into memory. It is left in // raw/ (visible, not lost), reported once, and not retried until it changes. func TestProcessDir_ANoteOverTheSizeCapIsRefusedOnceAndKept(t *testing.T) { brainDir := setupBrainDir(t) big := filepath.Join(brainDir, "raw", "big.md") require.NoError(t, os.WriteFile(big, []byte(strings.Repeat("x", 2048)), 0o644)) var calls atomic.Int32 cfg := Config{ BrainDir: brainDir, MaxNoteBytes: 1024, Pipeline: pipeline.Config{ Complete: func(ctx context.Context, s, u string) (string, error) { calls.Add(1) return successComplete(pipeline.RawPage{Title: "Big", Type: "source", Content: "x\n"})(ctx, s, u) }, Schema: "# Schema", }, } first := processDir(context.Background(), cfg, "2026-10-20") require.NotEmpty(t, first, "the refusal is reported") assert.Contains(t, first[0].Error(), "over the 1024 byte cap") second := processDir(context.Background(), cfg, "2026-10-20") assert.Empty(t, second, "and reported once, not every poll") assert.Zero(t, calls.Load(), "no extraction") _, err := os.Stat(big) assert.NoError(t, err, "the note stays in raw/") entries, _ := os.ReadDir(filepath.Join(brainDir, "raw", "processed")) assert.Empty(t, entries, "nothing was archived") } // hyperguild#199: a name near NAME_MAX cannot take a ".processed" suffix. Under // inbox zero the marker is only the fallback when removal fails, and any failure // there is remembered, so the note is still not handled every poll. func TestProcessDir_ALongNameWhoseMarkerCannotBeWrittenIsNotHandledEveryPoll(t *testing.T) { brainDir := setupBrainDir(t) rawDir := filepath.Join(brainDir, "raw") name := strings.Repeat("a", 250) + ".md" // 253 bytes; +".processed" exceeds 255 require.NoError(t, os.WriteFile(filepath.Join(rawDir, name), []byte("Content."), 0o644)) require.NoError(t, os.MkdirAll(filepath.Join(rawDir, "processed"), 0o755)) require.NoError(t, os.Chmod(rawDir, 0o555)) // removal fails, so the marker fallback runs t.Cleanup(func() { _ = os.Chmod(rawDir, 0o755) }) cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{ Complete: successComplete(pipeline.RawPage{Title: "Long", Type: "source", Content: "x\n"}), Schema: "# Schema"}} for i := 0; i < 3; i++ { errs := processDir(context.Background(), cfg, "2026-10-20") // Without the remember step the second poll tries to archive a second // copy under a name that is too long and errors, every poll. assert.Empty(t, errs, "poll %d handled the note again", i) } archived, err := filepath.Glob(filepath.Join(rawDir, "processed", "2026-10-20", "*")) require.NoError(t, err) assert.Len(t, archived, 1) } // Three notes named n.md in three folders, one poll. The archive and failed/ // copies are the only copy once the originals are retired, so none may replace // another. func TestProcessDir_SameNamedNotesAreAllArchivedNoneOverwritten(t *testing.T) { for _, outcome := range []string{"processed", "failed"} { t.Run(outcome, func(t *testing.T) { brainDir := setupBrainDir(t) for _, d := range []string{"a", "b", "c"} { require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "raw", d), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", d, "n.md"), []byte("CONTENT-"+d), 0o644)) } cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema", Complete: successComplete(pipeline.RawPage{Title: "N", Type: "source", Content: "x\n"})}} if outcome == "failed" { cfg.Pipeline.Complete = errorComplete } processDir(context.Background(), cfg, "2026-10-20") var all string files, _ := filepath.Glob(filepath.Join(brainDir, "raw", outcome, "*", "*")) more, _ := filepath.Glob(filepath.Join(brainDir, "raw", outcome, "*")) for _, f := range append(files, more...) { if b, err := os.ReadFile(f); err == nil { all += string(b) } } for _, d := range []string{"a", "b", "c"} { assert.Contains(t, all, "CONTENT-"+d, "the copy of note %s was overwritten", d) } }) } } // A marker older than the note describes an earlier note of the same name; it // must not hide the new one, silently, forever (the legacy .processed markers // from before inbox zero are exactly this). func TestProcessDir_AStaleMarkerDoesNotHideANewerNoteOfTheSameName(t *testing.T) { brainDir := setupBrainDir(t) rawDir := filepath.Join(brainDir, "raw") marker := filepath.Join(rawDir, "idea.md.processed") require.NoError(t, os.WriteFile(marker, []byte("2026-06-16"), 0o644)) old := time.Now().Add(-48 * time.Hour) require.NoError(t, os.Chtimes(marker, old, old)) require.NoError(t, os.WriteFile(filepath.Join(rawDir, "idea.md"), []byte("A new idea, same file name."), 0o644)) var calls atomic.Int32 cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema", Complete: func(ctx context.Context, s, u string) (string, error) { calls.Add(1) return successComplete(pipeline.RawPage{Title: "Idea", Type: "source", Content: "x\n"})(ctx, s, u) }}} require.Empty(t, processDir(context.Background(), cfg, "2026-10-20")) assert.Equal(t, int32(1), calls.Load(), "the new note is processed") _, err := os.Stat(filepath.Join(rawDir, "idea.md")) assert.ErrorIs(t, err, os.ErrNotExist, "and retired") _, err = os.Lstat(marker) assert.ErrorIs(t, err, os.ErrNotExist, "the stale marker is cleaned up") } func TestProcessDir_AMarkerNewerThanTheNoteStillMeansDone(t *testing.T) { brainDir := setupBrainDir(t) rawDir := filepath.Join(brainDir, "raw") note := filepath.Join(rawDir, "stuck.md") require.NoError(t, os.WriteFile(note, []byte("Content."), 0o644)) past := time.Now().Add(-time.Hour) require.NoError(t, os.Chtimes(note, past, past)) require.NoError(t, os.WriteFile(note+".processed", []byte("d"), 0o644)) // newer than the note var calls atomic.Int32 cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema", Complete: func(context.Context, string, string) (string, error) { calls.Add(1); return "[]", nil }}} processDir(context.Background(), cfg, "2026-10-20") assert.Zero(t, calls.Load(), "a fallback marker written after the note still stops reprocessing") } // The archive copy is a second read of the note; the cap must hold there too, // not only at the earlier Lstat (a note can grow in between). func TestReadNoteMax(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "n.md"), []byte(strings.Repeat("x", 100)), 0o644)) root, err := os.OpenRoot(dir) require.NoError(t, err) defer func() { _ = root.Close() }() got, err := readNoteMax(root, "n.md", 100) require.NoError(t, err) assert.Len(t, got, 100, "exactly at the cap is allowed") _, err = readNoteMax(root, "n.md", 99) require.Error(t, err) assert.ErrorIs(t, err, errTooLarge) } // countingReader reports how much was pulled from it: an endless file must be // abandoned at max+1 bytes, not slurped. type countingReader struct{ n int64 } func (c *countingReader) Read(p []byte) (int, error) { if c.n > 8<<20 { // a regression must fail the test, not exhaust memory return 0, errors.New("endless reader drained") } for i := range p { p[i] = 'x' } c.n += int64(len(p)) return len(p), nil } func TestReadAllMax_StopsReadingAtTheCap(t *testing.T) { c := &countingReader{} _, err := readAllMax(c, 1000) require.ErrorIs(t, err, errTooLarge) assert.LessOrEqual(t, c.n, int64(1000+1+32*1024), "pulled far more than the cap from an endless reader: %d", c.n) } // From the second review of #210: a marker dated in the future (clock skew, // Syncthing) must not hide the note for good, and when a marker does block it // has to say so, once. func TestProcessDir_AFutureDatedMarkerDoesNotHideTheNote(t *testing.T) { brainDir := setupBrainDir(t) rawDir := filepath.Join(brainDir, "raw") marker := filepath.Join(rawDir, "idea.md.processed") require.NoError(t, os.WriteFile(marker, []byte("x"), 0o644)) future := time.Now().Add(72 * time.Hour) require.NoError(t, os.Chtimes(marker, future, future)) require.NoError(t, os.WriteFile(filepath.Join(rawDir, "idea.md"), []byte("A new idea."), 0o644)) var calls atomic.Int32 cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema", Complete: func(ctx context.Context, s, u string) (string, error) { calls.Add(1) return successComplete(pipeline.RawPage{Title: "Idea", Type: "source", Content: "x\n"})(ctx, s, u) }}} processDir(context.Background(), cfg, "2026-10-20") assert.Equal(t, int32(1), calls.Load(), "a marker from the future is not evidence the note was handled") } func TestProcessDir_BothStaleMarkersAreRemoved(t *testing.T) { brainDir := setupBrainDir(t) rawDir := filepath.Join(brainDir, "raw") old := time.Now().Add(-48 * time.Hour) for _, suffix := range []string{".processed", ".failed"} { require.NoError(t, os.WriteFile(filepath.Join(rawDir, "idea.md"+suffix), []byte("x"), 0o644)) require.NoError(t, os.Chtimes(filepath.Join(rawDir, "idea.md"+suffix), old, old)) } require.NoError(t, os.WriteFile(filepath.Join(rawDir, "idea.md"), []byte("A new idea."), 0o644)) cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema", Complete: successComplete(pipeline.RawPage{Title: "Idea", Type: "source", Content: "x\n"})}} processDir(context.Background(), cfg, "2026-10-20") for _, suffix := range []string{".processed", ".failed"} { _, err := os.Lstat(filepath.Join(rawDir, "idea.md"+suffix)) assert.ErrorIs(t, err, os.ErrNotExist, "orphan %s marker left behind", suffix) } } func TestProcessDir_AMarkerThatBlocksANoteIsReportedOnce(t *testing.T) { var logs strings.Builder prev := slog.Default() slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) t.Cleanup(func() { slog.SetDefault(prev) }) brainDir := setupBrainDir(t) rawDir := filepath.Join(brainDir, "raw") note := filepath.Join(rawDir, "held.md") require.NoError(t, os.WriteFile(note, []byte("Content."), 0o644)) past := time.Now().Add(-time.Hour) require.NoError(t, os.Chtimes(note, past, past)) require.NoError(t, os.WriteFile(note+".processed", []byte("d"), 0o644)) // newer: blocks cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema", Complete: errorComplete}} for i := 0; i < 3; i++ { processDir(context.Background(), cfg, "2026-10-20") } assert.Equal(t, 1, strings.Count(logs.String(), "held by a marker"), "said once, not every poll: %s", logs.String()) }