Compare commits

...
Author SHA1 Message Date
mathiasandClaude Sonnet 5.5 e6e14102ee docs(watcher): state what changedSince can and cannot detect
CI / Lint / Test / Vet (pull_request) Successful in 1m4s
CI / Mirror to GitHub (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 21:09:13 +02:00
mathiasandClaude Sonnet 5.5 97ec9e7ce8 fix(watcher): keep a note that changed while it was being ingested
CI / Lint / Test / Vet (pull_request) Successful in 1m3s
CI / Mirror to GitHub (pull_request) Skipped
The archive holds the content read at the start; if Syncthing replaced the
note during the (minutes-long) LLM pass, removing the original would drop the
newer content. Compare size+mtime before removing; leave it for the next poll.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 21:06:46 +02:00
mathiasandClaude Sonnet 5.5 87ae782527 fix(watcher): don't re-ingest a note that cannot be retired; pin archive-failure keeps original
CI / Lint / Test / Vet (pull_request) Successful in 1m3s
CI / Mirror to GitHub (pull_request) Skipped
When both Remove and the marker write fail (read-only raw/), remember the
path in memory so later polls skip it instead of writing another archive
copy every tick. Adds a test that a failed archive copy leaves the original
in raw/, mutation-checked against removing the early return.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 21:06:05 +02:00
mathiasandClaude Sonnet 5.5 6bf61d75c0 feat(watcher): inbox zero — archived raw/ originals are removed, not copied
CI / Lint / Test / Vet (pull_request) Successful in 2m27s
CI / Mirror to GitHub (pull_request) Skipped
After a note is archived into raw/processed/ or raw/failed/, remove the
original from raw/ so raw/ only holds unprocessed input. The .processed /
.failed marker is now only a fallback when the removal itself fails.
Existing markers are still honoured.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 20:59:06 +02:00
mathias 18d01b64d7 chore(context): restore the root rules in AGENTS.md and system-prompt.txt (#200) (#201)
CI / Lint / Test / Vet (push) Successful in 1m0s
CI / Mirror to GitHub (push) Successful in 4s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-09 06:02:43 +00:00
mathiasandClaude Sonnet 5.5 5dfd2c0f7e docs: main is protected, ship by PR (local-dev#42)
CI / Lint / Test / Vet (push) Successful in 1m28s
CI / Mirror to GitHub (push) Successful in 3s
Gitea will refuse direct pushes to main in this repo. Rule 5's reviewed lane was enforced on the merge tools
(tbd_ship / pr_merge, gitea-mcp v0.17.0/v0.18.0) but not on a direct commit; this overrides rule 5's "risk/low straight
to main" for this repo and says why. Derived adapter files regenerated where the repo has them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
2026-10-08 08:34:50 +02:00
mathiasandClaude Opus 5.5 d6ddac4a03 fix(metrics): label an unmatched request "unmatched", never its raw path (#185)
CI / Mirror to GitHub (push) Successful in 5s
CI / Lint / Test / Vet (push) Successful in 1m4s
An authenticated REST request to an unregistered path reached the inner
mux, which left Pattern empty, and metrics.Middleware fell back to the
raw URL path: any token holder could create one Prometheus series per
unique path. The fallback is now the fixed label "unmatched"; real routes
keep their pattern. Tests: buildMux with an authenticated random path,
and the middleware unit test; the timing test now routes through a
ServeMux as production does.

Fixes #185.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:42:45 +02:00
mathiasandClaude Opus 5.5 4422dd1f69 test(symlinkguard): synchronise the alert-queue tests on observable completion (#193)
CI / Lint / Test / Vet (push) Successful in 1m3s
CI / Mirror to GitHub (push) Successful in 4s
Three Watch tests flaked under -race and CPU load (3/8 of 8 parallel
x10 runs on main):
- QueueIsBounded slept 100ms before looking for the summary; it now
  waits for the summary, then checks the bound.
- DeliversEveryQueuedAlert asserted FIFO order, but the outage ends
  asynchronously, possibly mid-flush, so a later item can send first. It
  now asserts each removal is delivered exactly once (order is not what
  it is named for).
- AccountsForEveryRemoval slept 50ms after the first summary, counted
  only "removed 1" and took the last summary's count. It now sums every
  "removed N" and every suppression count and waits until they account
  for all plants.

Stress: 0/8 failures in three rounds of 8 parallel x -test.count=10 under
-race. Flips still red: unbounded queue, dropped retries, suppressions
not counted.

Fixes #193.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:33:55 +02:00
mathiasandClaude Opus 5.5 f65f0b7826 fix(audit): each ntfy alert source sends its own title (#163)
CI / Lint / Test / Vet (push) Successful in 1m13s
CI / Mirror to GitHub (push) Successful in 4s
Every alert was titled "brain-capture audit degraded", including the
symlink tripwire's and governance-edit alerts. NtfyNotifier.WithTitle
returns a titled copy (nil stays nil); buildNotifier(getenv, title) is
called per source: capture audit (unchanged title), "brain symlink
tripwire", "brain governance edit". Tests assert the Title header.

Fixes #163.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:28:38 +02:00
14 changed files with 495 additions and 94 deletions
+12
View File
@@ -84,3 +84,15 @@ When acting as a coding agent on this project:
4. Never modify files outside the project root without explicit permission 4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message 5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CI / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/hyperguild/branch_protections/main {"enable_push": true}`.
+81 -17
View File
@@ -164,6 +164,63 @@ These rules apply to every task across every project, regardless of harness.
the thing it was named for was exploitable. If you cannot name the mechanism, the thing it was named for was exploitable. If you cannot name the mechanism,
you do not yet know what the test observes. you do not yet know what the test observes.
10. **Short sessions, one rc per repo, edit only in a locked worktree of the repo
you are changing** (ADR-0002, accepted 2026-10-06).
- One session per issue or task, ending with rule 6's close ritual. Related
issues may share a session only when they share a worktree and an owner.
State that must outlive the session goes in an issue, not the chat.
- Start `claude rc` in the repo you will edit (`infra`, `trucker-ecommerce`,
`hyperguild`, ...), not in `~/dev`: the rc worktree is a worktree of the
launch directory's repo, so launched from `~/dev` it protects nothing you
edit (measured: 73% of edits landed in shared checkouts). Start it only
while working there.
- Keep at most one thin `~/dev` coordinator session. It files issues and hands
out tasks; it reads and edits no sub-repo code.
- Split cross-repo work per repo. The contract between the halves lives in an
issue on each repo.
- Edit in a worktree of that repo, locked at creation:
`scripts/wt.sh add <repo> <task>` does it (fetch, `wt/<task>` from origin/main, lock);
`scripts/wt.sh done <repo>-<task>` removes it after the push and refuses dirty or
unpushed work. By hand: `git worktree add ~/worktrees/<name> -b <branch> origin/main`
then `git worktree lock --reason "manual: <purpose>" ~/worktrees/<name>`.
- Before working a repo another session may touch, announce your issue list to
that session (`SendMessage`) and ask for theirs.
- Auto-memory is keyed by cwd, so a repo rc starts without it. Cross-repo
working rules live in brain (`wiki/homelab/decisions/2026-10-06-062416-auto.md`, wing homelab, hall
decisions) and the section below, not in per-directory memory.
## Standing footguns
Each cost real time once. Detail and evidence: brain `wiki/homelab/decisions/2026-10-06-062416-auto.md` (title standing-working-rules).
- **Gate then commit with `&&`, never `;`.** "Green" means exit code 0 and
`grep -c FAIL` is 0, not a filtered grep.
- **Stage by path, never `git add -A` or `git add .`** (they stage from the repo
root and swept in other sessions' work twice). Check `git status --porcelain` first.
- **Before committing in a shared checkout:** `git branch --show-current`.
- **Before tagging:** `git fetch origin --tags`, then `git describe --tags --abbrev=0 <sha>`
and `git tag --sort=-v:refname | head`; go above both. Never take the last tag
from a report.
- **Run only what the project's gate runs.** No repo-wide formatter unless the gate
runs it.
- **Reproduce a CI red at the cited commit before applying an issue's prescribed fix.**
- **Read an issue's comments (`issue_list_comments`), not just its body, before working it.**
- **A comment naming a control ("behind branch protection") is not evidence; query
the control.** A working detector can hide a missing preventer.
- **On koala the MCP servers are `mcp__plugin_skills_{brain,gitea,infra}__*`.** The
per-turn auth reminder is not evidence; call the tool or run `claude mcp list`.
Brain writes always pass `wing` + `hall`.
- **`cat /etc/hostname` before assuming which host you are on.**
- **Never run bare `git remote -v` or `git config --list`** (url.insteadOf expands
into a live token); use `git config --get remote.origin.url`.
- **On koala `sudo` is denied outright; k8s exec, env dumps and flux reconcile need
per-action approval; decrypting a SOPS file and printing in one Bash call is
hook-blocked.** Do the repo-side work and hand the host-side step over in an issue.
- **`set -o pipefail` plus an optional `grep | head | awk` dies on no match:** append `|| true`.
- **Gitea `file_write_branch` takes plain text, not base64.**
- **`OP_SERVICE_ACCOUNT_TOKEN` in the shell is read-only for `op`.** Operational
secrets go in the AgentSecrets vault via `set -a; . ~/.op-agent-write.env; set +a`.
## CAD sprint patterns ## CAD sprint patterns
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`): Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
@@ -244,25 +301,20 @@ Three machines on Tailscale:
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`). All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders: Organized by **role**, because the role decides which rules apply (ADR-0003):
| Folder | Focus | Count | | Folder | Role | Rules that key off it |
|--------|-------|-------| |--------|------|-----------------------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 | | `platform/` | infra, brain, hyperguild, gitea-mcp, dispatch, shared tooling | control code (rule 8), GitOps review lane |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 | | `products/` | tapir, cobalt-dingo, openbanking-api, ... | ordinary trunk-based work |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 | | `clients/` | client work | local models only, never cloud APIs (Client work rules) |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 | | `spikes/` | throwaway | no ceremony |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project. New projects are born into a role folder (`task new-project -- <name> <client|personal>
<role-folder>`); a client tag requires `clients/` and `clients/` requires a client tag.
### Key active projects Existing repos still sit in the old `AGENTS/` and `AI/` folders until they are moved one by
one (ADR-0003 step 3). `~/dev/PROJECT_SUMMARY.md` (generated) lists every repo and where it
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP is. How a session runs on koala (worktrees, rc per repo, the reaper, memory): `docs/working-on-koala.md`.
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base — actively use it ## Knowledge base — actively use it
@@ -514,4 +566,16 @@ When acting as a coding agent on this project:
5. When adding a dependency, explain why in the commit message 5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CI / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/hyperguild/branch_protections/main {"enable_push": true}`.
--- ---
+81 -17
View File
@@ -159,6 +159,63 @@ These rules apply to every task across every project, regardless of harness.
the thing it was named for was exploitable. If you cannot name the mechanism, the thing it was named for was exploitable. If you cannot name the mechanism,
you do not yet know what the test observes. you do not yet know what the test observes.
10. **Short sessions, one rc per repo, edit only in a locked worktree of the repo
you are changing** (ADR-0002, accepted 2026-10-06).
- One session per issue or task, ending with rule 6's close ritual. Related
issues may share a session only when they share a worktree and an owner.
State that must outlive the session goes in an issue, not the chat.
- Start `claude rc` in the repo you will edit (`infra`, `trucker-ecommerce`,
`hyperguild`, ...), not in `~/dev`: the rc worktree is a worktree of the
launch directory's repo, so launched from `~/dev` it protects nothing you
edit (measured: 73% of edits landed in shared checkouts). Start it only
while working there.
- Keep at most one thin `~/dev` coordinator session. It files issues and hands
out tasks; it reads and edits no sub-repo code.
- Split cross-repo work per repo. The contract between the halves lives in an
issue on each repo.
- Edit in a worktree of that repo, locked at creation:
`scripts/wt.sh add <repo> <task>` does it (fetch, `wt/<task>` from origin/main, lock);
`scripts/wt.sh done <repo>-<task>` removes it after the push and refuses dirty or
unpushed work. By hand: `git worktree add ~/worktrees/<name> -b <branch> origin/main`
then `git worktree lock --reason "manual: <purpose>" ~/worktrees/<name>`.
- Before working a repo another session may touch, announce your issue list to
that session (`SendMessage`) and ask for theirs.
- Auto-memory is keyed by cwd, so a repo rc starts without it. Cross-repo
working rules live in brain (`wiki/homelab/decisions/2026-10-06-062416-auto.md`, wing homelab, hall
decisions) and the section below, not in per-directory memory.
## Standing footguns
Each cost real time once. Detail and evidence: brain `wiki/homelab/decisions/2026-10-06-062416-auto.md` (title standing-working-rules).
- **Gate then commit with `&&`, never `;`.** "Green" means exit code 0 and
`grep -c FAIL` is 0, not a filtered grep.
- **Stage by path, never `git add -A` or `git add .`** (they stage from the repo
root and swept in other sessions' work twice). Check `git status --porcelain` first.
- **Before committing in a shared checkout:** `git branch --show-current`.
- **Before tagging:** `git fetch origin --tags`, then `git describe --tags --abbrev=0 <sha>`
and `git tag --sort=-v:refname | head`; go above both. Never take the last tag
from a report.
- **Run only what the project's gate runs.** No repo-wide formatter unless the gate
runs it.
- **Reproduce a CI red at the cited commit before applying an issue's prescribed fix.**
- **Read an issue's comments (`issue_list_comments`), not just its body, before working it.**
- **A comment naming a control ("behind branch protection") is not evidence; query
the control.** A working detector can hide a missing preventer.
- **On koala the MCP servers are `mcp__plugin_skills_{brain,gitea,infra}__*`.** The
per-turn auth reminder is not evidence; call the tool or run `claude mcp list`.
Brain writes always pass `wing` + `hall`.
- **`cat /etc/hostname` before assuming which host you are on.**
- **Never run bare `git remote -v` or `git config --list`** (url.insteadOf expands
into a live token); use `git config --get remote.origin.url`.
- **On koala `sudo` is denied outright; k8s exec, env dumps and flux reconcile need
per-action approval; decrypting a SOPS file and printing in one Bash call is
hook-blocked.** Do the repo-side work and hand the host-side step over in an issue.
- **`set -o pipefail` plus an optional `grep | head | awk` dies on no match:** append `|| true`.
- **Gitea `file_write_branch` takes plain text, not base64.**
- **`OP_SERVICE_ACCOUNT_TOKEN` in the shell is read-only for `op`.** Operational
secrets go in the AgentSecrets vault via `set -a; . ~/.op-agent-write.env; set +a`.
## CAD sprint patterns ## CAD sprint patterns
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`): Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
@@ -239,25 +296,20 @@ Three machines on Tailscale:
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`). All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders: Organized by **role**, because the role decides which rules apply (ADR-0003):
| Folder | Focus | Count | | Folder | Role | Rules that key off it |
|--------|-------|-------| |--------|------|-----------------------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 | | `platform/` | infra, brain, hyperguild, gitea-mcp, dispatch, shared tooling | control code (rule 8), GitOps review lane |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 | | `products/` | tapir, cobalt-dingo, openbanking-api, ... | ordinary trunk-based work |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 | | `clients/` | client work | local models only, never cloud APIs (Client work rules) |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 | | `spikes/` | throwaway | no ceremony |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project. New projects are born into a role folder (`task new-project -- <name> <client|personal>
<role-folder>`); a client tag requires `clients/` and `clients/` requires a client tag.
### Key active projects Existing repos still sit in the old `AGENTS/` and `AI/` folders until they are moved one by
one (ADR-0003 step 3). `~/dev/PROJECT_SUMMARY.md` (generated) lists every repo and where it
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP is. How a session runs on koala (worktrees, rc per repo, the reaper, memory): `docs/working-on-koala.md`.
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base — actively use it ## Knowledge base — actively use it
@@ -508,3 +560,15 @@ When acting as a coding agent on this project:
4. Never modify files outside the project root without explicit permission 4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message 5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CI / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/hyperguild/branch_protections/main {"enable_push": true}`.
+12
View File
@@ -84,3 +84,15 @@ When acting as a coding agent on this project:
4. Never modify files outside the project root without explicit permission 4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message 5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM 6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CI / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/hyperguild/branch_protections/main {"enable_push": true}`.
+9 -6
View File
@@ -179,9 +179,12 @@ func alertingWarnings(getenv func(string) string) []string {
return out return out
} }
func buildNotifier() audit.Notifier { // buildNotifier returns the ntfy notifier for one alert source, titled so
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil { // the source is recognisable (hyperguild#163), or nil when ntfy is not
return n // configured.
func buildNotifier(getenv func(string) string, title string) audit.Notifier {
if n := audit.NewNtfyNotifier(getenv("BRAIN_NTFY_URL"), getenv("BRAIN_NTFY_TOKEN")); n != nil {
return n.WithTitle(title)
} }
return nil return nil
} }
@@ -203,7 +206,7 @@ func buildAuditSink(ctx context.Context, brainDir string, logger *slog.Logger) c
logger.Error("capture audit buffer init", "err", err) logger.Error("capture audit buffer init", "err", err)
os.Exit(1) os.Exit(1)
} }
notifier := buildNotifier() notifier := buildNotifier(os.Getenv, "brain-capture audit degraded")
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval) audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds())) logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
@@ -366,7 +369,7 @@ func main() {
ctx := context.Background() ctx := context.Background()
// hyperguild#157: report a symlink that appears in the brain while running. // hyperguild#157: report a symlink that appears in the brain while running.
go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier()) go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier(os.Getenv, "brain symlink tripwire"))
// Never fatal on an unreachable issuer (#83): on a cold cluster boot this // Never fatal on an unreachable issuer (#83): on a cold cluster boot this
// pod can beat dex to serving and get a 502 from ingress, and exiting meant // pod can beat dex to serving and get a 502 from ingress, and exiting meant
@@ -546,7 +549,7 @@ func main() {
// capture already gets, plus an out-of-band alert. Wired here because // capture already gets, plus an out-of-band alert. Wired here because
// brain_edit_root needs the capture plumbing anyway — that is what // brain_edit_root needs the capture plumbing anyway — that is what
// re-derives the static principal its gate checks. // re-derives the static principal its gate checks.
mcpSrv.WithGovernanceObserver(auditSink, buildNotifier()) mcpSrv.WithGovernanceObserver(auditSink, buildNotifier(os.Getenv, "brain governance edit"))
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign), logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign),
"governance_observer", true) "governance_observer", true)
} else { } else {
+13
View File
@@ -159,3 +159,16 @@ func TestBuildMuxObservesRequestsAndServesMetrics(t *testing.T) {
require.Equal(t, http.StatusOK, code) require.Equal(t, http.StatusOK, code)
assert.Contains(t, body, `path="GET /pending"`) assert.Contains(t, body, `path="GET /pending"`)
} }
// hyperguild#185: an authenticated request to an unregistered path reached
// the inner REST mux, which left Pattern empty, and the metrics label fell
// back to the raw URL path: one Prometheus series per unique path any token
// holder sends. It is now labelled with a fixed catch-all.
func TestBuildMuxLabelsAnUnregisteredPathWithAFixedLabel(t *testing.T) {
r := testMux(t)
code, _ := do(t, r, "GET", "/zqrandom-185-segment", "Bearer "+routesTok)
require.Equal(t, http.StatusNotFound, code)
_, body := do(t, r, "GET", "/metrics", "")
assert.NotContains(t, body, "zqrandom-185-segment", "the raw path became a metrics label")
assert.Contains(t, body, `path="unmatched"`)
}
@@ -1,6 +1,9 @@
package main package main
import ( import (
"context"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -74,3 +77,16 @@ func TestStartupSymlinkCheckRefusesABrainWithAFIFO(t *testing.T) {
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "classification.yaml") assert.Contains(t, err.Error(), "classification.yaml")
} }
// hyperguild#163: each alert source gets its own ntfy title.
func TestBuildNotifierUsesTheGivenTitle(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.Header.Get("Title") }))
defer srv.Close()
env := map[string]string{"BRAIN_NTFY_URL": srv.URL}
n := buildNotifier(func(k string) string { return env[k] }, "brain symlink tripwire")
require.NotNil(t, n)
require.NoError(t, n.Notify(context.Background(), "m"))
assert.Equal(t, "brain symlink tripwire", got)
assert.Nil(t, buildNotifier(func(string) string { return "" }, "x"), "no ntfy URL, no notifier")
}
+20 -1
View File
@@ -13,9 +13,14 @@ import (
type NtfyNotifier struct { type NtfyNotifier struct {
topicURL string topicURL string
token string token string
title string
http *http.Client http *http.Client
} }
// defaultNtfyTitle is the title of a notifier built without WithTitle: the
// capture audit's degraded-state alert, its first user.
const defaultNtfyTitle = "brain-capture audit degraded"
// NewNtfyNotifier constructs a notifier for the given ntfy topic URL // NewNtfyNotifier constructs a notifier for the given ntfy topic URL
// (e.g. https://ntfy.sh/my-topic). token is an optional bearer for // (e.g. https://ntfy.sh/my-topic). token is an optional bearer for
// protected ntfy instances; it is held here and only sent in the // protected ntfy instances; it is held here and only sent in the
@@ -27,17 +32,31 @@ func NewNtfyNotifier(topicURL, token string) *NtfyNotifier {
return &NtfyNotifier{ return &NtfyNotifier{
topicURL: strings.TrimRight(topicURL, "/"), topicURL: strings.TrimRight(topicURL, "/"),
token: token, token: token,
title: defaultNtfyTitle,
http: &http.Client{Timeout: 10 * time.Second}, http: &http.Client{Timeout: 10 * time.Second},
} }
} }
// WithTitle returns a copy that sends title as the ntfy Title header, so each
// alert source (the capture audit, the symlink tripwire, governance edits)
// is recognisable on the phone (hyperguild#163). A nil notifier (ntfy not
// configured) stays nil.
func (n *NtfyNotifier) WithTitle(title string) *NtfyNotifier {
if n == nil {
return nil
}
c := *n
c.title = title
return &c
}
// Notify posts a message to the ntfy topic. // Notify posts a message to the ntfy topic.
func (n *NtfyNotifier) Notify(ctx context.Context, msg string) error { func (n *NtfyNotifier) Notify(ctx context.Context, msg string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.topicURL, strings.NewReader(msg)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.topicURL, strings.NewReader(msg))
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("Title", "brain-capture audit degraded") req.Header.Set("Title", n.title)
req.Header.Set("Priority", "high") req.Header.Set("Priority", "high")
req.Header.Set("Tags", "warning,brain") req.Header.Set("Tags", "warning,brain")
if n.token != "" { if n.token != "" {
@@ -0,0 +1,29 @@
package audit
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hyperguild#163: every alert went out titled "brain-capture audit
// degraded", including the symlink tripwire's and governance alerts.
func TestNtfyNotifierSendsItsOwnTitle(t *testing.T) {
var got []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = append(got, r.Header.Get("Title"))
}))
defer srv.Close()
base := NewNtfyNotifier(srv.URL, "")
require.NoError(t, base.Notify(context.Background(), "m"))
require.NoError(t, base.WithTitle("brain symlink tripwire").Notify(context.Background(), "m"))
require.NoError(t, base.Notify(context.Background(), "m"))
assert.Equal(t, []string{"brain-capture audit degraded", "brain symlink tripwire", "brain-capture audit degraded"}, got,
"WithTitle returns a copy; the base keeps its default title")
var nilN *NtfyNotifier
assert.Nil(t, nilN.WithTitle("x"), "nil (ntfy not configured) stays nil")
}
+4 -3
View File
@@ -83,8 +83,9 @@ func (r *Registry) Observe(path, status string, d time.Duration) {
// Middleware wraps next, observing every request's duration + status. // Middleware wraps next, observing every request's duration + status.
// The metric label `path` uses the request's Pattern (Go 1.22+ ServeMux), // The metric label `path` uses the request's Pattern (Go 1.22+ ServeMux),
// falling back to the URL path if no Pattern is set. Pattern keeps // or the fixed label "unmatched" if no Pattern is set: falling back to the
// cardinality bounded (one series per route, not one per unique URL). // raw URL path gave any caller one series per unique path (hyperguild#185).
// Pattern keeps cardinality bounded (one series per route).
func (r *Registry) Middleware(next http.Handler) http.Handler { func (r *Registry) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
rec := &statusRecorder{ResponseWriter: w, code: http.StatusOK} rec := &statusRecorder{ResponseWriter: w, code: http.StatusOK}
@@ -92,7 +93,7 @@ func (r *Registry) Middleware(next http.Handler) http.Handler {
next.ServeHTTP(rec, req) next.ServeHTTP(rec, req)
path := req.Pattern path := req.Pattern
if path == "" { if path == "" {
path = req.URL.Path path = "unmatched"
} }
r.Observe(path, statusClass(rec.code), time.Since(start)) r.Observe(path, statusClass(rec.code), time.Since(start))
}) })
+23 -2
View File
@@ -71,11 +71,16 @@ func TestMiddleware_RecordsTiming(t *testing.T) {
t.Parallel() t.Parallel()
r := New() r := New()
handler := r.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { // Routed through a ServeMux, as in production: the mux sets the
// request's Pattern, which is the label (an unrouted request is
// "unmatched", hyperguild#185).
mux := http.NewServeMux()
mux.HandleFunc("/query", func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(2 * time.Millisecond) time.Sleep(2 * time.Millisecond)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok") _, _ = io.WriteString(w, "ok")
})) })
handler := r.Middleware(mux)
srv := httptest.NewServer(handler) srv := httptest.NewServer(handler)
defer srv.Close() defer srv.Close()
@@ -117,3 +122,19 @@ func TestStatusRecorder_DefaultsTo200(t *testing.T) {
t.Errorf("code %d, want 200", rec.Code) t.Errorf("code %d, want 200", rec.Code)
} }
} }
// A request with no matched Pattern is labelled "unmatched", never with its
// raw URL path (unbounded cardinality, hyperguild#185).
func TestMiddleware_LabelsAnUnmatchedRequestWithAFixedLabel(t *testing.T) {
r := New()
h := r.Middleware(http.NotFoundHandler())
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/zqrandom-185-unit", nil))
rec := httptest.NewRecorder()
r.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/metrics", nil))
if strings.Contains(rec.Body.String(), "zqrandom-185-unit") {
t.Errorf("the raw path became a metrics label:\n%s", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `path="unmatched"`) {
t.Errorf("no unmatched label:\n%s", rec.Body.String())
}
}
@@ -381,18 +381,19 @@ func TestWatchQueueIsBoundedDuringAnOutage(t *testing.T) {
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 2*time.Millisecond) require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 2*time.Millisecond)
} }
n.down.Store(false) n.down.Store(false)
require.Eventually(t, func() bool { return n.count() >= 1 }, 2*time.Second, 5*time.Millisecond) // Synchronise on the summary being delivered (hyperguild#193: a fixed
time.Sleep(100 * time.Millisecond) // sleep flaked under load); once it is, the queue has been flushed.
assert.LessOrEqual(t, n.count(), maxPending+1, "queued alerts plus at most one suppression summary") require.Eventually(t, func() bool {
n.mu.Lock() n.mu.Lock()
defer n.mu.Unlock() defer n.mu.Unlock()
found := false for _, m := range n.msgs {
for _, m := range n.msgs { if strings.Contains(m, "suppressed") {
if strings.Contains(m, "suppressed") { return true
found = true }
} }
} return false
assert.True(t, found, "suppressed alerts are counted in a summary") }, 10*time.Second, 5*time.Millisecond, "suppressed alerts are counted in a summary")
assert.LessOrEqual(t, n.count(), maxPending+1, "queued alerts plus at most one suppression summary")
} }
// Within the bound, every alert queued during an outage is delivered after // Within the bound, every alert queued during an outage is delivered after
@@ -411,11 +412,21 @@ func TestWatchDeliversEveryQueuedAlertAfterAShortOutage(t *testing.T) {
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 2*time.Millisecond) require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 2*time.Millisecond)
} }
n.down.Store(false) n.down.Store(false)
require.Eventually(t, func() bool { return n.count() == 3 }, 2*time.Second, 5*time.Millisecond) require.Eventually(t, func() bool { return n.count() >= 3 }, 10*time.Second, 5*time.Millisecond)
n.mu.Lock() n.mu.Lock()
defer n.mu.Unlock() defer n.mu.Unlock()
for i, name := range []string{"qa", "qb", "qc"} { // Each removal delivered exactly once, in any order: the outage ends
assert.Contains(t, n.msgs[i], strconv.Quote(name)) // asynchronously, possibly mid-flush, so a later item can send before an
// earlier one (hyperguild#193). Order is not what this test is about.
assert.Len(t, n.msgs, 3)
for _, name := range []string{"qa", "qb", "qc"} {
hits := 0
for _, m := range n.msgs {
if strings.Contains(m, strconv.Quote(name)) {
hits++
}
}
assert.Equal(t, 1, hits, "%s delivered exactly once", name)
} }
} }
@@ -457,29 +468,30 @@ func TestWatchAccountsForEveryRemovalDuringAnOutage(t *testing.T) {
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 1*time.Millisecond) require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 1*time.Millisecond)
} }
n.down.Store(false) n.down.Store(false)
require.Eventually(t, func() bool { // Synchronise on the record being complete, not on a fixed sleep after
// the first summary (hyperguild#193): sum every "removed N" alert and
// every suppression count until they account for all plants.
account := func() (delivered, counted int) {
n.mu.Lock() n.mu.Lock()
defer n.mu.Unlock() defer n.mu.Unlock()
for _, m := range n.msgs { for _, m := range n.msgs {
if strings.Contains(m, "suppressed") { var k int
return true if i := strings.Index(m, "removed "); i >= 0 && strings.Contains(m, "symlink(s)") {
if _, err := fmt.Sscanf(m[i+len("removed "):], "%d", &k); err == nil {
delivered += k
}
}
if i := strings.Index(m, "): "); i >= 0 && strings.Contains(m, "suppressed") {
if _, err := fmt.Sscanf(m[i+3:], "%d", &k); err == nil {
counted += k
}
} }
} }
return false return delivered, counted
}, 2*time.Second, 5*time.Millisecond)
time.Sleep(50 * time.Millisecond)
n.mu.Lock()
defer n.mu.Unlock()
delivered, counted := 0, 0
for _, m := range n.msgs {
if strings.Contains(m, "removed 1 symlink(s)") {
delivered++
}
if i := strings.Index(m, "): "); i >= 0 && strings.Contains(m, "suppressed") {
_, _ = fmt.Sscanf(m[i+3:], "%d", &counted)
}
} }
assert.Equal(t, plants, delivered+counted, "delivered %d + counted %d must equal %d removals", delivered, counted, plants) require.Eventually(t, func() bool { d, c := account(); return d+c >= plants }, 10*time.Second, 5*time.Millisecond)
d, c := account()
assert.Equal(t, plants, d+c, "delivered %d + counted %d must equal %d removals", d, c, plants)
} }
// A poison message is still dropped (others send while it fails), and the // A poison message is still dropped (others send while it fails), and the
+62 -7
View File
@@ -9,6 +9,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time" "time"
"unicode" "unicode"
@@ -21,11 +22,19 @@ type Config struct {
BrainDir string BrainDir string
Interval time.Duration Interval time.Duration
Pipeline pipeline.Config 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
} }
// Start launches the watcher in a background goroutine. // Start launches the watcher in a background goroutine.
// It returns immediately. The watcher stops when ctx is cancelled. // It returns immediately. The watcher stops when ctx is cancelled.
func Start(ctx context.Context, cfg Config) { func Start(ctx context.Context, cfg Config) {
if cfg.stuck == nil {
cfg.stuck = &sync.Map{}
}
go func() { go func() {
ticker := time.NewTicker(cfg.Interval) ticker := time.NewTicker(cfg.Interval)
defer ticker.Stop() defer ticker.Stop()
@@ -108,6 +117,12 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
return nil 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. // Skip files that have already been processed or permanently failed.
if _, err := os.Stat(path + ".processed"); err == nil { if _, err := os.Stat(path + ".processed"); err == nil {
return nil return nil
@@ -127,9 +142,12 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
return errs return errs
} }
// processFile reads a file, calls pipeline.Run, copies it to processed/ or failed/, // processFile reads a file, calls pipeline.Run, and archives it into processed/
// and writes a marker file next to the original so the watcher skips it next poll. // or failed/. The original is then removed from raw/ so that raw/ only ever
// The original file is never deleted, keeping Syncthing-connected vaults (e.g. Obsidian) intact. // 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 { func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, date string) error {
filename := filepath.Base(path) filename := filepath.Base(path)
source := deriveSource(filename) source := deriveSource(filename)
@@ -138,6 +156,10 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
return fmt.Errorf("relative path: %w", err) 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) content, err := extract.TextIn(root, rel)
if err != nil { if err != nil {
return fmt.Errorf("extract text: %w", err) return fmt.Errorf("extract text: %w", err)
@@ -154,9 +176,11 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
if cpErr := copyFromRoot(root, rel, dest); cpErr != nil { if cpErr := copyFromRoot(root, rel, dest); cpErr != nil {
return fmt.Errorf("copy to failed: %w", cpErr) return fmt.Errorf("copy to failed: %w", cpErr)
} }
if mkErr := os.WriteFile(path+".failed", []byte(runErr.Error()), 0o644); mkErr != nil { if changedSince(root, rel, before) {
slog.Error("watcher: failed to write .failed marker", "error", mkErr) 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) slog.Warn("watcher: file failed", "file", filename, "error", runErr)
@@ -182,14 +206,45 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
if err := copyFromRoot(root, rel, dest); err != nil { if err := copyFromRoot(root, rel, dest); err != nil {
return fmt.Errorf("copy to processed: %w", err) return fmt.Errorf("copy to processed: %w", err)
} }
if err := os.WriteFile(path+".processed", []byte(date), 0o644); err != nil { if changedSince(root, rel, before) {
slog.Error("watcher: failed to write .processed marker", "error", err) 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) slog.Info("watcher: file processed", "file", filename, "source", source)
return nil 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. // deriveSource turns a filename into a human-readable source name.
// "shape-up-book.md" → "Shape Up Book" // "shape-up-book.md" → "Shape Up Book"
func deriveSource(filename string) string { func deriveSource(filename string) string {
+90 -10
View File
@@ -89,13 +89,12 @@ func TestStart_ProcessesFile(t *testing.T) {
} }
require.True(t, found, "file should be copied to processed/") require.True(t, found, "file should be copied to processed/")
// Original file should still exist (copy, not move — keeps Obsidian vault intact). // 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) _, err := os.Stat(rawFile)
assert.NoError(t, err, "original file should remain in raw/") assert.ErrorIs(t, err, os.ErrNotExist, "original should be removed from raw/ once archived")
// A .processed marker should exist next to the original.
_, err = os.Stat(rawFile + ".processed") _, err = os.Stat(rawFile + ".processed")
assert.NoError(t, err, ".processed marker should be written") assert.ErrorIs(t, err, os.ErrNotExist, "no .processed marker: the archive copy is the record")
// Wiki page should exist. // Wiki page should exist.
wikiPath := filepath.Join(brainDir, "wiki", "sources", "shape-up-book.md") wikiPath := filepath.Join(brainDir, "wiki", "sources", "shape-up-book.md")
@@ -142,13 +141,11 @@ func TestStart_MovesToFailedOnError(t *testing.T) {
} }
require.True(t, found, "file should be copied to failed/") require.True(t, found, "file should be copied to failed/")
// Original file should still exist (copy, not move — keeps Obsidian vault intact). // Inbox zero: a quarantined original leaves raw/ too, no marker beside it.
_, err := os.Stat(rawFile) _, err := os.Stat(rawFile)
assert.NoError(t, err, "original file should remain in raw/") assert.ErrorIs(t, err, os.ErrNotExist, "original should be removed from raw/ once quarantined")
// A .failed marker should exist next to the original.
_, err = os.Stat(rawFile + ".failed") _, err = os.Stat(rawFile + ".failed")
assert.NoError(t, err, ".failed marker should be written") assert.ErrorIs(t, err, os.ErrNotExist, "no .failed marker: failed/ is the record")
// log.md should contain a watcher error entry. // log.md should contain a watcher error entry.
logContent, err := os.ReadFile(filepath.Join(brainDir, "log.md")) logContent, err := os.ReadFile(filepath.Join(brainDir, "log.md"))
@@ -390,3 +387,86 @@ func TestProcessDir_DoesNotHangUnderAFIFOSwapRace(t *testing.T) {
t.Fatalf("the watcher hung on a swapped-in FIFO after %d iterations", iters.Load()) 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",
},
stuck: &sync.Map{},
}
for i := 0; i < 3; i++ {
processDir(context.Background(), cfg, "2026-10-19")
}
assert.Equal(t, int32(1), calls.Load(), "a note that cannot be retired is ingested once, not once per poll")
}
// 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))
}