From 2e33ec36c1265aba12bdaca5d1248a7cde4157cc Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 16 Sep 2026 07:43:33 -0700 Subject: [PATCH] docs: three dangling links, and a gate so they cannot accumulate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vault is the project's source of truth, so a link to a page that does not exist is a promise the repo cannot keep. Three were dangling: - Dankclaw/Gitea.md pointed twice at dankclaw/compose.yaml — **which this session retired**. The clean cutover obligation from my own change: the page now describes the unit that publishes 3000 and the canonical Caddy unit, and records why the compose file is gone. - Dankclaw/Hermes-Dashboard.md linked [[LAN DNS]]; the page is [[LAN-DNS]]. - LLM/frank-medium-radiance-mxfp4.md linked [[Observability on Dankclaw]]; the page is [[Observability]]. scripts/check-vault-links.py + a seam keep it that way. The checker models Obsidian's rules, which took three corrections to get right — and each was a false positive that would have sent me editing healthy docs: 1. Wikilinks are case-insensitive. A case-sensitive matcher reported 45 breaks; 39 were `[[qBittorrent]]` vs qbittorrent.md. 2. `[[folder/Note]]` resolves by path suffix, not by stem. 3. `%20` in a markdown link is URL-encoding, not a filename. Validating the linter against the resolver it models is the prerequisite for trusting any of its output — the same discipline that caught a bad assertion of my own earlier tonight. --- scripts/check-vault-links.py | 76 +++++++++++++++++++ serverAppConfigs-docs/Dankclaw/Gitea.md | 4 +- .../Dankclaw/Hermes-Dashboard.md | 2 +- .../LLM/frank-medium-radiance-mxfp4.md | 4 +- tests/docs_completeness.bats | 10 +++ 5 files changed, 91 insertions(+), 5 deletions(-) create mode 100755 scripts/check-vault-links.py diff --git a/scripts/check-vault-links.py b/scripts/check-vault-links.py new file mode 100755 index 0000000..b4e4f24 --- /dev/null +++ b/scripts/check-vault-links.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Report unresolved links in the docs vault (serverAppConfigs-docs). + +The vault is the project's source of truth, so a link to a page that does not +exist is a promise the repo cannot keep. This models **Obsidian's** resolution +rules rather than a naive one — three separate false-positive classes were found +while writing it, and a linter that cries wolf is worse than no linter: + + 1. Wikilinks are **case-insensitive** (`[[qBittorrent]]` -> `qbittorrent.md`). + A case-sensitive matcher reported 45 breaks; 39 were this. + 2. A path-qualified wikilink (`[[folder/Note]]`) resolves by **path suffix**, + not by stem, and must not be stem-matched. Treating the whole string as a + stem reported the PP pages as missing when they exist one directory down. + 3. `%20` in a markdown link is URL-encoding. Dealing in raw text reported + `Network%20Architecture.md` as missing beside the real file. + +Exit status is 1 when any link is unresolved, so it gates a test. +""" +import pathlib +import re +import sys +import urllib.parse + +WIKI = re.compile(r"\[\[([^\]\|#]+)(?:#[^\]\|]*)?(?:\|[^\]]*)?\]\]") +MD = re.compile(r"(? str: + return path[:-3] if path.endswith(".md") else path + + +def build_index(vault: pathlib.Path): + pages = sorted(vault.rglob("*.md")) + stems: dict[str, list[pathlib.Path]] = {} + for page in pages: + stems.setdefault(page.stem.casefold(), []).append(page) + relative = [strip_md(str(p.relative_to(vault)).casefold()) for p in pages] + return pages, stems, relative + + +def wiki_resolves(target: str, stems, relative) -> bool: + want = target.strip().rstrip("\\").casefold() + if "/" in want: + return any(r == want or r.endswith("/" + want) for r in relative) + return want in stems + + +def md_resolves(base: pathlib.Path, target: str) -> bool: + text = urllib.parse.unquote(target.strip()) + if text.startswith(EXTERNAL): + return True + text = text.split("#")[0].strip() + return not text or (base / text).exists() + + +def main(argv: list[str]) -> int: + vault = pathlib.Path(argv[1] if len(argv) > 1 else "serverAppConfigs-docs") + pages, stems, relative = build_index(vault) + broken: list[str] = [] + for page in pages: + text = page.read_text(errors="replace") + for target in WIKI.findall(text): + if not wiki_resolves(target, stems, relative): + broken.append(f"[wiki] {page.relative_to(vault)} -> {target.strip()}") + for target in MD.findall(text): + if not md_resolves(page.parent, target): + broken.append(f"[md] {page.relative_to(vault)} -> {target.strip()}") + print(f"{len(pages)} pages, {len(broken)} unresolved links") + for line in broken: + print(" " + line) + return 1 if broken else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/serverAppConfigs-docs/Dankclaw/Gitea.md b/serverAppConfigs-docs/Dankclaw/Gitea.md index 22039c6..944f86f 100644 --- a/serverAppConfigs-docs/Dankclaw/Gitea.md +++ b/serverAppConfigs-docs/Dankclaw/Gitea.md @@ -103,9 +103,9 @@ Back up `/home/dank/Containers/gitea/` (tar) or `pg_dump` the database. See the containers. Edit files as the owning user with `podman exec --user 1000 gitea-server ...`. - **The host auto-reboots.** `unattended-upgrades` reboots Dankclaw when a package needs it. The quadlet units make Gitea survive that. See the 2026-08-16 incident below. -- **Port 3000 vs 3001.** The live host port is 3000. The full-stack [`dankclaw/compose.yaml`](../../dankclaw/compose.yaml) maps 3001. Align the two before deploying the full stack. +- **Port 3000, and one owner.** The live host port is 3000, published by the `gitea` Quadlet unit. The `dankclaw/compose.yaml` that mapped 3001 was **retired on 2026-09-16** (staged in `.AGENTTRASH/`) once an ownership audit showed every container on this host reports a `PODMAN_SYSTEMD_UNIT` label — a second composition path could only spawn a duplicate fighting a unit for a name. Do not reintroduce a compose service for Gitea or the proxy. - **Legacy compose retired.** `/opt/gitea/podman-compose.yml` is retired to `/opt/gitea/.deprecated/`. Do not run it. Its containers collide with the Quadlet units on ports 3000 and 2222. -- **Canonical proxy.** `gitea.franksdank.cloud` is served by the Caddy service in `dankclaw/compose.yaml`, which proxies to `192.168.0.143:3000`. Do not recreate a Gitea-specific Caddy unit or publish another proxy on ports 80/443. During migration, the deployer stages any installed legacy unit under `~/.AGENTTRASH/YYYYMMDD-gitea-caddy/` before starting canonical Caddy. +- **Canonical proxy.** `gitea.franksdank.cloud` is served by the canonical Caddy **unit** (`dankclaw/caddy/quadlet/caddy.container`, repo `dankclaw/Caddyfile`), which proxies to `192.168.0.143:3000`. Do not recreate a Gitea-specific Caddy unit or publish another proxy on ports 80/443. If a legacy unit is ever installed again, the deployer stages it under `~/.AGENTTRASH/YYYYMMDD-gitea-caddy/` before starting canonical Caddy. ## Incident: 2026-08-16 — down after unattended reboot diff --git a/serverAppConfigs-docs/Dankclaw/Hermes-Dashboard.md b/serverAppConfigs-docs/Dankclaw/Hermes-Dashboard.md index 0ab9339..fadcb23 100644 --- a/serverAppConfigs-docs/Dankclaw/Hermes-Dashboard.md +++ b/serverAppConfigs-docs/Dankclaw/Hermes-Dashboard.md @@ -54,7 +54,7 @@ login — there is no unauthenticated bind: reverse-proxies `192.168.0.143:9119` directly, with **no** Anubis/Outpost guard chain in front of it (exempt-by-construction: the app carries its own complete auth). The name deliberately has no public DNS record, so the vhost resolves on - the LAN only; WAN clients get NXDOMAIN. See [[LAN DNS]]. + the LAN only; WAN clients get NXDOMAIN. See [[LAN-DNS]]. 2. **`http://192.168.0.143:9119`** — the raw published port. Still reachable from any LAN host (the container binds `0.0.0.0` inside its namespace and publishes `9119:9119`), so treat the LAN as a trusted boundary here, not as a gate. diff --git a/serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md b/serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md index 48507cf..ed193e1 100644 --- a/serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md +++ b/serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md @@ -426,8 +426,8 @@ scraped — Prometheus has `frank-medium` and `frank-large` jobs `vllm:kv_cache_usage_perc` (per-card pool fill), `vllm:num_requests_waiting` (queue depth), `rate(vllm:prefix_cache_hits_total[5m]) / rate(vllm:prefix_cache_queries_total[5m])` (whether pinned conversations are -served warm), and the request-latency histograms. See -[[Observability on Dankclaw]], and one Grafana dashboard reads them +served warm), and the request-latency histograms. See [[Observability]], and one +Grafana dashboard reads them (**LLM fleet — capacity and reuse**). Five rules watch this service (`shared/prometheus/rules/llm-fleet.yml`): pool diff --git a/tests/docs_completeness.bats b/tests/docs_completeness.bats index 141d956..775e3d9 100644 --- a/tests/docs_completeness.bats +++ b/tests/docs_completeness.bats @@ -33,3 +33,13 @@ load helpers } done < <(find "$REPO_ROOT/serverAppConfigs-docs" -name '*.md' -not -name 'index.md' -not -path '*/.obsidian/*' | sort) } + +@test "no unresolved links in the docs vault" { + # A wikilink to a page that does not exist is a promise the repo cannot + # keep. Fixing the three that were dangling (2026-09-16) also meant + # retiring a reference to dankclaw/compose.yaml, which no longer exists. + # The checker models Obsidian's resolution rules; see its docstring for + # the three false-positive classes a naive version produces. + run "$REPO_ROOT/scripts/check-vault-links.py" "$REPO_ROOT/serverAppConfigs-docs" + [ "$status" -eq 0 ] || { echo "$output"; false; } +}