// ingestion/internal/watcher/watcher.go package watcher import ( "context" "errors" "fmt" "io" "io/fs" "log/slog" "os" "path/filepath" "strings" "sync" "time" "unicode" "github.com/mathiasbq/hyperguild/ingestion/internal/brain" "github.com/mathiasbq/hyperguild/ingestion/internal/extract" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" "github.com/mathiasbq/hyperguild/ingestion/internal/wing" ) // Config holds watcher configuration. type Config struct { BrainDir string Interval time.Duration Pipeline pipeline.Config // stuck remembers notes that were ingested but could neither be removed // from raw/ nor marked (e.g. a read-only raw/), so later polls skip them // instead of ingesting them again. In memory only: a restart retries once. stuck *sync.Map // Resolver, when set, decides each note's wing (hyperguild#205): explicit // subfolder/frontmatter, else classifier + scorer. A note it cannot place // is parked in raw/needs-review/ for a human. Nil keeps the retired layout. Resolver *wing.Resolver } // Start launches the watcher in a background goroutine. // It returns immediately. The watcher stops when ctx is cancelled. func Start(ctx context.Context, cfg Config) { if cfg.stuck == nil { cfg.stuck = &sync.Map{} } go func() { ticker := time.NewTicker(cfg.Interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: date := time.Now().UTC().Format("2006-01-02") errs := processDir(ctx, cfg, date) for _, err := range errs { slog.Error("watcher: error processing file", "error", err) } } } }() } // processDir walks brain/raw/, processes each eligible file, returns any errors encountered. func processDir(ctx context.Context, cfg Config, date string) []error { rawDir := filepath.Join(cfg.BrainDir, "raw") // Every read goes through an os.Root on raw/ (hyperguild#153): raw/ is // writable via Syncthing, and a file swapped for a symlink between the walk // and the read must not lead out of it. A symlinked raw/ is refused. if fi, lerr := os.Lstat(rawDir); lerr != nil { if os.IsNotExist(lerr) { return nil } return []error{fmt.Errorf("stat raw dir: %w", lerr)} } else if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() { return []error{fmt.Errorf("raw dir %s is not a real directory; refusing to ingest from it", rawDir)} } root, err := os.OpenRoot(rawDir) if err != nil { return []error{fmt.Errorf("open raw root: %w", err)} } defer func() { _ = root.Close() }() var errs []error err = filepath.WalkDir(rawDir, func(path string, d os.DirEntry, err error) error { if err != nil { return err } // Skip the root itself. if path == rawDir { return nil } // Skip processed/, failed/, and rejected/ subdirectories entirely. // rejected/ (hyperguild#81, brain_reject) holds notes a human already // looked at and declined — walking into it would run the extraction // pipeline over a note and publish it to the wiki, the exact opposite // of the human's decision. The whole reason RejectNote moves a note // there instead of leaving it in raw/ was to keep it out of every // path that treats raw/ as "awaiting processing" — this is that path. if d.IsDir() { name := d.Name() if name == "processed" || name == "failed" || name == "rejected" || name == "needs-review" { return filepath.SkipDir } return nil } // Only process supported extensions. ext := strings.ToLower(filepath.Ext(path)) if ext != ".md" && ext != ".txt" && ext != ".pdf" { return nil } // AutoTunnel's own fuzzy-match human-review queue, not source // material to extract (hyperguild#88) — same exclusion as // api.ListPending already applies when listing raw/ for promotion. if strings.HasPrefix(d.Name(), "tunnel-candidates-") { return nil } // Only regular files are notes; a symlink's target may be anywhere. if !d.Type().IsRegular() { return nil } if cfg.stuck != nil { if _, skip := cfg.stuck.Load(path); skip { return nil } } // Skip files that have already been processed or permanently failed. if _, err := os.Stat(path + ".processed"); err == nil { return nil } if _, err := os.Stat(path + ".failed"); err == nil { return nil } if err := processFile(ctx, cfg, root, rawDir, path, date); err != nil { errs = append(errs, fmt.Errorf("process %s: %w", filepath.Base(path), err)) } return nil }) if err != nil { errs = append(errs, fmt.Errorf("walk raw dir: %w", err)) } return errs } // processFile reads a file, calls pipeline.Run, and archives it into processed/ // or failed/. The original is then removed from raw/ so that raw/ only ever // holds unprocessed input (inbox zero). The removal syncs to Syncthing peers; // the archive copy under raw/processed/ or raw/failed/ is what they keep. A // marker is written next to the original only if the removal fails, so a // stuck file is not re-ingested every poll. func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, date string) error { filename := filepath.Base(path) source := deriveSource(filename) rel, err := filepath.Rel(rawDir, path) if err != nil { return fmt.Errorf("relative path: %w", err) } before, err := root.Lstat(rel) if err != nil { return fmt.Errorf("stat before ingest: %w", err) } content, err := extract.TextIn(root, rel) if err != nil { return fmt.Errorf("extract text: %w", err) } pcfg := cfg.Pipeline 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) } pcfg.Wing = d.Wing slog.Info("watcher: wing resolved", "file", filename, "wing", d.Wing, "how", d.Source, "reason", d.Reason) } _, runErr := pipeline.Run(ctx, pcfg, cfg.BrainDir, content, source, false) if runErr != nil { // Copy to failed/ and leave a .failed marker so we don't retry. failedDir := filepath.Join(cfg.BrainDir, "raw", "failed") if mkErr := os.MkdirAll(failedDir, 0o755); mkErr != nil { return fmt.Errorf("mkdir failed dir: %w", mkErr) } dest := filepath.Join(failedDir, filename) if cpErr := copyFromRoot(root, rel, dest); cpErr != nil { return fmt.Errorf("copy to failed: %w", cpErr) } if changedSince(root, rel, before) { slog.Warn("watcher: note changed during ingest; leaving it in raw/ for the next poll", "file", filename) return nil } retire(cfg, root, rel, path, ".failed", runErr.Error()) slog.Warn("watcher: file failed", "file", filename, "error", runErr) if logErr := appendWatcherLog(cfg.BrainDir, filename, runErr, date); logErr != nil { slog.Error("watcher: failed to write log entry", "error", logErr) } // Return nil: quarantine succeeded; error already logged. return nil } // Copy to processed/YYYY-MM-DD/ and leave a .processed marker so we don't re-ingest. processedDir := filepath.Join(cfg.BrainDir, "raw", "processed", date) if err := os.MkdirAll(processedDir, 0o755); err != nil { return fmt.Errorf("mkdir processed dir: %w", err) } dest := filepath.Join(processedDir, filename) if _, err := os.Stat(dest); err == nil { // Archive copy already exists; append timestamp to avoid overwriting. ext := filepath.Ext(filename) base := strings.TrimSuffix(filename, ext) dest = filepath.Join(processedDir, base+"-"+time.Now().UTC().Format("150405")+ext) } if err := copyFromRoot(root, rel, dest); err != nil { return fmt.Errorf("copy to processed: %w", err) } if changedSince(root, rel, before) { slog.Warn("watcher: note changed during ingest; leaving it in raw/ for the next poll", "file", filename) return nil } retire(cfg, root, rel, path, ".processed", date) slog.Info("watcher: file processed", "file", filename, "source", source) return nil } // changedSince reports whether the note at rel differs (size or mtime) from // before. A note replaced mid-ingest (Syncthing) was extracted from older // content than it now holds, so it must not be removed. Limits: a same-size // rewrite with a restored mtime is not detected (the new bytes then survive // only in the archive copy), nor a change between this check and Remove. func changedSince(root *os.Root, rel string, before os.FileInfo) bool { now, err := root.Lstat(rel) if err != nil { return true } return now.Size() != before.Size() || !now.ModTime().Equal(before.ModTime()) } // retire removes an archived original from raw/ (inbox zero). If the removal // fails it falls back to a marker file so the watcher still skips the file. func retire(cfg Config, root *os.Root, rel, path, markerSuffix, markerBody string) { rmErr := root.Remove(rel) if rmErr == nil { return } slog.Error("watcher: failed to remove archived original; writing marker", "file", rel, "error", rmErr) if err := os.WriteFile(path+markerSuffix, []byte(markerBody), 0o644); err != nil { slog.Error("watcher: failed to write marker; skipping this note until restart", "file", rel, "error", err) if cfg.stuck != nil { cfg.stuck.Store(path, struct{}{}) } } } // deriveSource turns a filename into a human-readable source name. // "shape-up-book.md" → "Shape Up Book" func deriveSource(filename string) string { // Strip extension. name := strings.TrimSuffix(filename, filepath.Ext(filename)) // Split on hyphens. words := strings.Split(name, "-") // Title-case each word. for i, w := range words { if w == "" { continue } runes := []rune(w) runes[0] = unicode.ToUpper(runes[0]) words[i] = string(runes) } return strings.Join(words, " ") } // appendWatcherLog appends a watcher error entry to brain/log.md. func appendWatcherLog(brainDir, filename string, runErr error, date string) error { entry := fmt.Sprintf("## %s — watcher error\n\n- **File:** %s\n- **Error:** %s\n\n", date, filename, runErr.Error()) logPath := filepath.Join(brainDir, "log.md") f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return fmt.Errorf("open log: %w", err) } if _, err = f.WriteString(entry); err != nil { f.Close() //nolint:errcheck return fmt.Errorf("write log: %w", err) } return f.Close() } // copyFromRoot archives a raw/ file read through the root, refusing anything // that is not a regular file, so a swapped-in symlink cannot copy an outside // file into raw/processed/ or raw/failed/ (hyperguild#153). func copyFromRoot(root *os.Root, rel, dst string) error { fi, err := root.Lstat(rel) if err != nil { return err } if !fi.Mode().IsRegular() { return fmt.Errorf("%s: not a regular file", rel) } in, _, err := extract.OpenRegular(root, rel) if err != nil { return err } defer func() { _ = in.Close() }() out, err := os.Create(dst) if err != nil { return err } if _, err := io.Copy(out, in); err != nil { _ = out.Close() return err } return out.Close() } // topDir is the first directory of a raw/-relative path, "" for a top-level file. func topDir(rel string) string { if i := strings.IndexByte(filepath.ToSlash(rel), '/'); i > 0 { return filepath.ToSlash(rel)[:i] } return "" } // frontmatterWing reads `wing:` from a note's leading frontmatter, "" if none. func frontmatterWing(content string) string { content = strings.ReplaceAll(content, "\r\n", "\n") if !strings.HasPrefix(content, "---\n") { return "" } block, _, _ := strings.Cut(content[4:], "\n---") for _, line := range strings.Split(block, "\n") { if key, val, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(key) == "wing" { return strings.Trim(strings.TrimSpace(val), `"'`) } } return "" } // 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) } 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) return fmt.Errorf("park pdf: %w", err) } } else if err := os.WriteFile(dest, []byte(withProposal(content, d)), 0o644); err != nil { _ = os.Remove(dest) 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) 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} // withProposal adds the resolver's proposal to a note's frontmatter, creating // the block when the note has none. Every value is on one line: the wing only // if it is a plain wing name (else it appears in the reason alone), strings // Go-quoted. Any proposal keys the note already carried are dropped first, so a // note cannot pre-empt the resolver's (frontmatter reads are first-wins). // Line endings are normalised to LF so a CRLF note keeps one valid block. func withProposal(content string, d wing.Decision) string { content = strings.ReplaceAll(content, "\r\n", "\n") reason := d.Reason var kv strings.Builder if d.Wing != "" && brain.Sanitise(d.Wing) == d.Wing { fmt.Fprintf(&kv, "proposed_wing: %s\n", d.Wing) } else if d.Wing != "" { reason = fmt.Sprintf("proposed wing %q is not a plain wing name; %s", d.Wing, reason) } fmt.Fprintf(&kv, "proposal_confidence: %.2f\nproposal_reason: %q\n", d.Confidence, reason) if !strings.HasPrefix(content, "---\n") { return "---\n" + kv.String() + "---\n" + content } block, rest, found := strings.Cut(content[4:], "\n---") if !found { return "---\n" + kv.String() + "---\n" + content } var kept []string for _, line := range strings.Split(block, "\n") { if key, _, ok := strings.Cut(line, ":"); ok && proposalKeys[strings.TrimSpace(key)] && !strings.HasPrefix(line, " ") { continue } kept = append(kept, line) } return "---\n" + kv.String() + strings.Join(kept, "\n") + "\n---" + rest }