Compare commits

...
Author SHA1 Message Date
mathiasandClaude Sonnet 5.5 b66b45b8fd fix(legacymove): second run never reuses an archive; Apply re-checks the planned bytes; rollback checks first and is repeatable (review of #213)
CI / Lint / Test / Vet (pull_request) Successful in 1m3s
CI / Mirror to GitHub (pull_request) Skipped
From the independent review:
- the archive dir is unique per run (-2, -3 ...): a second Apply the same day
  replaced the first run's archived original and rewrote its manifest
- every Move/Delete carries the SHA-256 it was planned on; Apply skips and
  reports (Result.Changed) a page whose bytes changed since planning, so a note
  dropped again after PlanRaw is not deleted unprocessed
- Rollback verifies all targets before changing anything, recognises pages
  already restored (so it can be run again), and refuses to delete a moved copy
  that was edited since the move
- names whose flattened archive key exceeds NAME_MAX are skipped at planning
  time instead of wedging the run
- a page that already has wing/hall keys, or whose type sits in a nested/block
  scalar line or an unterminated block, is not moved
Each has a test that fails without it (mutation-checked). Residual: a swap
between Apply's read and its remove; the archived copy is still the bytes read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 21:42:44 +02:00
mathias f2abee9b21 Merge remote-tracking branch 'origin/main' into wt/legacy-move
CI / Lint / Test / Vet (pull_request) Successful in 1m5s
CI / Mirror to GitHub (pull_request) Skipped
2026-10-20 21:37:23 +02:00
mathias 4c2276b46b Merge pull request 'feat(ingestion): clips land in web-clip by rule; shared concepts/entities; approve parked notes; remove the classifier (#208, #209)' (#211) from wt/web-clip into main
CI / Mirror to GitHub (push) Successful in 4s
CI / Lint / Test / Vet (push) Successful in 1m6s
2026-10-20 19:36:51 +00:00
mathiasandClaude Sonnet 5.5 2b16f2cc2f feat(legacymove): also clear raw/ leftovers from before inbox zero (#209)
PlanRaw: an original that was archived but never removed (it has a .processed
marker AND a byte-identical copy under raw/processed/) is deleted with its
marker; a marker without a note is deleted; an unmarked note, a note without an
identical archive copy, and AutoTunnel's tunnel-candidates-* review queue are
never touched. The archive key flattens the whole path so raw/x.md and
wiki/sources/x.md cannot overwrite each other. Live dry run: 84 entries, 16
move, 53 delete (24 junk + 29 raw leftovers), 15 left.
Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 21:02:41 +02:00
mathias e14577d814 Merge remote-tracking branch 'origin/wt/web-clip' into wt/legacy-move 2026-10-20 21:01:00 +02:00
mathiasandClaude Sonnet 5.5 90752f2ac9 fix(pipeline,api): write the source page last; say how to clear an unreadable note (review of #211, pass 4)
CI / Lint / Test / Vet (pull_request) Successful in 1m3s
CI / Mirror to GitHub (pull_request) Skipped
The source page carries source_hash, which the drift check trusts. Written first,
a batch that failed midway left a hash behind, so re-ingesting the same content
was skipped and the rest of the batch was lost for good. In a wing it is now
written last. An over-cap note in brain_pending says promote/reject cannot act
on it and to delete or move it by hand. Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 21:00:18 +02:00
mathiasandClaude Sonnet 5.5 27b1579b80 fix(pipeline,api,wing): never archive a note as processed when nothing was ingested; titles do not merge sources; ties prefer the page on disk (review of #211, pass 3)
CI / Lint / Test / Vet (pull_request) Successful in 1m6s
CI / Mirror to GitHub (pull_request) Skipped
- in a wing, an ingest that writes no source page is an error: the note goes to
  raw/failed with the warnings in log.md instead of leaving the inbox as
  'processed' (model returned [], only invalid types, only clashing entities)
- the wing's source path is deterministic, so Resolve no longer redirects a
  source onto another note's page because the model gave both the same title
  ('Home'); that overwrote the hash and the text
- a batch tie between concept and entity prefers the page already on disk, so
  its update is not lost; the wing's own source page keeps its slug even when a
  foreign page shares it
- brain_pending lists an unreadable (over-cap) note with the reason instead of
  dropping it; the client filter's 4 KiB match-span limit is documented
Each has a test that fails without it (mutation-checked).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:56:03 +02:00
mathiasandClaude Sonnet 5.5 49286ba8b8 fix(api,watcher,server): reject moves non-markdown untouched; read caps; BOM; strict routing flag (review of #211, pass 2)
CI / Lint / Test / Vet (pull_request) Successful in 1m2s
CI / Mirror to GitHub (pull_request) Skipped
- brain_reject on a parked pdf/txt moves the bytes; it no longer prepends YAML
- raw/ notes are read through a cap (readCapped); an endless reader test fails
  instead of exhausting memory
- a byte-order mark no longer hides a note's wing: key
- INGEST_WING_ROUTING is parsed strictly; a typo stops the server rather than
  leaving routing silently off
- tests for the reserved-wing check in every disguise (Sources, sources., ...)
Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:49:10 +02:00
mathiasandClaude Sonnet 5.5 6f5fb481ad fix(wing): client filter handles multi-word/\s/\b patterns, homoglyphs, marks, fillers; checked in bounded chunks (review of #211, pass 2)
Views of each chunk: as is; NFKD + marks/format/fillers dropped + Cyrillic/Greek
lookalikes mapped + dashes normalised, with whitespace collapsed (acme corp,
\s) or removed (acme-\nclient); and a soft hyphen read as a dash. Chunks of 1 MiB
with a 4 KiB overlap bound the folding's allocation (it was ~14x the input: 919
MiB for a 64 MiB note). Documented limits: leetspeak, punctuation splits,
encodings and homoglyphs outside a small table. Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:45:00 +02:00
mathiasandClaude Sonnet 5.5 74d6a08c7b fix(pipeline): slug clashes inside one batch; the source page is never the one skipped (review of #211, pass 2)
dropSlugClashes only compared against what was on disk, so a clip named like an
entity the model extracted wrote both pages and neither could be updated again.
In-batch winners: source over entity over concept. Against the brain, only
entities/concepts are skipped; the source's slug was already chosen free or its
own by resolveSourceSlug, so an existing bad pair no longer locks the source out.
Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:43:39 +02:00
mathiasandClaude Sonnet 5.5 87c40fe364 fix(api,watcher): name the queue, list parked non-markdown, reserved wings, top-level-only frontmatter key; document the inbox (review of #211)
CI / Lint / Test / Vet (pull_request) Successful in 1m2s
CI / Mirror to GitHub (pull_request) Skipped
- brain_promote/brain_reject take an optional queue (inbox | needs-review); a
  name present in both is refused instead of silently acting on the top-level
  note; brain_pending labels every entry with its queue
- parked pdf/txt files are listed (they were invisible); only markdown can be
  promoted, the others are moved or rejected
- promote refuses sources/concepts/entities/wiki as a wing
- frontmatterWing reads only a top-level key inside a properly fenced block
- docs/raw-inbox.md: the contract, the three env vars, the known limits
Each behaviour has a test that fails without it (mutation-checked).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:34:52 +02:00
mathiasandClaude Sonnet 5.5 6b6d430383 fix(wing): the client filter survives cheap evasions; lookalike and reserved folder names are refused (review of #211)
- client pattern is matched against the text and against an NFKC-folded,
  format-character-free, dash-normalised, whitespace-free form
  (non-breaking hyphen, zero-width characters, soft hyphen, line splits,
  fullwidth letters). x/text/unicode/norm was already in the module graph as an
  indirect dependency; this makes it direct, no new download.
- folder/frontmatter wing names must be ASCII (strings.ToLower folds the Kelvin
  sign to 'k') and must not be sources/concepts/entities/wiki; such names are
  never proposed as a wing, and a reserved default is a startup error
Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:29:09 +02:00
mathiasandClaude Sonnet 5.5 ae02d4764c fix(pipeline): same-named sources in two wings, entity/source slug clashes, model-chosen types (review of #211)
- a source whose slug is taken by anything but this wing's own page gets a
  wing-qualified, stable slug (talk-homelab) instead of failing the ingest
- an extracted entity/concept whose slug belongs to a different page is skipped
  with a warning, so one page can no longer lock a source out of re-ingest
- in a wing only source/concept/entity are valid page types; the model (and so
  the clip) can no longer choose wiki/<type>/ directories
Mutation-checked; stable-slug choice pinned directly because title resolution
masks it at the Run level.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:27:28 +02:00
mathiasandClaude Sonnet 5.5 e5f8947f3b feat(legacymove): clear the retired wiki/sources layout with an archive and a rollback (#209)
Videos and articles move to the default wing (wing/hall stamped, body and
links byte-preserved); auto-tunnel review logs are deleted; notes, projects and
untyped pages stay. Plan is read-only; Apply archives every page it touches to
archive/wing-migration-<date>/ (outside raw/) before changing anything,
creates destinations O_EXCL and removes sources only after; Rollback restores
byte for byte. concepts/ and entities/ are the shared layer and are untouched.
Dry run against the live brain: 16 move, 24 delete, 15 left.
Mutation-checked: junk guard, type filter, slug collision, no-overwrite,
archive-before-change, action gate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:24:03 +02:00
mathiasandClaude Sonnet 5.5 8e488b6e4b fix(pipeline): a slug used in two places is refused even when one is the wing's own (#208)
CI / Lint / Test / Vet (pull_request) Successful in 1m8s
CI / Mirror to GitHub (pull_request) Skipped
FindSlug returned the first hit, so a wing's own page of a name masked another
page of the same name (the retired dirs are searched before the wings, and wings
alphabetically). FindSlugs returns every match and callers refuse any that is
not their own. Found by the legacy-move planner's own test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:22:20 +02:00
mathias 3ccf6b46ac Merge remote-tracking branch 'origin/main' into wt/web-clip
CI / Mirror to GitHub (pull_request) Skipped
CI / Lint / Test / Vet (pull_request) Successful in 1m13s
2026-10-20 20:19:45 +02:00
mathias 44207f8ccf Merge pull request 'fix(watcher): writes through the brain root (lands #195 on current main); size cap; long-name case (#158, #198, #199)' (#210) from wt/watcher-write-safety into main
CI / Lint / Test / Vet (push) Successful in 1m1s
CI / Mirror to GitHub (push) Successful in 4s
2026-10-20 18:19:17 +00:00
mathias 603d2f9dfb Merge branch 'wt/watcher-write-safety' into wt/web-clip 2026-10-20 20:18:39 +02:00
mathiasandClaude Sonnet 5.5 8988c291ca feat(server): INGEST_WING_ROUTING wires the Router; remove the classifier path (#208)
Removes the LLM classifier, the substring 'BM25' scorer, the agreement gate,
the backtest and the old migrate/wingmigrate tooling: measured at 2 of 55
pages placed (#208), and the migration was built for the per-wing facts layout
this branch replaces. wing now holds the decision types, the Router and
KnownWings. INGEST_WING_ROUTING=1 builds the Router (default wing
INGEST_DEFAULT_WING=web-clip, client pattern from INGEST_CLIENT_BLOCK or
CLAUDE_INGEST_CLIENT_BLOCK); a bad default or pattern is fatal at startup.
Watcher tests rewritten for the Router, including a deliberate subfolder into
any existing wing, typo'd folders parked, forged-key and edit-while-placing
cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:18:39 +02:00
mathiasandClaude Sonnet 5.5 6717221989 fix(watcher): a held note is reported once; future-dated and both stale markers handled (review of #210, pass 2)
CI / Lint / Test / Vet (pull_request) Successful in 1m4s
CI / Mirror to GitHub (pull_request) Skipped
- a marker that blocks a note is warned about once, not silently forever
- a marker dated more than 10 minutes ahead (clock skew, a peer) is not
  believed and is treated as stale
- every stale marker is removed, not only the last; removal errors are logged
Each has a test that fails without it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:17:10 +02:00
mathiasandClaude Sonnet 5.5 409b85096b feat(api): brain_promote and brain_reject act on notes parked in raw/needs-review/ (#208)
brain_pending lists a parked note under its bare filename, but promote/reject
only read raw/<name>, so the documented approval path did not exist. They now
fall back to raw/needs-review/ (top level wins on a name clash; a symlinked
directory is refused) and promote drops the resolver's proposal keys.
Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:14:47 +02:00
mathiasandClaude Sonnet 5.5 1025d77795 fix(watcher): unique archive copies, read caps at the read, stale markers (review of #210)
CI / Lint / Test / Vet (pull_request) Successful in 1m5s
CI / Mirror to GitHub (pull_request) Skipped
From the independent review of #210:
- F1: processed/ and failed/ copies are created O_EXCL with -2,-3 suffixes via
  createUnique; same-named notes from different folders no longer replace one
  another's only surviving copy
- F2: extract.TextInMax / readAllMax cap the bytes actually read (a note can
  grow after the Lstat); extract.TextIn gets a 64 MiB default cap
- F3: a regular marker older than its note describes an earlier note of the
  same name; it no longer hides the new one (warn + remove); non-regular
  markers still count as done (no writes through links)
- F4: the planted-marker-link test is renamed to what it observes
Each has a test that fails without it; the read-cap test uses an endless reader
that errors at 8 MiB so a regression fails instead of exhausting memory.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:13:18 +02:00
mathiasandClaude Sonnet 5.5 750dc93bdb feat(watcher): Placer interface; acceptance tests for the sprint goal (#209)
A clip dropped in raw/ with no other action lands in web-clip, its entity and
concept are the shared pages, raw/ is empty and nothing waits for a human;
client-marked text and a typo'd wing name park; a deliberate subfolder places.
Real pipeline and file layout, model stubbed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:09:19 +02:00
mathiasandClaude Sonnet 5.5 ce5a5268e7 feat(pipeline): per-wing sources, shared concepts/entities, one slug one page (#208)
With Config.Wing a source goes to wiki/<wing>/sources/ (wing/hall frontmatter);
concepts and entities stay in the shared wiki/concepts and wiki/entities, so an
entity cited from two wings is ONE page with both sources (the per-wing facts/
layout duplicated it). A source slug already used anywhere but its own wing's
sources/ is refused (slug is the graph identity). wiki.FindSlug added.
Mutation-checked: collision guard, shared inventory, per-wing source path.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:08:30 +02:00
mathiasandClaude Sonnet 5.5 8342de5c49 feat(wing): Router — rules-only placement: deliberate subfolder, else default wing (#208)
No model. A subfolder/frontmatter wing must be a plain name of an existing wing
and then places; conflicting signals and client-marker text park; everything
else goes to the default wing. Wings are read from disk per decision.
Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:05:29 +02:00
mathiasandClaude Sonnet 5.5 d8c7defc78 fix(watcher): refuse oversize notes before reading them; pin the long-name marker case (#198, #199)
CI / Lint / Test / Vet (pull_request) Successful in 1m7s
CI / Mirror to GitHub (pull_request) Skipped
MaxNoteBytes (default 64 MiB) is checked on the Lstat size before extraction;
an oversize note stays in raw/, is reported once and not retried until it
changes. The #199 case (marker name over NAME_MAX) is covered by the
remember-on-any-failure retire path and now has a test that fails without it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:04:02 +02:00
mathiasandClaude Sonnet 5.5 2cc4f1b30d merge: land #195 (watcher/pipeline writes through the brain root, #158 part B) on top of inbox zero and the wing resolver
Reconciled with v0.25.0 semantics:
- markers are only the fallback when the original cannot be removed, and are
  now written through the brain root; the refused set (size+mtime keyed) replaces
  the path-only 'stuck' set
- #195's tests updated for inbox zero (archive written, original retired, no
  marker), and the planted-marker-link test keeps its point: nothing is ever
  written through the link, and the note is processed once
- the 'cannot retire' test now counts archive copies; it counted LLM calls,
  which pipeline.Run's source-hash check hides (mutation: removing the
  remember step left it green)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:02:15 +02:00
mathias 858ade6504 Merge pull request 'feat(ingestion): place raw notes in a wing without human typing (#205 S1–S4, S6)' (#207) from wt/wing-layout into main
CI / Lint / Test / Vet (push) Successful in 1m15s
CI / Mirror to GitHub (push) Successful in 5s
2026-10-20 06:21:44 +00:00
mathiasandClaude Sonnet 5.5 bd33a0a4e8 fix(watcher): park through the brain root, keep a note edited mid-resolve, honour only real fences (#205)
CI / Lint / Test / Vet (pull_request) Successful in 1m7s
CI / Mirror to GitHub (pull_request) Skipped
From the second independent review of #207:
- parkForReview now skips the delete when the note changed since it was read
  (an LLM call sits in that window; Syncthing can deliver a newer version)
- parked copies are created via brainfs (real dirs only, O_EXCL, no symlink
  following), so a planted raw/needs-review symlink cannot redirect or hide a
  note; this also removes the need for the guard allowlist entry added in
  1e14c56, which is reverted
- a '----' line no longer closes the proposal block
Each has a test that failed first and a mutant that fails it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:21:25 +02:00
mathiasandClaude Sonnet 5.5 1e14c56a49 test(extract): allow reserveParkedName in the raw-reader guard (creates in needs-review/, reads nothing) (#205)
CI / Lint / Test / Vet (pull_request) Successful in 1m1s
CI / Mirror to GitHub (pull_request) Skipped
The previous commit was pushed with this guard red: the gate was run with ';'
instead of '&&'. Allowlisting is the guard's own sanctioned mechanism and is
called out for review.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:17:30 +02:00
mathiasandClaude Sonnet 5.5 006a891423 fix(watcher): never overwrite a parked note; a proposal cannot be forged or pre-empted; CRLF notes; wing dirs must be plain (#205)
CI / Lint / Test / Vet (pull_request) Failing after 1m2s
CI / Mirror to GitHub (pull_request) Skipped
From the independent review of #207:
- parked notes are created O_EXCL with -2,-3 suffixes (same-named notes from
  different subfolders were overwritten and their originals deleted)
- proposed_wing is emitted only if a plain wing name; a note's own proposal_*
  keys are dropped so they cannot outrank the resolver's (first-wins reads)
- CRLF notes: wing: key honoured, one valid frontmatter block on parking
- KnownWings skips wiki dirs that are not canonical lower-case names
- adds the wingmigrate dry-run/apply command
Each fix has a test that failed first and a mutant that fails it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:16:48 +02:00
mathiasandClaude Sonnet 5.5 dd4a825f0a feat(migrate): plan and apply the move out of the retired wiki layout (#205 S5)
Sources are placed by the wing Resolver; concepts/entities inherit the strict
majority wing of the sources that cite them. Collisions, junk (auto-tunnel
logs) and anything unplaceable are Exceptions left in place. Apply writes the
destination O_EXCL then removes the source, only for Auto items.
Mutation-checked (junk guard, majority, slug identity, status gate,
no-overwrite, destination-exists).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:14:52 +02:00
mathiasandClaude Sonnet 5.5 b4b36c9bfd test(watcher): a re-synced unchanged note is not extracted twice; a changed one is (#205 S6)
CI / Lint / Test / Vet (pull_request) Successful in 1m4s
CI / Mirror to GitHub (pull_request) Skipped
Mutation-checked against the source-hash drift check.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:11:26 +02:00
mathiasandClaude Sonnet 5.5 96c63906c2 test(watcher): make the frontmatter-forgery test reach the code it names (#205 S4)
The first version fed the text through a path that never carried it into the
file, so it passed under the mutant. A classifier error does carry it; the
test now fails with %q replaced by %s.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:10:54 +02:00
mathiasandClaude Sonnet 5.5 b9657973ff feat(api): brain_pending lists notes parked in raw/needs-review/ with the wing proposal (#205 S4)
Approval is moving the note into raw/<wing>/ (the watcher ingests it there);
a dedicated approve tool is tracked separately. Proposal reason is written
Go-quoted so LLM text cannot start a frontmatter key; pinned by a
mutation-checked test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:10:39 +02:00
mathiasandClaude Sonnet 5.5 20d874a23d feat(server): INGEST_WING_RESOLVE=1 wires the wing resolver into the watcher (#205 S3)
Off by default (retired layout unchanged). On: eligibility from
classification.yaml, local ingest LLM as classifier, BM25 as cross-check,
INGEST_WING_THRESHOLD (default 0.9, rejected outside [0,1]). Build failure
is fatal when explicitly enabled.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:09:17 +02:00
mathiasandClaude Sonnet 5.5 2c6220782d feat(watcher): resolve each note's wing; park the unplaceable in raw/needs-review/ (#205 S3)
With Config.Resolver set, an explicit subfolder/frontmatter wing or an agreeing
classifier+scorer routes the note into that wing; anything else is parked with
its proposal in frontmatter and never extracted. needs-review/ is skipped by
the walk like rejected/. Nil Resolver keeps the retired layout.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:08:15 +02:00
mathiasandClaude Sonnet 5.5 ad793f320d feat(pipeline): write into wiki/<wing>/{sources,facts}/ when Config.Wing is set (#205 S2)
Sources -> wiki/<wing>/sources, concepts/entities -> wiki/<wing>/facts with
kind: frontmatter; inventory, title resolution and back-references are
confined to the target wing. Empty Wing keeps the retired layout unchanged.
Mutation-checked: widening the inventory beyond the wing fails the
cross-wing back-reference test; a non-plain wing name is rejected.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:07:14 +02:00
mathiasandClaude Sonnet 5.5 62de8ac2e6 feat(wing): score a wing by its best page, not summed hits; add wingbacktest command (#205 S1)
A first backtest showed BM25 summed over a wing's top hits favours big wings
(0/4 agreement). Best-page scoring is volume-robust; pinned by a test that
failed before the change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 06:04:45 +02:00
mathiasandClaude Sonnet 5.5 365130459f feat(wing): backtest metric (coverage/precision at a threshold) and shared TopWing (#205 S1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 05:58:55 +02:00
mathiasandClaude Sonnet 5.5 c06509afe4 feat(wing): BM25 scorer as the independent cross-check (#205 S1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 05:58:14 +02:00
mathiasandClaude Sonnet 5.5 85d8ecb5c7 feat(wing): LLM classifier with tolerant, fail-closed reply parsing (#205 S1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 05:57:51 +02:00
mathiasandClaude Sonnet 5.5 5bbf47064d feat(wing): resolver that places a raw note in a wing without human typing (#205 S1)
Explicit subfolder/frontmatter wins; otherwise a classifier and an
independent scorer must agree above a threshold on a known, eligible wing.
Anything else is an Exception for a human. Never creates a wing, never
assigns an ineligible (client/untagged) wing. Mutation-checked: dropping the
eligibility, known-wing, scorer, threshold or conflict check fails a test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 05:56:55 +02:00
mathias 2212ceedda Merge pull request 'feat(watcher): inbox zero — archived raw/ originals are removed, not copied' (#206) from wt/inbox-zero into main
CI / Lint / Test / Vet (push) Successful in 1m2s
CI / Mirror to GitHub (push) Successful in 4s
2026-10-19 19:22:58 +00:00
mathiasandClaude Sonnet 5.5 e6e14102ee docs(watcher): state what changedSince can and cannot detect
CI / Lint / Test / Vet (pull_request) Successful in 1m4s
CI / Mirror to GitHub (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 21:09:13 +02:00
mathiasandClaude Sonnet 5.5 97ec9e7ce8 fix(watcher): keep a note that changed while it was being ingested
CI / Lint / Test / Vet (pull_request) Successful in 1m3s
CI / Mirror to GitHub (pull_request) Skipped
The archive holds the content read at the start; if Syncthing replaced the
note during the (minutes-long) LLM pass, removing the original would drop the
newer content. Compare size+mtime before removing; leave it for the next poll.

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
2026-10-08 08:34:50 +02:00
mathiasandClaude Opus 5.5 22d0653b61 test(watcher): a marker link planted during the run is refused and not retried (#158)
CI / Lint / Test / Vet (pull_request) Successful in 1m3s
CI / Mirror to GitHub (pull_request) Skipped
Review round 2 on PR #195: the marker writes had no test, so reverting
them to by-path writes, or the refusal to log-only, stayed green. The
link is planted from inside the fake model call, between the walk's
Lstat and the marker write, for both markers and both an outside and
an in-brain relative target; red on all four flips. The marker-entry
skip is now checked for .failed too.

Also pins cleanRel's acceptance of a backslash as a filename byte in
brainfs itself, and stops the buildAndWrite control claiming writes it
does not count.

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

Fixes #185.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:42:45 +02:00
mathiasandClaude Opus 5.5 1a2a93d2f4 fix(watcher): refusals end in a terminal state; pin every converted writer (#158)
CI / Lint / Test / Vet (pull_request) Successful in 1m6s
CI / Mirror to GitHub (pull_request) Skipped
Review round 1 on PR #195:
- A refused marker or archive write left no marker, so the note was
  reprocessed every interval (archive copies piling up; an LLM call per
  round on the failure path). The marker check now uses Lstat (any entry
  at the marker path counts, so a dangling marker link skips the note
  instead of looping), and a note whose archive copy or marker write is
  refused is remembered (path, size, mtime) and not retried until it
  changes.
- cleanRel no longer refuses a backslash: it is a legal filename byte on
  Linux, not a traversal, and a raw note named with one was never
  archived or marked.
- Tests now reach every converted writer: the log.md append (real
  raw/failed so the log is reached, symlinked log.md), a dangling marker
  link, the refused-retry loop with a positive control, a backslash name,
  a buildAndWrite swap race, and the claude-session archive with in-brain
  and outside symlinked archive dirs. The stale lint allowlist entry for
  appendWatcherLog is removed.

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

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

Fixes #193.

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

Fixes #163.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:28:38 +02:00
mathiasandClaude Opus 5.5 77c44f298c fix: pipeline, backfill, watcher and archive writes go through the brain root (#158 part B)
CI / Lint / Test / Vet (pull_request) Successful in 1m13s
CI / Mirror to GitHub (pull_request) Skipped
The remaining writers created or rewrote brain files by path:
- pipeline buildAndWrite: os.MkdirAll + os.WriteFile of every page.
- BackfillRefs: os.WriteFile of each concept page; a page swapped for a
  symlink after the read was written through (the race test overwrote an
  in-brain target before this change).
- watcher: the raw/processed and raw/failed archive copies (os.Create),
  the .processed/.failed markers and the log.md entry (os.OpenFile).
- cmd/server: the claude-session archive.

All now go through brainfs PutRegular/AppendRegular (new package-level
PutFile/AppendFile for one-off writers: the watcher's call-site lint bans
.Open calls in that file so raw/ reads stay on OpenRegular).

Stacked on #189 (needs PutRegular/AppendRegular).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:26:02 +02:00
mathiasandClaude Opus 5.5 f4636c7ef0 fix: creating and generated writes go through the brain root (#188, #158 part A) (#189)
CI / Lint / Test / Vet (push) Failing after 1m2s
CI / Mirror to GitHub (push) Skipped
New brainfs PutRegular (create or replace), CreateRegular (create only), AppendRegular, Remove and RealParent: missing parents created one component at a time through the root, every existing component a real directory, new files created with O_EXCL and confirmed before writing. brain_write, promote/reject (O_EXCL create, source kept on refusal), StampProvenanceFile, BuildWingIndex, RebuildIndex, AppendLog and the tunnel candidate log use them; provenance.ApplyFile removed.

Fixes #188. Refs #158. Adversarial review: BLOCKED, BLOCKED, PASS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 21:25:54 +00:00
mathiasandClaude Opus 5.5 bd989f887f fix(linkcheck): keep the write when the link index cannot be built (#178) (#191)
CI / Lint / Test / Vet (push) Successful in 1m4s
CI / Mirror to GitHub (push) Successful in 4s
StripUnresolvedLinks logs a warning and returns the content unchanged when the link index cannot be built (e.g. an unreadable subdirectory), instead of failing every brain_write/brain_update and losing the learning. Nothing is unwrapped on a partial index.

Fixes #178.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 21:16:04 +00:00
mathiasandClaude Opus 5.5 4770f97ffd fix(symlinkguard): report and remove FIFOs, sockets and devices too (#179) (#190)
CI / Lint / Test / Vet (push) Successful in 1m5s
CI / Mirror to GitHub (push) Successful in 4s
Scan reports special files (not regular, directory or symlink) in Result.Special, so the startup check refuses with a message naming them; Watch removes them through an os.Root after re-checking the type, and reports any it cannot remove (never as removed).

Fixes #179. Adversarial review: BLOCKED, BLOCKED, PASS. Flake on main tracked in #193.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 21:15:57 +00:00
mathiasandClaude Opus 5.5 ddd4bdc2ac fix(brainfs): refuse a file behind a symlinked directory for reads and writes (#173) (#187)
CI / Lint / Test / Vet (push) Successful in 1m2s
CI / Mirror to GitHub (push) Successful in 3s
ReadRegular, ReadRegularMax and WriteRegular require a clean, local path and a real directory at every level above the file, so an in-brain directory symlink (or a ".." after one) can no longer redirect brain_update, brain_tunnel, brain_read/get or the other readers elsewhere in the brain. session.Append opens through an os.Root on the brain dir, refuses a non-directory sessions dir, and writes only after confirming the log path is the regular file it opened.

Fixes #173. Adversarial review: BLOCKED, PASS. Follow-up: #188 (brain_write create path).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 20:17:37 +00:00
mathiasandClaude Opus 5.5 1fff54769d fix(tunnel): DetectTunnels walks and reads through brainfs (#174, #157 slice 5b) (#186)
CI / Lint / Test / Vet (push) Successful in 1m2s
CI / Mirror to GitHub (push) Successful in 4s
DetectTunnels (inside every brain_write/brain_update via AutoTunnel) walks with brainfs.WalkRegular and reads titles with ReadRegular: a FIFO under wiki/ no longer hangs the write, and a symlinked note's title no longer becomes a tunnel candidate. A wiki/ that is not a real directory yields no candidates; other walk errors still abort.

Fixes #174. Refs #157 (last reader slice). Adversarial review: BLOCKED, PASS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 20:12:44 +00:00
mathias 0c3e82ca0a test(brainfs): per-caller in-brain symlink targets for slices 1-3 (#171) (#172)
CI / Lint / Test / Vet (push) Successful in 1m2s
CI / Mirror to GitHub (push) Successful in 4s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 19:36:31 +00:00
mathiasandClaude Opus 5.5 eedb110be5 fix(pipeline,classification): read the brain through brainfs (#157, slice 6) (#176)
CI / Lint / Test / Vet (push) Successful in 1m1s
CI / Mirror to GitHub (push) Successful in 4s
Ingest pipeline readers (readSourcePage and loadSchema feed the LLM prompt; reviseOrCreate; injectSourceRefs; BackfillRefs list and reads) and classification.Load read through brainfs: symlinked or special files no longer reach the prompt, written pages or the I1 sovereignty config, and a FIFO no longer hangs ingest or startup. A symlinked/special classification.yaml is a hard error; an absent one still yields defaults.

Refs #157. Adversarial review: BLOCKED, BLOCKED, PASS. Follow-ups: #158 (write paths), #179 (FIFO tripwire).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 18:54:42 +00:00
mathiasandClaude Opus 5.5 febab3e45d fix: brain_tunnel, brain_update and session_log confine caller-supplied paths (#169) (#170)
CI / Lint / Test / Vet (push) Successful in 1m2s
CI / Mirror to GitHub (push) Successful in 4s
brain_tunnel accepts only clean wiki/.../*.md paths and reads/rewrites both notes through brainfs; new brainfs.WriteRegular never creates, never writes through a final symlink (Lstat + O_NOFOLLOW|O_NONBLOCK + os.SameFile on the handle, since os.Root follows in-root final symlinks even with O_NOFOLLOW). brain_update resolves like brain_edit (knowledge/ and wiki/ notes only) and reads/rewrites through brainfs. session_log requires a plain-name session id and opens with O_NOFOLLOW|O_NONBLOCK.

Fixes #169. Adversarial review: BLOCKED, BLOCKED, PASS (round 3 by the estate session's reviewer). Follow-ups: #173, #174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 18:47:07 +00:00
mathias bdc1e51a1a fix(ingestion): build /mcp's bearer and the metrics wrap inside buildMux (#154) (#183)
CI / Lint / Test / Vet (push) Successful in 1m1s
CI / Mirror to GitHub (push) Successful in 4s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 17:48:44 +00:00
mathias 8c48e2e343 fix(symlinkguard): deliver alerts beside the scan, not inside it (#164) (#180)
CI / Lint / Test / Vet (push) Successful in 54s
CI / Mirror to GitHub (push) Successful in 4s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 14:48:16 +00:00
mathias c0be5c473c fix(ingestion): bump mcp-chassis to v0.7.1; log auth rejections; label refused REST requests by route (#181)
CI / Lint / Test / Vet (push) Successful in 1m4s
CI / Mirror to GitHub (push) Successful in 4s
Closes #155

Reviewed by pr-gatekeeper: #181 (comment) (block, round 1) and re-review at e05a13a (approve).

Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 14:40:20 +00:00
93 changed files with 8032 additions and 722 deletions
+12
View File
@@ -84,3 +84,15 @@ When acting as a coding agent on this project:
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CI / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/hyperguild/branch_protections/main {"enable_push": true}`.
+81 -17
View File
@@ -164,6 +164,63 @@ These rules apply to every task across every project, regardless of harness.
the thing it was named for was exploitable. If you cannot name the mechanism,
you do not yet know what the test observes.
10. **Short sessions, one rc per repo, edit only in a locked worktree of the repo
you are changing** (ADR-0002, accepted 2026-10-06).
- One session per issue or task, ending with rule 6's close ritual. Related
issues may share a session only when they share a worktree and an owner.
State that must outlive the session goes in an issue, not the chat.
- Start `claude rc` in the repo you will edit (`infra`, `trucker-ecommerce`,
`hyperguild`, ...), not in `~/dev`: the rc worktree is a worktree of the
launch directory's repo, so launched from `~/dev` it protects nothing you
edit (measured: 73% of edits landed in shared checkouts). Start it only
while working there.
- Keep at most one thin `~/dev` coordinator session. It files issues and hands
out tasks; it reads and edits no sub-repo code.
- Split cross-repo work per repo. The contract between the halves lives in an
issue on each repo.
- Edit in a worktree of that repo, locked at creation:
`scripts/wt.sh add <repo> <task>` does it (fetch, `wt/<task>` from origin/main, lock);
`scripts/wt.sh done <repo>-<task>` removes it after the push and refuses dirty or
unpushed work. By hand: `git worktree add ~/worktrees/<name> -b <branch> origin/main`
then `git worktree lock --reason "manual: <purpose>" ~/worktrees/<name>`.
- Before working a repo another session may touch, announce your issue list to
that session (`SendMessage`) and ask for theirs.
- Auto-memory is keyed by cwd, so a repo rc starts without it. Cross-repo
working rules live in brain (`wiki/homelab/decisions/2026-10-06-062416-auto.md`, wing homelab, hall
decisions) and the section below, not in per-directory memory.
## Standing footguns
Each cost real time once. Detail and evidence: brain `wiki/homelab/decisions/2026-10-06-062416-auto.md` (title standing-working-rules).
- **Gate then commit with `&&`, never `;`.** "Green" means exit code 0 and
`grep -c FAIL` is 0, not a filtered grep.
- **Stage by path, never `git add -A` or `git add .`** (they stage from the repo
root and swept in other sessions' work twice). Check `git status --porcelain` first.
- **Before committing in a shared checkout:** `git branch --show-current`.
- **Before tagging:** `git fetch origin --tags`, then `git describe --tags --abbrev=0 <sha>`
and `git tag --sort=-v:refname | head`; go above both. Never take the last tag
from a report.
- **Run only what the project's gate runs.** No repo-wide formatter unless the gate
runs it.
- **Reproduce a CI red at the cited commit before applying an issue's prescribed fix.**
- **Read an issue's comments (`issue_list_comments`), not just its body, before working it.**
- **A comment naming a control ("behind branch protection") is not evidence; query
the control.** A working detector can hide a missing preventer.
- **On koala the MCP servers are `mcp__plugin_skills_{brain,gitea,infra}__*`.** The
per-turn auth reminder is not evidence; call the tool or run `claude mcp list`.
Brain writes always pass `wing` + `hall`.
- **`cat /etc/hostname` before assuming which host you are on.**
- **Never run bare `git remote -v` or `git config --list`** (url.insteadOf expands
into a live token); use `git config --get remote.origin.url`.
- **On koala `sudo` is denied outright; k8s exec, env dumps and flux reconcile need
per-action approval; decrypting a SOPS file and printing in one Bash call is
hook-blocked.** Do the repo-side work and hand the host-side step over in an issue.
- **`set -o pipefail` plus an optional `grep | head | awk` dies on no match:** append `|| true`.
- **Gitea `file_write_branch` takes plain text, not base64.**
- **`OP_SERVICE_ACCOUNT_TOKEN` in the shell is read-only for `op`.** Operational
secrets go in the AgentSecrets vault via `set -a; . ~/.op-agent-write.env; set +a`.
## CAD sprint patterns
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
@@ -244,25 +301,20 @@ Three machines on Tailscale:
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders:
Organized by **role**, because the role decides which rules apply (ADR-0003):
| Folder | Focus | Count |
|--------|-------|-------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
| Folder | Role | Rules that key off it |
|--------|------|-----------------------|
| `platform/` | infra, brain, hyperguild, gitea-mcp, dispatch, shared tooling | control code (rule 8), GitOps review lane |
| `products/` | tapir, cobalt-dingo, openbanking-api, ... | ordinary trunk-based work |
| `clients/` | client work | local models only, never cloud APIs (Client work rules) |
| `spikes/` | throwaway | no ceremony |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
### Key active projects
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
New projects are born into a role folder (`task new-project -- <name> <client|personal>
<role-folder>`); a client tag requires `clients/` and `clients/` requires a client tag.
Existing repos still sit in the old `AGENTS/` and `AI/` folders until they are moved one by
one (ADR-0003 step 3). `~/dev/PROJECT_SUMMARY.md` (generated) lists every repo and where it
is. How a session runs on koala (worktrees, rc per repo, the reaper, memory): `docs/working-on-koala.md`.
## Knowledge base — actively use it
@@ -514,4 +566,16 @@ When acting as a coding agent on this project:
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CI / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/hyperguild/branch_protections/main {"enable_push": true}`.
---
+81 -17
View File
@@ -159,6 +159,63 @@ These rules apply to every task across every project, regardless of harness.
the thing it was named for was exploitable. If you cannot name the mechanism,
you do not yet know what the test observes.
10. **Short sessions, one rc per repo, edit only in a locked worktree of the repo
you are changing** (ADR-0002, accepted 2026-10-06).
- One session per issue or task, ending with rule 6's close ritual. Related
issues may share a session only when they share a worktree and an owner.
State that must outlive the session goes in an issue, not the chat.
- Start `claude rc` in the repo you will edit (`infra`, `trucker-ecommerce`,
`hyperguild`, ...), not in `~/dev`: the rc worktree is a worktree of the
launch directory's repo, so launched from `~/dev` it protects nothing you
edit (measured: 73% of edits landed in shared checkouts). Start it only
while working there.
- Keep at most one thin `~/dev` coordinator session. It files issues and hands
out tasks; it reads and edits no sub-repo code.
- Split cross-repo work per repo. The contract between the halves lives in an
issue on each repo.
- Edit in a worktree of that repo, locked at creation:
`scripts/wt.sh add <repo> <task>` does it (fetch, `wt/<task>` from origin/main, lock);
`scripts/wt.sh done <repo>-<task>` removes it after the push and refuses dirty or
unpushed work. By hand: `git worktree add ~/worktrees/<name> -b <branch> origin/main`
then `git worktree lock --reason "manual: <purpose>" ~/worktrees/<name>`.
- Before working a repo another session may touch, announce your issue list to
that session (`SendMessage`) and ask for theirs.
- Auto-memory is keyed by cwd, so a repo rc starts without it. Cross-repo
working rules live in brain (`wiki/homelab/decisions/2026-10-06-062416-auto.md`, wing homelab, hall
decisions) and the section below, not in per-directory memory.
## Standing footguns
Each cost real time once. Detail and evidence: brain `wiki/homelab/decisions/2026-10-06-062416-auto.md` (title standing-working-rules).
- **Gate then commit with `&&`, never `;`.** "Green" means exit code 0 and
`grep -c FAIL` is 0, not a filtered grep.
- **Stage by path, never `git add -A` or `git add .`** (they stage from the repo
root and swept in other sessions' work twice). Check `git status --porcelain` first.
- **Before committing in a shared checkout:** `git branch --show-current`.
- **Before tagging:** `git fetch origin --tags`, then `git describe --tags --abbrev=0 <sha>`
and `git tag --sort=-v:refname | head`; go above both. Never take the last tag
from a report.
- **Run only what the project's gate runs.** No repo-wide formatter unless the gate
runs it.
- **Reproduce a CI red at the cited commit before applying an issue's prescribed fix.**
- **Read an issue's comments (`issue_list_comments`), not just its body, before working it.**
- **A comment naming a control ("behind branch protection") is not evidence; query
the control.** A working detector can hide a missing preventer.
- **On koala the MCP servers are `mcp__plugin_skills_{brain,gitea,infra}__*`.** The
per-turn auth reminder is not evidence; call the tool or run `claude mcp list`.
Brain writes always pass `wing` + `hall`.
- **`cat /etc/hostname` before assuming which host you are on.**
- **Never run bare `git remote -v` or `git config --list`** (url.insteadOf expands
into a live token); use `git config --get remote.origin.url`.
- **On koala `sudo` is denied outright; k8s exec, env dumps and flux reconcile need
per-action approval; decrypting a SOPS file and printing in one Bash call is
hook-blocked.** Do the repo-side work and hand the host-side step over in an issue.
- **`set -o pipefail` plus an optional `grep | head | awk` dies on no match:** append `|| true`.
- **Gitea `file_write_branch` takes plain text, not base64.**
- **`OP_SERVICE_ACCOUNT_TOKEN` in the shell is read-only for `op`.** Operational
secrets go in the AgentSecrets vault via `set -a; . ~/.op-agent-write.env; set +a`.
## CAD sprint patterns
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
@@ -239,25 +296,20 @@ Three machines on Tailscale:
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders:
Organized by **role**, because the role decides which rules apply (ADR-0003):
| Folder | Focus | Count |
|--------|-------|-------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
| Folder | Role | Rules that key off it |
|--------|------|-----------------------|
| `platform/` | infra, brain, hyperguild, gitea-mcp, dispatch, shared tooling | control code (rule 8), GitOps review lane |
| `products/` | tapir, cobalt-dingo, openbanking-api, ... | ordinary trunk-based work |
| `clients/` | client work | local models only, never cloud APIs (Client work rules) |
| `spikes/` | throwaway | no ceremony |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
### Key active projects
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
New projects are born into a role folder (`task new-project -- <name> <client|personal>
<role-folder>`); a client tag requires `clients/` and `clients/` requires a client tag.
Existing repos still sit in the old `AGENTS/` and `AI/` folders until they are moved one by
one (ADR-0003 step 3). `~/dev/PROJECT_SUMMARY.md` (generated) lists every repo and where it
is. How a session runs on koala (worktrees, rc per repo, the reaper, memory): `docs/working-on-koala.md`.
## Knowledge base — actively use it
@@ -508,3 +560,15 @@ When acting as a coding agent on this project:
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CI / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/hyperguild/branch_protections/main {"enable_push": true}`.
+12
View File
@@ -84,3 +84,15 @@ When acting as a coding agent on this project:
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## `main` is protected: ship by PR (local-dev#42, decided 2026-10-08)
Gitea refuses direct pushes to `main` in this repo, and a PR merges only after its CI status (`CI / Lint / Test / Vet (pull_request)`) passes.
**Why:** local-dev rule 5's reviewed lane (risk/medium+ is reviewed by a human) was enforced on the merge tools
(`tbd_ship`, `pr_merge`: gitea-mcp v0.17.0/v0.18.0, merge only for an issue labelled exactly `risk/low` with green CI)
but not on a direct commit to `main`, which bypassed it. This overrides rule 5's "risk/low commits straight to main"
for this repo; control code is where the lane matters.
**How:** `tbd_ship` for a one-file change, or branch -> PR -> `pr_merge` (name the `risk/low` issue); above `risk/low`
open the PR and a human merges it in the Gitea UI after review. Tags are not protected. CD jobs push image tags to
the **infra** repo with deploy keys, never to this repo's `main`. Undo (Mathias only): Settings -> Branches, or
`PATCH /repos/mathias/hyperguild/branch_protections/main {"enable_push": true}`.
+57
View File
@@ -0,0 +1,57 @@
# 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.
+67
View File
@@ -0,0 +1,67 @@
// Command legacymove clears the leftovers of the retired layout (hyperguild
// #209): wiki/sources/ (videos and articles move to the default wing, the
// auto-tunnel review logs are deleted) and raw/ (originals archived before inbox
// zero, their markers, orphan markers). Default is a dry run that prints the
// plan; -apply performs it after archiving every page it touches; -rollback
// restores from an archive.
package main
import (
"flag"
"fmt"
"log/slog"
"os"
"strings"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/legacymove"
)
func main() {
brain := flag.String("brain", "/var/lib/supervisor/brain", "brain directory")
wing := flag.String("wing", "web-clip", "wing that receives video and article sources")
apply := flag.Bool("apply", false, "perform the plan (default: dry run)")
rollback := flag.String("rollback", "", "restore from this archive dir (brain-relative, e.g. archive/wing-migration-2026-10-20)")
flag.Parse()
if err := run(*brain, *wing, *apply, *rollback); err != nil {
slog.Error("legacymove", "err", err)
os.Exit(1)
}
}
func run(brain, wing string, apply bool, rollback string) error {
if rollback != "" {
if err := legacymove.Rollback(brain, rollback); err != nil {
return err
}
fmt.Println("rolled back from", rollback)
return nil
}
sources, err := legacymove.Plan(brain, wing)
if err != nil {
return err
}
rawLeft, err := legacymove.PlanRaw(brain)
if err != nil {
return err
}
items := append(sources, rawLeft...)
counts := map[legacymove.Action]int{}
for _, it := range items {
counts[it.Action]++
}
fmt.Printf("# legacy plan\n\n%d entries: %d move, %d delete, %d left\n\n", len(items), counts[legacymove.Move], counts[legacymove.Delete], counts[legacymove.Skip])
fmt.Println("| action | from | to | why |")
fmt.Println("|---|---|---|---|")
for _, it := range items {
fmt.Printf("| %s | %s | %s | %s |\n", it.Action, it.From, it.To, strings.ReplaceAll(it.Reason, "|", "/"))
}
if !apply {
fmt.Println("\n_dry run: nothing was changed_")
return nil
}
res, err := legacymove.Apply(brain, items, time.Now())
fmt.Printf("\nmoved %d, deleted %d; archive: %s\n", res.Moved, res.Deleted, res.ArchiveDir)
return err
}
+13 -12
View File
@@ -2,24 +2,25 @@ package main
import (
"context"
"fmt"
"log/slog"
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
"github.com/mathiasbq/hyperguild/ingestion/internal/startupretry"
)
// buildJWTValidator runs OIDC discovery under policy, so a 502 from an auth
// upstream that is not serving yet on a cold boot is retried instead of
// exiting (#83). It honours ctx between attempts.
func buildJWTValidator(
ctx context.Context, logger *slog.Logger, issuer, audience string, policy startupretry.Policy,
) (*chassisauth.JWTValidator, error) {
return startupretry.Do(ctx, policy, func(ctx context.Context) (*chassisauth.JWTValidator, error) {
// buildJWTValidator builds the JWT validator for issuer (nil when issuer is
// empty: JWT auth disabled). Since mcp-chassis v0.7 construction no longer
// fails when OIDC discovery does (mcp-chassis#4): the issuer resolves lazily,
// and until it does a JWT is answered 503-and-retry, not 401. That replaces
// #83's boot-time retry loop (hyperguild#155). An issuer still unresolved at
// boot is logged, so a misconfigured issuer is visible rather than silent.
func buildJWTValidator(ctx context.Context, logger *slog.Logger, issuer, audience string) (*chassisauth.JWTValidator, error) {
v, err := chassisauth.NewJWTValidator(ctx, issuer, audience)
if err != nil {
logger.Warn("oidc discovery not ready, retrying", "err", err)
return nil, fmt.Errorf("build jwt validator: %w", err)
}
return v, err
})
if ok, rerr := v.Ready(ctx); !ok {
logger.Warn("jwt issuer not resolved at boot; JWTs answer 503 until it is", "issuer", issuer, "err", rerr)
}
return v, nil
}
+61 -36
View File
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"context"
"io"
"log/slog"
@@ -8,12 +9,11 @@ import (
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/startupretry"
"github.com/mathiasbq/hyperguild/ingestion/internal/metrics"
)
// fakeIssuer answers OIDC discovery with 502 for the first fail requests, as
@@ -41,46 +41,71 @@ func fakeIssuer(t *testing.T, fail int32) (*httptest.Server, *atomic.Int32) {
return srv, &discoveries
}
var fastPolicy = startupretry.Policy{Attempts: 8, Base: time.Millisecond, Max: 5 * time.Millisecond}
func TestBuildJWTValidatorRetriesDiscovery502sThenSucceeds(t *testing.T) {
srv, discoveries := fakeIssuer(t, 3)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
v, err := buildJWTValidator(ctx, slog.New(slog.DiscardHandler), srv.URL, "brain", fastPolicy)
// #83 retried discovery at boot because chassis v0.2.0 failed construction on
// a 502. Since chassis v0.7 (mcp-chassis#4) the validator is built anyway and
// resolves the issuer lazily, so boot no longer waits or exits on it
// (hyperguild#155). An unresolved issuer is logged at boot.
func TestBuildJWTValidatorReturnsAValidatorWhileDiscovery502s(t *testing.T) {
srv, _ := fakeIssuer(t, 1000)
var buf bytes.Buffer
v, err := buildJWTValidator(context.Background(), slog.New(slog.NewTextHandler(&buf, nil)), srv.URL, "brain")
require.NoError(t, err)
assert.NotNil(t, v)
assert.Equal(t, int32(4), discoveries.Load(), "three 502s, then the successful discovery")
assert.Contains(t, buf.String(), "jwt issuer not resolved at boot")
}
func TestBuildJWTValidatorGivesUpAfterTheBudgetWithTheLastError(t *testing.T) {
srv, discoveries := fakeIssuer(t, 1000)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
_, err := buildJWTValidator(ctx, slog.New(slog.DiscardHandler), srv.URL, "brain", fastPolicy)
require.Error(t, err)
assert.Contains(t, err.Error(), "status 502")
assert.Equal(t, int32(8), discoveries.Load(), "bounded: exactly the attempt budget")
func TestBuildJWTValidatorLogsNothingAgainstAHealthyIssuer(t *testing.T) {
srv, _ := fakeIssuer(t, 0)
var buf bytes.Buffer
v, err := buildJWTValidator(context.Background(), slog.New(slog.NewTextHandler(&buf, nil)), srv.URL, "brain")
require.NoError(t, err)
assert.NotNil(t, v)
assert.NotContains(t, buf.String(), "not resolved")
}
func TestBuildJWTValidatorStopsRetryingWhenTheContextIsCancelled(t *testing.T) {
func TestBuildJWTValidatorIsNilWithoutAnIssuer(t *testing.T) {
v, err := buildJWTValidator(context.Background(), slog.New(slog.DiscardHandler), "", "brain")
require.NoError(t, err)
assert.Nil(t, v)
}
// With the issuer unresolved, a JWT on a REST route answers 503 (retry), not
// 401: the token may be fine, the server just cannot check it yet.
func TestBuildMuxAnswers503ToAJWTWhileTheIssuerIsUnresolved(t *testing.T) {
srv, _ := fakeIssuer(t, 1000)
ctx, cancel := context.WithCancel(context.Background())
slow := startupretry.Policy{Attempts: 8, Base: time.Hour, Max: time.Hour}
v, err := buildJWTValidator(context.Background(), slog.New(slog.DiscardHandler), srv.URL, "brain")
require.NoError(t, err)
mux, err := buildMux(restAuth{token: routesTok, validator: v, realm: "brain"}, testHandler(t),
okHandler("mcp"), metrics.New(), nil)
require.NoError(t, err)
code, _ := do(t, mux, "GET", "/pending", "Bearer aaa.bbb.ccc")
assert.Equal(t, http.StatusServiceUnavailable, code)
}
done := make(chan error, 1)
go func() {
_, err := buildJWTValidator(ctx, slog.New(slog.DiscardHandler), srv.URL, "brain", slow)
done <- err
}()
time.Sleep(50 * time.Millisecond)
cancel()
select {
case err := <-done:
assert.ErrorIs(t, err, context.Canceled)
case <-time.After(3 * time.Second):
t.Fatal("did not return after the context was cancelled")
}
// PR #183 review: /mcp shares REST's bearer, but only the static token was
// asserted on it. With the issuer unresolved, a JWT on /mcp answers 503 too,
// so /mcp's middleware was built with the JWT validator.
func TestBuildMuxAnswers503ToAJWTOnMCPWhileTheIssuerIsUnresolved(t *testing.T) {
srv, _ := fakeIssuer(t, 1000)
v, err := buildJWTValidator(context.Background(), slog.New(slog.DiscardHandler), srv.URL, "brain")
require.NoError(t, err)
mux, err := buildMux(restAuth{token: routesTok, validator: v, realm: "brain"}, testHandler(t),
okHandler("mcp"), metrics.New(), nil)
require.NoError(t, err)
code, _ := do(t, mux, "POST", "/mcp", "Bearer aaa.bbb.ccc")
assert.Equal(t, http.StatusServiceUnavailable, code)
}
// PR #183 review: a 401 on /mcp names the protected-resource metadata in
// WWW-Authenticate (what the claude.ai OAuth connector discovers from), so
// /mcp's middleware was built with resourceMetadataURL.
func TestBuildMuxMCP401CarriesResourceMetadataInWWWAuthenticate(t *testing.T) {
const rm = "https://brain.example/.well-known/oauth-protected-resource"
mux, err := buildMux(restAuth{token: routesTok, realm: "brain", resourceMetadataURL: rm}, testHandler(t),
okHandler("mcp"), metrics.New(), nil)
require.NoError(t, err)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil))
require.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Contains(t, rec.Header().Get("WWW-Authenticate"), `resource_metadata="`+rm+`"`)
}
+85 -42
View File
@@ -17,6 +17,7 @@ import (
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
"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/capturehttp"
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
@@ -32,11 +33,11 @@ import (
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
"github.com/mathiasbq/hyperguild/ingestion/internal/startupretry"
"github.com/mathiasbq/hyperguild/ingestion/internal/symlinkguard"
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
"github.com/mathiasbq/hyperguild/ingestion/internal/webhook"
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
@@ -90,11 +91,15 @@ func (s *claudeSink) Ingest(ctx context.Context, b claudewatcher.Batch) error {
sb.WriteString("\n\n")
}
slug := "session-" + b.Host + "-" + b.SessionID
dest := filepath.Join(s.brainDir, "archive", "claude-sessions", b.Host, slug+".md")
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("create claude-sessions archive dir: %w", err)
dest := "archive/claude-sessions/" + b.Host + "/" + slug + ".md"
// Written through the brain root, never through a symlinked directory
// or file (hyperguild#158).
bfs, err := brainfs.Open(s.brainDir)
if err != nil {
return fmt.Errorf("open brain: %w", err)
}
if err := os.WriteFile(dest, []byte(sb.String()), 0o644); err != nil {
defer func() { _ = bfs.Close() }()
if err := bfs.PutRegular(dest, []byte(sb.String())); err != nil {
return fmt.Errorf("write claude session archive: %w", err)
}
s.logger.Debug("claude session archived (non-indexed)", "path", dest)
@@ -145,6 +150,21 @@ func envInt(key string, fallback int) int {
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
// BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so
// DegradingSink/Reconcile and the governance observer skip it cleanly.
@@ -180,9 +200,12 @@ func alertingWarnings(getenv func(string) string) []string {
return out
}
func buildNotifier() audit.Notifier {
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
return n
// buildNotifier returns the ntfy notifier for one alert source, titled so
// the source is recognisable (hyperguild#163), or nil when ntfy is not
// configured.
func buildNotifier(getenv func(string) string, title string) audit.Notifier {
if n := audit.NewNtfyNotifier(getenv("BRAIN_NTFY_URL"), getenv("BRAIN_NTFY_TOKEN")); n != nil {
return n.WithTitle(title)
}
return nil
}
@@ -204,7 +227,7 @@ func buildAuditSink(ctx context.Context, brainDir string, logger *slog.Logger) c
logger.Error("capture audit buffer init", "err", err)
os.Exit(1)
}
notifier := buildNotifier()
notifier := buildNotifier(os.Getenv, "brain-capture audit degraded")
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
@@ -367,23 +390,17 @@ func main() {
ctx := context.Background()
// hyperguild#157: report a symlink that appears in the brain while running.
go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier())
go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier(os.Getenv, "brain symlink tripwire"))
// Built before the watcher, the graph backfill and session ingestion, and
// retried rather than fatal (#83). Two separate reasons:
//
// - On a cold cluster boot this pod can beat dex to serving and gets a
// 502 from ingress. Nothing is misconfigured; it simply started first.
// Exiting there meant CrashLoopBackOff redoing the backfill on every
// attempt, with exponential backoff stretching the brain outage.
// - Constructing it first means a genuine config error (wrong issuer,
// bad audience) fails in seconds instead of after all that work.
//
// The budget stays bounded: retrying forever would turn a misconfiguration
// into a process that looks alive and never serves.
// 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
// CrashLoopBackOff redoing the backfill. Since mcp-chassis v0.7 the
// validator resolves the issuer lazily and answers JWTs 503-and-retry until
// it does (hyperguild#155), so no boot retry is needed. A misconfigured
// issuer no longer exits the process: it is logged at boot and every JWT
// gets 503; the static bearer keeps working.
jwtValidator, err := buildJWTValidator(ctx, logger,
os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"),
startupretry.Policy{Attempts: 8, Base: time.Second, Max: 30 * time.Second})
os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
if err != nil {
logger.Error("build jwt validator", "err", err)
os.Exit(1)
@@ -393,10 +410,39 @@ func main() {
}
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{
BrainDir: brainDir,
Interval: time.Duration(watchInterval) * time.Second,
Pipeline: pipelineCfg,
Resolver: placer,
})
}
@@ -523,8 +569,6 @@ func main() {
}
}
public["/mcp"] = chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv)
// POST /capture (#53/#54): the uniform capture REST door. Needs a ticket
// tracker to file action items, so it only mounts when Gitea is
// configured. It reuses the MCP server's graph-wired brain store (one
@@ -548,14 +592,14 @@ func main() {
public["POST /capture"] = captureH
// Same use-case behind the MCP `capture` tool (#55 relay) so MCP-native
// harnesses (claude.ai, Crush, Pi, LLM Council) reach capture through
// the existing /mcp OAuth connector. mcpSrv is already wrapped above;
// the existing /mcp OAuth connector. buildMux wraps this same mcpSrv;
// WithCapture mutates the same instance, so the tool appears live.
mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
// hyperguild#97: a governance-file rewrite gets the same trail a
// capture already gets, plus an out-of-band alert. Wired here because
// brain_edit_root needs the capture plumbing anyway — that is what
// re-derives the static principal its gate checks.
mcpSrv.WithGovernanceObserver(auditSink, buildNotifier())
mcpSrv.WithGovernanceObserver(auditSink, buildNotifier(os.Getenv, "brain governance edit"))
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign),
"governance_observer", true)
} else {
@@ -590,19 +634,18 @@ func main() {
os.Exit(1)
}
// /metrics — unauthenticated Prometheus endpoint. kube-prometheus-stack
// scrapes it via the ServiceMonitor in k3s/apps/supervisor/. The metrics
// middleware below wraps every other registered handler so it observes
// real request latency. /metrics itself is excluded from its own
// observation by registering it on the outer mux (post-wrap).
reg := metrics.New()
public["GET /metrics"] = http.HandlerFunc(reg.Handler())
// The whole router, built in one place so the test of buildMux is a test
// of production wiring (PR #152 review). The REST API's credential is the
// same as /mcp's.
mux, err := buildMux(restAuth{token: mcpToken, validator: jwtValidator, realm: "brain",
resourceMetadataURL: resourceMetadataURL}, h, public)
// /metrics — unauthenticated Prometheus endpoint, scraped via the
// ServiceMonitor in k3s/apps/supervisor/. buildMux registers it and wraps
// the whole router in the registry's middleware.
//
// The whole router, /mcp's bearer included, is built in buildMux so its
// tests are tests of production wiring (PR #152 review, hyperguild#154).
// The REST API's credential is the same as /mcp's. Untested here: the
// restAuth fields and the public map this call passes in (e.g. a nil
// validator would drop JWT auth on /mcp and REST and the suite stays
// green), and ListenAndServe serving the result unchanged (hyperguild#182).
handler, err := buildMux(restAuth{token: mcpToken, validator: jwtValidator, realm: "brain",
resourceMetadataURL: resourceMetadataURL}, h, mcpSrv, metrics.New(), public)
if err != nil {
logger.Error("build router", "err", err)
os.Exit(1)
@@ -623,7 +666,7 @@ func main() {
"watch_interval", watchIntervalLog,
"mcp_enabled", true,
)
if err := http.ListenAndServe(addr, reg.Middleware(mux)); err != nil {
if err := http.ListenAndServe(addr, handler); err != nil {
logger.Error("server stopped", "err", err)
os.Exit(1)
}
+53
View File
@@ -76,3 +76,56 @@ 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)
})
}
}
+51 -19
View File
@@ -8,22 +8,22 @@ import (
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
"github.com/mathiasbq/hyperguild/ingestion/internal/metrics"
)
// publicRoutes is the complete set of patterns allowed on the outer mux,
// outside the REST API's auth (hyperguild#149). Each authenticates itself or is
// meant to be open: /mcp (bearer/JWT), /capture (bearer/JWT), the webhook
// (HMAC), /oauth/token (client secret), OAuth metadata and /metrics (open).
// A REST pattern added here would beat the authenticated catch-all by
// specificity and ship open, which is why buildMux refuses anything else.
// publicRoutes is the complete set of patterns a caller may register on the
// outer mux, outside the REST API's auth (hyperguild#149). Each authenticates
// itself or is meant to be open: /capture (bearer/JWT), the webhook (HMAC),
// /oauth/token (client secret) and OAuth metadata (open). /mcp and /metrics
// are not here: buildMux builds them itself (hyperguild#154). A REST pattern
// added here would beat the authenticated catch-all by specificity and ship
// open, which is why buildMux refuses anything else.
var publicRoutes = map[string]bool{
"/mcp": true,
"POST /capture": true,
"POST /webhooks/brain-sync": true,
"GET /.well-known/oauth-protected-resource": true,
"GET /.well-known/oauth-authorization-server": true,
"POST /oauth/token": true,
"GET /metrics": true,
}
// restAuth is the credential the REST API checks: the same as /mcp's.
@@ -34,16 +34,25 @@ type restAuth struct {
resourceMetadataURL string
}
// buildMux is the server's whole router. The REST API is mounted at "/"
// behind bearer auth that buildMux constructs itself, so a caller cannot pass
// a no-op; public handlers are registered as given, and only for patterns on
// the publicRoutes allowlist. It returns an http.Handler, not the
// *ServeMux, so nothing can register a route on the result afterwards and
// bypass the allowlist.
func buildMux(auth restAuth, h *api.Handler, public map[string]http.Handler) (http.Handler, error) {
// buildMux builds the handler main passes to ListenAndServe unchanged. The
// REST API is mounted at "/" and /mcp is registered, both behind bearer auth
// that buildMux constructs itself from auth (one credential), so a caller
// cannot pass a no-op. GET /metrics serves reg, and the whole router is
// wrapped in reg's middleware. Other public handlers are registered as given,
// and only for patterns on the publicRoutes allowlist. It returns an
// http.Handler, not the *ServeMux, so nothing can register a route on the
// result. What stays untested is main's call site: the restAuth fields and
// the public map main passes in, and ListenAndServe serving this handler
// unchanged (hyperguild#182). The tests build their own arguments.
func buildMux(
auth restAuth, h *api.Handler, mcpHandler http.Handler, reg *metrics.Registry, public map[string]http.Handler,
) (http.Handler, error) {
if auth.token == "" {
return nil, errors.New("buildMux: REST auth needs a token")
}
if mcpHandler == nil || reg == nil {
return nil, errors.New("buildMux: needs the MCP handler and the metrics registry")
}
mux := http.NewServeMux()
for pattern, handler := range public {
if !publicRoutes[pattern] {
@@ -51,10 +60,15 @@ func buildMux(auth restAuth, h *api.Handler, public map[string]http.Handler) (ht
}
mux.Handle(pattern, handler)
}
mountREST(mux, h, func(next http.Handler) http.Handler {
bearer := func(next http.Handler) http.Handler {
return chassisauth.BearerMiddleware(auth.token, auth.validator, auth.realm, auth.resourceMetadataURL, next)
})
return mux, nil
}
mux.Handle("/mcp", bearer(mcpHandler))
// /metrics is registered on the mux and so observed by its own
// middleware, like any route.
mux.Handle("GET /metrics", reg.Handler())
mountREST(mux, h, bearer)
return reg.Middleware(mux), nil
}
// mountREST registers the REST API on its own mux and mounts it at "/" behind
@@ -73,5 +87,23 @@ func mountREST(mux *http.ServeMux, h *api.Handler, auth func(http.Handler) http.
rest.HandleFunc("POST /promote", h.Promote)
rest.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
rest.HandleFunc("GET /pass-rate", h.PassRate)
mux.Handle("/", auth(rest))
mux.Handle("/", labelRESTPattern(rest, auth(rest)))
}
// labelRESTPattern sets the request's Pattern to the REST route it addresses
// before auth runs, so the metrics middleware (which reads Pattern from the
// same *http.Request) labels a refused request by its route rather than by
// the "/" mount (hyperguild#155). A refused request to a path no route
// matches keeps "/", so for refused requests the label set stays bounded by
// the registered routes. That bound does not hold for authenticated
// requests: an unregistered path reaches the REST mux, which 404s with an
// empty Pattern, and the metrics middleware falls back to the raw URL path
// (hyperguild#185).
func labelRESTPattern(rest *http.ServeMux, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, pattern := rest.Handler(r); pattern != "" {
r.Pattern = pattern
}
next.ServeHTTP(w, r)
})
}
@@ -0,0 +1,88 @@
package main
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// wrongTokSentinel is a presented credential distinctive enough that any
// occurrence in a log line is the raw token, not a coincidence.
const wrongTokSentinel = "wrong-tok-sentinel-q7zx"
// hyperguild#155: since #149 every REST route sits behind BearerMiddleware,
// and chassis v0.2.0 refused without logging, so an unknown caller got
// silent 401s. The chassis now writes an `mcp auth rejected` line, and the
// presented token appears nowhere in the log as a bare value (PR #181
// review: asserting on the "Bearer " header form could never fail, since
// the chassis logs fields, not headers).
func TestBuildMuxRejectionLogLineOmitsTheBarePresentedToken(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
code, _ := do(t, testMux(t), "POST", "/query", "Bearer "+wrongTokSentinel)
require.Equal(t, http.StatusUnauthorized, code)
assert.Contains(t, buf.String(), "mcp auth rejected")
assert.Contains(t, buf.String(), "path=/query")
assert.NotContains(t, buf.String(), wrongTokSentinel, "the rejected token is logged as a bare value")
assert.NotContains(t, buf.String(), routesTok, "the valid static token is logged")
}
// The `mcp auth accepted` line leaves out the valid static token.
func TestBuildMuxAcceptedLogLineOmitsTheBareStaticToken(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
code, _ := do(t, testMux(t), "GET", "/pending", "Bearer "+routesTok)
require.Equal(t, http.StatusOK, code)
assert.Contains(t, buf.String(), "mcp auth accepted")
assert.NotContains(t, buf.String(), routesTok, "the accepted static token is logged as a bare value")
}
// An authenticated REST request is labelled with the REST mux's pattern:
// the inner mux sets it on the same *http.Request the outer middleware reads.
func TestMetricsLabelAnAuthenticatedRESTRequestWithItsRoutePattern(t *testing.T) {
mux, reg := testMuxAndRegistry(t)
code, _ := do(t, mux, "GET", "/pending", "Bearer "+routesTok)
require.Equal(t, http.StatusOK, code)
rec := httptest.NewRecorder()
reg.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
assert.Contains(t, rec.Body.String(), `path="GET /pending"`)
assert.NotContains(t, rec.Body.String(), `path="/"`)
}
// hyperguild#155: a request refused by REST auth never reaches the REST mux,
// so it was labelled path="/" and a 4xx on /query could not be told from one
// on /write. It is now labelled with the REST pattern it was addressed to.
func TestMetricsLabelARejectedRESTRequestWithItsRoutePattern(t *testing.T) {
mux, reg := testMuxAndRegistry(t)
code, _ := do(t, mux, "POST", "/query", "")
require.Equal(t, http.StatusUnauthorized, code)
rec := httptest.NewRecorder()
reg.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
assert.Contains(t, rec.Body.String(), `path="POST /query",status="4xx"`)
}
// A path no REST route matches keeps the catch-all label, so an attacker
// probing random URLs cannot grow the series set.
func TestMetricsLabelARejectedUnknownPathWithTheCatchAll(t *testing.T) {
mux, reg := testMuxAndRegistry(t)
code, _ := do(t, mux, "POST", "/no-such-route-zq", "")
require.Equal(t, http.StatusUnauthorized, code)
rec := httptest.NewRecorder()
reg.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
assert.Contains(t, rec.Body.String(), `path="/",status="4xx"`)
assert.NotContains(t, rec.Body.String(), "no-such-route-zq")
}
+80 -12
View File
@@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
"github.com/mathiasbq/hyperguild/ingestion/internal/metrics"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
)
@@ -21,20 +22,34 @@ func okHandler(body string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) })
}
// testMux builds the router exactly as main does: buildMux with the full
// public set main registers (stub handlers) and real bearer config.
// hyperguild#149: the REST API is authenticated as a whole; the PR #152
// review showed a test of a COPY of the router let main.go regress unseen.
// testMux builds the router through buildMux, the function main calls, with
// every allowlisted public pattern (stub handlers), a stub MCP server and a
// static token only: no JWT validator and no resourceMetadataURL (the tests
// in jwt_validator_test.go pass those). It does not use main's own restAuth
// or public map (hyperguild#182). hyperguild#149: the REST API is
// authenticated as a whole; the PR #152 review showed a test of a COPY of
// the router let main.go regress unseen.
func testMux(t *testing.T) http.Handler {
t.Helper()
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
mux, _ := testMuxAndRegistry(t)
return mux
}
func testMuxAndRegistry(t *testing.T) (http.Handler, *metrics.Registry) {
t.Helper()
public := map[string]http.Handler{}
for p := range publicRoutes {
public[p] = okHandler("public " + p)
}
mux, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, public)
reg := metrics.New()
mux, err := buildMux(restAuth{token: routesTok, realm: "brain"}, testHandler(t), okHandler("mcp"), reg, public)
require.NoError(t, err)
return mux
return mux, reg
}
func testHandler(t *testing.T) *api.Handler {
t.Helper()
return api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
}
func do(t *testing.T, h http.Handler, method, path, authz string) (int, string) {
@@ -74,8 +89,9 @@ func TestBuildMuxAuthenticatesAnUnregisteredPath(t *testing.T) {
}
// buildMux registers public handlers as given, without adding auth: each
// public route owns its own (bearer on /mcp, HMAC on the webhook, client
// secret on /oauth/token) or is meant to be open (/metrics, OAuth metadata).
// public route owns its own (bearer/JWT on /capture, HMAC on the webhook,
// client secret on /oauth/token) or is meant to be open (OAuth metadata).
// /mcp and /metrics are not public routes: buildMux builds them itself.
func TestBuildMuxDoesNotWrapPublicRoutes(t *testing.T) {
r := testMux(t)
for p := range publicRoutes {
@@ -93,14 +109,66 @@ func TestBuildMuxDoesNotWrapPublicRoutes(t *testing.T) {
// over the authenticated catch-all (more specific pattern), so it is refused.
func TestBuildMuxRefusesAPublicPatternOffTheAllowlist(t *testing.T) {
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
for _, p := range []string{"POST /query", "GET /pending", "/", "POST /metrics"} {
_, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, map[string]http.Handler{p: okHandler("x")})
// "/mcp" and "GET /metrics" are built by buildMux itself (hyperguild#154):
// passing either as public would register them unauthenticated or twice.
for _, p := range []string{"POST /query", "GET /pending", "/", "POST /metrics", "/mcp", "GET /metrics"} {
_, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, okHandler("mcp"), metrics.New(),
map[string]http.Handler{p: okHandler("x")})
assert.Error(t, err, "public pattern %q must be refused", p)
}
}
func TestBuildMuxRefusesAnEmptyToken(t *testing.T) {
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
_, err := buildMux(restAuth{token: "", realm: "brain"}, h, nil)
_, err := buildMux(restAuth{token: "", realm: "brain"}, h, okHandler("mcp"), metrics.New(), nil)
assert.Error(t, err)
}
// hyperguild#154: /mcp's bearer was built in main.go, untested; the buildMux
// tests used stubs. buildMux now wraps the MCP handler itself, with the same
// credential as the REST API.
func TestBuildMuxRefusesMCPWithoutAValidToken(t *testing.T) {
r := testMux(t)
for _, authz := range []string{"", "Bearer wrong"} {
code, body := do(t, r, "POST", "/mcp", authz)
assert.Equal(t, http.StatusUnauthorized, code, "authz %q", authz)
assert.NotEqual(t, "mcp", body)
}
code, body := do(t, r, "POST", "/mcp", "Bearer "+routesTok)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, "mcp", body)
}
func TestBuildMuxRefusesANilMCPHandlerOrRegistry(t *testing.T) {
auth := restAuth{token: routesTok, realm: "brain"}
_, err := buildMux(auth, testHandler(t), nil, metrics.New(), nil)
assert.Error(t, err)
_, err = buildMux(auth, testHandler(t), okHandler("mcp"), nil, nil)
assert.Error(t, err)
}
// hyperguild#154: main wrapped buildMux's result in the metrics middleware
// before ListenAndServe, untested. buildMux now returns the handler main
// serves as-is, metrics included: a request through it is observed and
// exposed on its own GET /metrics.
func TestBuildMuxObservesRequestsAndServesMetrics(t *testing.T) {
r := testMux(t)
code, _ := do(t, r, "GET", "/pending", "Bearer "+routesTok)
require.Equal(t, http.StatusOK, code)
code, body := do(t, r, "GET", "/metrics", "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, body, `path="GET /pending"`)
}
// hyperguild#185: an authenticated request to an unregistered path reached
// the inner REST mux, which left Pattern empty, and the metrics label fell
// back to the raw URL path: one Prometheus series per unique path any token
// holder sends. It is now labelled with a fixed catch-all.
func TestBuildMuxLabelsAnUnregisteredPathWithAFixedLabel(t *testing.T) {
r := testMux(t)
code, _ := do(t, r, "GET", "/zqrandom-185-segment", "Bearer "+routesTok)
require.Equal(t, http.StatusNotFound, code)
_, body := do(t, r, "GET", "/metrics", "")
assert.NotContains(t, body, "zqrandom-185-segment", "the raw path became a metrics label")
assert.Contains(t, body, `path="unmatched"`)
}
@@ -1,9 +1,13 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"github.com/stretchr/testify/assert"
@@ -63,3 +67,26 @@ func TestStartupSymlinkCheckRefusesAnUnlistableBrainRoot(t *testing.T) {
t.Cleanup(func() { _ = os.Chmod(brain, 0o755) })
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")
}
+3 -3
View File
@@ -51,7 +51,7 @@ require (
)
require (
git.d-ma.be/mathias/mcp-chassis v0.2.0
git.d-ma.be/mathias/mcp-chassis v0.7.1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
@@ -68,7 +68,7 @@ require (
github.com/segmentio/asm v1.2.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.39.0 // indirect
golang.org/x/sys v0.45.0
golang.org/x/text v0.39.0
gopkg.in/yaml.v3 v3.0.1
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.d-ma.be/mathias/mcp-chassis v0.2.0 h1:6fLmb7xqRa2nNVWsHaUbbfbArgDXJw/gDhb09clBIjo=
git.d-ma.be/mathias/mcp-chassis v0.2.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
git.d-ma.be/mathias/mcp-chassis v0.7.1 h1:4h8TOBHTA0L/no4IPcr4tLcQtmKUlhE2/cXyPdNFGmM=
git.d-ma.be/mathias/mcp-chassis v0.7.1/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+12
View File
@@ -83,6 +83,18 @@ func (f *frontmatter) get(key string) string {
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
// `key: value` line when the key is absent.
func (f *frontmatter) set(key, value string) {
+16 -6
View File
@@ -12,6 +12,7 @@ import (
"time"
"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/pipeline"
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
@@ -227,14 +228,22 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
// 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
// (brain#21; provenance refusal joins the envelope refusal here).
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("create hall dir: %w", err)
// Created through the brain root, never through a symlinked wing or
// hall directory (hyperguild#188).
rel, err := filepath.Rel(brainDir, dest)
if err != nil {
return "", fmt.Errorf("note path: %w", err)
}
if err := os.WriteFile(dest, []byte(final), 0o644); err != nil {
rel = filepath.ToSlash(rel)
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)
}
rel, _ := filepath.Rel(brainDir, dest)
return filepath.ToSlash(rel), nil
return rel, nil
}
// scalarValues flattens frontmatter fields to key -> scalar value for
@@ -608,6 +617,7 @@ type promoteRequest struct {
Wing string `json:"wing"`
Hall string `json:"hall"`
Slug string `json:"slug,omitempty"`
Queue string `json:"queue,omitempty"`
}
// Promote handles POST /promote — move a raw/ note into the wiki. A bad
@@ -621,7 +631,7 @@ func (h *Handler) Promote(w http.ResponseWriter, r *http.Request) {
// Field by field: this door carries no principal, so the promoted note is
// stamped origin: unknown and the body cannot set provenance (#145).
rel, err := PromoteNote(h.brainDir, PromoteOptions{
Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug,
Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug, Queue: req.Queue,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
@@ -0,0 +1,51 @@
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)")
}
+159 -30
View File
@@ -8,6 +8,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -20,6 +21,15 @@ type PendingNote struct {
CreatedAt string `json:"created_at"`
SizeBytes int64 `json:"size_bytes"`
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
@@ -38,18 +48,65 @@ func ListPending(brainDir string) ([]PendingNote, error) {
return nil, fmt.Errorf("read raw dir: %w", err)
}
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(), ".")
if err != nil {
return nil, fmt.Errorf("read raw dir: %w", err)
}
out := make([]PendingNote, 0, len(entries))
for _, e := range entries {
if !e.Type().IsRegular() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
isMD := strings.HasSuffix(e.Name(), ".md")
// 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)
}
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())
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
}
fm, body := parseFrontmatter(string(raw))
@@ -57,14 +114,20 @@ func ListPending(brainDir string) ([]PendingNote, error) {
if created == "" {
created = info.ModTime().UTC().Format(time.RFC3339)
}
out = append(out, PendingNote{
n := PendingNote{
Filename: e.Name(),
CreatedAt: created,
SizeBytes: info.Size(),
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
}
@@ -74,6 +137,9 @@ type PromoteOptions struct {
Wing string
Hall string
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
// note's own frontmatter claims are not provenance (hyperguild#145).
Origin string
@@ -96,21 +162,30 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
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
if slug == "" {
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.
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
if err != nil {
return "", err
}
src := filepath.Join(brainDir, "raw", base)
raw, err := readRawSource(brainDir, base)
raw, srcRel, err := readRawSource(brainDir, base, opts.Queue)
if err != nil {
return "", err
}
src := filepath.Join(brainDir, filepath.FromSlash(srcRel))
// Collision: never silently overwrite an existing promoted note.
if _, statErr := os.Stat(dest); statErr == nil {
@@ -119,6 +194,11 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
}
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)
fm.set("wing", brain.Sanitise(opts.Wing))
fm.set("hall", opts.Hall)
@@ -127,18 +207,18 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
}
fm.set("promoted_at", now)
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("create wing dir: %w", err)
}
// Write-then-delete: the source survives any write failure.
// Write-then-delete: the source survives any write failure. Both go
// through the brain root (hyperguild#188).
promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
if err != nil {
return "", err
}
if err := os.WriteFile(dest, []byte(promoted), 0o644); err != nil {
// Created with O_EXCL: a note that appeared after the collision check
// above is never overwritten.
if err := createBrainFile(brainDir, dest, []byte(promoted)); err != nil {
return "", fmt.Errorf("write promoted note: %w", err)
}
if err := os.Remove(src); err != nil {
if err := removeBrainFile(brainDir, src); err != nil {
return "", fmt.Errorf("promoted note written but source removal failed: %w", err)
}
@@ -156,6 +236,7 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
type RejectOptions struct {
Filename string // basename in brain/raw/
Reason string // optional
Queue string // as PromoteOptions.Queue
}
// RejectNote records a human decision not to promote a brain/raw/ note,
@@ -178,11 +259,11 @@ func RejectNote(brainDir string, opts RejectOptions) (string, error) {
return "", fmt.Errorf("invalid filename %q", opts.Filename)
}
src := filepath.Join(brainDir, "raw", base)
raw, err := readRawSource(brainDir, base)
raw, srcRel, err := readRawSource(brainDir, base, opts.Queue)
if err != nil {
return "", err
}
src := filepath.Join(brainDir, filepath.FromSlash(srcRel))
dest := filepath.Join(brainDir, "raw", "rejected", base)
if _, statErr := os.Stat(dest); statErr == nil {
@@ -190,20 +271,22 @@ func RejectNote(brainDir string, opts RejectOptions) (string, error) {
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.set("rejected_at", time.Now().UTC().Format(time.RFC3339))
if 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
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("create rejected dir: %w", err)
}
// Write-then-delete: the source survives any write failure.
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
// Write-then-delete: the source survives any write failure. Both go
// through the brain root (hyperguild#188).
if err := createBrainFile(brainDir, dest, []byte(content)); err != nil {
return "", fmt.Errorf("write rejected note: %w", err)
}
if err := os.Remove(src); err != nil {
if err := removeBrainFile(brainDir, src); err != nil {
return "", fmt.Errorf("rejected note written but source removal failed: %w", err)
}
@@ -223,21 +306,67 @@ func excerpt(s string, n int) string {
// readRawSource reads a pending note for promote/reject through the raw root,
// refusing symlinks and a symlinked raw/ (hyperguild#153).
func readRawSource(brainDir, base string) ([]byte, error) {
func readRawSource(brainDir, base, queue string) ([]byte, string, 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)
if err != nil {
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() }()
raw, _, err := readRawNote(root, base)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("pending note %q does not exist in raw/", base)
var topRaw, parkedRaw []byte
var topErr, parkedErr error
if queue != QueueNeedsReview {
topRaw, _, topErr = readRawNote(root, base)
}
return nil, fmt.Errorf("read source: %w", err)
if queue != QueueInbox {
// 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
}
@@ -0,0 +1,210 @@
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")
}
@@ -0,0 +1,87 @@
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")
}
@@ -0,0 +1,62 @@
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)
}
+57 -3
View File
@@ -1,6 +1,10 @@
package api
import (
"fmt"
"path/filepath"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
)
@@ -11,11 +15,61 @@ func StampProvenance(content, origin, writtenBy string) (string, error) {
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 {
_, abs, err := resolveWithin(brainDir, relPath)
rel, _, err := resolveWithin(brainDir, relPath)
if err != nil {
return err
}
return provenance.ApplyFile(abs, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
bfs, err := brainfs.Open(brainDir)
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))
}
+19 -2
View File
@@ -38,6 +38,10 @@ func OpenRawRoot(brainDir string) (*os.Root, string, error) {
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
// anything that is not a regular file.
//
@@ -59,9 +63,22 @@ func readRawNote(r *os.Root, base string) ([]byte, os.FileInfo, error) {
return nil, nil, err
}
defer func() { _ = f.Close() }()
b, err := io.ReadAll(f)
b, err := readCapped(f, maxRawNoteBytes)
if err != nil {
return nil, nil, err
return nil, nil, fmt.Errorf("%s: %w", base, err)
}
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,6 +23,13 @@ func TestReadNoteRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
_, body, _, err := api.ReadNote(brain, "wiki/w/facts/leak.md")
assert.Error(t, err)
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")
require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644))
@@ -39,5 +46,7 @@ func TestReadNoteRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
return s
}
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() })
}
+18 -6
View File
@@ -3,15 +3,18 @@ package api
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
"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/notes"
)
// ContentHash returns the lowercase hex sha256 of b. It is the note's
@@ -91,14 +94,23 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
rel = filepath.ToSlash(rel)
}
rel, abs, err := resolveWithin(brainDir, rel)
// The path is caller-supplied (brain_update): resolve it as brain_edit
// 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 {
return "", "", "", err
}
prior, err := os.ReadFile(abs)
rel = t.Rel
bfs, err := brainfs.Open(brainDir)
if err != nil {
if os.IsNotExist(err) {
return "", "", "", fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
prior, err := bfs.ReadRegular(rel)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return "", "", "", fmt.Errorf("note %q does not exist: use brain_write to create", rel)
}
return "", "", "", fmt.Errorf("read target: %w", err)
@@ -164,7 +176,7 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
return "", "", "", err
}
out := []byte(stamped)
if err := os.WriteFile(abs, out, 0o644); err != nil {
if err := bfs.WriteRegular(rel, out); err != nil {
return "", "", "", fmt.Errorf("write: %w", err)
}
return rel, ContentHash(out), priorHash, nil
@@ -0,0 +1,36 @@
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")
}
+112
View File
@@ -0,0 +1,112 @@
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")
}
@@ -0,0 +1,34 @@
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")
}
@@ -0,0 +1,43 @@
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")
})
}
}
+20 -1
View File
@@ -13,9 +13,14 @@ import (
type NtfyNotifier struct {
topicURL string
token string
title string
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
// (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
@@ -27,17 +32,31 @@ func NewNtfyNotifier(topicURL, token string) *NtfyNotifier {
return &NtfyNotifier{
topicURL: strings.TrimRight(topicURL, "/"),
token: token,
title: defaultNtfyTitle,
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.
func (n *NtfyNotifier) Notify(ctx context.Context, msg string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.topicURL, strings.NewReader(msg))
if err != nil {
return err
}
req.Header.Set("Title", "brain-capture audit degraded")
req.Header.Set("Title", n.title)
req.Header.Set("Priority", "high")
req.Header.Set("Tags", "warning,brain")
if n.token != "" {
@@ -0,0 +1,29 @@
package audit
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hyperguild#163: every alert went out titled "brain-capture audit
// degraded", including the symlink tripwire's and governance alerts.
func TestNtfyNotifierSendsItsOwnTitle(t *testing.T) {
var got []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = append(got, r.Header.Get("Title"))
}))
defer srv.Close()
base := NewNtfyNotifier(srv.URL, "")
require.NoError(t, base.Notify(context.Background(), "m"))
require.NoError(t, base.WithTitle("brain symlink tripwire").Notify(context.Background(), "m"))
require.NoError(t, base.Notify(context.Background(), "m"))
assert.Equal(t, []string{"brain-capture audit degraded", "brain symlink tripwire", "brain-capture audit degraded"}, got,
"WithTitle returns a copy; the base keeps its default title")
var nilN *NtfyNotifier
assert.Nil(t, nilN.WithTitle("x"), "nil (ntfy not configured) stays nil")
}
@@ -0,0 +1,125 @@
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")
}
+3 -22
View File
@@ -7,8 +7,6 @@ import (
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
@@ -32,7 +30,6 @@ func BuildWingIndex(brainDir, wing string) error {
if w == "" {
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
// hall that is a symlink is not followed, and notes are regular files.
bfs, err := brainfs.Open(brainDir)
@@ -63,8 +60,9 @@ 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)
}
dest := filepath.Join(wingDir, "_index.md")
return os.WriteFile(dest, []byte(b.String()), 0o644)
// Written through the brain root, never through a symlinked _index.md
// (hyperguild#158).
return bfs.PutRegular("wiki/"+w+"/_index.md", []byte(b.String()))
}
// BuildAllWingIndexes regenerates _index.md for every wing under brain/wiki/.
@@ -116,23 +114,6 @@ func collectWingEntries(bfs *brainfs.Brain, wingRel string) ([]noteEntry, error)
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,
// regular files only (hyperguild#157).
func readTitleAndCreatedIn(bfs *brainfs.Brain, rel, slug string) (string, string) {
+6 -1
View File
@@ -47,7 +47,12 @@ var (
func StripUnresolvedLinks(brainDir, content string) (string, []string, error) {
index, err := entryIndex(brainDir)
if err != nil {
return "", nil, err
// A partial index cannot tell an invented link from one into the
// 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 {
return content, nil, nil
@@ -0,0 +1,41 @@
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")
}
+96 -60
View File
@@ -2,11 +2,15 @@ package brain
import (
"bufio"
"errors"
"fmt"
"os"
"io/fs"
"path"
"path/filepath"
"strings"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
)
// seeAlsoHeader is the markdown heading used to group cross-wing links.
@@ -36,24 +40,29 @@ type TunnelCandidate struct {
// A note's title is read from YAML frontmatter `title:`; failing that,
// the filename slug (sans `.md`, hyphens → spaces) is used.
func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
wikiDir := filepath.Join(brainDir, "wiki")
if _, err := os.Stat(wikiDir); os.IsNotExist(err) {
// Walked and read through the brain root, regular files only
// (hyperguild#174, #157): this runs inside every brain_write, so a FIFO
// 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
} else if err != nil {
return nil, fmt.Errorf("stat wiki: %w", err)
}
lowerContent := strings.ToLower(content)
var out []TunnelCandidate
err := filepath.WalkDir(wikiDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".md") || d.Name() == "_index.md" {
var walkErr error
err = bfs.WalkRegular("wiki", func(rel string) error {
name := path.Base(rel)
if !strings.HasSuffix(name, ".md") || name == "_index.md" {
return nil
}
title, _ := readTitleAndCreated(path, strings.TrimSuffix(d.Name(), ".md"))
title, _ := readTitleAndCreatedIn(bfs, rel, strings.TrimSuffix(name, ".md"))
title = strings.TrimSpace(title)
if title == "" {
return nil
@@ -75,17 +84,25 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
if idx == -1 {
return nil
}
rel, err := filepath.Rel(brainDir, path)
if err != nil {
return err
}
out = append(out, TunnelCandidate{
TargetPath: filepath.ToSlash(rel),
TargetPath: rel,
MatchedTerm: title,
Exact: isWholeWord(haystack, idx, len(needle)),
})
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 {
return nil, err
}
@@ -158,35 +175,23 @@ func logFuzzyCandidates(brainDir, sourcePath string, cs []TunnelCandidate) error
if len(cs) == 0 {
return nil
}
rawDir := filepath.Join(brainDir, "raw")
if err := os.MkdirAll(rawDir, 0o755); err != nil {
return err
}
stamp := time.Now().UTC().Format("2006-01-02")
path := filepath.Join(rawDir, "tunnel-candidates-"+stamp+".md")
existed := fileExists(path)
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
// Appended through the brain root, never through a symlinked raw/ or
// log file (hyperguild#158).
bfs, err := brainfs.Open(brainDir)
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
return fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
stamp := time.Now().UTC().Format("2006-01-02")
rel := "raw/tunnel-candidates-" + stamp + ".md"
var buf strings.Builder
if _, err := bfs.Lstat(rel); errors.Is(err, fs.ErrNotExist) {
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")
}
for _, c := range cs {
line := fmt.Sprintf("- `%s` ↔ `%s` (term: %q)\n", sourcePath, c.TargetPath, c.MatchedTerm)
if _, err := f.WriteString(line); err != nil {
return err
fmt.Fprintf(&buf, "- `%s` ↔ `%s` (term: %q)\n", sourcePath, c.TargetPath, c.MatchedTerm)
}
}
return nil
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
return bfs.AppendRegular(rel, []byte(buf.String()))
}
// WriteTunnel appends a bidirectional wikilink between sourcePath and
@@ -197,6 +202,12 @@ func fileExists(p string) bool {
// section headers. Rejects same-wing pairs (a tunnel is by definition
// cross-wing) and missing notes.
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)
if err != nil {
return fmt.Errorf("source: %w", err)
@@ -209,20 +220,50 @@ func WriteTunnel(brainDir, sourcePath, targetPath string) error {
return fmt.Errorf("tunnel must cross wings; got both in %q", srcWing)
}
srcFull := filepath.Join(brainDir, filepath.FromSlash(sourcePath))
tgtFull := filepath.Join(brainDir, filepath.FromSlash(targetPath))
if _, err := os.Stat(srcFull); err != nil {
// Both notes are read and rewritten through the brain root, regular
// files only: brain_tunnel passes caller-supplied paths, and the brain is
// a Syncthing folder that can hold symlinks.
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)
}
if _, err := os.Stat(tgtFull); err != nil {
tgtContent, err := bfs.ReadRegular(targetPath)
if err != nil {
return fmt.Errorf("target note: %w", err)
}
if err := appendSeeAlso(srcFull, wikilinkOf(targetPath)); err != nil {
if out, changed, err := withSeeAlso(srcContent, 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)
}
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)
} 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
}
@@ -248,16 +289,13 @@ func wingOf(relPath string) (string, error) {
return parts[1], nil
}
// appendSeeAlso inserts `- [[link]]` under the file's See also section,
// creating the section if absent. No-op when the link is already present.
func appendSeeAlso(filePath, link string) error {
content, err := os.ReadFile(filePath)
if err != nil {
return err
}
// withSeeAlso returns content with `- [[link]]` under its See also section,
// creating the section if absent. changed is false when the link is already
// present.
func withSeeAlso(content []byte, link string) (out []byte, changed bool, err error) {
wikilink := "[[" + link + "]]"
if strings.Contains(string(content), wikilink) {
return nil
return content, false, nil
}
bullet := "- " + wikilink
@@ -266,8 +304,7 @@ func appendSeeAlso(filePath, link string) error {
// No section yet — append a fresh one. Always emit a trailing
// newline so subsequent appends don't merge into the previous line.
trimmed := strings.TrimRight(string(content), "\n")
out := trimmed + "\n\n" + seeAlsoHeader + "\n\n" + bullet + "\n"
return os.WriteFile(filePath, []byte(out), 0o644)
return []byte(trimmed + "\n\n" + seeAlsoHeader + "\n\n" + bullet + "\n"), true, nil
}
// Section exists — splice the bullet in just before the next `## `
@@ -293,12 +330,11 @@ func appendSeeAlso(filePath, link string) error {
b.WriteByte('\n')
}
if err := scanner.Err(); err != nil {
return err
return nil, false, err
}
if !inserted {
// section was the last thing in the file — just append bullet
out := strings.TrimRight(b.String(), "\n") + "\n" + bullet + "\n"
return os.WriteFile(filePath, []byte(out), 0o644)
return []byte(strings.TrimRight(b.String(), "\n") + "\n" + bullet + "\n"), true, nil
}
return os.WriteFile(filePath, []byte(b.String()), 0o644)
return []byte(b.String()), true, nil
}
@@ -0,0 +1,38 @@
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")
}
@@ -0,0 +1,141 @@
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")
}
@@ -0,0 +1,52 @@
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/")
}
+226
View File
@@ -104,6 +104,17 @@ func (b *Brain) ReadDirRegular(relDir string) ([]string, error) {
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
// directory, by name. Every component of relDir must be a real directory
// (ErrNotRegular otherwise); symlinks to directories are left out.
@@ -125,9 +136,170 @@ func (b *Brain) ReadDirDirs(relDir string) ([]string, error) {
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
// regular file, both before the open and on the opened handle.
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)
if err != nil {
return nil, err
@@ -154,6 +326,12 @@ func (b *Brain) readOpened(rel string) ([]byte, error) { return b.readOpenedMax(
// ReadRegularMax is ReadRegular with a size limit, checked on the opened
// handle before reading (max < 0 means no limit).
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)
if err != nil {
return nil, err
@@ -249,3 +427,51 @@ func (b *Brain) WalkRegular(relDir string, fn func(rel string) error, onErr func
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()
}
+267 -1
View File
@@ -218,7 +218,7 @@ func TestReadDirRegularListsOnlyRegularFilesAndRefusesSymlinkedComponents(t *tes
// 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
// only comparing the two (os.SameFile) refuses it.
func TestReadRegularNeverReturnsAnInBrainFileSwappedInBehindASymlink(t *testing.T) {
func TestReadRegularDoesNotReturnAnInBrainFileSwappedInBehindASymlink(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "s.md"), []byte("zqsamefilesentinel"), 0o644))
p := filepath.Join(dir, "r.md")
@@ -263,6 +263,52 @@ func TestReadRegularNeverReturnsAnInBrainFileSwappedInBehindASymlink(t *testing.
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) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "a", "real"), 0o755))
@@ -280,3 +326,223 @@ func TestReadDirDirsListsOnlyRealSubdirsAndRefusesSymlinkedComponents(t *testing
_, err = b.ReadDirDirs("alias")
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,8 +1,10 @@
// Package brainfstest is the shared harness for hyperguild#157's swap-race
// tests: it keeps swapping a regular note, a symlink to an outside file, or a
// FIFO (with a 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
// reader's output, and the reader returns within a timeout. It does not cover
// tests: it keeps swapping a regular note, a symlink to a sentinel target file
// (outside the brain, or inside it for hyperguild#171), or a FIFO (with a
// writer held open) into a path while a reader under test loops.
// It observes two things, over a sampled window only: no sentinel byte from
// 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.
package brainfstest
@@ -18,7 +20,7 @@ import (
"time"
)
// Sentinel marks the outside file's contents.
// Sentinel marks the target file's contents.
const Sentinel = "zqbrainfssentinelonefiftyseven"
// outsideContent carries Sentinel in every field a reader might extract: the
@@ -141,7 +143,7 @@ func AssertNoLeakFrom(t testing.TB, path, inside, target string, d time.Duration
}
}
if leaks > 0 {
t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters)
t.Fatalf("symlink target %s leaked %d times in %d iterations", target, leaks, iters)
}
if n := swaps.Load(); n == 0 {
t.Fatalf("no symlink was swapped into %s in %d iterations: the race did not run", path, iters)
@@ -16,9 +16,10 @@
package classification
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"io/fs"
"strings"
"gopkg.in/yaml.v3"
@@ -110,9 +111,20 @@ type rawConfig struct {
func Load(brainDir string) (*Config, error) {
cfg := &Config{wings: map[string]Level{}, repos: map[string]Level{}}
data, err := os.ReadFile(filepath.Join(brainDir, "classification.yaml"))
// Read through the brain root, regular files only (hyperguild#157): a
// 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 os.IsNotExist(err) {
return nil, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
data, err := bfs.ReadRegular("classification.yaml")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return cfg, nil
}
return nil, fmt.Errorf("read classification.yaml: %w", err)
@@ -0,0 +1,46 @@
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,7 +88,6 @@ func TestNoPackageReferencesExtractTextOutsideExtract(t *testing.T) {
// osOpenAllowed names the functions in guarded files that may call os.Open*
// on a path that is not a raw/ read.
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
// it (TestNoPackageReferencesExtractTextOutsideExtract enforces that).
"internal/extract/extract.go:Text": true,
+23 -2
View File
@@ -2,6 +2,7 @@
package extract
import (
"errors"
"fmt"
"io"
"os"
@@ -50,6 +51,18 @@ func fileExt(path string) string {
// external tool (pdf, docmark) are copied to a temp file from the handle
// opened through the root, then extracted from the copy.
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)
if err != nil {
return "", fmt.Errorf("stat %s: %w", rel, err)
@@ -65,10 +78,13 @@ func TextIn(r *os.Root, rel string) (string, error) {
ext := strings.ToLower(fileExt(rel))
switch ext {
case ".md", ".txt":
b, err := io.ReadAll(f)
b, err := io.ReadAll(io.LimitReader(f, max+1))
if err != nil {
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
case ".pdf", ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg":
tmp, err := os.CreateTemp("", "ingest-*"+ext)
@@ -76,10 +92,15 @@ func TextIn(r *os.Root, rel string) (string, error) {
return "", fmt.Errorf("temp copy: %w", err)
}
defer func() { _ = os.Remove(tmp.Name()) }()
if _, err := io.Copy(tmp, f); err != nil {
n, err := io.Copy(tmp, io.LimitReader(f, max+1))
if err != nil {
_ = tmp.Close()
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 {
return "", fmt.Errorf("copy %s: %w", rel, err)
}
@@ -0,0 +1,62 @@
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)
}
+512
View File
@@ -0,0 +1,512 @@
// Package legacymove clears wiki/sources/ of the retired layout (hyperguild
// #209). It does the one thing that is safe to do mechanically: video and
// article source pages move to the default wing, and the auto-tunnel review
// logs that were once ingested as "sources" are deleted. Notes, projects and
// untyped pages stay where they are, and wiki/concepts and wiki/entities are
// not touched at all (they are the shared layer).
//
// Plan only reads. Apply copies every page it touches into
// archive/wing-migration-<date>/ first, so Rollback can restore the brain
// byte for byte. The archive is outside raw/, so the watcher never ingests it.
package legacymove
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"path"
"sort"
"strings"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
)
// Action is what the plan does with one page.
type Action string
const (
Move Action = "move"
Delete Action = "delete"
Skip Action = "skip"
)
// Item is one planned operation.
type Item struct {
From string
To string // Move only
Slug string
Action Action
Wing string // Move only
Reason string
// SHA is the SHA-256 (hex) of the bytes the plan was decided on. Apply
// re-reads the page and does nothing if the bytes differ: a page changed
// between planning and applying is not the page that was judged safe.
SHA string
}
// maxArchiveName is NAME_MAX. The archive key (the whole path, flattened) is
// longer than the page's own name, so a long name must be refused at planning
// time rather than wedge Apply halfway.
const maxArchiveName = 255
const legacyDir = "wiki/sources"
// Plan reads the retired source dir and decides. It writes nothing.
func Plan(brainDir, wing string) ([]Item, error) {
if wing == "" || brain.Sanitise(wing) != wing {
return nil, fmt.Errorf("wing %q is not a plain wing name", wing)
}
bfs, err := brainfs.Open(brainDir)
if err != nil {
return nil, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
names, err := bfs.ReadDirRegular(legacyDir)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read %s: %w", legacyDir, err)
}
var items []Item
for _, name := range names {
if !strings.HasSuffix(name, ".md") || name == "_index.md" {
continue
}
from := legacyDir + "/" + name
slug := strings.TrimSuffix(name, ".md")
it := Item{From: from, Slug: slug}
if len(archiveKey(from)) > maxArchiveName {
it.Action, it.Reason = Skip, "name too long to archive; left in place"
items = append(items, it)
continue
}
content, err := bfs.ReadRegular(from)
if err != nil {
it.Action, it.Reason = Skip, fmt.Sprintf("unreadable: %v", err)
items = append(items, it)
continue
}
it.SHA = sum(content)
if strings.HasPrefix(name, "tunnel-candidates-") {
it.Action, it.Reason = Delete, "auto-tunnel review log ingested as a source; not a source"
items = append(items, it)
continue
}
if has := topLevelKeys(string(content)); has["wing"] || has["hall"] {
it.Action, it.Reason = Skip, "already has wing/hall in its frontmatter; left in place"
items = append(items, it)
continue
}
switch typ := frontmatterValue(string(content), "type"); typ {
case "video", "article":
to := "wiki/" + wing + "/sources/" + name
it.Action, it.To, it.Wing = Move, to, wing
existing, ferr := wiki.FindSlugs(brainDir, slug)
if ferr != nil {
it.Action, it.To, it.Wing, it.Reason = Skip, "", "", fmt.Sprintf("cannot check slug: %v", ferr)
}
for _, p := range existing {
if p != from {
it.Action, it.To, it.Wing, it.Reason = Skip, "", "", fmt.Sprintf("slug %q already used by %s", slug, p)
break
}
}
default:
it.Action, it.Reason = Skip, fmt.Sprintf("type %q: left in place", typ)
}
items = append(items, it)
}
sort.SliceStable(items, func(i, j int) bool { return items[i].From < items[j].From })
return items, nil
}
// Result reports what Apply did and where the archive is (brain-relative).
type Result struct {
Moved, Deleted int
ArchiveDir string
// Changed lists planned pages that were NOT touched because their bytes no
// longer match what the plan was decided on.
Changed []string
}
type manifest struct {
Moves []moveRec `json:"moves"`
Deletes []string `json:"deletes"`
}
type moveRec struct {
From string `json:"from"`
To string `json:"to"`
Wing string `json:"wing"`
}
// Apply performs the Move and Delete items, one at a time, stopping at the
// first failure. For each page it re-reads the bytes and compares them with the
// plan's hash: a page that changed since planning is left alone and reported in
// Result.Changed. Each original is archived (O_EXCL, into an archive directory
// no earlier run has used) before anything changes; a move writes its
// destination O_EXCL before the source is removed, so a failure never loses a
// page. The manifest is rewritten after every step so a partial run can still
// be rolled back. A swap between the read and the remove is a residual window
// that only a locking watcher could close.
func Apply(brainDir string, items []Item, now time.Time) (Result, error) {
bfs, err := brainfs.Open(brainDir)
if err != nil {
return Result{}, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
res := Result{ArchiveDir: freshArchiveDir(bfs, "archive/wing-migration-"+now.UTC().Format("2006-01-02"))}
var man manifest
save := func() error {
b, err := json.MarshalIndent(man, "", " ")
if err != nil {
return err
}
return bfs.PutRegular(res.ArchiveDir+"/manifest.json", b)
}
for _, it := range items {
if it.Action != Move && it.Action != Delete {
continue
}
content, err := bfs.ReadRegular(it.From)
if err != nil {
return res, fmt.Errorf("read %s: %w", it.From, err)
}
if it.SHA == "" || sum(content) != it.SHA {
res.Changed = append(res.Changed, it.From)
continue
}
if err := bfs.CreateRegular(res.ArchiveDir+"/originals/"+archiveKey(it.From), content); err != nil {
return res, fmt.Errorf("archive %s: %w", it.From, err)
}
switch it.Action {
case Move:
if err := bfs.CreateRegular(it.To, []byte(stamp(string(content), it.Wing))); err != nil {
return res, fmt.Errorf("write %s: %w", it.To, err)
}
man.Moves = append(man.Moves, moveRec{From: it.From, To: it.To, Wing: it.Wing})
res.Moved++
case Delete:
man.Deletes = append(man.Deletes, it.From)
res.Deleted++
}
if err := save(); err != nil {
return res, fmt.Errorf("write manifest: %w", err)
}
if err := bfs.Remove(it.From); err != nil {
return res, fmt.Errorf("handled %s but removing it failed: %w", it.From, err)
}
}
for w := range wingsOf(items) {
_ = brain.BuildWingIndex(brainDir, w) // best-effort upkeep; the pages are already moved
}
return res, nil
}
// freshArchiveDir returns base, or base-2, base-3, ... : the first that does not
// exist, so a second run never reuses (and never overwrites) an earlier archive.
func freshArchiveDir(bfs *brainfs.Brain, base string) string {
dir := base
for n := 2; ; n++ {
if _, err := bfs.Lstat(dir); err != nil {
return dir
}
dir = fmt.Sprintf("%s-%d", base, n)
}
}
func sum(b []byte) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func wingsOf(items []Item) map[string]bool {
out := map[string]bool{}
for _, it := range items {
if it.Action == Move {
out[it.Wing] = true
}
}
return out
}
// Rollback restores every page named in the archive's manifest from its
// archived original and removes the moved copies. It checks everything first
// and changes nothing if anything is in the way: a restore target that exists
// with different bytes, or a moved copy that was edited since the move (which
// is never deleted). Pages already restored are recognised by their bytes, so a
// rollback can be run again. mtime and mode of the restored pages are new.
func Rollback(brainDir, archiveDir string) error {
bfs, err := brainfs.Open(brainDir)
if err != nil {
return fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
raw, err := bfs.ReadRegular(archiveDir + "/manifest.json")
if err != nil {
return fmt.Errorf("read manifest: %w", err)
}
var man manifest
if err := json.Unmarshal(raw, &man); err != nil {
return fmt.Errorf("parse manifest: %w", err)
}
original := func(from string) ([]byte, error) {
orig, err := bfs.ReadRegular(archiveDir + "/originals/" + archiveKey(from))
if err != nil {
return nil, fmt.Errorf("read archived %s: %w", from, err)
}
return orig, nil
}
type step struct {
from string
orig []byte
restore bool // From is missing and must be written
remove string // moved copy to delete, if any
}
var steps []step
var problems []string
plan := func(from string, orig []byte, moved string, expectMoved []byte) {
st := step{from: from, orig: orig}
cur, err := bfs.ReadRegular(from)
switch {
case err == nil && bytes.Equal(cur, orig):
// already restored
case err == nil:
problems = append(problems, fmt.Sprintf("%s exists with different content", from))
case errors.Is(err, fs.ErrNotExist):
st.restore = true
default:
problems = append(problems, fmt.Sprintf("%s: %v", from, err))
}
if moved != "" {
cur, err := bfs.ReadRegular(moved)
switch {
case err == nil && bytes.Equal(cur, expectMoved):
st.remove = moved
case err == nil:
problems = append(problems, fmt.Sprintf("%s was edited since the move; not deleting it", moved))
case errors.Is(err, fs.ErrNotExist):
default:
problems = append(problems, fmt.Sprintf("%s: %v", moved, err))
}
}
steps = append(steps, st)
}
for _, m := range man.Moves {
orig, err := original(m.From)
if err != nil {
return err
}
plan(m.From, orig, m.To, []byte(stamp(string(orig), m.Wing)))
}
for _, d := range man.Deletes {
orig, err := original(d)
if err != nil {
return err
}
plan(d, orig, "", nil)
}
if len(problems) > 0 {
return fmt.Errorf("rollback refused, nothing changed: %s", strings.Join(problems, "; "))
}
for _, st := range steps {
if st.restore {
if err := bfs.CreateRegular(st.from, st.orig); err != nil {
return fmt.Errorf("restore %s: %w", st.from, err)
}
}
if st.remove != "" {
if err := bfs.Remove(st.remove); err != nil {
return fmt.Errorf("remove moved copy %s: %w", st.remove, err)
}
}
}
return nil
}
// archiveKey flattens a brain path into one file name, so two pages with the
// same basename in different places (raw/x.md and wiki/sources/x.md) never
// overwrite each other in the archive.
func archiveKey(p string) string { return strings.ReplaceAll(p, "/", "__") }
// stamp inserts wing/hall after the opening fence, leaving every other byte
// alone. A page without frontmatter gets a block.
func stamp(content, wing string) string {
extra := "wing: " + wing + "\nhall: sources\n"
if strings.HasPrefix(content, "---\n") {
return "---\n" + extra + content[4:]
}
return "---\n" + extra + "---\n" + content
}
// frontmatterBlock returns the text between the opening fence and a real
// closing fence (a line that is exactly "---"), or "" when there is none.
func frontmatterBlock(content string) (string, bool) {
if !strings.HasPrefix(content, "---\n") {
return "", false
}
rest := content[4:]
if i := strings.Index(rest, "\n---\n"); i >= 0 {
return rest[:i], true
}
if strings.HasSuffix(rest, "\n---") {
return rest[:len(rest)-4], true
}
return "", false
}
// topLevelKeys is the set of unindented `key:` names in the frontmatter block.
func topLevelKeys(content string) map[string]bool {
out := map[string]bool{}
block, ok := frontmatterBlock(content)
if !ok {
return out
}
for _, line := range strings.Split(block, "\n") {
if line == "" || line[0] == ' ' || line[0] == '\t' {
continue
}
if k, _, found := strings.Cut(line, ":"); found {
out[strings.TrimSpace(k)] = true
}
}
return out
}
// frontmatterValue reads a top-level key from a CLOSED frontmatter block. A
// nested or block-scalar line that merely looks like `type: video` does not
// count, and neither does an unterminated block.
func frontmatterValue(content, key string) string {
block, ok := frontmatterBlock(content)
if !ok {
return ""
}
for _, line := range strings.Split(block, "\n") {
if line == "" || line[0] == ' ' || line[0] == '\t' {
continue
}
if k, v, found := strings.Cut(line, ":"); found && strings.TrimSpace(k) == key {
return strings.Trim(strings.TrimSpace(v), `"'`)
}
}
return ""
}
const rawDir = "raw"
// PlanRaw finds what the watcher left in raw/ before inbox zero: originals that
// were archived but never removed, with their .processed markers, and markers
// whose note is gone. An original is deleted only when a byte-identical copy
// exists under raw/processed/ (so nothing is lost), and only when it has a
// marker (an unmarked note is still in the inbox). tunnel-candidates-* are
// AutoTunnel's own review queue and are never touched. Every Delete carries the
// SHA-256 it was decided on, which Apply re-checks.
func PlanRaw(brainDir string) ([]Item, error) {
bfs, err := brainfs.Open(brainDir)
if err != nil {
return nil, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
names, err := bfs.ReadDirRegular(rawDir)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read %s: %w", rawDir, err)
}
present := map[string]bool{}
for _, n := range names {
present[n] = true
}
archived, err := archivedCopies(bfs)
if err != nil {
return nil, err
}
del := func(name, why string) Item {
it := Item{From: rawDir + "/" + name, Slug: name, Action: Delete, Reason: why}
b, err := bfs.ReadRegular(it.From)
if err != nil {
return Item{From: it.From, Slug: name, Action: Skip, Reason: fmt.Sprintf("unreadable: %v", err)}
}
it.SHA = sum(b)
return it
}
var items []Item
for _, n := range names {
if strings.HasPrefix(n, "tunnel-candidates-") {
continue
}
switch {
case strings.HasSuffix(n, ".processed"):
note := strings.TrimSuffix(n, ".processed")
if !present[note] {
items = append(items, tooLongOr(rawDir+"/"+n, func() Item { return del(n, "marker without a note") }))
}
case present[n+".processed"]:
content, rerr := bfs.ReadRegular(rawDir + "/" + n)
if rerr != nil {
items = append(items, Item{From: rawDir + "/" + n, Slug: n, Action: Skip, Reason: fmt.Sprintf("unreadable: %v", rerr)})
continue
}
if !archived[sha256.Sum256(content)] {
items = append(items, Item{From: rawDir + "/" + n, Slug: n, Action: Skip, Reason: "marked processed but no identical copy under raw/processed/; kept"})
continue
}
// Both or neither: the archive keys of the note and its (longer)
// marker must fit, or a run would delete the note and wedge on the marker.
if len(archiveKey(rawDir+"/"+n+".processed")) > maxArchiveName {
items = append(items, Item{From: rawDir + "/" + n, Slug: n, Action: Skip, Reason: "name too long to archive; kept"})
continue
}
items = append(items, del(n, "archived copy exists under raw/processed/"), del(n+".processed", "marker of an archived note"))
}
}
sort.SliceStable(items, func(i, j int) bool { return items[i].From < items[j].From })
return items, nil
}
// tooLongOr returns a Skip when the page's archive key cannot fit in a file
// name, else whatever mk builds.
func tooLongOr(from string, mk func() Item) Item {
if len(archiveKey(from)) > maxArchiveName {
return Item{From: from, Slug: path.Base(from), Action: Skip, Reason: "name too long to archive; kept"}
}
return mk()
}
// archivedCopies returns the SHA-256 of every file under raw/processed/, as a
// set, so "is there an identical copy" is one lookup.
func archivedCopies(bfs *brainfs.Brain) (map[[32]byte]bool, error) {
out := map[[32]byte]bool{}
days, err := bfs.ReadDirDirs(rawDir + "/processed")
if errors.Is(err, fs.ErrNotExist) {
return out, nil
}
if err != nil {
return nil, fmt.Errorf("list %s/processed: %w", rawDir, err)
}
for _, d := range days {
files, err := bfs.ReadDirRegular(rawDir + "/processed/" + d)
if err != nil {
continue
}
for _, f := range files {
b, err := bfs.ReadRegular(rawDir + "/processed/" + d + "/" + f)
if err != nil {
continue
}
out[sha256.Sum256(b)] = true
}
}
return out, nil
}
@@ -0,0 +1,445 @@
package legacymove_test
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/legacymove"
)
func page(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))
}
func src(typ string) string {
return "---\ntitle: T\ntype: " + typ + "\n---\n\n## Summary\n\nBody with [[a-link|A]] kept as is.\n"
}
func byFrom(items []legacymove.Item) map[string]legacymove.Item {
m := map[string]legacymove.Item{}
for _, it := range items {
m[it.From] = it
}
return m
}
func TestPlan_VideosAndArticlesMoveToTheDefaultWing(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-talk.md", src("video"))
page(t, dir, "wiki/sources/an-article.md", src("article"))
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
m := byFrom(items)
assert.Equal(t, legacymove.Move, m["wiki/sources/a-talk.md"].Action)
assert.Equal(t, "wiki/web-clip/sources/a-talk.md", m["wiki/sources/a-talk.md"].To)
assert.Equal(t, legacymove.Move, m["wiki/sources/an-article.md"].Action)
}
func TestPlan_NotesProjectsAndUntypedPagesStayWhereTheyAre(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-note.md", src("note"))
page(t, dir, "wiki/sources/a-project.md", src("project"))
page(t, dir, "wiki/sources/untyped.md", "---\ntitle: T\n---\nbody")
page(t, dir, "wiki/sources/_index.md", "index")
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
m := byFrom(items)
for _, f := range []string{"a-note", "a-project", "untyped"} {
assert.Equal(t, legacymove.Skip, m["wiki/sources/"+f+".md"].Action, f)
}
_, listed := m["wiki/sources/_index.md"]
assert.False(t, listed, "the index is not a page")
}
func TestPlan_AutoTunnelLogsAreDeleted(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", src("video"))
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
assert.Equal(t, legacymove.Delete, byFrom(items)["wiki/sources/tunnel-candidates-2026-06-14.md"].Action,
"never moved, whatever the type says")
}
func TestPlan_ASlugAlreadyUsedElsewhereIsSkippedNotOverwritten(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-talk.md", src("video"))
page(t, dir, "wiki/entities/a-talk.md", "an entity with the same slug")
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
it := byFrom(items)["wiki/sources/a-talk.md"]
assert.Equal(t, legacymove.Skip, it.Action)
assert.Contains(t, it.Reason, "already used")
}
func TestPlan_ADefaultWingThatIsNotAPlainNameIsRefused(t *testing.T) {
_, err := legacymove.Plan(t.TempDir(), "../x")
assert.Error(t, err)
}
func TestApply_MovesStampsAndArchivesEverythingItTouches(t *testing.T) {
dir := t.TempDir()
talk := src("video")
junk := "tunnel log"
page(t, dir, "wiki/sources/a-talk.md", talk)
page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", junk)
page(t, dir, "wiki/sources/a-note.md", src("note"))
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
res, err := legacymove.Apply(dir, items, time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC))
require.NoError(t, err)
assert.Equal(t, 1, res.Moved)
assert.Equal(t, 1, res.Deleted)
moved, err := os.ReadFile(filepath.Join(dir, "wiki/web-clip/sources/a-talk.md"))
require.NoError(t, err)
assert.Contains(t, string(moved), "wing: web-clip")
assert.Contains(t, string(moved), "hall: sources")
assert.Contains(t, string(moved), "Body with [[a-link|A]] kept as is.", "body and links are byte-preserved")
for _, gone := range []string{"wiki/sources/a-talk.md", "wiki/sources/tunnel-candidates-2026-06-14.md"} {
_, statErr := os.Stat(filepath.Join(dir, gone))
assert.ErrorIs(t, statErr, os.ErrNotExist, gone)
}
_, statErr := os.Stat(filepath.Join(dir, "wiki/sources/a-note.md"))
assert.NoError(t, statErr, "a skipped page is left exactly where it is")
again, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
for _, it := range again {
assert.Equal(t, legacymove.Skip, it.Action, "nothing is left to do: %s", it.From)
}
}
func TestRollback_RestoresEveryTouchedPageByteForByte(t *testing.T) {
dir := t.TempDir()
talk := src("video")
junk := "tunnel log"
page(t, dir, "wiki/sources/a-talk.md", talk)
page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", junk)
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
res, err := legacymove.Apply(dir, items, time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC))
require.NoError(t, err)
require.NoError(t, legacymove.Rollback(dir, res.ArchiveDir))
got, err := os.ReadFile(filepath.Join(dir, "wiki/sources/a-talk.md"))
require.NoError(t, err)
assert.Equal(t, talk, string(got))
got, err = os.ReadFile(filepath.Join(dir, "wiki/sources/tunnel-candidates-2026-06-14.md"))
require.NoError(t, err)
assert.Equal(t, junk, string(got))
_, statErr := os.Stat(filepath.Join(dir, "wiki/web-clip/sources/a-talk.md"))
assert.ErrorIs(t, statErr, os.ErrNotExist, "the moved copy is removed")
}
func TestApply_NeverOverwritesAndKeepsTheSourceIfTheDestinationAppearsLate(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-talk.md", src("video"))
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
page(t, dir, "wiki/web-clip/sources/a-talk.md", "appeared after the plan")
_, err = legacymove.Apply(dir, items, time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC))
require.Error(t, err)
got, _ := os.ReadFile(filepath.Join(dir, "wiki/web-clip/sources/a-talk.md"))
assert.Equal(t, "appeared after the plan", string(got))
_, statErr := os.Stat(filepath.Join(dir, "wiki/sources/a-talk.md"))
assert.NoError(t, statErr, "the source survives")
}
func TestApply_TheArchiveIsOutsideRawSoTheWatcherNeverIngestsIt(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", "x")
items, _ := legacymove.Plan(dir, "web-clip")
res, err := legacymove.Apply(dir, items, time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC))
require.NoError(t, err)
assert.False(t, strings.Contains(res.ArchiveDir, "/raw"), res.ArchiveDir)
assert.True(t, strings.HasPrefix(res.ArchiveDir, "archive/"), res.ArchiveDir)
}
// --- raw/ leftovers from before inbox zero ---
func rawFile(t *testing.T, dir, rel, body string) { page(t, dir, rel, body) }
func TestPlanRaw_AnArchivedOriginalAndItsMarkerAreDeleted(t *testing.T) {
dir := t.TempDir()
rawFile(t, dir, "raw/clip.md", "the clip text")
rawFile(t, dir, "raw/clip.md.processed", "2026-10-19")
rawFile(t, dir, "raw/processed/2026-10-19/clip.md", "the clip text")
items, err := legacymove.PlanRaw(dir)
require.NoError(t, err)
m := byFrom(items)
assert.Equal(t, legacymove.Delete, m["raw/clip.md"].Action)
assert.Equal(t, legacymove.Delete, m["raw/clip.md.processed"].Action)
}
func TestPlanRaw_AnOriginalWhoseArchiveCopyDiffersIsLeftAlone(t *testing.T) {
dir := t.TempDir()
rawFile(t, dir, "raw/clip.md", "the clip text, edited since")
rawFile(t, dir, "raw/clip.md.processed", "2026-10-19")
rawFile(t, dir, "raw/processed/2026-10-19/clip.md", "the clip text")
items, err := legacymove.PlanRaw(dir)
require.NoError(t, err)
m := byFrom(items)
assert.Equal(t, legacymove.Skip, m["raw/clip.md"].Action, "no identical archive copy: not ours to delete")
assert.Contains(t, m["raw/clip.md"].Reason, "no identical copy")
_, hasMarker := m["raw/clip.md.processed"]
assert.False(t, hasMarker, "the marker of a note we keep is kept")
}
func TestPlanRaw_ACopyUnderATimestampedNameCounts(t *testing.T) {
dir := t.TempDir()
rawFile(t, dir, "raw/clip.md", "same text")
rawFile(t, dir, "raw/clip.md.processed", "d")
rawFile(t, dir, "raw/processed/2026-10-19/clip-180200.md", "same text")
rawFile(t, dir, "raw/processed/2026-10-19/clip-2.md", "other text")
items, err := legacymove.PlanRaw(dir)
require.NoError(t, err)
assert.Equal(t, legacymove.Delete, byFrom(items)["raw/clip.md"].Action)
}
func TestPlanRaw_AnOrphanMarkerIsDeleted(t *testing.T) {
dir := t.TempDir()
rawFile(t, dir, "raw/gone.md.processed", "2026-06-16")
items, err := legacymove.PlanRaw(dir)
require.NoError(t, err)
assert.Equal(t, legacymove.Delete, byFrom(items)["raw/gone.md.processed"].Action)
}
func TestPlanRaw_TheAutoTunnelReviewQueueAndANoteWithoutAMarkerAreNotTouched(t *testing.T) {
dir := t.TempDir()
rawFile(t, dir, "raw/tunnel-candidates-2026-06-14.md", "AutoTunnel's own queue")
rawFile(t, dir, "raw/tunnel-candidates-2026-06-14.md.processed", "x")
rawFile(t, dir, "raw/fresh.md", "an unprocessed clip")
rawFile(t, dir, "raw/processed/2026-10-19/fresh.md", "an unprocessed clip") // same bytes, but no marker: still in the inbox
items, err := legacymove.PlanRaw(dir)
require.NoError(t, err)
m := byFrom(items)
for _, f := range []string{"raw/tunnel-candidates-2026-06-14.md", "raw/tunnel-candidates-2026-06-14.md.processed", "raw/fresh.md"} {
_, listed := m[f]
assert.False(t, listed, "%s is not a leftover", f)
}
}
func TestApplyAndRollback_HandleRawLeftoversAndNeverCollideWithSourcesOfTheSameName(t *testing.T) {
dir := t.TempDir()
rawFile(t, dir, "raw/same.md", "raw text")
rawFile(t, dir, "raw/same.md.processed", "d")
rawFile(t, dir, "raw/processed/2026-10-19/same.md", "raw text")
page(t, dir, "wiki/sources/same.md", src("video")) // a wiki source with the same basename
rawItems, err := legacymove.PlanRaw(dir)
require.NoError(t, err)
srcItems, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
res, err := legacymove.Apply(dir, append(rawItems, srcItems...), time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC))
require.NoError(t, err)
for _, gone := range []string{"raw/same.md", "raw/same.md.processed", "wiki/sources/same.md"} {
_, statErr := os.Stat(filepath.Join(dir, gone))
assert.ErrorIs(t, statErr, os.ErrNotExist, gone)
}
_, statErr := os.Stat(filepath.Join(dir, "raw/processed/2026-10-19/same.md"))
assert.NoError(t, statErr, "the archive copy in raw/processed is never touched")
require.NoError(t, legacymove.Rollback(dir, res.ArchiveDir))
got, err := os.ReadFile(filepath.Join(dir, "raw/same.md"))
require.NoError(t, err)
assert.Equal(t, "raw text", string(got), "the raw note and the wiki source of one name did not overwrite each other in the archive")
got, err = os.ReadFile(filepath.Join(dir, "wiki/sources/same.md"))
require.NoError(t, err)
assert.Equal(t, src("video"), string(got))
}
// --- review of #213 ---
var day = time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC)
// F1: a second Apply on the same day must not touch the first run's archive.
func TestApply_ASecondRunTheSameDayGetsItsOwnArchiveAndKeepsTheFirst(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-talk.md", src("video"))
page(t, dir, "wiki/sources/tunnel-candidates-1.md", "FIRST tunnel bytes")
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
first, err := legacymove.Apply(dir, items, day)
require.NoError(t, err)
page(t, dir, "wiki/sources/tunnel-candidates-1.md", "SECOND tunnel bytes") // same name reappears
items2, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
second, err := legacymove.Apply(dir, items2, day)
require.NoError(t, err)
assert.NotEqual(t, first.ArchiveDir, second.ArchiveDir)
got, err := os.ReadFile(filepath.Join(dir, first.ArchiveDir, "originals", "wiki__sources__tunnel-candidates-1.md"))
require.NoError(t, err)
assert.Equal(t, "FIRST tunnel bytes", string(got), "the first run's archived original is intact")
require.NoError(t, legacymove.Rollback(dir, first.ArchiveDir))
_, statErr := os.Stat(filepath.Join(dir, "wiki/sources/a-talk.md"))
assert.NoError(t, statErr, "the first run's move is still rolled back by its own manifest")
}
// F2: Plan decided on the bytes it saw. Apply must not delete different bytes.
func TestApply_ARawNoteThatChangedSincePlanningIsNotDeleted(t *testing.T) {
dir := t.TempDir()
rawFile(t, dir, "raw/a.md", "bytes that have an archive copy")
rawFile(t, dir, "raw/a.md.processed", "d")
rawFile(t, dir, "raw/processed/2026-10-19/a.md", "bytes that have an archive copy")
items, err := legacymove.PlanRaw(dir)
require.NoError(t, err)
rawFile(t, dir, "raw/a.md", "NEW UNPROCESSED BYTES") // dropped again before Apply runs
res, err := legacymove.Apply(dir, items, day)
require.NoError(t, err)
got, err := os.ReadFile(filepath.Join(dir, "raw/a.md"))
require.NoError(t, err, "the changed note is still in the inbox")
assert.Equal(t, "NEW UNPROCESSED BYTES", string(got))
assert.Contains(t, strings.Join(res.Changed, ","), "raw/a.md")
}
func TestApply_AWikiSourceThatChangedSincePlanningIsNotMoved(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-talk.md", src("video"))
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
page(t, dir, "wiki/sources/a-talk.md", src("video")+"\nan edit made after the plan\n")
res, err := legacymove.Apply(dir, items, day)
require.NoError(t, err)
assert.Zero(t, res.Moved)
got, _ := os.ReadFile(filepath.Join(dir, "wiki/sources/a-talk.md"))
assert.Contains(t, string(got), "an edit made after the plan")
assert.NotEmpty(t, res.Changed)
}
// F3: rollback checks everything first, changes nothing on a conflict, can be
// repeated, and never throws away an edit.
func TestRollback_RefusesBeforeChangingAnythingWhenATargetExists(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-talk.md", src("video"))
page(t, dir, "wiki/sources/b-talk.md", src("video"))
items, _ := legacymove.Plan(dir, "web-clip")
res, err := legacymove.Apply(dir, items, day)
require.NoError(t, err)
page(t, dir, "wiki/sources/b-talk.md", "someone put a different b here")
err = legacymove.Rollback(dir, res.ArchiveDir)
require.Error(t, err)
_, statErr := os.Stat(filepath.Join(dir, "wiki/sources/a-talk.md"))
assert.ErrorIs(t, statErr, os.ErrNotExist, "nothing was restored: a was not touched either")
_, statErr = os.Stat(filepath.Join(dir, "wiki/web-clip/sources/a-talk.md"))
assert.NoError(t, statErr, "and the moved copies are still there")
}
func TestRollback_CanBeRunTwice(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-talk.md", src("video"))
items, _ := legacymove.Plan(dir, "web-clip")
res, err := legacymove.Apply(dir, items, day)
require.NoError(t, err)
require.NoError(t, legacymove.Rollback(dir, res.ArchiveDir))
require.NoError(t, legacymove.Rollback(dir, res.ArchiveDir), "a repeat finds everything already restored")
got, _ := os.ReadFile(filepath.Join(dir, "wiki/sources/a-talk.md"))
assert.Equal(t, src("video"), string(got))
}
func TestRollback_DoesNotDeleteAMovedPageThatWasEditedSinceTheMove(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/a-talk.md", src("video"))
items, _ := legacymove.Plan(dir, "web-clip")
res, err := legacymove.Apply(dir, items, day)
require.NoError(t, err)
moved := filepath.Join(dir, "wiki/web-clip/sources/a-talk.md")
b, _ := os.ReadFile(moved)
require.NoError(t, os.WriteFile(moved, append(b, []byte("\nmy edit\n")...), 0o644))
err = legacymove.Rollback(dir, res.ArchiveDir)
require.Error(t, err)
got, readErr := os.ReadFile(moved)
require.NoError(t, readErr)
assert.Contains(t, string(got), "my edit", "the edit survives")
_, statErr := os.Stat(filepath.Join(dir, "wiki/sources/a-talk.md"))
assert.ErrorIs(t, statErr, os.ErrNotExist, "nothing was changed")
}
// F4: a name whose archive key cannot fit must be skipped at planning time.
func TestPlanRaw_ANameTooLongToArchiveIsSkippedNotAbortedOn(t *testing.T) {
dir := t.TempDir()
long := strings.Repeat("a", 240) + ".md"
rawFile(t, dir, "raw/"+long, "text")
rawFile(t, dir, "raw/"+long+".processed", "d")
rawFile(t, dir, "raw/processed/2026-10-19/"+long, "text")
rawFile(t, dir, "raw/ok.md", "fine")
rawFile(t, dir, "raw/ok.md.processed", "d")
rawFile(t, dir, "raw/processed/2026-10-19/ok.md", "fine")
items, err := legacymove.PlanRaw(dir)
require.NoError(t, err)
m := byFrom(items)
assert.Equal(t, legacymove.Skip, m["raw/"+long].Action)
assert.Contains(t, m["raw/"+long].Reason, "too long")
assert.Equal(t, legacymove.Delete, m["raw/ok.md"].Action)
_, err = legacymove.Apply(dir, items, day)
require.NoError(t, err, "the long name does not wedge the run")
_, statErr := os.Stat(filepath.Join(dir, "raw/"+long))
assert.NoError(t, statErr, "and it is left where it is")
}
// F6: do not stamp a page that already carries a wing or hall; only a top-level
// type inside a closed frontmatter block decides.
func TestPlan_AMovedPageIsNeverGivenDuplicateWingKeys(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/has-wing.md", "---\ntitle: T\ntype: video\nwing: old\nhall: facts\n---\nbody")
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
it := byFrom(items)["wiki/sources/has-wing.md"]
assert.Equal(t, legacymove.Skip, it.Action)
assert.Contains(t, it.Reason, "already has")
}
func TestPlan_OnlyATopLevelTypeInAClosedBlockClassifiesAPage(t *testing.T) {
dir := t.TempDir()
page(t, dir, "wiki/sources/nested.md", "---\ntitle: T\nmeta:\n type: video\n---\nbody")
page(t, dir, "wiki/sources/blockscalar.md", "---\ntitle: T\ndesc: |\n type: article\n---\nbody")
page(t, dir, "wiki/sources/unterminated.md", "---\ntitle: T\ntype: video\nbody with no closing fence")
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
m := byFrom(items)
for _, f := range []string{"nested", "blockscalar", "unterminated"} {
assert.Equal(t, legacymove.Skip, m["wiki/sources/"+f+".md"].Action, f)
}
}
func TestPlan_AWikiSourceWithATooLongNameIsSkipped(t *testing.T) {
dir := t.TempDir()
long := strings.Repeat("a", 240) + ".md"
page(t, dir, "wiki/sources/"+long, src("video"))
items, err := legacymove.Plan(dir, "web-clip")
require.NoError(t, err)
it := byFrom(items)["wiki/sources/"+long]
assert.Equal(t, legacymove.Skip, it.Action)
assert.Contains(t, it.Reason, "too long")
}
+6 -2
View File
@@ -94,6 +94,7 @@ func (s *Server) tools() []map[string]any {
"wing": str("target wing, e.g. jepa-fx"),
"hall": enum("target hall", halls...),
"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"),
}),
},
{
@@ -102,6 +103,7 @@ func (s *Server) tools() []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"),
"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],
@@ -383,6 +385,7 @@ type brainPromoteArgs struct {
Wing string `json:"wing"`
Hall string `json:"hall"`
Slug string `json:"slug,omitempty"`
Queue string `json:"queue,omitempty"`
}
// brainPromote moves a raw/ note into the structured wiki (frontmatter
@@ -395,7 +398,7 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
}
origin, writtenBy := provenanceFrom(ctx)
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug,
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug, Queue: a.Queue,
Origin: origin, WrittenBy: writtenBy,
})
if err != nil {
@@ -408,6 +411,7 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
type brainRejectArgs struct {
Filename string `json:"filename"`
Reason string `json:"reason,omitempty"`
Queue string `json:"queue,omitempty"`
}
// brainReject records a decision not to promote a raw/ note (api.RejectNote:
@@ -418,7 +422,7 @@ func (s *Server) brainReject(_ context.Context, args json.RawMessage) (json.RawM
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason})
relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason, Queue: a.Queue})
if err != nil {
return nil, err
}
+25
View File
@@ -431,3 +431,28 @@ func TestBrainRejectMissingSourceErrors(t *testing.T) {
resp := toolCall(t, srv, "brain_reject", map[string]any{"filename": "ghost.md"})
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,6 +21,11 @@ func TestReadExcerptRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
require.NoError(t, os.MkdirAll(facts, 0o755))
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)
// 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")
require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644))
@@ -28,6 +33,9 @@ func TestReadExcerptRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, func() string {
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() {
_ = readExcerpt(brain, "wiki/w/facts/r.md", 500)
})
+4 -3
View File
@@ -83,8 +83,9 @@ func (r *Registry) Observe(path, status string, d time.Duration) {
// Middleware wraps next, observing every request's duration + status.
// The metric label `path` uses the request's Pattern (Go 1.22+ ServeMux),
// falling back to the URL path if no Pattern is set. Pattern keeps
// cardinality bounded (one series per route, not one per unique URL).
// or the fixed label "unmatched" if no Pattern is set: falling back to the
// raw URL path gave any caller one series per unique path (hyperguild#185).
// Pattern keeps cardinality bounded (one series per route).
func (r *Registry) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
rec := &statusRecorder{ResponseWriter: w, code: http.StatusOK}
@@ -92,7 +93,7 @@ func (r *Registry) Middleware(next http.Handler) http.Handler {
next.ServeHTTP(rec, req)
path := req.Pattern
if path == "" {
path = req.URL.Path
path = "unmatched"
}
r.Observe(path, statusClass(rec.code), time.Since(start))
})
+23 -2
View File
@@ -71,11 +71,16 @@ func TestMiddleware_RecordsTiming(t *testing.T) {
t.Parallel()
r := New()
handler := r.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// Routed through a ServeMux, as in production: the mux sets the
// request's Pattern, which is the label (an unrouted request is
// "unmatched", hyperguild#185).
mux := http.NewServeMux()
mux.HandleFunc("/query", func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(2 * time.Millisecond)
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok")
}))
})
handler := r.Middleware(mux)
srv := httptest.NewServer(handler)
defer srv.Close()
@@ -117,3 +122,19 @@ func TestStatusRecorder_DefaultsTo200(t *testing.T) {
t.Errorf("code %d, want 200", rec.Code)
}
}
// A request with no matched Pattern is labelled "unmatched", never with its
// raw URL path (unbounded cardinality, hyperguild#185).
func TestMiddleware_LabelsAnUnmatchedRequestWithAFixedLabel(t *testing.T) {
r := New()
h := r.Middleware(http.NotFoundHandler())
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/zqrandom-185-unit", nil))
rec := httptest.NewRecorder()
r.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/metrics", nil))
if strings.Contains(rec.Body.String(), "zqrandom-185-unit") {
t.Errorf("the raw path became a metrics label:\n%s", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `path="unmatched"`) {
t.Errorf("no unmatched label:\n%s", rec.Body.String())
}
}
@@ -0,0 +1,36 @@
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,3 +38,31 @@ func TestReadScopedRefusesANoteOverMaxNoteBytes(t *testing.T) {
_, err := ReadScoped(brain, "wiki/w/facts/big.md", ScopeNotes)
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)
}
+18 -12
View File
@@ -3,9 +3,10 @@ package pipeline
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"io/fs"
"strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
@@ -23,10 +24,16 @@ func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
return 0, fmt.Errorf("load inventory: %w", err)
}
sourcesDir := filepath.Join(brainDir, "wiki", "sources")
entries, err := os.ReadDir(sourcesDir)
// Listed and read through the brain root, regular files only
// (hyperguild#157).
bfs, err := brainfs.Open(brainDir)
if err != nil {
if os.IsNotExist(err) {
return 0, fmt.Errorf("open brain: %w", 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, fmt.Errorf("read sources dir: %w", err)
@@ -37,20 +44,20 @@ func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
// get both refs merged before a single write.
pending := make(map[string]wiki.Page)
for _, e := range entries {
for _, name := range names {
if ctx.Err() != nil {
return 0, ctx.Err()
}
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
if !strings.HasSuffix(name, ".md") {
continue
}
b, err := os.ReadFile(filepath.Join(sourcesDir, e.Name()))
b, err := bfs.ReadRegular("wiki/sources/" + name)
if err != nil {
continue
}
sourceContent := string(b)
sourceSlug := strings.TrimSuffix(e.Name(), ".md")
sourceSlug := strings.TrimSuffix(name, ".md")
sourceTitle := extractTitle(sourceContent)
if sourceTitle == "" {
sourceTitle = sourceSlug
@@ -70,7 +77,7 @@ func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
// Start from already-accumulated version if we've seen this page.
page, seen := pending[relPath]
if !seen {
raw, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(relPath)))
raw, err := bfs.ReadRegular(relPath)
if err != nil {
continue
}
@@ -81,8 +88,7 @@ func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
}
for relPath, page := range pending {
dest := filepath.Join(brainDir, filepath.FromSlash(relPath))
if err := os.WriteFile(dest, []byte(page.Content), 0o644); err != nil {
if err := bfs.PutRegular(relPath, []byte(page.Content)); err != nil {
return 0, fmt.Errorf("write %s: %w", relPath, err)
}
}
+29 -3
View File
@@ -14,12 +14,21 @@ import (
// (derived from the filename, not the LLM title). Pages whose title resolves to an
// empty slug are skipped and returned as warnings instead.
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))
var warnings []string
for _, rp := range rawPages {
// The type becomes a directory under wiki/; "../raw" wrote outside it
// (PR #147 review). Only a plain lower-case name is a page type.
if !validPageType.MatchString(rp.Type) {
if !validPageType.MatchString(rp.Type) || (wing != "" && !layoutType(rp.Type)) {
warnings = append(warnings, fmt.Sprintf("skipped page with invalid type %q", rp.Type))
continue
}
@@ -28,11 +37,16 @@ func BuildPages(rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []str
warnings = append(warnings, fmt.Sprintf("skipped page with empty title (type: %s)", rp.Type))
continue
}
out = append(out, buildPage(rp, sourceSlug, date))
out = append(out, buildPage(rp, sourceSlug, date, wing))
}
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-]*$`)
func computeSlug(rp RawPage, sourceSlug string) string {
@@ -42,7 +56,7 @@ func computeSlug(rp RawPage, sourceSlug string) string {
return wiki.Slug(rp.Title)
}
func buildPage(rp RawPage, sourceSlug, date string) wiki.Page {
func buildPage(rp RawPage, sourceSlug, date, wing string) wiki.Page {
var slug, dir string
switch rp.Type {
case "source":
@@ -59,8 +73,14 @@ func buildPage(rp RawPage, sourceSlug, date string) wiki.Page {
dir = "wiki/" + rp.Type
}
if wing != "" && rp.Type == "source" {
dir = "wiki/" + wing + "/sources"
}
path := dir + "/" + slug + ".md"
fm := buildFrontmatter(rp, date)
if wing != "" && rp.Type == "source" {
fm = addWingFrontmatter(fm, wing, "sources")
}
return wiki.Page{
Path: path,
@@ -122,6 +142,12 @@ func buildFrontmatter(rp RawPage, date string) 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 {
// 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
+251 -23
View File
@@ -5,9 +5,13 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"io/fs"
"path/filepath"
"sort"
"strings"
"time"
@@ -23,6 +27,11 @@ type Config struct {
Complete CompleteFunc
ChunkSize int // 0 = no chunking
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.
@@ -39,7 +48,16 @@ type Result struct {
// Run ingests content and writes structured wiki pages to brainDir/wiki/.
// 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) {
inventory, err := wiki.LoadInventory(brainDir)
if cfg.Wing != "" && brain.Sanitise(cfg.Wing) != cfg.Wing {
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 {
return Result{}, fmt.Errorf("load inventory: %w", err)
}
@@ -50,6 +68,12 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
}
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")
// Drift check BEFORE any extraction: an unchanged source costs a full LLM
@@ -57,7 +81,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
// correctness — which is why it is safe for it to be an early return.
bodyHash := hashBody(content)
if !dryRun && bodyHash != "" && bodyHash == storedSourceHash(brainDir, sourceSlug) {
if !dryRun && bodyHash != "" && bodyHash == storedSourceHash(brainDir, cfg.Wing, sourceSlug) {
return Result{Skipped: true, Warnings: []string{
fmt.Sprintf("source %q unchanged since last ingest (hash %s) — extraction skipped", source, bodyHash[:12]),
}}, nil
@@ -68,7 +92,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
// extraction (#106) is scoped to the source page: it's the one page every
// chunk's prompt can be given in advance.
existingPage := readSourcePage(brainDir, sourceSlug)
existingPage := readSourcePage(brainDir, cfg.Wing, sourceSlug)
chunks := Chunk(content, cfg.ChunkSize)
@@ -90,7 +114,19 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
meta.BodyHash = bodyHash
applySourceMeta(allRaw, meta)
return buildAndWrite(ctx, allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
res, err := buildAndWrite(ctx, cfg.Wing, 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
@@ -109,27 +145,38 @@ func RunRaw(ctx context.Context, brainDir, source string, rawPages []RawPage, dr
sourceSlug := wiki.Slug(source)
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.
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 := BuildPages(rawPages, sourceSlug, date)
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) {
pages, buildWarnings := BuildPagesIn(wing, rawPages, sourceSlug, date)
warnings = append(warnings, buildWarnings...)
resolved := Resolve(pages, inventory)
canonicalized, linkWarnings := CanonicalizeLinks(resolved, inventory)
warnings = append(warnings, linkWarnings...)
withRefs := injectSourceRefs(canonicalized, inventory, brainDir)
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
for _, page := range merged {
if !dryRun {
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)
content, conflicts, err := reviseOrCreate(brainDir, page)
if err != nil {
return Result{}, err
}
@@ -147,7 +194,9 @@ func buildAndWrite(ctx context.Context, rawPages []RawPage, sourceSlug, date, br
if err != nil {
return Result{}, fmt.Errorf("stamp %s: %w", page.Path, err)
}
if err := os.WriteFile(dest, []byte(stamped), 0o644); err != nil {
// Written through the brain root, never through a symlinked
// 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)
}
}
@@ -155,7 +204,11 @@ func buildAndWrite(ctx context.Context, rawPages []RawPage, sourceSlug, date, br
}
if !dryRun {
if err := wiki.RebuildIndex(brainDir, date); err != nil {
if wing != "" {
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))
}
if err := wiki.AppendLog(brainDir, source, written, warnings, date); err != nil {
@@ -188,22 +241,50 @@ func hashBody(content string) string {
// readSourcePage returns the full on-disk content of the source page at
// wiki/sources/<sourceSlug>.md, or "" when it doesn't exist yet.
func readSourcePage(brainDir, sourceSlug string) string {
raw, err := os.ReadFile(filepath.Join(brainDir, "wiki", "sources", sourceSlug+".md"))
// It reads through the brain root, regular files only (hyperguild#157): the
// content goes into the LLM prompt.
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 {
return ""
}
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
// wrote, or "" when there is no such page or no such field.
//
// 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
// silence is the failure mode this repo keeps paying for.
func storedSourceHash(brainDir, sourceSlug string) string {
for _, line := range strings.Split(readSourcePage(brainDir, sourceSlug), "\n") {
func storedSourceHash(brainDir, wing, sourceSlug string) string {
for _, line := range strings.Split(readSourcePage(brainDir, wing, sourceSlug), "\n") {
if key, value, found := strings.Cut(line, ":"); found && strings.TrimSpace(key) == "source_hash" {
return strings.TrimSpace(value)
}
@@ -230,10 +311,10 @@ func storedSourceHash(brainDir, sourceSlug string) string {
// 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
// field today.
func reviseOrCreate(dest string, page wiki.Page) (string, []string, error) {
existing, err := os.ReadFile(dest)
func reviseOrCreate(brainDir string, page wiki.Page) (string, []string, error) {
existing, err := readBrainFile(brainDir, page.Path)
switch {
case os.IsNotExist(err):
case errors.Is(err, fs.ErrNotExist):
return page.Content, nil, nil
case err != nil:
return "", nil, fmt.Errorf("read existing %s: %w", page.Path, err)
@@ -267,9 +348,156 @@ See brain/schema.md for the full schema.
`
func loadSchema(brainDir string) string {
b, err := os.ReadFile(filepath.Join(brainDir, "schema.md"))
b, err := readBrainFile(brainDir, "schema.md")
if err != nil {
return defaultSchema
}
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)
}
+30 -7
View File
@@ -2,7 +2,6 @@
package pipeline
import (
"os"
"path/filepath"
"regexp"
"strings"
@@ -24,7 +23,7 @@ func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entr
var sourceContent string
for _, p := range pages {
if strings.HasPrefix(p.Path, "wiki/sources/") &&
if isSourcePath(p.Path) &&
strings.TrimSuffix(filepath.Base(p.Path), ".md") == sourceSlug {
sourceContent = p.Content
break
@@ -36,7 +35,7 @@ func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entr
bySlug := make(map[string]int, len(pages))
for i, p := range pages {
if !strings.HasPrefix(p.Path, "wiki/sources/") {
if !isSourcePath(p.Path) {
bySlug[strings.TrimSuffix(filepath.Base(p.Path), ".md")] = i
}
}
@@ -53,13 +52,13 @@ func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entr
if !ok {
continue
}
diskPath := filepath.Join(brainDir, "wiki", string(pt), slug+".md")
b, err := os.ReadFile(diskPath)
path := entryPath(slug, pt, inventory)
b, err := readBrainFile(brainDir, path)
if err != nil {
continue
}
page := wiki.Page{
Path: "wiki/" + string(pt) + "/" + slug + ".md",
Path: path,
Content: string(b),
}
pages = append(pages, addSourceRef(page, sourceRef))
@@ -90,7 +89,7 @@ func extractWikilinks(content string) map[string]bool {
// findSourcePage returns the slug and title of the first wiki/sources/ page in pages.
func findSourcePage(pages []wiki.Page) (slug, title string, found bool) {
for _, p := range pages {
if strings.HasPrefix(p.Path, "wiki/sources/") {
if isSourcePath(p.Path) {
slug = strings.TrimSuffix(filepath.Base(p.Path), ".md")
title = extractTitle(p.Content)
if title == "" {
@@ -113,3 +112,27 @@ func findInInventory(slug string, inventory map[wiki.PageType][]wiki.Entry) (wik
}
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"
}
+25 -1
View File
@@ -32,7 +32,14 @@ func Resolve(proposed []wiki.Page, inventory map[wiki.PageType][]wiki.Entry) []w
out := make([]wiki.Page, 0, len(proposed))
for _, page := range proposed {
pt := pageTypeFromPath(page.Path)
if isWingSourcePath(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)
k := key{pt: pt, normalized: normalizeTitle(title)}
if canonicalSlug, ok := lookup[k]; ok {
@@ -55,6 +62,17 @@ func normalizeTitle(s string) string {
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".
func pageTypeFromPath(path string) wiki.PageType {
parts := strings.Split(filepath.ToSlash(path), "/")
@@ -86,3 +104,9 @@ func extractTitle(content string) string {
}
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"
}
+217
View File
@@ -0,0 +1,217 @@
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")
}
@@ -0,0 +1,396 @@
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,7 +28,6 @@ import (
"context"
"errors"
"fmt"
"os"
"regexp"
"strings"
@@ -102,25 +101,6 @@ func Apply(content string, s Stamp) (string, error) {
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.
func plainGroups(block string) string {
lines := strings.Split(strings.TrimSuffix(block, "\n"), "\n")
@@ -12,6 +12,8 @@ import (
"github.com/stretchr/testify/assert"
"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
@@ -189,3 +191,49 @@ func TestHydrateRefusesASymlink(t *testing.T) {
assert.Error(t, err)
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
})
}
+177
View File
@@ -0,0 +1,177 @@
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")
}
+55 -3
View File
@@ -9,6 +9,8 @@ import (
"io/fs"
"os"
"path/filepath"
"regexp"
"syscall"
"time"
)
@@ -42,16 +44,63 @@ type Attempt struct {
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.
// The open does not follow a final symlink.
func Append(sessionsDir, sessionID string, entry Entry) error {
if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
if err := checkID(sessionID); 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)
}
path := filepath.Join(sessionsDir, sessionID+".jsonl")
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
// The sessions dir itself must be a real directory, not a symlink that
// redirects the log elsewhere (hyperguild#173).
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 {
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)
if err != nil {
@@ -70,6 +119,9 @@ func Append(sessionsDir, sessionID string, entry Entry) error {
// Read returns all entries for sessionID. Returns empty slice if no log exists.
func Read(sessionsDir, sessionID string) ([]Entry, error) {
if err := checkID(sessionID); err != nil {
return nil, err
}
path := filepath.Join(sessionsDir, sessionID+".jsonl")
f, err := os.Open(path)
if errors.Is(err, fs.ErrNotExist) {
@@ -1,75 +0,0 @@
// Package startupretry retries a startup dependency that may not be serving
// yet, instead of exiting on its first failure.
//
// It exists for the cold-boot ordering race (#83): on a cluster restart the
// ingestion pod can reach OIDC discovery before dex is answering, gets a 502
// from ingress, and dies — after having already connected postgres, backfilled
// the graph and ingested session batches, all of which the restart redoes.
// Nothing is misconfigured in that scenario; the pod simply started first.
//
// The budget is bounded on purpose. Retrying forever would turn a genuine
// misconfiguration (wrong issuer URL, bad audience) into a process that looks
// healthy and never serves, which is harder to diagnose than a crash loop.
package startupretry
import (
"context"
"fmt"
"time"
)
// Policy bounds the retry: at most Attempts calls, with the delay doubling
// from Base and capped at Max.
type Policy struct {
Attempts int // total calls, including the first; < 1 means 1
Base time.Duration // delay before the second attempt
Max time.Duration // ceiling for the doubling delay
}
// Do calls fn until it succeeds, the attempt budget is spent, or ctx ends.
//
// The returned error wraps the last failure, so callers keep the diagnosis
// they would have had without the retry.
func Do[T any](ctx context.Context, p Policy, fn func(context.Context) (T, error)) (T, error) {
var zero T
attempts := p.Attempts
if attempts < 1 {
attempts = 1
}
delay := p.Base
var lastErr error
for i := range attempts {
if err := ctx.Err(); err != nil {
return zero, fmt.Errorf("startupretry: %w", err)
}
v, err := fn(ctx)
if err == nil {
return v, nil
}
lastErr = err
if i == attempts-1 {
break
}
if delay <= 0 {
delay = time.Second
}
if p.Max > 0 && delay > p.Max {
delay = p.Max
}
select {
case <-ctx.Done():
return zero, fmt.Errorf("startupretry: %w", ctx.Err())
case <-time.After(delay):
}
delay *= 2
}
return zero, fmt.Errorf("startupretry: gave up after %d attempts: %w", attempts, lastErr)
}
@@ -1,142 +0,0 @@
// ingestion/internal/startupretry/startupretry_test.go
package startupretry
import (
"context"
"errors"
"testing"
"time"
)
func fastPolicy(attempts int) Policy {
return Policy{Attempts: attempts, Base: time.Millisecond, Max: 2 * time.Millisecond}
}
func TestDo_ReturnsImmediatelyOnFirstSuccess(t *testing.T) {
calls := 0
got, err := Do(context.Background(), fastPolicy(5), func(context.Context) (string, error) {
calls++
return "ready", nil
})
if err != nil {
t.Fatalf("Do: %v", err)
}
if got != "ready" {
t.Errorf("got %q, want %q", got, "ready")
}
if calls != 1 {
t.Errorf("calls = %d, want 1", calls)
}
}
func TestDo_SurvivesTransientFailuresThenSucceeds(t *testing.T) {
// The observed cold-boot race: dex is not serving yet, ingress answers
// 502 for the first few attempts, then discovery works.
calls := 0
got, err := Do(context.Background(), fastPolicy(5), func(context.Context) (string, error) {
calls++
if calls < 4 {
return "", errors.New("oidc discovery: status 502")
}
return "ready", nil
})
if err != nil {
t.Fatalf("Do returned %v — a transient 502 must not be fatal", err)
}
if got != "ready" {
t.Errorf("got %q, want %q", got, "ready")
}
if calls != 4 {
t.Errorf("calls = %d, want 4", calls)
}
}
func TestDo_GivesUpAfterTheAttemptBudgetAndReportsTheLastError(t *testing.T) {
// A genuinely broken issuer must still fail — retrying forever would turn
// a config error into a silent hang, which is worse than crash-looping.
last := errors.New("oidc discovery: status 404")
calls := 0
_, err := Do(context.Background(), fastPolicy(3), func(context.Context) (string, error) {
calls++
return "", last
})
if !errors.Is(err, last) {
t.Fatalf("err = %v, want it to wrap the last failure", err)
}
if calls != 3 {
t.Errorf("calls = %d, want 3 (the attempt budget)", calls)
}
}
func TestDo_StopsPromptlyWhenTheContextIsCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
calls := 0
done := make(chan struct{})
var err error
go func() {
defer close(done)
_, err = Do(ctx, Policy{Attempts: 100, Base: 50 * time.Millisecond, Max: time.Second},
func(context.Context) (string, error) {
calls++
if calls == 1 {
cancel()
}
return "", errors.New("still down")
})
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Do did not return after context cancellation")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want context.Canceled", err)
}
}
func TestDo_BacksOffAndIsCappedByMax(t *testing.T) {
var delays []time.Duration
p := Policy{Attempts: 5, Base: 10 * time.Millisecond, Max: 25 * time.Millisecond}
last := time.Now()
_, _ = Do(context.Background(), p, func(context.Context) (string, error) {
now := time.Now()
delays = append(delays, now.Sub(last))
last = now
return "", errors.New("down")
})
// delays[0] is the first call (no wait); the rest are the sleeps.
if len(delays) != 5 {
t.Fatalf("attempts = %d, want 5", len(delays))
}
if delays[1] >= delays[2] {
t.Errorf("backoff is not growing: %v then %v", delays[1], delays[2])
}
for i, d := range delays[1:] {
if d > 2*p.Max {
t.Errorf("delay %d = %v, exceeds the Max cap %v with slack", i+1, d, p.Max)
}
}
}
func TestPolicy_ZeroValueStillMakesOneAttempt(t *testing.T) {
// A caller that forgets to configure the policy must not silently get a
// no-op that returns a nil validator and a nil error.
calls := 0
_, err := Do(context.Background(), Policy{}, func(context.Context) (string, error) {
calls++
return "", errors.New("down")
})
if calls != 1 {
t.Errorf("calls = %d, want 1", calls)
}
if err == nil {
t.Error("err = nil, want the failure surfaced")
}
}
@@ -0,0 +1,104 @@
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")
}
}
+152 -6
View File
@@ -18,9 +18,14 @@ import (
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"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.
const DefaultInterval = 30 * time.Second
@@ -41,10 +46,16 @@ type Notifier interface {
type Result struct {
Symlinks []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.
func (r Result) Clean() bool { return len(r.Symlinks) == 0 && len(r.Unreadable) == 0 }
func (r Result) Clean() bool {
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
// a name containing a newline cannot blur two findings together.
@@ -54,6 +65,9 @@ func (r Result) String() string {
if len(r.Symlinks) > 0 {
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 {
parts = append(parts, fmt.Sprintf("%d unreadable dir(s): %s", len(r.Unreadable), q(r.Unreadable)))
}
@@ -108,12 +122,16 @@ func Scan(brainDir string) (Result, error) {
if d.IsDir() && p == rootGit {
return filepath.SkipDir
}
if d.Type()&os.ModeSymlink != 0 {
switch {
case d.Type()&os.ModeSymlink != 0:
r.Symlinks = append(r.Symlinks, rel)
case !d.IsDir() && !d.Type().IsRegular():
r.Special = append(r.Special, rel)
}
return nil
})
sort.Strings(r.Symlinks)
sort.Strings(r.Special)
sort.Strings(r.Unreadable)
if walkErr != nil {
// Only the root's own error reaches here (deeper errors are recorded
@@ -133,13 +151,32 @@ func Scan(brainDir string) (Result, error) {
// 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
// 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) {
if interval <= 0 {
interval = DefaultInterval
}
t := time.NewTicker(interval)
defer t.Stop()
q := &alertQueue{}
alerts := make(chan string, maxPending)
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 := ""
for {
select {
@@ -158,17 +195,92 @@ 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",
len(removed), Result{}.quoted(removed)))
}
key = Result{Symlinks: failed, Unreadable: r.Unreadable}.String()
sRemoved, sFailed := removeSpecial(brainDir, r.Special)
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 {
reported = key
if key != "" {
slog.Error("brain symlink tripwire", "finding", key)
q.push("brain symlink tripwire (hyperguild#157): " + key +
". Readers may follow symlinks out of the brain; fix them.")
q.push("brain symlink tripwire (hyperguild#157, #179): " + key +
". Symlinks can lead readers out of the brain and special files can hang them; remove 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)
case <-t.C:
if !drain() {
continue // the closed channel is handled by the case above
}
q.flush(ctx, n)
}
}
}
@@ -229,6 +341,40 @@ func permanent(err error) bool {
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
// 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.
@@ -1,10 +1,12 @@
package symlinkguard
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"log/slog"
"path/filepath"
"strconv"
"strings"
@@ -345,13 +347,19 @@ func TestWatchDropsAMessageThatNeverSendsSoLaterAlertsFlow(t *testing.T) {
}, 3*time.Second, 10*time.Millisecond, "a later alert is delivered despite a poison message")
}
// toggleNotifier fails while down is set.
// toggleNotifier fails while down is set. Like the real audit.NtfyNotifier
// (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 {
recNotifier
down atomic.Bool
}
func (r *toggleNotifier) Notify(ctx context.Context, m string) error {
if err := ctx.Err(); err != nil {
return err
}
if r.down.Load() {
return errors.New("ntfy down")
}
@@ -373,18 +381,19 @@ func TestWatchQueueIsBoundedDuringAnOutage(t *testing.T) {
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 2*time.Millisecond)
}
n.down.Store(false)
require.Eventually(t, func() bool { return n.count() >= 1 }, 2*time.Second, 5*time.Millisecond)
time.Sleep(100 * time.Millisecond)
assert.LessOrEqual(t, n.count(), maxPending+1, "queued alerts plus at most one suppression summary")
// Synchronise on the summary being delivered (hyperguild#193: a fixed
// sleep flaked under load); once it is, the queue has been flushed.
require.Eventually(t, func() bool {
n.mu.Lock()
defer n.mu.Unlock()
found := false
for _, m := range n.msgs {
if strings.Contains(m, "suppressed") {
found = true
return true
}
}
assert.True(t, found, "suppressed alerts are counted in a summary")
return false
}, 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
@@ -403,11 +412,21 @@ func TestWatchDeliversEveryQueuedAlertAfterAShortOutage(t *testing.T) {
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 2*time.Millisecond)
}
n.down.Store(false)
require.Eventually(t, func() bool { return n.count() == 3 }, 2*time.Second, 5*time.Millisecond)
require.Eventually(t, func() bool { return n.count() >= 3 }, 10*time.Second, 5*time.Millisecond)
n.mu.Lock()
defer n.mu.Unlock()
for i, name := range []string{"qa", "qb", "qc"} {
assert.Contains(t, n.msgs[i], strconv.Quote(name))
// Each removal delivered exactly once, in any order: the outage ends
// asynchronously, possibly mid-flush, so a later item can send before an
// earlier one (hyperguild#193). Order is not what this test is about.
assert.Len(t, n.msgs, 3)
for _, name := range []string{"qa", "qb", "qc"} {
hits := 0
for _, m := range n.msgs {
if strings.Contains(m, strconv.Quote(name)) {
hits++
}
}
assert.Equal(t, 1, hits, "%s delivered exactly once", name)
}
}
@@ -449,29 +468,30 @@ func TestWatchAccountsForEveryRemovalDuringAnOutage(t *testing.T) {
require.Eventually(t, func() bool { _, err := os.Lstat(link); return os.IsNotExist(err) }, 2*time.Second, 1*time.Millisecond)
}
n.down.Store(false)
require.Eventually(t, func() bool {
// Synchronise on the record being complete, not on a fixed sleep after
// the first summary (hyperguild#193): sum every "removed N" alert and
// every suppression count until they account for all plants.
account := func() (delivered, counted int) {
n.mu.Lock()
defer n.mu.Unlock()
for _, m := range n.msgs {
if strings.Contains(m, "suppressed") {
return true
var k int
if i := strings.Index(m, "removed "); i >= 0 && strings.Contains(m, "symlink(s)") {
if _, err := fmt.Sscanf(m[i+len("removed "):], "%d", &k); err == nil {
delivered += k
}
}
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") {
_, _ = fmt.Sscanf(m[i+3:], "%d", &counted)
if _, err := fmt.Sscanf(m[i+3:], "%d", &k); err == nil {
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
@@ -496,3 +516,271 @@ func TestWatchDropsAPoisonMessageAndCountsIt(t *testing.T) {
return false
}, 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,6 +42,11 @@ func TestSyncRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
brain := t.TempDir()
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")))
// 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{}
store := &stubStore{}
@@ -51,6 +56,7 @@ func TestSyncRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
assert.NotContains(t, emb.all(), brainfstest.Sentinel)
for p := range store.upserts {
assert.NotContains(t, p, "leak.md")
assert.NotContains(t, p, "inlink.md")
}
p := filepath.Join(brain, "knowledge", "r.md")
@@ -61,6 +67,7 @@ func TestSyncRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
return e.all()
}
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() })
}
@@ -0,0 +1,116 @@
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"))
}
+51
View File
@@ -0,0 +1,51 @@
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)
}
+354 -54
View File
@@ -3,17 +3,22 @@ package watcher
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"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/pipeline"
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
)
// Config holds watcher configuration.
@@ -21,8 +26,27 @@ type Config struct {
BrainDir string
Interval time.Duration
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.
// It returns immediately. The watcher stops when ctx is cancelled.
func Start(ctx context.Context, cfg Config) {
@@ -85,7 +109,7 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
// path that treats raw/ as "awaiting processing" — this is that path.
if d.IsDir() {
name := d.Name()
if name == "processed" || name == "failed" || name == "rejected" {
if name == "processed" || name == "failed" || name == "rejected" || name == "needs-review" {
return filepath.SkipDir
}
return nil
@@ -109,14 +133,33 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
}
// Skip files that have already been processed or permanently failed.
if _, err := os.Stat(path + ".processed"); err == nil {
// Any entry at the marker path counts (Lstat, not Stat): a dangling
// 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
} 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
}
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))
}
return nil
@@ -127,9 +170,12 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
return errs
}
// processFile reads a file, calls pipeline.Run, copies it to processed/ or failed/,
// and writes a marker file next to the original so the watcher skips it next poll.
// The original file is never deleted, keeping Syncthing-connected vaults (e.g. Obsidian) intact.
// processFile reads a file, calls pipeline.Run, and archives it into processed/
// or failed/. The original is then removed from raw/ so that raw/ only ever
// holds unprocessed input (inbox zero). The removal syncs to Syncthing peers;
// the archive copy under raw/processed/ or raw/failed/ is what they keep. A
// marker is written next to the original only if the removal fails, so a
// stuck file is not re-ingested every poll.
func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, date string) error {
filename := filepath.Base(path)
source := deriveSource(filename)
@@ -138,25 +184,43 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
return fmt.Errorf("relative path: %w", err)
}
content, err := extract.TextIn(root, rel)
before, err := root.Lstat(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 {
return fmt.Errorf("extract text: %w", err)
}
_, runErr := pipeline.Run(ctx, cfg.Pipeline, cfg.BrainDir, content, source, false)
if runErr != nil {
// Copy to failed/ and leave a .failed marker so we don't retry.
failedDir := filepath.Join(cfg.BrainDir, "raw", "failed")
if mkErr := os.MkdirAll(failedDir, 0o755); mkErr != nil {
return fmt.Errorf("mkdir failed dir: %w", mkErr)
pcfg := cfg.Pipeline
if cfg.Resolver != nil {
d := cfg.Resolver.Resolve(ctx, wing.Note{Dir: topDir(rel), FrontmatterWing: frontmatterWing(content), Text: content})
if d.Source == wing.Exception {
return parkForReview(cfg, root, rel, path, filename, content, d, date, before)
}
dest := filepath.Join(failedDir, filename)
if cpErr := copyFromRoot(root, rel, dest); cpErr != nil {
pcfg.Wing = d.Wing
slog.Info("watcher: wing resolved", "file", filename, "wing", d.Wing, "how", d.Source, "reason", d.Reason)
}
_, runErr := pipeline.Run(ctx, pcfg, cfg.BrainDir, content, source, false)
if runErr != nil {
// Copy to failed/ and leave a .failed marker so we don't retry. Both
// written through the brain root (hyperguild#158).
if cpErr := copyFromRoot(root, rel, cfg.BrainDir, "raw/failed", filename, limit); cpErr != nil {
return fmt.Errorf("copy to failed: %w", cpErr)
}
if mkErr := os.WriteFile(path+".failed", []byte(runErr.Error()), 0o644); mkErr != nil {
slog.Error("watcher: failed to write .failed marker", "error", mkErr)
if changedSince(root, rel, before) {
slog.Warn("watcher: note changed during ingest; leaving it in raw/ for the next poll", "file", filename)
return nil
}
retire(cfg, root, rel, path, ".failed", runErr.Error(), before)
slog.Warn("watcher: file failed", "file", filename, "error", runErr)
@@ -168,28 +232,75 @@ 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.
processedDir := filepath.Join(cfg.BrainDir, "raw", "processed", date)
if err := os.MkdirAll(processedDir, 0o755); err != nil {
return fmt.Errorf("mkdir processed dir: %w", err)
}
dest := filepath.Join(processedDir, filename)
if _, err := os.Stat(dest); err == nil {
// Archive copy already exists; append timestamp to avoid overwriting.
ext := filepath.Ext(filename)
base := strings.TrimSuffix(filename, ext)
dest = filepath.Join(processedDir, base+"-"+time.Now().UTC().Format("150405")+ext)
}
if err := copyFromRoot(root, rel, dest); err != nil {
// Written through the brain root (hyperguild#158).
if err := copyFromRoot(root, rel, cfg.BrainDir, "raw/processed/"+date, filename, limit); err != nil {
return fmt.Errorf("copy to processed: %w", err)
}
if err := os.WriteFile(path+".processed", []byte(date), 0o644); err != nil {
slog.Error("watcher: failed to write .processed marker", "error", err)
if changedSince(root, rel, before) {
slog.Warn("watcher: note changed during ingest; leaving it in raw/ for the next poll", "file", filename)
return nil
}
retire(cfg, root, rel, path, ".processed", date, before)
slog.Info("watcher: file processed", "file", filename, "source", source)
return nil
}
// changedSince reports whether the note at rel differs (size or mtime) from
// before. A note replaced mid-ingest (Syncthing) was extracted from older
// content than it now holds, so it must not be removed. Limits: a same-size
// rewrite with a restored mtime is not detected (the new bytes then survive
// only in the archive copy), nor a change between this check and Remove.
func changedSince(root *os.Root, rel string, before os.FileInfo) bool {
now, err := root.Lstat(rel)
if err != nil {
return true
}
return now.Size() != before.Size() || !now.ModTime().Equal(before.ModTime())
}
// retire removes an archived original from raw/ (inbox zero). If the removal
// fails it falls back to a marker 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.
// "shape-up-book.md" → "Shape Up Book"
func deriveSource(filename string) string {
@@ -214,42 +325,231 @@ 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",
date, filename, runErr.Error())
logPath := filepath.Join(brainDir, "log.md")
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("open log: %w", err)
}
if _, err = f.WriteString(entry); err != nil {
f.Close() //nolint:errcheck
if err := brainfs.AppendFile(brainDir, "log.md", []byte(entry)); err != nil {
return fmt.Errorf("write log: %w", err)
}
return f.Close()
return nil
}
// copyFromRoot archives a raw/ file read through the root, refusing anything
// that is not a regular file, so a swapped-in symlink cannot copy an outside
// file into raw/processed/ or raw/failed/ (hyperguild#153).
func copyFromRoot(root *os.Root, rel, dst string) error {
fi, err := root.Lstat(rel)
// putBrainFile creates or replaces a brain-relative file through the brain
// root, never through a symlinked directory or file (hyperguild#158).
func putBrainFile(brainDir, rel string, data []byte) error {
return brainfs.PutFile(brainDir, rel, data)
}
// 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 {
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() {
return fmt.Errorf("%s: not a regular file", rel)
return nil, fmt.Errorf("%s: not a regular file", rel)
}
in, _, err := extract.OpenRegular(root, rel)
if err != nil {
return err
return nil, err
}
defer func() { _ = in.Close() }()
out, err := os.Create(dst)
if err != nil {
return err
data, err := readAllMax(in, max)
if errors.Is(err, errTooLarge) {
return nil, fmt.Errorf("%s: %w (%d bytes)", rel, errTooLarge, max)
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
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 {
return nil, err
}
return out.Close()
if int64(len(data)) > max {
return nil, errTooLarge
}
return data, nil
}
// 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
}
+330 -10
View File
@@ -4,7 +4,9 @@ package watcher
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
@@ -89,13 +91,12 @@ func TestStart_ProcessesFile(t *testing.T) {
}
require.True(t, found, "file should be copied to processed/")
// Original file should still exist (copy, not move — keeps Obsidian vault intact).
// Inbox zero: once archived, the original leaves raw/ and no marker is
// left behind, so what remains in raw/ is exactly the unprocessed set.
_, err := os.Stat(rawFile)
assert.NoError(t, err, "original file should remain in raw/")
// A .processed marker should exist next to the original.
assert.ErrorIs(t, err, os.ErrNotExist, "original should be removed from raw/ once archived")
_, err = os.Stat(rawFile + ".processed")
assert.NoError(t, err, ".processed marker should be written")
assert.ErrorIs(t, err, os.ErrNotExist, "no .processed marker: the archive copy is the record")
// Wiki page should exist.
wikiPath := filepath.Join(brainDir, "wiki", "sources", "shape-up-book.md")
@@ -142,13 +143,11 @@ func TestStart_MovesToFailedOnError(t *testing.T) {
}
require.True(t, found, "file should be copied to failed/")
// Original file should still exist (copy, not move — keeps Obsidian vault intact).
// Inbox zero: a quarantined original leaves raw/ too, no marker beside it.
_, err := os.Stat(rawFile)
assert.NoError(t, err, "original file should remain in raw/")
// A .failed marker should exist next to the original.
assert.ErrorIs(t, err, os.ErrNotExist, "original should be removed from raw/ once quarantined")
_, err = os.Stat(rawFile + ".failed")
assert.NoError(t, err, ".failed marker should be written")
assert.ErrorIs(t, err, os.ErrNotExist, "no .failed marker: failed/ is the record")
// log.md should contain a watcher error entry.
logContent, err := os.ReadFile(filepath.Join(brainDir, "log.md"))
@@ -390,3 +389,324 @@ func TestProcessDir_DoesNotHangUnderAFIFOSwapRace(t *testing.T) {
t.Fatalf("the watcher hung on a swapped-in FIFO after %d iterations", iters.Load())
}
}
// A note whose removal AND marker write both fail (read-only raw/) must not be
// re-ingested on every poll: each pass would write another archive copy.
func TestProcessDir_DoesNotReingestANoteItCannotRetire(t *testing.T) {
brainDir := setupBrainDir(t)
rawDir := filepath.Join(brainDir, "raw")
require.NoError(t, os.WriteFile(filepath.Join(rawDir, "stuck.md"), []byte("Content."), 0o644))
// processed/ must exist and stay writable; only raw/ itself is locked.
require.NoError(t, os.MkdirAll(filepath.Join(rawDir, "processed"), 0o755))
require.NoError(t, os.Chmod(rawDir, 0o555))
t.Cleanup(func() { _ = os.Chmod(rawDir, 0o755) })
var calls atomic.Int32
cfg := Config{
BrainDir: brainDir,
Pipeline: pipeline.Config{
Complete: func(ctx context.Context, s, u string) (string, error) {
calls.Add(1)
return successComplete(pipeline.RawPage{Title: "Stuck", Type: "source", Subtype: "article", Content: "x\n"})(ctx, s, u)
},
Schema: "# Schema",
},
}
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())
}
@@ -0,0 +1,358 @@
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")
}
@@ -0,0 +1,190 @@
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")
}
+56
View File
@@ -0,0 +1,56 @@
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)
}
+2 -4
View File
@@ -3,8 +3,6 @@ package wiki
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
@@ -50,8 +48,8 @@ func RebuildIndex(brainDir, date string) error {
sb.WriteString("\n")
}
dest := filepath.Join(brainDir, "wiki", "index.md")
return os.WriteFile(dest, []byte(sb.String()), 0o644)
// Written through the brain root, never through a symlink (#158).
return bfs.PutRegular("wiki/index.md", []byte(sb.String()))
}
// pageFirstSentence reads the page through the brain root, regular files
+96
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io/fs"
"sort"
"strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
@@ -96,3 +97,98 @@ func readFrontmatter(bfs *brainfs.Brain, rel, fallbackSlug string) (title string
}
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
}
+7 -8
View File
@@ -3,8 +3,7 @@ package wiki
import (
"fmt"
"os"
"path/filepath"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"strings"
)
@@ -27,14 +26,14 @@ func AppendLog(brainDir, source string, pages, warnings []string, date string) e
}
sb.WriteString("\n")
logPath := filepath.Join(brainDir, "log.md")
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
// Appended through the brain root, never through a symlink (#158).
bfs, err := brainfs.Open(brainDir)
if err != nil {
return fmt.Errorf("open log: %w", err)
return fmt.Errorf("open brain: %w", err)
}
if _, err = f.WriteString(sb.String()); err != nil {
f.Close() //nolint:errcheck
defer func() { _ = bfs.Close() }()
if err := bfs.AppendRegular("log.md", []byte(sb.String())); err != nil {
return fmt.Errorf("write log: %w", err)
}
return f.Close()
return nil
}
+1
View File
@@ -22,4 +22,5 @@ type Entry struct {
Title string
Aliases []string
Type PageType
Path string // brain-relative path; set for wing-layout entries
}
@@ -0,0 +1,35 @@
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")
}
+16
View File
@@ -0,0 +1,16 @@
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))
}
+46
View File
@@ -0,0 +1,46 @@
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 }
+42
View File
@@ -0,0 +1,42 @@
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)
}
+53
View File
@@ -0,0 +1,53 @@
// 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)) }
+220
View File
@@ -0,0 +1,220 @@
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',
}
+236
View File
@@ -0,0 +1,236 @@
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")
}