Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2212ceedda | ||
|
|
e6e14102ee | ||
|
|
97ec9e7ce8 | ||
|
|
87ae782527 | ||
|
|
6bf61d75c0 | ||
|
|
18d01b64d7 | ||
|
|
5dfd2c0f7e | ||
|
|
d6ddac4a03 |
@@ -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
@@ -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}`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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}`.
|
||||||
|
|||||||
@@ -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}`.
|
||||||
|
|||||||
@@ -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"`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user