Compare commits

...
Author SHA1 Message Date
mathias 2212ceedda Merge pull request 'feat(watcher): inbox zero — archived raw/ originals are removed, not copied' (#206) from wt/inbox-zero into main
CI / Lint / Test / Vet (push) Successful in 1m2s
CI / Mirror to GitHub (push) Successful in 4s
2026-10-19 19:22:58 +00:00
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
9 changed files with 378 additions and 56 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
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
## `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,
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
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/`).
Organized in thematic folders:
Organized by **role**, because the role decides which rules apply (ADR-0003):
| Folder | Focus | Count |
|--------|-------|-------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
| Folder | Role | Rules that key off it |
|--------|------|-----------------------|
| `platform/` | infra, brain, hyperguild, gitea-mcp, dispatch, shared tooling | control code (rule 8), GitOps review lane |
| `products/` | tapir, cobalt-dingo, openbanking-api, ... | ordinary trunk-based work |
| `clients/` | client work | local models only, never cloud APIs (Client work rules) |
| `spikes/` | throwaway | no ceremony |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
### Key active projects
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
- **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
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.
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
is. How a session runs on koala (worktrees, rc per repo, the reaper, memory): `docs/working-on-koala.md`.
## 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
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,
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
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/`).
Organized in thematic folders:
Organized by **role**, because the role decides which rules apply (ADR-0003):
| Folder | Focus | Count |
|--------|-------|-------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
| Folder | Role | Rules that key off it |
|--------|------|-----------------------|
| `platform/` | infra, brain, hyperguild, gitea-mcp, dispatch, shared tooling | control code (rule 8), GitOps review lane |
| `products/` | tapir, cobalt-dingo, openbanking-api, ... | ordinary trunk-based work |
| `clients/` | client work | local models only, never cloud APIs (Client work rules) |
| `spikes/` | throwaway | no ceremony |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
### Key active projects
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
- **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
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.
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
is. How a session runs on koala (worktrees, rc per repo, the reaper, memory): `docs/working-on-koala.md`.
## 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
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
## `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
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
## `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}`.
+13
View File
@@ -159,3 +159,16 @@ func TestBuildMuxObservesRequestsAndServesMetrics(t *testing.T) {
require.Equal(t, http.StatusOK, code)
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"`)
}
+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.
// 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
// cardinality bounded (one series per route, not one per unique URL).
// or the fixed label "unmatched" if no Pattern is set: falling back to the
// 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 {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
rec := &statusRecorder{ResponseWriter: w, code: http.StatusOK}
@@ -92,7 +93,7 @@ func (r *Registry) Middleware(next http.Handler) http.Handler {
next.ServeHTTP(rec, req)
path := req.Pattern
if path == "" {
path = req.URL.Path
path = "unmatched"
}
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()
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)
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok")
}))
})
handler := r.Middleware(mux)
srv := httptest.NewServer(handler)
defer srv.Close()
@@ -117,3 +122,19 @@ func TestStatusRecorder_DefaultsTo200(t *testing.T) {
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())
}
}
+62 -7
View File
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"unicode"
@@ -21,11 +22,19 @@ 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
}
// 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()
@@ -108,6 +117,12 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
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
@@ -127,9 +142,12 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
return errs
}
// processFile reads a file, calls pipeline.Run, copies it to processed/ or failed/,
// and writes a marker file next to the original so the watcher skips it next poll.
// The original file is never deleted, keeping Syncthing-connected vaults (e.g. Obsidian) intact.
// 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)
@@ -138,6 +156,10 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
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)
@@ -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 {
return fmt.Errorf("copy to failed: %w", cpErr)
}
if mkErr := os.WriteFile(path+".failed", []byte(runErr.Error()), 0o644); mkErr != nil {
slog.Error("watcher: failed to write .failed marker", "error", mkErr)
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)
@@ -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 {
return fmt.Errorf("copy to processed: %w", err)
}
if err := os.WriteFile(path+".processed", []byte(date), 0o644); err != nil {
slog.Error("watcher: failed to write .processed marker", "error", 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 {
+90 -10
View File
@@ -89,13 +89,12 @@ func TestStart_ProcessesFile(t *testing.T) {
}
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)
assert.NoError(t, err, "original file should remain in raw/")
// A .processed marker should exist next to the original.
assert.ErrorIs(t, err, os.ErrNotExist, "original should be removed from raw/ once archived")
_, 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.
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/")
// 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)
assert.NoError(t, err, "original file should remain in raw/")
// A .failed marker should exist next to the original.
assert.ErrorIs(t, err, os.ErrNotExist, "original should be removed from raw/ once quarantined")
_, 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.
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())
}
}
// 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))
}