Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c7fcb6bdc | ||
|
|
4a1abafd8f |
@@ -84,15 +84,3 @@ 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}`.
|
|
||||||
|
|||||||
+17
-81
@@ -164,63 +164,6 @@ 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`):
|
||||||
@@ -301,20 +244,25 @@ 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 by **role**, because the role decides which rules apply (ADR-0003):
|
Organized in thematic folders:
|
||||||
|
|
||||||
| Folder | Role | Rules that key off it |
|
| Folder | Focus | Count |
|
||||||
|--------|------|-----------------------|
|
|--------|-------|-------|
|
||||||
| `platform/` | infra, brain, hyperguild, gitea-mcp, dispatch, shared tooling | control code (rule 8), GitOps review lane |
|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
|
||||||
| `products/` | tapir, cobalt-dingo, openbanking-api, ... | ordinary trunk-based work |
|
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
|
||||||
| `clients/` | client work | local models only, never cloud APIs (Client work rules) |
|
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
|
||||||
| `spikes/` | throwaway | no ceremony |
|
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
|
||||||
|
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
|
||||||
|
|
||||||
New projects are born into a role folder (`task new-project -- <name> <client|personal>
|
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
||||||
<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
|
### Key active projects
|
||||||
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`.
|
- **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
|
||||||
|
|
||||||
## Knowledge base — actively use it
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
@@ -566,16 +514,4 @@ 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,63 +159,6 @@ 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`):
|
||||||
@@ -296,20 +239,25 @@ 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 by **role**, because the role decides which rules apply (ADR-0003):
|
Organized in thematic folders:
|
||||||
|
|
||||||
| Folder | Role | Rules that key off it |
|
| Folder | Focus | Count |
|
||||||
|--------|------|-----------------------|
|
|--------|-------|-------|
|
||||||
| `platform/` | infra, brain, hyperguild, gitea-mcp, dispatch, shared tooling | control code (rule 8), GitOps review lane |
|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
|
||||||
| `products/` | tapir, cobalt-dingo, openbanking-api, ... | ordinary trunk-based work |
|
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
|
||||||
| `clients/` | client work | local models only, never cloud APIs (Client work rules) |
|
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
|
||||||
| `spikes/` | throwaway | no ceremony |
|
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
|
||||||
|
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
|
||||||
|
|
||||||
New projects are born into a role folder (`task new-project -- <name> <client|personal>
|
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
||||||
<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
|
### Key active projects
|
||||||
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`.
|
- **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
|
||||||
|
|
||||||
## Knowledge base — actively use it
|
## Knowledge base — actively use it
|
||||||
|
|
||||||
@@ -560,15 +508,3 @@ 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,15 +84,3 @@ 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}`.
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
# The raw/ inbox
|
|
||||||
|
|
||||||
`raw/` is an inbox: only unprocessed material sits there. Syncthing shares it
|
|
||||||
with the machines you drop files from. (hyperguild#205, #208, #209)
|
|
||||||
|
|
||||||
## What happens to a file you drop
|
|
||||||
|
|
||||||
| You drop | Result |
|
|
||||||
|---|---|
|
|
||||||
| `raw/clip.md` (no folder, no `wing:`) | extracted and filed as a source in `wiki/web-clip/sources/`; its concepts and entities update the shared `wiki/concepts/` and `wiki/entities/` pages; the original moves to `raw/processed/<date>/`, so `raw/` is empty again |
|
|
||||||
| `raw/<wing>/clip.md` or `wing: <wing>` in the frontmatter | the same, with the source in `wiki/<wing>/sources/`. The wing must already exist; a typo is parked, never created. This is your deliberate choice and is not second-guessed |
|
|
||||||
| a note whose text matches the client-content pattern, with no folder | parked in `raw/needs-review/`, not extracted |
|
|
||||||
| a folder or `wing:` naming an unknown, reserved or non-ASCII wing, or two disagreeing signals | parked in `raw/needs-review/` |
|
|
||||||
| a note over the size cap, or one that cannot be archived | stays in `raw/`, reported once, not retried until it changes |
|
|
||||||
| a note the model returned nothing usable for (no source page could be written) | fails: the note is kept in `raw/failed/` and the reason is in `log.md`; it is never archived as processed |
|
|
||||||
|
|
||||||
Concepts and entities are shared across wings because their slug is the graph's
|
|
||||||
identity. A source whose slug is taken gets a wing-qualified one (`notes-homelab`).
|
|
||||||
|
|
||||||
## Parked notes
|
|
||||||
|
|
||||||
`brain_pending` lists them with `queue: needs-review`, a proposed wing and the
|
|
||||||
reason. To act on one: `brain_promote` / `brain_reject` (pass `queue` when the
|
|
||||||
same name also sits in the inbox), or move the file into `raw/<wing>/` to have it
|
|
||||||
extracted. Parked pdf/txt files are listed but can only be moved or rejected.
|
|
||||||
|
|
||||||
## Switching it on
|
|
||||||
|
|
||||||
Routing is off by default (the retired layout is used). Set on the ingestion
|
|
||||||
deployment:
|
|
||||||
|
|
||||||
| Variable | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `INGEST_WING_ROUTING` | `1`/`true` enables the rules above; unset or `0`/`false` is off; any other value stops the server at startup |
|
|
||||||
| `INGEST_DEFAULT_WING` | wing for unsignalled notes (default `web-clip`; must be a plain name, not `sources`/`concepts`/`entities`/`wiki`) |
|
|
||||||
| `INGEST_CLIENT_BLOCK` | case-insensitive regexp over note text; a match parks an unsignalled note. Falls back to `CLAUDE_INGEST_CLIENT_BLOCK`. Matched after folding fullwidth letters, removing zero-width characters, normalising dashes and removing whitespace. Patterns with spaces, `\\s` or `\\b` work (whitespace is collapsed in one view and removed in another). It catches what it names and nothing else: leetspeak, punctuation splits (`a.c.m.e`), encodings and homoglyphs outside a small Cyrillic/Greek table are not caught. Large notes are checked in 1 MiB chunks |
|
|
||||||
|
|
||||||
A bad default or pattern stops the server at startup. A wing created while the
|
|
||||||
server runs is usable without a restart.
|
|
||||||
|
|
||||||
## Known limits
|
|
||||||
|
|
||||||
- The client filter works on 1 MiB chunks that overlap by 4 KiB. A match that can
|
|
||||||
span more than 4 KiB (crafted padding between two words of a multi-word
|
|
||||||
pattern, exactly at a chunk edge) is not guaranteed to be found; this affects
|
|
||||||
notes over 1 MiB only. On a 64 MiB note the check takes about ten seconds.
|
|
||||||
- A pattern that matches the empty string (`.*`, `x*`) parks every unsignalled
|
|
||||||
note. That fails closed and only the operator's own configuration can do it.
|
|
||||||
|
|
||||||
- A deliberate folder into a sensitive wing places the note there, and the shared
|
|
||||||
concept/entity pages then cite that source by title. Client material does not
|
|
||||||
belong in the brain at all; the pattern only guards the unsignalled lane.
|
|
||||||
- A merge into a shared page refreshes `last_updated` and `origin` to the latest
|
|
||||||
ingest; the first wing's attribution is not kept.
|
|
||||||
- A marker file (`<note>.processed`) is only written when a processed original
|
|
||||||
cannot be removed. One at least as new as its note holds the note (and says
|
|
||||||
so once); an older or future-dated one is ignored and removed.
|
|
||||||
@@ -17,7 +17,6 @@ import (
|
|||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
@@ -37,7 +36,6 @@ import (
|
|||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/webhook"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/webhook"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
|
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
"k8s.io/client-go/tools/clientcmd"
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
@@ -91,15 +89,11 @@ func (s *claudeSink) Ingest(ctx context.Context, b claudewatcher.Batch) error {
|
|||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
slug := "session-" + b.Host + "-" + b.SessionID
|
slug := "session-" + b.Host + "-" + b.SessionID
|
||||||
dest := "archive/claude-sessions/" + b.Host + "/" + slug + ".md"
|
dest := filepath.Join(s.brainDir, "archive", "claude-sessions", b.Host, slug+".md")
|
||||||
// Written through the brain root, never through a symlinked directory
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
// or file (hyperguild#158).
|
return fmt.Errorf("create claude-sessions archive dir: %w", err)
|
||||||
bfs, err := brainfs.Open(s.brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open brain: %w", err)
|
|
||||||
}
|
}
|
||||||
defer func() { _ = bfs.Close() }()
|
if err := os.WriteFile(dest, []byte(sb.String()), 0o644); err != nil {
|
||||||
if err := bfs.PutRegular(dest, []byte(sb.String())); err != nil {
|
|
||||||
return fmt.Errorf("write claude session archive: %w", err)
|
return fmt.Errorf("write claude session archive: %w", err)
|
||||||
}
|
}
|
||||||
s.logger.Debug("claude session archived (non-indexed)", "path", dest)
|
s.logger.Debug("claude session archived (non-indexed)", "path", dest)
|
||||||
@@ -150,21 +144,6 @@ func envInt(key string, fallback int) int {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// envFlag reads a boolean switch. Unset is off; a value strconv.ParseBool does
|
|
||||||
// not understand is an error, so a typo ("ture") cannot silently leave a
|
|
||||||
// safety-relevant switch off.
|
|
||||||
func envFlag(key string) (bool, error) {
|
|
||||||
v := os.Getenv(key)
|
|
||||||
if v == "" {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
b, err := strconv.ParseBool(v)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("%s=%q: want 1, 0, true or false", key, v)
|
|
||||||
}
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildNotifier returns the ntfy notifier, or a nil interface when
|
// buildNotifier returns the ntfy notifier, or a nil interface when
|
||||||
// BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so
|
// BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so
|
||||||
// DegradingSink/Reconcile and the governance observer skip it cleanly.
|
// DegradingSink/Reconcile and the governance observer skip it cleanly.
|
||||||
@@ -200,12 +179,9 @@ func alertingWarnings(getenv func(string) string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildNotifier returns the ntfy notifier for one alert source, titled so
|
func buildNotifier() audit.Notifier {
|
||||||
// the source is recognisable (hyperguild#163), or nil when ntfy is not
|
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
||||||
// configured.
|
return n
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -227,7 +203,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(os.Getenv, "brain-capture audit degraded")
|
notifier := buildNotifier()
|
||||||
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()))
|
||||||
@@ -390,7 +366,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(os.Getenv, "brain symlink tripwire"))
|
go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier())
|
||||||
|
|
||||||
// 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
|
||||||
@@ -410,39 +386,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if watchInterval > 0 {
|
if watchInterval > 0 {
|
||||||
// INGEST_WING_ROUTING=1 files raw/ notes by rule (hyperguild#208): a
|
|
||||||
// deliberate raw/<wing>/ folder or `wing:` frontmatter, else the default
|
|
||||||
// wing (INGEST_DEFAULT_WING, "web-clip"); a note whose text matches the
|
|
||||||
// client-content pattern (INGEST_CLIENT_BLOCK, else
|
|
||||||
// CLAUDE_INGEST_CLIENT_BLOCK) or that names an unknown wing is parked in
|
|
||||||
// raw/needs-review/. Off = the retired layout. Set explicitly, a bad
|
|
||||||
// default or pattern is fatal: silently falling back would write into
|
|
||||||
// the layout this flag exists to leave. The interface stays nil when off
|
|
||||||
// (a nil *Router inside it would not be nil).
|
|
||||||
var placer wing.Placer
|
|
||||||
routing, ferr := envFlag("INGEST_WING_ROUTING")
|
|
||||||
if ferr != nil {
|
|
||||||
logger.Error("INGEST_WING_ROUTING", "err", ferr)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if routing {
|
|
||||||
pattern := os.Getenv("INGEST_CLIENT_BLOCK")
|
|
||||||
if pattern == "" {
|
|
||||||
pattern = os.Getenv("CLAUDE_INGEST_CLIENT_BLOCK")
|
|
||||||
}
|
|
||||||
router, rerr := wing.NewRouter(brainDir, envOr("INGEST_DEFAULT_WING", "web-clip"), pattern)
|
|
||||||
if rerr != nil {
|
|
||||||
logger.Error("build wing router", "err", rerr)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
placer = router
|
|
||||||
logger.Info("wing routing enabled", "default_wing", router.Default, "client_filter", router.ClientBlock != nil)
|
|
||||||
}
|
|
||||||
watcher.Start(ctx, watcher.Config{
|
watcher.Start(ctx, watcher.Config{
|
||||||
BrainDir: brainDir,
|
BrainDir: brainDir,
|
||||||
Interval: time.Duration(watchInterval) * time.Second,
|
Interval: time.Duration(watchInterval) * time.Second,
|
||||||
Pipeline: pipelineCfg,
|
Pipeline: pipelineCfg,
|
||||||
Resolver: placer,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,7 +546,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(os.Getenv, "brain governance edit"))
|
mcpSrv.WithGovernanceObserver(auditSink, buildNotifier())
|
||||||
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 {
|
||||||
|
|||||||
@@ -76,56 +76,3 @@ func TestAlertingWarnings(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// hyperguild#158: the claude-session archive was written with os.MkdirAll +
|
|
||||||
// os.WriteFile by path, so a symlinked archive directory redirected it.
|
|
||||||
func TestClaudeSinkDoesNotWriteThroughASymlinkedArchiveDir(t *testing.T) {
|
|
||||||
for name, mk := range map[string]func(t *testing.T, dir string) (link, target string){
|
|
||||||
"outside": func(t *testing.T, _ string) (string, string) { o := t.TempDir(); return o, o },
|
|
||||||
"in-brain": func(t *testing.T, dir string) (string, string) {
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
|
|
||||||
return filepath.Join("..", "raw"), filepath.Join(dir, "raw")
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
link, target := mk(t, dir)
|
|
||||||
require.NoError(t, os.Symlink(link, filepath.Join(dir, "archive")))
|
|
||||||
sink := &claudeSink{brainDir: dir, logger: slog.New(slog.NewTextHandler(os.Stderr, nil))}
|
|
||||||
err := sink.Ingest(context.Background(), claudewatcher.Batch{Host: "koala", SessionID: "abc",
|
|
||||||
Turns: []claudewatcher.Turn{{Type: "assistant", Content: "x"}}})
|
|
||||||
assert.Error(t, err)
|
|
||||||
ents, _ := os.ReadDir(target)
|
|
||||||
assert.Empty(t, ents, "the archive was written at the link's target")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnvFlag(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name, set string
|
|
||||||
want bool
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{"unset is off", "", false, false},
|
|
||||||
{"1", "1", true, false},
|
|
||||||
{"true", "true", true, false},
|
|
||||||
{"TRUE", "TRUE", true, false},
|
|
||||||
{"0", "0", false, false},
|
|
||||||
{"false", "false", false, false},
|
|
||||||
{"a typo is an error, not silently off", "ture", false, true},
|
|
||||||
{"yes is not a bool here", "yes", false, true},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Setenv("TEST_ENV_FLAG", tt.set)
|
|
||||||
got, err := envFlag("TEST_ENV_FLAG")
|
|
||||||
if tt.wantErr {
|
|
||||||
assert.Error(t, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.want, got)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -159,16 +159,3 @@ 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,13 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -67,26 +63,3 @@ func TestStartupSymlinkCheckRefusesAnUnlistableBrainRoot(t *testing.T) {
|
|||||||
t.Cleanup(func() { _ = os.Chmod(brain, 0o755) })
|
t.Cleanup(func() { _ = os.Chmod(brain, 0o755) })
|
||||||
assert.Error(t, startupSymlinkCheck(brain))
|
assert.Error(t, startupSymlinkCheck(brain))
|
||||||
}
|
}
|
||||||
|
|
||||||
// hyperguild#179: a FIFO in the brain is refused at startup with a clear
|
|
||||||
// message, instead of hanging or exiting later in a reader.
|
|
||||||
func TestStartupSymlinkCheckRefusesABrainWithAFIFO(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
require.NoError(t, syscall.Mkfifo(filepath.Join(brain, "classification.yaml"), 0o644))
|
|
||||||
err := startupSymlinkCheck(brain)
|
|
||||||
require.Error(t, err)
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-2
@@ -68,7 +68,7 @@ require (
|
|||||||
github.com/segmentio/asm v1.2.0 // indirect
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
golang.org/x/crypto v0.51.0 // indirect
|
golang.org/x/crypto v0.51.0 // indirect
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.45.0
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
golang.org/x/text v0.39.0
|
golang.org/x/text v0.39.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -83,18 +83,6 @@ func (f *frontmatter) get(key string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// drop removes every `key: value` line for key (nothing else), so a
|
|
||||||
// machine-written key does not travel with a note into the wiki.
|
|
||||||
func (f *frontmatter) drop(key string) {
|
|
||||||
kept := f.lines[:0]
|
|
||||||
for _, l := range f.lines {
|
|
||||||
if l.key != key {
|
|
||||||
kept = append(kept, l)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
f.lines = kept
|
|
||||||
}
|
|
||||||
|
|
||||||
// set overrides the value for an existing key in place, or appends a new
|
// set overrides the value for an existing key in place, or appends a new
|
||||||
// `key: value` line when the key is absent.
|
// `key: value` line when the key is absent.
|
||||||
func (f *frontmatter) set(key, value string) {
|
func (f *frontmatter) set(key, value string) {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
@@ -228,22 +227,14 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
// The directory is created only once the note is known to be writable:
|
// The directory is created only once the note is known to be writable:
|
||||||
// a refused write leaves nothing behind, not even an empty hall dir
|
// a refused write leaves nothing behind, not even an empty hall dir
|
||||||
// (brain#21; provenance refusal joins the envelope refusal here).
|
// (brain#21; provenance refusal joins the envelope refusal here).
|
||||||
// Created through the brain root, never through a symlinked wing or
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
// hall directory (hyperguild#188).
|
return "", fmt.Errorf("create hall dir: %w", err)
|
||||||
rel, err := filepath.Rel(brainDir, dest)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("note path: %w", err)
|
|
||||||
}
|
}
|
||||||
rel = filepath.ToSlash(rel)
|
if err := os.WriteFile(dest, []byte(final), 0o644); err != nil {
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("open brain: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
if err := bfs.PutRegular(rel, []byte(final)); err != nil {
|
|
||||||
return "", fmt.Errorf("write: %w", err)
|
return "", fmt.Errorf("write: %w", err)
|
||||||
}
|
}
|
||||||
return rel, nil
|
rel, _ := filepath.Rel(brainDir, dest)
|
||||||
|
return filepath.ToSlash(rel), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// scalarValues flattens frontmatter fields to key -> scalar value for
|
// scalarValues flattens frontmatter fields to key -> scalar value for
|
||||||
@@ -617,7 +608,6 @@ type promoteRequest struct {
|
|||||||
Wing string `json:"wing"`
|
Wing string `json:"wing"`
|
||||||
Hall string `json:"hall"`
|
Hall string `json:"hall"`
|
||||||
Slug string `json:"slug,omitempty"`
|
Slug string `json:"slug,omitempty"`
|
||||||
Queue string `json:"queue,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Promote handles POST /promote — move a raw/ note into the wiki. A bad
|
// Promote handles POST /promote — move a raw/ note into the wiki. A bad
|
||||||
@@ -631,7 +621,7 @@ func (h *Handler) Promote(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Field by field: this door carries no principal, so the promoted note is
|
// Field by field: this door carries no principal, so the promoted note is
|
||||||
// stamped origin: unknown and the body cannot set provenance (#145).
|
// stamped origin: unknown and the body cannot set provenance (#145).
|
||||||
rel, err := PromoteNote(h.brainDir, PromoteOptions{
|
rel, err := PromoteNote(h.brainDir, PromoteOptions{
|
||||||
Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug, Queue: req.Queue,
|
Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func writeParked(t *testing.T, brainDir, name, content string) {
|
|
||||||
t.Helper()
|
|
||||||
dir := filepath.Join(brainDir, "raw", "needs-review")
|
|
||||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListPending_ShowsParkedNotesWithTheirProposal(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeRaw(t, dir, "fresh.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nA note not yet processed.\n")
|
|
||||||
writeParked(t, dir, "unsure.md", "---\nproposed_wing: radar\nproposal_confidence: 0.55\nproposal_reason: \"below threshold\"\ncreated_at: 2026-10-19T08:00:00Z\n---\nBody of the unsure note.\n")
|
|
||||||
|
|
||||||
got, err := ListPending(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, got, 2)
|
|
||||||
|
|
||||||
assert.Equal(t, "unsure.md", got[0].Filename, "oldest first across both queues")
|
|
||||||
assert.Equal(t, "needs-review", got[0].Queue)
|
|
||||||
assert.Equal(t, "radar", got[0].ProposedWing)
|
|
||||||
assert.Equal(t, "below threshold", got[0].ProposalReason)
|
|
||||||
assert.InDelta(t, 0.55, got[0].ProposalConfidence, 1e-9)
|
|
||||||
assert.Contains(t, got[0].Excerpt, "Body of the unsure note")
|
|
||||||
assert.NotContains(t, got[0].Excerpt, "proposed_wing", "excerpt is body, not frontmatter")
|
|
||||||
|
|
||||||
assert.Equal(t, "fresh.md", got[1].Filename)
|
|
||||||
assert.Equal(t, QueueInbox, got[1].Queue)
|
|
||||||
assert.Empty(t, got[1].ProposedWing)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListPending_ParkedSymlinkIsNotListed(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
outside := filepath.Join(t.TempDir(), "secret.md")
|
|
||||||
require.NoError(t, os.WriteFile(outside, []byte("outside"), 0o644))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755))
|
|
||||||
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "raw", "needs-review", "link.md")))
|
|
||||||
|
|
||||||
got, err := ListPending(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, got, "same symlink rule as the top level (#153)")
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,15 +20,6 @@ type PendingNote struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
SizeBytes int64 `json:"size_bytes"`
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
Excerpt string `json:"excerpt"`
|
Excerpt string `json:"excerpt"`
|
||||||
|
|
||||||
// Set only for a note parked in raw/needs-review/ by the wing resolver
|
|
||||||
// (hyperguild#205): it was not placed automatically, and the proposal says
|
|
||||||
// what the resolver would have done and why it did not. Approve by moving
|
|
||||||
// the note into raw/<wing>/ and the watcher ingests it there.
|
|
||||||
Queue string `json:"queue"`
|
|
||||||
ProposedWing string `json:"proposed_wing,omitempty"`
|
|
||||||
ProposalReason string `json:"proposal_reason,omitempty"`
|
|
||||||
ProposalConfidence float64 `json:"proposal_confidence,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// datePrefix matches a leading YYYY-MM-DD- on a raw filename, stripped when
|
// datePrefix matches a leading YYYY-MM-DD- on a raw filename, stripped when
|
||||||
@@ -48,65 +38,18 @@ func ListPending(brainDir string) ([]PendingNote, error) {
|
|||||||
return nil, fmt.Errorf("read raw dir: %w", err)
|
return nil, fmt.Errorf("read raw dir: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = root.Close() }()
|
defer func() { _ = root.Close() }()
|
||||||
|
|
||||||
out, err := listNotes(root, QueueInbox)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// raw/needs-review/ is read through its own root, so a symlinked directory
|
|
||||||
// or note there is refused exactly like at the top level (#153).
|
|
||||||
if sub, serr := root.OpenRoot("needs-review"); serr == nil {
|
|
||||||
defer func() { _ = sub.Close() }()
|
|
||||||
parked, perr := listNotes(sub, QueueNeedsReview)
|
|
||||||
if perr != nil {
|
|
||||||
return nil, perr
|
|
||||||
}
|
|
||||||
out = append(out, parked...)
|
|
||||||
}
|
|
||||||
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// listNotes lists the regular .md notes directly under root. queue labels the
|
|
||||||
// notes ("" for the top of raw/) and enables reading a wing proposal.
|
|
||||||
func listNotes(root *os.Root, queue string) ([]PendingNote, error) {
|
|
||||||
entries, err := fs.ReadDir(root.FS(), ".")
|
entries, err := fs.ReadDir(root.FS(), ".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read raw dir: %w", err)
|
return nil, fmt.Errorf("read raw dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]PendingNote, 0, len(entries))
|
out := make([]PendingNote, 0, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
isMD := strings.HasSuffix(e.Name(), ".md")
|
if !e.Type().IsRegular() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
||||||
// A parked pdf or txt is listed too (the watcher parks any supported
|
|
||||||
// file); at the top level only markdown ever was.
|
|
||||||
parkedOther := queue == QueueNeedsReview && (strings.HasSuffix(e.Name(), ".txt") || strings.HasSuffix(e.Name(), ".pdf"))
|
|
||||||
if !e.Type().IsRegular() || (!isMD && !parkedOther) || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
|
||||||
continue // directories, and symlinks whose target may be anywhere (#153)
|
continue // directories, and symlinks whose target may be anywhere (#153)
|
||||||
}
|
}
|
||||||
if !isMD {
|
|
||||||
info, ierr := root.Lstat(e.Name())
|
|
||||||
if ierr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, PendingNote{
|
|
||||||
Filename: e.Name(), CreatedAt: info.ModTime().UTC().Format(time.RFC3339), SizeBytes: info.Size(),
|
|
||||||
Excerpt: "(not markdown; the wing proposal is in log.md. Move the file into raw/<wing>/ to ingest it.)",
|
|
||||||
Queue: queue,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
raw, info, readErr := readRawNote(root, e.Name())
|
raw, info, readErr := readRawNote(root, e.Name())
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
// Not skipped: a note that cannot be read (over the cap, say) must
|
|
||||||
// not silently vanish from the queue it is waiting in.
|
|
||||||
fi, ierr := root.Lstat(e.Name())
|
|
||||||
if ierr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, PendingNote{
|
|
||||||
Filename: e.Name(), CreatedAt: fi.ModTime().UTC().Format(time.RFC3339), SizeBytes: fi.Size(),
|
|
||||||
Excerpt: "(cannot be read: " + readErr.Error() + "; promote and reject cannot act on it: delete or move it by hand)", Queue: queue,
|
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fm, body := parseFrontmatter(string(raw))
|
fm, body := parseFrontmatter(string(raw))
|
||||||
@@ -114,20 +57,14 @@ func listNotes(root *os.Root, queue string) ([]PendingNote, error) {
|
|||||||
if created == "" {
|
if created == "" {
|
||||||
created = info.ModTime().UTC().Format(time.RFC3339)
|
created = info.ModTime().UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
n := PendingNote{
|
out = append(out, PendingNote{
|
||||||
Filename: e.Name(),
|
Filename: e.Name(),
|
||||||
CreatedAt: created,
|
CreatedAt: created,
|
||||||
SizeBytes: info.Size(),
|
SizeBytes: info.Size(),
|
||||||
Excerpt: excerpt(body, 200),
|
Excerpt: excerpt(body, 200),
|
||||||
Queue: queue,
|
})
|
||||||
}
|
|
||||||
if queue == QueueNeedsReview {
|
|
||||||
n.ProposedWing = fm.get("proposed_wing")
|
|
||||||
n.ProposalReason = unquoteScalar(fm.get("proposal_reason"))
|
|
||||||
n.ProposalConfidence, _ = strconv.ParseFloat(fm.get("proposal_confidence"), 64)
|
|
||||||
}
|
|
||||||
out = append(out, n)
|
|
||||||
}
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,9 +74,6 @@ type PromoteOptions struct {
|
|||||||
Wing string
|
Wing string
|
||||||
Hall string
|
Hall string
|
||||||
Slug string // optional; defaults to Filename minus date prefix + .md
|
Slug string // optional; defaults to Filename minus date prefix + .md
|
||||||
// Queue names which listing the note is in: QueueInbox or QueueNeedsReview.
|
|
||||||
// Empty is accepted only when the name exists in exactly one of them.
|
|
||||||
Queue string
|
|
||||||
// Origin/WrittenBy re-attribute the promoted note to the promoter: the raw
|
// Origin/WrittenBy re-attribute the promoted note to the promoter: the raw
|
||||||
// note's own frontmatter claims are not provenance (hyperguild#145).
|
// note's own frontmatter claims are not provenance (hyperguild#145).
|
||||||
Origin string
|
Origin string
|
||||||
@@ -162,30 +96,21 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasSuffix(base, ".md") {
|
|
||||||
return "", fmt.Errorf("only a markdown note can be promoted; move %q into raw/<wing>/ to ingest it", base)
|
|
||||||
}
|
|
||||||
slug := opts.Slug
|
slug := opts.Slug
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "")
|
slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "")
|
||||||
}
|
}
|
||||||
// The retired layout's directory names are not wings: a note promoted into
|
|
||||||
// "sources" would land in wiki/sources/<hall>/, inside the retired dir.
|
|
||||||
switch brain.Sanitise(opts.Wing) {
|
|
||||||
case "sources", "concepts", "entities", "wiki":
|
|
||||||
return "", fmt.Errorf("%q is not a wing", opts.Wing)
|
|
||||||
}
|
|
||||||
// NotePath validates hall + wing + slug; do this before reading anything.
|
// NotePath validates hall + wing + slug; do this before reading anything.
|
||||||
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
|
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, srcRel, err := readRawSource(brainDir, base, opts.Queue)
|
src := filepath.Join(brainDir, "raw", base)
|
||||||
|
raw, err := readRawSource(brainDir, base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
src := filepath.Join(brainDir, filepath.FromSlash(srcRel))
|
|
||||||
|
|
||||||
// Collision: never silently overwrite an existing promoted note.
|
// Collision: never silently overwrite an existing promoted note.
|
||||||
if _, statErr := os.Stat(dest); statErr == nil {
|
if _, statErr := os.Stat(dest); statErr == nil {
|
||||||
@@ -194,11 +119,6 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fm, body := parseFrontmatter(string(raw))
|
fm, body := parseFrontmatter(string(raw))
|
||||||
// The resolver's proposal described where the note was going to be placed;
|
|
||||||
// once a human has placed it, it is noise (and `wing` is set below).
|
|
||||||
for _, k := range []string{"proposed_wing", "proposal_confidence", "proposal_reason"} {
|
|
||||||
fm.drop(k)
|
|
||||||
}
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
fm.set("wing", brain.Sanitise(opts.Wing))
|
fm.set("wing", brain.Sanitise(opts.Wing))
|
||||||
fm.set("hall", opts.Hall)
|
fm.set("hall", opts.Hall)
|
||||||
@@ -207,18 +127,18 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
}
|
}
|
||||||
fm.set("promoted_at", now)
|
fm.set("promoted_at", now)
|
||||||
|
|
||||||
// Write-then-delete: the source survives any write failure. Both go
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
// through the brain root (hyperguild#188).
|
return "", fmt.Errorf("create wing dir: %w", err)
|
||||||
|
}
|
||||||
|
// Write-then-delete: the source survives any write failure.
|
||||||
promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
|
promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
// Created with O_EXCL: a note that appeared after the collision check
|
if err := os.WriteFile(dest, []byte(promoted), 0o644); err != nil {
|
||||||
// above is never overwritten.
|
|
||||||
if err := createBrainFile(brainDir, dest, []byte(promoted)); err != nil {
|
|
||||||
return "", fmt.Errorf("write promoted note: %w", err)
|
return "", fmt.Errorf("write promoted note: %w", err)
|
||||||
}
|
}
|
||||||
if err := removeBrainFile(brainDir, src); err != nil {
|
if err := os.Remove(src); err != nil {
|
||||||
return "", fmt.Errorf("promoted note written but source removal failed: %w", err)
|
return "", fmt.Errorf("promoted note written but source removal failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +156,6 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
type RejectOptions struct {
|
type RejectOptions struct {
|
||||||
Filename string // basename in brain/raw/
|
Filename string // basename in brain/raw/
|
||||||
Reason string // optional
|
Reason string // optional
|
||||||
Queue string // as PromoteOptions.Queue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RejectNote records a human decision not to promote a brain/raw/ note,
|
// RejectNote records a human decision not to promote a brain/raw/ note,
|
||||||
@@ -259,11 +178,11 @@ func RejectNote(brainDir string, opts RejectOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, srcRel, err := readRawSource(brainDir, base, opts.Queue)
|
src := filepath.Join(brainDir, "raw", base)
|
||||||
|
raw, err := readRawSource(brainDir, base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
src := filepath.Join(brainDir, filepath.FromSlash(srcRel))
|
|
||||||
|
|
||||||
dest := filepath.Join(brainDir, "raw", "rejected", base)
|
dest := filepath.Join(brainDir, "raw", "rejected", base)
|
||||||
if _, statErr := os.Stat(dest); statErr == nil {
|
if _, statErr := os.Stat(dest); statErr == nil {
|
||||||
@@ -271,22 +190,20 @@ func RejectNote(brainDir string, opts RejectOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("a rejected note named %s already exists", filepath.ToSlash(rel))
|
return "", fmt.Errorf("a rejected note named %s already exists", filepath.ToSlash(rel))
|
||||||
}
|
}
|
||||||
|
|
||||||
content := string(raw)
|
|
||||||
if strings.HasSuffix(base, ".md") {
|
|
||||||
fm, body := parseFrontmatter(string(raw))
|
fm, body := parseFrontmatter(string(raw))
|
||||||
fm.set("rejected_at", time.Now().UTC().Format(time.RFC3339))
|
fm.set("rejected_at", time.Now().UTC().Format(time.RFC3339))
|
||||||
if opts.Reason != "" {
|
if opts.Reason != "" {
|
||||||
fm.set("reject_reason", opts.Reason)
|
fm.set("reject_reason", opts.Reason)
|
||||||
}
|
}
|
||||||
content = fm.render() + body
|
|
||||||
} // a pdf or txt is moved byte for byte: prepending frontmatter would alter it
|
|
||||||
|
|
||||||
// Write-then-delete: the source survives any write failure. Both go
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
// through the brain root (hyperguild#188).
|
return "", fmt.Errorf("create rejected dir: %w", err)
|
||||||
if err := createBrainFile(brainDir, dest, []byte(content)); err != nil {
|
}
|
||||||
|
// Write-then-delete: the source survives any write failure.
|
||||||
|
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
|
||||||
return "", fmt.Errorf("write rejected note: %w", err)
|
return "", fmt.Errorf("write rejected note: %w", err)
|
||||||
}
|
}
|
||||||
if err := removeBrainFile(brainDir, src); err != nil {
|
if err := os.Remove(src); err != nil {
|
||||||
return "", fmt.Errorf("rejected note written but source removal failed: %w", err)
|
return "", fmt.Errorf("rejected note written but source removal failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,67 +223,21 @@ func excerpt(s string, n int) string {
|
|||||||
|
|
||||||
// readRawSource reads a pending note for promote/reject through the raw root,
|
// readRawSource reads a pending note for promote/reject through the raw root,
|
||||||
// refusing symlinks and a symlinked raw/ (hyperguild#153).
|
// refusing symlinks and a symlinked raw/ (hyperguild#153).
|
||||||
func readRawSource(brainDir, base, queue string) ([]byte, string, error) {
|
func readRawSource(brainDir, base string) ([]byte, error) {
|
||||||
if queue != "" && queue != QueueInbox && queue != QueueNeedsReview {
|
|
||||||
return nil, "", fmt.Errorf("unknown queue %q (want %q or %q)", queue, QueueInbox, QueueNeedsReview)
|
|
||||||
}
|
|
||||||
root, _, err := OpenRawRoot(brainDir)
|
root, _, err := OpenRawRoot(brainDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return nil, "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
return nil, fmt.Errorf("pending note %q does not exist in raw/", base)
|
||||||
}
|
}
|
||||||
return nil, "", fmt.Errorf("read source: %w", err)
|
return nil, fmt.Errorf("read source: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = root.Close() }()
|
defer func() { _ = root.Close() }()
|
||||||
|
raw, _, err := readRawNote(root, base)
|
||||||
var topRaw, parkedRaw []byte
|
if err != nil {
|
||||||
var topErr, parkedErr error
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
if queue != QueueNeedsReview {
|
return nil, fmt.Errorf("pending note %q does not exist in raw/", base)
|
||||||
topRaw, _, topErr = readRawNote(root, base)
|
|
||||||
}
|
}
|
||||||
if queue != QueueInbox {
|
return nil, fmt.Errorf("read source: %w", err)
|
||||||
// The sub-root refuses a symlinked directory like OpenRawRoot does.
|
|
||||||
if sub, serr := root.OpenRoot(parkedDir); serr != nil {
|
|
||||||
parkedErr = serr
|
|
||||||
} else {
|
|
||||||
parkedRaw, _, parkedErr = readRawNote(sub, base)
|
|
||||||
_ = sub.Close()
|
|
||||||
}
|
}
|
||||||
}
|
return raw, nil
|
||||||
haveTop := queue != QueueNeedsReview && topErr == nil
|
|
||||||
haveParked := queue != QueueInbox && parkedErr == nil
|
|
||||||
switch {
|
|
||||||
case haveTop && haveParked:
|
|
||||||
return nil, "", fmt.Errorf("%q is in both raw/ and raw/%s/: name the queue (%q or %q)", base, parkedDir, QueueInbox, QueueNeedsReview)
|
|
||||||
case haveTop:
|
|
||||||
return topRaw, "raw/" + base, nil
|
|
||||||
case haveParked:
|
|
||||||
return parkedRaw, "raw/" + parkedDir + "/" + base, nil
|
|
||||||
}
|
|
||||||
for _, e := range []error{topErr, parkedErr} {
|
|
||||||
if e != nil && !errors.Is(e, os.ErrNotExist) {
|
|
||||||
return nil, "", fmt.Errorf("read source: %w", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parkedDir is where the watcher parks notes it could not place.
|
|
||||||
const parkedDir = "needs-review"
|
|
||||||
|
|
||||||
// The two review queues brain_pending lists. promote and reject take one of
|
|
||||||
// them (or neither, when the name is unambiguous).
|
|
||||||
const (
|
|
||||||
QueueInbox = "inbox" // raw/<name>
|
|
||||||
QueueNeedsReview = "needs-review" // raw/needs-review/<name>
|
|
||||||
)
|
|
||||||
|
|
||||||
// unquoteScalar undoes the Go-style double quoting the watcher writes around a
|
|
||||||
// free-text value (strconv.Quote keeps it on one line); a bare value is
|
|
||||||
// returned as is.
|
|
||||||
func unquoteScalar(v string) string {
|
|
||||||
if u, err := strconv.Unquote(v); err == nil {
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// brain_pending lists a top-level note and a parked note of the same name as two
|
|
||||||
// entries with the same filename. Acting on "a.md" must not silently pick one.
|
|
||||||
func TestPromoteNote_ANameInBothQueuesNeedsTheQueueToBeNamed(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeRaw(t, dir, "a.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nTop-level body.\n")
|
|
||||||
writeParked(t, dir, "a.md", parkedNote)
|
|
||||||
|
|
||||||
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts"})
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "both")
|
|
||||||
assert.Contains(t, err.Error(), "queue")
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/a.md"))
|
|
||||||
assert.ErrorIs(t, statErr, os.ErrNotExist, "nothing was promoted")
|
|
||||||
|
|
||||||
rel, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueNeedsReview})
|
|
||||||
require.NoError(t, err)
|
|
||||||
got, _ := os.ReadFile(filepath.Join(dir, rel))
|
|
||||||
assert.Contains(t, string(got), "Body of the parked note.")
|
|
||||||
_, statErr = os.Stat(filepath.Join(dir, "raw", "a.md"))
|
|
||||||
assert.NoError(t, statErr, "the top-level note of the same name is untouched")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPromoteNote_QueueInboxSelectsTheTopLevelNote(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeRaw(t, dir, "a.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nTop-level body.\n")
|
|
||||||
writeParked(t, dir, "a.md", parkedNote)
|
|
||||||
|
|
||||||
rel, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueInbox})
|
|
||||||
require.NoError(t, err)
|
|
||||||
got, _ := os.ReadFile(filepath.Join(dir, rel))
|
|
||||||
assert.Contains(t, string(got), "Top-level body.")
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "a.md"))
|
|
||||||
assert.NoError(t, statErr, "the parked note is untouched")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPromoteNote_ANamedQueueThatDoesNotHoldTheNoteIsAnError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeRaw(t, dir, "a.md", "top")
|
|
||||||
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueNeedsReview})
|
|
||||||
assert.Error(t, err)
|
|
||||||
_, err = PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: "elsewhere"})
|
|
||||||
assert.Error(t, err, "an unknown queue name is refused")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRejectNote_ANameInBothQueuesNeedsTheQueueToBeNamed(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeRaw(t, dir, "a.md", "top")
|
|
||||||
writeParked(t, dir, "a.md", parkedNote)
|
|
||||||
|
|
||||||
_, err := RejectNote(dir, RejectOptions{Filename: "a.md"})
|
|
||||||
require.Error(t, err)
|
|
||||||
_, err = RejectNote(dir, RejectOptions{Filename: "a.md", Queue: QueueNeedsReview})
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "raw", "a.md"))
|
|
||||||
assert.NoError(t, statErr, "the top-level note is untouched")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPromoteNote_NeverIntoARetiredLayoutName(t *testing.T) {
|
|
||||||
for _, w := range []string{"sources", "concepts", "entities", "wiki"} {
|
|
||||||
t.Run(w, func(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeRaw(t, dir, "a.md", "top")
|
|
||||||
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: w, Hall: "facts"})
|
|
||||||
assert.Error(t, err)
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "wiki", w))
|
|
||||||
assert.ErrorIs(t, statErr, os.ErrNotExist)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListPending_LabelsEveryEntryWithItsQueue(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeRaw(t, dir, "fresh.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nx\n")
|
|
||||||
writeParked(t, dir, "p.md", parkedNote)
|
|
||||||
got, err := ListPending(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, got, 2)
|
|
||||||
queues := map[string]string{}
|
|
||||||
for _, n := range got {
|
|
||||||
queues[n.Filename] = n.Queue
|
|
||||||
}
|
|
||||||
assert.Equal(t, QueueInbox, queues["fresh.md"])
|
|
||||||
assert.Equal(t, QueueNeedsReview, queues["p.md"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parked notes that are not markdown (a pdf, a txt) were invisible: the only
|
|
||||||
// trace was a log.md line.
|
|
||||||
func TestListPending_ShowsParkedNonMarkdownFiles(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "paper.pdf"), []byte("%PDF-1.4 binary"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "plain.txt"), []byte("some text"), 0o644))
|
|
||||||
|
|
||||||
got, err := ListPending(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
names := map[string]PendingNote{}
|
|
||||||
for _, n := range got {
|
|
||||||
names[n.Filename] = n
|
|
||||||
}
|
|
||||||
require.Contains(t, names, "paper.pdf")
|
|
||||||
require.Contains(t, names, "plain.txt")
|
|
||||||
assert.Equal(t, QueueNeedsReview, names["paper.pdf"].Queue)
|
|
||||||
assert.Contains(t, names["paper.pdf"].Excerpt, "proposal", "points the reader at where the proposal is")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPromoteNote_OnlyMarkdownCanBePromoted(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "paper.pdf"), []byte("%PDF binary"), 0o644))
|
|
||||||
_, err := PromoteNote(dir, PromoteOptions{Filename: "paper.pdf", Wing: "homelab", Hall: "facts"})
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "markdown")
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "paper.pdf"))
|
|
||||||
assert.NoError(t, statErr, "refused, untouched")
|
|
||||||
}
|
|
||||||
|
|
||||||
// brain.Sanitise normalises case, trailing dots and separators; the reserved-name
|
|
||||||
// check must look at the normalised name or "Sources" and "sources." walk past it.
|
|
||||||
func TestPromoteNote_ReservedWingNamesAreRefusedInEveryDisguise(t *testing.T) {
|
|
||||||
for _, w := range []string{"Sources", "sources.", "SOURCES ", "Entities/", "concepts\\", " wiki "} {
|
|
||||||
t.Run(w, func(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeRaw(t, dir, "a.md", "top")
|
|
||||||
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: w, Hall: "facts"})
|
|
||||||
assert.Error(t, err)
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "raw", "a.md"))
|
|
||||||
assert.NoError(t, statErr, "refused, untouched")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject moves the bytes; it must not prepend frontmatter to a pdf or txt.
|
|
||||||
func TestRejectNote_AParkedNonMarkdownFileIsMovedUntouched(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
pdf := "%PDF-1.4\r\n\x00\xff binary \r\n"
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "paper.pdf"), []byte(pdf), 0o644))
|
|
||||||
|
|
||||||
rel, err := RejectNote(dir, RejectOptions{Filename: "paper.pdf", Reason: "not relevant"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
got, err := os.ReadFile(filepath.Join(dir, rel))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, pdf, string(got), "byte for byte")
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "paper.pdf"))
|
|
||||||
assert.ErrorIs(t, statErr, os.ErrNotExist)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadRawNote_IsBoundedByTheCap(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "big.md"), []byte(strings.Repeat("x", 200)), 0o644))
|
|
||||||
root, err := os.OpenRoot(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = root.Close() }()
|
|
||||||
prev := maxRawNoteBytes
|
|
||||||
maxRawNoteBytes = 100
|
|
||||||
t.Cleanup(func() { maxRawNoteBytes = prev })
|
|
||||||
|
|
||||||
_, _, err = readRawNote(root, "big.md")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "exceeds")
|
|
||||||
}
|
|
||||||
|
|
||||||
type endless struct{ n int64 }
|
|
||||||
|
|
||||||
func (e *endless) Read(p []byte) (int, error) {
|
|
||||||
if e.n > 8<<20 { // a regression fails the test instead of exhausting memory
|
|
||||||
return 0, errors.New("endless reader drained")
|
|
||||||
}
|
|
||||||
e.n += int64(len(p))
|
|
||||||
return len(p), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadCapped_StopsAtTheCap(t *testing.T) {
|
|
||||||
e := &endless{}
|
|
||||||
_, err := readCapped(e, 1000)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "exceeds")
|
|
||||||
assert.LessOrEqual(t, e.n, int64(1000+1+32*1024), "pulled far more than the cap: %d", e.n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// An .md over the read cap used to vanish from the listing (readRawNote failed
|
|
||||||
// and the entry was skipped). It must stay visible, with the reason.
|
|
||||||
func TestListPending_AnUnreadableNoteIsListedWithTheReason(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "huge.md"), []byte(strings.Repeat("x", 200)), 0o644))
|
|
||||||
prev := maxRawNoteBytes
|
|
||||||
maxRawNoteBytes = 100
|
|
||||||
t.Cleanup(func() { maxRawNoteBytes = prev })
|
|
||||||
|
|
||||||
got, err := ListPending(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, got, 1)
|
|
||||||
assert.Equal(t, "huge.md", got[0].Filename)
|
|
||||||
assert.Contains(t, got[0].Excerpt, "cannot be read")
|
|
||||||
assert.Contains(t, got[0].Excerpt, "exceeds")
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
package api_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hyperguild#188: promote and reject wrote the destination with
|
|
||||||
// os.MkdirAll + os.WriteFile by path, so a symlinked wing (promote) or a
|
|
||||||
// symlinked raw/rejected (reject) redirected the note; the raw source was
|
|
||||||
// then removed.
|
|
||||||
func TestPromoteAndRejectRefuseASymlinkedDestinationDirectory(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
outside := t.TempDir()
|
|
||||||
raw := filepath.Join(brain, "raw")
|
|
||||||
require.NoError(t, os.MkdirAll(raw, 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-10-05-n.md"), []byte("---\ntitle: n\n---\nbody\n"), 0o644))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki"), 0o755))
|
|
||||||
require.NoError(t, os.Symlink(outside, filepath.Join(brain, "wiki", "lw")))
|
|
||||||
require.NoError(t, os.Symlink(outside, filepath.Join(raw, "rejected")))
|
|
||||||
|
|
||||||
_, err := api.PromoteNote(brain, api.PromoteOptions{Filename: "2026-10-05-n.md", Wing: "lw", Hall: "facts"})
|
|
||||||
assert.Error(t, err)
|
|
||||||
_, err = api.RejectNote(brain, api.RejectOptions{Filename: "2026-10-05-n.md"})
|
|
||||||
assert.Error(t, err)
|
|
||||||
ents, _ := os.ReadDir(outside)
|
|
||||||
assert.Empty(t, ents, "a note was written outside the brain")
|
|
||||||
_, err = os.Stat(filepath.Join(raw, "2026-10-05-n.md"))
|
|
||||||
assert.NoError(t, err, "the raw source is kept when the write is refused")
|
|
||||||
|
|
||||||
require.NoError(t, os.Remove(filepath.Join(raw, "rejected")))
|
|
||||||
_, err = api.PromoteNote(brain, api.PromoteOptions{Filename: "2026-10-05-n.md", Wing: "w", Hall: "facts"})
|
|
||||||
require.NoError(t, err, "positive control: promote into a real wing")
|
|
||||||
}
|
|
||||||
|
|
||||||
// StampProvenanceFile read and rewrote the note by path (provenance.ApplyFile).
|
|
||||||
func TestStampProvenanceFileRefusesANoteBehindASymlinkedDirectory(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
victim := filepath.Join(brain, "raw", "facts", "v.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(victim, []byte("---\ntitle: v\n---\nuntouched\n"), 0o644))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w"), 0o755))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "facts"), filepath.Join(brain, "wiki", "w", "alias")))
|
|
||||||
assert.Error(t, api.StampProvenanceFile(brain, "wiki/w/alias/v.md", "static", "x"))
|
|
||||||
got, _ := os.ReadFile(victim)
|
|
||||||
assert.Equal(t, "---\ntitle: v\n---\nuntouched\n", string(got))
|
|
||||||
|
|
||||||
real := filepath.Join(brain, "wiki", "w", "facts", "r.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(real), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(real, []byte("---\ntitle: r\n---\nbody\n"), 0o644))
|
|
||||||
require.NoError(t, api.StampProvenanceFile(brain, "wiki/w/facts/r.md", "static", "x"), "positive control")
|
|
||||||
got, _ = os.ReadFile(real)
|
|
||||||
assert.Contains(t, string(got), "written_by: x")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The same through an in-brain symlinked wing and raw/rejected, which
|
|
||||||
// os.Root alone would follow.
|
|
||||||
func TestPromoteAndRejectRefuseAnInBrainSymlinkedDestinationDirectory(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
raw := filepath.Join(brain, "raw")
|
|
||||||
require.NoError(t, os.MkdirAll(raw, 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-10-05-n.md"), []byte("---\ntitle: n\n---\nbody\n"), 0o644))
|
|
||||||
target := filepath.Join(brain, "archive")
|
|
||||||
require.NoError(t, os.MkdirAll(target, 0o755))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki"), 0o755))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "archive"), filepath.Join(brain, "wiki", "lw")))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "archive"), filepath.Join(raw, "rejected")))
|
|
||||||
|
|
||||||
_, err := api.PromoteNote(brain, api.PromoteOptions{Filename: "2026-10-05-n.md", Wing: "lw", Hall: "facts"})
|
|
||||||
assert.Error(t, err)
|
|
||||||
_, err = api.RejectNote(brain, api.RejectOptions{Filename: "2026-10-05-n.md"})
|
|
||||||
assert.Error(t, err)
|
|
||||||
ents, _ := os.ReadDir(target)
|
|
||||||
assert.Empty(t, ents, "a note was written at the in-brain link's target")
|
|
||||||
_, err = os.Stat(filepath.Join(raw, "2026-10-05-n.md"))
|
|
||||||
assert.NoError(t, err, "the raw source is kept")
|
|
||||||
|
|
||||||
require.NoError(t, os.Remove(filepath.Join(raw, "rejected")))
|
|
||||||
_, err = api.RejectNote(brain, api.RejectOptions{Filename: "2026-10-05-n.md"})
|
|
||||||
require.NoError(t, err, "positive control: reject into a real raw/rejected")
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
const parkedNote = "---\nproposed_wing: radar\nproposal_confidence: 0.40\nproposal_reason: \"text matches the client-content marker\"\ncreated_at: 2026-10-19T08:00:00Z\n---\nBody of the parked note.\n"
|
|
||||||
|
|
||||||
// brain_pending lists a parked note under its bare filename, so promote and
|
|
||||||
// reject must be able to act on exactly that name (#208: they could not).
|
|
||||||
func TestPromoteNote_ActsOnANoteParkedInNeedsReview(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeParked(t, dir, "unsure.md", parkedNote)
|
|
||||||
|
|
||||||
rel, err := PromoteNote(dir, PromoteOptions{Filename: "unsure.md", Wing: "homelab", Hall: "facts"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "wiki/homelab/facts/unsure.md", rel)
|
|
||||||
|
|
||||||
got, err := os.ReadFile(filepath.Join(dir, "wiki/homelab/facts/unsure.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(got), "Body of the parked note.")
|
|
||||||
assert.Contains(t, string(got), "wing: homelab")
|
|
||||||
assert.NotContains(t, string(got), "proposed_wing", "the resolver's proposal is not part of the promoted note")
|
|
||||||
assert.NotContains(t, string(got), "proposal_reason")
|
|
||||||
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "unsure.md"))
|
|
||||||
assert.ErrorIs(t, statErr, os.ErrNotExist, "the parked note is consumed")
|
|
||||||
pending, err := ListPending(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, pending)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRejectNote_ActsOnANoteParkedInNeedsReview(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeParked(t, dir, "unsure.md", parkedNote)
|
|
||||||
|
|
||||||
rel, err := RejectNote(dir, RejectOptions{Filename: "unsure.md", Reason: "not worth keeping"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "raw/rejected/unsure.md", rel)
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "unsure.md"))
|
|
||||||
assert.ErrorIs(t, statErr, os.ErrNotExist)
|
|
||||||
pending, _ := ListPending(dir)
|
|
||||||
assert.Empty(t, pending, "a rejected note is not re-listed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPromoteNote_ASymlinkedNeedsReviewIsNotRead(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
outside := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.md"), []byte("outside"), 0o644))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
|
|
||||||
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "raw", "needs-review")))
|
|
||||||
|
|
||||||
_, err := PromoteNote(dir, PromoteOptions{Filename: "secret.md", Wing: "homelab", Hall: "facts"})
|
|
||||||
assert.Error(t, err)
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/secret.md"))
|
|
||||||
assert.ErrorIs(t, statErr, os.ErrNotExist)
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,61 +11,11 @@ func StampProvenance(content, origin, writtenBy string) (string, error) {
|
|||||||
return provenance.Apply(content, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
|
return provenance.Apply(content, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
|
||||||
}
|
}
|
||||||
|
|
||||||
// StampProvenanceFile applies StampProvenance to a brain-relative note path,
|
// StampProvenanceFile applies StampProvenance to a brain-relative note path.
|
||||||
// read and rewritten through the brain root (hyperguild#158).
|
|
||||||
func StampProvenanceFile(brainDir, relPath, origin, writtenBy string) error {
|
func StampProvenanceFile(brainDir, relPath, origin, writtenBy string) error {
|
||||||
rel, _, err := resolveWithin(brainDir, relPath)
|
_, abs, err := resolveWithin(brainDir, relPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
bfs, err := brainfs.Open(brainDir)
|
return provenance.ApplyFile(abs, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open brain: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
b, err := bfs.ReadRegular(rel)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read for provenance: %w", err)
|
|
||||||
}
|
|
||||||
out, err := provenance.Apply(string(b), provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if out == string(b) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := bfs.WriteRegular(rel, []byte(out)); err != nil {
|
|
||||||
return fmt.Errorf("write provenance: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createBrainFile creates the file at abs (inside brainDir) through the
|
|
||||||
// brain root and fails if anything is already there.
|
|
||||||
func createBrainFile(brainDir, abs string, data []byte) error {
|
|
||||||
rel, err := filepath.Rel(brainDir, abs)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open brain: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
return bfs.CreateRegular(filepath.ToSlash(rel), data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeBrainFile removes the file at abs (inside brainDir) through the
|
|
||||||
// brain root.
|
|
||||||
func removeBrainFile(brainDir, abs string) error {
|
|
||||||
rel, err := filepath.Rel(brainDir, abs)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open brain: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
return bfs.Remove(filepath.ToSlash(rel))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,10 +38,6 @@ func OpenRawRoot(brainDir string) (*os.Root, string, error) {
|
|||||||
return r, p, nil
|
return r, p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxRawNoteBytes bounds what promote, reject and the pending listing read from
|
|
||||||
// one raw/ entry. A variable so a test can lower it.
|
|
||||||
var maxRawNoteBytes int64 = 64 << 20
|
|
||||||
|
|
||||||
// readRawNote reads one raw/ entry by basename through the root, refusing
|
// readRawNote reads one raw/ entry by basename through the root, refusing
|
||||||
// anything that is not a regular file.
|
// anything that is not a regular file.
|
||||||
//
|
//
|
||||||
@@ -63,22 +59,9 @@ func readRawNote(r *os.Root, base string) ([]byte, os.FileInfo, error) {
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer func() { _ = f.Close() }()
|
defer func() { _ = f.Close() }()
|
||||||
b, err := readCapped(f, maxRawNoteBytes)
|
b, err := io.ReadAll(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("%s: %w", base, err)
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
return b, fi, nil
|
return b, fi, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readCapped reads r but never more than max+1 bytes, so an oversize or growing
|
|
||||||
// file costs at most that much memory.
|
|
||||||
func readCapped(r io.Reader, max int64) ([]byte, error) {
|
|
||||||
b, err := io.ReadAll(io.LimitReader(r, max+1))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if int64(len(b)) > max {
|
|
||||||
return nil, fmt.Errorf("note exceeds %d bytes", max)
|
|
||||||
}
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,13 +23,6 @@ func TestReadNoteRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
|
|||||||
_, body, _, err := api.ReadNote(brain, "wiki/w/facts/leak.md")
|
_, body, _, err := api.ReadNote(brain, "wiki/w/facts/leak.md")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.NotContains(t, body, brainfstest.Sentinel)
|
assert.NotContains(t, body, brainfstest.Sentinel)
|
||||||
// A link to a file elsewhere in the brain: os.Root allows it, only the
|
|
||||||
// regular-file check refuses it (hyperguild#171).
|
|
||||||
brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
|
|
||||||
_, body, _, err = api.ReadNote(brain, "wiki/w/facts/inlink.md")
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.NotContains(t, body, brainfstest.Sentinel)
|
|
||||||
|
|
||||||
p := filepath.Join(facts, "r.md")
|
p := filepath.Join(facts, "r.md")
|
||||||
require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644))
|
require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644))
|
||||||
@@ -46,7 +39,5 @@ func TestReadNoteRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, read)
|
brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, read)
|
||||||
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
|
|
||||||
brainfstest.AssertNoLeakFrom(t, p, "---\nx: y\n---\ninside", inBrain, 1500*time.Millisecond, read)
|
|
||||||
brainfstest.AssertNoHang(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, 6*time.Second, func() { _ = read() })
|
brainfstest.AssertNoHang(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, 6*time.Second, func() { _ = read() })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,15 @@ package api
|
|||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContentHash returns the lowercase hex sha256 of b. It is the note's
|
// ContentHash returns the lowercase hex sha256 of b. It is the note's
|
||||||
@@ -94,23 +91,14 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
|||||||
rel = filepath.ToSlash(rel)
|
rel = filepath.ToSlash(rel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The path is caller-supplied (brain_update): resolve it as brain_edit
|
rel, abs, err := resolveWithin(brainDir, rel)
|
||||||
// does, knowledge/ and wiki/ notes only, and read and rewrite it through
|
|
||||||
// the brain root, never through a symlink (hyperguild#169).
|
|
||||||
t, err := notes.Resolve(brainDir, rel, notes.ScopeNotes)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
rel = t.Rel
|
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("open brain: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
|
|
||||||
prior, err := bfs.ReadRegular(rel)
|
prior, err := os.ReadFile(abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if os.IsNotExist(err) {
|
||||||
return "", "", "", fmt.Errorf("note %q does not exist: use brain_write to create", rel)
|
return "", "", "", fmt.Errorf("note %q does not exist: use brain_write to create", rel)
|
||||||
}
|
}
|
||||||
return "", "", "", fmt.Errorf("read target: %w", err)
|
return "", "", "", fmt.Errorf("read target: %w", err)
|
||||||
@@ -176,7 +164,7 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
|||||||
return "", "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
out := []byte(stamped)
|
out := []byte(stamped)
|
||||||
if err := bfs.WriteRegular(rel, out); err != nil {
|
if err := os.WriteFile(abs, out, 0o644); err != nil {
|
||||||
return "", "", "", fmt.Errorf("write: %w", err)
|
return "", "", "", fmt.Errorf("write: %w", err)
|
||||||
}
|
}
|
||||||
return rel, ContentHash(out), priorHash, nil
|
return rel, ContentHash(out), priorHash, nil
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
package api_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hyperguild#173: a directory symlink inside wiki/ that points elsewhere in
|
|
||||||
// the brain redirected brain_update's write (os.Root follows in-root
|
|
||||||
// intermediate symlinks). Every directory above the note must be real.
|
|
||||||
func TestUpdateNoteRefusesANoteBehindASymlinkedDirectory(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
victim := filepath.Join(brain, "raw", "facts", "v.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(victim, []byte("---\ntitle: v\n---\nuntouched\n"), 0o644))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w"), 0o755))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "facts"), filepath.Join(brain, "wiki", "w", "alias")))
|
|
||||||
|
|
||||||
_, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: "wiki/w/alias/v.md", Content: "replaced"})
|
|
||||||
assert.ErrorIs(t, err, brainfs.ErrNotRegular)
|
|
||||||
got, _ := os.ReadFile(victim)
|
|
||||||
assert.Contains(t, string(got), "untouched")
|
|
||||||
|
|
||||||
real := filepath.Join(brain, "wiki", "w", "facts", "r.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(real), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(real, []byte("---\ntitle: r\n---\nold\n"), 0o644))
|
|
||||||
_, _, _, err = api.UpdateNote(brain, api.UpdateNoteOptions{Path: "wiki/w/facts/r.md", Content: "new"})
|
|
||||||
require.NoError(t, err, "positive control: a note in a real directory is updated")
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
package api_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
// brain_update passed the caller's path through resolveWithin, a lexical
|
|
||||||
// containment check only: any file in the brain could be superseded
|
|
||||||
// (classification.yaml, raw/, sessions/), and a final symlink was written
|
|
||||||
// through. It now resolves like brain_edit: knowledge/ and wiki/ notes only,
|
|
||||||
// read and rewritten through the brain root.
|
|
||||||
func TestUpdateNoteRefusesPathsOutsideKnowledgeAndWikiNotes(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
files := map[string]string{
|
|
||||||
"classification.yaml": "levels: {}\n",
|
|
||||||
"raw/r.md": "raw\n",
|
|
||||||
"sessions/s.jsonl": "{}\n",
|
|
||||||
"wiki/w/facts/ok.md": "---\ntitle: ok\n---\nold\n",
|
|
||||||
"knowledge/k.md": "---\ntitle: k\n---\nold\n",
|
|
||||||
"wiki/w/facts/x.txt": "txt\n",
|
|
||||||
}
|
|
||||||
for rel, body := range files {
|
|
||||||
p := filepath.Join(brain, filepath.FromSlash(rel))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(p, []byte(body), 0o644))
|
|
||||||
}
|
|
||||||
for _, rel := range []string{"classification.yaml", "raw/r.md", "sessions/s.jsonl", "wiki/w/facts/x.txt", "wiki/w/../../raw/r.md"} {
|
|
||||||
_, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: rel, Content: "replaced"})
|
|
||||||
assert.Error(t, err, rel)
|
|
||||||
}
|
|
||||||
for rel, body := range files {
|
|
||||||
if rel == "wiki/w/facts/ok.md" || rel == "knowledge/k.md" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
got, err := os.ReadFile(filepath.Join(brain, filepath.FromSlash(rel)))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, body, string(got), rel)
|
|
||||||
}
|
|
||||||
for _, rel := range []string{"wiki/w/facts/ok.md", "knowledge/k.md"} {
|
|
||||||
_, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: rel, Content: "new body"})
|
|
||||||
require.NoError(t, err, "positive control: %s", rel)
|
|
||||||
got, _ := os.ReadFile(filepath.Join(brain, filepath.FromSlash(rel)))
|
|
||||||
assert.Contains(t, string(got), "new body")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateNoteDoesNotWriteThroughASymlinkedNote(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
facts := filepath.Join(brain, "wiki", "w", "facts")
|
|
||||||
require.NoError(t, os.MkdirAll(facts, 0o755))
|
|
||||||
victim := filepath.Join(brain, "raw", "victim.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "victim.md"), filepath.Join(facts, "l.md")))
|
|
||||||
_, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: "wiki/w/facts/l.md", Content: "replaced"})
|
|
||||||
assert.Error(t, err)
|
|
||||||
got, _ := os.ReadFile(victim)
|
|
||||||
assert.Equal(t, "untouched\n", string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
// The note is swapped between a regular file and an in-brain symlink to a
|
|
||||||
// raw/ file while UpdateNote runs: the read and the write each refuse a
|
|
||||||
// symlink, so the raw/ file is never rewritten.
|
|
||||||
func TestUpdateNoteNeverRewritesASymlinkSwappedInForTheNote(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
facts := filepath.Join(brain, "wiki", "w", "facts")
|
|
||||||
require.NoError(t, os.MkdirAll(facts, 0o755))
|
|
||||||
victim := filepath.Join(brain, "raw", "victim.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
|
|
||||||
note := filepath.Join(facts, "n.md")
|
|
||||||
body := "---\ntitle: n\n---\nold\n"
|
|
||||||
require.NoError(t, os.WriteFile(note, []byte(body), 0o644))
|
|
||||||
|
|
||||||
var stop atomic.Bool
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
tmp := note + ".swap"
|
|
||||||
for i := 0; !stop.Load(); i++ {
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
if i%2 == 0 {
|
|
||||||
_ = os.WriteFile(tmp, []byte(body), 0o644)
|
|
||||||
} else {
|
|
||||||
_ = os.Symlink(filepath.Join("..", "..", "..", "raw", "victim.md"), tmp)
|
|
||||||
}
|
|
||||||
_ = os.Rename(tmp, note)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
ok := 0
|
|
||||||
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); {
|
|
||||||
if _, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: "wiki/w/facts/n.md", Content: "new"}); err == nil {
|
|
||||||
ok++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stop.Store(true)
|
|
||||||
wg.Wait()
|
|
||||||
got, _ := os.ReadFile(victim)
|
|
||||||
assert.Equal(t, "untouched\n", string(got))
|
|
||||||
assert.Positive(t, ok, "positive control: some updates reach the regular note")
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
package api_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hyperguild#178: an unreadable directory under wiki/ made the link check
|
|
||||||
// fail and brain_write refuse the note, losing the learning. The note is now
|
|
||||||
// written with its links as given.
|
|
||||||
func TestWriteNoteKeepsTheLearningWhenTheLinkCheckCannotIndex(t *testing.T) {
|
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
t.Skip("root ignores directory permissions")
|
|
||||||
}
|
|
||||||
brain := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w", "facts"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "w", "facts", "real.md"), []byte("x"), 0o644))
|
|
||||||
locked := filepath.Join(brain, "wiki", "other", "locked")
|
|
||||||
require.NoError(t, os.MkdirAll(locked, 0o755))
|
|
||||||
require.NoError(t, os.Chmod(locked, 0o000))
|
|
||||||
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
|
|
||||||
|
|
||||||
rel, err := api.WriteNote(brain, api.WriteNoteOptions{Wing: "w", Hall: "facts", Filename: "n", Content: "learning [[real]] and [[gone]]"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
got, err := os.ReadFile(filepath.Join(brain, filepath.FromSlash(rel)))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(got), "[[gone]]", "links kept as given when the index is partial")
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package api_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hyperguild#188: brain_write created a new note with os.MkdirAll +
|
|
||||||
// os.WriteFile by path, so a symlinked wing directory redirected the note
|
|
||||||
// elsewhere in the brain or outside it.
|
|
||||||
func TestWriteNoteRefusesASymlinkedWing(t *testing.T) {
|
|
||||||
for name, target := range map[string]func(t *testing.T, brain string) string{
|
|
||||||
"in-brain": func(t *testing.T, brain string) string {
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brain, "raw"), 0o755))
|
|
||||||
return filepath.Join("..", "raw")
|
|
||||||
},
|
|
||||||
"outside": func(t *testing.T, _ string) string { return t.TempDir() },
|
|
||||||
} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki"), 0o755))
|
|
||||||
link := target(t, brain)
|
|
||||||
require.NoError(t, os.Symlink(link, filepath.Join(brain, "wiki", "lw")))
|
|
||||||
_, err := api.WriteNote(brain, api.WriteNoteOptions{Wing: "lw", Hall: "facts", Filename: "n", Content: "learning"})
|
|
||||||
assert.Error(t, err)
|
|
||||||
resolved := link
|
|
||||||
if !filepath.IsAbs(resolved) {
|
|
||||||
resolved = filepath.Join(brain, "wiki", link)
|
|
||||||
}
|
|
||||||
_, serr := os.Stat(filepath.Join(resolved, "facts"))
|
|
||||||
assert.True(t, os.IsNotExist(serr), "a hall was created at the link's target")
|
|
||||||
|
|
||||||
_, err = api.WriteNote(brain, api.WriteNoteOptions{Wing: "w", Hall: "facts", Filename: "n", Content: "learning"})
|
|
||||||
require.NoError(t, err, "positive control: a real wing is written")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,14 +13,9 @@ 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
|
||||||
@@ -32,31 +27,17 @@ 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", n.title)
|
req.Header.Set("Title", "brain-capture audit degraded")
|
||||||
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 != "" {
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
package brain_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hyperguild#174 / #157 slice 5b: DetectTunnels walked wiki/ and opened
|
|
||||||
// every note with a blocking os.Open, so a FIFO named *.md anywhere under
|
|
||||||
// wiki/ hung every brain_write and brain_update (wikiUpkeep -> AutoTunnel),
|
|
||||||
// and a symlinked note's title became a tunnel candidate.
|
|
||||||
func TestDetectTunnelsReturnsPromptlyWithAFIFOUnderWikiWithAndWithoutAWriter(t *testing.T) {
|
|
||||||
for _, withWriter := range []bool{false, true} {
|
|
||||||
t.Run(fmt.Sprintf("writer=%v", withWriter), func(t *testing.T) {
|
|
||||||
b := t.TempDir()
|
|
||||||
facts := filepath.Join(b, "wiki", "w", "facts")
|
|
||||||
require.NoError(t, os.MkdirAll(facts, 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(facts, "ok.md"), []byte("---\ntitle: Zqinside Phrase\n---\n"), 0o644))
|
|
||||||
fifo := filepath.Join(facts, "f.md")
|
|
||||||
require.NoError(t, syscall.Mkfifo(fifo, 0o644))
|
|
||||||
if withWriter {
|
|
||||||
// A writer held open: a reader that opens the FIFO gets no
|
|
||||||
// EOF and parks, unless the FIFO is never opened.
|
|
||||||
w, err := os.OpenFile(fifo, os.O_RDWR, 0)
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { _ = w.Close() })
|
|
||||||
}
|
|
||||||
done := make(chan []brain.TunnelCandidate, 1)
|
|
||||||
go func() {
|
|
||||||
cs, _ := brain.DetectTunnels(b, "text mentioning zqinside phrase here")
|
|
||||||
done <- cs
|
|
||||||
}()
|
|
||||||
select {
|
|
||||||
case cs := <-done:
|
|
||||||
assert.Contains(t, fmt.Sprintf("%+v", cs), "Zqinside Phrase", "positive control")
|
|
||||||
case <-time.After(3 * time.Second):
|
|
||||||
if r, err := os.OpenFile(fifo, os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil {
|
|
||||||
_ = r.Close()
|
|
||||||
}
|
|
||||||
t.Fatal("DetectTunnels blocked on a FIFO")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectTunnelsRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
|
|
||||||
b := t.TempDir()
|
|
||||||
facts := filepath.Join(b, "wiki", "w", "facts")
|
|
||||||
require.NoError(t, os.MkdirAll(facts, 0o755))
|
|
||||||
// The static note's title differs from the raced note's, so the
|
|
||||||
// harness's "inside was read" floor can only be met by reading r.md.
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(facts, "ok.md"), []byte("---\ntitle: Zqstatic Words\n---\n"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "out.md")))
|
|
||||||
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(b, "raw", "s.md"))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "in.md")))
|
|
||||||
content := "zqstatic words, zqinside phrase and " + brainfstest.Sentinel
|
|
||||||
|
|
||||||
cs, err := brain.DetectTunnels(b, content)
|
|
||||||
require.NoError(t, err)
|
|
||||||
got := fmt.Sprintf("%+v", cs)
|
|
||||||
assert.Contains(t, got, "Zqstatic Words", "positive control")
|
|
||||||
assert.NotContains(t, got, brainfstest.Sentinel)
|
|
||||||
|
|
||||||
p := filepath.Join(facts, "r.md")
|
|
||||||
read := func() string {
|
|
||||||
cs, _ := brain.DetectTunnels(b, content)
|
|
||||||
return fmt.Sprintf("%+v", cs)
|
|
||||||
}
|
|
||||||
brainfstest.AssertNoLeak(t, p, "---\ntitle: Zqinside Phrase\n---\n", 1500*time.Millisecond, read)
|
|
||||||
brainfstest.AssertNoLeakFrom(t, p, "---\ntitle: Zqinside Phrase\n---\n", inBrain, 1500*time.Millisecond, read)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A wiki/ that is not a real directory yields no candidates and no error:
|
|
||||||
// AutoTunnel runs inside brain_write.
|
|
||||||
func TestDetectTunnelsSkipsAWikiThatIsNotARealDirectory(t *testing.T) {
|
|
||||||
b := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(b, "raw", "w", "facts"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(b, "raw", "w", "facts", "r.md"), []byte("---\ntitle: Zqrawonly Phrase\n---\n"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink("raw", filepath.Join(b, "wiki")))
|
|
||||||
cs, err := brain.DetectTunnels(b, "zqrawonly phrase")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, cs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// An unreadable directory under wiki/ is a walk error and DetectTunnels
|
|
||||||
// reports it, as filepath.WalkDir did; it is not silently dropped.
|
|
||||||
func TestDetectTunnelsReportsAnUnreadableDirectoryUnderWiki(t *testing.T) {
|
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
t.Skip("root ignores directory permissions")
|
|
||||||
}
|
|
||||||
b := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "w", "facts"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(b, "wiki", "w", "facts", "ok.md"), []byte("---\ntitle: Zqinside Phrase\n---\n"), 0o644))
|
|
||||||
locked := filepath.Join(b, "wiki", "other", "locked")
|
|
||||||
require.NoError(t, os.MkdirAll(locked, 0o755))
|
|
||||||
require.NoError(t, os.Chmod(locked, 0o000))
|
|
||||||
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
|
|
||||||
_, err := brain.DetectTunnels(b, "zqinside phrase")
|
|
||||||
assert.ErrorIs(t, err, os.ErrPermission)
|
|
||||||
}
|
|
||||||
|
|
||||||
// _index.md files are generated, not notes: their title never becomes a
|
|
||||||
// candidate.
|
|
||||||
func TestDetectTunnelsSkipsIndexFiles(t *testing.T) {
|
|
||||||
b := t.TempDir()
|
|
||||||
w := filepath.Join(b, "wiki", "w")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(w, "facts"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(w, "_index.md"), []byte("---\ntitle: Zqindex Words\n---\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(w, "facts", "ok.md"), []byte("---\ntitle: Zqinside Phrase\n---\n"), 0o644))
|
|
||||||
cs, err := brain.DetectTunnels(b, "zqindex words and zqinside phrase")
|
|
||||||
require.NoError(t, err)
|
|
||||||
got := fmt.Sprintf("%+v", cs)
|
|
||||||
assert.Contains(t, got, "Zqinside Phrase", "positive control")
|
|
||||||
assert.NotContains(t, got, "Zqindex")
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -30,6 +32,7 @@ func BuildWingIndex(brainDir, wing string) error {
|
|||||||
if w == "" {
|
if w == "" {
|
||||||
return fmt.Errorf("invalid wing %q", wing)
|
return fmt.Errorf("invalid wing %q", wing)
|
||||||
}
|
}
|
||||||
|
wingDir := filepath.Join(brainDir, "wiki", w)
|
||||||
// Listed and read through the brain root (hyperguild#157): a wing or
|
// Listed and read through the brain root (hyperguild#157): a wing or
|
||||||
// hall that is a symlink is not followed, and notes are regular files.
|
// hall that is a symlink is not followed, and notes are regular files.
|
||||||
bfs, err := brainfs.Open(brainDir)
|
bfs, err := brainfs.Open(brainDir)
|
||||||
@@ -60,9 +63,8 @@ func BuildWingIndex(brainDir, wing string) error {
|
|||||||
fmt.Fprintf(&b, "| %s | [%s](%s/%s.md) | %s |\n", e.Hall, e.Title, e.Hall, e.Slug, e.Created)
|
fmt.Fprintf(&b, "| %s | [%s](%s/%s.md) | %s |\n", e.Hall, e.Title, e.Hall, e.Slug, e.Created)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Written through the brain root, never through a symlinked _index.md
|
dest := filepath.Join(wingDir, "_index.md")
|
||||||
// (hyperguild#158).
|
return os.WriteFile(dest, []byte(b.String()), 0o644)
|
||||||
return bfs.PutRegular("wiki/"+w+"/_index.md", []byte(b.String()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildAllWingIndexes regenerates _index.md for every wing under brain/wiki/.
|
// BuildAllWingIndexes regenerates _index.md for every wing under brain/wiki/.
|
||||||
@@ -114,6 +116,23 @@ func collectWingEntries(bfs *brainfs.Brain, wingRel string) ([]noteEntry, error)
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readTitleAndCreated reads YAML frontmatter for title + created_at; falls
|
||||||
|
// back to slug and file mtime when absent.
|
||||||
|
func readTitleAndCreated(path, slug string) (string, string) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return slug, ""
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
title, created := parseTitleAndCreated(f)
|
||||||
|
if created == "" {
|
||||||
|
if info, err := os.Stat(path); err == nil {
|
||||||
|
created = info.ModTime().UTC().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return titleOrSlug(title, slug), created
|
||||||
|
}
|
||||||
|
|
||||||
// readTitleAndCreatedIn is readTitleAndCreated through the brain root,
|
// readTitleAndCreatedIn is readTitleAndCreated through the brain root,
|
||||||
// regular files only (hyperguild#157).
|
// regular files only (hyperguild#157).
|
||||||
func readTitleAndCreatedIn(bfs *brainfs.Brain, rel, slug string) (string, string) {
|
func readTitleAndCreatedIn(bfs *brainfs.Brain, rel, slug string) (string, string) {
|
||||||
|
|||||||
@@ -47,12 +47,7 @@ var (
|
|||||||
func StripUnresolvedLinks(brainDir, content string) (string, []string, error) {
|
func StripUnresolvedLinks(brainDir, content string) (string, []string, error) {
|
||||||
index, err := entryIndex(brainDir)
|
index, err := entryIndex(brainDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A partial index cannot tell an invented link from one into the
|
return "", nil, err
|
||||||
// part it could not read, so unwrap nothing, and do not refuse the
|
|
||||||
// write either: this runs before every brain_write, and a refused
|
|
||||||
// write is a lost learning (hyperguild#178).
|
|
||||||
slog.Warn("link check skipped: cannot index the brain", "err", err)
|
|
||||||
return content, nil, nil
|
|
||||||
}
|
}
|
||||||
if len(index) == 0 {
|
if len(index) == 0 {
|
||||||
return content, nil, nil
|
return content, nil, nil
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
package brain_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hyperguild#178: StripUnresolvedLinks runs before every brain_write and
|
|
||||||
// brain_update. An unreadable directory under knowledge/ or wiki/ made it
|
|
||||||
// error, the write was refused and the learning lost. When the link index
|
|
||||||
// cannot be built, the content is now returned unchanged (no link is
|
|
||||||
// unwrapped, none is invented) and no error stops the write.
|
|
||||||
func TestStripUnresolvedLinksKeepsContentWhenTheIndexCannotBeBuilt(t *testing.T) {
|
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
t.Skip("root ignores directory permissions")
|
|
||||||
}
|
|
||||||
b := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "w", "facts"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(b, "wiki", "w", "facts", "real.md"), []byte("x"), 0o644))
|
|
||||||
locked := filepath.Join(b, "wiki", "other", "locked")
|
|
||||||
require.NoError(t, os.MkdirAll(locked, 0o755))
|
|
||||||
require.NoError(t, os.Chmod(locked, 0o000))
|
|
||||||
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
|
|
||||||
|
|
||||||
in := "learning [[real]] and [[gone]]"
|
|
||||||
out, unlinked, err := brain.StripUnresolvedLinks(b, in)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, in, out, "content unchanged: no link unwrapped on a partial index")
|
|
||||||
assert.Empty(t, unlinked)
|
|
||||||
|
|
||||||
require.NoError(t, os.Chmod(locked, 0o755))
|
|
||||||
out, _, err = brain.StripUnresolvedLinks(b, in)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotContains(t, out, "[[gone]]", "positive control: with a readable tree the strip runs")
|
|
||||||
}
|
|
||||||
@@ -2,15 +2,11 @@ package brain
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"os"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// seeAlsoHeader is the markdown heading used to group cross-wing links.
|
// seeAlsoHeader is the markdown heading used to group cross-wing links.
|
||||||
@@ -40,29 +36,24 @@ type TunnelCandidate struct {
|
|||||||
// A note's title is read from YAML frontmatter `title:`; failing that,
|
// A note's title is read from YAML frontmatter `title:`; failing that,
|
||||||
// the filename slug (sans `.md`, hyphens → spaces) is used.
|
// the filename slug (sans `.md`, hyphens → spaces) is used.
|
||||||
func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
||||||
// Walked and read through the brain root, regular files only
|
wikiDir := filepath.Join(brainDir, "wiki")
|
||||||
// (hyperguild#174, #157): this runs inside every brain_write, so a FIFO
|
if _, err := os.Stat(wikiDir); os.IsNotExist(err) {
|
||||||
// must not block it and a symlinked note's title must not become a
|
|
||||||
// candidate.
|
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("open brain: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
if _, err := bfs.Lstat("wiki"); errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, fmt.Errorf("stat wiki: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
lowerContent := strings.ToLower(content)
|
lowerContent := strings.ToLower(content)
|
||||||
|
|
||||||
var out []TunnelCandidate
|
var out []TunnelCandidate
|
||||||
var walkErr error
|
err := filepath.WalkDir(wikiDir, func(path string, d os.DirEntry, err error) error {
|
||||||
err = bfs.WalkRegular("wiki", func(rel string) error {
|
if err != nil {
|
||||||
name := path.Base(rel)
|
return err
|
||||||
if !strings.HasSuffix(name, ".md") || name == "_index.md" {
|
}
|
||||||
|
if d.IsDir() || !strings.HasSuffix(path, ".md") || d.Name() == "_index.md" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
title, _ := readTitleAndCreatedIn(bfs, rel, strings.TrimSuffix(name, ".md"))
|
title, _ := readTitleAndCreated(path, strings.TrimSuffix(d.Name(), ".md"))
|
||||||
title = strings.TrimSpace(title)
|
title = strings.TrimSpace(title)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -84,25 +75,17 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
|||||||
if idx == -1 {
|
if idx == -1 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
rel, err := filepath.Rel(brainDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
out = append(out, TunnelCandidate{
|
out = append(out, TunnelCandidate{
|
||||||
TargetPath: rel,
|
TargetPath: filepath.ToSlash(rel),
|
||||||
MatchedTerm: title,
|
MatchedTerm: title,
|
||||||
Exact: isWholeWord(haystack, idx, len(needle)),
|
Exact: isWholeWord(haystack, idx, len(needle)),
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
}, func(rel string, err error) {
|
|
||||||
// A wiki/ that is not a real directory yields no candidates, as
|
|
||||||
// filepath.WalkDir did; other walk errors still abort.
|
|
||||||
if rel == "wiki" && errors.Is(err, brainfs.ErrNotRegular) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if walkErr == nil {
|
|
||||||
walkErr = fmt.Errorf("%s: %w", rel, err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
if err == nil {
|
|
||||||
err = walkErr
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -175,23 +158,35 @@ func logFuzzyCandidates(brainDir, sourcePath string, cs []TunnelCandidate) error
|
|||||||
if len(cs) == 0 {
|
if len(cs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Appended through the brain root, never through a symlinked raw/ or
|
rawDir := filepath.Join(brainDir, "raw")
|
||||||
// log file (hyperguild#158).
|
if err := os.MkdirAll(rawDir, 0o755); err != nil {
|
||||||
bfs, err := brainfs.Open(brainDir)
|
return err
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open brain: %w", err)
|
|
||||||
}
|
}
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
stamp := time.Now().UTC().Format("2006-01-02")
|
stamp := time.Now().UTC().Format("2006-01-02")
|
||||||
rel := "raw/tunnel-candidates-" + stamp + ".md"
|
path := filepath.Join(rawDir, "tunnel-candidates-"+stamp+".md")
|
||||||
var buf strings.Builder
|
existed := fileExists(path)
|
||||||
if _, err := bfs.Lstat(rel); errors.Is(err, fs.ErrNotExist) {
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
buf.WriteString("# Tunnel candidates " + stamp + "\n\nFuzzy cross-wing matches surfaced by AutoTunnel. Review and promote to a tunnel via `brain_tunnel` if relevant.\n\n")
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
if !existed {
|
||||||
|
if _, err := f.WriteString("# Tunnel candidates " + stamp + "\n\nFuzzy cross-wing matches surfaced by AutoTunnel. Review and promote to a tunnel via `brain_tunnel` if relevant.\n\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, c := range cs {
|
for _, c := range cs {
|
||||||
fmt.Fprintf(&buf, "- `%s` ↔ `%s` (term: %q)\n", sourcePath, c.TargetPath, c.MatchedTerm)
|
line := fmt.Sprintf("- `%s` ↔ `%s` (term: %q)\n", sourcePath, c.TargetPath, c.MatchedTerm)
|
||||||
|
if _, err := f.WriteString(line); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return bfs.AppendRegular(rel, []byte(buf.String()))
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(p string) bool {
|
||||||
|
_, err := os.Stat(p)
|
||||||
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteTunnel appends a bidirectional wikilink between sourcePath and
|
// WriteTunnel appends a bidirectional wikilink between sourcePath and
|
||||||
@@ -202,12 +197,6 @@ func logFuzzyCandidates(brainDir, sourcePath string, cs []TunnelCandidate) error
|
|||||||
// section headers. Rejects same-wing pairs (a tunnel is by definition
|
// section headers. Rejects same-wing pairs (a tunnel is by definition
|
||||||
// cross-wing) and missing notes.
|
// cross-wing) and missing notes.
|
||||||
func WriteTunnel(brainDir, sourcePath, targetPath string) error {
|
func WriteTunnel(brainDir, sourcePath, targetPath string) error {
|
||||||
if err := checkNotePath(sourcePath); err != nil {
|
|
||||||
return fmt.Errorf("source: %w", err)
|
|
||||||
}
|
|
||||||
if err := checkNotePath(targetPath); err != nil {
|
|
||||||
return fmt.Errorf("target: %w", err)
|
|
||||||
}
|
|
||||||
srcWing, err := wingOf(sourcePath)
|
srcWing, err := wingOf(sourcePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("source: %w", err)
|
return fmt.Errorf("source: %w", err)
|
||||||
@@ -220,50 +209,20 @@ func WriteTunnel(brainDir, sourcePath, targetPath string) error {
|
|||||||
return fmt.Errorf("tunnel must cross wings; got both in %q", srcWing)
|
return fmt.Errorf("tunnel must cross wings; got both in %q", srcWing)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both notes are read and rewritten through the brain root, regular
|
srcFull := filepath.Join(brainDir, filepath.FromSlash(sourcePath))
|
||||||
// files only: brain_tunnel passes caller-supplied paths, and the brain is
|
tgtFull := filepath.Join(brainDir, filepath.FromSlash(targetPath))
|
||||||
// a Syncthing folder that can hold symlinks.
|
if _, err := os.Stat(srcFull); err != nil {
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open brain: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
srcContent, err := bfs.ReadRegular(sourcePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("source note: %w", err)
|
return fmt.Errorf("source note: %w", err)
|
||||||
}
|
}
|
||||||
tgtContent, err := bfs.ReadRegular(targetPath)
|
if _, err := os.Stat(tgtFull); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("target note: %w", err)
|
return fmt.Errorf("target note: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if out, changed, err := withSeeAlso(srcContent, wikilinkOf(targetPath)); err != nil {
|
if err := appendSeeAlso(srcFull, wikilinkOf(targetPath)); err != nil {
|
||||||
return fmt.Errorf("update source: %w", err)
|
|
||||||
} else if changed {
|
|
||||||
if err := bfs.WriteRegular(sourcePath, out); err != nil {
|
|
||||||
return fmt.Errorf("update source: %w", err)
|
return fmt.Errorf("update source: %w", err)
|
||||||
}
|
}
|
||||||
}
|
if err := appendSeeAlso(tgtFull, wikilinkOf(sourcePath)); err != nil {
|
||||||
if out, changed, err := withSeeAlso(tgtContent, wikilinkOf(sourcePath)); err != nil {
|
|
||||||
return fmt.Errorf("update target: %w", err)
|
return fmt.Errorf("update target: %w", err)
|
||||||
} else if changed {
|
|
||||||
if err := bfs.WriteRegular(targetPath, out); err != nil {
|
|
||||||
return fmt.Errorf("update target: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkNotePath accepts only a clean, local wiki/<wing>/<hall>/.../<slug>.md
|
|
||||||
// path: no "." or ".." segments, no empty segments, no leading slash.
|
|
||||||
func checkNotePath(p string) error {
|
|
||||||
if p == "" || path.Clean(p) != p || !filepath.IsLocal(p) || !strings.HasSuffix(p, ".md") {
|
|
||||||
return fmt.Errorf("not a clean wiki note path: %q", p)
|
|
||||||
}
|
|
||||||
for _, part := range strings.Split(p, "/") {
|
|
||||||
if part == "" || part == "." || part == ".." {
|
|
||||||
return fmt.Errorf("not a clean wiki note path: %q", p)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -289,13 +248,16 @@ func wingOf(relPath string) (string, error) {
|
|||||||
return parts[1], nil
|
return parts[1], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// withSeeAlso returns content with `- [[link]]` under its See also section,
|
// appendSeeAlso inserts `- [[link]]` under the file's See also section,
|
||||||
// creating the section if absent. changed is false when the link is already
|
// creating the section if absent. No-op when the link is already present.
|
||||||
// present.
|
func appendSeeAlso(filePath, link string) error {
|
||||||
func withSeeAlso(content []byte, link string) (out []byte, changed bool, err error) {
|
content, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
wikilink := "[[" + link + "]]"
|
wikilink := "[[" + link + "]]"
|
||||||
if strings.Contains(string(content), wikilink) {
|
if strings.Contains(string(content), wikilink) {
|
||||||
return content, false, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
bullet := "- " + wikilink
|
bullet := "- " + wikilink
|
||||||
@@ -304,7 +266,8 @@ func withSeeAlso(content []byte, link string) (out []byte, changed bool, err err
|
|||||||
// No section yet — append a fresh one. Always emit a trailing
|
// No section yet — append a fresh one. Always emit a trailing
|
||||||
// newline so subsequent appends don't merge into the previous line.
|
// newline so subsequent appends don't merge into the previous line.
|
||||||
trimmed := strings.TrimRight(string(content), "\n")
|
trimmed := strings.TrimRight(string(content), "\n")
|
||||||
return []byte(trimmed + "\n\n" + seeAlsoHeader + "\n\n" + bullet + "\n"), true, nil
|
out := trimmed + "\n\n" + seeAlsoHeader + "\n\n" + bullet + "\n"
|
||||||
|
return os.WriteFile(filePath, []byte(out), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Section exists — splice the bullet in just before the next `## `
|
// Section exists — splice the bullet in just before the next `## `
|
||||||
@@ -330,11 +293,12 @@ func withSeeAlso(content []byte, link string) (out []byte, changed bool, err err
|
|||||||
b.WriteByte('\n')
|
b.WriteByte('\n')
|
||||||
}
|
}
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
return nil, false, err
|
return err
|
||||||
}
|
}
|
||||||
if !inserted {
|
if !inserted {
|
||||||
// section was the last thing in the file — just append bullet
|
// section was the last thing in the file — just append bullet
|
||||||
return []byte(strings.TrimRight(b.String(), "\n") + "\n" + bullet + "\n"), true, nil
|
out := strings.TrimRight(b.String(), "\n") + "\n" + bullet + "\n"
|
||||||
|
return os.WriteFile(filePath, []byte(out), 0o644)
|
||||||
}
|
}
|
||||||
return []byte(b.String()), true, nil
|
return os.WriteFile(filePath, []byte(b.String()), 0o644)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
package brain_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hyperguild#173: brain_tunnel's write followed a directory symlink inside
|
|
||||||
// wiki/ that points elsewhere in the brain.
|
|
||||||
func TestWriteTunnelRefusesATargetBehindASymlinkedDirectory(t *testing.T) {
|
|
||||||
b := t.TempDir()
|
|
||||||
src := filepath.Join(b, "wiki", "a", "facts", "s.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(src), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(src, []byte("x\n"), 0o644))
|
|
||||||
victim := filepath.Join(b, "raw", "facts", "v.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "b"), 0o755))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "facts"), filepath.Join(b, "wiki", "b", "facts")))
|
|
||||||
|
|
||||||
assert.ErrorIs(t, brain.WriteTunnel(b, "wiki/a/facts/s.md", "wiki/b/facts/v.md"), brainfs.ErrNotRegular)
|
|
||||||
got, _ := os.ReadFile(victim)
|
|
||||||
assert.Equal(t, "untouched\n", string(got))
|
|
||||||
got, _ = os.ReadFile(src)
|
|
||||||
assert.Equal(t, "x\n", string(got), "the source is not rewritten either")
|
|
||||||
|
|
||||||
real := filepath.Join(b, "wiki", "c", "facts", "t.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(real), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(real, []byte("t\n"), 0o644))
|
|
||||||
require.NoError(t, brain.WriteTunnel(b, "wiki/a/facts/s.md", "wiki/c/facts/t.md"), "positive control: a tunnel between real directories is written")
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
package brain_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// brain_tunnel passes caller-supplied source and target paths to WriteTunnel,
|
|
||||||
// which checked only "wiki/<wing>/..." and then joined them onto the brain
|
|
||||||
// dir: ".." segments resolved outside the brain and appendSeeAlso rewrote the
|
|
||||||
// file there.
|
|
||||||
func TestWriteTunnelRefusesATargetThatTraversesOutOfTheBrain(t *testing.T) {
|
|
||||||
brain0 := t.TempDir()
|
|
||||||
src := filepath.Join(brain0, "wiki", "a", "facts", "s.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(src), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(src, []byte("---\ntitle: S\n---\nbody\n"), 0o644))
|
|
||||||
victim := filepath.Join(t.TempDir(), "victim.md")
|
|
||||||
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
|
|
||||||
|
|
||||||
up := strings.Repeat("../", strings.Count(filepath.Clean(brain0), "/")+3)
|
|
||||||
target := "wiki/b/x/" + up + strings.TrimPrefix(victim, "/")
|
|
||||||
err := brain.WriteTunnel(brain0, "wiki/a/facts/s.md", target)
|
|
||||||
assert.Error(t, err)
|
|
||||||
got, rerr := os.ReadFile(victim)
|
|
||||||
require.NoError(t, rerr)
|
|
||||||
assert.Equal(t, "untouched\n", string(got), "a file outside the brain was modified")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteTunnelRefusesUncleanOrNonWikiPaths(t *testing.T) {
|
|
||||||
brain0 := t.TempDir()
|
|
||||||
for _, p := range []string{"wiki/a/facts/s.md", "wiki/b/facts/t.md", "wiki/b/facts/t.txt", "raw/r.md"} {
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(brain0, p)), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brain0, p), []byte("x\n"), 0o644))
|
|
||||||
}
|
|
||||||
for _, target := range []string{
|
|
||||||
"wiki/b/facts/../../../raw/r.md",
|
|
||||||
"wiki/b/./facts/t.md",
|
|
||||||
"wiki/b//facts/t.md",
|
|
||||||
"/wiki/b/facts/t.md",
|
|
||||||
"wiki/b/facts/t.txt",
|
|
||||||
"wiki/b/facts/",
|
|
||||||
} {
|
|
||||||
assert.Error(t, brain.WriteTunnel(brain0, "wiki/a/facts/s.md", target), target)
|
|
||||||
}
|
|
||||||
for _, p := range []string{"raw/r.md", "wiki/b/facts/t.txt"} {
|
|
||||||
got, err := os.ReadFile(filepath.Join(brain0, p))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "x\n", string(got), p)
|
|
||||||
}
|
|
||||||
require.NoError(t, brain.WriteTunnel(brain0, "wiki/a/facts/s.md", "wiki/b/facts/t.md"), "positive control")
|
|
||||||
got, err := os.ReadFile(filepath.Join(brain0, "wiki", "b", "facts", "t.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(got), "[[a/facts/s]]")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteTunnelDoesNotWriteThroughASymlinkedNote(t *testing.T) {
|
|
||||||
brain0 := t.TempDir()
|
|
||||||
src := filepath.Join(brain0, "wiki", "a", "facts", "s.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(src), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(src, []byte("x\n"), 0o644))
|
|
||||||
tdir := filepath.Join(brain0, "wiki", "b", "facts")
|
|
||||||
require.NoError(t, os.MkdirAll(tdir, 0o755))
|
|
||||||
victim := filepath.Join(t.TempDir(), "victim.md")
|
|
||||||
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(victim, filepath.Join(tdir, "t.md")))
|
|
||||||
assert.Error(t, brain.WriteTunnel(brain0, "wiki/a/facts/s.md", "wiki/b/facts/t.md"))
|
|
||||||
got, err := os.ReadFile(victim)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "untouched\n", string(got))
|
|
||||||
|
|
||||||
// The same with a link to a file elsewhere in the brain, which os.Root
|
|
||||||
// would follow.
|
|
||||||
inVictim := filepath.Join(brain0, "raw", "v.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(inVictim), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(inVictim, []byte("untouched\n"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "v.md"), filepath.Join(tdir, "u.md")))
|
|
||||||
assert.Error(t, brain.WriteTunnel(brain0, "wiki/a/facts/s.md", "wiki/b/facts/u.md"))
|
|
||||||
got, err = os.ReadFile(inVictim)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "untouched\n", string(got))
|
|
||||||
got, err = os.ReadFile(src)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "x\n", string(got), "the source is not touched when the target is refused")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The target is swapped between a regular note and a symlink to an in-brain
|
|
||||||
// file outside wiki/ while WriteTunnel runs: the read and the write each
|
|
||||||
// refuse a symlink, so the other file is never rewritten.
|
|
||||||
func TestWriteTunnelNeverRewritesASymlinkSwappedInForTheTarget(t *testing.T) {
|
|
||||||
brain0 := t.TempDir()
|
|
||||||
src := filepath.Join(brain0, "wiki", "a", "facts", "s.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(src), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(src, []byte("x\n"), 0o644))
|
|
||||||
tdir := filepath.Join(brain0, "wiki", "b", "facts")
|
|
||||||
require.NoError(t, os.MkdirAll(tdir, 0o755))
|
|
||||||
victim := filepath.Join(brain0, "raw", "victim.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
|
|
||||||
tgt := filepath.Join(tdir, "t.md")
|
|
||||||
require.NoError(t, os.WriteFile(tgt, []byte("t\n"), 0o644))
|
|
||||||
|
|
||||||
var stop atomic.Bool
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
tmp := tgt + ".swap"
|
|
||||||
for i := 0; !stop.Load(); i++ {
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
if i%2 == 0 {
|
|
||||||
_ = os.WriteFile(tmp, []byte("t\n"), 0o644)
|
|
||||||
} else {
|
|
||||||
_ = os.Symlink(filepath.Join("..", "..", "..", "raw", "victim.md"), tmp)
|
|
||||||
}
|
|
||||||
_ = os.Rename(tmp, tgt)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
ok := 0
|
|
||||||
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); {
|
|
||||||
if brain.WriteTunnel(brain0, "wiki/a/facts/s.md", "wiki/b/facts/t.md") == nil {
|
|
||||||
ok++
|
|
||||||
}
|
|
||||||
_ = os.WriteFile(src, []byte("x\n"), 0o644)
|
|
||||||
}
|
|
||||||
stop.Store(true)
|
|
||||||
wg.Wait()
|
|
||||||
got, err := os.ReadFile(victim)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "untouched\n", string(got), "the swapped-in symlink's target was rewritten")
|
|
||||||
assert.Positive(t, ok, "positive control: some tunnels were written to the regular target")
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package brain_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hyperguild#158: BuildWingIndex wrote wiki/<wing>/_index.md with
|
|
||||||
// os.WriteFile, so a symlinked _index.md was written through.
|
|
||||||
func TestBuildWingIndexDoesNotWriteThroughASymlinkedIndex(t *testing.T) {
|
|
||||||
b := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "w", "facts"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(b, "wiki", "w", "facts", "n.md"), []byte("---\ntitle: zqn\n---\n"), 0o644))
|
|
||||||
victim := filepath.Join(b, "raw", "v.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(victim, []byte("untouched"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "v.md"), filepath.Join(b, "wiki", "w", "_index.md")))
|
|
||||||
assert.Error(t, brain.BuildWingIndex(b, "w"))
|
|
||||||
got, _ := os.ReadFile(victim)
|
|
||||||
assert.Equal(t, "untouched", string(got))
|
|
||||||
|
|
||||||
require.NoError(t, os.Remove(filepath.Join(b, "wiki", "w", "_index.md")))
|
|
||||||
require.NoError(t, brain.BuildWingIndex(b, "w"), "positive control")
|
|
||||||
idx, _ := os.ReadFile(filepath.Join(b, "wiki", "w", "_index.md"))
|
|
||||||
assert.Contains(t, string(idx), "zqn")
|
|
||||||
}
|
|
||||||
|
|
||||||
// AutoTunnel's fuzzy-candidate log appended to raw/tunnel-candidates-*.md
|
|
||||||
// with os.OpenFile, so a symlinked raw/ (or log file) was written through.
|
|
||||||
func TestAutoTunnelDoesNotAppendTheCandidateLogThroughASymlinkedRaw(t *testing.T) {
|
|
||||||
b := t.TempDir()
|
|
||||||
outside := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "a", "facts"), 0o755))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "b", "facts"), 0o755))
|
|
||||||
// "zq widget" matches as a substring of "zq widgets", a fuzzy candidate.
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(b, "wiki", "b", "facts", "t.md"), []byte("---\ntitle: Zq Widget\n---\n"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(outside, filepath.Join(b, "raw")))
|
|
||||||
_ = brain.AutoTunnel(b, "wiki/a/facts/s.md", "about zq widgets")
|
|
||||||
ents, _ := os.ReadDir(outside)
|
|
||||||
assert.Empty(t, ents, "the candidate log was written outside the brain")
|
|
||||||
|
|
||||||
require.NoError(t, os.Remove(filepath.Join(b, "raw")))
|
|
||||||
require.NoError(t, brain.AutoTunnel(b, "wiki/a/facts/s.md", "about zq widgets"))
|
|
||||||
logs, _ := filepath.Glob(filepath.Join(b, "raw", "tunnel-candidates-*.md"))
|
|
||||||
assert.Len(t, logs, 1, "positive control: the fuzzy candidate is logged in the real raw/")
|
|
||||||
}
|
|
||||||
@@ -104,17 +104,6 @@ func (b *Brain) ReadDirRegular(relDir string) ([]string, error) {
|
|||||||
return names, nil
|
return names, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanRel refuses a path that is not clean and local. realDir checks the
|
|
||||||
// cleaned parent, so the I/O must use that same path: os.Root resolves ".."
|
|
||||||
// after following a symlink, and a ".." after a symlinked directory would
|
|
||||||
// otherwise pass the check and redirect the read or write.
|
|
||||||
func cleanRel(rel string) error {
|
|
||||||
if rel == "" || path.Clean(rel) != rel || !filepath.IsLocal(rel) {
|
|
||||||
return fmt.Errorf("%q: not a clean brain-relative path: %w", rel, ErrNotRegular)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadDirDirs lists the real subdirectories directly in a brain-relative
|
// ReadDirDirs lists the real subdirectories directly in a brain-relative
|
||||||
// directory, by name. Every component of relDir must be a real directory
|
// directory, by name. Every component of relDir must be a real directory
|
||||||
// (ErrNotRegular otherwise); symlinks to directories are left out.
|
// (ErrNotRegular otherwise); symlinks to directories are left out.
|
||||||
@@ -136,170 +125,9 @@ func (b *Brain) ReadDirDirs(relDir string) ([]string, error) {
|
|||||||
return names, nil
|
return names, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PutRegular creates or replaces a regular file at a brain-relative path,
|
|
||||||
// for creating writes (hyperguild#188, #158). Missing parent directories are
|
|
||||||
// created one component at a time through the root, and every existing
|
|
||||||
// component must be a real directory, so a symlinked directory cannot
|
|
||||||
// redirect the write elsewhere in the brain or outside it. An existing file
|
|
||||||
// is replaced only if it is regular (WriteRegular's checks); a new file is
|
|
||||||
// created with O_EXCL and confirmed to be the file that was opened. A
|
|
||||||
// directory swapped in after the checks is the #167 residual.
|
|
||||||
func (b *Brain) PutRegular(rel string, data []byte) error {
|
|
||||||
if err := cleanRel(rel); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := b.mkdirReal(path.Dir(rel)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := b.root.Lstat(rel); err == nil {
|
|
||||||
return b.WriteRegular(rel, data)
|
|
||||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return b.createNew(rel, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateRegular creates a new regular file at a brain-relative path, with
|
|
||||||
// PutRegular's parent rules, and fails with fs.ErrExist if anything is
|
|
||||||
// already there: for writers that must never overwrite (promote, reject).
|
|
||||||
func (b *Brain) CreateRegular(rel string, data []byte) error {
|
|
||||||
if err := cleanRel(rel); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := b.mkdirReal(path.Dir(rel)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return b.createNew(rel, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// createNew creates rel with O_EXCL and writes data only after confirming
|
|
||||||
// the path is the regular file it created.
|
|
||||||
func (b *Brain) createNew(rel string, data []byte) error {
|
|
||||||
f, err := b.root.OpenFile(rel, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() { _ = f.Close() }()
|
|
||||||
hfi, herr := f.Stat()
|
|
||||||
lfi, lerr := b.root.Lstat(rel)
|
|
||||||
if herr != nil || lerr != nil || !lfi.Mode().IsRegular() || !os.SameFile(lfi, hfi) {
|
|
||||||
return fmt.Errorf("%s: changed while creating: %w", rel, ErrNotRegular)
|
|
||||||
}
|
|
||||||
if _, err := f.Write(data); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return f.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppendRegular appends data to a regular file at a brain-relative path,
|
|
||||||
// creating it (and missing parent directories, as PutRegular does) if it is
|
|
||||||
// absent. It writes only after confirming the path is the regular file it
|
|
||||||
// opened, so a final symlink or special file, or one swapped in, is refused.
|
|
||||||
func (b *Brain) AppendRegular(rel string, data []byte) error {
|
|
||||||
if err := cleanRel(rel); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := b.mkdirReal(path.Dir(rel)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if fi, err := b.root.Lstat(rel); err == nil && !fi.Mode().IsRegular() {
|
|
||||||
return fmt.Errorf("%s: %w", rel, ErrNotRegular)
|
|
||||||
} else if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
f, err := b.root.OpenFile(rel, os.O_WRONLY|os.O_APPEND|os.O_CREATE|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() { _ = f.Close() }()
|
|
||||||
hfi, herr := f.Stat()
|
|
||||||
lfi, lerr := b.root.Lstat(rel)
|
|
||||||
if herr != nil || lerr != nil || !lfi.Mode().IsRegular() || !os.SameFile(lfi, hfi) {
|
|
||||||
return fmt.Errorf("%s: changed while opening: %w", rel, ErrNotRegular)
|
|
||||||
}
|
|
||||||
if _, err := f.Write(data); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return f.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove removes the file at a clean brain-relative path through the root,
|
|
||||||
// refusing a path behind a symlinked directory. A final symlink is removed
|
|
||||||
// itself, never its target.
|
|
||||||
func (b *Brain) Remove(rel string) error {
|
|
||||||
if err := b.RealParent(rel); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return b.root.Remove(rel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RealParent returns ErrNotRegular unless rel is clean and every directory
|
|
||||||
// above it is a real directory, for writers that do their own I/O (an atomic
|
|
||||||
// rename) but must not be redirected by a symlinked directory.
|
|
||||||
func (b *Brain) RealParent(rel string) error {
|
|
||||||
if err := cleanRel(rel); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return b.realDir(path.Dir(rel))
|
|
||||||
}
|
|
||||||
|
|
||||||
// PutFile opens the brain dir as a root and calls PutRegular, for one-off
|
|
||||||
// writers that hold no Brain.
|
|
||||||
func PutFile(brainDir, rel string, data []byte) error {
|
|
||||||
b, err := Open(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() { _ = b.Close() }()
|
|
||||||
return b.PutRegular(rel, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppendFile opens the brain dir as a root and calls AppendRegular.
|
|
||||||
func AppendFile(brainDir, rel string, data []byte) error {
|
|
||||||
b, err := Open(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() { _ = b.Close() }()
|
|
||||||
return b.AppendRegular(rel, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mkdirReal makes sure every component of rel exists as a real directory,
|
|
||||||
// creating missing ones through the root; an existing component that is
|
|
||||||
// not a real directory is ErrNotRegular.
|
|
||||||
func (b *Brain) mkdirReal(rel string) error {
|
|
||||||
if rel == "." {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
prefix := ""
|
|
||||||
for _, part := range strings.Split(rel, "/") {
|
|
||||||
prefix = path.Join(prefix, part)
|
|
||||||
fi, err := b.root.Lstat(prefix)
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
if err := b.root.Mkdir(prefix, 0o755); err != nil && !errors.Is(err, fs.ErrExist) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fi, err = b.root.Lstat(prefix)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !fi.IsDir() {
|
|
||||||
return fmt.Errorf("%s: component %s is not a directory: %w", rel, prefix, ErrNotRegular)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadRegular reads a brain-relative path, refusing anything that is not a
|
// ReadRegular reads a brain-relative path, refusing anything that is not a
|
||||||
// regular file, both before the open and on the opened handle.
|
// regular file, both before the open and on the opened handle.
|
||||||
func (b *Brain) ReadRegular(rel string) ([]byte, error) {
|
func (b *Brain) ReadRegular(rel string) ([]byte, error) {
|
||||||
if err := cleanRel(rel); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := b.realDir(path.Dir(rel)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
fi, err := b.root.Lstat(rel)
|
fi, err := b.root.Lstat(rel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -326,12 +154,6 @@ func (b *Brain) readOpened(rel string) ([]byte, error) { return b.readOpenedMax(
|
|||||||
// ReadRegularMax is ReadRegular with a size limit, checked on the opened
|
// ReadRegularMax is ReadRegular with a size limit, checked on the opened
|
||||||
// handle before reading (max < 0 means no limit).
|
// handle before reading (max < 0 means no limit).
|
||||||
func (b *Brain) ReadRegularMax(rel string, max int64) ([]byte, error) {
|
func (b *Brain) ReadRegularMax(rel string, max int64) ([]byte, error) {
|
||||||
if err := cleanRel(rel); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := b.realDir(path.Dir(rel)); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
fi, err := b.root.Lstat(rel)
|
fi, err := b.root.Lstat(rel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -427,51 +249,3 @@ func (b *Brain) WalkRegular(relDir string, fn func(rel string) error, onErr func
|
|||||||
return fn(p)
|
return fn(p)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteRegular replaces the contents of an existing regular file at a
|
|
||||||
// brain-relative path. It never creates a file and never writes through a
|
|
||||||
// final symlink: os.Root.OpenFile follows an in-root final symlink even with
|
|
||||||
// O_NOFOLLOW (verified on go1.27), so the path is Lstat-ed through the root
|
|
||||||
// first and the opened handle must be that same file (os.SameFile), which
|
|
||||||
// also catches a swap between the two. O_NONBLOCK keeps a FIFO from blocking
|
|
||||||
// the open. Every directory above the file must be a real directory
|
|
||||||
// (hyperguild#173); a directory swapped in after that check is the #167
|
|
||||||
// residual.
|
|
||||||
func (b *Brain) WriteRegular(rel string, data []byte) error {
|
|
||||||
if err := cleanRel(rel); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Every directory above the file must be real: os.Root follows an
|
|
||||||
// in-brain intermediate symlink, which would redirect the write
|
|
||||||
// elsewhere in the brain (hyperguild#173). A swap after this check is
|
|
||||||
// the #167 residual.
|
|
||||||
if err := b.realDir(path.Dir(rel)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
lfi, err := b.root.Lstat(rel)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !lfi.Mode().IsRegular() {
|
|
||||||
return fmt.Errorf("%s: %w", rel, ErrNotRegular)
|
|
||||||
}
|
|
||||||
f, err := b.root.OpenFile(rel, os.O_WRONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() { _ = f.Close() }()
|
|
||||||
fi, err := f.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !fi.Mode().IsRegular() || !os.SameFile(lfi, fi) {
|
|
||||||
return fmt.Errorf("%s: changed while opening: %w", rel, ErrNotRegular)
|
|
||||||
}
|
|
||||||
if err := f.Truncate(0); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := f.Write(data); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return f.Close()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ func TestReadDirRegularListsOnlyRegularFilesAndRefusesSymlinkedComponents(t *tes
|
|||||||
// A final component swapped for a symlink to another regular file in the
|
// A final component swapped for a symlink to another regular file in the
|
||||||
// brain between the Lstat and the open: os.Root follows in-root links, so
|
// brain between the Lstat and the open: os.Root follows in-root links, so
|
||||||
// only comparing the two (os.SameFile) refuses it.
|
// only comparing the two (os.SameFile) refuses it.
|
||||||
func TestReadRegularDoesNotReturnAnInBrainFileSwappedInBehindASymlink(t *testing.T) {
|
func TestReadRegularNeverReturnsAnInBrainFileSwappedInBehindASymlink(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "s.md"), []byte("zqsamefilesentinel"), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "s.md"), []byte("zqsamefilesentinel"), 0o644))
|
||||||
p := filepath.Join(dir, "r.md")
|
p := filepath.Join(dir, "r.md")
|
||||||
@@ -263,52 +263,6 @@ func TestReadRegularDoesNotReturnAnInBrainFileSwappedInBehindASymlink(t *testing
|
|||||||
assert.Positive(t, ok, "positive control: the regular file is read")
|
assert.Positive(t, ok, "positive control: the regular file is read")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriteRegularRefusesFinalSymlinksFIFOsEscapesAndMissingFilesAndRewritesRegularFiles(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
outside := filepath.Join(t.TempDir(), "o.md")
|
|
||||||
require.NoError(t, os.WriteFile(outside, []byte("untouched"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "l.md")))
|
|
||||||
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "f.md"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "n.md"), []byte("old content"), 0o644))
|
|
||||||
b, err := Open(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = b.Close() }()
|
|
||||||
|
|
||||||
assert.Error(t, b.WriteRegular("l.md", []byte("x")))
|
|
||||||
got, _ := os.ReadFile(outside)
|
|
||||||
assert.Equal(t, "untouched", string(got))
|
|
||||||
|
|
||||||
// A symlink to another file inside the brain: os.Root would allow it,
|
|
||||||
// only O_NOFOLLOW refuses it.
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "in.md"), []byte("inside"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink("in.md", filepath.Join(dir, "il.md")))
|
|
||||||
assert.Error(t, b.WriteRegular("il.md", []byte("x")))
|
|
||||||
got, _ = os.ReadFile(filepath.Join(dir, "in.md"))
|
|
||||||
assert.Equal(t, "inside", string(got))
|
|
||||||
|
|
||||||
// A reader held open, so a non-blocking write open would succeed. The
|
|
||||||
// Lstat type check refuses the FIFO first; the handle check and
|
|
||||||
// O_NONBLOCK matter only if it is swapped in after the Lstat.
|
|
||||||
r, err := os.OpenFile(filepath.Join(dir, "f.md"), os.O_RDONLY|syscall.O_NONBLOCK, 0)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = r.Close() }()
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- b.WriteRegular("f.md", []byte("x")) }()
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
assert.Error(t, err)
|
|
||||||
case <-time.After(3 * time.Second):
|
|
||||||
t.Fatal("WriteRegular hung on a FIFO")
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Error(t, b.WriteRegular("../"+filepath.Base(filepath.Dir(outside))+"/o.md", []byte("x")))
|
|
||||||
assert.Error(t, b.WriteRegular("missing.md", []byte("x")), "does not create files")
|
|
||||||
|
|
||||||
require.NoError(t, b.WriteRegular("n.md", []byte("new")))
|
|
||||||
got, _ = os.ReadFile(filepath.Join(dir, "n.md"))
|
|
||||||
assert.Equal(t, "new", string(got), "truncated and rewritten")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadDirDirsListsOnlyRealSubdirsAndRefusesSymlinkedComponents(t *testing.T) {
|
func TestReadDirDirsListsOnlyRealSubdirsAndRefusesSymlinkedComponents(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "a", "real"), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "a", "real"), 0o755))
|
||||||
@@ -326,223 +280,3 @@ func TestReadDirDirsListsOnlyRealSubdirsAndRefusesSymlinkedComponents(t *testing
|
|||||||
_, err = b.ReadDirDirs("alias")
|
_, err = b.ReadDirDirs("alias")
|
||||||
assert.ErrorIs(t, err, ErrNotRegular)
|
assert.ErrorIs(t, err, ErrNotRegular)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hyperguild#173: a file reached through a symlinked directory inside the
|
|
||||||
// brain is refused for reads and writes, so a caller-supplied path cannot be
|
|
||||||
// redirected to another part of the brain.
|
|
||||||
func TestReadAndWriteRegularRefuseAFileBehindASymlinkedDirectory(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(dir, "wiki", "alias")))
|
|
||||||
b, err := Open(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = b.Close() }()
|
|
||||||
|
|
||||||
_, err = b.ReadRegular("wiki/alias/v.md")
|
|
||||||
assert.ErrorIs(t, err, ErrNotRegular)
|
|
||||||
_, err = b.ReadRegularMax("wiki/alias/v.md", 100)
|
|
||||||
assert.ErrorIs(t, err, ErrNotRegular)
|
|
||||||
assert.ErrorIs(t, b.WriteRegular("wiki/alias/v.md", []byte("x")), ErrNotRegular)
|
|
||||||
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
|
|
||||||
assert.Equal(t, "untouched", string(got))
|
|
||||||
got, err = b.ReadRegular("raw/v.md")
|
|
||||||
require.NoError(t, err, "positive control: the real path still reads")
|
|
||||||
assert.Equal(t, "untouched", string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
// realDir checks the cleaned parent, so the I/O must use that same clean
|
|
||||||
// path: os.Root resolves ".." after following a symlink, and a ".." placed
|
|
||||||
// after a symlinked directory would otherwise pass the check and redirect.
|
|
||||||
func TestReadAndWriteRegularRefuseAPathThatIsNotClean(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "facts"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "raw", "facts"), filepath.Join(dir, "wiki", "alias")))
|
|
||||||
b, err := Open(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = b.Close() }()
|
|
||||||
|
|
||||||
for _, rel := range []string{"wiki/alias/../v.md", "./raw/v.md", "raw//v.md", "/raw/v.md"} {
|
|
||||||
got, err := b.ReadRegular(rel)
|
|
||||||
assert.ErrorIs(t, err, ErrNotRegular, rel)
|
|
||||||
assert.NotContains(t, string(got), "untouched", rel)
|
|
||||||
_, err = b.ReadRegularMax(rel, 100)
|
|
||||||
assert.ErrorIs(t, err, ErrNotRegular, rel)
|
|
||||||
assert.ErrorIs(t, b.WriteRegular(rel, []byte("OVERWRITTEN")), ErrNotRegular, rel)
|
|
||||||
}
|
|
||||||
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
|
|
||||||
assert.Equal(t, "untouched", string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
// hyperguild#188: PutRegular creates or replaces a regular file through the
|
|
||||||
// root. Missing parent directories are created; an existing component that
|
|
||||||
// is not a real directory, a final symlink or special file, and an unclean
|
|
||||||
// path are refused, and nothing is created at a link's target.
|
|
||||||
func TestPutRegularCreatesOrReplacesOnlyThroughRealDirectories(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
outside := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(dir, "wiki", "inwing")))
|
|
||||||
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "wiki", "outwing")))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "w"), 0o755))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "v.md"), filepath.Join(dir, "wiki", "w", "link.md")))
|
|
||||||
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "wiki", "w", "f.md"), 0o644))
|
|
||||||
b, err := Open(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = b.Close() }()
|
|
||||||
|
|
||||||
for _, rel := range []string{"wiki/inwing/facts/n.md", "wiki/outwing/facts/n.md", "wiki/w/link.md", "wiki/w/f.md", "wiki/w/../../raw/v.md", "/abs.md"} {
|
|
||||||
assert.ErrorIs(t, b.PutRegular(rel, []byte("x")), ErrNotRegular, rel)
|
|
||||||
}
|
|
||||||
ents, _ := os.ReadDir(outside)
|
|
||||||
assert.Empty(t, ents, "nothing created outside the brain")
|
|
||||||
_, err = os.Stat(filepath.Join(dir, "raw", "facts"))
|
|
||||||
assert.True(t, os.IsNotExist(err), "nothing created through the in-brain link")
|
|
||||||
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
|
|
||||||
assert.Equal(t, "untouched", string(got))
|
|
||||||
|
|
||||||
require.NoError(t, b.PutRegular("wiki/new/facts/n.md", []byte("one")), "positive control: create with missing parents")
|
|
||||||
got, _ = os.ReadFile(filepath.Join(dir, "wiki", "new", "facts", "n.md"))
|
|
||||||
assert.Equal(t, "one", string(got))
|
|
||||||
require.NoError(t, b.PutRegular("wiki/new/facts/n.md", []byte("two")), "positive control: replace")
|
|
||||||
got, _ = os.ReadFile(filepath.Join(dir, "wiki", "new", "facts", "n.md"))
|
|
||||||
assert.Equal(t, "two", string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAppendRegularCreatesOrAppendsOnlyThroughRealDirectories(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
outside := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "outdir")))
|
|
||||||
require.NoError(t, os.Symlink("raw", filepath.Join(dir, "indir")))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("raw", "v.md"), filepath.Join(dir, "log.md")))
|
|
||||||
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "f.md"), 0o644))
|
|
||||||
b, err := Open(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = b.Close() }()
|
|
||||||
|
|
||||||
for _, rel := range []string{"outdir/l.md", "indir/v.md", "log.md", "f.md", "raw/../log.md"} {
|
|
||||||
assert.ErrorIs(t, b.AppendRegular(rel, []byte("x")), ErrNotRegular, rel)
|
|
||||||
}
|
|
||||||
ents, _ := os.ReadDir(outside)
|
|
||||||
assert.Empty(t, ents)
|
|
||||||
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
|
|
||||||
assert.Equal(t, "untouched", string(got))
|
|
||||||
|
|
||||||
require.NoError(t, b.AppendRegular("logs/a.md", []byte("one\n")), "positive control: create")
|
|
||||||
require.NoError(t, b.AppendRegular("logs/a.md", []byte("two\n")), "positive control: append")
|
|
||||||
got, _ = os.ReadFile(filepath.Join(dir, "logs", "a.md"))
|
|
||||||
assert.Equal(t, "one\ntwo\n", string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRealParentRefusesASymlinkedDirectoryOrUncleanPath(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(dir, "wiki", "alias")))
|
|
||||||
b, err := Open(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = b.Close() }()
|
|
||||||
assert.ErrorIs(t, b.RealParent("wiki/alias/x.md"), ErrNotRegular)
|
|
||||||
assert.ErrorIs(t, b.RealParent("wiki/../raw/x.md"), ErrNotRegular)
|
|
||||||
assert.NoError(t, b.RealParent("wiki/x.md"), "positive control")
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanRel must refuse an unclean path on its own: here the ".." resolves,
|
|
||||||
// after a symlinked directory, to a real regular file, so only the clean
|
|
||||||
// check stands between the call and that file.
|
|
||||||
func TestAppendRegularRefusesAnUncleanPathThatResolvesToARealFile(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "keep"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("raw", "keep"), filepath.Join(dir, "indir")))
|
|
||||||
b, err := Open(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = b.Close() }()
|
|
||||||
assert.ErrorIs(t, b.AppendRegular("indir/../v.md", []byte("x")), ErrNotRegular)
|
|
||||||
assert.ErrorIs(t, b.PutRegular("indir/../v.md", []byte("x")), ErrNotRegular)
|
|
||||||
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
|
|
||||||
assert.Equal(t, "untouched", string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRemoveRefusesAPathBehindASymlinkedDirectoryAndRemovesOnlyTheLink(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("keep"), 0o644))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(dir, "wiki", "alias")))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "raw", "v.md"), filepath.Join(dir, "wiki", "link.md")))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki", "n.md"), []byte("n"), 0o644))
|
|
||||||
b, err := Open(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = b.Close() }()
|
|
||||||
|
|
||||||
assert.ErrorIs(t, b.Remove("wiki/alias/v.md"), ErrNotRegular)
|
|
||||||
_, err = os.Stat(filepath.Join(dir, "raw", "v.md"))
|
|
||||||
assert.NoError(t, err, "the file behind the symlinked directory is kept")
|
|
||||||
require.NoError(t, b.Remove("wiki/link.md"))
|
|
||||||
_, err = os.Stat(filepath.Join(dir, "raw", "v.md"))
|
|
||||||
assert.NoError(t, err, "removing a final symlink never removes its target")
|
|
||||||
require.NoError(t, b.Remove("wiki/n.md"), "positive control")
|
|
||||||
_, err = os.Stat(filepath.Join(dir, "wiki", "n.md"))
|
|
||||||
assert.True(t, os.IsNotExist(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateRegular is the only write path for promote and reject: it creates
|
|
||||||
// missing parents through the root, refuses an in-brain symlinked parent and
|
|
||||||
// an unclean path, and never replaces anything already there (fs.ErrExist,
|
|
||||||
// content unchanged), including a dangling link.
|
|
||||||
func TestCreateRegularCreatesOnlyNewFilesThroughRealDirectories(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "keep"), 0o755))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(dir, "wiki", "inwing")))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("raw", "keep"), filepath.Join(dir, "indir")))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki", "exists.md"), []byte("original"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink("nowhere.md", filepath.Join(dir, "wiki", "dangling.md")))
|
|
||||||
b, err := Open(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = b.Close() }()
|
|
||||||
|
|
||||||
assert.ErrorIs(t, b.CreateRegular("wiki/inwing/facts/n.md", []byte("x")), ErrNotRegular)
|
|
||||||
_, err = os.Stat(filepath.Join(dir, "raw", "facts"))
|
|
||||||
assert.True(t, os.IsNotExist(err), "nothing created through the in-brain link")
|
|
||||||
assert.ErrorIs(t, b.CreateRegular("indir/../v.md", []byte("x")), ErrNotRegular)
|
|
||||||
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
|
|
||||||
assert.Equal(t, "untouched", string(got))
|
|
||||||
|
|
||||||
assert.ErrorIs(t, b.CreateRegular("wiki/exists.md", []byte("x")), fs.ErrExist)
|
|
||||||
got, _ = os.ReadFile(filepath.Join(dir, "wiki", "exists.md"))
|
|
||||||
assert.Equal(t, "original", string(got))
|
|
||||||
assert.ErrorIs(t, b.CreateRegular("wiki/dangling.md", []byte("x")), fs.ErrExist)
|
|
||||||
_, err = os.Stat(filepath.Join(dir, "wiki", "nowhere.md"))
|
|
||||||
assert.True(t, os.IsNotExist(err), "nothing created at the dangling link's target")
|
|
||||||
|
|
||||||
require.NoError(t, b.CreateRegular("wiki/new/facts/n.md", []byte("one")), "positive control: create with missing parents")
|
|
||||||
got, _ = os.ReadFile(filepath.Join(dir, "wiki", "new", "facts", "n.md"))
|
|
||||||
assert.Equal(t, "one", string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
// On Linux a backslash is an ordinary filename byte, not a separator, so a
|
|
||||||
// name containing one stays a single in-root component (hyperguild#158: the
|
|
||||||
// watcher retried such a note forever while cleanRel refused it).
|
|
||||||
func TestReadAndPutRegularTreatABackslashAsAFilenameByte(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
b, err := Open(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = b.Close() }()
|
|
||||||
require.NoError(t, b.PutRegular(`a\..\b.md`, []byte("x")))
|
|
||||||
got, err := os.ReadFile(filepath.Join(dir, `a\..\b.md`))
|
|
||||||
require.NoError(t, err, "the name was not kept as one component")
|
|
||||||
assert.Equal(t, "x", string(got))
|
|
||||||
got, err = b.ReadRegular(`a\..\b.md`)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "x", string(got))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
// Package brainfstest is the shared harness for hyperguild#157's swap-race
|
// Package brainfstest is the shared harness for hyperguild#157's swap-race
|
||||||
// tests: it keeps swapping a regular note, a symlink to a sentinel target file
|
// tests: it keeps swapping a regular note, a symlink to an outside file, or a
|
||||||
// (outside the brain, or inside it for hyperguild#171), or a FIFO (with a
|
// FIFO (with a writer held open) into a path while a reader under test loops.
|
||||||
// writer held open) into a path while a reader under test loops.
|
// It observes two things: no sentinel byte from the outside file reaches the
|
||||||
// It observes two things, over a sampled window only: no sentinel byte from
|
// reader's output, and the reader returns within a timeout. It does not cover
|
||||||
// the target reaches the reader's output, and the reader returns within a
|
|
||||||
// timeout. It does not cover
|
|
||||||
// hard links, other paths into the reader, or races it does not generate.
|
// hard links, other paths into the reader, or races it does not generate.
|
||||||
package brainfstest
|
package brainfstest
|
||||||
|
|
||||||
@@ -20,7 +18,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sentinel marks the target file's contents.
|
// Sentinel marks the outside file's contents.
|
||||||
const Sentinel = "zqbrainfssentinelonefiftyseven"
|
const Sentinel = "zqbrainfssentinelonefiftyseven"
|
||||||
|
|
||||||
// outsideContent carries Sentinel in every field a reader might extract: the
|
// outsideContent carries Sentinel in every field a reader might extract: the
|
||||||
@@ -143,7 +141,7 @@ func AssertNoLeakFrom(t testing.TB, path, inside, target string, d time.Duration
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if leaks > 0 {
|
if leaks > 0 {
|
||||||
t.Fatalf("symlink target %s leaked %d times in %d iterations", target, leaks, iters)
|
t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters)
|
||||||
}
|
}
|
||||||
if n := swaps.Load(); n == 0 {
|
if n := swaps.Load(); n == 0 {
|
||||||
t.Fatalf("no symlink was swapped into %s in %d iterations: the race did not run", path, iters)
|
t.Fatalf("no symlink was swapped into %s in %d iterations: the race did not run", path, iters)
|
||||||
|
|||||||
@@ -16,10 +16,9 @@
|
|||||||
package classification
|
package classification
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
"os"
|
||||||
"io/fs"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -111,20 +110,9 @@ type rawConfig struct {
|
|||||||
func Load(brainDir string) (*Config, error) {
|
func Load(brainDir string) (*Config, error) {
|
||||||
cfg := &Config{wings: map[string]Level{}, repos: map[string]Level{}}
|
cfg := &Config{wings: map[string]Level{}, repos: map[string]Level{}}
|
||||||
|
|
||||||
// Read through the brain root, regular files only (hyperguild#157): a
|
data, err := os.ReadFile(filepath.Join(brainDir, "classification.yaml"))
|
||||||
// symlinked or special file is not trusted as the config, which is a hard
|
|
||||||
// error like a malformed one.
|
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open brain: %w", err)
|
if os.IsNotExist(err) {
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
data, err := bfs.ReadRegular("classification.yaml")
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("read classification.yaml: %w", err)
|
return nil, fmt.Errorf("read classification.yaml: %w", err)
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
package classification
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hyperguild#157 slice 6: Load read classification.yaml with os.ReadFile. It
|
|
||||||
// sets the I1 sovereignty levels; a symlinked file must not be trusted as the
|
|
||||||
// config (a hard error, as for a malformed file), and a FIFO must not hang
|
|
||||||
// startup.
|
|
||||||
func TestLoadRefusesASymlinkedOrFIFOClassificationFile(t *testing.T) {
|
|
||||||
ok := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(ok, "classification.yaml"), []byte("wings:\n w: confidential\n"), 0o644))
|
|
||||||
_, err := Load(ok)
|
|
||||||
require.NoError(t, err, "positive control")
|
|
||||||
|
|
||||||
linked := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(linked, "real.yaml"), []byte("wings:\n w: public\n"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink("real.yaml", filepath.Join(linked, "classification.yaml")))
|
|
||||||
_, err = Load(linked)
|
|
||||||
assert.Error(t, err, "an in-brain symlink is not trusted as the config")
|
|
||||||
|
|
||||||
fifo := t.TempDir()
|
|
||||||
require.NoError(t, syscall.Mkfifo(filepath.Join(fifo, "classification.yaml"), 0o644))
|
|
||||||
w, err := os.OpenFile(filepath.Join(fifo, "classification.yaml"), os.O_RDWR, 0)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = w.Close() }()
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { _, err := Load(fifo); done <- err }()
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
assert.Error(t, err)
|
|
||||||
case <-time.After(3 * time.Second):
|
|
||||||
t.Fatal("Load hung on a FIFO")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = Load(t.TempDir())
|
|
||||||
assert.NoError(t, err, "an absent file is still not an error")
|
|
||||||
}
|
|
||||||
@@ -88,6 +88,7 @@ func TestNoPackageReferencesExtractTextOutsideExtract(t *testing.T) {
|
|||||||
// osOpenAllowed names the functions in guarded files that may call os.Open*
|
// osOpenAllowed names the functions in guarded files that may call os.Open*
|
||||||
// on a path that is not a raw/ read.
|
// on a path that is not a raw/ read.
|
||||||
var osOpenAllowed = map[string]bool{
|
var osOpenAllowed = map[string]bool{
|
||||||
|
"internal/watcher/watcher.go:appendWatcherLog": true, // ingest log append, not a raw/ read
|
||||||
// Text reads by path; outside this package only TextIn's temp copy reaches
|
// Text reads by path; outside this package only TextIn's temp copy reaches
|
||||||
// it (TestNoPackageReferencesExtractTextOutsideExtract enforces that).
|
// it (TestNoPackageReferencesExtractTextOutsideExtract enforces that).
|
||||||
"internal/extract/extract.go:Text": true,
|
"internal/extract/extract.go:Text": true,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
package extract
|
package extract
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -51,18 +50,6 @@ func fileExt(path string) string {
|
|||||||
// external tool (pdf, docmark) are copied to a temp file from the handle
|
// external tool (pdf, docmark) are copied to a temp file from the handle
|
||||||
// opened through the root, then extracted from the copy.
|
// opened through the root, then extracted from the copy.
|
||||||
func TextIn(r *os.Root, rel string) (string, error) {
|
func TextIn(r *os.Root, rel string) (string, error) {
|
||||||
return TextInMax(r, rel, DefaultMaxBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultMaxBytes bounds what TextIn reads or copies from one file.
|
|
||||||
const DefaultMaxBytes = 64 << 20
|
|
||||||
|
|
||||||
// ErrTooLarge reports a file that exceeded the read cap while being read: the
|
|
||||||
// size seen at an earlier Lstat is not a bound, the file may have grown.
|
|
||||||
var ErrTooLarge = errors.New("file exceeds the read cap")
|
|
||||||
|
|
||||||
// TextInMax is TextIn with an explicit cap on the bytes read (hyperguild#198).
|
|
||||||
func TextInMax(r *os.Root, rel string, max int64) (string, error) {
|
|
||||||
fi, err := r.Lstat(rel)
|
fi, err := r.Lstat(rel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("stat %s: %w", rel, err)
|
return "", fmt.Errorf("stat %s: %w", rel, err)
|
||||||
@@ -78,13 +65,10 @@ func TextInMax(r *os.Root, rel string, max int64) (string, error) {
|
|||||||
ext := strings.ToLower(fileExt(rel))
|
ext := strings.ToLower(fileExt(rel))
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".md", ".txt":
|
case ".md", ".txt":
|
||||||
b, err := io.ReadAll(io.LimitReader(f, max+1))
|
b, err := io.ReadAll(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read %s: %w", rel, err)
|
return "", fmt.Errorf("read %s: %w", rel, err)
|
||||||
}
|
}
|
||||||
if int64(len(b)) > max {
|
|
||||||
return "", fmt.Errorf("%s: %w (%d bytes)", rel, ErrTooLarge, max)
|
|
||||||
}
|
|
||||||
return string(b), nil
|
return string(b), nil
|
||||||
case ".pdf", ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg":
|
case ".pdf", ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg":
|
||||||
tmp, err := os.CreateTemp("", "ingest-*"+ext)
|
tmp, err := os.CreateTemp("", "ingest-*"+ext)
|
||||||
@@ -92,15 +76,10 @@ func TextInMax(r *os.Root, rel string, max int64) (string, error) {
|
|||||||
return "", fmt.Errorf("temp copy: %w", err)
|
return "", fmt.Errorf("temp copy: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = os.Remove(tmp.Name()) }()
|
defer func() { _ = os.Remove(tmp.Name()) }()
|
||||||
n, err := io.Copy(tmp, io.LimitReader(f, max+1))
|
if _, err := io.Copy(tmp, f); err != nil {
|
||||||
if err != nil {
|
|
||||||
_ = tmp.Close()
|
_ = tmp.Close()
|
||||||
return "", fmt.Errorf("copy %s: %w", rel, err)
|
return "", fmt.Errorf("copy %s: %w", rel, err)
|
||||||
}
|
}
|
||||||
if n > max {
|
|
||||||
_ = tmp.Close()
|
|
||||||
return "", fmt.Errorf("%s: %w (%d bytes)", rel, ErrTooLarge, max)
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
if err := tmp.Close(); err != nil {
|
||||||
return "", fmt.Errorf("copy %s: %w", rel, err)
|
return "", fmt.Errorf("copy %s: %w", rel, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
package extract
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func rootWith(t *testing.T, name string, size int) *os.Root {
|
|
||||||
t.Helper()
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(strings.Repeat("x", size)), 0o644))
|
|
||||||
r, err := os.OpenRoot(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { _ = r.Close() })
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// hyperguild#198: the size check in the watcher is a pre-check on Lstat; a note
|
|
||||||
// that grows before it is read must still be capped at the read.
|
|
||||||
func TestTextInMax(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
size int
|
|
||||||
max int64
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{"under the cap", 100, 1024, false},
|
|
||||||
{"exactly at the cap", 1024, 1024, false},
|
|
||||||
{"one byte over", 1025, 1024, true},
|
|
||||||
{"far over", 1 << 20, 1024, true},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
r := rootWith(t, "n.md", tt.size)
|
|
||||||
got, err := TextInMax(r, "n.md", tt.max)
|
|
||||||
if tt.wantErr {
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.True(t, errors.Is(err, ErrTooLarge))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, got, tt.size)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTextInMax_TheBinaryTempCopyIsCappedToo(t *testing.T) {
|
|
||||||
r := rootWith(t, "n.pdf", 4096)
|
|
||||||
_, err := TextInMax(r, "n.pdf", 1024)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.True(t, errors.Is(err, ErrTooLarge), "the temp copy of a pdf must not exceed the cap either: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTextIn_HasADefaultCap(t *testing.T) {
|
|
||||||
assert.Positive(t, DefaultMaxBytes)
|
|
||||||
}
|
|
||||||
@@ -94,7 +94,6 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"wing": str("target wing, e.g. jepa-fx"),
|
"wing": str("target wing, e.g. jepa-fx"),
|
||||||
"hall": enum("target hall", halls...),
|
"hall": enum("target hall", halls...),
|
||||||
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
||||||
"queue": enum("which brain_pending listing the note is in; required only when the name exists in both", "inbox", "needs-review"),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -103,7 +102,6 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"inputSchema": schema([]string{"filename"}, map[string]any{
|
"inputSchema": schema([]string{"filename"}, map[string]any{
|
||||||
"filename": str("basename in brain/raw/, e.g. 2026-04-23-fed-sentiment-scrape.md"),
|
"filename": str("basename in brain/raw/, e.g. 2026-04-23-fed-sentiment-scrape.md"),
|
||||||
"reason": str("optional; why this isn't worth promoting"),
|
"reason": str("optional; why this isn't worth promoting"),
|
||||||
"queue": enum("which brain_pending listing the note is in; required only when the name exists in both", "inbox", "needs-review"),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
noteToolDescriptors()[0],
|
noteToolDescriptors()[0],
|
||||||
@@ -385,7 +383,6 @@ type brainPromoteArgs struct {
|
|||||||
Wing string `json:"wing"`
|
Wing string `json:"wing"`
|
||||||
Hall string `json:"hall"`
|
Hall string `json:"hall"`
|
||||||
Slug string `json:"slug,omitempty"`
|
Slug string `json:"slug,omitempty"`
|
||||||
Queue string `json:"queue,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// brainPromote moves a raw/ note into the structured wiki (frontmatter
|
// brainPromote moves a raw/ note into the structured wiki (frontmatter
|
||||||
@@ -398,7 +395,7 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
|
|||||||
}
|
}
|
||||||
origin, writtenBy := provenanceFrom(ctx)
|
origin, writtenBy := provenanceFrom(ctx)
|
||||||
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
|
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
|
||||||
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug, Queue: a.Queue,
|
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug,
|
||||||
Origin: origin, WrittenBy: writtenBy,
|
Origin: origin, WrittenBy: writtenBy,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -411,7 +408,6 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
|
|||||||
type brainRejectArgs struct {
|
type brainRejectArgs struct {
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
Queue string `json:"queue,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// brainReject records a decision not to promote a raw/ note (api.RejectNote:
|
// brainReject records a decision not to promote a raw/ note (api.RejectNote:
|
||||||
@@ -422,7 +418,7 @@ func (s *Server) brainReject(_ context.Context, args json.RawMessage) (json.RawM
|
|||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
}
|
}
|
||||||
relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason, Queue: a.Queue})
|
relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,28 +431,3 @@ func TestBrainRejectMissingSourceErrors(t *testing.T) {
|
|||||||
resp := toolCall(t, srv, "brain_reject", map[string]any{"filename": "ghost.md"})
|
resp := toolCall(t, srv, "brain_reject", map[string]any{"filename": "ghost.md"})
|
||||||
require.NotNil(t, resp["error"])
|
require.NotNil(t, resp["error"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// brain_pending lists two entries for one name when a top-level note and a
|
|
||||||
// parked one share it; the tool must let the caller say which it means.
|
|
||||||
func TestBrainPromoteAndRejectTakeTheQueueOfAnAmbiguousName(t *testing.T) {
|
|
||||||
brainDir := t.TempDir()
|
|
||||||
raw := filepath.Join(brainDir, "raw")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(raw, "needs-review"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "a.md"), []byte("---\ncreated_at: 2026-10-20T00:00:00Z\n---\nTOP\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "needs-review", "a.md"), []byte("---\nproposed_wing: homelab\n---\nPARKED\n"), 0o644))
|
|
||||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
|
||||||
|
|
||||||
resp := toolCall(t, srv, "brain_promote", map[string]any{"filename": "a.md", "wing": "homelab", "hall": "facts"})
|
|
||||||
require.NotNil(t, resp["error"], "an ambiguous name is refused, not guessed")
|
|
||||||
|
|
||||||
resp = toolCall(t, srv, "brain_promote", map[string]any{"filename": "a.md", "wing": "homelab", "hall": "facts", "queue": "needs-review"})
|
|
||||||
require.Nil(t, resp["error"], "got: %v", resp["error"])
|
|
||||||
got, err := os.ReadFile(filepath.Join(brainDir, "wiki/homelab/facts/a.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(got), "PARKED")
|
|
||||||
|
|
||||||
resp = toolCall(t, srv, "brain_reject", map[string]any{"filename": "a.md", "queue": "inbox", "reason": "no"})
|
|
||||||
require.Nil(t, resp["error"], "got: %v", resp["error"])
|
|
||||||
_, err = os.Stat(filepath.Join(brainDir, "raw/rejected/a.md"))
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,11 +21,6 @@ func TestReadExcerptRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
|
|||||||
require.NoError(t, os.MkdirAll(facts, 0o755))
|
require.NoError(t, os.MkdirAll(facts, 0o755))
|
||||||
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "leak.md")))
|
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "leak.md")))
|
||||||
assert.NotContains(t, readExcerpt(brain, "wiki/w/facts/leak.md", 500), brainfstest.Sentinel)
|
assert.NotContains(t, readExcerpt(brain, "wiki/w/facts/leak.md", 500), brainfstest.Sentinel)
|
||||||
// A link to a file elsewhere in the brain: os.Root allows it, only the
|
|
||||||
// regular-file check refuses it (hyperguild#171).
|
|
||||||
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
|
|
||||||
assert.NotContains(t, readExcerpt(brain, "wiki/w/facts/inlink.md", 500), brainfstest.Sentinel)
|
|
||||||
|
|
||||||
p := filepath.Join(facts, "r.md")
|
p := filepath.Join(facts, "r.md")
|
||||||
require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644))
|
require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644))
|
||||||
@@ -33,9 +28,6 @@ func TestReadExcerptRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
|
|||||||
brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, func() string {
|
brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, func() string {
|
||||||
return readExcerpt(brain, "wiki/w/facts/r.md", 500)
|
return readExcerpt(brain, "wiki/w/facts/r.md", 500)
|
||||||
})
|
})
|
||||||
brainfstest.AssertNoLeakFrom(t, p, "---\nx: y\n---\ninside", inBrain, 1500*time.Millisecond, func() string {
|
|
||||||
return readExcerpt(brain, "wiki/w/facts/r.md", 500)
|
|
||||||
})
|
|
||||||
brainfstest.AssertNoHang(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, 6*time.Second, func() {
|
brainfstest.AssertNoHang(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, 6*time.Second, func() {
|
||||||
_ = readExcerpt(brain, "wiki/w/facts/r.md", 500)
|
_ = readExcerpt(brain, "wiki/w/facts/r.md", 500)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -83,9 +83,8 @@ 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),
|
||||||
// or the fixed label "unmatched" if no Pattern is set: falling back to the
|
// falling back to the URL path if no Pattern is set. Pattern keeps
|
||||||
// raw URL path gave any caller one series per unique path (hyperguild#185).
|
// cardinality bounded (one series per route, not one per unique URL).
|
||||||
// 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}
|
||||||
@@ -93,7 +92,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 = "unmatched"
|
path = req.URL.Path
|
||||||
}
|
}
|
||||||
r.Observe(path, statusClass(rec.code), time.Since(start))
|
r.Observe(path, statusClass(rec.code), time.Since(start))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -71,16 +71,11 @@ func TestMiddleware_RecordsTiming(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := New()
|
r := New()
|
||||||
// Routed through a ServeMux, as in production: the mux sets the
|
handler := r.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
// 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()
|
||||||
@@ -122,19 +117,3 @@ 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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
package notes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hyperguild#188/#173: brain_edit's atomic rename must not be redirected by
|
|
||||||
// a symlinked directory inside the brain.
|
|
||||||
func TestEditRefusesANoteBehindASymlinkedDirectory(t *testing.T) {
|
|
||||||
b := t.TempDir()
|
|
||||||
// Edit refuses a tree that is not the sync source before any path
|
|
||||||
// check, so the test needs a .git to reach the symlink refusal.
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(b, ".git"), 0o755))
|
|
||||||
victim := filepath.Join(b, "raw", "facts", "v.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(victim, []byte("old text\n"), 0o644))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "w"), 0o755))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "facts"), filepath.Join(b, "wiki", "w", "alias")))
|
|
||||||
_, err := Edit(b, "wiki/w/alias/v.md", "old text", "new text", ScopeNotes)
|
|
||||||
assert.Error(t, err)
|
|
||||||
got, _ := os.ReadFile(victim)
|
|
||||||
assert.Equal(t, "old text\n", string(got))
|
|
||||||
|
|
||||||
real := filepath.Join(b, "wiki", "w", "facts", "r.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(real), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(real, []byte("old text\n"), 0o644))
|
|
||||||
_, err = Edit(b, "wiki/w/facts/r.md", "old text", "new text", ScopeNotes)
|
|
||||||
require.NoError(t, err, "positive control: a note in a real directory is edited")
|
|
||||||
got, _ = os.ReadFile(real)
|
|
||||||
assert.Equal(t, "new text\n", string(got))
|
|
||||||
}
|
|
||||||
@@ -38,31 +38,3 @@ func TestReadScopedRefusesANoteOverMaxNoteBytes(t *testing.T) {
|
|||||||
_, err := ReadScoped(brain, "wiki/w/facts/big.md", ScopeNotes)
|
_, err := ReadScoped(brain, "wiki/w/facts/big.md", ScopeNotes)
|
||||||
assert.ErrorIs(t, err, ErrTooLarge)
|
assert.ErrorIs(t, err, ErrTooLarge)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hyperguild#171 (review of #172, finding 1): os.Root alone refuses links that
|
|
||||||
// leave the brain, so the outside-target race above stays green for a reader
|
|
||||||
// that skips brainfs's Lstat and SameFile checks. An in-brain target (raw/,
|
|
||||||
// outside ScopeNotes) is what tells them apart.
|
|
||||||
func TestReadScopedDoesNotReturnAnInBrainSymlinkTargetStaticallyOrDuringASampledSwapRace(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
facts := filepath.Join(brain, "wiki", "w", "facts")
|
|
||||||
require.NoError(t, os.MkdirAll(facts, 0o755))
|
|
||||||
target := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
|
|
||||||
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
|
|
||||||
b, err := ReadScoped(brain, "wiki/w/facts/inlink.md", ScopeNotes)
|
|
||||||
assert.Error(t, err, "static in-brain link")
|
|
||||||
assert.NotContains(t, string(b), brainfstest.Sentinel, "static in-brain link")
|
|
||||||
|
|
||||||
p := filepath.Join(facts, "r.md")
|
|
||||||
require.NoError(t, os.WriteFile(p, []byte("inside"), 0o644))
|
|
||||||
b, err = ReadScoped(brain, "wiki/w/facts/r.md", ScopeNotes)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "inside", string(b), "positive control: a regular note reads")
|
|
||||||
|
|
||||||
read := func() string {
|
|
||||||
b, _ := ReadScoped(brain, "wiki/w/facts/r.md", ScopeNotes)
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
brainfstest.AssertNoLeakFrom(t, p, "inside", target, 1500*time.Millisecond, read)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ package pipeline
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
"os"
|
||||||
"io/fs"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
||||||
@@ -24,16 +23,10 @@ func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
|
|||||||
return 0, fmt.Errorf("load inventory: %w", err)
|
return 0, fmt.Errorf("load inventory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listed and read through the brain root, regular files only
|
sourcesDir := filepath.Join(brainDir, "wiki", "sources")
|
||||||
// (hyperguild#157).
|
entries, err := os.ReadDir(sourcesDir)
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("open brain: %w", err)
|
if os.IsNotExist(err) {
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
names, err := bfs.ReadDirRegular("wiki/sources")
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
return 0, fmt.Errorf("read sources dir: %w", err)
|
return 0, fmt.Errorf("read sources dir: %w", err)
|
||||||
@@ -44,20 +37,20 @@ func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
|
|||||||
// get both refs merged before a single write.
|
// get both refs merged before a single write.
|
||||||
pending := make(map[string]wiki.Page)
|
pending := make(map[string]wiki.Page)
|
||||||
|
|
||||||
for _, name := range names {
|
for _, e := range entries {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return 0, ctx.Err()
|
return 0, ctx.Err()
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(name, ".md") {
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err := bfs.ReadRegular("wiki/sources/" + name)
|
b, err := os.ReadFile(filepath.Join(sourcesDir, e.Name()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sourceContent := string(b)
|
sourceContent := string(b)
|
||||||
sourceSlug := strings.TrimSuffix(name, ".md")
|
sourceSlug := strings.TrimSuffix(e.Name(), ".md")
|
||||||
sourceTitle := extractTitle(sourceContent)
|
sourceTitle := extractTitle(sourceContent)
|
||||||
if sourceTitle == "" {
|
if sourceTitle == "" {
|
||||||
sourceTitle = sourceSlug
|
sourceTitle = sourceSlug
|
||||||
@@ -77,7 +70,7 @@ func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
|
|||||||
// Start from already-accumulated version if we've seen this page.
|
// Start from already-accumulated version if we've seen this page.
|
||||||
page, seen := pending[relPath]
|
page, seen := pending[relPath]
|
||||||
if !seen {
|
if !seen {
|
||||||
raw, err := bfs.ReadRegular(relPath)
|
raw, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(relPath)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -88,7 +81,8 @@ func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for relPath, page := range pending {
|
for relPath, page := range pending {
|
||||||
if err := bfs.PutRegular(relPath, []byte(page.Content)); err != nil {
|
dest := filepath.Join(brainDir, filepath.FromSlash(relPath))
|
||||||
|
if err := os.WriteFile(dest, []byte(page.Content), 0o644); err != nil {
|
||||||
return 0, fmt.Errorf("write %s: %w", relPath, err)
|
return 0, fmt.Errorf("write %s: %w", relPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,21 +14,12 @@ import (
|
|||||||
// (derived from the filename, not the LLM title). Pages whose title resolves to an
|
// (derived from the filename, not the LLM title). Pages whose title resolves to an
|
||||||
// empty slug are skipped and returned as warnings instead.
|
// empty slug are skipped and returned as warnings instead.
|
||||||
func BuildPages(rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []string) {
|
func BuildPages(rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []string) {
|
||||||
return BuildPagesIn("", rawPages, sourceSlug, date)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildPagesIn is BuildPages for a wing: a source goes to wiki/<wing>/sources/
|
|
||||||
// with wing/hall frontmatter. Concepts and entities stay in the shared
|
|
||||||
// wiki/concepts/ and wiki/entities/, since their slug is the graph's global
|
|
||||||
// identity and they are cited from many wings. An empty wing is the retired
|
|
||||||
// layout for sources too.
|
|
||||||
func BuildPagesIn(wing string, rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []string) {
|
|
||||||
out := make([]wiki.Page, 0, len(rawPages))
|
out := make([]wiki.Page, 0, len(rawPages))
|
||||||
var warnings []string
|
var warnings []string
|
||||||
for _, rp := range rawPages {
|
for _, rp := range rawPages {
|
||||||
// The type becomes a directory under wiki/; "../raw" wrote outside it
|
// The type becomes a directory under wiki/; "../raw" wrote outside it
|
||||||
// (PR #147 review). Only a plain lower-case name is a page type.
|
// (PR #147 review). Only a plain lower-case name is a page type.
|
||||||
if !validPageType.MatchString(rp.Type) || (wing != "" && !layoutType(rp.Type)) {
|
if !validPageType.MatchString(rp.Type) {
|
||||||
warnings = append(warnings, fmt.Sprintf("skipped page with invalid type %q", rp.Type))
|
warnings = append(warnings, fmt.Sprintf("skipped page with invalid type %q", rp.Type))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -37,16 +28,11 @@ func BuildPagesIn(wing string, rawPages []RawPage, sourceSlug, date string) ([]w
|
|||||||
warnings = append(warnings, fmt.Sprintf("skipped page with empty title (type: %s)", rp.Type))
|
warnings = append(warnings, fmt.Sprintf("skipped page with empty title (type: %s)", rp.Type))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, buildPage(rp, sourceSlug, date, wing))
|
out = append(out, buildPage(rp, sourceSlug, date))
|
||||||
}
|
}
|
||||||
return out, warnings
|
return out, warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
// layoutType reports whether a wing-mode page type has a place in the layout.
|
|
||||||
// The type comes from the model, so from the clip being ingested: anything else
|
|
||||||
// would name a directory (wiki/<type>/) of the model's choosing.
|
|
||||||
func layoutType(t string) bool { return t == "source" || t == "concept" || t == "entity" }
|
|
||||||
|
|
||||||
var validPageType = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
var validPageType = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||||
|
|
||||||
func computeSlug(rp RawPage, sourceSlug string) string {
|
func computeSlug(rp RawPage, sourceSlug string) string {
|
||||||
@@ -56,7 +42,7 @@ func computeSlug(rp RawPage, sourceSlug string) string {
|
|||||||
return wiki.Slug(rp.Title)
|
return wiki.Slug(rp.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPage(rp RawPage, sourceSlug, date, wing string) wiki.Page {
|
func buildPage(rp RawPage, sourceSlug, date string) wiki.Page {
|
||||||
var slug, dir string
|
var slug, dir string
|
||||||
switch rp.Type {
|
switch rp.Type {
|
||||||
case "source":
|
case "source":
|
||||||
@@ -73,14 +59,8 @@ func buildPage(rp RawPage, sourceSlug, date, wing string) wiki.Page {
|
|||||||
dir = "wiki/" + rp.Type
|
dir = "wiki/" + rp.Type
|
||||||
}
|
}
|
||||||
|
|
||||||
if wing != "" && rp.Type == "source" {
|
|
||||||
dir = "wiki/" + wing + "/sources"
|
|
||||||
}
|
|
||||||
path := dir + "/" + slug + ".md"
|
path := dir + "/" + slug + ".md"
|
||||||
fm := buildFrontmatter(rp, date)
|
fm := buildFrontmatter(rp, date)
|
||||||
if wing != "" && rp.Type == "source" {
|
|
||||||
fm = addWingFrontmatter(fm, wing, "sources")
|
|
||||||
}
|
|
||||||
|
|
||||||
return wiki.Page{
|
return wiki.Page{
|
||||||
Path: path,
|
Path: path,
|
||||||
@@ -142,12 +122,6 @@ func buildFrontmatter(rp RawPage, date string) string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// addWingFrontmatter inserts wing/hall right after the opening fence of an
|
|
||||||
// already-built frontmatter block.
|
|
||||||
func addWingFrontmatter(fm, wing, hall string) string {
|
|
||||||
return strings.Replace(fm, "---\n", "---\nwing: "+wing+"\nhall: "+hall+"\n", 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func yamlScalar(s string) string {
|
func yamlScalar(s string) string {
|
||||||
// A raw newline or CR would end the scalar for every line-oriented
|
// A raw newline or CR would end the scalar for every line-oriented
|
||||||
// frontmatter reader and start a new key (PR #147 review: a title of
|
// frontmatter reader and start a new key (PR #147 review: a title of
|
||||||
|
|||||||
@@ -5,13 +5,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"os"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
|
||||||
"io/fs"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -27,11 +23,6 @@ type Config struct {
|
|||||||
Complete CompleteFunc
|
Complete CompleteFunc
|
||||||
ChunkSize int // 0 = no chunking
|
ChunkSize int // 0 = no chunking
|
||||||
Schema string // overrides brain/schema.md when set (useful in tests)
|
Schema string // overrides brain/schema.md when set (useful in tests)
|
||||||
|
|
||||||
// Wing, when set, writes into wiki/<wing>/{sources,facts}/ and scopes the
|
|
||||||
// inventory (title resolution, back-references) to that wing alone. Empty
|
|
||||||
// keeps the retired wiki/{sources,concepts,entities}/ layout.
|
|
||||||
Wing string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result is the outcome of a pipeline run.
|
// Result is the outcome of a pipeline run.
|
||||||
@@ -48,16 +39,7 @@ type Result struct {
|
|||||||
// Run ingests content and writes structured wiki pages to brainDir/wiki/.
|
// Run ingests content and writes structured wiki pages to brainDir/wiki/.
|
||||||
// In dry-run mode, pages are returned but not written to disk.
|
// In dry-run mode, pages are returned but not written to disk.
|
||||||
func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryRun bool) (Result, error) {
|
func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryRun bool) (Result, error) {
|
||||||
if cfg.Wing != "" && brain.Sanitise(cfg.Wing) != cfg.Wing {
|
inventory, err := wiki.LoadInventory(brainDir)
|
||||||
return Result{}, fmt.Errorf("invalid wing %q: must be a plain lower-case wing name", cfg.Wing)
|
|
||||||
}
|
|
||||||
var inventory map[wiki.PageType][]wiki.Entry
|
|
||||||
var err error
|
|
||||||
if cfg.Wing != "" {
|
|
||||||
inventory, err = wiki.LoadWingInventory(brainDir, cfg.Wing)
|
|
||||||
} else {
|
|
||||||
inventory, err = wiki.LoadInventory(brainDir)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, fmt.Errorf("load inventory: %w", err)
|
return Result{}, fmt.Errorf("load inventory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -68,12 +50,6 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
}
|
}
|
||||||
|
|
||||||
sourceSlug := wiki.Slug(source)
|
sourceSlug := wiki.Slug(source)
|
||||||
if cfg.Wing != "" {
|
|
||||||
var rerr error
|
|
||||||
if sourceSlug, rerr = resolveSourceSlug(brainDir, cfg.Wing, sourceSlug); rerr != nil {
|
|
||||||
return Result{}, rerr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
date := time.Now().UTC().Format("2006-01-02")
|
date := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
// Drift check BEFORE any extraction: an unchanged source costs a full LLM
|
// Drift check BEFORE any extraction: an unchanged source costs a full LLM
|
||||||
@@ -81,7 +57,7 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
// The merge is already idempotent (#103), so this is cost and signal, not
|
// The merge is already idempotent (#103), so this is cost and signal, not
|
||||||
// correctness — which is why it is safe for it to be an early return.
|
// correctness — which is why it is safe for it to be an early return.
|
||||||
bodyHash := hashBody(content)
|
bodyHash := hashBody(content)
|
||||||
if !dryRun && bodyHash != "" && bodyHash == storedSourceHash(brainDir, cfg.Wing, sourceSlug) {
|
if !dryRun && bodyHash != "" && bodyHash == storedSourceHash(brainDir, sourceSlug) {
|
||||||
return Result{Skipped: true, Warnings: []string{
|
return Result{Skipped: true, Warnings: []string{
|
||||||
fmt.Sprintf("source %q unchanged since last ingest (hash %s) — extraction skipped", source, bodyHash[:12]),
|
fmt.Sprintf("source %q unchanged since last ingest (hash %s) — extraction skipped", source, bodyHash[:12]),
|
||||||
}}, nil
|
}}, nil
|
||||||
@@ -92,7 +68,7 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
// only exists once the model has produced a title. So page-aware
|
// only exists once the model has produced a title. So page-aware
|
||||||
// extraction (#106) is scoped to the source page: it's the one page every
|
// extraction (#106) is scoped to the source page: it's the one page every
|
||||||
// chunk's prompt can be given in advance.
|
// chunk's prompt can be given in advance.
|
||||||
existingPage := readSourcePage(brainDir, cfg.Wing, sourceSlug)
|
existingPage := readSourcePage(brainDir, sourceSlug)
|
||||||
|
|
||||||
chunks := Chunk(content, cfg.ChunkSize)
|
chunks := Chunk(content, cfg.ChunkSize)
|
||||||
|
|
||||||
@@ -114,19 +90,7 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
meta.BodyHash = bodyHash
|
meta.BodyHash = bodyHash
|
||||||
applySourceMeta(allRaw, meta)
|
applySourceMeta(allRaw, meta)
|
||||||
|
|
||||||
res, err := buildAndWrite(ctx, cfg.Wing, allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
return buildAndWrite(ctx, allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
||||||
if err != nil || cfg.Wing == "" {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
// In a wing every ingest must leave its source page: it is what ties the
|
|
||||||
// note to the wiki (and carries the hash the drift check reads). Without one
|
|
||||||
// nothing of the note exists, and the caller would archive it as processed.
|
|
||||||
for _, p := range res.Pages {
|
|
||||||
if isSourcePath(p) {
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res, fmt.Errorf("no source page was written for %q; nothing of the note was ingested (%s)", source, strings.Join(res.Warnings, "; "))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunRaw runs the pipeline on pre-parsed RawPages, skipping the LLM extraction
|
// RunRaw runs the pipeline on pre-parsed RawPages, skipping the LLM extraction
|
||||||
@@ -145,38 +109,27 @@ func RunRaw(ctx context.Context, brainDir, source string, rawPages []RawPage, dr
|
|||||||
sourceSlug := wiki.Slug(source)
|
sourceSlug := wiki.Slug(source)
|
||||||
date := time.Now().UTC().Format("2006-01-02")
|
date := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
return buildAndWrite(ctx, "", rawPages, sourceSlug, date, brainDir, source, inventory, nil, dryRun)
|
return buildAndWrite(ctx, rawPages, sourceSlug, date, brainDir, source, inventory, nil, dryRun)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildAndWrite runs BuildPages through write for both Run and RunRaw.
|
// buildAndWrite runs BuildPages through write for both Run and RunRaw.
|
||||||
func buildAndWrite(ctx context.Context, wing string, rawPages []RawPage, sourceSlug, date, brainDir, source string, inventory map[wiki.PageType][]wiki.Entry, warnings []string, dryRun bool) (Result, error) {
|
func buildAndWrite(ctx context.Context, rawPages []RawPage, sourceSlug, date, brainDir, source string, inventory map[wiki.PageType][]wiki.Entry, warnings []string, dryRun bool) (Result, error) {
|
||||||
pages, buildWarnings := BuildPagesIn(wing, rawPages, sourceSlug, date)
|
pages, buildWarnings := BuildPages(rawPages, sourceSlug, date)
|
||||||
warnings = append(warnings, buildWarnings...)
|
warnings = append(warnings, buildWarnings...)
|
||||||
resolved := Resolve(pages, inventory)
|
resolved := Resolve(pages, inventory)
|
||||||
canonicalized, linkWarnings := CanonicalizeLinks(resolved, inventory)
|
canonicalized, linkWarnings := CanonicalizeLinks(resolved, inventory)
|
||||||
warnings = append(warnings, linkWarnings...)
|
warnings = append(warnings, linkWarnings...)
|
||||||
withRefs := injectSourceRefs(canonicalized, inventory, brainDir)
|
withRefs := injectSourceRefs(canonicalized, inventory, brainDir)
|
||||||
merged := mergeAll(withRefs)
|
merged := mergeAll(withRefs)
|
||||||
if wing != "" {
|
|
||||||
var clashWarnings []string
|
|
||||||
var cerr error
|
|
||||||
merged, clashWarnings, cerr = dropSlugClashes(brainDir, merged)
|
|
||||||
if cerr != nil {
|
|
||||||
return Result{}, cerr
|
|
||||||
}
|
|
||||||
warnings = append(warnings, clashWarnings...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wing != "" {
|
|
||||||
// The source page carries source_hash, which the drift check trusts: it
|
|
||||||
// goes last, so a batch that fails midway leaves nothing to hide behind.
|
|
||||||
sort.SliceStable(merged, func(i, j int) bool { return !isSourcePath(merged[i].Path) && isSourcePath(merged[j].Path) })
|
|
||||||
}
|
|
||||||
|
|
||||||
var written []string
|
var written []string
|
||||||
for _, page := range merged {
|
for _, page := range merged {
|
||||||
if !dryRun {
|
if !dryRun {
|
||||||
content, conflicts, err := reviseOrCreate(brainDir, page)
|
dest := filepath.Join(brainDir, filepath.FromSlash(page.Path))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
|
||||||
|
}
|
||||||
|
content, conflicts, err := reviseOrCreate(dest, page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
@@ -194,9 +147,7 @@ func buildAndWrite(ctx context.Context, wing string, rawPages []RawPage, sourceS
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, fmt.Errorf("stamp %s: %w", page.Path, err)
|
return Result{}, fmt.Errorf("stamp %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
// Written through the brain root, never through a symlinked
|
if err := os.WriteFile(dest, []byte(stamped), 0o644); err != nil {
|
||||||
// directory or page (hyperguild#158).
|
|
||||||
if err := writeBrainFile(brainDir, page.Path, []byte(stamped)); err != nil {
|
|
||||||
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
|
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,11 +155,7 @@ func buildAndWrite(ctx context.Context, wing string, rawPages []RawPage, sourceS
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !dryRun {
|
if !dryRun {
|
||||||
if wing != "" {
|
if err := wiki.RebuildIndex(brainDir, date); err != nil {
|
||||||
if err := brain.BuildWingIndex(brainDir, wing); err != nil {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("rebuild wing index: %v", err))
|
|
||||||
}
|
|
||||||
} else if err := wiki.RebuildIndex(brainDir, date); err != nil {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("rebuild index: %v", err))
|
warnings = append(warnings, fmt.Sprintf("rebuild index: %v", err))
|
||||||
}
|
}
|
||||||
if err := wiki.AppendLog(brainDir, source, written, warnings, date); err != nil {
|
if err := wiki.AppendLog(brainDir, source, written, warnings, date); err != nil {
|
||||||
@@ -241,50 +188,22 @@ func hashBody(content string) string {
|
|||||||
|
|
||||||
// readSourcePage returns the full on-disk content of the source page at
|
// readSourcePage returns the full on-disk content of the source page at
|
||||||
// wiki/sources/<sourceSlug>.md, or "" when it doesn't exist yet.
|
// wiki/sources/<sourceSlug>.md, or "" when it doesn't exist yet.
|
||||||
// It reads through the brain root, regular files only (hyperguild#157): the
|
func readSourcePage(brainDir, sourceSlug string) string {
|
||||||
// content goes into the LLM prompt.
|
raw, err := os.ReadFile(filepath.Join(brainDir, "wiki", "sources", sourceSlug+".md"))
|
||||||
func readSourcePage(brainDir, wing, sourceSlug string) string {
|
|
||||||
rel := "wiki/sources/" + sourceSlug + ".md"
|
|
||||||
if wing != "" {
|
|
||||||
rel = "wiki/" + wing + "/sources/" + sourceSlug + ".md"
|
|
||||||
}
|
|
||||||
raw, err := readBrainFile(brainDir, rel)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return string(raw)
|
return string(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeBrainFile creates or replaces one brain-relative file through the
|
|
||||||
// brain root (hyperguild#158).
|
|
||||||
func writeBrainFile(brainDir, rel string, data []byte) error {
|
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
return bfs.PutRegular(rel, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// readBrainFile reads one brain-relative file through the brain root,
|
|
||||||
// regular files only (hyperguild#157).
|
|
||||||
func readBrainFile(brainDir, rel string) ([]byte, error) {
|
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
return bfs.ReadRegular(rel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// storedSourceHash reads source_hash from the source page a previous ingest
|
// storedSourceHash reads source_hash from the source page a previous ingest
|
||||||
// wrote, or "" when there is no such page or no such field.
|
// wrote, or "" when there is no such page or no such field.
|
||||||
//
|
//
|
||||||
// Absence always means "extract". That is the fail-safe direction: a missed
|
// Absence always means "extract". That is the fail-safe direction: a missed
|
||||||
// skip costs one extraction, while a wrong skip loses an update silently — and
|
// skip costs one extraction, while a wrong skip loses an update silently — and
|
||||||
// silence is the failure mode this repo keeps paying for.
|
// silence is the failure mode this repo keeps paying for.
|
||||||
func storedSourceHash(brainDir, wing, sourceSlug string) string {
|
func storedSourceHash(brainDir, sourceSlug string) string {
|
||||||
for _, line := range strings.Split(readSourcePage(brainDir, wing, sourceSlug), "\n") {
|
for _, line := range strings.Split(readSourcePage(brainDir, sourceSlug), "\n") {
|
||||||
if key, value, found := strings.Cut(line, ":"); found && strings.TrimSpace(key) == "source_hash" {
|
if key, value, found := strings.Cut(line, ":"); found && strings.TrimSpace(key) == "source_hash" {
|
||||||
return strings.TrimSpace(value)
|
return strings.TrimSpace(value)
|
||||||
}
|
}
|
||||||
@@ -311,10 +230,10 @@ func storedSourceHash(brainDir, wing, sourceSlug string) string {
|
|||||||
// its first-ingest date on a revised page. Left deliberately — the alternative
|
// its first-ingest date on a revised page. Left deliberately — the alternative
|
||||||
// is this function editing frontmatter it does not own, and no lint reads that
|
// is this function editing frontmatter it does not own, and no lint reads that
|
||||||
// field today.
|
// field today.
|
||||||
func reviseOrCreate(brainDir string, page wiki.Page) (string, []string, error) {
|
func reviseOrCreate(dest string, page wiki.Page) (string, []string, error) {
|
||||||
existing, err := readBrainFile(brainDir, page.Path)
|
existing, err := os.ReadFile(dest)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, fs.ErrNotExist):
|
case os.IsNotExist(err):
|
||||||
return page.Content, nil, nil
|
return page.Content, nil, nil
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return "", nil, fmt.Errorf("read existing %s: %w", page.Path, err)
|
return "", nil, fmt.Errorf("read existing %s: %w", page.Path, err)
|
||||||
@@ -348,156 +267,9 @@ See brain/schema.md for the full schema.
|
|||||||
`
|
`
|
||||||
|
|
||||||
func loadSchema(brainDir string) string {
|
func loadSchema(brainDir string) string {
|
||||||
b, err := readBrainFile(brainDir, "schema.md")
|
b, err := os.ReadFile(filepath.Join(brainDir, "schema.md"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return defaultSchema
|
return defaultSchema
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(b))
|
return strings.TrimSpace(string(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveSourceSlug returns the slug this wing's source page uses. A slug is a
|
|
||||||
// page's graph identity and must be unique brain-wide, but two wings dropping a
|
|
||||||
// file of the same name (notes.md, readme.md) is ordinary, so when the plain
|
|
||||||
// slug is taken by anything but this wing's own source page the wing name is
|
|
||||||
// appended (talk-homelab, then talk-homelab-2, ...). The choice is stable: a
|
|
||||||
// re-ingest finds the page it made, because that page is "its own".
|
|
||||||
func resolveSourceSlug(brainDir, wing, base string) (string, error) {
|
|
||||||
for n := 1; n <= 100; n++ {
|
|
||||||
cand := base
|
|
||||||
switch n {
|
|
||||||
case 1:
|
|
||||||
case 2:
|
|
||||||
cand = base + "-" + wing
|
|
||||||
default:
|
|
||||||
cand = fmt.Sprintf("%s-%s-%d", base, wing, n-1)
|
|
||||||
}
|
|
||||||
existing, err := wiki.FindSlugs(brainDir, cand)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("check source slug: %w", err)
|
|
||||||
}
|
|
||||||
own := "wiki/" + wing + "/sources/" + cand + ".md"
|
|
||||||
free, hasOwn := true, false
|
|
||||||
for _, p := range existing {
|
|
||||||
if p == own {
|
|
||||||
hasOwn = true
|
|
||||||
} else {
|
|
||||||
free = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The wing's own page is its identity: keep using it even if some other
|
|
||||||
// page shares the slug (that other page is the one dropSlugClashes skips).
|
|
||||||
if hasOwn {
|
|
||||||
return cand, nil
|
|
||||||
}
|
|
||||||
if free {
|
|
||||||
return cand, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("no free source slug for %q in wing %q", base, wing)
|
|
||||||
}
|
|
||||||
|
|
||||||
// dropSlugClashes removes, from the pages about to be written in a wing, any
|
|
||||||
// page whose slug belongs to a different page. A slug is the graph identity, so
|
|
||||||
// two pages must not share one; and a page that shares one cannot be updated
|
|
||||||
// again, because every later ingest sees the other as its clash.
|
|
||||||
//
|
|
||||||
// Two kinds of clash, both by slug, neither failing the ingest (one extracted
|
|
||||||
// page must not be able to lock a source out of re-ingest):
|
|
||||||
// - inside the batch (a clip named notes.md whose model output also has an
|
|
||||||
// entity "Notes"): the source wins over an entity, an entity over a concept;
|
|
||||||
// - against the brain: an entity or concept whose slug is another page's. The
|
|
||||||
// SOURCE page is exempt: it is the note's own output, and its slug was
|
|
||||||
// already chosen free (or its own) by resolveSourceSlug.
|
|
||||||
//
|
|
||||||
// A page's own path is never a clash (a shared entity merging into itself).
|
|
||||||
func dropSlugClashes(brainDir string, pages []wiki.Page) ([]wiki.Page, []string, error) {
|
|
||||||
rank := func(p wiki.Page) int {
|
|
||||||
switch {
|
|
||||||
case isSourcePath(p.Path):
|
|
||||||
return 0
|
|
||||||
case strings.HasPrefix(p.Path, "wiki/entities/"):
|
|
||||||
return 1
|
|
||||||
case strings.HasPrefix(p.Path, "wiki/concepts/"):
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
return 3
|
|
||||||
}
|
|
||||||
slugOf := func(p wiki.Page) string { return strings.TrimSuffix(filepath.Base(p.Path), ".md") }
|
|
||||||
|
|
||||||
// Group by slug; where several pages with different paths want one slug,
|
|
||||||
// the source wins, then a page that already exists on disk (so its update
|
|
||||||
// is not lost), then entity over concept.
|
|
||||||
bySlug := map[string][]wiki.Page{}
|
|
||||||
for _, p := range pages {
|
|
||||||
bySlug[slugOf(p)] = append(bySlug[slugOf(p)], p)
|
|
||||||
}
|
|
||||||
winner := map[string]wiki.Page{}
|
|
||||||
for slug, group := range bySlug {
|
|
||||||
best := group[0]
|
|
||||||
if distinctPaths(group) > 1 {
|
|
||||||
existing, err := wiki.FindSlugs(brainDir, slug)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("check slug %q: %w", slug, err)
|
|
||||||
}
|
|
||||||
onDisk := func(p wiki.Page) int {
|
|
||||||
for _, e := range existing {
|
|
||||||
if e == p.Path {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
for _, p := range group[1:] {
|
|
||||||
if rank(p) != rank(best) && (rank(p) == 0 || rank(best) == 0) {
|
|
||||||
if rank(p) < rank(best) {
|
|
||||||
best = p
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if onDisk(p) < onDisk(best) || (onDisk(p) == onDisk(best) && rank(p) < rank(best)) {
|
|
||||||
best = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
winner[slug] = best
|
|
||||||
}
|
|
||||||
|
|
||||||
var kept []wiki.Page
|
|
||||||
var warnings []string
|
|
||||||
for _, p := range pages {
|
|
||||||
slug := slugOf(p)
|
|
||||||
if w := winner[slug]; w.Path != p.Path {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("skipped %s: slug %q is also used by %s in this batch", p.Path, slug, w.Path))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if rank(p) == 0 {
|
|
||||||
kept = append(kept, p)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
existing, err := wiki.FindSlugs(brainDir, slug)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("check slug %q: %w", slug, err)
|
|
||||||
}
|
|
||||||
clash := ""
|
|
||||||
for _, e := range existing {
|
|
||||||
if e != p.Path {
|
|
||||||
clash = e
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if clash != "" {
|
|
||||||
warnings = append(warnings, fmt.Sprintf("skipped %s: slug %q is already used by %s", p.Path, slug, clash))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, p)
|
|
||||||
}
|
|
||||||
return kept, warnings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func distinctPaths(pages []wiki.Page) int {
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, p := range pages {
|
|
||||||
seen[p.Path] = true
|
|
||||||
}
|
|
||||||
return len(seen)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package pipeline
|
package pipeline
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -23,7 +24,7 @@ func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entr
|
|||||||
|
|
||||||
var sourceContent string
|
var sourceContent string
|
||||||
for _, p := range pages {
|
for _, p := range pages {
|
||||||
if isSourcePath(p.Path) &&
|
if strings.HasPrefix(p.Path, "wiki/sources/") &&
|
||||||
strings.TrimSuffix(filepath.Base(p.Path), ".md") == sourceSlug {
|
strings.TrimSuffix(filepath.Base(p.Path), ".md") == sourceSlug {
|
||||||
sourceContent = p.Content
|
sourceContent = p.Content
|
||||||
break
|
break
|
||||||
@@ -35,7 +36,7 @@ func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entr
|
|||||||
|
|
||||||
bySlug := make(map[string]int, len(pages))
|
bySlug := make(map[string]int, len(pages))
|
||||||
for i, p := range pages {
|
for i, p := range pages {
|
||||||
if !isSourcePath(p.Path) {
|
if !strings.HasPrefix(p.Path, "wiki/sources/") {
|
||||||
bySlug[strings.TrimSuffix(filepath.Base(p.Path), ".md")] = i
|
bySlug[strings.TrimSuffix(filepath.Base(p.Path), ".md")] = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,13 +53,13 @@ func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entr
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
path := entryPath(slug, pt, inventory)
|
diskPath := filepath.Join(brainDir, "wiki", string(pt), slug+".md")
|
||||||
b, err := readBrainFile(brainDir, path)
|
b, err := os.ReadFile(diskPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
page := wiki.Page{
|
page := wiki.Page{
|
||||||
Path: path,
|
Path: "wiki/" + string(pt) + "/" + slug + ".md",
|
||||||
Content: string(b),
|
Content: string(b),
|
||||||
}
|
}
|
||||||
pages = append(pages, addSourceRef(page, sourceRef))
|
pages = append(pages, addSourceRef(page, sourceRef))
|
||||||
@@ -89,7 +90,7 @@ func extractWikilinks(content string) map[string]bool {
|
|||||||
// findSourcePage returns the slug and title of the first wiki/sources/ page in pages.
|
// findSourcePage returns the slug and title of the first wiki/sources/ page in pages.
|
||||||
func findSourcePage(pages []wiki.Page) (slug, title string, found bool) {
|
func findSourcePage(pages []wiki.Page) (slug, title string, found bool) {
|
||||||
for _, p := range pages {
|
for _, p := range pages {
|
||||||
if isSourcePath(p.Path) {
|
if strings.HasPrefix(p.Path, "wiki/sources/") {
|
||||||
slug = strings.TrimSuffix(filepath.Base(p.Path), ".md")
|
slug = strings.TrimSuffix(filepath.Base(p.Path), ".md")
|
||||||
title = extractTitle(p.Content)
|
title = extractTitle(p.Content)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
@@ -112,27 +113,3 @@ func findInInventory(slug string, inventory map[wiki.PageType][]wiki.Entry) (wik
|
|||||||
}
|
}
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// isSourcePath reports whether path is a source page in either layout:
|
|
||||||
// wiki/sources/<slug>.md or wiki/<wing>/sources/<slug>.md.
|
|
||||||
func isSourcePath(path string) bool {
|
|
||||||
parts := strings.Split(filepath.ToSlash(path), "/")
|
|
||||||
switch {
|
|
||||||
case len(parts) == 3 && parts[0] == "wiki" && parts[1] == "sources":
|
|
||||||
return true
|
|
||||||
case len(parts) == 4 && parts[0] == "wiki" && parts[2] == "sources":
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// entryPath is the brain-relative path of an inventory page: the recorded Path
|
|
||||||
// for a wing-layout entry, else the retired wiki/<type>/<slug>.md.
|
|
||||||
func entryPath(slug string, pt wiki.PageType, inventory map[wiki.PageType][]wiki.Entry) string {
|
|
||||||
for _, e := range inventory[pt] {
|
|
||||||
if e.Slug == slug && e.Path != "" {
|
|
||||||
return e.Path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "wiki/" + string(pt) + "/" + slug + ".md"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -32,14 +32,7 @@ func Resolve(proposed []wiki.Page, inventory map[wiki.PageType][]wiki.Entry) []w
|
|||||||
|
|
||||||
out := make([]wiki.Page, 0, len(proposed))
|
out := make([]wiki.Page, 0, len(proposed))
|
||||||
for _, page := range proposed {
|
for _, page := range proposed {
|
||||||
if isWingSourcePath(page.Path) {
|
pt := pageTypeFromPath(page.Path)
|
||||||
// In a wing the source's path is its filename-derived slug. Matching
|
|
||||||
// by TITLE would merge two different notes the model happened to
|
|
||||||
// title alike ("Home", "Untitled") into one page.
|
|
||||||
out = append(out, page)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pt := pageTypeOf(page)
|
|
||||||
title := extractTitle(page.Content)
|
title := extractTitle(page.Content)
|
||||||
k := key{pt: pt, normalized: normalizeTitle(title)}
|
k := key{pt: pt, normalized: normalizeTitle(title)}
|
||||||
if canonicalSlug, ok := lookup[k]; ok {
|
if canonicalSlug, ok := lookup[k]; ok {
|
||||||
@@ -62,17 +55,6 @@ func normalizeTitle(s string) string {
|
|||||||
return strings.Join(strings.Fields(s), " ")
|
return strings.Join(strings.Fields(s), " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageTypeOf is the page's type: from its directory in the retired layout, or
|
|
||||||
// "sources" for wiki/<wing>/sources/<slug>.md. Concepts and entities are always
|
|
||||||
// in their shared directories.
|
|
||||||
func pageTypeOf(p wiki.Page) wiki.PageType {
|
|
||||||
parts := strings.Split(filepath.ToSlash(p.Path), "/")
|
|
||||||
if len(parts) == 4 && parts[0] == "wiki" && parts[2] == "sources" {
|
|
||||||
return wiki.PageTypeSource
|
|
||||||
}
|
|
||||||
return pageTypeFromPath(p.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// pageTypeFromPath extracts the wiki.PageType from a path like "wiki/entities/foo.md".
|
// pageTypeFromPath extracts the wiki.PageType from a path like "wiki/entities/foo.md".
|
||||||
func pageTypeFromPath(path string) wiki.PageType {
|
func pageTypeFromPath(path string) wiki.PageType {
|
||||||
parts := strings.Split(filepath.ToSlash(path), "/")
|
parts := strings.Split(filepath.ToSlash(path), "/")
|
||||||
@@ -104,9 +86,3 @@ func extractTitle(content string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// isWingSourcePath reports whether path is wiki/<wing>/sources/<slug>.md.
|
|
||||||
func isWingSourcePath(path string) bool {
|
|
||||||
parts := strings.Split(filepath.ToSlash(path), "/")
|
|
||||||
return len(parts) == 4 && parts[0] == "wiki" && parts[2] == "sources"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
package pipeline
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hyperguild#157 slice 6: the pipeline read the source page, schema.md, the
|
|
||||||
// pages it revises and the pages it adds source refs to with os.ReadFile, so
|
|
||||||
// a symlinked file's content reached the LLM prompt and the written pages,
|
|
||||||
// and a FIFO hung the ingest.
|
|
||||||
|
|
||||||
func mkdirs(t *testing.T, brain string, dirs ...string) {
|
|
||||||
t.Helper()
|
|
||||||
for _, d := range dirs {
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brain, filepath.FromSlash(d)), 0o755))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadSourcePageAndSchemaRefuseSymlinksAndSurviveSwapRaces(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
mkdirs(t, brain, "wiki/sources")
|
|
||||||
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "sources", "ok.md"), []byte("zqinside source"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brain, "schema.md"), []byte("zqinside schema"), 0o644))
|
|
||||||
assert.Equal(t, "zqinside source", readSourcePage(brain, "", "ok"), "positive control")
|
|
||||||
assert.Equal(t, "zqinside schema", loadSchema(brain), "positive control")
|
|
||||||
|
|
||||||
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(brain, "wiki", "sources", "out.md")))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "s.md"), filepath.Join(brain, "wiki", "sources", "in.md")))
|
|
||||||
assert.NotContains(t, readSourcePage(brain, "", "out"), brainfstest.Sentinel)
|
|
||||||
assert.NotContains(t, readSourcePage(brain, "", "in"), brainfstest.Sentinel)
|
|
||||||
assert.NotContains(t, storedSourceHash(brain, "", "in"), brainfstest.Sentinel)
|
|
||||||
|
|
||||||
src := filepath.Join(brain, "wiki", "sources", "r.md")
|
|
||||||
read := func() string { return readSourcePage(brain, "", "r") }
|
|
||||||
brainfstest.AssertNoLeak(t, src, "zqinside", 1500*time.Millisecond, read)
|
|
||||||
brainfstest.AssertNoLeakFrom(t, src, "zqinside", inBrain, 1500*time.Millisecond, read)
|
|
||||||
brainfstest.AssertNoHang(t, src, "zqinside", 1500*time.Millisecond, 6*time.Second, func() { _ = read() })
|
|
||||||
|
|
||||||
schema := filepath.Join(brain, "schema.md")
|
|
||||||
rs := func() string { return loadSchema(brain) }
|
|
||||||
brainfstest.AssertNoLeakFrom(t, schema, "zqinside schema", inBrain, 1500*time.Millisecond, rs)
|
|
||||||
brainfstest.AssertNoHang(t, schema, "zqinside schema", 1500*time.Millisecond, 6*time.Second, func() { _ = rs() })
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviseOrCreateRefusesASymlinkedExistingPage(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
mkdirs(t, brain, "wiki/concepts")
|
|
||||||
brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "s.md"), filepath.Join(brain, "wiki", "concepts", "c.md")))
|
|
||||||
page := wiki.Page{Path: "wiki/concepts/c.md", Content: "---\ntitle: C\n---\nnew\n"}
|
|
||||||
out, _, err := reviseOrCreate(brain, page)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.NotContains(t, out, brainfstest.Sentinel)
|
|
||||||
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "concepts", "d.md"), []byte("---\ntitle: D\n---\nzqold\n"), 0o644))
|
|
||||||
out, _, err = reviseOrCreate(brain, wiki.Page{Path: "wiki/concepts/d.md", Content: "---\ntitle: D\n---\nnew\n"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, out, "zqold", "positive control: the existing page is read")
|
|
||||||
out, _, err = reviseOrCreate(brain, wiki.Page{Path: "wiki/concepts/new.md", Content: "fresh"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "fresh", out, "positive control: a missing page is created")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInjectSourceRefsDoesNotReadASymlinkedPage(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
mkdirs(t, brain, "wiki/concepts")
|
|
||||||
brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "s.md"), filepath.Join(brain, "wiki", "concepts", "c.md")))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "concepts", "d.md"), []byte("zqinside d"), 0o644))
|
|
||||||
inv := map[wiki.PageType][]wiki.Entry{wiki.PageTypeConcept: {
|
|
||||||
{Slug: "c", Type: wiki.PageTypeConcept}, {Slug: "d", Type: wiki.PageTypeConcept},
|
|
||||||
}}
|
|
||||||
src := wiki.Page{Path: "wiki/sources/src.md", Content: "---\ntitle: Src\n---\nsee [[c|C]] and [[d|D]]\n"}
|
|
||||||
out := injectSourceRefs([]wiki.Page{src}, inv, brain)
|
|
||||||
got := fmt.Sprintf("%+v", out)
|
|
||||||
assert.Contains(t, got, "zqinside d", "positive control")
|
|
||||||
assert.NotContains(t, got, brainfstest.Sentinel)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBackfillRefsSkipsSymlinkedSourcesAndPages(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
mkdirs(t, brain, "wiki/sources", "wiki/concepts", "raw")
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "concepts", "real.md"), []byte("---\ntitle: R\n---\nbody\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "sources", "ok.md"), []byte("---\ntitle: zqinside\n---\n[[real|R]]\n"), 0o644))
|
|
||||||
// A source elsewhere in the brain whose title is the sentinel and which
|
|
||||||
// links the real concept with the [[slug|title]] form the pipeline follows.
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brain, "raw", "src.md"),
|
|
||||||
[]byte("---\ntitle: "+brainfstest.Sentinel+"\n---\n[[real|R]]\n"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "src.md"), filepath.Join(brain, "wiki", "sources", "in.md")))
|
|
||||||
// A concept page linked by the regular source that is itself a symlink.
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "sources", "ok2.md"), []byte("---\ntitle: Two\n---\n[[lnk|L]]\n"), 0o644))
|
|
||||||
brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "page.md"))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "page.md"), filepath.Join(brain, "wiki", "concepts", "lnk.md")))
|
|
||||||
|
|
||||||
_, err := BackfillRefs(context.Background(), brain)
|
|
||||||
require.NoError(t, err)
|
|
||||||
b, _ := os.ReadFile(filepath.Join(brain, "wiki", "concepts", "real.md"))
|
|
||||||
assert.Contains(t, string(b), "zqinside", "positive control: the regular source's ref is added")
|
|
||||||
assert.NotContains(t, string(b), brainfstest.Sentinel, "a symlinked source's title was written as a ref")
|
|
||||||
raw, _ := os.ReadFile(filepath.Join(brain, "raw", "page.md"))
|
|
||||||
assert.NotContains(t, string(raw), "[[ok2", "the symlinked concept page's target was rewritten")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A source swapped between a regular file and a symlink to an in-brain source
|
|
||||||
// whose title is the sentinel: the read refuses the swapped-in link, so the
|
|
||||||
// sentinel never lands in the concept page as a ref.
|
|
||||||
func TestBackfillRefsDoesNotWriteARefFromASourceSwappedInBehindASymlink(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
mkdirs(t, brain, "wiki/sources", "wiki/concepts", "raw")
|
|
||||||
realPage := filepath.Join(brain, "wiki", "concepts", "real.md")
|
|
||||||
target := filepath.Join(brain, "raw", "src.md")
|
|
||||||
require.NoError(t, os.WriteFile(target, []byte("---\ntitle: "+brainfstest.Sentinel+"\n---\n[[real|R]]\n"), 0o644))
|
|
||||||
src := filepath.Join(brain, "wiki", "sources", "r.md")
|
|
||||||
inside := "---\ntitle: zqinside\n---\n[[real|R]]\n"
|
|
||||||
read := func() string {
|
|
||||||
_ = os.WriteFile(realPage, []byte("---\ntitle: R\n---\nbody\n"), 0o644)
|
|
||||||
_, _ = BackfillRefs(context.Background(), brain)
|
|
||||||
b, _ := os.ReadFile(realPage)
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
require.NoError(t, os.WriteFile(src, []byte(inside), 0o644))
|
|
||||||
require.Contains(t, read(), "zqinside", "positive control")
|
|
||||||
brainfstest.AssertNoLeakFrom(t, src, inside, target, 1500*time.Millisecond, read)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A concept page that is regular when the inventory lists it, then swapped
|
|
||||||
// for a symlink to an in-brain file before BackfillRefs reads it: only the
|
|
||||||
// page read's own check refuses it (a static symlink never gets this far,
|
|
||||||
// because LoadInventory does not list it).
|
|
||||||
func TestBackfillRefsDoesNotCopyAPageSwappedInBehindASymlink(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
mkdirs(t, brain, "wiki/sources", "wiki/concepts", "raw")
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "sources", "s.md"), []byte("---\ntitle: S\n---\n[[c|C]]\n"), 0o644))
|
|
||||||
page := filepath.Join(brain, "wiki", "concepts", "c.md")
|
|
||||||
target := filepath.Join(brain, "raw", "secret.md")
|
|
||||||
inside := "---\ntitle: C\n---\nzqinside\n"
|
|
||||||
read := func() string {
|
|
||||||
// The write path still follows a swapped-in link (#158) and can
|
|
||||||
// overwrite the target, so restore it each round.
|
|
||||||
_ = os.WriteFile(target, []byte("---\ntitle: X\n---\n"+brainfstest.Sentinel+"\n"), 0o644)
|
|
||||||
_, _ = BackfillRefs(context.Background(), brain)
|
|
||||||
f, err := os.OpenFile(page, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
defer func() { _ = f.Close() }()
|
|
||||||
b, _ := io.ReadAll(f)
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
require.NoError(t, os.WriteFile(page, []byte(inside), 0o644))
|
|
||||||
require.Contains(t, read(), "[[s|S]]", "positive control: the regular page gets the source ref")
|
|
||||||
brainfstest.AssertNoLeakFrom(t, page, inside, target, 3*time.Second, read)
|
|
||||||
}
|
|
||||||
|
|
||||||
// hyperguild#158: BackfillRefs read a regular concept page, a symlink could
|
|
||||||
// be swapped in, and os.WriteFile then followed it and overwrote the link's
|
|
||||||
// target. The target is not restored between rounds here, so any
|
|
||||||
// write-through shows.
|
|
||||||
func TestBackfillRefsNeverWritesThroughAPageSwappedForASymlink(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
mkdirs(t, brain, "wiki/sources", "wiki/concepts", "raw")
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "sources", "s.md"), []byte("---\ntitle: S\n---\n[[c|C]]\n"), 0o644))
|
|
||||||
page := filepath.Join(brain, "wiki", "concepts", "c.md")
|
|
||||||
target := filepath.Join(brain, "raw", "secret.md")
|
|
||||||
require.NoError(t, os.WriteFile(target, []byte("untouched"), 0o644))
|
|
||||||
inside := "---\ntitle: C\n---\nbody\n"
|
|
||||||
require.NoError(t, os.WriteFile(page, []byte(inside), 0o644))
|
|
||||||
brainfstest.StartSwap(t, page, inside, target, false)
|
|
||||||
ok := 0
|
|
||||||
for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
|
|
||||||
if _, err := BackfillRefs(context.Background(), brain); err == nil {
|
|
||||||
ok++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
got, _ := os.ReadFile(target)
|
|
||||||
assert.Equal(t, "untouched", string(got), "BackfillRefs wrote through a swapped-in symlink")
|
|
||||||
assert.Positive(t, ok, "positive control: some backfills ran")
|
|
||||||
}
|
|
||||||
|
|
||||||
// hyperguild#158: buildAndWrite wrote each page with os.WriteFile by path, so
|
|
||||||
// a page read as regular and swapped for a symlink before the write was
|
|
||||||
// written through. The target is not restored between rounds, so any
|
|
||||||
// write-through shows.
|
|
||||||
func TestBuildAndWriteNeverWritesThroughAPageSwappedForASymlink(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
mkdirs(t, brain, "wiki/sources", "wiki/concepts", "wiki/entities", "raw")
|
|
||||||
page := filepath.Join(brain, "wiki", "concepts", "zq-concept.md")
|
|
||||||
target := filepath.Join(brain, "raw", "secret.md")
|
|
||||||
require.NoError(t, os.WriteFile(target, []byte("untouched"), 0o644))
|
|
||||||
inside := "---\ntitle: Zq Concept\n---\nbody\n"
|
|
||||||
require.NoError(t, os.WriteFile(page, []byte(inside), 0o644))
|
|
||||||
pages := []RawPage{{Title: "Zq Concept", Type: "concept", Content: "## About\n\nmore\n"}}
|
|
||||||
brainfstest.StartSwap(t, page, inside, target, false)
|
|
||||||
ok := 0
|
|
||||||
for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
|
|
||||||
if _, err := RunRaw(context.Background(), brain, "zq source", pages, false); err == nil {
|
|
||||||
ok++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
got, _ := os.ReadFile(target)
|
|
||||||
assert.Equal(t, "untouched", string(got), "buildAndWrite wrote through a swapped-in symlink")
|
|
||||||
assert.Positive(t, ok, "positive control: some runs returned without error")
|
|
||||||
}
|
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
package pipeline
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func wingCfg(wing string, pages ...RawPage) Config {
|
|
||||||
return Config{
|
|
||||||
Wing: wing,
|
|
||||||
Schema: "# Schema",
|
|
||||||
Complete: func(context.Context, string, string) (string, error) {
|
|
||||||
return mustJSON(pages), nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceEntityConcept() []RawPage {
|
|
||||||
return []RawPage{
|
|
||||||
{Title: "Dark Factory Talk", Type: "source", Subtype: "video", Domain: "ai",
|
|
||||||
Content: "## Summary\n\nA talk.\n\n## Entities Mentioned\n\n[[Jev]]\n\n## Concepts Introduced or Reinforced\n\n[[Dark Factory]]\n"},
|
|
||||||
{Title: "Jev", Type: "entity", Subtype: "tool", Domain: "ai",
|
|
||||||
Content: "## Definition\n\nA decision model.\n\n## Sources\n"},
|
|
||||||
{Title: "Dark Factory", Type: "concept", Domain: "ai",
|
|
||||||
Content: "## Definition\n\nA fully automated build line.\n\n## Sources\n"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func exists(dir, rel string) bool {
|
|
||||||
_, err := os.Stat(filepath.Join(dir, rel))
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func read(t *testing.T, dir, rel string) string {
|
|
||||||
t.Helper()
|
|
||||||
b, err := os.ReadFile(filepath.Join(dir, rel))
|
|
||||||
require.NoError(t, err)
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// With a wing, the SOURCE belongs to the wing; concepts and entities are the
|
|
||||||
// shared cross-wing layer (wiki/concepts, wiki/entities) because their slug is
|
|
||||||
// the graph's global identity and they are cited from many wings.
|
|
||||||
func TestRun_WithAWingTheSourceIsPerWingAndConceptsEntitiesAreShared(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body of the talk", "dark-factory-talk", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
src := read(t, dir, "wiki/web-clip/sources/dark-factory-talk.md")
|
|
||||||
assert.Contains(t, src, "wing: web-clip")
|
|
||||||
assert.Contains(t, src, "hall: sources")
|
|
||||||
assert.True(t, exists(dir, "wiki/entities/jev.md"))
|
|
||||||
assert.True(t, exists(dir, "wiki/concepts/dark-factory.md"))
|
|
||||||
assert.NotContains(t, read(t, dir, "wiki/entities/jev.md"), "wing:", "a shared page belongs to no single wing")
|
|
||||||
assert.False(t, exists(dir, "wiki/web-clip/facts"), "no per-wing copy of a shared page")
|
|
||||||
assert.False(t, exists(dir, "wiki/sources"), "the retired source layout is not used")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_WithAWingTheSharedPageLinksBackToTheSource(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body", "dark-factory-talk", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, read(t, dir, "wiki/entities/jev.md"), "[[dark-factory-talk|Dark Factory Talk]]")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_TheSameEntityCitedFromTwoWingsIsOnePageWithBothSources(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "first body", "talk-one", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
// A wing must already exist to be named; create it the way a note would.
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/facts"), 0o755))
|
|
||||||
|
|
||||||
again := []RawPage{
|
|
||||||
{Title: "Second Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nMore.\n\n## Entities Mentioned\n\n[[The Jev]]\n"},
|
|
||||||
{Title: "The Jev", Type: "entity", Subtype: "tool", Content: "## Definition\n\nStill a decision model.\n\n## Sources\n"},
|
|
||||||
}
|
|
||||||
_, err = Run(context.Background(), wingCfg("homelab", again...), dir, "second body", "talk-two", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
files, err := filepath.Glob(filepath.Join(dir, "wiki/entities/*.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, files, 1, "one entity page, matched by title with the leading article ignored")
|
|
||||||
jev := read(t, dir, "wiki/entities/jev.md")
|
|
||||||
assert.Contains(t, jev, "talk-one")
|
|
||||||
assert.Contains(t, jev, "talk-two")
|
|
||||||
assert.False(t, exists(dir, "wiki/homelab/facts/jev.md"), "no second copy in the second wing")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A source slug is the graph's identity too: a second wing must not take over
|
|
||||||
// the slug of a page that lives elsewhere. Two wings dropping a file of the same
|
|
||||||
// name (notes.md, readme.md) is ordinary, so the second source gets a distinct,
|
|
||||||
// stable slug instead of failing.
|
|
||||||
func TestRun_ASourceWhoseSlugIsTakenElsewhereGetsADistinctStableSlug(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body", "talk", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/facts"), 0o755))
|
|
||||||
|
|
||||||
_, err = Run(context.Background(), wingCfg("homelab", sourceEntityConcept()...), dir, "different body", "talk", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, exists(dir, "wiki/web-clip/sources/talk.md"), "the first wing's page is untouched")
|
|
||||||
assert.True(t, exists(dir, "wiki/homelab/sources/talk-homelab.md"), "the second gets a wing-qualified slug")
|
|
||||||
assert.False(t, exists(dir, "wiki/homelab/sources/talk.md"))
|
|
||||||
|
|
||||||
_, err = Run(context.Background(), wingCfg("homelab", sourceEntityConcept()...), dir, "different body, edited", "talk", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
files, _ := filepath.Glob(filepath.Join(dir, "wiki/homelab/sources/*.md"))
|
|
||||||
assert.Len(t, files, 1, "re-ingesting finds the same page, it does not make talk-homelab-2")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_ASourceWhoseSlugIsTakenByARetiredPageOrASharedPageGetsADistinctSlug(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/sources"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/sources/talk.md"), []byte("---\ntitle: Talk\n---\nold"), 0o644))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/entities"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/entities/other.md"), []byte("---\ntitle: Other\n---\nx"), 0o644))
|
|
||||||
|
|
||||||
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body", "talk", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, exists(dir, "wiki/web-clip/sources/talk-web-clip.md"))
|
|
||||||
assert.Equal(t, "---\ntitle: Talk\n---\nold", read(t, dir, "wiki/sources/talk.md"), "the retired page is untouched")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The other direction: a model-extracted entity or concept whose slug is a
|
|
||||||
// source's slug in some wing would lock that source out of re-ingest. The page
|
|
||||||
// is skipped with a warning; the ingest itself succeeds.
|
|
||||||
func TestRun_AnEntityWhoseSlugIsASourceElsewhereIsSkippedNotWritten(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
_, err := Run(context.Background(), wingCfg("web-clip", []RawPage{
|
|
||||||
{Title: "Jev", Type: "source", Subtype: "video", Content: "## Summary\n\nA clip titled Jev.\n"},
|
|
||||||
}...), dir, "body", "jev", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/facts"), 0o755))
|
|
||||||
|
|
||||||
res, err := Run(context.Background(), wingCfg("homelab", sourceEntityConcept()...), dir, "another body", "other-talk", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, exists(dir, "wiki/entities/jev.md"), "the entity would take the source's slug")
|
|
||||||
assert.Contains(t, strings.Join(res.Warnings, "\n"), "jev")
|
|
||||||
|
|
||||||
_, err = Run(context.Background(), wingCfg("web-clip", []RawPage{
|
|
||||||
{Title: "Jev", Type: "source", Subtype: "video", Content: "## Summary\n\nEdited.\n"},
|
|
||||||
}...), dir, "body, edited", "jev", false)
|
|
||||||
require.NoError(t, err, "the first wing can still re-ingest its own source")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The type comes from the model, so from the clip: in a wing it may only be one
|
|
||||||
// of the three the layout has a place for.
|
|
||||||
func TestRun_WithAWingAModelChosenTypeCannotWriteOutsideTheLayout(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/alpha/facts"), 0o755))
|
|
||||||
pages := append(sourceEntityConcept(),
|
|
||||||
RawPage{Title: "Planted", Type: "sources", Content: "## x\n\nplanted\n"},
|
|
||||||
RawPage{Title: "Loose", Type: "alpha", Content: "## x\n\nloose\n"},
|
|
||||||
RawPage{Title: "Sneaky", Type: "homelab", Content: "## x\n\nsneaky\n"})
|
|
||||||
res, err := Run(context.Background(), wingCfg("web-clip", pages...), dir, "body", "talk", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, exists(dir, "wiki/sources/planted.md"))
|
|
||||||
assert.False(t, exists(dir, "wiki/alpha/loose.md"))
|
|
||||||
assert.False(t, exists(dir, "wiki/homelab"))
|
|
||||||
assert.Contains(t, strings.Join(res.Warnings, "\n"), "invalid type")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_ReIngestingTheSameSourceIntoItsOwnWingIsNotACollision(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body one", "talk", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body two, edited", "talk", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_WithAWingRejectsANameThatIsNotAPlainWing(t *testing.T) {
|
|
||||||
for _, bad := range []string{"../escape", "a/b", "Radar", "has space", "."} {
|
|
||||||
t.Run(bad, func(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
_, err := Run(context.Background(), wingCfg(bad, sourceEntityConcept()...), dir, "body", "talk", false)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Len(t, listAll(t, dir), 1, "nothing is written for a rejected wing (only the temp dir itself exists)")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func listAll(t *testing.T, dir string) []string {
|
|
||||||
t.Helper()
|
|
||||||
var out []string
|
|
||||||
_ = filepath.WalkDir(dir, func(p string, _ os.DirEntry, _ error) error { out = append(out, p); return nil })
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Title resolution can mask a wrong slug choice at the Run level, so the
|
|
||||||
// stability of the choice is pinned directly: the page this wing made earlier
|
|
||||||
// under the qualified slug is "its own" and is found again.
|
|
||||||
func TestResolveSourceSlug(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
for _, rel := range []string{
|
|
||||||
"wiki/web-clip/sources/talk.md",
|
|
||||||
"wiki/homelab/sources/talk-homelab.md",
|
|
||||||
"wiki/entities/kube.md",
|
|
||||||
} {
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, rel)), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, rel), []byte("---\ntitle: x\n---\n"), 0o644))
|
|
||||||
}
|
|
||||||
tests := []struct {
|
|
||||||
name, wing, base, want string
|
|
||||||
}{
|
|
||||||
{"free slug is used as is", "homelab", "fresh", "fresh"},
|
|
||||||
{"own page under the plain slug", "web-clip", "talk", "talk"},
|
|
||||||
{"taken elsewhere: wing-qualified", "tapir", "talk", "talk-tapir"},
|
|
||||||
{"own earlier qualified page is found again", "homelab", "talk", "talk-homelab"},
|
|
||||||
{"taken by a shared page", "tapir", "kube", "kube-tapir"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got, err := resolveSourceSlug(dir, tt.wing, tt.base)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.want, got)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveSourceSlug_AFurtherClashGetsANumber(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
for _, rel := range []string{"wiki/a/sources/talk.md", "wiki/entities/talk-b.md"} {
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, rel)), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, rel), []byte("x"), 0o644))
|
|
||||||
}
|
|
||||||
got, err := resolveSourceSlug(dir, "b", "talk")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "talk-b-2", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// From the second review of #211: slug clashes INSIDE one batch. dropSlugClashes
|
|
||||||
// only looked at what was already on disk, so a source and an entity extracted
|
|
||||||
// together with one slug were both written, and neither could ever be updated.
|
|
||||||
func TestRun_ASourceAndAnEntityWithOneSlugInTheSameBatchWriteOnlyTheSource(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
pages := []RawPage{
|
|
||||||
{Title: "Notes", Type: "source", Subtype: "note", Content: "## Summary\n\nfirst\n"},
|
|
||||||
{Title: "Notes", Type: "entity", Subtype: "tool", Content: "## Definition\n\nan entity named like its source\n"},
|
|
||||||
}
|
|
||||||
res, err := Run(context.Background(), wingCfg("homelab", pages...), dir, "body one", "notes", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, exists(dir, "wiki/homelab/sources/notes.md"))
|
|
||||||
assert.False(t, exists(dir, "wiki/entities/notes.md"), "two pages must not share a slug")
|
|
||||||
assert.Contains(t, strings.Join(res.Warnings, "\n"), "notes")
|
|
||||||
|
|
||||||
pages[0].Content = "## Summary\n\nfirst\n\n## Key Claims\n\n- second\n"
|
|
||||||
_, err = Run(context.Background(), wingCfg("homelab", pages...), dir, "body two, changed", "notes", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, read(t, dir, "wiki/homelab/sources/notes.md"), "second", "the source can still be updated")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_AConceptAndAnEntityWithOneSlugInTheSameBatchWriteOne(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
pages := []RawPage{
|
|
||||||
{Title: "Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nx\n"},
|
|
||||||
{Title: "Foo", Type: "concept", Content: "## Definition\n\na concept\n"},
|
|
||||||
{Title: "Foo", Type: "entity", Subtype: "tool", Content: "## Definition\n\nan entity\n"},
|
|
||||||
}
|
|
||||||
res, err := Run(context.Background(), wingCfg("homelab", pages...), dir, "body", "talk", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
written := 0
|
|
||||||
for _, rel := range []string{"wiki/concepts/foo.md", "wiki/entities/foo.md"} {
|
|
||||||
if exists(dir, rel) {
|
|
||||||
written++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert.Equal(t, 1, written, "one slug, one page")
|
|
||||||
assert.Contains(t, strings.Join(res.Warnings, "\n"), "foo")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A brain that already holds the bad pair (written before this fix) must not
|
|
||||||
// lock the source out: the source page is the note's own output and is never
|
|
||||||
// the one skipped.
|
|
||||||
func TestRun_AnExistingSourceEntityPairDoesNotLockTheSourceOut(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/sources"), 0o755))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/entities"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/homelab/sources/notes.md"),
|
|
||||||
[]byte("---\ntitle: 'Notes'\ntype: 'note'\nwing: homelab\nhall: sources\naliases:\n - 'Notes'\n---\n\n## Summary\n\nold\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/entities/notes.md"),
|
|
||||||
[]byte("---\ntitle: 'Notes'\naliases:\n - 'Notes'\n---\n\n## Definition\n\nstale entity\n"), 0o644))
|
|
||||||
|
|
||||||
pages := []RawPage{{Title: "Notes", Type: "source", Subtype: "note", Content: "## Summary\n\nnew text\n\n## Key Claims\n\n- fresh\n"}}
|
|
||||||
_, err := Run(context.Background(), wingCfg("homelab", pages...), dir, "changed body", "notes", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, read(t, dir, "wiki/homelab/sources/notes.md"), "fresh", "the source took the update")
|
|
||||||
assert.Contains(t, read(t, dir, "wiki/entities/notes.md"), "stale entity", "the entity is untouched")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass 3 of #211: a note must never leave the inbox as "processed" when nothing
|
|
||||||
// of it was written.
|
|
||||||
func TestRun_WithAWingAnIngestThatWritesNoSourcePageIsAnError(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
pages []RawPage
|
|
||||||
prep func(t *testing.T, dir string)
|
|
||||||
}{
|
|
||||||
{"the model returned nothing", nil, nil},
|
|
||||||
{"only pages of a type the layout has no place for", []RawPage{{Title: "Claim", Type: "claim", Content: "## x\n\ny\n"}}, nil},
|
|
||||||
{"only an entity, and its slug is taken by a source", []RawPage{{Title: "S1", Type: "entity", Subtype: "tool", Content: "## Definition\n\nx\n"}},
|
|
||||||
func(t *testing.T, dir string) {
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/web-clip/sources"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/web-clip/sources/s1.md"), []byte("---\ntitle: 'S1'\n---\nx"), 0o644))
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
if tt.prep != nil {
|
|
||||||
tt.prep(t, dir)
|
|
||||||
}
|
|
||||||
res, err := Run(context.Background(), wingCfg("web-clip", tt.pages...), dir, "a note body", "other-note", false)
|
|
||||||
require.Error(t, err, "nothing was ingested; the caller must not archive it as done")
|
|
||||||
assert.Empty(t, res.Pages)
|
|
||||||
assert.Contains(t, err.Error(), "no source page")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_WithAWingAnUnchangedSourceIsStillASkipNotAnError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
res, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, res.Skipped)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Title resolution must not merge two different notes into one source page just
|
|
||||||
// because the model titled both "Home": in a wing the source path is already
|
|
||||||
// deterministic (the filename), so the title must not redirect it.
|
|
||||||
func TestRun_WithAWingTwoNotesTheModelTitlesAlikeKeepTheirOwnSourcePages(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
home := func(body string) []RawPage {
|
|
||||||
return []RawPage{{Title: "Home", Type: "source", Subtype: "article", Content: "## Summary\n\n" + body + "\n"}}
|
|
||||||
}
|
|
||||||
_, err := Run(context.Background(), wingCfg("web-clip", home("note A text")...), dir, "body A", "note-a", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = Run(context.Background(), wingCfg("web-clip", home("note B text")...), dir, "body B", "note-b", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.True(t, exists(dir, "wiki/web-clip/sources/note-a.md"))
|
|
||||||
assert.True(t, exists(dir, "wiki/web-clip/sources/note-b.md"), "note B has its own page")
|
|
||||||
assert.Contains(t, read(t, dir, "wiki/web-clip/sources/note-a.md"), "note A text")
|
|
||||||
assert.Contains(t, read(t, dir, "wiki/web-clip/sources/note-b.md"), "note B text")
|
|
||||||
|
|
||||||
res, err := Run(context.Background(), wingCfg("web-clip", home("note A text")...), dir, "body A", "note-a", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, res.Skipped, "note A's hash was not overwritten by note B")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both a concept and an entity named "Foo" arrive and the CONCEPT already exists
|
|
||||||
// on disk: the page that exists wins the tie, so its source reference is added.
|
|
||||||
func TestRun_ABatchTieBetweenAConceptAndAnEntityPrefersThePageAlreadyOnDisk(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/concepts"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/concepts/foo.md"),
|
|
||||||
[]byte("---\ntitle: 'Foo'\naliases:\n - 'Foo'\n---\n\n## Definition\n\nexisting concept\n\n## Sources\n"), 0o644))
|
|
||||||
pages := []RawPage{
|
|
||||||
{Title: "Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nx\n\n## Concepts Introduced or Reinforced\n\n[[Foo]]\n"},
|
|
||||||
{Title: "Foo", Type: "entity", Subtype: "tool", Content: "## Definition\n\nan entity\n"},
|
|
||||||
{Title: "Foo", Type: "concept", Content: "## Definition\n\na concept\n\n## Sources\n"},
|
|
||||||
}
|
|
||||||
_, err := Run(context.Background(), wingCfg("web-clip", pages...), dir, "body", "talk", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, exists(dir, "wiki/entities/foo.md"))
|
|
||||||
assert.Contains(t, read(t, dir, "wiki/concepts/foo.md"), "talk", "the existing concept got the new source reference")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass 4 of #211: the source page carries the hash the drift check reads. If it
|
|
||||||
// is written FIRST and a later page fails to write, the next ingest of the same
|
|
||||||
// content is skipped and the rest of the batch is lost for good. Written LAST,
|
|
||||||
// a failed batch leaves no hash and is simply redone.
|
|
||||||
func TestRun_WithAWingAFailedBatchLeavesNoSourcePageToHideBehind(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
// An entity path that cannot be written: a directory where the page goes.
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/entities/jev.md"), 0o755))
|
|
||||||
|
|
||||||
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
|
|
||||||
require.Error(t, err, "the entity write fails")
|
|
||||||
assert.False(t, exists(dir, "wiki/web-clip/sources/talk.md"),
|
|
||||||
"the source page (and its hash) must not exist when the batch did not complete")
|
|
||||||
|
|
||||||
require.NoError(t, os.RemoveAll(filepath.Join(dir, "wiki/entities/jev.md")))
|
|
||||||
res, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, res.Skipped, "the retry is a real ingest, not a hash skip")
|
|
||||||
assert.True(t, exists(dir, "wiki/entities/jev.md"))
|
|
||||||
assert.True(t, exists(dir, "wiki/web-clip/sources/talk.md"))
|
|
||||||
}
|
|
||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -101,6 +102,25 @@ func Apply(content string, s Stamp) (string, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApplyFile applies the stamp to the note at absPath in place.
|
||||||
|
func ApplyFile(absPath string, s Stamp) error {
|
||||||
|
b, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read for provenance: %w", err)
|
||||||
|
}
|
||||||
|
out, err := Apply(string(b), s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if out == string(b) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(absPath, []byte(out), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write provenance: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// plainGroups keeps the provably plain key groups of a frontmatter block.
|
// plainGroups keeps the provably plain key groups of a frontmatter block.
|
||||||
func plainGroups(block string) string {
|
func plainGroups(block string) string {
|
||||||
lines := strings.Split(strings.TrimSuffix(block, "\n"), "\n")
|
lines := strings.Split(strings.TrimSuffix(block, "\n"), "\n")
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// hyperguild#157 option 1: search walked and read the brain by path, so a
|
// hyperguild#157 option 1: search walked and read the brain by path, so a
|
||||||
@@ -191,49 +189,3 @@ func TestHydrateRefusesASymlink(t *testing.T) {
|
|||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.NotContains(t, r.Excerpt, sentinel157)
|
assert.NotContains(t, r.Excerpt, sentinel157)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hyperguild#171: os.Root refuses links that leave the brain on its own, so
|
|
||||||
// the outside-target cases above would stay green for a caller that read
|
|
||||||
// without brainfs's Lstat and SameFile checks. These targets are inside the
|
|
||||||
// brain (raw/, which is not a search root).
|
|
||||||
func inBrainTarget(t *testing.T, brain string) string {
|
|
||||||
t.Helper()
|
|
||||||
return brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestQueryDoesNotReturnAnInBrainSymlinkTargetDuringASampledSwapRace(t *testing.T) {
|
|
||||||
brain, facts, _ := searchBrain(t)
|
|
||||||
target := inBrainTarget(t, brain)
|
|
||||||
read := func() string {
|
|
||||||
// Both terms, so the regular note is a possible hit: a reader that
|
|
||||||
// never returns the inside note would pass vacuously (#177 floor).
|
|
||||||
rs, _ := Query(brain, QueryOptions{Query: brainfstest.Sentinel + " zqinside", Limit: 10})
|
|
||||||
s := ""
|
|
||||||
for _, r := range rs {
|
|
||||||
s += r.Title + r.Excerpt
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
brainfstest.AssertNoLeakFrom(t, filepath.Join(facts, "r.md"), "# r\n\nzqinside", target, 1500*time.Millisecond, read)
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
|
|
||||||
assert.NotContains(t, read(), brainfstest.Sentinel, "static in-brain link")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHydrateRefusesAnInBrainSymlinkAndDoesNotReturnItsTargetDuringASampledSwapRace(t *testing.T) {
|
|
||||||
brain, facts, _ := searchBrain(t)
|
|
||||||
target := inBrainTarget(t, brain)
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
|
|
||||||
r, err := hydrate(brain, "wiki/w/facts/inlink.md")
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.NotContains(t, r.Excerpt+r.Title, brainfstest.Sentinel)
|
|
||||||
|
|
||||||
p := filepath.Join(facts, "r.md")
|
|
||||||
require.NoError(t, os.WriteFile(p, []byte("# r\n\nzqinside"), 0o644))
|
|
||||||
r, err = hydrate(brain, "wiki/w/facts/r.md")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, r.Excerpt, "zqinside", "positive control: a regular note hydrates")
|
|
||||||
brainfstest.AssertNoLeakFrom(t, p, "# r\n\nzqinside", target, 1500*time.Millisecond, func() string {
|
|
||||||
r, _ := hydrate(brain, "wiki/w/facts/r.md")
|
|
||||||
return r.Title + r.Excerpt
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
package session
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync/atomic"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
)
|
|
||||||
|
|
||||||
// session_log passed the caller's session_id straight into
|
|
||||||
// filepath.Join(sessionsDir, id+".jsonl") and opened it O_CREATE|O_APPEND:
|
|
||||||
// path segments in the id created or appended files anywhere the pod could
|
|
||||||
// write. The id is now a plain name, and the open does not follow a final
|
|
||||||
// symlink.
|
|
||||||
func TestAppendRefusesSessionIDsThatAreNotPlainNames(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
dir := filepath.Join(root, "sessions")
|
|
||||||
for _, id := range []string{"../x", "a/b", "/abs", ".", "..", ".hidden", "", "a\\b", "a\x00b"} {
|
|
||||||
assert.Error(t, Append(dir, id, Entry{SessionID: id}), "%q", id)
|
|
||||||
}
|
|
||||||
ents, _ := os.ReadDir(root)
|
|
||||||
for _, e := range ents {
|
|
||||||
assert.Equal(t, "sessions", e.Name(), "nothing created beside the sessions dir")
|
|
||||||
}
|
|
||||||
for _, id := range []string{"1557b2e1-bf0d-5c4f-bbe3-0754d087e356", "_routing", "2026-05-05-infra.plan", "0"} {
|
|
||||||
require.NoError(t, Append(dir, id, Entry{SessionID: id}), "positive control: %q", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAppendDoesNotWriteThroughASymlinkedLog(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
dir := filepath.Join(root, "sessions")
|
|
||||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
|
||||||
victim := filepath.Join(root, "victim.md")
|
|
||||||
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "victim.md"), filepath.Join(dir, "s.jsonl")))
|
|
||||||
assert.Error(t, Append(dir, "s", Entry{SessionID: "s"}))
|
|
||||||
got, _ := os.ReadFile(victim)
|
|
||||||
assert.Equal(t, "untouched\n", string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadRefusesSessionIDsThatAreNotPlainNames(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(root, "x.jsonl"), []byte(`{"session_id":"x"}`+"\n"), 0o644))
|
|
||||||
_, err := Read(filepath.Join(root, "sessions"), "../x")
|
|
||||||
assert.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A name that starts plainly and then climbs out with ".." is exactly what the
|
|
||||||
// ban on "/" in the id's tail stops: filepath.Join cleans "a/../../x" to a
|
|
||||||
// path beside the sessions dir without needing sessions/a to exist. The
|
|
||||||
// assertion is on the refusal itself (ErrInvalidID), not on any error.
|
|
||||||
func TestAppendRefusesAnIDThatClimbsOutAfterAPlainPrefix(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
dir := filepath.Join(root, "sessions")
|
|
||||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
|
||||||
for _, id := range []string{"a/../../escape", "a/../../../escape", "a/../b"} {
|
|
||||||
err := Append(dir, id, Entry{SessionID: id})
|
|
||||||
assert.ErrorIs(t, err, ErrInvalidID, "%q", id)
|
|
||||||
}
|
|
||||||
_, err := os.Lstat(filepath.Join(root, "escape.jsonl"))
|
|
||||||
assert.True(t, errors.Is(err, os.ErrNotExist), "nothing created beside the sessions dir")
|
|
||||||
_, err = os.Lstat(filepath.Join(dir, "b.jsonl"))
|
|
||||||
assert.True(t, errors.Is(err, os.ErrNotExist), "no in-dir alias created either")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append does no Lstat type check, so O_NONBLOCK is the only thing that keeps
|
|
||||||
// a FIFO named <id>.jsonl with no reader from blocking session_log forever.
|
|
||||||
func TestAppendReturnsPromptlyOnAFIFOLogWithNoReader(t *testing.T) {
|
|
||||||
dir := filepath.Join(t.TempDir(), "sessions")
|
|
||||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
|
||||||
fifo := filepath.Join(dir, "s.jsonl")
|
|
||||||
require.NoError(t, syscall.Mkfifo(fifo, 0o644))
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- Append(dir, "s", Entry{SessionID: "s"}) }()
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
assert.Error(t, err, "a FIFO with no reader is refused, not written")
|
|
||||||
case <-time.After(3 * time.Second):
|
|
||||||
// Unblock the stuck open so the goroutine and TempDir cleanup finish.
|
|
||||||
if r, err := os.OpenFile(fifo, os.O_RDONLY|syscall.O_NONBLOCK, 0); err == nil {
|
|
||||||
defer r.Close() //nolint:errcheck
|
|
||||||
}
|
|
||||||
<-done
|
|
||||||
t.Fatal("Append blocked on a FIFO log with no reader")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hyperguild#173: session_log followed a symlinked sessions dir.
|
|
||||||
func TestAppendRefusesASymlinkedSessionsDir(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
elsewhere := filepath.Join(root, "elsewhere")
|
|
||||||
require.NoError(t, os.MkdirAll(elsewhere, 0o755))
|
|
||||||
dir := filepath.Join(root, "sessions")
|
|
||||||
require.NoError(t, os.Symlink("elsewhere", dir))
|
|
||||||
assert.Error(t, Append(dir, "s", Entry{SessionID: "s"}))
|
|
||||||
ents, _ := os.ReadDir(elsewhere)
|
|
||||||
assert.Empty(t, ents, "a log was created through the symlinked dir")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The sessions dir is swapped (RENAME_EXCHANGE) with a symlink to a
|
|
||||||
// directory outside the brain while Append runs. Append opens through an
|
|
||||||
// os.Root on the sessions dir's parent, so a swap can redirect it only
|
|
||||||
// inside that parent (the #167 residual), never outside it.
|
|
||||||
func TestAppendNeverCreatesALogOutsideTheParentUnderADirSwapRace(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
outside := t.TempDir()
|
|
||||||
dir := filepath.Join(brain, "sessions")
|
|
||||||
other := filepath.Join(brain, "sessions.link")
|
|
||||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
|
||||||
require.NoError(t, os.Symlink(outside, other))
|
|
||||||
var stop atomic.Bool
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
for !stop.Load() {
|
|
||||||
_ = unix.Renameat2(unix.AT_FDCWD, dir, unix.AT_FDCWD, other, unix.RENAME_EXCHANGE)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
ok := 0
|
|
||||||
for i := 0; i < 20000; i++ {
|
|
||||||
if Append(dir, fmt.Sprintf("s%d", i), Entry{SessionID: "s"}) == nil {
|
|
||||||
ok++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stop.Store(true)
|
|
||||||
<-done
|
|
||||||
ents, _ := os.ReadDir(outside)
|
|
||||||
assert.Empty(t, ents, "logs were created outside the brain")
|
|
||||||
assert.Positive(t, ok, "positive control: appends to the real dir succeed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The log file is swapped between a regular file and a symlink to another
|
|
||||||
// file in the brain while Append runs: Append writes only after confirming
|
|
||||||
// the path is the regular file it opened, so the other file never grows.
|
|
||||||
func TestAppendNeverWritesThroughALogSwappedForASymlink(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
dir := filepath.Join(brain, "sessions")
|
|
||||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
|
||||||
victim := filepath.Join(brain, "victim.md")
|
|
||||||
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
|
|
||||||
log := filepath.Join(dir, "s.jsonl")
|
|
||||||
var stop atomic.Bool
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
tmp := log + ".swap"
|
|
||||||
for i := 0; !stop.Load(); i++ {
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
if i%2 == 0 {
|
|
||||||
_ = os.WriteFile(tmp, nil, 0o644)
|
|
||||||
} else {
|
|
||||||
_ = os.Symlink(filepath.Join("..", "victim.md"), tmp)
|
|
||||||
}
|
|
||||||
_ = os.Rename(tmp, log)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
ok := 0
|
|
||||||
for i := 0; i < 20000; i++ {
|
|
||||||
if Append(dir, "s", Entry{SessionID: "s"}) == nil {
|
|
||||||
ok++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stop.Store(true)
|
|
||||||
<-done
|
|
||||||
got, _ := os.ReadFile(victim)
|
|
||||||
assert.Equal(t, "untouched\n", string(got), "a log line was written through the swapped-in symlink")
|
|
||||||
assert.Positive(t, ok, "positive control: appends to the regular log succeed")
|
|
||||||
}
|
|
||||||
@@ -9,8 +9,6 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,63 +42,16 @@ type Attempt struct {
|
|||||||
RunnerOutput string `json:"runner_output,omitempty"`
|
RunnerOutput string `json:"runner_output,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// idPattern is a plain file name: session_log takes the id from the caller,
|
|
||||||
// and a path segment in it created or appended files anywhere the pod could
|
|
||||||
// write (hyperguild#169).
|
|
||||||
var idPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$`)
|
|
||||||
|
|
||||||
// ErrInvalidID is returned when a session id is not a plain file name.
|
|
||||||
var ErrInvalidID = errors.New("session id is not a plain name")
|
|
||||||
|
|
||||||
func checkID(sessionID string) error {
|
|
||||||
if !idPattern.MatchString(sessionID) {
|
|
||||||
return fmt.Errorf("%w: %q", ErrInvalidID, sessionID)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append writes entry as a single JSON line to sessionsDir/{sessionID}.jsonl.
|
// Append writes entry as a single JSON line to sessionsDir/{sessionID}.jsonl.
|
||||||
// The open does not follow a final symlink.
|
|
||||||
func Append(sessionsDir, sessionID string, entry Entry) error {
|
func Append(sessionsDir, sessionID string, entry Entry) error {
|
||||||
if err := checkID(sessionID); err != nil {
|
if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Opened through an os.Root on the sessions dir's parent (the brain dir),
|
|
||||||
// so a sessions dir swapped for a symlink after the check below can
|
|
||||||
// redirect the log only inside that parent, never outside it (#173; the
|
|
||||||
// in-brain swap is the #167 residual).
|
|
||||||
parentDir, name := filepath.Dir(sessionsDir), filepath.Base(sessionsDir)
|
|
||||||
if err := os.MkdirAll(parentDir, 0o755); err != nil {
|
|
||||||
return fmt.Errorf("create sessions parent: %w", err)
|
|
||||||
}
|
|
||||||
root, err := os.OpenRoot(parentDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open sessions parent: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = root.Close() }()
|
|
||||||
if err := root.Mkdir(name, 0o755); err != nil && !errors.Is(err, fs.ErrExist) {
|
|
||||||
return fmt.Errorf("create sessions dir: %w", err)
|
return fmt.Errorf("create sessions dir: %w", err)
|
||||||
}
|
}
|
||||||
// The sessions dir itself must be a real directory, not a symlink that
|
path := filepath.Join(sessionsDir, sessionID+".jsonl")
|
||||||
// redirects the log elsewhere (hyperguild#173).
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
if fi, err := root.Lstat(name); err != nil {
|
|
||||||
return fmt.Errorf("stat sessions dir: %w", err)
|
|
||||||
} else if !fi.IsDir() {
|
|
||||||
return fmt.Errorf("sessions dir %s is not a real directory", sessionsDir)
|
|
||||||
}
|
|
||||||
logRel := name + "/" + sessionID + ".jsonl"
|
|
||||||
f, err := root.OpenFile(logRel, os.O_CREATE|os.O_APPEND|os.O_WRONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o644)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("open session log: %w", err)
|
return fmt.Errorf("open session log: %w", err)
|
||||||
}
|
}
|
||||||
// os.Root follows an in-root final symlink even with O_NOFOLLOW, so
|
|
||||||
// confirm, before writing, that the path is the regular file we opened.
|
|
||||||
hfi, herr := f.Stat()
|
|
||||||
lfi, lerr := root.Lstat(logRel)
|
|
||||||
if herr != nil || lerr != nil || !lfi.Mode().IsRegular() || !os.SameFile(lfi, hfi) {
|
|
||||||
_ = f.Close()
|
|
||||||
return fmt.Errorf("session log %s is not a regular file", logRel)
|
|
||||||
}
|
|
||||||
|
|
||||||
line, err := json.Marshal(entry)
|
line, err := json.Marshal(entry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -119,9 +70,6 @@ func Append(sessionsDir, sessionID string, entry Entry) error {
|
|||||||
|
|
||||||
// Read returns all entries for sessionID. Returns empty slice if no log exists.
|
// Read returns all entries for sessionID. Returns empty slice if no log exists.
|
||||||
func Read(sessionsDir, sessionID string) ([]Entry, error) {
|
func Read(sessionsDir, sessionID string) ([]Entry, error) {
|
||||||
if err := checkID(sessionID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
path := filepath.Join(sessionsDir, sessionID+".jsonl")
|
path := filepath.Join(sessionsDir, sessionID+".jsonl")
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
package symlinkguard
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hyperguild#179: the tripwire looked only for symlinks, so a FIFO (or
|
|
||||||
// socket) in the brain passed it. A FIFO at classification.yaml then made
|
|
||||||
// startup exit; at a note path it could hang readers that skip the handle
|
|
||||||
// check.
|
|
||||||
func TestScanReportsFIFOsAndSocketsAsSpecialFiles(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w"), 0o755))
|
|
||||||
require.NoError(t, syscall.Mkfifo(filepath.Join(brain, "classification.yaml"), 0o644))
|
|
||||||
require.NoError(t, syscall.Mkfifo(filepath.Join(brain, "wiki", "w", "f.md"), 0o644))
|
|
||||||
l, err := net.Listen("unix", filepath.Join(brain, "s.sock"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = l.Close() }()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "w", "n.md"), []byte("n"), 0o644))
|
|
||||||
|
|
||||||
r, err := Scan(brain)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{"classification.yaml", "s.sock", "wiki/w/f.md"}, r.Special)
|
|
||||||
assert.False(t, r.Clean())
|
|
||||||
assert.Contains(t, r.String(), "special file")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch removes a FIFO as it removes symlinks, and leaves a regular file
|
|
||||||
// (which Scan never reports) alone.
|
|
||||||
func TestWatchRemovesAFIFOAndLeavesAnUnreportedRegularFile(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
fifo := filepath.Join(brain, "f.md")
|
|
||||||
require.NoError(t, syscall.Mkfifo(fifo, 0o644))
|
|
||||||
regular := filepath.Join(brain, "n.md")
|
|
||||||
require.NoError(t, os.WriteFile(regular, []byte("n"), 0o644))
|
|
||||||
n := &recNotifier{}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go Watch(ctx, brain, 10*time.Millisecond, n)
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
_, err := os.Lstat(fifo)
|
|
||||||
return os.IsNotExist(err)
|
|
||||||
}, 2*time.Second, 10*time.Millisecond, "the FIFO is removed")
|
|
||||||
require.Eventually(t, func() bool { return n.count() >= 1 }, 2*time.Second, 10*time.Millisecond)
|
|
||||||
b, err := os.ReadFile(regular)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "n", string(b))
|
|
||||||
n.mu.Lock()
|
|
||||||
defer n.mu.Unlock()
|
|
||||||
assert.Contains(t, n.msgs[0], "f.md")
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeSpecial re-checks the type: a path that is a regular file or a
|
|
||||||
// directory at the re-check is not removed. (A swap between the re-check and
|
|
||||||
// the unlink can still remove one; Linux has no conditional unlink. The same
|
|
||||||
// window exists in removeSymlinks.)
|
|
||||||
func TestRemoveSpecialRefusesRegularFilesAndDirectories(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brain, "n.md"), []byte("n"), 0o644))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brain, "d"), 0o755))
|
|
||||||
require.NoError(t, syscall.Mkfifo(filepath.Join(brain, "f.md"), 0o644))
|
|
||||||
removed, failed := removeSpecial(brain, []string{"n.md", "d", "f.md"})
|
|
||||||
assert.Equal(t, []string{"f.md"}, removed)
|
|
||||||
assert.Equal(t, []string{"n.md", "d"}, failed)
|
|
||||||
_, err := os.Stat(filepath.Join(brain, "n.md"))
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A special file Watch cannot remove (its directory is read-only) stays
|
|
||||||
// reported: the persistent-finding alert names it.
|
|
||||||
func TestWatchReportsASpecialFileItCannotRemove(t *testing.T) {
|
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
t.Skip("root ignores directory permissions")
|
|
||||||
}
|
|
||||||
brain := t.TempDir()
|
|
||||||
ro := filepath.Join(brain, "ro")
|
|
||||||
require.NoError(t, os.MkdirAll(ro, 0o755))
|
|
||||||
require.NoError(t, syscall.Mkfifo(filepath.Join(ro, "f.md"), 0o644))
|
|
||||||
require.NoError(t, os.Chmod(ro, 0o555))
|
|
||||||
t.Cleanup(func() { _ = os.Chmod(ro, 0o755) })
|
|
||||||
n := &recNotifier{}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go Watch(ctx, brain, 10*time.Millisecond, n)
|
|
||||||
require.Eventually(t, func() bool { return n.count() >= 1 }, 2*time.Second, 10*time.Millisecond)
|
|
||||||
_, err := os.Lstat(filepath.Join(ro, "f.md"))
|
|
||||||
require.NoError(t, err, "the FIFO could not be removed")
|
|
||||||
n.mu.Lock()
|
|
||||||
defer n.mu.Unlock()
|
|
||||||
assert.Contains(t, n.msgs[0], "ro/f.md")
|
|
||||||
assert.Contains(t, n.msgs[0], "special file")
|
|
||||||
for _, m := range n.msgs {
|
|
||||||
assert.NotContains(t, m, "removed", "a file it could not remove was reported as removed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,14 +18,9 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// shutdownFlushTimeout bounds the final flush when Watch stops.
|
|
||||||
var shutdownFlushTimeout = 5 * time.Second
|
|
||||||
|
|
||||||
// DefaultInterval is used when Watch is given a non-positive interval.
|
// DefaultInterval is used when Watch is given a non-positive interval.
|
||||||
const DefaultInterval = 30 * time.Second
|
const DefaultInterval = 30 * time.Second
|
||||||
|
|
||||||
@@ -46,16 +41,10 @@ type Notifier interface {
|
|||||||
type Result struct {
|
type Result struct {
|
||||||
Symlinks []string
|
Symlinks []string
|
||||||
Unreadable []string
|
Unreadable []string
|
||||||
// Special holds FIFOs, sockets and devices (hyperguild#179): a reader
|
|
||||||
// that opens one by path can hang, and a FIFO at classification.yaml
|
|
||||||
// stops startup.
|
|
||||||
Special []string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean reports whether the scan found nothing to act on.
|
// Clean reports whether the scan found nothing to act on.
|
||||||
func (r Result) Clean() bool {
|
func (r Result) Clean() bool { return len(r.Symlinks) == 0 && len(r.Unreadable) == 0 }
|
||||||
return len(r.Symlinks) == 0 && len(r.Unreadable) == 0 && len(r.Special) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// String describes the findings for a log line or alert. Paths are quoted, so
|
// String describes the findings for a log line or alert. Paths are quoted, so
|
||||||
// a name containing a newline cannot blur two findings together.
|
// a name containing a newline cannot blur two findings together.
|
||||||
@@ -65,9 +54,6 @@ func (r Result) String() string {
|
|||||||
if len(r.Symlinks) > 0 {
|
if len(r.Symlinks) > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("%d symlink(s): %s", len(r.Symlinks), q(r.Symlinks)))
|
parts = append(parts, fmt.Sprintf("%d symlink(s): %s", len(r.Symlinks), q(r.Symlinks)))
|
||||||
}
|
}
|
||||||
if len(r.Special) > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("%d special file(s) (FIFO, socket or device; delete them): %s", len(r.Special), q(r.Special)))
|
|
||||||
}
|
|
||||||
if len(r.Unreadable) > 0 {
|
if len(r.Unreadable) > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("%d unreadable dir(s): %s", len(r.Unreadable), q(r.Unreadable)))
|
parts = append(parts, fmt.Sprintf("%d unreadable dir(s): %s", len(r.Unreadable), q(r.Unreadable)))
|
||||||
}
|
}
|
||||||
@@ -122,16 +108,12 @@ func Scan(brainDir string) (Result, error) {
|
|||||||
if d.IsDir() && p == rootGit {
|
if d.IsDir() && p == rootGit {
|
||||||
return filepath.SkipDir
|
return filepath.SkipDir
|
||||||
}
|
}
|
||||||
switch {
|
if d.Type()&os.ModeSymlink != 0 {
|
||||||
case d.Type()&os.ModeSymlink != 0:
|
|
||||||
r.Symlinks = append(r.Symlinks, rel)
|
r.Symlinks = append(r.Symlinks, rel)
|
||||||
case !d.IsDir() && !d.Type().IsRegular():
|
|
||||||
r.Special = append(r.Special, rel)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
sort.Strings(r.Symlinks)
|
sort.Strings(r.Symlinks)
|
||||||
sort.Strings(r.Special)
|
|
||||||
sort.Strings(r.Unreadable)
|
sort.Strings(r.Unreadable)
|
||||||
if walkErr != nil {
|
if walkErr != nil {
|
||||||
// Only the root's own error reaches here (deeper errors are recorded
|
// Only the root's own error reaches here (deeper errors are recorded
|
||||||
@@ -151,32 +133,13 @@ func Scan(brainDir string) (Result, error) {
|
|||||||
// queue until Notify succeeds (see alertQueue): an outage delays them, and
|
// queue until Notify succeeds (see alertQueue): an outage delays them, and
|
||||||
// any that do not fit the bounded queue, or are undeliverable, are counted in
|
// any that do not fit the bounded queue, or are undeliverable, are counted in
|
||||||
// a summary that is never dropped, so every incident is delivered or counted.
|
// a summary that is never dropped, so every incident is delivered or counted.
|
||||||
//
|
|
||||||
// Delivery runs in its own goroutine (hyperguild#164): a hung notifier, with
|
|
||||||
// a full queue, blocked one flush for several notify timeouts, and while the
|
|
||||||
// scan waited on it a planted link stayed readable for several intervals.
|
|
||||||
// The scan hands alerts over without blocking; any that do not fit are
|
|
||||||
// counted in the summary. When ctx ends, Watch returns after a final flush
|
|
||||||
// of what was handed over (bounded by shutdownFlushTimeout); alerts that
|
|
||||||
// flush cannot deliver are counted in one error log line instead. That
|
|
||||||
// shutdown path only runs for a caller that cancels ctx: cmd/server passes
|
|
||||||
// context.Background(), so in production the flush on handoff is what keeps
|
|
||||||
// alerts from waiting on a restart.
|
|
||||||
func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notifier) {
|
func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notifier) {
|
||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
interval = DefaultInterval
|
interval = DefaultInterval
|
||||||
}
|
}
|
||||||
t := time.NewTicker(interval)
|
t := time.NewTicker(interval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
alerts := make(chan string, maxPending)
|
q := &alertQueue{}
|
||||||
var overflow atomic.Int64
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(1)
|
|
||||||
go func() { defer wg.Done(); deliver(ctx, interval, n, alerts, &overflow) }()
|
|
||||||
// The scan is the only sender: closing alerts tells deliver to drain and
|
|
||||||
// flush once more, and Watch returns only after it has.
|
|
||||||
defer func() { close(alerts); wg.Wait() }()
|
|
||||||
q := alertSender{alerts: alerts, overflow: &overflow}
|
|
||||||
reported := ""
|
reported := ""
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -195,92 +158,17 @@ func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notif
|
|||||||
q.push(fmt.Sprintf("brain symlink tripwire (hyperguild#157): removed %d symlink(s) from the brain dir: %s",
|
q.push(fmt.Sprintf("brain symlink tripwire (hyperguild#157): removed %d symlink(s) from the brain dir: %s",
|
||||||
len(removed), Result{}.quoted(removed)))
|
len(removed), Result{}.quoted(removed)))
|
||||||
}
|
}
|
||||||
sRemoved, sFailed := removeSpecial(brainDir, r.Special)
|
key = Result{Symlinks: failed, Unreadable: r.Unreadable}.String()
|
||||||
if len(sRemoved) > 0 {
|
|
||||||
slog.Error("brain symlink tripwire removed special files", "count", len(sRemoved))
|
|
||||||
q.push(fmt.Sprintf("brain symlink tripwire (hyperguild#179): removed %d special file(s) (FIFO, socket or device) from the brain dir: %s",
|
|
||||||
len(sRemoved), Result{}.quoted(sRemoved)))
|
|
||||||
}
|
|
||||||
key = Result{Symlinks: failed, Unreadable: r.Unreadable, Special: sFailed}.String()
|
|
||||||
}
|
}
|
||||||
if key != reported {
|
if key != reported {
|
||||||
reported = key
|
reported = key
|
||||||
if key != "" {
|
if key != "" {
|
||||||
slog.Error("brain symlink tripwire", "finding", key)
|
slog.Error("brain symlink tripwire", "finding", key)
|
||||||
q.push("brain symlink tripwire (hyperguild#157, #179): " + key +
|
q.push("brain symlink tripwire (hyperguild#157): " + key +
|
||||||
". Symlinks can lead readers out of the brain and special files can hang them; remove them.")
|
". Readers may follow symlinks out of the brain; fix them.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// alertSender hands alerts to deliver without ever blocking the scan; an
|
|
||||||
// alert that does not fit is counted in overflow, which deliver adds to the
|
|
||||||
// summary.
|
|
||||||
type alertSender struct {
|
|
||||||
alerts chan<- string
|
|
||||||
overflow *atomic.Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s alertSender) push(msg string) {
|
|
||||||
select {
|
|
||||||
case s.alerts <- msg:
|
|
||||||
default:
|
|
||||||
s.overflow.Add(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliver owns the alertQueue. It flushes as soon as an alert is handed
|
|
||||||
// over, and again every interval while any are pending (outage retries).
|
|
||||||
// When alerts is closed (Watch is stopping) it drains it and flushes once
|
|
||||||
// more, bounded by shutdownFlushTimeout, so an alert handed over just before
|
|
||||||
// shutdown is not lost (PR #180 review). Whatever that flush cannot deliver
|
|
||||||
// is logged as one error with its counts, never dropped silently.
|
|
||||||
func deliver(ctx context.Context, interval time.Duration, n Notifier, alerts <-chan string, overflow *atomic.Int64) {
|
|
||||||
t := time.NewTicker(interval)
|
|
||||||
defer t.Stop()
|
|
||||||
q := &alertQueue{}
|
|
||||||
// drain moves everything handed over into the queue; it reports false
|
|
||||||
// once alerts is closed.
|
|
||||||
drain := func() bool {
|
|
||||||
defer func() { q.suppressed += int(overflow.Swap(0)) }()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case msg, ok := <-alerts:
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
q.push(msg)
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case msg, ok := <-alerts:
|
|
||||||
if ok {
|
|
||||||
q.push(msg)
|
|
||||||
ok = drain()
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
q.suppressed += int(overflow.Swap(0))
|
|
||||||
fctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownFlushTimeout)
|
|
||||||
q.flush(fctx, n)
|
|
||||||
cancel()
|
|
||||||
if len(q.items) > 0 || q.suppressed > 0 {
|
|
||||||
slog.Error("symlink tripwire alerts not delivered at shutdown",
|
|
||||||
"undelivered", len(q.items), "suppressed", q.suppressed)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
q.flush(ctx, n)
|
q.flush(ctx, n)
|
||||||
case <-t.C:
|
|
||||||
if !drain() {
|
|
||||||
continue // the closed channel is handled by the case above
|
|
||||||
}
|
|
||||||
q.flush(ctx, n)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,40 +229,6 @@ func permanent(err error) bool {
|
|||||||
return errors.As(err, &p) && p.Permanent()
|
return errors.As(err, &p) && p.Permanent()
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeSpecial unlinks each brain-relative FIFO, socket or device through
|
|
||||||
// an os.Root on the brain dir, after re-checking that it is still one: a
|
|
||||||
// path that is a regular file, a directory or a symlink at the re-check is
|
|
||||||
// left alone and reported as failed. The re-check narrows, but cannot close,
|
|
||||||
// the window before the unlink (Linux has no conditional unlink); the same
|
|
||||||
// window exists in removeSymlinks, and only a local writer can use it.
|
|
||||||
func removeSpecial(brainDir string, rels []string) (removed, failed []string) {
|
|
||||||
if len(rels) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
resolved, err := filepath.EvalSymlinks(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, rels
|
|
||||||
}
|
|
||||||
root, err := os.OpenRoot(resolved)
|
|
||||||
if err != nil {
|
|
||||||
return nil, rels
|
|
||||||
}
|
|
||||||
defer func() { _ = root.Close() }()
|
|
||||||
for _, rel := range rels {
|
|
||||||
fi, err := root.Lstat(rel)
|
|
||||||
if err != nil || fi.IsDir() || fi.Mode().IsRegular() || fi.Mode()&os.ModeSymlink != 0 {
|
|
||||||
failed = append(failed, rel)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := root.Remove(rel); err != nil {
|
|
||||||
failed = append(failed, rel)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
removed = append(removed, rel)
|
|
||||||
}
|
|
||||||
return removed, failed
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeSymlinks unlinks each brain-relative symlink through an os.Root on
|
// removeSymlinks unlinks each brain-relative symlink through an os.Root on
|
||||||
// the brain dir, after re-checking it is still a symlink. Removing a symlink
|
// the brain dir, after re-checking it is still a symlink. Removing a symlink
|
||||||
// never touches its target. It returns what was removed and what was not.
|
// never touches its target. It returns what was removed and what was not.
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
package symlinkguard
|
package symlinkguard
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"log/slog"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -347,19 +345,13 @@ func TestWatchDropsAMessageThatNeverSendsSoLaterAlertsFlow(t *testing.T) {
|
|||||||
}, 3*time.Second, 10*time.Millisecond, "a later alert is delivered despite a poison message")
|
}, 3*time.Second, 10*time.Millisecond, "a later alert is delivered despite a poison message")
|
||||||
}
|
}
|
||||||
|
|
||||||
// toggleNotifier fails while down is set. Like the real audit.NtfyNotifier
|
// toggleNotifier fails while down is set.
|
||||||
// (http.NewRequestWithContext), it also fails, without recording, when its
|
|
||||||
// ctx is already done (PR #180 round 2: notifiers that ignored ctx let a
|
|
||||||
// final flush on the cancelled ctx pass).
|
|
||||||
type toggleNotifier struct {
|
type toggleNotifier struct {
|
||||||
recNotifier
|
recNotifier
|
||||||
down atomic.Bool
|
down atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *toggleNotifier) Notify(ctx context.Context, m string) error {
|
func (r *toggleNotifier) Notify(ctx context.Context, m string) error {
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if r.down.Load() {
|
if r.down.Load() {
|
||||||
return errors.New("ntfy down")
|
return errors.New("ntfy down")
|
||||||
}
|
}
|
||||||
@@ -381,19 +373,18 @@ 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)
|
||||||
// Synchronise on the summary being delivered (hyperguild#193: a fixed
|
require.Eventually(t, func() bool { return n.count() >= 1 }, 2*time.Second, 5*time.Millisecond)
|
||||||
// sleep flaked under load); once it is, the queue has been flushed.
|
time.Sleep(100 * time.Millisecond)
|
||||||
require.Eventually(t, func() bool {
|
assert.LessOrEqual(t, n.count(), maxPending+1, "queued alerts plus at most one suppression summary")
|
||||||
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
|
||||||
@@ -412,21 +403,11 @@ 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 }, 10*time.Second, 5*time.Millisecond)
|
require.Eventually(t, func() bool { return n.count() == 3 }, 2*time.Second, 5*time.Millisecond)
|
||||||
n.mu.Lock()
|
n.mu.Lock()
|
||||||
defer n.mu.Unlock()
|
defer n.mu.Unlock()
|
||||||
// Each removal delivered exactly once, in any order: the outage ends
|
for i, name := range []string{"qa", "qb", "qc"} {
|
||||||
// asynchronously, possibly mid-flush, so a later item can send before an
|
assert.Contains(t, n.msgs[i], strconv.Quote(name))
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,30 +449,29 @@ 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)
|
||||||
// Synchronise on the record being complete, not on a fixed sleep after
|
require.Eventually(t, func() bool {
|
||||||
// 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 {
|
||||||
var k int
|
if strings.Contains(m, "suppressed") {
|
||||||
if i := strings.Index(m, "removed "); i >= 0 && strings.Contains(m, "symlink(s)") {
|
return true
|
||||||
if _, err := fmt.Sscanf(m[i+len("removed "):], "%d", &k); err == nil {
|
|
||||||
delivered += k
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
|
}, 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") {
|
if i := strings.Index(m, "): "); i >= 0 && strings.Contains(m, "suppressed") {
|
||||||
if _, err := fmt.Sscanf(m[i+3:], "%d", &k); err == nil {
|
_, _ = fmt.Sscanf(m[i+3:], "%d", &counted)
|
||||||
counted += k
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
assert.Equal(t, plants, delivered+counted, "delivered %d + counted %d must equal %d removals", delivered, counted, plants)
|
||||||
return delivered, counted
|
|
||||||
}
|
|
||||||
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
|
||||||
@@ -516,271 +496,3 @@ func TestWatchDropsAPoisonMessageAndCountsIt(t *testing.T) {
|
|||||||
return false
|
return false
|
||||||
}, 3*time.Second, 5*time.Millisecond, "the dropped poison alert is counted in a summary")
|
}, 3*time.Second, 5*time.Millisecond, "the dropped poison alert is counted in a summary")
|
||||||
}
|
}
|
||||||
|
|
||||||
// hungNotifier answers every Notify only after latency (or when ctx ends),
|
|
||||||
// with an outage error: an ntfy that accepts connections but never responds
|
|
||||||
// until its timeout.
|
|
||||||
type hungNotifier struct{ latency time.Duration }
|
|
||||||
|
|
||||||
func (h hungNotifier) Notify(ctx context.Context, _ string) error {
|
|
||||||
select {
|
|
||||||
case <-time.After(h.latency):
|
|
||||||
case <-ctx.Done():
|
|
||||||
}
|
|
||||||
return errors.New("ntfy timeout")
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitGone(t *testing.T, link string) {
|
|
||||||
t.Helper()
|
|
||||||
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 10*time.Second, time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
// hyperguild#164: alerts were flushed in the scan goroutine, so with a hung
|
|
||||||
// ntfy and a full queue one flush (6 x notify timeout) delayed the next scan.
|
|
||||||
// The notifier here is slower than the interval, as in production (10 s
|
|
||||||
// timeout vs 30 s interval with 6 messages), so the alert handoff is full:
|
|
||||||
// a scan that waited on it would keep a link for seconds.
|
|
||||||
func TestWatchLinkLifetimeStaysNearOneIntervalWithANotifierSlowerThanTheInterval(t *testing.T) {
|
|
||||||
const interval = 100 * time.Millisecond
|
|
||||||
brain := t.TempDir()
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go Watch(ctx, brain, interval, hungNotifier{latency: 400 * time.Millisecond})
|
|
||||||
|
|
||||||
for i := 0; i < 2*maxPending+3; i++ { // overfill queue and handoff
|
|
||||||
link := filepath.Join(brain, "fill"+strconv.Itoa(i))
|
|
||||||
require.NoError(t, os.Symlink(t.TempDir(), link))
|
|
||||||
waitGone(t, link)
|
|
||||||
}
|
|
||||||
var worst time.Duration
|
|
||||||
for i := 0; i < 6; i++ {
|
|
||||||
link := filepath.Join(brain, "probe"+strconv.Itoa(i))
|
|
||||||
require.NoError(t, os.Symlink(t.TempDir(), link))
|
|
||||||
planted := time.Now()
|
|
||||||
waitGone(t, link)
|
|
||||||
worst = max(worst, time.Since(planted))
|
|
||||||
}
|
|
||||||
assert.LessOrEqual(t, worst, interval+interval/2, "worst link lifetime %v with a hung notifier", worst)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Review of PR #180: delivery waited for its own tick, about one interval
|
|
||||||
// after the removal. An alert is now flushed as soon as it is handed over.
|
|
||||||
func TestWatchDeliversARemovalAlertWellWithinOneInterval(t *testing.T) {
|
|
||||||
const interval = 400 * time.Millisecond
|
|
||||||
brain := t.TempDir()
|
|
||||||
n := &recNotifier{}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go Watch(ctx, brain, interval, n)
|
|
||||||
link := filepath.Join(brain, "prompt")
|
|
||||||
require.NoError(t, os.Symlink(t.TempDir(), link))
|
|
||||||
waitGone(t, link)
|
|
||||||
removed := time.Now()
|
|
||||||
require.Eventually(t, func() bool { return n.count() == 1 }, 2*time.Second, time.Millisecond)
|
|
||||||
assert.Less(t, time.Since(removed), interval/4)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Review of PR #180: on ctx end, deliver returned without draining what the
|
|
||||||
// scan had handed over, so a removal just before shutdown was neither
|
|
||||||
// delivered nor counted. Watch now drains and flushes before returning.
|
|
||||||
func TestWatchDeliversAnAlertHandedOverJustBeforeShutdown(t *testing.T) {
|
|
||||||
for i := 0; i < 20; i++ {
|
|
||||||
brain := t.TempDir()
|
|
||||||
n := &recNotifier{}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() { Watch(ctx, brain, 5*time.Millisecond, n); close(done) }()
|
|
||||||
link := filepath.Join(brain, "late")
|
|
||||||
require.NoError(t, os.Symlink(t.TempDir(), link))
|
|
||||||
waitGone(t, link)
|
|
||||||
cancel()
|
|
||||||
<-done
|
|
||||||
require.Equal(t, 1, n.count(), "iteration %d: the removal alert was lost at shutdown", i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// blockingNotifier holds every Notify until its ctx ends, tracking calls in
|
|
||||||
// flight.
|
|
||||||
type blockingNotifier struct{ inFlight atomic.Int32 }
|
|
||||||
|
|
||||||
func (b *blockingNotifier) Notify(ctx context.Context, _ string) error {
|
|
||||||
b.inFlight.Add(1)
|
|
||||||
defer b.inFlight.Add(-1)
|
|
||||||
<-ctx.Done()
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch does not return while its delivery goroutine is still notifying.
|
|
||||||
func TestWatchReturnsOnlyAfterDeliveryHasStopped(t *testing.T) {
|
|
||||||
prev := shutdownFlushTimeout
|
|
||||||
shutdownFlushTimeout = 50 * time.Millisecond
|
|
||||||
t.Cleanup(func() { shutdownFlushTimeout = prev })
|
|
||||||
brain := t.TempDir()
|
|
||||||
n := &blockingNotifier{}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() { Watch(ctx, brain, 5*time.Millisecond, n); close(done) }()
|
|
||||||
link := filepath.Join(brain, "x")
|
|
||||||
require.NoError(t, os.Symlink(t.TempDir(), link))
|
|
||||||
waitGone(t, link)
|
|
||||||
require.Eventually(t, func() bool { return n.inFlight.Load() == 1 }, 2*time.Second, time.Millisecond)
|
|
||||||
cancel()
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
t.Fatal("Watch did not return after cancel")
|
|
||||||
}
|
|
||||||
assert.Zero(t, n.inFlight.Load(), "Notify still running after Watch returned")
|
|
||||||
}
|
|
||||||
|
|
||||||
// slowToggleNotifier fails slowly (a hung ntfy) while down, then records.
|
|
||||||
type slowToggleNotifier struct {
|
|
||||||
recNotifier
|
|
||||||
down atomic.Bool
|
|
||||||
latency time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *slowToggleNotifier) Notify(ctx context.Context, m string) error {
|
|
||||||
if r.down.Load() {
|
|
||||||
select {
|
|
||||||
case <-time.After(r.latency):
|
|
||||||
case <-ctx.Done():
|
|
||||||
}
|
|
||||||
return errors.New("ntfy timeout")
|
|
||||||
}
|
|
||||||
return r.recNotifier.Notify(ctx, m)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Review of PR #180: with a slow notifier the scan's handoff overflows, and
|
|
||||||
// those alerts exist only in the overflow counter. Every removal is still
|
|
||||||
// delivered or counted in the summary.
|
|
||||||
func TestWatchCountsHandoffOverflowWithASlowNotifier(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
n := &slowToggleNotifier{latency: 30 * time.Millisecond}
|
|
||||||
n.down.Store(true)
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go Watch(ctx, brain, 2*time.Millisecond, n)
|
|
||||||
const plants = 40
|
|
||||||
for i := 0; i < plants; i++ {
|
|
||||||
link := filepath.Join(brain, "ov"+strconv.Itoa(i))
|
|
||||||
require.NoError(t, os.Symlink(t.TempDir(), link))
|
|
||||||
waitGone(t, link)
|
|
||||||
}
|
|
||||||
n.down.Store(false)
|
|
||||||
delivered, counted := 0, 0
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
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") {
|
|
||||||
var c int
|
|
||||||
_, _ = fmt.Sscanf(m[i+3:], "%d", &c)
|
|
||||||
counted += c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return delivered+counted == plants
|
|
||||||
}, 5*time.Second, 10*time.Millisecond, "delivered %d + counted %d of %d", delivered, counted, plants)
|
|
||||||
}
|
|
||||||
|
|
||||||
// An alert still queued when Watch stops (its send failed, the notifier has
|
|
||||||
// since recovered, the retry tick has not come) is flushed once more before
|
|
||||||
// Watch returns, on a ctx that is not the cancelled one: toggleNotifier fails
|
|
||||||
// on a done ctx, as the real ntfy notifier does.
|
|
||||||
func TestWatchFinalFlushSendsAQueuedAlertOnACtxThatOutlivesTheCancel(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
n := &toggleNotifier{}
|
|
||||||
n.down.Store(true)
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() { Watch(ctx, brain, 300*time.Millisecond, n); close(done) }()
|
|
||||||
link := filepath.Join(brain, "queued")
|
|
||||||
require.NoError(t, os.Symlink(t.TempDir(), link))
|
|
||||||
waitGone(t, link)
|
|
||||||
time.Sleep(20 * time.Millisecond) // let the immediate send fail
|
|
||||||
n.down.Store(false)
|
|
||||||
cancel()
|
|
||||||
<-done
|
|
||||||
assert.Equal(t, 1, n.count(), "the queued alert was not flushed at shutdown")
|
|
||||||
}
|
|
||||||
|
|
||||||
// lockedBuffer is a goroutine-safe log sink: Watch goroutines of earlier
|
|
||||||
// tests may still log while one test captures.
|
|
||||||
type lockedBuffer struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
buf bytes.Buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *lockedBuffer) Write(p []byte) (int, error) {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
return b.buf.Write(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *lockedBuffer) String() string { b.mu.Lock(); defer b.mu.Unlock(); return b.buf.String() }
|
|
||||||
|
|
||||||
func captureLog(t *testing.T) *lockedBuffer {
|
|
||||||
t.Helper()
|
|
||||||
b := &lockedBuffer{}
|
|
||||||
prev := slog.Default()
|
|
||||||
slog.SetDefault(slog.New(slog.NewTextHandler(b, nil)))
|
|
||||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// PR #180 round 2: when the final flush fails (ntfy hung at shutdown) the
|
|
||||||
// undelivered alerts vanished with only "will retry" warnings. Watch now logs
|
|
||||||
// one error with both counts.
|
|
||||||
func TestWatchLogsUndeliveredAndSuppressedCountsWhenTheFinalFlushFails(t *testing.T) {
|
|
||||||
prev := shutdownFlushTimeout
|
|
||||||
shutdownFlushTimeout = 50 * time.Millisecond
|
|
||||||
t.Cleanup(func() { shutdownFlushTimeout = prev })
|
|
||||||
logs := captureLog(t)
|
|
||||||
brain := t.TempDir()
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() { Watch(ctx, brain, 5*time.Millisecond, hungNotifier{latency: time.Hour}); close(done) }()
|
|
||||||
link := filepath.Join(brain, "lost")
|
|
||||||
require.NoError(t, os.Symlink(t.TempDir(), link))
|
|
||||||
waitGone(t, link)
|
|
||||||
cancel()
|
|
||||||
<-done
|
|
||||||
out := logs.String()
|
|
||||||
assert.Contains(t, out, "level=ERROR")
|
|
||||||
assert.Contains(t, out, `msg="symlink tripwire alerts not delivered at shutdown"`)
|
|
||||||
assert.Contains(t, out, "undelivered=1")
|
|
||||||
assert.Contains(t, out, "suppressed=0")
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliver, given a handoff that already holds alerts and is closed, moves them
|
|
||||||
// into the queue and runs the final flush, even though the ctx is cancelled:
|
|
||||||
// the drain-on-close path, without depending on when the close lands.
|
|
||||||
func TestDeliverFlushesAlertsDrainedFromAClosedHandoffOnACancelledCtx(t *testing.T) {
|
|
||||||
alerts := make(chan string, 3)
|
|
||||||
alerts <- "a1"
|
|
||||||
alerts <- "a2"
|
|
||||||
close(alerts)
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
n := &toggleNotifier{}
|
|
||||||
var overflow atomic.Int64
|
|
||||||
deliver(ctx, time.Hour, n, alerts, &overflow)
|
|
||||||
assert.Equal(t, []string{"a1", "a2"}, n.msgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PR #180 round 2: when the first receive after close finds the handoff empty,
|
|
||||||
// overflow counted by the scan was never moved into the summary.
|
|
||||||
func TestDeliverCountsOverflowWhenTheHandoffIsClosedAndEmpty(t *testing.T) {
|
|
||||||
alerts := make(chan string)
|
|
||||||
close(alerts)
|
|
||||||
n := &recNotifier{}
|
|
||||||
var overflow atomic.Int64
|
|
||||||
overflow.Store(3)
|
|
||||||
deliver(context.Background(), time.Hour, n, alerts, &overflow)
|
|
||||||
require.Len(t, n.msgs, 1)
|
|
||||||
assert.Contains(t, n.msgs[0], "3 further alert(s)")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -42,11 +42,6 @@ func TestSyncRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
|
|||||||
brain := t.TempDir()
|
brain := t.TempDir()
|
||||||
writeNote(t, brain, "wiki/w/facts/ok.md", "inside note")
|
writeNote(t, brain, "wiki/w/facts/ok.md", "inside note")
|
||||||
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(brain, "wiki", "w", "facts", "leak.md")))
|
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(brain, "wiki", "w", "facts", "leak.md")))
|
||||||
// A link to a file elsewhere in the brain: os.Root allows it, only the
|
|
||||||
// regular-file checks refuse it (hyperguild#171). raw/ is not a scan root,
|
|
||||||
// so its sentinel can only arrive through the link.
|
|
||||||
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(brain, "wiki", "w", "facts", "inlink.md")))
|
|
||||||
|
|
||||||
emb := &recordingEmbedder{}
|
emb := &recordingEmbedder{}
|
||||||
store := &stubStore{}
|
store := &stubStore{}
|
||||||
@@ -56,7 +51,6 @@ func TestSyncRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
|
|||||||
assert.NotContains(t, emb.all(), brainfstest.Sentinel)
|
assert.NotContains(t, emb.all(), brainfstest.Sentinel)
|
||||||
for p := range store.upserts {
|
for p := range store.upserts {
|
||||||
assert.NotContains(t, p, "leak.md")
|
assert.NotContains(t, p, "leak.md")
|
||||||
assert.NotContains(t, p, "inlink.md")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
p := filepath.Join(brain, "knowledge", "r.md")
|
p := filepath.Join(brain, "knowledge", "r.md")
|
||||||
@@ -67,7 +61,6 @@ func TestSyncRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
|
|||||||
return e.all()
|
return e.all()
|
||||||
}
|
}
|
||||||
brainfstest.AssertNoLeak(t, p, "inside", 1500*time.Millisecond, sync)
|
brainfstest.AssertNoLeak(t, p, "inside", 1500*time.Millisecond, sync)
|
||||||
brainfstest.AssertNoLeakFrom(t, p, "inside", inBrain, 1500*time.Millisecond, sync)
|
|
||||||
brainfstest.AssertNoHang(t, p, "inside", 1500*time.Millisecond, 6*time.Second, func() { _ = sync() })
|
brainfstest.AssertNoHang(t, p, "inside", 1500*time.Millisecond, 6*time.Second, func() { _ = sync() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
package watcher
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// These tests are the sprint's goal (hyperguild#209) stated as behaviour, with
|
|
||||||
// the real pipeline and real file layout and only the model stubbed: a person
|
|
||||||
// drops a clip into raw/ and does nothing else.
|
|
||||||
|
|
||||||
func acceptanceCfg(t *testing.T) (Config, string, *atomic.Int32) {
|
|
||||||
t.Helper()
|
|
||||||
brainDir := t.TempDir()
|
|
||||||
var calls atomic.Int32
|
|
||||||
// Each call extracts a source with its own title, as real clips have: two
|
|
||||||
// sources with one title are deliberately merged into one page by the
|
|
||||||
// pipeline's title resolution.
|
|
||||||
pagesFor := func(n int32) []pipeline.RawPage {
|
|
||||||
return []pipeline.RawPage{
|
|
||||||
{Title: fmt.Sprintf("Dark Factory Talk %d", n), Type: "source", Subtype: "video", Domain: "ai",
|
|
||||||
Content: "## Summary\n\nA talk.\n\n## Entities Mentioned\n\n[[Jev]]\n\n## Concepts Introduced or Reinforced\n\n[[Dark Factory]]\n"},
|
|
||||||
{Title: "Jev", Type: "entity", Subtype: "tool", Content: "## Definition\n\nA decision model.\n\n## Sources\n"},
|
|
||||||
{Title: "Dark Factory", Type: "concept", Content: "## Definition\n\nA fully automated build line.\n\n## Sources\n"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Config{
|
|
||||||
BrainDir: brainDir,
|
|
||||||
Resolver: wing.Router{BrainDir: brainDir, Default: "web-clip", ClientBlock: regexp.MustCompile(`(?i)acme-client`)},
|
|
||||||
Pipeline: pipeline.Config{
|
|
||||||
Schema: "# Schema",
|
|
||||||
Complete: func(context.Context, string, string) (string, error) {
|
|
||||||
return mustJSONPages(pagesFor(calls.Add(1))), nil
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, brainDir, &calls
|
|
||||||
}
|
|
||||||
|
|
||||||
// topLevel lists the regular files directly in raw/: the inbox.
|
|
||||||
func topLevel(t *testing.T, brainDir string) []string {
|
|
||||||
t.Helper()
|
|
||||||
entries, err := os.ReadDir(filepath.Join(brainDir, "raw"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
var out []string
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.Type().IsRegular() {
|
|
||||||
out = append(out, e.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAcceptance_ADroppedClipLandsInWebClipAndTheInboxIsEmpty(t *testing.T) {
|
|
||||||
cfg, brainDir, calls := acceptanceCfg(t)
|
|
||||||
put(t, brainDir, "dark-factory-talk.md", "Transcript of a talk about dark factories.")
|
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
|
|
||||||
assert.True(t, has(brainDir, "wiki/web-clip/sources/dark-factory-talk.md"), "the clip is a page in web-clip")
|
|
||||||
assert.True(t, has(brainDir, "wiki/entities/jev.md"), "its entity is in the shared layer")
|
|
||||||
assert.True(t, has(brainDir, "wiki/concepts/dark-factory.md"), "its concept is in the shared layer")
|
|
||||||
assert.Empty(t, topLevel(t, brainDir), "raw/ holds nothing unprocessed")
|
|
||||||
assert.False(t, has(brainDir, "raw/needs-review"), "and nothing waits for a human")
|
|
||||||
assert.Equal(t, int32(1), calls.Load(), "one extraction, no classification step")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAcceptance_TheSameEntityFromTwoClipsIsOnePage(t *testing.T) {
|
|
||||||
cfg, brainDir, _ := acceptanceCfg(t)
|
|
||||||
put(t, brainDir, "talk-one.md", "First talk.")
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
put(t, brainDir, "talk-two.md", "Second talk, different words.")
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
|
|
||||||
files, err := filepath.Glob(filepath.Join(brainDir, "wiki", "entities", "*.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, files, 1)
|
|
||||||
b, _ := os.ReadFile(filepath.Join(brainDir, "wiki/entities/jev.md"))
|
|
||||||
assert.True(t, strings.Contains(string(b), "talk-one") && strings.Contains(string(b), "talk-two"), "both sources are cited")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAcceptance_AClipThatLooksLikeClientMaterialIsParkedNotFiled(t *testing.T) {
|
|
||||||
cfg, brainDir, calls := acceptanceCfg(t)
|
|
||||||
put(t, brainDir, "kickoff.md", "Notes from the ACME-CLIENT kickoff call.")
|
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
|
|
||||||
assert.Zero(t, calls.Load(), "not even extracted")
|
|
||||||
assert.False(t, has(brainDir, "wiki/web-clip"), "never filed into a shared wing")
|
|
||||||
assert.True(t, has(brainDir, "raw/needs-review/kickoff.md"), "waits for the owner")
|
|
||||||
assert.Empty(t, topLevel(t, brainDir))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAcceptance_ASubfolderNamingAnExistingWingPlacesThereAndATypoParks(t *testing.T) {
|
|
||||||
cfg, brainDir, _ := acceptanceCfg(t)
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "wiki", "homelab", "facts"), 0o755))
|
|
||||||
put(t, brainDir, "homelab/ops-note.md", "A note about the cluster.")
|
|
||||||
put(t, brainDir, "hoemlab/typo-note.md", "A misfiled note.")
|
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
|
|
||||||
assert.True(t, has(brainDir, "wiki/homelab/sources/ops-note.md"))
|
|
||||||
assert.False(t, has(brainDir, "wiki/hoemlab"), "a typo never creates a wing")
|
|
||||||
assert.True(t, has(brainDir, "raw/needs-review/typo-note.md"))
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package watcher
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Kept out of watcher.go on purpose: that file is covered by the raw-reader
|
|
||||||
// guard (internal/extract/callsites_test.go), which forbids any .Open selector
|
|
||||||
// there. This function reads nothing from raw/; it creates a file under the
|
|
||||||
// brain root, which refuses symlinked directories and never follows a final
|
|
||||||
// symlink.
|
|
||||||
|
|
||||||
// createParked writes data to raw/needs-review/<filename> through the brain
|
|
||||||
// root, never replacing a parked note: on a name clash it tries -2, -3, ...
|
|
||||||
// A needs-review that is a symlink, or any non-directory, is refused.
|
|
||||||
func createParked(brainDir, filename string, data []byte) error {
|
|
||||||
return createUnique(brainDir, "raw/needs-review", filename, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// createUnique writes data to dirRel/filename through the brain root, never
|
|
||||||
// replacing an existing file: on a name clash it tries -2, -3, ... A dirRel that
|
|
||||||
// is a symlink, or any non-directory, is refused.
|
|
||||||
func createUnique(brainDir, dirRel, filename string, data []byte) error {
|
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
ext := filepath.Ext(filename)
|
|
||||||
stem := strings.TrimSuffix(filename, ext)
|
|
||||||
for n := 1; n < 10000; n++ {
|
|
||||||
name := filename
|
|
||||||
if n > 1 {
|
|
||||||
name = fmt.Sprintf("%s-%d%s", stem, n, ext)
|
|
||||||
}
|
|
||||||
err := bfs.CreateRegular(dirRel+"/"+name, data)
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !errors.Is(err, fs.ErrExist) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("no free name for %q in %s", filename, dirRel)
|
|
||||||
}
|
|
||||||
@@ -3,22 +3,17 @@ package watcher
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds watcher configuration.
|
// Config holds watcher configuration.
|
||||||
@@ -26,27 +21,8 @@ type Config struct {
|
|||||||
BrainDir string
|
BrainDir string
|
||||||
Interval time.Duration
|
Interval time.Duration
|
||||||
Pipeline pipeline.Config
|
Pipeline pipeline.Config
|
||||||
|
|
||||||
// MaxNoteBytes refuses a note larger than this before anything is read into
|
|
||||||
// memory (hyperguild#198). 0 means defaultMaxNoteBytes.
|
|
||||||
MaxNoteBytes int64
|
|
||||||
|
|
||||||
// Resolver, when set, decides each note's wing (hyperguild#205, #208): a
|
|
||||||
// deliberate subfolder/frontmatter wing, else the default wing. A note it
|
|
||||||
// cannot place is parked in raw/needs-review/ for a human. Nil keeps the
|
|
||||||
// retired layout. Set it only to a non-nil value: a nil pointer inside the
|
|
||||||
// interface is not nil.
|
|
||||||
Resolver wing.Placer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultMaxNoteBytes bounds a single note: extraction and the archive copy both
|
|
||||||
// hold it in memory.
|
|
||||||
const defaultMaxNoteBytes = 64 << 20
|
|
||||||
|
|
||||||
// errTooLarge marks a note refused for its size; like a refused write it is
|
|
||||||
// reported once and not retried until the note changes.
|
|
||||||
var errTooLarge = extract.ErrTooLarge
|
|
||||||
|
|
||||||
// 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) {
|
||||||
@@ -109,7 +85,7 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
|
|||||||
// path that treats raw/ as "awaiting processing" — this is that path.
|
// path that treats raw/ as "awaiting processing" — this is that path.
|
||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
name := d.Name()
|
name := d.Name()
|
||||||
if name == "processed" || name == "failed" || name == "rejected" || name == "needs-review" {
|
if name == "processed" || name == "failed" || name == "rejected" {
|
||||||
return filepath.SkipDir
|
return filepath.SkipDir
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -133,33 +109,14 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Skip files that have already been processed or permanently failed.
|
// Skip files that have already been processed or permanently failed.
|
||||||
// Any entry at the marker path counts (Lstat, not Stat): a dangling
|
if _, err := os.Stat(path + ".processed"); err == nil {
|
||||||
// marker symlink made os.Stat report nothing and the note was
|
|
||||||
// reprocessed every interval (hyperguild#158).
|
|
||||||
if skip, stale := markerBlocks(path); skip {
|
|
||||||
warnMarkerOnce(path)
|
|
||||||
return nil
|
return nil
|
||||||
} else {
|
|
||||||
for _, m := range stale {
|
|
||||||
slog.Warn("watcher: ignoring a stale marker older than its note", "marker", m)
|
|
||||||
if rmErr := root.Remove(relOf(rawDir, m)); rmErr != nil {
|
|
||||||
slog.Error("watcher: failed to remove a stale marker", "marker", m, "error", rmErr)
|
|
||||||
}
|
}
|
||||||
}
|
if _, err := os.Stat(path + ".failed"); err == nil {
|
||||||
}
|
|
||||||
// A note whose archive copy or marker was refused is not retried
|
|
||||||
// until it changes: a refusal has no marker to stop the next round,
|
|
||||||
// and each round can cost an LLM call (hyperguild#158).
|
|
||||||
info, ierr := d.Info()
|
|
||||||
if ierr == nil && refusedUnchanged(path, info) {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := processFile(ctx, cfg, root, rawDir, path, date); err != nil {
|
if err := processFile(ctx, cfg, root, rawDir, path, date); err != nil {
|
||||||
if ierr == nil && (errors.Is(err, brainfs.ErrNotRegular) || errors.Is(err, errTooLarge)) {
|
|
||||||
rememberRefused(path, info)
|
|
||||||
slog.Error("watcher: note refused; not retried until it changes", "file", filepath.Base(path), "error", err)
|
|
||||||
}
|
|
||||||
errs = append(errs, fmt.Errorf("process %s: %w", filepath.Base(path), err))
|
errs = append(errs, fmt.Errorf("process %s: %w", filepath.Base(path), err))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -170,12 +127,9 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
|
|||||||
return errs
|
return errs
|
||||||
}
|
}
|
||||||
|
|
||||||
// processFile reads a file, calls pipeline.Run, and archives it into processed/
|
// processFile reads a file, calls pipeline.Run, copies it to processed/ or failed/,
|
||||||
// or failed/. The original is then removed from raw/ so that raw/ only ever
|
// and writes a marker file next to the original so the watcher skips it next poll.
|
||||||
// holds unprocessed input (inbox zero). The removal syncs to Syncthing peers;
|
// The original file is never deleted, keeping Syncthing-connected vaults (e.g. Obsidian) intact.
|
||||||
// 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)
|
||||||
@@ -184,43 +138,25 @@ 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)
|
content, err := extract.TextIn(root, rel)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("stat before ingest: %w", err)
|
|
||||||
}
|
|
||||||
limit := cfg.MaxNoteBytes
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = defaultMaxNoteBytes
|
|
||||||
}
|
|
||||||
if before.Size() > limit {
|
|
||||||
return fmt.Errorf("%s is %d bytes, over the %d byte cap: %w", filename, before.Size(), limit, errTooLarge)
|
|
||||||
}
|
|
||||||
content, err := extract.TextInMax(root, rel, limit)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("extract text: %w", err)
|
return fmt.Errorf("extract text: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pcfg := cfg.Pipeline
|
_, runErr := pipeline.Run(ctx, cfg.Pipeline, cfg.BrainDir, content, source, false)
|
||||||
if cfg.Resolver != nil {
|
|
||||||
d := cfg.Resolver.Resolve(ctx, wing.Note{Dir: topDir(rel), FrontmatterWing: frontmatterWing(content), Text: content})
|
|
||||||
if d.Source == wing.Exception {
|
|
||||||
return parkForReview(cfg, root, rel, path, filename, content, d, date, before)
|
|
||||||
}
|
|
||||||
pcfg.Wing = d.Wing
|
|
||||||
slog.Info("watcher: wing resolved", "file", filename, "wing", d.Wing, "how", d.Source, "reason", d.Reason)
|
|
||||||
}
|
|
||||||
_, runErr := pipeline.Run(ctx, pcfg, cfg.BrainDir, content, source, false)
|
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
// Copy to failed/ and leave a .failed marker so we don't retry. Both
|
// Copy to failed/ and leave a .failed marker so we don't retry.
|
||||||
// written through the brain root (hyperguild#158).
|
failedDir := filepath.Join(cfg.BrainDir, "raw", "failed")
|
||||||
if cpErr := copyFromRoot(root, rel, cfg.BrainDir, "raw/failed", filename, limit); cpErr != nil {
|
if mkErr := os.MkdirAll(failedDir, 0o755); mkErr != nil {
|
||||||
|
return fmt.Errorf("mkdir failed dir: %w", mkErr)
|
||||||
|
}
|
||||||
|
dest := filepath.Join(failedDir, filename)
|
||||||
|
if cpErr := copyFromRoot(root, rel, dest); cpErr != nil {
|
||||||
return fmt.Errorf("copy to failed: %w", cpErr)
|
return fmt.Errorf("copy to failed: %w", cpErr)
|
||||||
}
|
}
|
||||||
if changedSince(root, rel, before) {
|
if mkErr := os.WriteFile(path+".failed", []byte(runErr.Error()), 0o644); mkErr != nil {
|
||||||
slog.Warn("watcher: note changed during ingest; leaving it in raw/ for the next poll", "file", filename)
|
slog.Error("watcher: failed to write .failed marker", "error", mkErr)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
retire(cfg, root, rel, path, ".failed", runErr.Error(), before)
|
|
||||||
|
|
||||||
slog.Warn("watcher: file failed", "file", filename, "error", runErr)
|
slog.Warn("watcher: file failed", "file", filename, "error", runErr)
|
||||||
|
|
||||||
@@ -232,75 +168,28 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Copy to processed/YYYY-MM-DD/ and leave a .processed marker so we don't re-ingest.
|
// Copy to processed/YYYY-MM-DD/ and leave a .processed marker so we don't re-ingest.
|
||||||
// Written through the brain root (hyperguild#158).
|
processedDir := filepath.Join(cfg.BrainDir, "raw", "processed", date)
|
||||||
if err := copyFromRoot(root, rel, cfg.BrainDir, "raw/processed/"+date, filename, limit); err != nil {
|
if err := os.MkdirAll(processedDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("mkdir processed dir: %w", err)
|
||||||
|
}
|
||||||
|
dest := filepath.Join(processedDir, filename)
|
||||||
|
if _, err := os.Stat(dest); err == nil {
|
||||||
|
// Archive copy already exists; append timestamp to avoid overwriting.
|
||||||
|
ext := filepath.Ext(filename)
|
||||||
|
base := strings.TrimSuffix(filename, ext)
|
||||||
|
dest = filepath.Join(processedDir, base+"-"+time.Now().UTC().Format("150405")+ext)
|
||||||
|
}
|
||||||
|
if err := copyFromRoot(root, rel, dest); err != nil {
|
||||||
return fmt.Errorf("copy to processed: %w", err)
|
return fmt.Errorf("copy to processed: %w", err)
|
||||||
}
|
}
|
||||||
if changedSince(root, rel, before) {
|
if err := os.WriteFile(path+".processed", []byte(date), 0o644); err != nil {
|
||||||
slog.Warn("watcher: note changed during ingest; leaving it in raw/ for the next poll", "file", filename)
|
slog.Error("watcher: failed to write .processed marker", "error", err)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
retire(cfg, root, rel, path, ".processed", date, before)
|
|
||||||
|
|
||||||
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 written through the brain root (#158), and if
|
|
||||||
// even that fails the note is remembered, with the size and mtime it had, so it
|
|
||||||
// is not ingested again until it changes.
|
|
||||||
func retire(cfg Config, root *os.Root, rel, path, markerSuffix, markerBody string, before os.FileInfo) {
|
|
||||||
rmErr := root.Remove(rel)
|
|
||||||
if rmErr == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("watcher: failed to remove archived original; writing marker", "file", rel, "error", rmErr)
|
|
||||||
if err := putBrainFile(cfg.BrainDir, "raw/"+filepath.ToSlash(rel)+markerSuffix, []byte(markerBody)); err != nil {
|
|
||||||
slog.Error("watcher: failed to write marker; skipping this note until it changes", "file", rel, "error", err)
|
|
||||||
rememberRefused(path, before)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// refused remembers notes whose archive copy or marker write was refused,
|
|
||||||
// by path, with the size and mtime they had, for the life of the process.
|
|
||||||
var refused sync.Map
|
|
||||||
|
|
||||||
type refusedStamp struct {
|
|
||||||
size int64
|
|
||||||
mtime time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func rememberRefused(path string, info os.FileInfo) {
|
|
||||||
refused.Store(path, refusedStamp{size: info.Size(), mtime: info.ModTime()})
|
|
||||||
}
|
|
||||||
|
|
||||||
func refusedUnchanged(path string, info os.FileInfo) bool {
|
|
||||||
v, ok := refused.Load(path)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
st := v.(refusedStamp)
|
|
||||||
if st.size == info.Size() && st.mtime.Equal(info.ModTime()) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
refused.Delete(path)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -325,231 +214,42 @@ func appendWatcherLog(brainDir, filename string, runErr error, date string) erro
|
|||||||
entry := fmt.Sprintf("## %s — watcher error\n\n- **File:** %s\n- **Error:** %s\n\n",
|
entry := fmt.Sprintf("## %s — watcher error\n\n- **File:** %s\n- **Error:** %s\n\n",
|
||||||
date, filename, runErr.Error())
|
date, filename, runErr.Error())
|
||||||
|
|
||||||
if err := brainfs.AppendFile(brainDir, "log.md", []byte(entry)); err != nil {
|
logPath := filepath.Join(brainDir, "log.md")
|
||||||
|
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = f.WriteString(entry); err != nil {
|
||||||
|
f.Close() //nolint:errcheck
|
||||||
return fmt.Errorf("write log: %w", err)
|
return fmt.Errorf("write log: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return f.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// putBrainFile creates or replaces a brain-relative file through the brain
|
// copyFromRoot archives a raw/ file read through the root, refusing anything
|
||||||
// root, never through a symlinked directory or file (hyperguild#158).
|
// that is not a regular file, so a swapped-in symlink cannot copy an outside
|
||||||
func putBrainFile(brainDir, rel string, data []byte) error {
|
// file into raw/processed/ or raw/failed/ (hyperguild#153).
|
||||||
return brainfs.PutFile(brainDir, rel, data)
|
func copyFromRoot(root *os.Root, rel, dst string) error {
|
||||||
}
|
fi, err := root.Lstat(rel)
|
||||||
|
|
||||||
// copyFromRoot archives a raw/ file read through the root into dstDir under a
|
|
||||||
// name no existing file has (so a note never replaces another's copy: the copy
|
|
||||||
// is the only one left once the original is retired), refusing anything that is
|
|
||||||
// not a regular file and anything over max bytes at the read, not just at the
|
|
||||||
// earlier Lstat (hyperguild#158, #198).
|
|
||||||
func copyFromRoot(root *os.Root, rel, brainDir, dstDir, filename string, max int64) error {
|
|
||||||
data, err := readNoteMax(root, rel, max)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return createUnique(brainDir, dstDir, filename, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// readNoteMax reads a raw/ note through the root, at most max bytes.
|
|
||||||
func readNoteMax(root *os.Root, rel string, max int64) ([]byte, error) {
|
|
||||||
fi, err := root.Lstat(rel)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !fi.Mode().IsRegular() {
|
if !fi.Mode().IsRegular() {
|
||||||
return nil, fmt.Errorf("%s: not a regular file", rel)
|
return fmt.Errorf("%s: not a regular file", rel)
|
||||||
}
|
}
|
||||||
in, _, err := extract.OpenRegular(root, rel)
|
in, _, err := extract.OpenRegular(root, rel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
defer func() { _ = in.Close() }()
|
defer func() { _ = in.Close() }()
|
||||||
data, err := readAllMax(in, max)
|
out, err := os.Create(dst)
|
||||||
if errors.Is(err, errTooLarge) {
|
|
||||||
return nil, fmt.Errorf("%s: %w (%d bytes)", rel, errTooLarge, max)
|
|
||||||
}
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// readAllMax reads r but never more than max+1 bytes, so an oversize or growing
|
|
||||||
// file costs at most that much memory, and reports errTooLarge past max.
|
|
||||||
func readAllMax(r io.Reader, max int64) ([]byte, error) {
|
|
||||||
data, err := io.ReadAll(io.LimitReader(r, max+1))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
if int64(len(data)) > max {
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
return nil, errTooLarge
|
_ = out.Close()
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return data, nil
|
return out.Close()
|
||||||
}
|
|
||||||
|
|
||||||
// topDir is the first directory of a raw/-relative path, "" for a top-level file.
|
|
||||||
func topDir(rel string) string {
|
|
||||||
if i := strings.IndexByte(filepath.ToSlash(rel), '/'); i > 0 {
|
|
||||||
return filepath.ToSlash(rel)[:i]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// frontmatterWing reads a top-level `wing:` from a note's leading frontmatter,
|
|
||||||
// "" if there is none. The block counts only if it has a real closing fence (a
|
|
||||||
// line that is exactly "---"), and an indented `wing:` belongs to some other
|
|
||||||
// mapping, not to the note.
|
|
||||||
func frontmatterWing(content string) string {
|
|
||||||
content = strings.TrimPrefix(content, "\ufeff") // a byte-order mark must not hide the block
|
|
||||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
|
||||||
if !strings.HasPrefix(content, "---\n") {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
block, _, found := cutFence(content[4:])
|
|
||||||
if !found {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(block, "\n") {
|
|
||||||
if line == "" || line[0] == ' ' || line[0] == '\t' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if key, val, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(key) == "wing" {
|
|
||||||
return strings.Trim(strings.TrimSpace(val), `"'`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// parkForReview moves a note the resolver could not place into
|
|
||||||
// raw/needs-review/, recording the proposal in its frontmatter so a human can
|
|
||||||
// approve it by moving it into raw/<wing>/. Nothing is extracted or written to
|
|
||||||
// the wiki.
|
|
||||||
//
|
|
||||||
// The parked copy is created through the brain root (real directories only,
|
|
||||||
// O_EXCL, never overwriting) and is the only copy once the original is retired,
|
|
||||||
// so the original is removed only after that write succeeds and only if it has
|
|
||||||
// not changed since it was read: a newer version delivered while the resolver
|
|
||||||
// ran stays in raw/ for the next poll.
|
|
||||||
func parkForReview(cfg Config, root *os.Root, rel, path, filename, content string, d wing.Decision, date string, before os.FileInfo) error {
|
|
||||||
var data []byte
|
|
||||||
if strings.ToLower(filepath.Ext(filename)) == ".pdf" {
|
|
||||||
// Binary: park the bytes untouched; the proposal is in log.md only.
|
|
||||||
var err error
|
|
||||||
data, err = readNoteMax(root, rel, defaultMaxNoteBytes)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("park pdf: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
data = []byte(withProposal(content, d))
|
|
||||||
}
|
|
||||||
if err := createParked(cfg.BrainDir, filename, data); err != nil {
|
|
||||||
return fmt.Errorf("park note: %w", err)
|
|
||||||
}
|
|
||||||
if err := appendWatcherLog(cfg.BrainDir, filename, fmt.Errorf("needs review (proposed wing %q): %s", d.Wing, d.Reason), date); err != nil {
|
|
||||||
slog.Error("watcher: failed to write log entry", "error", err)
|
|
||||||
}
|
|
||||||
slog.Info("watcher: parked for review", "file", filename, "proposed_wing", d.Wing, "reason", d.Reason)
|
|
||||||
if changedSince(root, rel, before) {
|
|
||||||
slog.Warn("watcher: note changed while it was being resolved; leaving the newer version in raw/", "file", filename)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
retire(cfg, root, rel, path, ".processed", date, before)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// proposalKeys are the frontmatter keys the resolver owns in a parked note.
|
|
||||||
var proposalKeys = map[string]bool{"proposed_wing": true, "proposal_confidence": true, "proposal_reason": true}
|
|
||||||
|
|
||||||
// withProposal adds the resolver's proposal to a note's frontmatter, creating
|
|
||||||
// the block when the note has none. Every value is on one line: the wing only
|
|
||||||
// if it is a plain wing name (else it appears in the reason alone), strings
|
|
||||||
// Go-quoted. Any proposal keys the note already carried are dropped first, so a
|
|
||||||
// note cannot pre-empt the resolver's (frontmatter reads are first-wins).
|
|
||||||
// Line endings are normalised to LF so a CRLF note keeps one valid block.
|
|
||||||
func withProposal(content string, d wing.Decision) string {
|
|
||||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
|
||||||
reason := d.Reason
|
|
||||||
var kv strings.Builder
|
|
||||||
if d.Wing != "" && brain.Sanitise(d.Wing) == d.Wing {
|
|
||||||
fmt.Fprintf(&kv, "proposed_wing: %s\n", d.Wing)
|
|
||||||
} else if d.Wing != "" {
|
|
||||||
reason = fmt.Sprintf("proposed wing %q is not a plain wing name; %s", d.Wing, reason)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&kv, "proposal_confidence: %.2f\nproposal_reason: %q\n", d.Confidence, reason)
|
|
||||||
|
|
||||||
if !strings.HasPrefix(content, "---\n") {
|
|
||||||
return "---\n" + kv.String() + "---\n" + content
|
|
||||||
}
|
|
||||||
block, rest, found := cutFence(content[4:])
|
|
||||||
if !found {
|
|
||||||
return "---\n" + kv.String() + "---\n" + content
|
|
||||||
}
|
|
||||||
var kept []string
|
|
||||||
for _, line := range strings.Split(block, "\n") {
|
|
||||||
if key, _, ok := strings.Cut(line, ":"); ok && proposalKeys[strings.TrimSpace(key)] && !strings.HasPrefix(line, " ") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, line)
|
|
||||||
}
|
|
||||||
return "---\n" + kv.String() + strings.Join(kept, "\n") + "\n---" + rest
|
|
||||||
}
|
|
||||||
|
|
||||||
// cutFence splits s at the first closing fence: a line that is exactly "---".
|
|
||||||
// "----" or "---x" is not a fence, matching how the frontmatter readers parse.
|
|
||||||
func cutFence(s string) (block, rest string, found bool) {
|
|
||||||
if i := strings.Index(s, "\n---\n"); i >= 0 {
|
|
||||||
return s[:i], s[i+4:], true
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(s, "\n---") {
|
|
||||||
return s[:len(s)-4], "", true
|
|
||||||
}
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// markerBlocks reports whether a marker next to path means "already handled".
|
|
||||||
// Any non-regular entry at the marker path counts (a dangling symlink must not
|
|
||||||
// be written through, hyperguild#158). A regular marker describes an EARLIER
|
|
||||||
// note of the same name, so it does not block this one, and is returned in
|
|
||||||
// stale, when it is older than the note or dated implausibly far in the future
|
|
||||||
// (clock skew, a peer): those are removed by the caller. Heuristic by nature:
|
|
||||||
// a marker at least as new as the note still means "done".
|
|
||||||
func markerBlocks(path string) (skip bool, stale []string) {
|
|
||||||
note, err := os.Lstat(path)
|
|
||||||
if err != nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
for _, suffix := range []string{".processed", ".failed"} {
|
|
||||||
m, err := os.Lstat(path + suffix)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !m.Mode().IsRegular() {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
if m.ModTime().Before(note.ModTime()) || m.ModTime().After(time.Now().Add(markerSkew)) {
|
|
||||||
stale = append(stale, path+suffix)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
return false, stale
|
|
||||||
}
|
|
||||||
|
|
||||||
// markerSkew is how far ahead of now a marker may be dated before it is not
|
|
||||||
// believed.
|
|
||||||
const markerSkew = 10 * time.Minute
|
|
||||||
|
|
||||||
// markerWarned remembers notes already reported as held by a marker, so the
|
|
||||||
// 30-second poll says it once instead of forever.
|
|
||||||
var markerWarned sync.Map
|
|
||||||
|
|
||||||
func warnMarkerOnce(path string) {
|
|
||||||
if _, seen := markerWarned.LoadOrStore(path, struct{}{}); !seen {
|
|
||||||
slog.Warn("watcher: note is held by a marker newer than itself and will not be processed; delete the marker or edit the note", "file", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func relOf(rawDir, path string) string {
|
|
||||||
rel, err := filepath.Rel(rawDir, path)
|
|
||||||
if err != nil {
|
|
||||||
return filepath.Base(path)
|
|
||||||
}
|
|
||||||
return rel
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ package watcher
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -91,12 +89,13 @@ 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/")
|
||||||
|
|
||||||
// Inbox zero: once archived, the original leaves raw/ and no marker is
|
// Original file should still exist (copy, not move — keeps Obsidian vault intact).
|
||||||
// left behind, so what remains in raw/ is exactly the unprocessed set.
|
|
||||||
_, err := os.Stat(rawFile)
|
_, err := os.Stat(rawFile)
|
||||||
assert.ErrorIs(t, err, os.ErrNotExist, "original should be removed from raw/ once archived")
|
assert.NoError(t, err, "original file should remain in raw/")
|
||||||
|
|
||||||
|
// A .processed marker should exist next to the original.
|
||||||
_, err = os.Stat(rawFile + ".processed")
|
_, err = os.Stat(rawFile + ".processed")
|
||||||
assert.ErrorIs(t, err, os.ErrNotExist, "no .processed marker: the archive copy is the record")
|
assert.NoError(t, err, ".processed marker should be written")
|
||||||
|
|
||||||
// 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")
|
||||||
@@ -143,11 +142,13 @@ 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/")
|
||||||
|
|
||||||
// Inbox zero: a quarantined original leaves raw/ too, no marker beside it.
|
// Original file should still exist (copy, not move — keeps Obsidian vault intact).
|
||||||
_, err := os.Stat(rawFile)
|
_, err := os.Stat(rawFile)
|
||||||
assert.ErrorIs(t, err, os.ErrNotExist, "original should be removed from raw/ once quarantined")
|
assert.NoError(t, err, "original file should remain in raw/")
|
||||||
|
|
||||||
|
// A .failed marker should exist next to the original.
|
||||||
_, err = os.Stat(rawFile + ".failed")
|
_, err = os.Stat(rawFile + ".failed")
|
||||||
assert.ErrorIs(t, err, os.ErrNotExist, "no .failed marker: failed/ is the record")
|
assert.NoError(t, err, ".failed marker should be written")
|
||||||
|
|
||||||
// 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"))
|
||||||
@@ -389,324 +390,3 @@ 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",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
processDir(context.Background(), cfg, "2026-10-19")
|
|
||||||
}
|
|
||||||
// The LLM count alone proves nothing: pipeline.Run's source-hash check skips
|
|
||||||
// extraction on an unchanged note, so a re-run is invisible there. What a
|
|
||||||
// re-run does leave behind is another archive copy, every poll.
|
|
||||||
archived, err := filepath.Glob(filepath.Join(rawDir, "processed", "2026-10-19", "*"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, archived, 1, "a note that cannot be retired is archived once, not once per poll")
|
|
||||||
assert.Equal(t, int32(1), calls.Load())
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the archive copy cannot be written, the original is the only copy left:
|
|
||||||
// it must stay in raw/.
|
|
||||||
func TestProcessDir_KeepsTheOriginalWhenTheArchiveCopyFails(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
rawDir := filepath.Join(brainDir, "raw")
|
|
||||||
note := filepath.Join(rawDir, "keep-me.md")
|
|
||||||
require.NoError(t, os.WriteFile(note, []byte("Content."), 0o644))
|
|
||||||
// The dated archive dir exists but is read-only: MkdirAll succeeds, the
|
|
||||||
// copy itself (os.Create) fails.
|
|
||||||
dated := filepath.Join(rawDir, "processed", "2026-10-19")
|
|
||||||
require.NoError(t, os.MkdirAll(dated, 0o755))
|
|
||||||
require.NoError(t, os.Chmod(dated, 0o555))
|
|
||||||
t.Cleanup(func() { _ = os.Chmod(dated, 0o755) })
|
|
||||||
|
|
||||||
cfg := Config{
|
|
||||||
BrainDir: brainDir,
|
|
||||||
Pipeline: pipeline.Config{
|
|
||||||
Complete: successComplete(pipeline.RawPage{Title: "Keep Me", Type: "source", Subtype: "article", Content: "x\n"}),
|
|
||||||
Schema: "# Schema",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
errs := processDir(context.Background(), cfg, "2026-10-19")
|
|
||||||
|
|
||||||
assert.NotEmpty(t, errs, "the failed archive is reported")
|
|
||||||
_, err := os.Stat(note)
|
|
||||||
assert.NoError(t, err, "original stays in raw/ when it could not be archived")
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the note is replaced (e.g. by Syncthing) while it is being ingested, the
|
|
||||||
// wiki page was extracted from the old content; removing the original would
|
|
||||||
// leave the new content only in the archive and never re-ingest it. Observes
|
|
||||||
// size/mtime change only: a same-size rewrite with a restored mtime is not caught.
|
|
||||||
func TestProcessDir_KeepsANoteThatChangedWhileItWasBeingIngested(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
note := filepath.Join(brainDir, "raw", "edited.md")
|
|
||||||
require.NoError(t, os.WriteFile(note, []byte("old"), 0o644))
|
|
||||||
|
|
||||||
cfg := Config{
|
|
||||||
BrainDir: brainDir,
|
|
||||||
Pipeline: pipeline.Config{
|
|
||||||
Complete: func(ctx context.Context, s, u string) (string, error) {
|
|
||||||
require.NoError(t, os.WriteFile(note, []byte("newer and longer content"), 0o644))
|
|
||||||
return successComplete(pipeline.RawPage{Title: "Edited", Type: "source", Subtype: "article", Content: "x\n"})(ctx, s, u)
|
|
||||||
},
|
|
||||||
Schema: "# Schema",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
processDir(context.Background(), cfg, "2026-10-19")
|
|
||||||
|
|
||||||
got, err := os.ReadFile(note)
|
|
||||||
require.NoError(t, err, "a note that changed during ingest must stay in raw/")
|
|
||||||
assert.Equal(t, "newer and longer content", string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
// hyperguild#198: an oversized note must not be read into memory. It is left in
|
|
||||||
// raw/ (visible, not lost), reported once, and not retried until it changes.
|
|
||||||
func TestProcessDir_ANoteOverTheSizeCapIsRefusedOnceAndKept(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
big := filepath.Join(brainDir, "raw", "big.md")
|
|
||||||
require.NoError(t, os.WriteFile(big, []byte(strings.Repeat("x", 2048)), 0o644))
|
|
||||||
var calls atomic.Int32
|
|
||||||
cfg := Config{
|
|
||||||
BrainDir: brainDir,
|
|
||||||
MaxNoteBytes: 1024,
|
|
||||||
Pipeline: pipeline.Config{
|
|
||||||
Complete: func(ctx context.Context, s, u string) (string, error) {
|
|
||||||
calls.Add(1)
|
|
||||||
return successComplete(pipeline.RawPage{Title: "Big", Type: "source", Content: "x\n"})(ctx, s, u)
|
|
||||||
},
|
|
||||||
Schema: "# Schema",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
first := processDir(context.Background(), cfg, "2026-10-20")
|
|
||||||
require.NotEmpty(t, first, "the refusal is reported")
|
|
||||||
assert.Contains(t, first[0].Error(), "over the 1024 byte cap")
|
|
||||||
second := processDir(context.Background(), cfg, "2026-10-20")
|
|
||||||
assert.Empty(t, second, "and reported once, not every poll")
|
|
||||||
|
|
||||||
assert.Zero(t, calls.Load(), "no extraction")
|
|
||||||
_, err := os.Stat(big)
|
|
||||||
assert.NoError(t, err, "the note stays in raw/")
|
|
||||||
entries, _ := os.ReadDir(filepath.Join(brainDir, "raw", "processed"))
|
|
||||||
assert.Empty(t, entries, "nothing was archived")
|
|
||||||
}
|
|
||||||
|
|
||||||
// hyperguild#199: a name near NAME_MAX cannot take a ".processed" suffix. Under
|
|
||||||
// inbox zero the marker is only the fallback when removal fails, and any failure
|
|
||||||
// there is remembered, so the note is still not handled every poll.
|
|
||||||
func TestProcessDir_ALongNameWhoseMarkerCannotBeWrittenIsNotHandledEveryPoll(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
rawDir := filepath.Join(brainDir, "raw")
|
|
||||||
name := strings.Repeat("a", 250) + ".md" // 253 bytes; +".processed" exceeds 255
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(rawDir, name), []byte("Content."), 0o644))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(rawDir, "processed"), 0o755))
|
|
||||||
require.NoError(t, os.Chmod(rawDir, 0o555)) // removal fails, so the marker fallback runs
|
|
||||||
t.Cleanup(func() { _ = os.Chmod(rawDir, 0o755) })
|
|
||||||
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{
|
|
||||||
Complete: successComplete(pipeline.RawPage{Title: "Long", Type: "source", Content: "x\n"}), Schema: "# Schema"}}
|
|
||||||
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
errs := processDir(context.Background(), cfg, "2026-10-20")
|
|
||||||
// Without the remember step the second poll tries to archive a second
|
|
||||||
// copy under a name that is too long and errors, every poll.
|
|
||||||
assert.Empty(t, errs, "poll %d handled the note again", i)
|
|
||||||
}
|
|
||||||
archived, err := filepath.Glob(filepath.Join(rawDir, "processed", "2026-10-20", "*"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, archived, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Three notes named n.md in three folders, one poll. The archive and failed/
|
|
||||||
// copies are the only copy once the originals are retired, so none may replace
|
|
||||||
// another.
|
|
||||||
func TestProcessDir_SameNamedNotesAreAllArchivedNoneOverwritten(t *testing.T) {
|
|
||||||
for _, outcome := range []string{"processed", "failed"} {
|
|
||||||
t.Run(outcome, func(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
for _, d := range []string{"a", "b", "c"} {
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "raw", d), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", d, "n.md"), []byte("CONTENT-"+d), 0o644))
|
|
||||||
}
|
|
||||||
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
|
|
||||||
Complete: successComplete(pipeline.RawPage{Title: "N", Type: "source", Content: "x\n"})}}
|
|
||||||
if outcome == "failed" {
|
|
||||||
cfg.Pipeline.Complete = errorComplete
|
|
||||||
}
|
|
||||||
processDir(context.Background(), cfg, "2026-10-20")
|
|
||||||
|
|
||||||
var all string
|
|
||||||
files, _ := filepath.Glob(filepath.Join(brainDir, "raw", outcome, "*", "*"))
|
|
||||||
more, _ := filepath.Glob(filepath.Join(brainDir, "raw", outcome, "*"))
|
|
||||||
for _, f := range append(files, more...) {
|
|
||||||
if b, err := os.ReadFile(f); err == nil {
|
|
||||||
all += string(b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, d := range []string{"a", "b", "c"} {
|
|
||||||
assert.Contains(t, all, "CONTENT-"+d, "the copy of note %s was overwritten", d)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A marker older than the note describes an earlier note of the same name; it
|
|
||||||
// must not hide the new one, silently, forever (the legacy .processed markers
|
|
||||||
// from before inbox zero are exactly this).
|
|
||||||
func TestProcessDir_AStaleMarkerDoesNotHideANewerNoteOfTheSameName(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
rawDir := filepath.Join(brainDir, "raw")
|
|
||||||
marker := filepath.Join(rawDir, "idea.md.processed")
|
|
||||||
require.NoError(t, os.WriteFile(marker, []byte("2026-06-16"), 0o644))
|
|
||||||
old := time.Now().Add(-48 * time.Hour)
|
|
||||||
require.NoError(t, os.Chtimes(marker, old, old))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(rawDir, "idea.md"), []byte("A new idea, same file name."), 0o644))
|
|
||||||
|
|
||||||
var calls atomic.Int32
|
|
||||||
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
|
|
||||||
Complete: func(ctx context.Context, s, u string) (string, error) {
|
|
||||||
calls.Add(1)
|
|
||||||
return successComplete(pipeline.RawPage{Title: "Idea", Type: "source", Content: "x\n"})(ctx, s, u)
|
|
||||||
}}}
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
|
|
||||||
assert.Equal(t, int32(1), calls.Load(), "the new note is processed")
|
|
||||||
_, err := os.Stat(filepath.Join(rawDir, "idea.md"))
|
|
||||||
assert.ErrorIs(t, err, os.ErrNotExist, "and retired")
|
|
||||||
_, err = os.Lstat(marker)
|
|
||||||
assert.ErrorIs(t, err, os.ErrNotExist, "the stale marker is cleaned up")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessDir_AMarkerNewerThanTheNoteStillMeansDone(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
rawDir := filepath.Join(brainDir, "raw")
|
|
||||||
note := filepath.Join(rawDir, "stuck.md")
|
|
||||||
require.NoError(t, os.WriteFile(note, []byte("Content."), 0o644))
|
|
||||||
past := time.Now().Add(-time.Hour)
|
|
||||||
require.NoError(t, os.Chtimes(note, past, past))
|
|
||||||
require.NoError(t, os.WriteFile(note+".processed", []byte("d"), 0o644)) // newer than the note
|
|
||||||
|
|
||||||
var calls atomic.Int32
|
|
||||||
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
|
|
||||||
Complete: func(context.Context, string, string) (string, error) { calls.Add(1); return "[]", nil }}}
|
|
||||||
processDir(context.Background(), cfg, "2026-10-20")
|
|
||||||
assert.Zero(t, calls.Load(), "a fallback marker written after the note still stops reprocessing")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The archive copy is a second read of the note; the cap must hold there too,
|
|
||||||
// not only at the earlier Lstat (a note can grow in between).
|
|
||||||
func TestReadNoteMax(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "n.md"), []byte(strings.Repeat("x", 100)), 0o644))
|
|
||||||
root, err := os.OpenRoot(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = root.Close() }()
|
|
||||||
|
|
||||||
got, err := readNoteMax(root, "n.md", 100)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, got, 100, "exactly at the cap is allowed")
|
|
||||||
|
|
||||||
_, err = readNoteMax(root, "n.md", 99)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.ErrorIs(t, err, errTooLarge)
|
|
||||||
}
|
|
||||||
|
|
||||||
// countingReader reports how much was pulled from it: an endless file must be
|
|
||||||
// abandoned at max+1 bytes, not slurped.
|
|
||||||
type countingReader struct{ n int64 }
|
|
||||||
|
|
||||||
func (c *countingReader) Read(p []byte) (int, error) {
|
|
||||||
if c.n > 8<<20 { // a regression must fail the test, not exhaust memory
|
|
||||||
return 0, errors.New("endless reader drained")
|
|
||||||
}
|
|
||||||
for i := range p {
|
|
||||||
p[i] = 'x'
|
|
||||||
}
|
|
||||||
c.n += int64(len(p))
|
|
||||||
return len(p), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadAllMax_StopsReadingAtTheCap(t *testing.T) {
|
|
||||||
c := &countingReader{}
|
|
||||||
_, err := readAllMax(c, 1000)
|
|
||||||
require.ErrorIs(t, err, errTooLarge)
|
|
||||||
assert.LessOrEqual(t, c.n, int64(1000+1+32*1024), "pulled far more than the cap from an endless reader: %d", c.n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// From the second review of #210: a marker dated in the future (clock skew,
|
|
||||||
// Syncthing) must not hide the note for good, and when a marker does block it
|
|
||||||
// has to say so, once.
|
|
||||||
func TestProcessDir_AFutureDatedMarkerDoesNotHideTheNote(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
rawDir := filepath.Join(brainDir, "raw")
|
|
||||||
marker := filepath.Join(rawDir, "idea.md.processed")
|
|
||||||
require.NoError(t, os.WriteFile(marker, []byte("x"), 0o644))
|
|
||||||
future := time.Now().Add(72 * time.Hour)
|
|
||||||
require.NoError(t, os.Chtimes(marker, future, future))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(rawDir, "idea.md"), []byte("A new idea."), 0o644))
|
|
||||||
|
|
||||||
var calls atomic.Int32
|
|
||||||
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
|
|
||||||
Complete: func(ctx context.Context, s, u string) (string, error) {
|
|
||||||
calls.Add(1)
|
|
||||||
return successComplete(pipeline.RawPage{Title: "Idea", Type: "source", Content: "x\n"})(ctx, s, u)
|
|
||||||
}}}
|
|
||||||
processDir(context.Background(), cfg, "2026-10-20")
|
|
||||||
assert.Equal(t, int32(1), calls.Load(), "a marker from the future is not evidence the note was handled")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessDir_BothStaleMarkersAreRemoved(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
rawDir := filepath.Join(brainDir, "raw")
|
|
||||||
old := time.Now().Add(-48 * time.Hour)
|
|
||||||
for _, suffix := range []string{".processed", ".failed"} {
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(rawDir, "idea.md"+suffix), []byte("x"), 0o644))
|
|
||||||
require.NoError(t, os.Chtimes(filepath.Join(rawDir, "idea.md"+suffix), old, old))
|
|
||||||
}
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(rawDir, "idea.md"), []byte("A new idea."), 0o644))
|
|
||||||
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
|
|
||||||
Complete: successComplete(pipeline.RawPage{Title: "Idea", Type: "source", Content: "x\n"})}}
|
|
||||||
processDir(context.Background(), cfg, "2026-10-20")
|
|
||||||
for _, suffix := range []string{".processed", ".failed"} {
|
|
||||||
_, err := os.Lstat(filepath.Join(rawDir, "idea.md"+suffix))
|
|
||||||
assert.ErrorIs(t, err, os.ErrNotExist, "orphan %s marker left behind", suffix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessDir_AMarkerThatBlocksANoteIsReportedOnce(t *testing.T) {
|
|
||||||
var logs strings.Builder
|
|
||||||
prev := slog.Default()
|
|
||||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
|
|
||||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
|
||||||
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
rawDir := filepath.Join(brainDir, "raw")
|
|
||||||
note := filepath.Join(rawDir, "held.md")
|
|
||||||
require.NoError(t, os.WriteFile(note, []byte("Content."), 0o644))
|
|
||||||
past := time.Now().Add(-time.Hour)
|
|
||||||
require.NoError(t, os.Chtimes(note, past, past))
|
|
||||||
require.NoError(t, os.WriteFile(note+".processed", []byte("d"), 0o644)) // newer: blocks
|
|
||||||
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema", Complete: errorComplete}}
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
processDir(context.Background(), cfg, "2026-10-20")
|
|
||||||
}
|
|
||||||
assert.Equal(t, 1, strings.Count(logs.String(), "held by a marker"), "said once, not every poll: %s", logs.String())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,358 +0,0 @@
|
|||||||
package watcher
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func wingTestCfg(t *testing.T, mk func(brainDir string) wing.Placer, llmCalls *atomic.Int32) (Config, string) {
|
|
||||||
t.Helper()
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
cfg := Config{
|
|
||||||
BrainDir: brainDir,
|
|
||||||
Pipeline: pipeline.Config{
|
|
||||||
Schema: "# Schema",
|
|
||||||
Complete: func(ctx context.Context, s, u string) (string, error) {
|
|
||||||
llmCalls.Add(1)
|
|
||||||
return successComplete(pipeline.RawPage{Title: "Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nx\n"})(ctx, s, u)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if mk != nil {
|
|
||||||
cfg.Resolver = mk(brainDir)
|
|
||||||
}
|
|
||||||
return cfg, brainDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// router is the production Router with a client-content marker.
|
|
||||||
func router(brainDir string) wing.Placer {
|
|
||||||
return wing.Router{BrainDir: brainDir, Default: "web-clip", ClientBlock: regexp.MustCompile(`(?i)acme-client`)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeWing(t *testing.T, brainDir, name string) {
|
|
||||||
t.Helper()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "wiki", name, "facts"), 0o755))
|
|
||||||
}
|
|
||||||
|
|
||||||
func put(t *testing.T, brainDir, rel, body string) {
|
|
||||||
t.Helper()
|
|
||||||
full := filepath.Join(brainDir, "raw", rel)
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(full, []byte(body), 0o644))
|
|
||||||
}
|
|
||||||
|
|
||||||
func has(brainDir, rel string) bool {
|
|
||||||
_, err := os.Stat(filepath.Join(brainDir, rel))
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustJSONPages(pages []pipeline.RawPage) string {
|
|
||||||
b, err := json.Marshal(pages)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parkedBody(t *testing.T, brainDir, name string) string {
|
|
||||||
t.Helper()
|
|
||||||
b, err := os.ReadFile(filepath.Join(brainDir, "raw", "needs-review", name))
|
|
||||||
require.NoError(t, err)
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func linesStartingWith(s, prefix string) int {
|
|
||||||
n := 0
|
|
||||||
for _, l := range strings.Split(s, "\n") {
|
|
||||||
if strings.HasPrefix(l, prefix) {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessDir_ASubfolderNamesTheWing(t *testing.T) {
|
|
||||||
var llmCalls atomic.Int32
|
|
||||||
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
|
||||||
makeWing(t, brainDir, "radar")
|
|
||||||
put(t, brainDir, "radar/dark-factory.md", "A talk about factories.")
|
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
|
|
||||||
assert.True(t, has(brainDir, "wiki/radar/sources/dark-factory.md"), "lands in the named wing")
|
|
||||||
assert.False(t, has(brainDir, "raw/radar/dark-factory.md"), "inbox zero holds for subfolders too")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessDir_ADeliberateSubfolderIntoAnyExistingWingPlaces(t *testing.T) {
|
|
||||||
var llmCalls atomic.Int32
|
|
||||||
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
|
||||||
makeWing(t, brainDir, "rikatillsammans")
|
|
||||||
put(t, brainDir, "rikatillsammans/meeting.md", "Notes the owner put there on purpose.")
|
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
|
|
||||||
assert.True(t, has(brainDir, "wiki/rikatillsammans/sources/meeting.md"),
|
|
||||||
"a folder is the owner's deliberate choice; extraction runs on the local model")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessDir_ANoteTheRouterCannotPlaceIsParkedNotIngested(t *testing.T) {
|
|
||||||
var llmCalls atomic.Int32
|
|
||||||
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
|
||||||
put(t, brainDir, "kickoff.md", "Some note about the ACME-CLIENT kickoff.")
|
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
|
|
||||||
assert.Zero(t, llmCalls.Load(), "no extraction runs for an unplaced note")
|
|
||||||
assert.False(t, has(brainDir, "raw/kickoff.md"), "leaves the top of raw/")
|
|
||||||
parked := parkedBody(t, brainDir, "kickoff.md")
|
|
||||||
assert.Contains(t, parked, "proposal_reason:")
|
|
||||||
assert.Contains(t, parked, "client-content marker")
|
|
||||||
assert.Contains(t, parked, "ACME-CLIENT kickoff", "the note body is preserved")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessDir_ATypoedWingFolderIsParkedAndNeverCreated(t *testing.T) {
|
|
||||||
var llmCalls atomic.Int32
|
|
||||||
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
|
||||||
makeWing(t, brainDir, "homelab")
|
|
||||||
put(t, brainDir, "hoemlab/n.md", "Note.")
|
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
|
|
||||||
assert.False(t, has(brainDir, "wiki/hoemlab"))
|
|
||||||
assert.True(t, has(brainDir, "raw/needs-review/n.md"))
|
|
||||||
assert.Zero(t, llmCalls.Load())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessDir_ParkedNotesAreNotReprocessed(t *testing.T) {
|
|
||||||
var llmCalls atomic.Int32
|
|
||||||
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
|
||||||
put(t, brainDir, "needs-review/waiting.md", "---\nproposed_wing: radar\n---\nA parked note.")
|
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
|
|
||||||
assert.Zero(t, llmCalls.Load(), "needs-review/ is a human queue, like rejected/")
|
|
||||||
assert.True(t, has(brainDir, "raw/needs-review/waiting.md"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessDir_WithoutAResolverTheRetiredLayoutIsUnchanged(t *testing.T) {
|
|
||||||
var llmCalls atomic.Int32
|
|
||||||
cfg, brainDir := wingTestCfg(t, nil, &llmCalls)
|
|
||||||
put(t, brainDir, "plain.md", "Plain note.")
|
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
|
|
||||||
assert.True(t, has(brainDir, "wiki/sources/plain.md"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// The reason comes from file and folder names and ends up in frontmatter: a
|
|
||||||
// newline in it must not be able to start a new key (PR #147: a title once
|
|
||||||
// forged `origin:`).
|
|
||||||
func TestProcessDir_AFolderNameCannotForgeFrontmatterKeys(t *testing.T) {
|
|
||||||
var llmCalls atomic.Int32
|
|
||||||
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
|
||||||
put(t, brainDir, "radar\nproposal_confidence: 0.99\nwing: homelab/forge.md", "Note.")
|
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(filepath.Join(brainDir, "raw", "needs-review"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, entries, 1)
|
|
||||||
b := parkedBody(t, brainDir, entries[0].Name())
|
|
||||||
assert.Equal(t, 1, linesStartingWith(b, "proposal_confidence:"), "exactly one confidence key at line start")
|
|
||||||
assert.Equal(t, 0, linesStartingWith(b, "wing:"), "no injected wing key")
|
|
||||||
assert.Equal(t, 0, linesStartingWith(b, "proposed_wing:"), "a non-plain wing name is never a proposal")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessDir_ANotesOwnProposalKeysDoNotOutrankTheRouters(t *testing.T) {
|
|
||||||
var llmCalls atomic.Int32
|
|
||||||
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
|
||||||
put(t, brainDir, "n.md", "---\nproposed_wing: homelab\nproposal_confidence: 1.00\nproposal_reason: \"self-approved\"\n---\nACME-CLIENT body.")
|
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
|
|
||||||
b := parkedBody(t, brainDir, "n.md")
|
|
||||||
assert.NotContains(t, b, "self-approved")
|
|
||||||
assert.Equal(t, 1, linesStartingWith(b, "proposal_reason:"))
|
|
||||||
assert.Equal(t, 0, linesStartingWith(b, "proposed_wing:"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessDir_ACRLFNoteWithAWingKeyIsStillRead(t *testing.T) {
|
|
||||||
var llmCalls atomic.Int32
|
|
||||||
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
|
||||||
makeWing(t, brainDir, "radar")
|
|
||||||
put(t, brainDir, "n.md", "---\r\nwing: radar\r\n---\r\nBody.\r\n")
|
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
|
|
||||||
assert.True(t, has(brainDir, "wiki/radar/sources/n.md"), "the declared wing is honoured")
|
|
||||||
}
|
|
||||||
|
|
||||||
// editingPlacer rewrites the note while the placement decision is being made,
|
|
||||||
// then parks it: the realistic window is a slow decision with Syncthing
|
|
||||||
// delivering a newer version meanwhile.
|
|
||||||
type editingPlacer struct{ edit func() }
|
|
||||||
|
|
||||||
func (e editingPlacer) Resolve(context.Context, wing.Note) wing.Decision {
|
|
||||||
e.edit()
|
|
||||||
return wing.Decision{Source: wing.Exception, Reason: "test: parked after an edit"}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessDir_ANoteEditedWhileItIsBeingPlacedIsNotDeleted(t *testing.T) {
|
|
||||||
var llmCalls atomic.Int32
|
|
||||||
var notePath string
|
|
||||||
cfg, brainDir := wingTestCfg(t, func(string) wing.Placer {
|
|
||||||
return editingPlacer{edit: func() {
|
|
||||||
require.NoError(t, os.WriteFile(notePath, []byte("NEWER and longer version"), 0o644))
|
|
||||||
}}
|
|
||||||
}, &llmCalls)
|
|
||||||
notePath = filepath.Join(brainDir, "raw", "n.md")
|
|
||||||
put(t, brainDir, "n.md", "ORIGINAL")
|
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
|
|
||||||
got, err := os.ReadFile(notePath)
|
|
||||||
require.NoError(t, err, "the edited note must still be in raw/")
|
|
||||||
assert.Equal(t, "NEWER and longer version", string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
// A planted symlink at raw/needs-review must not redirect a parked note out of
|
|
||||||
// the brain, nor lose it from the review queue.
|
|
||||||
func TestProcessDir_AParkedNoteIsNeverWrittenThroughASymlinkedNeedsReview(t *testing.T) {
|
|
||||||
var llmCalls atomic.Int32
|
|
||||||
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
|
||||||
outside := t.TempDir()
|
|
||||||
require.NoError(t, os.Symlink(outside, filepath.Join(brainDir, "raw", "needs-review")))
|
|
||||||
put(t, brainDir, "n.md", "ACME-CLIENT note.")
|
|
||||||
|
|
||||||
errs := processDir(context.Background(), cfg, "2026-10-20")
|
|
||||||
|
|
||||||
assert.NotEmpty(t, errs, "parking is refused, loudly")
|
|
||||||
entries, _ := os.ReadDir(outside)
|
|
||||||
assert.Empty(t, entries, "nothing was written through the symlink")
|
|
||||||
assert.True(t, has(brainDir, "raw/n.md"), "the note stays where it was")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithProposal_ANonFenceDashLineDoesNotCloseTheBlock(t *testing.T) {
|
|
||||||
out := withProposal("---\nfoo: bar\n----\nBody", wing.Decision{Wing: "radar", Confidence: 0.4, Reason: "r"})
|
|
||||||
lines := strings.Split(out, "\n")
|
|
||||||
require.Equal(t, "---", lines[0])
|
|
||||||
closing := -1
|
|
||||||
for i := 1; i < len(lines); i++ {
|
|
||||||
if lines[i] == "---" {
|
|
||||||
closing = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
require.Positive(t, closing, "the proposal block is closed by a real fence")
|
|
||||||
assert.Contains(t, strings.Join(lines[:closing], "\n"), "proposal_reason:")
|
|
||||||
assert.NotContains(t, strings.Join(lines[:closing], "\n"), "foo: bar", "the unfenced note is body, not part of the block")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The parked copy is the only copy once the original is retired, so two notes
|
|
||||||
// that share a name must both survive.
|
|
||||||
func TestProcessDir_SameNamedNotesAreAllParkedNoneOverwritten(t *testing.T) {
|
|
||||||
var llmCalls atomic.Int32
|
|
||||||
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
|
||||||
for i, body := range []string{"ONE", "TWO", "THREE", "FOUR"} {
|
|
||||||
put(t, brainDir, filepath.Join("typo"+string(rune('1'+i)), "x.md"), body)
|
|
||||||
}
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
|
|
||||||
files, err := filepath.Glob(filepath.Join(brainDir, "raw", "needs-review", "*.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
var all string
|
|
||||||
for _, f := range files {
|
|
||||||
b, _ := os.ReadFile(f)
|
|
||||||
all += string(b)
|
|
||||||
}
|
|
||||||
for _, body := range []string{"ONE", "TWO", "THREE", "FOUR"} {
|
|
||||||
assert.Contains(t, all, body, "note %s must not have been overwritten", body)
|
|
||||||
}
|
|
||||||
assert.Len(t, files, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Syncthing can bring a removed note back (an offline peer, a restored backup).
|
|
||||||
// The source-hash drift check must keep that from costing a second extraction.
|
|
||||||
func TestProcessDir_ANoteThatReappearsUnchangedIsNotExtractedAgain(t *testing.T) {
|
|
||||||
var llmCalls atomic.Int32
|
|
||||||
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
|
||||||
makeWing(t, brainDir, "radar")
|
|
||||||
|
|
||||||
put(t, brainDir, "radar/talk.md", "The same talk, byte for byte.")
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
require.Equal(t, int32(1), llmCalls.Load(), "positive control: the first drop is extracted")
|
|
||||||
require.False(t, has(brainDir, "raw/radar/talk.md"))
|
|
||||||
|
|
||||||
put(t, brainDir, "radar/talk.md", "The same talk, byte for byte.") // the peer sends it back
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-21"))
|
|
||||||
|
|
||||||
assert.Equal(t, int32(1), llmCalls.Load(), "an unchanged source is not extracted twice")
|
|
||||||
assert.False(t, has(brainDir, "raw/radar/talk.md"), "and inbox zero still holds")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessDir_ANoteThatReappearsChangedIsExtractedAgain(t *testing.T) {
|
|
||||||
var llmCalls atomic.Int32
|
|
||||||
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
|
||||||
makeWing(t, brainDir, "radar")
|
|
||||||
|
|
||||||
put(t, brainDir, "radar/talk.md", "Version one.")
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
put(t, brainDir, "radar/talk.md", "Version two, edited.")
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-21"))
|
|
||||||
|
|
||||||
assert.Equal(t, int32(2), llmCalls.Load(), "a real edit must still be picked up")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFrontmatterWing(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name, in, want string
|
|
||||||
}{
|
|
||||||
{"plain key", "---\nwing: radar\n---\nbody", "radar"},
|
|
||||||
{"quoted", "---\nwing: \"radar\"\n---\nbody", "radar"},
|
|
||||||
{"CRLF", "---\r\nwing: radar\r\n---\r\nbody", "radar"},
|
|
||||||
{"no frontmatter", "wing: radar\nbody", ""},
|
|
||||||
{"an indented key belongs to another mapping", "---\nmeta:\n wing: radar\n---\nbody", ""},
|
|
||||||
{"a key before a non-fence dash line is not frontmatter", "---\nwing: radar\n----\nbody", ""},
|
|
||||||
{"no closing fence", "---\nwing: radar\nbody", ""},
|
|
||||||
{"closing fence at end of input", "---\nwing: radar\n---", "radar"},
|
|
||||||
{"another key first", "---\ntitle: x\nwing: radar\n---\nbody", "radar"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
assert.Equal(t, tt.want, frontmatterWing(tt.in))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFrontmatterWing_ABOMDoesNotHideTheKey(t *testing.T) {
|
|
||||||
assert.Equal(t, "homelab", frontmatterWing("\ufeff---\nwing: homelab\n---\nbody"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// A note that produced nothing must not leave the inbox as "processed".
|
|
||||||
func TestProcessDir_AWingIngestThatWritesNothingFailsTheNoteVisibly(t *testing.T) {
|
|
||||||
var llmCalls atomic.Int32
|
|
||||||
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
|
||||||
cfg.Pipeline.Complete = func(context.Context, string, string) (string, error) { return "[]", nil }
|
|
||||||
put(t, brainDir, "empty-result.md", "A note the model returned nothing for.")
|
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
|
||||||
|
|
||||||
assert.True(t, has(brainDir, "raw/failed/empty-result.md"), "kept where a human looks")
|
|
||||||
processed, _ := filepath.Glob(filepath.Join(brainDir, "raw", "processed", "*", "*"))
|
|
||||||
assert.Empty(t, processed, "never archived as processed")
|
|
||||||
logb, err := os.ReadFile(filepath.Join(brainDir, "log.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(logb), "no source page")
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
package watcher
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
|
||||||
)
|
|
||||||
|
|
||||||
func okPipeline() pipeline.Config {
|
|
||||||
return pipeline.Config{Complete: func(context.Context, string, string) (string, error) {
|
|
||||||
b, _ := json.Marshal([]pipeline.RawPage{{Title: "X", Type: "source", Subtype: "article", Content: "## S\n\nx\n"}})
|
|
||||||
return string(b), nil
|
|
||||||
}, Schema: "# Schema"}
|
|
||||||
}
|
|
||||||
|
|
||||||
func failingPipeline() pipeline.Config {
|
|
||||||
return pipeline.Config{Complete: func(context.Context, string, string) (string, error) {
|
|
||||||
return "", errors.New("model down")
|
|
||||||
}, Schema: "# Schema"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hyperguild#158: the watcher wrote the archive copy under raw/processed or
|
|
||||||
// raw/failed, and its log.md entry, by path, so a symlinked archive
|
|
||||||
// directory or log redirected the write elsewhere in the brain or outside
|
|
||||||
// it. (A pre-planted marker symlink makes the watcher skip the note, so the
|
|
||||||
// marker write itself is reachable only by a race.)
|
|
||||||
func TestProcessDirDoesNotWriteThroughASymlinkedArchiveDir(t *testing.T) {
|
|
||||||
for name, pcfg := range map[string]pipeline.Config{"processed": okPipeline(), "failed": failingPipeline()} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
outside := t.TempDir()
|
|
||||||
victim := filepath.Join(brainDir, "wiki", "concepts", "v.md")
|
|
||||||
require.NoError(t, os.WriteFile(victim, []byte("untouched"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(outside, filepath.Join(brainDir, "raw", name)))
|
|
||||||
_ = processDir(context.Background(), Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pcfg}, "2026-10-05")
|
|
||||||
got, _ := os.ReadFile(victim)
|
|
||||||
assert.Equal(t, "untouched", string(got))
|
|
||||||
ents, _ := os.ReadDir(outside)
|
|
||||||
assert.Empty(t, ents, "the archive copy was written outside the brain")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessDirWritesTheArchiveInRealDirectoriesAndRetiresTheOriginal(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note"), 0o644))
|
|
||||||
errs := processDir(context.Background(), Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: okPipeline()}, "2026-10-05")
|
|
||||||
assert.Empty(t, errs)
|
|
||||||
// Inbox zero (v0.25.0): no marker is written beside a note that was removed.
|
|
||||||
_, err := os.Stat(filepath.Join(brainDir, "raw", "n.md.processed"))
|
|
||||||
assert.ErrorIs(t, err, os.ErrNotExist, "a marker is only the fallback when the removal fails")
|
|
||||||
_, err = os.Stat(filepath.Join(brainDir, "raw", "n.md"))
|
|
||||||
assert.ErrorIs(t, err, os.ErrNotExist, "positive control: the original is retired")
|
|
||||||
_, err = os.Stat(filepath.Join(brainDir, "raw", "processed", "2026-10-05", "n.md"))
|
|
||||||
assert.NoError(t, err, "positive control: archive copy written")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The log.md entry for a failed note is appended through the brain root: with
|
|
||||||
// a real raw/failed (so the archive copy succeeds and the log is reached), a
|
|
||||||
// symlinked log.md is not written through.
|
|
||||||
func TestProcessDirDoesNotAppendTheLogThroughASymlink(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
victim := filepath.Join(brainDir, "wiki", "concepts", "v.md")
|
|
||||||
require.NoError(t, os.WriteFile(victim, []byte("untouched"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("wiki", "concepts", "v.md"), filepath.Join(brainDir, "log.md")))
|
|
||||||
_ = processDir(context.Background(), Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: failingPipeline()}, "2026-10-05")
|
|
||||||
_, err := os.Stat(filepath.Join(brainDir, "raw", "failed", "n.md"))
|
|
||||||
require.NoError(t, err, "positive control: the archive copy was written, so the log append was reached")
|
|
||||||
got, _ := os.ReadFile(victim)
|
|
||||||
assert.Equal(t, "untouched", string(got), "the log was appended through a symlink")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A marker path holding anything (here a dangling symlink) counts as a
|
|
||||||
// marker: the note is skipped and nothing is created at the link's target.
|
|
||||||
// (os.Stat followed the link, saw nothing, and processed the note again.)
|
|
||||||
func TestProcessDirTreatsAnyEntryAtTheMarkerPathAsAMarker(t *testing.T) {
|
|
||||||
for _, kind := range []string{"processed", "failed"} {
|
|
||||||
t.Run(kind, func(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
outside := filepath.Join(t.TempDir(), "created")
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(outside, filepath.Join(brainDir, "raw", "n.md."+kind)))
|
|
||||||
calls := 0
|
|
||||||
cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{
|
|
||||||
Complete: func(context.Context, string, string) (string, error) { calls++; return "[]", nil }, Schema: "# Schema"}}
|
|
||||||
_ = processDir(context.Background(), cfg, "2026-10-05")
|
|
||||||
assert.Zero(t, calls, "the note was processed despite an entry at its marker path")
|
|
||||||
_, err := os.Lstat(outside)
|
|
||||||
assert.True(t, os.IsNotExist(err), "a file was created at the marker link's target")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A link planted at the marker path while pipeline.Run is in flight (in
|
|
||||||
// production, an LLM call) ends up with its target untouched, and the note is
|
|
||||||
// processed once, even after the link is removed (as symlinkguard does). Under
|
|
||||||
// inbox zero the original is removed, so the marker write that this scenario
|
|
||||||
// was written to redirect is never reached: this test does NOT pin that the
|
|
||||||
// marker-fallback write goes through the brain root (see the PR description).
|
|
||||||
func TestProcessDirLeavesAMarkerLinkPlantedDuringTheRunUntouchedAndProcessesTheNoteOnce(t *testing.T) {
|
|
||||||
for _, kind := range []string{"processed", "failed"} {
|
|
||||||
for _, target := range []string{"outside", "in-brain"} {
|
|
||||||
t.Run(kind+"/"+target, func(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
raw := filepath.Join(brainDir, "raw")
|
|
||||||
marker := filepath.Join(raw, "n.md."+kind)
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "n.md"), []byte("note"), 0o644))
|
|
||||||
// The in-brain target is relative, so os.Root itself would
|
|
||||||
// follow it; only the regular-file check refuses it.
|
|
||||||
victim := filepath.Join(raw, "victim")
|
|
||||||
link := filepath.Join(t.TempDir(), "created")
|
|
||||||
if target == "in-brain" {
|
|
||||||
require.NoError(t, os.WriteFile(victim, []byte("untouched"), 0o644))
|
|
||||||
link = "victim"
|
|
||||||
}
|
|
||||||
ok := okPipeline().Complete
|
|
||||||
calls := 0
|
|
||||||
cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{Schema: "# Schema",
|
|
||||||
Complete: func(ctx context.Context, sys, user string) (string, error) {
|
|
||||||
calls++
|
|
||||||
require.NoError(t, os.Symlink(link, marker))
|
|
||||||
if kind == "failed" {
|
|
||||||
return "", errors.New("model down")
|
|
||||||
}
|
|
||||||
return ok(ctx, sys, user)
|
|
||||||
}}}
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
_ = processDir(context.Background(), cfg, "2026-10-05")
|
|
||||||
_ = os.Remove(marker)
|
|
||||||
}
|
|
||||||
assert.Equal(t, 1, calls, "the note was retried after the link was removed")
|
|
||||||
if target == "in-brain" {
|
|
||||||
got, err := os.ReadFile(victim)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "untouched", string(got), "the marker was written through the link")
|
|
||||||
} else {
|
|
||||||
_, err := os.Lstat(link)
|
|
||||||
assert.True(t, os.IsNotExist(err), "the marker was written through the link")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A refused archive copy must not retry every interval: the note is
|
|
||||||
// remembered and skipped until it changes (a symlinked raw/failed cost one
|
|
||||||
// LLM call per round before).
|
|
||||||
func TestProcessDirDoesNotRetryANoteWhoseArchiveCopyIsRefused(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brainDir, "raw", "failed")))
|
|
||||||
calls := 0
|
|
||||||
cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{
|
|
||||||
Complete: func(context.Context, string, string) (string, error) { calls++; return "", errors.New("model down") }, Schema: "# Schema"}}
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
_ = processDir(context.Background(), cfg, "2026-10-05")
|
|
||||||
}
|
|
||||||
assert.Equal(t, 1, calls, "the refused note was retried every round")
|
|
||||||
|
|
||||||
// Once the note changes it is tried again.
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note, edited"), 0o644))
|
|
||||||
future := time.Now().Add(2 * time.Second)
|
|
||||||
require.NoError(t, os.Chtimes(filepath.Join(brainDir, "raw", "n.md"), future, future))
|
|
||||||
_ = processDir(context.Background(), cfg, "2026-10-05")
|
|
||||||
assert.Equal(t, 2, calls, "positive control: a changed note is tried again")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A note whose name has a backslash is legal on Linux and must be processed
|
|
||||||
// (cleanRel refused it, so it was never archived or marked).
|
|
||||||
func TestProcessDirProcessesANoteWithABackslashInItsName(t *testing.T) {
|
|
||||||
brainDir := setupBrainDir(t)
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "a\\b.md"), []byte("note"), 0o644))
|
|
||||||
errs := processDir(context.Background(), Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: okPipeline()}, "2026-10-05")
|
|
||||||
assert.Empty(t, errs)
|
|
||||||
_, err := os.Stat(filepath.Join(brainDir, "raw", "processed", "2026-10-05", "a\\b.md"))
|
|
||||||
assert.NoError(t, err, "archived")
|
|
||||||
_, err = os.Stat(filepath.Join(brainDir, "raw", "a\\b.md"))
|
|
||||||
assert.ErrorIs(t, err, os.ErrNotExist, "and retired")
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package wiki
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func put(t *testing.T, dir, rel string) {
|
|
||||||
t.Helper()
|
|
||||||
full := filepath.Join(dir, rel)
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(full, []byte("---\ntitle: x\n---\n"), 0o644))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindSlugs(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
put(t, dir, "wiki/sources/legacy-src.md")
|
|
||||||
put(t, dir, "wiki/concepts/a-concept.md")
|
|
||||||
put(t, dir, "wiki/entities/an-entity.md")
|
|
||||||
put(t, dir, "wiki/homelab/failures/in-a-hall.md")
|
|
||||||
put(t, dir, "wiki/web-clip/sources/a-clip.md")
|
|
||||||
// One slug in two places: the first hit must not hide the second.
|
|
||||||
put(t, dir, "wiki/sources/shared-slug.md")
|
|
||||||
put(t, dir, "wiki/entities/shared-slug.md")
|
|
||||||
put(t, dir, "wiki/aaa/facts/shared-slug.md")
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
slug string
|
|
||||||
want []string
|
|
||||||
}{
|
|
||||||
{"legacy-src", []string{"wiki/sources/legacy-src.md"}},
|
|
||||||
{"a-concept", []string{"wiki/concepts/a-concept.md"}},
|
|
||||||
{"an-entity", []string{"wiki/entities/an-entity.md"}},
|
|
||||||
{"in-a-hall", []string{"wiki/homelab/failures/in-a-hall.md"}},
|
|
||||||
{"a-clip", []string{"wiki/web-clip/sources/a-clip.md"}},
|
|
||||||
{"shared-slug", []string{"wiki/aaa/facts/shared-slug.md", "wiki/entities/shared-slug.md", "wiki/sources/shared-slug.md"}},
|
|
||||||
{"nothing-here", nil},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.slug, func(t *testing.T) {
|
|
||||||
got, err := FindSlugs(dir, tt.slug)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.want, got)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindSlugs_NoWikiDirFindsNothing(t *testing.T) {
|
|
||||||
got, err := FindSlugs(t.TempDir(), "x")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, got)
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,8 @@ package wiki
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||||
@@ -48,8 +50,8 @@ func RebuildIndex(brainDir, date string) error {
|
|||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Written through the brain root, never through a symlink (#158).
|
dest := filepath.Join(brainDir, "wiki", "index.md")
|
||||||
return bfs.PutRegular("wiki/index.md", []byte(sb.String()))
|
return os.WriteFile(dest, []byte(sb.String()), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageFirstSentence reads the page through the brain root, regular files
|
// pageFirstSentence reads the page through the brain root, regular files
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||||
@@ -97,98 +96,3 @@ func readFrontmatter(bfs *brainfs.Brain, rel, fallbackSlug string) (title string
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadWingInventory returns what an ingest into one wing can match against:
|
|
||||||
// that wing's sources (wiki/<wing>/sources/) and the SHARED concepts and
|
|
||||||
// entities (wiki/concepts/, wiki/entities/), which belong to no single wing
|
|
||||||
// because their slug is the graph's global identity. Other wings' sources are
|
|
||||||
// not read.
|
|
||||||
func LoadWingInventory(brainDir, wing string) (map[PageType][]Entry, error) {
|
|
||||||
result, err := LoadInventory(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result[PageTypeSource] = []Entry{} // the retired source layout is not this wing's
|
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("open brain: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
dir := "wiki/" + wing + "/sources"
|
|
||||||
names, err := bfs.ReadDirRegular(dir)
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read dir %s: %w", dir, err)
|
|
||||||
}
|
|
||||||
for _, name := range names {
|
|
||||||
if !strings.HasSuffix(name, ".md") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rel := dir + "/" + name
|
|
||||||
slug := strings.TrimSuffix(name, ".md")
|
|
||||||
title, aliases := readFrontmatter(bfs, rel, slug)
|
|
||||||
result[PageTypeSource] = append(result[PageTypeSource], Entry{Slug: slug, Title: title, Aliases: aliases, Type: PageTypeSource, Path: rel})
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindSlugs returns the brain-relative path of EVERY existing page whose
|
|
||||||
// basename is slug, searching the retired dirs (wiki/{sources,concepts,
|
|
||||||
// entities}/) and every wing hall (wiki/<wing>/<hall>/), sorted. The slug is a
|
|
||||||
// page's graph identity, so two pages must not share one; returning all of
|
|
||||||
// them (not the first) is what lets a caller tell "only my own page" from "my
|
|
||||||
// own page and somebody else's".
|
|
||||||
func FindSlugs(brainDir, slug string) ([]string, error) {
|
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("open brain: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
file := slug + ".md"
|
|
||||||
var found []string
|
|
||||||
check := func(dir string) error {
|
|
||||||
names, err := bfs.ReadDirRegular(dir)
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, n := range names {
|
|
||||||
if n == file {
|
|
||||||
found = append(found, dir+"/"+file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, d := range []string{"wiki/sources", "wiki/concepts", "wiki/entities"} {
|
|
||||||
if err := check(d); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wings, err := bfs.ReadDirDirs("wiki")
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return found, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, w := range wings {
|
|
||||||
if w == "sources" || w == "concepts" || w == "entities" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
halls, err := bfs.ReadDirDirs("wiki/" + w)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, h := range halls {
|
|
||||||
if err := check("wiki/" + w + "/" + h); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(found)
|
|
||||||
return found, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ package wiki
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,14 +27,14 @@ func AppendLog(brainDir, source string, pages, warnings []string, date string) e
|
|||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
// Appended through the brain root, never through a symlink (#158).
|
logPath := filepath.Join(brainDir, "log.md")
|
||||||
bfs, err := brainfs.Open(brainDir)
|
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("open brain: %w", err)
|
return fmt.Errorf("open log: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = bfs.Close() }()
|
if _, err = f.WriteString(sb.String()); err != nil {
|
||||||
if err := bfs.AppendRegular("log.md", []byte(sb.String())); err != nil {
|
f.Close() //nolint:errcheck
|
||||||
return fmt.Errorf("write log: %w", err)
|
return fmt.Errorf("write log: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return f.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,5 +22,4 @@ type Entry struct {
|
|||||||
Title string
|
Title string
|
||||||
Aliases []string
|
Aliases []string
|
||||||
Type PageType
|
Type PageType
|
||||||
Path string // brain-relative path; set for wing-layout entries
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
package wiki
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hyperguild#158: RebuildIndex wrote wiki/index.md and AppendLog appended
|
|
||||||
// log.md with os.WriteFile / os.OpenFile, so a symlinked file was written
|
|
||||||
// through.
|
|
||||||
func TestRebuildIndexAndAppendLogDoNotWriteThroughSymlinks(t *testing.T) {
|
|
||||||
b := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "concepts"), 0o755))
|
|
||||||
victim := filepath.Join(b, "raw", "v.md")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(victim, []byte("untouched"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("..", "raw", "v.md"), filepath.Join(b, "wiki", "index.md")))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join("raw", "v.md"), filepath.Join(b, "log.md")))
|
|
||||||
|
|
||||||
assert.Error(t, RebuildIndex(b, "2026-10-05"))
|
|
||||||
assert.Error(t, AppendLog(b, "src", []string{"p"}, nil, "2026-10-05"))
|
|
||||||
got, _ := os.ReadFile(victim)
|
|
||||||
assert.Equal(t, "untouched", string(got))
|
|
||||||
|
|
||||||
require.NoError(t, os.Remove(filepath.Join(b, "wiki", "index.md")))
|
|
||||||
require.NoError(t, os.Remove(filepath.Join(b, "log.md")))
|
|
||||||
require.NoError(t, RebuildIndex(b, "2026-10-05"), "positive control")
|
|
||||||
require.NoError(t, AppendLog(b, "src", []string{"p"}, nil, "2026-10-05"), "positive control")
|
|
||||||
l, _ := os.ReadFile(filepath.Join(b, "log.md"))
|
|
||||||
assert.Contains(t, string(l), "src")
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package wing_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func writePage(t *testing.T, dir, rel, body string) {
|
|
||||||
t.Helper()
|
|
||||||
full := filepath.Join(dir, rel)
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(full, []byte(body), 0o644))
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package wing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// legacyDirs are the retired wiki/{concepts,entities,sources}/ layout, not wings.
|
|
||||||
var legacyDirs = map[string]bool{"concepts": true, "entities": true, "sources": true}
|
|
||||||
|
|
||||||
// KnownWings lists wiki/<wing>/ directories that contain at least one hall
|
|
||||||
// subdirectory, sorted. The retired layout is not a wing. A missing wiki/ is
|
|
||||||
// an error: an unreadable brain must not look like "no wings".
|
|
||||||
func KnownWings(brainDir string) ([]string, error) {
|
|
||||||
entries, err := os.ReadDir(filepath.Join(brainDir, "wiki"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("list wings: %w", err)
|
|
||||||
}
|
|
||||||
var out []string
|
|
||||||
for _, e := range entries {
|
|
||||||
if !e.IsDir() || legacyDirs[e.Name()] || !plainWingName(e.Name()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
subs, err := os.ReadDir(filepath.Join(brainDir, "wiki", e.Name()))
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, s := range subs {
|
|
||||||
if s.IsDir() {
|
|
||||||
out = append(out, e.Name())
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// plainWingName reports whether name is already in the canonical lower-case form
|
|
||||||
// the pipeline writes. Anything else would make the pipeline create a different
|
|
||||||
// wing than the one matched.
|
|
||||||
func plainWingName(name string) bool { return brain.Sanitise(name) == name }
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package wing_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestKnownWings_ListsWingsWithHallsAndSkipsTheRetiredLayout(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
writePage(t, dir, "wiki/homelab/facts/a.md", "x")
|
|
||||||
writePage(t, dir, "wiki/tapir/decisions/b.md", "x")
|
|
||||||
writePage(t, dir, "wiki/concepts/c.md", "x") // retired layout, a file directly under a legacy dir
|
|
||||||
writePage(t, dir, "wiki/sources/d.md", "x") // retired layout
|
|
||||||
writePage(t, dir, "wiki/entities/e.md", "x") // retired layout
|
|
||||||
writePage(t, dir, "wiki/loose-note.md", "x") // a file, not a wing
|
|
||||||
writePage(t, dir, "wiki/emptywing/_index.md", "x") // a dir with no hall subdirectory
|
|
||||||
got, err := wing.KnownWings(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{"homelab", "tapir"}, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKnownWings_NoWikiDirIsAnError(t *testing.T) {
|
|
||||||
_, err := wing.KnownWings(t.TempDir())
|
|
||||||
assert.Error(t, err, "a brain without wiki/ must not look like a brain with no wings")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Known matches case-insensitively and the pipeline writes the lower-case name,
|
|
||||||
// so a wiki directory that is not already plain lower-case would make the
|
|
||||||
// pipeline create a different, new wing. It is not a candidate.
|
|
||||||
func TestKnownWings_SkipsADirectoryThatIsNotAPlainWingName(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
writePage(t, dir, "wiki/Radar/facts/a.md", "x")
|
|
||||||
writePage(t, dir, "wiki/has space/facts/a.md", "x")
|
|
||||||
writePage(t, dir, "wiki/ok/facts/a.md", "x")
|
|
||||||
got, err := wing.KnownWings(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{"ok"}, got)
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
// Package wing decides which brain wing an incoming raw note belongs to
|
|
||||||
// without the human typing it (hyperguild#205, #208).
|
|
||||||
//
|
|
||||||
// An earlier design classified each note with a local model and cross-checked
|
|
||||||
// it with a text score. Measured against pages whose wing a human had chosen,
|
|
||||||
// the model was right 60% of the time with a saturated self-reported
|
|
||||||
// confidence, and the cross-check was right 11% of the time, so the gate it
|
|
||||||
// formed placed 2 of 55 pages (hyperguild#208). The Router that replaced it
|
|
||||||
// decides with rules only: a deliberate folder or frontmatter wing, else one
|
|
||||||
// default wing; nothing it cannot decide safely is guessed.
|
|
||||||
package wing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Source says how a Decision was reached.
|
|
||||||
type Source string
|
|
||||||
|
|
||||||
const (
|
|
||||||
Explicit Source = "explicit" // subfolder or frontmatter named the wing
|
|
||||||
Default Source = "default" // no signal; the configured default wing
|
|
||||||
Exception Source = "exception" // needs a human
|
|
||||||
)
|
|
||||||
|
|
||||||
// Note is what the Placer sees of an incoming raw note.
|
|
||||||
type Note struct {
|
|
||||||
Dir string // first subfolder under raw/, "" when dropped at the top
|
|
||||||
FrontmatterWing string // `wing:` from the note's frontmatter, "" if absent
|
|
||||||
Text string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decision is the outcome. For an Exception, Wing holds the proposal when
|
|
||||||
// there was one (so a reviewer can approve it) and is empty otherwise.
|
|
||||||
type Decision struct {
|
|
||||||
Wing string
|
|
||||||
Source Source
|
|
||||||
Confidence float64
|
|
||||||
Reason string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Placer decides where a raw note goes. Router is the production
|
|
||||||
// implementation; the interface lets the watcher stay ignorant of how.
|
|
||||||
type Placer interface {
|
|
||||||
Resolve(ctx context.Context, n Note) Decision
|
|
||||||
}
|
|
||||||
|
|
||||||
func exception(w string, conf float64, reason string) Decision {
|
|
||||||
return Decision{Wing: w, Source: Exception, Confidence: conf, Reason: reason}
|
|
||||||
}
|
|
||||||
|
|
||||||
func norm(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
package wing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
xnorm "golang.org/x/text/unicode/norm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Router places a note with rules only, no model (hyperguild#208):
|
|
||||||
//
|
|
||||||
// 1. A subfolder or frontmatter `wing:` is the owner's deliberate choice. It
|
|
||||||
// must name a plain, EXISTING wing (a typo never creates one) and then it
|
|
||||||
// places, whatever the wing is: extraction runs on the local model, and it
|
|
||||||
// is the owner who dropped the note there. Two signals that disagree park.
|
|
||||||
// 2. With no signal, a note whose text matches ClientBlock is parked; any
|
|
||||||
// other note goes to Default, the one wing that is created on first use.
|
|
||||||
//
|
|
||||||
// Wings are read from disk on every decision, so a wing created while the
|
|
||||||
// server runs is usable at once.
|
|
||||||
type Router struct {
|
|
||||||
BrainDir string
|
|
||||||
Default string
|
|
||||||
ClientBlock *regexp.Regexp // nil = no content check
|
|
||||||
|
|
||||||
// FilterChunk is how many bytes of a note the content filter folds and
|
|
||||||
// matches at a time (0 = 1 MiB). Folding allocates several times its input,
|
|
||||||
// so a 64 MiB note is checked in pieces (with an overlap, so a marker on a
|
|
||||||
// boundary is still found) instead of all at once.
|
|
||||||
FilterChunk int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve implements Placer. It never returns an error: anything it cannot
|
|
||||||
// decide safely is an Exception with the reason spelled out.
|
|
||||||
func (r Router) Resolve(_ context.Context, n Note) Decision {
|
|
||||||
for _, raw := range []string{n.Dir, n.FrontmatterWing} {
|
|
||||||
if !isASCII(raw) {
|
|
||||||
// strings.ToLower folds some non-ASCII runes to ASCII (the Kelvin
|
|
||||||
// sign becomes "k"), which would let a lookalike name a real wing.
|
|
||||||
return exception("", 0, fmt.Sprintf("wing name %q contains non-ASCII characters", strings.TrimSpace(raw)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dir, fm := norm(n.Dir), norm(n.FrontmatterWing)
|
|
||||||
switch {
|
|
||||||
case dir != "" && fm != "" && dir != fm:
|
|
||||||
return exception("", 0, fmt.Sprintf("subfolder %q and frontmatter wing %q disagree", dir, fm))
|
|
||||||
case dir != "":
|
|
||||||
return r.explicit(dir, "subfolder")
|
|
||||||
case fm != "":
|
|
||||||
return r.explicit(fm, "frontmatter")
|
|
||||||
}
|
|
||||||
if r.ClientBlock != nil && matchesClient(r.ClientBlock, n.Text, r.FilterChunk) {
|
|
||||||
return exception("", 0, "text matches the client-content marker; not defaulted into a shared wing")
|
|
||||||
}
|
|
||||||
if r.Default == "" || !usableWingName(r.Default) {
|
|
||||||
return exception("", 0, "no valid default wing configured")
|
|
||||||
}
|
|
||||||
return Decision{Wing: r.Default, Source: Default, Confidence: 1, Reason: "no wing named; default wing"}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r Router) explicit(w, via string) Decision {
|
|
||||||
if !usableWingName(w) {
|
|
||||||
return exception("", 0, fmt.Sprintf("%s names %q, which is not a usable wing name", via, w))
|
|
||||||
}
|
|
||||||
if w == r.Default {
|
|
||||||
return Decision{Wing: w, Source: Explicit, Confidence: 1, Reason: "named by " + via}
|
|
||||||
}
|
|
||||||
known, err := KnownWings(r.BrainDir)
|
|
||||||
if err != nil {
|
|
||||||
return exception(w, 0, fmt.Sprintf("cannot list wings: %v", err))
|
|
||||||
}
|
|
||||||
if !slices.Contains(known, w) {
|
|
||||||
return exception(w, 0, fmt.Sprintf("%s names %q: not an existing wing (new wings are never created from a folder name)", via, strings.TrimSpace(w)))
|
|
||||||
}
|
|
||||||
return Decision{Wing: w, Source: Explicit, Confidence: 1, Reason: "named by " + via}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRouter builds the production Router. defaultWing must be a plain wing
|
|
||||||
// name; clientPattern, when set, is a case-insensitive regexp over note text
|
|
||||||
// whose match parks an unsignalled note instead of filing it. Both fail at
|
|
||||||
// startup rather than later: a client filter that silently does nothing is
|
|
||||||
// worse than none.
|
|
||||||
func NewRouter(brainDir, defaultWing, clientPattern string) (Router, error) {
|
|
||||||
if defaultWing == "" || !usableWingName(defaultWing) {
|
|
||||||
return Router{}, fmt.Errorf("default wing %q is not a usable wing name", defaultWing)
|
|
||||||
}
|
|
||||||
r := Router{BrainDir: brainDir, Default: defaultWing}
|
|
||||||
if clientPattern != "" {
|
|
||||||
re, err := regexp.Compile("(?i)" + clientPattern)
|
|
||||||
if err != nil {
|
|
||||||
return Router{}, fmt.Errorf("client-content pattern: %w", err)
|
|
||||||
}
|
|
||||||
r.ClientBlock = re
|
|
||||||
}
|
|
||||||
return r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// reservedWingNames are directory names under wiki/ that are not wings: the
|
|
||||||
// retired layout and the root. A note must never be proposed into one.
|
|
||||||
var reservedWingNames = map[string]bool{"sources": true, "concepts": true, "entities": true, "wiki": true}
|
|
||||||
|
|
||||||
// usableWingName: a plain name that is not reserved.
|
|
||||||
func usableWingName(w string) bool { return plainWingName(w) && !reservedWingNames[w] }
|
|
||||||
|
|
||||||
func isASCII(s string) bool {
|
|
||||||
for _, r := range s {
|
|
||||||
if r > unicode.MaxASCII {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
defaultFilterChunk = 1 << 20
|
|
||||||
filterOverlap = 4096
|
|
||||||
)
|
|
||||||
|
|
||||||
// matchesClient applies the client-content pattern to the text in bounded
|
|
||||||
// chunks, each overlapping the next, so memory stays proportional to the chunk
|
|
||||||
// and a marker on a chunk edge is still found.
|
|
||||||
func matchesClient(re *regexp.Regexp, text string, chunk int) bool {
|
|
||||||
if chunk <= 0 {
|
|
||||||
chunk = defaultFilterChunk
|
|
||||||
}
|
|
||||||
overlap := filterOverlap
|
|
||||||
if overlap >= chunk {
|
|
||||||
overlap = chunk / 2
|
|
||||||
}
|
|
||||||
for start := 0; start < len(text); {
|
|
||||||
end := start + chunk
|
|
||||||
if end >= len(text) {
|
|
||||||
end = len(text)
|
|
||||||
}
|
|
||||||
if matchesFolded(re, text[start:end]) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if end == len(text) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// A cut inside a rune is harmless: the overlap carries every match that
|
|
||||||
// spans at most filterOverlap bytes whole into the next chunk. A match
|
|
||||||
// longer than that (a pattern like `acme.{5000}corp`, or padding
|
|
||||||
// between two words) can be missed on a chunk edge; documented limit.
|
|
||||||
start = end - overlap
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// matchesFolded matches the pattern against the chunk as it is and against
|
|
||||||
// folded forms, so the cheap evasions do not defeat it. Folding: NFKD (fullwidth
|
|
||||||
// letters, accents become base letter + mark), then marks, format characters and
|
|
||||||
// invisible fillers dropped, Cyrillic/Greek lookalikes mapped to Latin, every
|
|
||||||
// dash made "-". Views: whitespace collapsed to one space (so "acme corp" and
|
|
||||||
// \s work), whitespace removed (so "acme-\nclient" works), and the same with a
|
|
||||||
// soft hyphen read as a dash. It catches what it names and nothing else:
|
|
||||||
// leetspeak, punctuation splits (a.c.m.e), encodings and the many homoglyphs
|
|
||||||
// not in the small table below are not caught. It is a filter, not a classifier.
|
|
||||||
func matchesFolded(re *regexp.Regexp, chunk string) bool {
|
|
||||||
if re.MatchString(chunk) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
decomposed := xnorm.NFKD.String(chunk)
|
|
||||||
return re.MatchString(fold(decomposed, false, true)) ||
|
|
||||||
re.MatchString(fold(decomposed, false, false)) ||
|
|
||||||
re.MatchString(fold(decomposed, true, false))
|
|
||||||
}
|
|
||||||
|
|
||||||
// fold builds one view. softAsDash reads U+00AD as "-" instead of dropping it;
|
|
||||||
// keepSpace collapses whitespace to a single space instead of removing it.
|
|
||||||
func fold(s string, softAsDash, keepSpace bool) string {
|
|
||||||
var b strings.Builder
|
|
||||||
b.Grow(len(s))
|
|
||||||
space := false
|
|
||||||
for _, r := range s {
|
|
||||||
switch {
|
|
||||||
case r == '\u00ad' && softAsDash:
|
|
||||||
b.WriteRune('-')
|
|
||||||
space = false
|
|
||||||
continue
|
|
||||||
case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) || unicode.Is(unicode.Mc, r) ||
|
|
||||||
unicode.Is(unicode.Cf, r) || invisibleFiller[r]:
|
|
||||||
continue
|
|
||||||
case unicode.IsSpace(r) || unicode.Is(unicode.Zs, r):
|
|
||||||
if keepSpace && !space {
|
|
||||||
b.WriteByte(' ')
|
|
||||||
space = true
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
space = false
|
|
||||||
switch {
|
|
||||||
case unicode.Is(unicode.Pd, r) || r == '\u2212':
|
|
||||||
b.WriteRune('-')
|
|
||||||
default:
|
|
||||||
if l, ok := latinLookalike[r]; ok {
|
|
||||||
r = l
|
|
||||||
}
|
|
||||||
b.WriteRune(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// invisibleFiller are characters that render as nothing but are not format
|
|
||||||
// characters: Hangul fillers and the blank Braille pattern.
|
|
||||||
var invisibleFiller = map[rune]bool{0x115F: true, 0x1160: true, 0x3164: true, 0xFFA0: true, 0x2800: true}
|
|
||||||
|
|
||||||
// latinLookalike maps the commonest Cyrillic and Greek letters that read as
|
|
||||||
// Latin ones. Deliberately small; see matchesFolded.
|
|
||||||
var latinLookalike = map[rune]rune{
|
|
||||||
'\u0430': 'a', '\u0435': 'e', '\u043e': 'o', '\u0440': 'p', '\u0441': 'c', '\u0445': 'x', '\u0443': 'y',
|
|
||||||
'\u0456': 'i', '\u0458': 'j', '\u0455': 's', '\u04bb': 'h', '\u0501': 'd', '\u043a': 'k', '\u043d': 'h', '\u0442': 't',
|
|
||||||
'\u0410': 'A', '\u0412': 'B', '\u0415': 'E', '\u041a': 'K', '\u041c': 'M', '\u041d': 'H', '\u041e': 'O', '\u0420': 'P', '\u0421': 'C', '\u0422': 'T', '\u0425': 'X',
|
|
||||||
'\u03bf': 'o', '\u03bd': 'v', '\u03c1': 'p', '\u0391': 'A', '\u0392': 'B', '\u0395': 'E', '\u039f': 'O', '\u03a1': 'P',
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
package wing_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func routerBrain(t *testing.T) string {
|
|
||||||
dir := t.TempDir()
|
|
||||||
writePage(t, dir, "wiki/homelab/facts/a.md", "x")
|
|
||||||
writePage(t, dir, "wiki/rikatillsammans/facts/b.md", "x")
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouter_Decisions(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
note wing.Note
|
|
||||||
want wing.Source
|
|
||||||
wantWing string
|
|
||||||
}{
|
|
||||||
{"no signal goes to the default wing", wing.Note{Text: "a talk about agents"}, wing.Default, "web-clip"},
|
|
||||||
{"a subfolder naming an existing wing places there", wing.Note{Dir: "homelab", Text: "x"}, wing.Explicit, "homelab"},
|
|
||||||
{"a deliberate subfolder into a client wing is the owner's call and is placed", wing.Note{Dir: "rikatillsammans", Text: "x"}, wing.Explicit, "rikatillsammans"},
|
|
||||||
{"frontmatter wing places like a subfolder", wing.Note{FrontmatterWing: "homelab", Text: "x"}, wing.Explicit, "homelab"},
|
|
||||||
{"subfolder is case-insensitive", wing.Note{Dir: "HomeLab", Text: "x"}, wing.Explicit, "homelab"},
|
|
||||||
{"a typo never creates a wing", wing.Note{Dir: "hoemlab", Text: "x"}, wing.Exception, "hoemlab"},
|
|
||||||
{"conflicting signals are parked", wing.Note{Dir: "homelab", FrontmatterWing: "rikatillsammans", Text: "x"}, wing.Exception, ""},
|
|
||||||
{"the default wing may be named explicitly even before it exists", wing.Note{Dir: "web-clip", Text: "x"}, wing.Explicit, "web-clip"},
|
|
||||||
{"client marker with no signal is parked, not defaulted", wing.Note{Text: "notes from the ACME-CLIENT kickoff"}, wing.Exception, ""},
|
|
||||||
{"client marker does not block a deliberate subfolder", wing.Note{Dir: "homelab", Text: "ACME-CLIENT"}, wing.Explicit, "homelab"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
r := wing.Router{BrainDir: routerBrain(t), Default: "web-clip", ClientBlock: regexp.MustCompile(`(?i)acme-client`)}
|
|
||||||
d := r.Resolve(context.Background(), tt.note)
|
|
||||||
assert.Equal(t, tt.want, d.Source)
|
|
||||||
assert.Equal(t, tt.wantWing, d.Wing)
|
|
||||||
assert.NotEmpty(t, d.Reason)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouter_ReadsTheWingsFreshOnEveryDecision(t *testing.T) {
|
|
||||||
dir := routerBrain(t)
|
|
||||||
r := wing.Router{BrainDir: dir, Default: "web-clip"}
|
|
||||||
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Dir: "newwing", Text: "x"}).Source)
|
|
||||||
|
|
||||||
writePage(t, dir, "wiki/newwing/facts/c.md", "x") // a wing created after startup
|
|
||||||
assert.Equal(t, wing.Explicit, r.Resolve(context.Background(), wing.Note{Dir: "newwing", Text: "x"}).Source,
|
|
||||||
"a wing made while the server runs is usable without a restart")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouter_AnUnreadableBrainParksInsteadOfGuessing(t *testing.T) {
|
|
||||||
r := wing.Router{BrainDir: t.TempDir() + "/missing", Default: "web-clip"}
|
|
||||||
d := r.Resolve(context.Background(), wing.Note{Dir: "homelab", Text: "x"})
|
|
||||||
assert.Equal(t, wing.Exception, d.Source)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouter_WithoutADefaultAnUnsignalledNoteIsParked(t *testing.T) {
|
|
||||||
r := wing.Router{BrainDir: routerBrain(t)}
|
|
||||||
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: "x"}).Source)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouter_ADefaultThatIsNotAPlainNameIsRefused(t *testing.T) {
|
|
||||||
r := wing.Router{BrainDir: routerBrain(t), Default: "../escape"}
|
|
||||||
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: "x"}).Source)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouter_ANameThatIsNotPlainIsRefusedAsSuch(t *testing.T) {
|
|
||||||
for _, bad := range []string{"../x", "a/b", "has space", "dot.ted"} {
|
|
||||||
t.Run(bad, func(t *testing.T) {
|
|
||||||
// Default equals the bad name: the "named explicitly" shortcut for the
|
|
||||||
// default wing must not wave a malformed name through.
|
|
||||||
r := wing.Router{BrainDir: routerBrain(t), Default: bad}
|
|
||||||
d := r.Resolve(context.Background(), wing.Note{Dir: bad, Text: "x"})
|
|
||||||
assert.Equal(t, wing.Exception, d.Source)
|
|
||||||
assert.Empty(t, d.Wing, "a malformed name is never echoed as a proposal")
|
|
||||||
assert.Contains(t, d.Reason, "not a usable wing name")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewRouter(t *testing.T) {
|
|
||||||
dir := routerBrain(t)
|
|
||||||
t.Run("valid", func(t *testing.T) {
|
|
||||||
r, err := wing.NewRouter(dir, "web-clip", "acme-client|globex")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
d := r.Resolve(context.Background(), wing.Note{Text: "kickoff with GLOBEX"})
|
|
||||||
assert.Equal(t, wing.Exception, d.Source, "the marker is matched case-insensitively")
|
|
||||||
})
|
|
||||||
t.Run("no marker pattern means no content check", func(t *testing.T) {
|
|
||||||
r, err := wing.NewRouter(dir, "web-clip", "")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: "anything"}).Source)
|
|
||||||
})
|
|
||||||
t.Run("a default that is not a plain wing name is a startup error", func(t *testing.T) {
|
|
||||||
_, err := wing.NewRouter(dir, "Web Clip", "")
|
|
||||||
assert.Error(t, err)
|
|
||||||
})
|
|
||||||
t.Run("an empty default is a startup error", func(t *testing.T) {
|
|
||||||
_, err := wing.NewRouter(dir, "", "")
|
|
||||||
assert.Error(t, err)
|
|
||||||
})
|
|
||||||
t.Run("a pattern that does not compile is a startup error", func(t *testing.T) {
|
|
||||||
_, err := wing.NewRouter(dir, "web-clip", "([unclosed")
|
|
||||||
assert.Error(t, err, "a client filter that silently does nothing is worse than none")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// From the review of #211: the content filter is the only guard on the default
|
|
||||||
// lane, so it must survive the cheap evasions.
|
|
||||||
func TestRouter_ClientMarkerSurvivesCheapEvasions(t *testing.T) {
|
|
||||||
r := wing.Router{BrainDir: routerBrain(t), Default: "web-clip", ClientBlock: regexp.MustCompile(`(?i)acme-client`)}
|
|
||||||
tests := []struct{ name, text string }{
|
|
||||||
{"non-breaking hyphen", "notes on acme‑client"},
|
|
||||||
{"zero-width joiner inside", "notes on acme\u200d-client"},
|
|
||||||
{"zero-width space inside", "notes on ac\u200bme-client"},
|
|
||||||
{"split across lines", "notes on acme-\nclient"},
|
|
||||||
{"spaced hyphen", "notes on acme - client"},
|
|
||||||
{"fullwidth letters", "notes on acme-client"},
|
|
||||||
{"soft hyphen", "notes on acme\u00ad-client"},
|
|
||||||
{"en dash", "notes on acme–client"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
d := r.Resolve(context.Background(), wing.Note{Text: tt.text})
|
|
||||||
assert.Equal(t, wing.Exception, d.Source, tt.text)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
t.Run("an unrelated text still files", func(t *testing.T) {
|
|
||||||
d := r.Resolve(context.Background(), wing.Note{Text: "a talk about agents and a client-server model"})
|
|
||||||
assert.Equal(t, wing.Default, d.Source)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouter_FolderNamesAreAsciiAndNotReserved(t *testing.T) {
|
|
||||||
dir := routerBrain(t)
|
|
||||||
writePage(t, dir, "wiki/kubernetes/facts/a.md", "x")
|
|
||||||
r := wing.Router{BrainDir: dir, Default: "web-clip"}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
dir string
|
|
||||||
}{
|
|
||||||
{"Kelvin sign lowercases to an ASCII k", "Kubernetes"},
|
|
||||||
{"cyrillic lookalike", "h\u043emelab"},
|
|
||||||
{"retired layout name: sources", "sources"},
|
|
||||||
{"retired layout name: concepts", "concepts"},
|
|
||||||
{"retired layout name: entities", "entities"},
|
|
||||||
{"wiki", "wiki"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
d := r.Resolve(context.Background(), wing.Note{Dir: tt.dir, Text: "x"})
|
|
||||||
assert.Equal(t, wing.Exception, d.Source)
|
|
||||||
assert.Empty(t, d.Wing, "never proposed as a wing")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
t.Run("plain ASCII case folding still works", func(t *testing.T) {
|
|
||||||
d := r.Resolve(context.Background(), wing.Note{Dir: "KubernetEs", Text: "x"})
|
|
||||||
assert.Equal(t, wing.Explicit, d.Source)
|
|
||||||
assert.Equal(t, "kubernetes", d.Wing)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewRouter_AReservedDefaultIsRefused(t *testing.T) {
|
|
||||||
_, err := wing.NewRouter(routerBrain(t), "sources", "")
|
|
||||||
assert.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// From the second review of #211: the multi-word, \s and \b patterns an operator
|
|
||||||
// would actually write, and the next tier of evasions.
|
|
||||||
func TestRouter_ClientMarkerHandlesRealisticPatternsAndHarderEvasions(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name, pattern, text string
|
|
||||||
}{
|
|
||||||
{"space in pattern vs NBSP", `acme corp`, "about acme\u00a0corp today"},
|
|
||||||
{"space in pattern vs two spaces", `acme corp`, "about acme corp today"},
|
|
||||||
{"space in pattern vs newline", `acme corp`, "about acme\ncorp today"},
|
|
||||||
{"backslash-s pattern vs NBSP", `acme\s+corp`, "about acme\u00a0corp today"},
|
|
||||||
{"word boundary vs a zero-width char inside", `\bacme\b`, "the ac\u200bme corp"},
|
|
||||||
{"dash pattern vs a soft hyphen as the dash", `acme-client`, "acme\u00adclient"},
|
|
||||||
{"cyrillic a", `acme-client`, "\u0430cme-client"},
|
|
||||||
{"cyrillic e", `acme-client`, "acm\u0435-client"},
|
|
||||||
{"cyrillic o and c", `coop`, "c\u043e\u043ep"},
|
|
||||||
{"combining acute", `acme-client`, "a\u0301cme-client"},
|
|
||||||
{"precomposed accent", `acme-client`, "\u00e1cme-client"},
|
|
||||||
{"hangul filler", `acme-client`, "ac\u3164me-client"},
|
|
||||||
{"halfwidth hangul filler", `acme-client`, "ac\uffa0me-client"},
|
|
||||||
{"braille blank", `acme-client`, "ac\u2800me-client"},
|
|
||||||
{"grapheme joiner", `acme-client`, "ac\u034fme-client"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
r, err := wing.NewRouter(routerBrain(t), "web-clip", tt.pattern)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: tt.text}).Source, "%q should be caught by %q", tt.text, tt.pattern)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
t.Run("ordinary text is not caught by a multi-word pattern", func(t *testing.T) {
|
|
||||||
r, _ := wing.NewRouter(routerBrain(t), "web-clip", `acme corp`)
|
|
||||||
assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: "acme is not corp, and corp is not acme"}).Source)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// The folding allocates several times the text, so a large note is checked in
|
|
||||||
// bounded chunks. A marker that straddles a chunk edge must still be found.
|
|
||||||
func TestRouter_ClientMarkerIsFoundAcrossAChunkBoundary(t *testing.T) {
|
|
||||||
r, err := wing.NewRouter(routerBrain(t), "web-clip", `acme-client`)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
r.FilterChunk = 64
|
|
||||||
for _, at := range []int{0, 30, 55, 58, 62, 64, 70, 120, 190} {
|
|
||||||
text := strings.Repeat("x", at) + "acme-client" + strings.Repeat("y", 200)
|
|
||||||
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: text}).Source, "marker at offset %d was missed", at)
|
|
||||||
}
|
|
||||||
clean := strings.Repeat("lorem ipsum ", 100)
|
|
||||||
assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: clean}).Source)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouter_ClientMarkerChunkingDoesNotSplitAMultiByteRune(t *testing.T) {
|
|
||||||
r, err := wing.NewRouter(routerBrain(t), "web-clip", `acme-client`)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
r.FilterChunk = 65 // odd, over two-byte runes: every boundary falls inside a rune unless it is moved
|
|
||||||
for pad := 0; pad < 8; pad++ {
|
|
||||||
text := strings.Repeat("\u00e9", 40+pad) + "acme-client" + strings.Repeat("\u00e9", 40)
|
|
||||||
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: text}).Source, "pad %d", pad)
|
|
||||||
}
|
|
||||||
clean := strings.Repeat("\u00e9", 300)
|
|
||||||
assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: clean}).Source, "no panic or false match on a text of 2-byte runes")
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user