Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9f3d1be1e | ||
|
|
afa3a4521c | ||
|
|
9cc8ca1c88 | ||
|
|
9c05c40aa0 | ||
|
|
63219787af | ||
|
|
e914ecbca6 | ||
|
|
1eebf3b129 | ||
|
|
af5a1f69a2 | ||
|
|
0930510f9b | ||
|
|
af327bcc5e | ||
|
|
fcf94562d1 | ||
|
|
861920797d | ||
|
|
3ef711f146 | ||
|
|
823fd52d0e | ||
|
|
bda011d26f | ||
|
|
b19f1c49d6 | ||
|
|
b1ca4e02fa | ||
|
|
7f65cf0252 | ||
|
|
346e1d22f4 | ||
|
|
53ef121180 | ||
|
|
fa093e617d | ||
|
|
6215f1a6c8 | ||
|
|
8bac97ec87 | ||
|
|
69739237c8 | ||
|
|
e5580abb6f | ||
|
|
ee6d8610b3 | ||
|
|
933420fd29 | ||
|
|
9ffa99902d | ||
|
|
b8f564f3bd | ||
|
|
d42d053abe | ||
|
|
7eed636bd1 | ||
|
|
d9b82b372e |
No files matched your search
@@ -462,12 +462,15 @@ disagrees with it is the bug.
|
||||
- Owns: `scripts/frank-archon.env`, `scripts/serve-frank-archon.sh`,
|
||||
`scripts/frank-archon.service`, `scripts/ttm-preflight.sh`, template
|
||||
`scripts/qwen38-froggeric-v22.5.jinja`; image digest-pinned
|
||||
`stilldeadcode/radiance@sha256:4c3f4845…`; container name = unit name.
|
||||
- Shape: TP=2 over `wht6`, `--max-num-seqs 3`, `--max-model-len 200000`,
|
||||
`expert_tiered` placement, 44,928 MiB pinned host pool per rank, VRAM pair
|
||||
`--vram-weights-mib 25600 --vram-kv-mib 5120` (ONE budget: weights + kv ≤
|
||||
30,969 MiB/card), `fp8` KV, MTP depth 2, MNBT 2048, prefix cache 8 GiB host
|
||||
+ 64 GiB disk, n-gram table pinned to RAM.
|
||||
`stilldeadcode/radiance@sha256:d590e300…` (radiance 1.8.1); container name = unit name.
|
||||
- Shape: TP=2 over `wht6`, `--max-num-seqs 2`, `--max-model-len 200000`,
|
||||
`expert_tiered` placement, 44,928 MiB pinned host pool per rank, `fp8` KV,
|
||||
MTP depth 2, MNBT 2048, `--busy-prefill-chunk auto`, prefix cache 8 GiB host
|
||||
tier only, n-gram table pinned to RAM. The per-card weights/KV split is derived
|
||||
by the engine's budget resolver from `--gpu-headroom-mib`, the cache bound and
|
||||
the host pool; the `--vram-weights-mib`/`--vram-kv-mib` pair and the prefix disk
|
||||
tier are gone (removed at 1.3.0 / 1.8.1) and naming either is a boot refusal,
|
||||
not a no-op.
|
||||
- Harness/gate contract: `models.yml` `contextWindow` MUST quote 200,000
|
||||
(over-length is refused, never truncated); the ~147 GiB pinned-RAM ledger
|
||||
is why this is a root unit, and `ttm-preflight.sh` raises
|
||||
@@ -800,6 +803,20 @@ dies, its page is destroyed. Rollback is a commit, not a document.
|
||||
`serverAppConfigs-docs/index.md` is the locator for every page that survives;
|
||||
retired scripts live in `old/` and `.AGENTTRASH/`.
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Issues live in Gitea (`dank/serverAppConfigs` on Dankclaw), via its REST API with the stored credential. See `serverAppConfigs-docs/Agent/Issue Tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
The five canonical roles keep their default strings (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `serverAppConfigs-docs/Agent/Triage Labels.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
Single-context: root `GLOSSARY.md` + `docs/adr/`, created lazily; this vault stays the owner of operational facts. See `serverAppConfigs-docs/Agent/Domain Docs.md`.
|
||||
|
||||
## Why
|
||||
|
||||
Direct edits on the remote drift from version control, and future agents
|
||||
|
||||
@@ -162,6 +162,25 @@ marreta.franksdank.cloud {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Open Notebook (guarded: Anubis → Authentik; notebook-proxy provider, internal_host caddy:8017) ---
|
||||
# The podcast generator. ONE upstream carries the whole app: the image's own
|
||||
# Next.js layer rewrites /api/* to the FastAPI beside it (INTERNAL_API_URL,
|
||||
# default http://localhost:5055 inside the container), so a browser on this
|
||||
# vhost never needs the API port. That is why scripts/open-notebook.env sets
|
||||
# API_URL to exactly this origin — left unset, the app auto-detects
|
||||
# `<host-you-opened>:5055`, and 5055 on this host belongs to seerr — and why
|
||||
# the published API port sits on loopback. App-level login
|
||||
# (OPEN_NOTEBOOK_PASSWORD) stays on behind the chain: two owners, edge identity
|
||||
# and the app's own session.
|
||||
# Dialled by published LAN port, not container name: open-notebook is a
|
||||
# rootless quadlet and this Caddy lives in root's storage, so root's DNS does
|
||||
# not see the container (the same reason marreta and SearXNG are dialled by IP).
|
||||
notebook.franksdank.cloud {
|
||||
reverse_proxy anubis:8923 {
|
||||
header_up X-Real-Ip {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Pi-hole (quadlet on slirp4netns; published 8083) ---
|
||||
pihole.franksdank.cloud {
|
||||
basicauth {
|
||||
@@ -420,6 +439,16 @@ http://:8017 {
|
||||
handle @marreta {
|
||||
reverse_proxy 192.168.0.143:8082
|
||||
}
|
||||
|
||||
@notebook host notebook.franksdank.cloud
|
||||
handle @notebook {
|
||||
# Bare proxy, like ai.franksdank.cloud: the app's SSE answers
|
||||
# (/api/search/ask, chat sessions) stream for minutes through this
|
||||
# same listener with no flush or timeout tuning, so nothing here
|
||||
# needs it — a read/write timeout on the route would be the thing
|
||||
# that cuts them.
|
||||
reverse_proxy 192.168.0.143:8502
|
||||
}
|
||||
}
|
||||
|
||||
# --- Optional: Jackett ---
|
||||
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/bin/sh
|
||||
# halogen-fabric-clock hold | release | status: the AMD GPU's fabric clock (FCLK), held at its top level.
|
||||
#
|
||||
# On Strix Halo the GPU and the NPU working at the same time can hang the machine, and corrupt the NPU's results,
|
||||
# while the GPU's fabric clock changes speed; held at its top level, the two run together cleanly. halogen-flash-server
|
||||
# with HALOGEN_NPU_MODELS refuses to start until the clock is held. This holds it: the performance level `manual` and
|
||||
# only the top pp_dpm_fclk level, on every amdgpu device that has the control (the GPU and SoC clocks stay automatic).
|
||||
# Cost: about 1 W at idle. Run as root; halogen-fabric-clock.service runs `hold` at every boot and after a resume.
|
||||
#
|
||||
# hold hold it (waits up to 60 s for the GPU driver to come up)
|
||||
# release give it back to the driver (the performance level `auto`)
|
||||
# status print each device's state; exit 0 when every device is held
|
||||
set -u
|
||||
SYS="${HALOGEN_SYSFS:-/sys}"
|
||||
|
||||
devices() {
|
||||
for d in "$SYS"/class/drm/card*/device; do
|
||||
[ -w "$d/pp_dpm_fclk" ] || [ -r "$d/pp_dpm_fclk" ] || continue
|
||||
[ -e "$d/power_dpm_force_performance_level" ] || continue
|
||||
echo "$d"
|
||||
done | sort -u
|
||||
}
|
||||
|
||||
held() { # held DEVICE_DIR: the performance level high, or manual with only the top fclk level selected
|
||||
perf=$(cat "$1/power_dpm_force_performance_level" 2>/dev/null)
|
||||
[ "$perf" = high ] && return 0
|
||||
[ "$perf" = manual ] || return 1
|
||||
top=$(grep . "$1/pp_dpm_fclk" | tail -n 1)
|
||||
[ "$(grep -c '\*' "$1/pp_dpm_fclk")" = 1 ] && [ "${top%\*}" != "$top" ]
|
||||
}
|
||||
|
||||
cmd="${1:-status}"
|
||||
case "$cmd" in
|
||||
hold)
|
||||
n=0
|
||||
while [ -z "$(devices)" ] && [ "$n" -lt 60 ]; do sleep 1; n=$((n + 1)); done
|
||||
[ -n "$(devices)" ] || { echo "halogen-fabric-clock: no amdgpu device with a fabric clock control (pp_dpm_fclk) under $SYS/class/drm" >&2; exit 1; }
|
||||
rc=0
|
||||
for d in $(devices); do
|
||||
lvl=$(grep . "$d/pp_dpm_fclk" | tail -n 1 | cut -d: -f1)
|
||||
# the driver applies the level several seconds after the write, so the read-back waits up to 30 s
|
||||
ok=0
|
||||
if echo manual > "$d/power_dpm_force_performance_level" && echo "$lvl" > "$d/pp_dpm_fclk"; then
|
||||
t=0; until held "$d" || [ "$t" -ge 300 ]; do sleep 0.1; t=$((t + 1)); done
|
||||
held "$d" && ok=1
|
||||
fi
|
||||
if [ "$ok" = 1 ]; then
|
||||
echo "halogen-fabric-clock: $d: held at level $lvl ($(grep . "$d/pp_dpm_fclk" | tail -n 1 | awk '{print $2}'))"
|
||||
else
|
||||
echo "halogen-fabric-clock: $d: could not hold it (level $lvl)" >&2; rc=1
|
||||
fi
|
||||
done
|
||||
exit "$rc" ;;
|
||||
release)
|
||||
for d in $(devices); do echo auto > "$d/power_dpm_force_performance_level" && echo "halogen-fabric-clock: $d: released (auto)"; done ;;
|
||||
status)
|
||||
[ -n "$(devices)" ] || { echo "halogen-fabric-clock: no amdgpu device with a fabric clock control"; exit 1; }
|
||||
rc=0
|
||||
for d in $(devices); do
|
||||
if held "$d"; then echo "$d: held ($(cat "$d/power_dpm_force_performance_level"))"
|
||||
else echo "$d: NOT held ($(cat "$d/power_dpm_force_performance_level"))"; rc=1; fi
|
||||
done
|
||||
exit "$rc" ;;
|
||||
*) echo "usage: halogen-fabric-clock hold | release | status" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,19 @@
|
||||
# Holds the AMD GPU's fabric clock at its top level at boot and after every resume, for halogen-flash-server with
|
||||
# the NPU (HALOGEN_NPU_MODELS): on Strix Halo the GPU and the NPU at once are safe only with that clock held.
|
||||
# Install (as root), from this directory:
|
||||
# install -m 755 halogen-fabric-clock /usr/local/sbin/
|
||||
# install -m 644 halogen-fabric-clock.service /etc/systemd/system/
|
||||
# systemctl daemon-reload && systemctl enable --now halogen-fabric-clock.service
|
||||
# Check: halogen-fabric-clock status. Undo: systemctl disable halogen-fabric-clock.service, then
|
||||
# halogen-fabric-clock release (or reboot). The unit runs once at boot and once after each resume (a resume can hand
|
||||
# the clock back to the driver); it holds nothing open, so it shows inactive between runs.
|
||||
[Unit]
|
||||
Description=Hold the AMD GPU fabric clock at its top level (GPU and NPU together)
|
||||
After=systemd-modules-load.service suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/halogen-fabric-clock hold
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target
|
||||
@@ -48,13 +48,22 @@
|
||||
# without `podman unshare` — accepted for a datastore nobody edits by hand;
|
||||
# episode AUDIO is a host directory for the opposite reason (see the app unit).
|
||||
#
|
||||
# HEALTH. The base image is distroless: no shell, no curl, no wget, so no
|
||||
# CMD-SHELL probe and no wget/curl probe is possible here (same class of unit
|
||||
# as loki, whose probe had to move into the image). SurrealDB ships its own
|
||||
# probe instead — `surreal is-ready` connects to ws://localhost:8000 by default
|
||||
# and needs no credentials — verified against this digest: it prints `OK` and
|
||||
# exits 0 while the server is up. Argument-only HealthCmd, like every unit on
|
||||
# this host, because the quadlet generator splits it on spaces.
|
||||
# HEALTH. The base image is distroless — measured against this digest, not even
|
||||
# `ls` exists (`exec: "ls": executable file not found in $PATH`), so there is no
|
||||
# `/bin/sh`, no curl and no wget. SurrealDB ships its own probe instead:
|
||||
# `surreal is-ready` connects to ws://localhost:8000 by default, needs no
|
||||
# credentials, prints `OK` and exits 0 while the server is up.
|
||||
#
|
||||
# It must be handed over as a QUADLET-SIDE JSON ARRAY, not a bare command.
|
||||
# podman wraps a bare `HealthCmd` string as `CMD-SHELL` (measured on this host's
|
||||
# podman 5.7: `--health-cmd "/surreal is-ready"` stores
|
||||
# `{"Test":["CMD-SHELL","/surreal is-ready"]}`), and with no `/bin/sh` in the
|
||||
# image that probe fails in ~30 ms with empty output on every interval while the
|
||||
# database answers perfectly — which is exactly how this service's first deploy
|
||||
# died on 2026-10-19 (`did not become healthy in 90s`). The quoted array survives
|
||||
# the generator (`--health-cmd "[\"/surreal\",\"is-ready\"]"`) and stores
|
||||
# `["CMD","/surreal","is-ready"]`, which execs directly and goes healthy. Do not
|
||||
# "simplify" it back to a bare command.
|
||||
|
||||
[Unit]
|
||||
Description=SurrealDB (open-notebook datastore)
|
||||
@@ -70,7 +79,7 @@ EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env
|
||||
EnvironmentFile=/home/dank/Containers/open-notebook/env
|
||||
Exec=start --log info rocksdb:/mydata/mydatabase.db
|
||||
Volume=open-notebook-db:/mydata
|
||||
HealthCmd=/surreal is-ready
|
||||
HealthCmd=["/surreal","is-ready"]
|
||||
HealthInterval=30s
|
||||
HealthTimeout=10s
|
||||
HealthRetries=3
|
||||
|
||||
@@ -33,17 +33,21 @@
|
||||
# and 5055 is exactly the port seerr already holds on this host
|
||||
# (`PublishPort=127.0.0.1:5055:5055`; `ss -ltn` on Dankclaw 2026-10-18 shows
|
||||
# 127.0.0.1:5055 taken). So the API leaves the container on host port 5056.
|
||||
# That forces one setting: the frontend auto-detects the API as
|
||||
# `<host-you-opened>:5055`, so API_URL in scripts/open-notebook.env must name
|
||||
# the remapped port explicitly, or the UI loads and every call fails. Both
|
||||
# ports are published on all interfaces because the consumer is a browser on
|
||||
# the LAN — the fleet's browser-facing services (open-webui, comfyui, gitea)
|
||||
# bind the same way, while loopback-only is reserved for services whose user
|
||||
# path is the guard chain. This service has NO vhost and no Caddy route: it is
|
||||
# internal-only until it is guarded, and its own password
|
||||
# (OPEN_NOTEBOOK_PASSWORD) is what stands in front of it. That password is not
|
||||
# decoration: with it unset the API answers everyone on the LAN, including the
|
||||
# unauthenticated episode-audio download.
|
||||
# Only 8502 is published on all interfaces, because it is the single upstream
|
||||
# the guard chain dials: notebook.franksdank.cloud → anubis → authentik →
|
||||
# http://:8017 → 192.168.0.143:8502 (dankclaw/Caddyfile), and this Caddy lives
|
||||
# in root's storage, so a published port is the only way it can reach a rootless
|
||||
# quadlet. The API needs no outward face at all — the image's own Next.js layer
|
||||
# rewrites /api/* to the FastAPI beside it, so one origin serves UI and API —
|
||||
# and the fleet's rule is that loopback-only is for services whose user path is
|
||||
# the guard chain, which this became on 2026-10-19; 5056 stays published on
|
||||
# loopback because the deployer's health gate dials it there. That is also why
|
||||
# API_URL in scripts/open-notebook.env names the vhost rather than a port: left
|
||||
# to itself the frontend auto-detects `<host-you-opened>:5055`, and the UI loads
|
||||
# while every call fails. OPEN_NOTEBOOK_PASSWORD is not made redundant by the
|
||||
# chain: it is what keeps the API honest for everything else on this host, and
|
||||
# with it unset the API answers anyone, including the unauthenticated
|
||||
# episode-audio download.
|
||||
#
|
||||
# VOLUMES. `/app/data` is a named volume: uploads, the LangGraph SQLite
|
||||
# checkpoints and the extraction caches the image keeps under it
|
||||
@@ -81,7 +85,7 @@ Pull=never
|
||||
Network=dankclaw_default
|
||||
Network=open-notebook.network
|
||||
PublishPort=0.0.0.0:8502:8502
|
||||
PublishPort=0.0.0.0:5056:5055
|
||||
PublishPort=127.0.0.1:5056:5055
|
||||
EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env
|
||||
EnvironmentFile=/home/dank/Containers/open-notebook/env
|
||||
Volume=open-notebook-data:/app/data
|
||||
|
||||
@@ -45,3 +45,4 @@
|
||||
192.168.0.143 pihole.franksdank.cloud
|
||||
192.168.0.143 seerr.franksdank.cloud
|
||||
192.168.0.143 marreta.franksdank.cloud
|
||||
192.168.0.143 notebook.franksdank.cloud
|
||||
@@ -0,0 +1,26 @@
|
||||
# /etc/udev/rules.d/98-halogen-npu-access.rules
|
||||
#
|
||||
# Why the NPU node is world-writable: `frank-dragoon` runs rootless podman under a
|
||||
# unit with `User=dank`, and the container's user namespace maps exactly one uid and
|
||||
# one gid (dank). `/dev/accel/accel0` is `root:render 0660`, so inside the container
|
||||
# the node reads `nobody:nogroup` and the engine dies with its own diagnosis:
|
||||
#
|
||||
# halogen npu: /dev/accel/accel0 is here, but this process cannot open it
|
||||
# (crw-rw---- nobody:nogroup).
|
||||
#
|
||||
# `--group-add keep-groups` is the vendor's advice and it does NOT work in this
|
||||
# shape: measured 2026-10-19 from the host as a `dank` shell that was already in
|
||||
# `render`, with and without the flag, the node still read `nobody:nogroup` and the
|
||||
# open was refused — an unmapped gid cannot be granted to a process inside a
|
||||
# namespace that does not map it. Same conclusion `99-comfyui-gpu-access.rules`
|
||||
# reached for `/dev/kfd` from the systemd-user-spawn direction: the mode is the only
|
||||
# spawn-context-independent door short of running the stack rootful.
|
||||
#
|
||||
# Blast radius: any process in this host's single uid domain (every rootless
|
||||
# container here shares it) can submit to the XDNA2 NPU. The NPU holds no user data
|
||||
# and no privileged context — the same trade already accepted for the GPU compute
|
||||
# nodes beside it. Installed by `deploy-remote.sh` when the NPU lane is armed and left
|
||||
# in place afterwards — the mode costs nothing while the lane is idle. `serve-frank-dragoon.sh`
|
||||
# refuses to arm the lane on a node that is not other-accessible, so a host that lost this
|
||||
# file fails loudly at boot instead of crash-looping.
|
||||
KERNEL=="accel[0-9]*", SUBSYSTEM=="accel", MODE="0666"
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Set the authentik login-session TTL on Dankclaw.
|
||||
#
|
||||
# The login-session lifetime is governed by the `default-authentication-login`
|
||||
# stage's `session_duration`, NOT a global setting and NOT SESSION_COOKIE_AGE
|
||||
# (that is the pre-login session, sessions.unauthenticated_age). Authentik does
|
||||
# not expose SESSION_SAVE_EVERY_REQUEST at all, so the session is an absolute
|
||||
# window from login, not a sliding one.
|
||||
#
|
||||
# The field is a STRING in authentik's own idiom ("days=7", "seconds=30",
|
||||
# "hours=12") — not a Python timedelta. Writing str(timedelta(days=7)) stores
|
||||
# "7 days, 0:00:00", which authentik's timedelta_from_string parser REJECTS
|
||||
# (ValueError), and login then crashes computing the session expiry. Always pass
|
||||
# the "key=value" form; this script validates it before writing.
|
||||
#
|
||||
# Existing sessions keep their old expiry; the new TTL applies from the next
|
||||
# login. This is DB state — it is NOT repo-managed and a blueprint re-import or
|
||||
# rebuild-from-scratch reverts it. Run this again after such a rebuild.
|
||||
#
|
||||
# Usage: authentik-set-login-session-duration.sh ["days=7"] (default days=7)
|
||||
set -euo pipefail
|
||||
|
||||
DUR="${1:-days=7}"
|
||||
STAGE="default-authentication-login"
|
||||
HOST="${AUTHENTIK_HOST:-dankclaw}"
|
||||
|
||||
# Refuse anything that is not the safe key=value duration idiom.
|
||||
if ! printf '%s' "$DUR" | grep -Eq '^[A-Za-z]+=[0-9]+([ ]+[A-Za-z]+=[0-9]+)*$'; then
|
||||
echo "invalid duration '$DUR' — use authentik form, e.g. 'days=7' or 'seconds=30'" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
ssh "$HOST" 'podman exec -i authentik-server ak shell' <<PYEOF
|
||||
from django.apps import apps
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
dur = "$DUR"
|
||||
timedelta_from_string(dur) # validate the form before touching the row
|
||||
ULS = apps.get_model("authentik_stages_user_login", "UserLoginStage")
|
||||
s = ULS.objects.get(name="$STAGE")
|
||||
s.session_duration = dur
|
||||
s.save(update_fields=["session_duration"])
|
||||
back = ULS.objects.get(name="$STAGE").session_duration
|
||||
assert back == dur, "persisted value mismatch: %r" % back
|
||||
print("session_duration set to", repr(back), "->", timedelta_from_string(back))
|
||||
PYEOF
|
||||
|
||||
echo "Login-session TTL for '$STAGE' is now $DUR (applies on next login)."
|
||||
@@ -293,6 +293,17 @@ def services_for_path(path: str, target: str) -> set[str]:
|
||||
if len(parts) >= 2 and parts[1] in {"caddy", "comfyui", "loki", "alloy", "hermes", "anubis", "seerr"}:
|
||||
_add(services, parts[1])
|
||||
return services
|
||||
if len(parts) >= 3 and parts[1] == "host":
|
||||
# Host state a service needs at boot lives under dankclaw/host/.
|
||||
# The fabric-clock hold is frank-dragoon's NPU precondition (the
|
||||
# engine refuses to start with HALOGEN_NPU_MODELS set while the
|
||||
# clock moves) and the deployer installs and gates it, so changing
|
||||
# it re-dispatches that endpoint. `grub` is the tracked boot line,
|
||||
# which moves only through scripts/set-dankclaw-bootline.sh and is
|
||||
# a reboot decision — it plans no service.
|
||||
if parts[2].startswith("halogen-fabric-clock"):
|
||||
_add(services, "frank-dragoon")
|
||||
return services
|
||||
if len(parts) >= 4 and parts[1] == "authentik" and parts[2] == "quadlet":
|
||||
name = parts[3].removesuffix(".container")
|
||||
if name in {"authentik-postgres", "authentik-server", "authentik-worker", "authentik-outpost", "authentik-outpost-ldap"}:
|
||||
|
||||
@@ -1329,6 +1329,9 @@ deploy_anubis() {
|
||||
# port gate is the same as every cutover since 2026-09-16: nothing
|
||||
# starts until 7770 is released.
|
||||
LLM_DRAGOON_IMAGE="${LLM_DRAGOON_IMAGE:-ghcr.io/peonist-ai/halogen-flash-server@sha256:0c61bf84ac22308a53f5d1ca6b86806702d7039e5ebc51cae4c66621b92fe04a}"
|
||||
# The payload dir is its own twin because the NPU lane's containment gate below
|
||||
# reads it; every artifact path under it stays a full literal as usual.
|
||||
LLM_DRAGOON_MODEL_DIR="${LLM_DRAGOON_MODEL_DIR:-/home/dank/models/halogen-v2-abliterated}"
|
||||
LLM_DRAGOON_CKPT="${LLM_DRAGOON_CKPT:-/home/dank/models/halogen-v2-abliterated/qwen38-flash-next-v2-abliterated.hgn}"
|
||||
LLM_DRAGOON_CKPT_BYTES="${LLM_DRAGOON_CKPT_BYTES:-66687678432}"
|
||||
LLM_DRAGOON_NGRAM="${LLM_DRAGOON_NGRAM:-/home/dank/models/halogen-v2-abliterated/qwen38-flash-next-ngram.hgn}"
|
||||
@@ -1337,6 +1340,18 @@ LLM_DRAGOON_VISION="${LLM_DRAGOON_VISION:-/home/dank/models/halogen-v2-abliterat
|
||||
LLM_DRAGOON_VISION_BYTES="${LLM_DRAGOON_VISION_BYTES:-897916416}"
|
||||
LLM_DRAGOON_TOK_DIR="${LLM_DRAGOON_TOK_DIR:-/home/dank/models/halogen-v2-abliterated/tokenizer}"
|
||||
LLM_DRAGOON_TOK_FILES="${LLM_DRAGOON_TOK_FILES:-tokenizer.json chat_template.jinja tokenizer_config.json merges.txt vocab.json generation_config.json}"
|
||||
# NPU lane (2026-10-19). HALOGEN_NPU_MODELS is the whole switch and the parity
|
||||
# twin of the env file's line: empty here AND in the env = the pre-lane
|
||||
# endpoint. The gates below mirror serve-frank-dragoon.sh, and the vendor's
|
||||
# fabric-clock host unit (dankclaw/host/, verbatim at tag v0.16.2) is installed
|
||||
# ONLY while the lane is named — on Strix Halo the iGPU and the NPU co-running
|
||||
# hang the machine while the fabric clock moves, so halogen refuses to boot with
|
||||
# the lane armed unless that clock is held.
|
||||
LLM_DRAGOON_NPU_MODELS="${LLM_DRAGOON_NPU_MODELS:-qwen3-embedding-0.6b}"
|
||||
LLM_DRAGOON_NPU_DIR="${LLM_DRAGOON_NPU_DIR:-/home/dank/models/halogen-v2-abliterated/npu}"
|
||||
LLM_DRAGOON_DEV_ACCEL="${LLM_DRAGOON_DEV_ACCEL:-/dev/accel/accel0}"
|
||||
LLM_DRAGOON_XRT_DIR="${LLM_DRAGOON_XRT_DIR:-/usr/lib/x86_64-linux-gnu}"
|
||||
LLM_DRAGOON_NETWORK="${LLM_DRAGOON_NETWORK:-dankclaw_default}"
|
||||
|
||||
retire_halogen_dragoon_quadlet() {
|
||||
local trash="$HOME/.AGENTTRASH/20260919-orca-cutover/quadlet"
|
||||
@@ -1415,6 +1430,77 @@ deploy_llm_dragoon() {
|
||||
fi
|
||||
done
|
||||
|
||||
# The container network has to exist before the run line names it (a typo
|
||||
# there is a restart-looping unit, not a config error anyone reads).
|
||||
if [[ -n "$LLM_DRAGOON_NETWORK" ]] && ! podman network exists "$LLM_DRAGOON_NETWORK"; then
|
||||
echo "ERROR: podman network $LLM_DRAGOON_NETWORK does not exist" >&2
|
||||
echo "frank-dragoon: create it or blank FRANK_DRAGOON_NETWORK (pasta default)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- NPU lane: the same predicates the serve script checks --------------
|
||||
if [[ -n "$LLM_DRAGOON_NPU_MODELS" ]]; then
|
||||
# One door into the container: the read-only /models mount of the
|
||||
# payload dir. A staging dir outside it needs a nested bind whose
|
||||
# mountpoint runc cannot create through a read-only parent, so the
|
||||
# parity twin of the serve gate refuses the shape before the restart.
|
||||
if [[ "$LLM_DRAGOON_NPU_DIR/" != "$LLM_DRAGOON_MODEL_DIR"/* ]]; then
|
||||
echo "ERROR: NPU staging dir $LLM_DRAGOON_NPU_DIR is outside the payload dir $LLM_DRAGOON_MODEL_DIR — the lane's files reach the container only through the read-only /models mount" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -e "$LLM_DRAGOON_DEV_ACCEL" ]]; then
|
||||
echo "ERROR: NPU node $LLM_DRAGOON_DEV_ACCEL absent — amdxdna is not bound (is iommu=pt still on the boot line?)" >&2
|
||||
return 1
|
||||
fi
|
||||
# Access, not existence: a rootless container cannot open a root:render
|
||||
# 0660 node (its namespace maps one uid/gid, and keep-groups cannot grant
|
||||
# an unmapped gid — measured 2026-10-19). Install the mode rule, re-trigger
|
||||
# the node, then PROVE the node is other-accessible before the restart.
|
||||
run_privileged install -m 0644 dankclaw/udev/98-halogen-npu-access.rules \
|
||||
/etc/udev/rules.d/98-halogen-npu-access.rules
|
||||
run_privileged udevadm control --reload-rules
|
||||
run_privileged udevadm trigger --sysname-match="$(basename "$LLM_DRAGOON_DEV_ACCEL")"
|
||||
# (`-o FILE` is not "other-writable" — it means "exists", and `-o` is also
|
||||
# the OR operator inside test), so the bit is read straight off the mode.
|
||||
if [[ $(( 8#$(stat -c '%a' "$LLM_DRAGOON_DEV_ACCEL") & 8#2 )) -eq 0 ]]; then
|
||||
echo "ERROR: $LLM_DRAGOON_DEV_ACCEL is still $(stat -c '%a %U:%G' "$LLM_DRAGOON_DEV_ACCEL") after installing the access rule — the lane cannot open it rootless" >&2
|
||||
return 1
|
||||
fi
|
||||
for xl in libxrt_coreutil.so.2 libxrt_core.so.2 libxrt_driver_xdna.so.2; do
|
||||
if [[ ! -e "$LLM_DRAGOON_XRT_DIR/$xl" ]]; then
|
||||
echo "ERROR: XRT NPU library $LLM_DRAGOON_XRT_DIR/$xl absent (Ubuntu libxrt2 + libxrt-npu2)" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
for nm in ${LLM_DRAGOON_NPU_MODELS//,/ }; do
|
||||
if [[ ! -d "$LLM_DRAGOON_NPU_DIR/$nm" ]]; then
|
||||
echo "ERROR: NPU model $nm not staged in $LLM_DRAGOON_NPU_DIR" >&2
|
||||
echo "frank-dragoon: stage it: bash scripts/download-frank-dragoon-npu.sh" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
# The hold must exist BEFORE the engine starts and survive every boot
|
||||
# (a resume hands the clock back too — the unit's WantedBy covers it).
|
||||
run_privileged install -m 0755 dankclaw/host/halogen-fabric-clock \
|
||||
/usr/local/sbin/halogen-fabric-clock
|
||||
run_privileged install -m 0644 dankclaw/host/halogen-fabric-clock.service \
|
||||
/etc/systemd/system/halogen-fabric-clock.service
|
||||
run_privileged systemctl daemon-reload
|
||||
run_privileged systemctl enable --now halogen-fabric-clock.service
|
||||
if ! run_privileged /usr/local/sbin/halogen-fabric-clock status; then
|
||||
echo "ERROR: fabric clock is not held — halogen refuses to start with HALOGEN_NPU_MODELS set" >&2
|
||||
echo "frank-dragoon: journalctl -u halogen-fabric-clock.service -n 40" >&2
|
||||
return 1
|
||||
fi
|
||||
elif [[ -x /usr/local/sbin/halogen-fabric-clock ]]; then
|
||||
# Lane rolled back: hand the clock back to the driver and drop the boot
|
||||
# hook. `disable` deletes the INSTALLED copy of this unit — unlike the
|
||||
# tracked LLM unit symlinks the fleet's disable law protects, this file
|
||||
# is repo-owned and the next lane-on deploy reinstalls it.
|
||||
run_privileged systemctl disable halogen-fabric-clock.service 2>/dev/null || true
|
||||
run_privileged /usr/local/sbin/halogen-fabric-clock release || true
|
||||
fi
|
||||
|
||||
retire_halogen_dragoon_quadlet
|
||||
# The predecessor on a re-deploy is THIS unit: stop it (ExecStop stops
|
||||
# the container) before waiting on the port. The stray sweep is keyed
|
||||
@@ -1434,6 +1520,33 @@ deploy_llm_dragoon() {
|
||||
run_privileged systemctl enable frank-dragoon.service
|
||||
run_privileged systemctl restart frank-dragoon.service
|
||||
wait_for_health frank-dragoon http://127.0.0.1:7770/health 1200
|
||||
# The lane's own gate. /health proves the engine is up, nothing about the
|
||||
# NPU programs; one real embedding is the observable output. The first
|
||||
# request after a boot is a warmup artifact (fleet law), so retry before
|
||||
# calling a booted lane dead.
|
||||
if [[ -n "$LLM_DRAGOON_NPU_MODELS" ]]; then
|
||||
for nm in ${LLM_DRAGOON_NPU_MODELS//,/ }; do
|
||||
lane_ok=0
|
||||
for _try in 1 2 3 4 5; do
|
||||
if curl -fsS -m 60 -H 'content-type: application/json' \
|
||||
-d "{\"model\":\"$nm\",\"input\":[\"frank-dragoon npu lane gate\"]}" \
|
||||
http://127.0.0.1:7770/v1/embeddings 2>/dev/null \
|
||||
| python3 -c 'import json,sys
|
||||
v = json.load(sys.stdin)["data"][0]["embedding"]
|
||||
raise SystemExit(0 if isinstance(v, list) and len(v) and all(isinstance(x, (int, float)) for x in v) else 1)'; then
|
||||
lane_ok=1
|
||||
break
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
if [[ "$lane_ok" != 1 ]]; then
|
||||
echo "ERROR: NPU model $nm answered no vector on /v1/embeddings" >&2
|
||||
echo "frank-dragoon: read the boot ledger: tail -100 /home/dank/logs/frank_dragoon.log" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "frank-dragoon: NPU lane verified — $nm embedded"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# deploy_llm_zealot — frank-zealot is llama.cpp (PrismML fork build) on the
|
||||
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
# download-frank-dragoon-npu.sh — stage the NPU half of frank-dragoon: the
|
||||
# vendor-precompiled AIE programs halogen-flash-server runs on the XDNA2 NPU
|
||||
# when HALOGEN_NPU_MODELS names them. These are NOT model weights you can
|
||||
# quantize yourself: XDNA2 executes only programs compiled by AMD's AIE
|
||||
# compiler, so the only source is peonist-ai's per-model NPU repo, and the
|
||||
# engine checks every file's size and sha256 against its own catalog at boot
|
||||
# (/opt/halogen/npu/models.txt inside the image) and refuses to load a model
|
||||
# whose files do not match.
|
||||
#
|
||||
# The manifest below IS that catalog block (dumped from the pinned 0.16.2
|
||||
# image on 2026-10-19), so the files land in the layout the entrypoint
|
||||
# expects — <dir>/<model-id>/devices/*, <dir>/<model-id>/<model-id>.hnpw and
|
||||
# <dir>/<model-id>/tokenizer/tokenizer.json — and the endpoint can keep
|
||||
# /models read-only and open no outbound connection at boot (HALOGEN_DOWNLOAD
|
||||
# stays unset; the vendor's own README: with it unset the container makes no
|
||||
# outbound connections at all).
|
||||
#
|
||||
# The fetch law, same as every sibling script: curl -f (an access error must
|
||||
# not become a "successful" 153-byte artifact), the exact byte count and the
|
||||
# sha256 from the manifest, --verify-only to re-check a staged set.
|
||||
#
|
||||
# Adding a second NPU model: read its block out of the image's catalog and add
|
||||
# a case below —
|
||||
# podman exec frank-dragoon-halogen grep "^file <model-id> " /opt/halogen/npu/models.txt
|
||||
# podman exec frank-dragoon-halogen grep "^model <model-id> " /opt/halogen/npu/models.txt
|
||||
# and add the model id to HALOGEN_NPU_MODELS in frank-dragoon.env.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${FRANK_DRAGOON_ENV_FILE:-$SCRIPT_DIR/frank-dragoon.env}"
|
||||
# The env file owns the staging path (it must sit inside the payload dir, which
|
||||
# is the container's only /models mount); an exported FRANK_DRAGOON_NPU_DIR wins
|
||||
# so the bats seam can stage somewhere disposable.
|
||||
DEST="${FRANK_DRAGOON_NPU_DIR:-}"
|
||||
if [ -z "$DEST" ] && [ -f "$ENV_FILE" ]; then
|
||||
DEST="$(sed -nE 's/^FRANK_DRAGOON_NPU_DIR=(.*)$/\1/p' "$ENV_FILE" | tail -1)"
|
||||
fi
|
||||
: "${DEST:?FRANK_DRAGOON_NPU_DIR is not set and $ENV_FILE names no FRANK_DRAGOON_NPU_DIR}"
|
||||
HF="https://huggingface.co"
|
||||
|
||||
MODELS="${HALOGEN_NPU_MODELS:-}"
|
||||
if [ -z "$MODELS" ] && [ -f "$ENV_FILE" ]; then
|
||||
MODELS="$(sed -nE 's/^HALOGEN_NPU_MODELS=(.*)$/\1/p' "$ENV_FILE" | tail -1)"
|
||||
fi
|
||||
|
||||
VERIFY_ONLY=0
|
||||
case "${1:-}" in
|
||||
--verify-only) VERIFY_ONLY=1 ;;
|
||||
"") ;;
|
||||
*) echo "usage: ${0##*/} [--verify-only] (models come from HALOGEN_NPU_MODELS)" >&2; exit 2 ;;
|
||||
esac
|
||||
[ -n "$MODELS" ] || { echo "no NPU model named — set HALOGEN_NPU_MODELS or run with it in the environment" >&2; exit 1; }
|
||||
|
||||
# manifest <model-id> -> repo revision + one "path bytes sha256" per file.
|
||||
manifest() {
|
||||
case "$1" in
|
||||
qwen3-embedding-0.6b)
|
||||
NPU_REPO="peonist-ai/halogen-npu-qwen3-embedding-0.6b"
|
||||
NPU_REV="4960fd9d4423dd15373ad21c83db48dbdb3b2798"
|
||||
FILES=(
|
||||
"devices/NOTICE.md 3248 0f267b3d2523ddf14997ee58660a492a9eaf10ccf6b08bf19dfdc85b9a478b91"
|
||||
"devices/consts.hnpb 5086496 ae430e41545a0b1012938cda373b0c450364d3be631111cca3a8c43679f93336"
|
||||
"devices/devices.hnpm 24047 4e9bca9d95d084fb89ce5b99f579a00666c8ca1efae2613c53e3a4878cc9f174"
|
||||
"devices/u0.elf 4931968 b38f165886cb037b7d3951d9cb878473eaba58a432acd45416ff85fd88aa4e46"
|
||||
"devices/u1.elf 5456000 b1811feee851b8af833b37721da92099d89772110815bbbe2c3fa1c276f0ff42"
|
||||
"devices/u2.elf 6505552 406ea9a95b470628a7382e376f4a72cdf4d7aa4c59b4ddf0a4bdd139d63d6ff2"
|
||||
"devices/u3.elf 6817216 bc205d51b523d770151a0c61968f7c70bf624412827580bb6fe0aaf4650dc2ee"
|
||||
"devices/u4.elf 7444032 a5a91338a2603acdcd5c11df532033fda2d3f5479ea44ae87f98fafbdff9a62d"
|
||||
"devices/u5.elf 7432416 6c2bf41835569e0eba25ded7c05c7264ae69c6700bdeb4791057d884f2a4c126"
|
||||
"devices/u6.elf 4941232 919d0038ca088eafddf195f3466ff38db27d3635be1cffa5d4d4b5140902a68e"
|
||||
"devices/u7.elf 5488304 d904cf8afae9428dd167960549e18cce5441745f18c62f8c50975dffc077d3c2"
|
||||
"devices/u8.elf 4941232 db3aef69b17825854ee0dd2df475125a3f5c872f95c686793d6af1ee83596fa7"
|
||||
"devices/u9.elf 5488304 d182727e551084db8fb9600441d8aa373c7f09a3d267ae0a5e921ef92326db1c"
|
||||
"qwen3-embedding-0.6b.hnpw 813032248 b9f9901429df7b46ec17153aa3899f738adbea8e4356080da0c6e6586fb750d1"
|
||||
"tokenizer/tokenizer.json 11423705 def76fb086971c7867b829c23a26261e38d9d74e02139253b38aeb9df8b4b50a"
|
||||
) ;;
|
||||
*)
|
||||
echo "unknown NPU model '$1' — add its catalog block to manifest() in this script" >&2
|
||||
return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
for MODEL in ${MODELS//,/ }; do
|
||||
manifest "$MODEL" || exit 1
|
||||
echo "== $MODEL ($NPU_REPO@$NPU_REV) =="
|
||||
for entry in "${FILES[@]}"; do
|
||||
read -r f want sha <<<"$entry"
|
||||
path="$DEST/$MODEL/$f"
|
||||
mkdir -p "$(dirname "$path")"
|
||||
if [ "$VERIFY_ONLY" -eq 0 ]; then
|
||||
if [ -f "$path" ] && [ "$(stat -c %s "$path")" = "$want" ]; then
|
||||
echo "HAVE $f ($want bytes) — skipping fetch"
|
||||
else
|
||||
echo "FETCH $f -> $path"
|
||||
# HF answers /resolve/ with a 302 to the Xet CDN and curl
|
||||
# DROPS the Range header across redirects, so resolve the
|
||||
# effective URL first and resume against the CDN (the sibling
|
||||
# 62 GiB fetch hit this on 2026-10-18).
|
||||
url=$(curl -sfIL -o /dev/null -w '%{url_effective}' "$HF/$NPU_REPO/resolve/$NPU_REV/$f") \
|
||||
|| { echo "cannot resolve $NPU_REPO/$f" >&2; exit 1; }
|
||||
curl -fL --retry 5 -C - -o "$path" "$url"
|
||||
fi
|
||||
fi
|
||||
[ -f "$path" ] || { echo "MISSING $path (run without --verify-only)" >&2; exit 1; }
|
||||
if [ -L "$path" ]; then
|
||||
echo "SYMLINK $path — the serve gate refuses symlinks; replace with a real file" >&2
|
||||
exit 1
|
||||
fi
|
||||
got="$(stat -c %s "$path")"
|
||||
[ "$got" = "$want" ] || { echo "SIZE $path: $got != $want" >&2; exit 1; }
|
||||
got="$(sha256sum "$path" | cut -d' ' -f1)"
|
||||
[ "$got" = "$sha" ] || { echo "SHA256 $path: $got != $sha" >&2; exit 1; }
|
||||
echo "OK $f ($want bytes, sha256)"
|
||||
done
|
||||
done
|
||||
echo "DOWNLOAD_DONE $DEST ($MODELS)"
|
||||
@@ -80,3 +80,48 @@ HALOGEN_TOP_K=20
|
||||
# Weights stay pinned: the engine pins by default and only the retired w4b build
|
||||
# honored HALOGEN_FLASH_PIN_TRUNK=0, which this payload rejects.
|
||||
HALOGEN_WEIGHTS_LOCK=1
|
||||
|
||||
# --- NPU lane (XDNA2) -------------------------------------------------------
|
||||
# HALOGEN_NPU_MODELS is the whole switch: EMPTY or unset = the endpoint exactly
|
||||
# as it ran before this lane existed (no accel device, no XRT mount, no
|
||||
# fabric-clock dependency, no /v1/embeddings). Rolling the lane back is blanking
|
||||
# this one line and re-deploying — nothing else moves.
|
||||
# Names are the vendor catalog's folder names (`/opt/halogen/npu/models.txt`
|
||||
# inside the image) and the engine reads a model ONLY at /models/npu/<name>/ —
|
||||
# HALOGEN_NPU_DIR is gone from the image and setting it is a boot refusal.
|
||||
# The engine cannot fetch anything here: /models is read-only and
|
||||
# HALOGEN_DOWNLOAD stays unset so the container opens no outbound connection at
|
||||
# boot, so scripts/download-frank-dragoon-npu.sh must stage the files first
|
||||
# (the serve gate and the deployer both refuse by name when a named model is
|
||||
# missing). FRANK_DRAGOON_NPU_DIR therefore lives INSIDE the payload dir: the
|
||||
# payload mount is the container's only handle on /models, and runc cannot
|
||||
# mkdir a nested /models/npu mountpoint through a read-only bind — the lane's
|
||||
# first boot died on precisely that (2026-10-19). Swap the payload dir and this
|
||||
# path moves with it.
|
||||
# Cost: ~1.5 GiB extra pinned host RAM per named model, and a hard dependency on
|
||||
# the host holding the GPU fabric clock — on Strix Halo the iGPU and the NPU
|
||||
# together hang the machine (and corrupt NPU results) while FCLK changes speed,
|
||||
# so halogen refuses to start with this variable set unless the clock is held
|
||||
# (dankclaw/host/halogen-fabric-clock; the deployer installs and gates it).
|
||||
HALOGEN_NPU_MODELS=qwen3-embedding-0.6b
|
||||
FRANK_DRAGOON_NPU_DIR=/home/dank/models/halogen-v2-abliterated/npu
|
||||
# Node paths and the XRT directory are variables so the bats seam can drive
|
||||
# these gates on a box with no NPU. Ubuntu's distro XRT (libxrt2 + libxrt-npu2)
|
||||
# is mounted FILE BY FILE and TWICE EACH — once where the image looks for XRT,
|
||||
# once at its own path, because those libraries find each other at their install
|
||||
# path; the vendor forbids mounting an /opt/xilinx/xrt of symlinks into the
|
||||
# system dir, which dangle inside the container.
|
||||
FRANK_DRAGOON_DEV_ACCEL=/dev/accel/accel0
|
||||
FRANK_DRAGOON_XRT_DIR=/usr/lib/x86_64-linux-gnu
|
||||
# sysfs root the fabric-clock preflight reads (same knob the vendor's
|
||||
# halogen-fabric-clock status reads; the seam points it at a fixture).
|
||||
FRANK_DRAGOON_SYSFS=/sys
|
||||
# Container network. Empty = podman's rootless default (`pasta`): a private
|
||||
# netns that nothing else can join — podman refuses a second network while the
|
||||
# primary is pasta, the published port is refused from dankclaw_default
|
||||
# containers, and host.containers.internal resolves link-local, which
|
||||
# open-notebook's URL guard refuses to store. Named = the app bridge, so
|
||||
# open-notebook can dial http://frank-dragoon-halogen:8731 for the NPU
|
||||
# endpoints. The engine's unauthenticated 8730 stays private either way (it
|
||||
# binds the container's own loopback); only the API port becomes name-resolvable.
|
||||
FRANK_DRAGOON_NETWORK=dankclaw_default
|
||||
+27
-17
@@ -1,5 +1,6 @@
|
||||
# open-notebook.env — configuration for **open-notebook**, the fleet's podcast
|
||||
# generator on Dankclaw (UI host port **8502**, API host port **5056**). Read by
|
||||
# generator on Dankclaw (user path https://notebook.franksdank.cloud; UI host
|
||||
# port **8502**, API host port **5056 loopback-only**). Read by
|
||||
# BOTH Quadlet units (dankclaw/open-notebook/quadlet/) as the FIRST
|
||||
# EnvironmentFile= line, which is why every path and host name below is the
|
||||
# value the CONTAINER must see.
|
||||
@@ -47,13 +48,17 @@
|
||||
#
|
||||
# THE SCRIPTWRITER. ESPERANTO_LLM_TIMEOUT is the app's own default (180 s), kept
|
||||
# visible beside the TTS number because both belong to one podcast run: the
|
||||
# outline call and the transcript call are the slow text work, and frank-zealot
|
||||
# answers them well inside 180. Provider choice is made in the UI, not here —
|
||||
# see [[Open Notebook]] for the one-time credential provisioning — and it is
|
||||
# frank-zealot on purpose: script writing asks for a JSON-schema response, which
|
||||
# the OpenAI-compatible llama.cpp endpoint is known to serve, while halogen's
|
||||
# structured-output behaviour on frank-dragoon is UNVERIFIED. halogen remains
|
||||
# the unproven alternative, not the fallback.
|
||||
# outline call and the transcript call are the slow text work, and the endpoint
|
||||
# provisioned today answers them well inside 180. The provider itself is chosen
|
||||
# in the database, not here — [[Open Notebook]] owns that provisioning and the
|
||||
# address census behind it. Short version: frank-zealot was the plan (script
|
||||
# writing asks for a JSON-schema response, which llama.cpp serves and halogen's
|
||||
# behaviour on frank-dragoon is UNVERIFIED), but no Dankclaw LLM is dialable from
|
||||
# this container's netns, and the single address that reaches one —
|
||||
# `host.containers.internal`, 169.254.1.2 — the app refuses to store at all,
|
||||
# because link-local is a blocked address class. The writer is therefore
|
||||
# frank-archon (`http://192.168.0.235:7770/v1`), the only fleet LLM that both
|
||||
# answers from inside this netns and passes that check.
|
||||
#
|
||||
# THE DATABASE COORDINATES. SURREAL_URL addresses the sibling container BY NAME
|
||||
# on systemd-open-notebook (container DNS is scoped to the owning network, so
|
||||
@@ -73,15 +78,20 @@ SURREAL_PASS=
|
||||
OPEN_NOTEBOOK_ENCRYPTION_KEY=
|
||||
OPEN_NOTEBOOK_PASSWORD=
|
||||
|
||||
# WHERE THE BROWSER FINDS THE API. The UI auto-detects `<host-you-opened>:5055`,
|
||||
# and 5055 on this host belongs to seerr, so the API leaves the container on 5056
|
||||
# and this line has to say so. Both origins are listed in CORS_ORIGINS because
|
||||
# the API is called cross-origin from the UI's own port: the LAN origin is the
|
||||
# normal one, the localhost one covers a browser opened on Dankclaw itself. The
|
||||
# app logs a warning at start while CORS_ORIGINS is unset and defaults to `*`,
|
||||
# which is a wildcard in front of an endpoint that serves every episode's audio.
|
||||
API_URL=http://192.168.0.143:5056
|
||||
CORS_ORIGINS=http://192.168.0.143:8502,http://localhost:8502
|
||||
# WHERE THE BROWSER FINDS THE API. The guarded vhost
|
||||
# (notebook.franksdank.cloud → Caddy → 8502) is the only user path, and the
|
||||
# image's own Next.js layer rewrites `/api/*` to the FastAPI running beside it,
|
||||
# so ONE origin serves UI and API and API_URL has to name that vhost. This is
|
||||
# not cosmetic: unset, the app auto-detects `<host-you-opened>:5055` — the port
|
||||
# upstream ships, and the one seerr holds on this host — and hands the browser
|
||||
# an address nothing answers; pointed at the LAN `:5056` instead, every call
|
||||
# leaves the guarded origin and an authenticated browser session does not ride
|
||||
# it. Same-origin traffic needs no CORS at all, but CORS_ORIGINS stays set to
|
||||
# the one origin because the app warns at start while it is unset and defaults
|
||||
# to `*` — a wildcard in front of an endpoint that serves every episode's
|
||||
# audio. No trailing slash: the UI concatenates `${API_URL}/api/...`.
|
||||
API_URL=https://notebook.franksdank.cloud
|
||||
CORS_ORIGINS=https://notebook.franksdank.cloud
|
||||
|
||||
# Concurrency: see "THE CPU-SHUTDOWN GUARD" above. Defaults are 5 and 5.
|
||||
TTS_BATCH_SIZE=1
|
||||
|
||||
@@ -45,7 +45,7 @@ set +a
|
||||
: "${FRANK_ARCHON_IMAGE:?FRANK_ARCHON_IMAGE must name the radiance image}"
|
||||
: "${FRANK_ARCHON_MODEL_DIR:?FRANK_ARCHON_MODEL_DIR must name the .rad dir}"
|
||||
: "${FRANK_ARCHON_MODEL_FILE:?FRANK_ARCHON_MODEL_FILE must name the .rad file}"
|
||||
: "${FRANK_ARCHON_STATE_DIR:?FRANK_ARCHON_STATE_DIR must name the /data state dir}"
|
||||
: "${FRANK_ARCHON_STATE_DIR:?FRANK_ARCHON_STATE_DIR must name the host dir holding the rad-tune cache}"
|
||||
: "${FRANK_ARCHON_CONTAINER:?FRANK_ARCHON_CONTAINER must name the container}"
|
||||
: "${FRANK_ARCHON_SERVED_NAME:?FRANK_ARCHON_SERVED_NAME must name the alias}"
|
||||
: "${FRANK_ARCHON_PORT:?FRANK_ARCHON_PORT must be the port}"
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
# the flash-server line reads it; it ships as a container; the root unit
|
||||
# keeps LimitMEMLOCK=infinity for parity and to outlive the login session,
|
||||
# exactly like frank-archon's.
|
||||
# Since 2026-10-19 the container also carries the XDNA2 NPU lane
|
||||
# (HALOGEN_NPU_MODELS): the vendor's precompiled AIE programs for the models
|
||||
# named there run on the NPU inside THIS container and answer /v1/embeddings,
|
||||
# beside the Flash model, on the same port. Opt-in — the variable empty is the
|
||||
# pre-lane endpoint, byte for byte.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -115,6 +120,119 @@ if [ ! -e "$DEV_KFD" ] || [ ! -d "$DEV_DRI" ]; then
|
||||
echo "frank-dragoon: $DEV_KFD or $DEV_DRI missing — no gfx1151 to serve on" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- NPU lane (XDNA2), opt-in via HALOGEN_NPU_MODELS ------------------------
|
||||
# Empty = the endpoint exactly as it ran before the lane existed: no device, no
|
||||
# extra mounts, no gate below. Set = three host preconditions, each refused BY
|
||||
# NAME (the 2026-10-01 law: the serve gate and the deployer gate must agree):
|
||||
# 1. the staged model files under $FRANK_DRAGOON_NPU_DIR — a path that MUST
|
||||
# live INSIDE $FRANK_DRAGOON_MODEL_DIR, because the container's only
|
||||
# handle on them is the read-only /models mount. A nested bind at
|
||||
# /models/npu cannot work: runc has to mkdir its mountpoint and the parent
|
||||
# bind is ro (`mkdirat .../merged/models/npu: read-only file system`,
|
||||
# measured 2026-10-19), and the image ships no /models at all for it to
|
||||
# land on. `$MODEL_DIR/npu/<id>` is visible at /models/npu/<id> through
|
||||
# the existing mount, which is also where the vendor's catalog expects it.
|
||||
# The engine re-checks size+sha256 against that catalog at boot; it cannot
|
||||
# fetch anything here because /models is read-only and HALOGEN_DOWNLOAD
|
||||
# stays unset — no outbound connection at boot),
|
||||
# 2. the distro XRT's three libraries, each mounted twice (where the image
|
||||
# looks for XRT, and at its own path — the libraries find each other there;
|
||||
# an /opt/xilinx/xrt of symlinks into the system dir dangles inside the
|
||||
# container, which the vendor calls out explicitly),
|
||||
# 3. the GPU fabric clock HELD: on Strix Halo the iGPU and the NPU co-running
|
||||
# hang the machine while FCLK changes speed, and the engine refuses to
|
||||
# start when it moves — it cannot read host sysfs from inside the
|
||||
# container, so the proof lives here, checked exactly the way the vendor's
|
||||
# `halogen-fabric-clock status` checks it.
|
||||
NPU_ARGS=()
|
||||
NPU_MODELS="${HALOGEN_NPU_MODELS:-}"
|
||||
if [ -n "$NPU_MODELS" ]; then
|
||||
DEV_ACCEL="${FRANK_DRAGOON_DEV_ACCEL:-/dev/accel/accel0}"
|
||||
NPU_DIR="${FRANK_DRAGOON_NPU_DIR:?HALOGEN_NPU_MODELS is set, so FRANK_DRAGOON_NPU_DIR must name the staged model dir}"
|
||||
XRT_DIR="${FRANK_DRAGOON_XRT_DIR:-/usr/lib/x86_64-linux-gnu}"
|
||||
SYSFS="${FRANK_DRAGOON_SYSFS:-/sys}"
|
||||
# The lane's files have exactly one door into the container: the read-only
|
||||
# payload mount. A staging dir OUTSIDE it would need a nested bind whose
|
||||
# mountpoint runc cannot create through that read-only parent, so refuse the
|
||||
# shape by name here rather than let runc die at container init (2026-10-19).
|
||||
case "$NPU_DIR/" in
|
||||
"$FRANK_DRAGOON_MODEL_DIR"/*) ;;
|
||||
*) echo "frank-dragoon: FRANK_DRAGOON_NPU_DIR=$NPU_DIR is outside the payload dir $FRANK_DRAGOON_MODEL_DIR — the lane's files reach the container only through the read-only /models mount; move the staging dir inside it" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
if [ ! -e "$DEV_ACCEL" ]; then
|
||||
echo "frank-dragoon: $DEV_ACCEL missing — the NPU lane needs the amdxdna node (is iommu=pt still on the boot line?)" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Existence is not access. This endpoint runs rootless, so the container's
|
||||
# namespace maps exactly one uid/gid (dank) and a root:render 0660 node reads
|
||||
# `nobody:nogroup` inside it — the engine then dies naming the node itself
|
||||
# (measured 2026-10-19). --group-add keep-groups cannot help: an unmapped gid
|
||||
# cannot be granted into a namespace that does not map it, verified with and
|
||||
# without the flag from a shell already in `render`. The door is the udev mode.
|
||||
# NB: `[ -o FILE ]` is NOT "other-writable" — it means "exists", and `-o` is
|
||||
# also the OR operator inside test — so the permission bit is read directly.
|
||||
if [ $(( 8#$(stat -c '%a' "$DEV_ACCEL") & 8#2 )) -eq 0 ]; then
|
||||
echo "frank-dragoon: $DEV_ACCEL is $(stat -c '%a %U:%G' "$DEV_ACCEL") — a rootless container cannot open it; install dankclaw/udev/98-halogen-npu-access.rules and re-trigger udev (sudo udevadm control --reload-rules && sudo udevadm trigger --sysname-match=accel0)" >&2
|
||||
exit 1
|
||||
fi
|
||||
for M in ${NPU_MODELS//,/ }; do
|
||||
if [ ! -d "$NPU_DIR/$M" ]; then
|
||||
echo "frank-dragoon: NPU model $M not staged in $NPU_DIR;" >&2
|
||||
echo " fetch it: bash scripts/download-frank-dragoon-npu.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
for L in libxrt_coreutil.so.2 libxrt_core.so.2 libxrt_driver_xdna.so.2; do
|
||||
if [ ! -e "$XRT_DIR/$L" ]; then
|
||||
echo "frank-dragoon: XRT NPU library $XRT_DIR/$L missing — the NPU lane cannot load (Ubuntu libxrt2 + libxrt-npu2)" >&2
|
||||
exit 1
|
||||
fi
|
||||
REAL="$(readlink -f "$XRT_DIR/$L")"
|
||||
NPU_ARGS+=(-v "$REAL:/opt/xilinx/xrt/lib/$L:ro" -v "$REAL:$XRT_DIR/$L:ro")
|
||||
done
|
||||
HELD=0
|
||||
for D in "$SYSFS"/class/drm/card*/device; do
|
||||
[ -e "$D/pp_dpm_fclk" ] || continue
|
||||
[ -e "$D/power_dpm_force_performance_level" ] || continue
|
||||
PERF="$(cat "$D/power_dpm_force_performance_level" 2>/dev/null || true)"
|
||||
TOP="$(grep . "$D/pp_dpm_fclk" | tail -n 1)"
|
||||
if [ "$PERF" = high ] || { [ "$PERF" = manual ] \
|
||||
&& [ "$(grep -c '\*' "$D/pp_dpm_fclk")" = 1 ] && [ "${TOP%\*}" != "$TOP" ]; }; then
|
||||
HELD=1
|
||||
else
|
||||
echo "frank-dragoon: fabric clock at $D is '$PERF', not held — the NPU lane refuses to start (GPU+NPU together need FCLK pinned)" >&2
|
||||
echo " hold it: sudo halogen-fabric-clock hold" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [ "$HELD" = 0 ]; then
|
||||
echo "frank-dragoon: no amdgpu device with a fabric-clock control under $SYSFS/class/drm — cannot prove FCLK is held" >&2
|
||||
exit 1
|
||||
fi
|
||||
NPU_ARGS+=(--device "$DEV_ACCEL" \
|
||||
-e HALOGEN_NPU_MODELS="$NPU_MODELS")
|
||||
echo "frank-dragoon: NPU lane armed: $NPU_MODELS on $DEV_ACCEL (fabric clock held)" >&2
|
||||
fi
|
||||
|
||||
# Container network. Empty = podman's rootless default (`pasta`), which is a
|
||||
# private netns and CANNOT be joined by anything else: podman refuses a second
|
||||
# network while the primary is pasta ("cannot set multiple networks without
|
||||
# bridge network mode"), the endpoint's published port answers 200 on the host
|
||||
# and on the LAN but is REFUSED from a dankclaw_default container (measured
|
||||
# 2026-10-19), and `host.containers.internal:7770` answers 200 yet resolves to
|
||||
# 169.254.1.2 — link-local, which open-notebook's URL guard refuses to store.
|
||||
# So an endpoint on pasta has no address an app container can use; naming the
|
||||
# app bridge here is the only shape that lets open-notebook dial
|
||||
# http://frank-dragoon-halogen:8731 for the NPU endpoints. It does not widen
|
||||
# the surface: the engine's unauthenticated 8730 is bound to the container's
|
||||
# OWN loopback (/proc/net/tcp: 127.0.0.1:8730 vs 0.0.0.0:8731, checked the same
|
||||
# day), so only the API port is reachable by name.
|
||||
NET_ARGS=()
|
||||
if [ -n "${FRANK_DRAGOON_NETWORK:-}" ]; then
|
||||
NET_ARGS=(--network "$FRANK_DRAGOON_NETWORK")
|
||||
fi
|
||||
# Host-RAM preflight — the engine's pin gate, checked BEFORE podman run.
|
||||
# The engine refuses to pin when MemAvailable is below the checkpoint plus
|
||||
# a 16 GiB floor: pinned pages cannot be swapped, and under the floor the
|
||||
@@ -189,6 +307,8 @@ podman run --name "$FRANK_DRAGOON_CONTAINER" \
|
||||
${HALOGEN_WEIGHTS_LOCK:+-e HALOGEN_WEIGHTS_LOCK="$HALOGEN_WEIGHTS_LOCK"} \
|
||||
-v "$FRANK_DRAGOON_MODEL_DIR:/models:ro" \
|
||||
-v "$FRANK_DRAGOON_MODEL_DIR/tokenizer:/tokenizer:ro" \
|
||||
"${NET_ARGS[@]}" \
|
||||
"${NPU_ARGS[@]}" \
|
||||
"$FRANK_DRAGOON_IMAGE" \
|
||||
> "$LOG" 2>&1
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
type: reference
|
||||
title: Domain Docs
|
||||
description: "Consumer rules for GLOSSARY.md and docs/adr/, and where this vault out-ranks them on operational facts."
|
||||
tags: [agent, glossary, adr, documentation]
|
||||
timestamp: 2026-10-20T00:00:00Z
|
||||
---
|
||||
|
||||
# Domain Docs
|
||||
|
||||
How the engineering skills (`/grill-with-docs`, `/domain-modeling`,
|
||||
`/improve-codebase-architecture`, `/to-spec`) should consume this repo's design
|
||||
vocabulary when exploring the codebase.
|
||||
|
||||
## Before exploring, read these
|
||||
|
||||
- **`GLOSSARY.md`** at the repo root
|
||||
- **`docs/adr/`**: read ADRs that touch the area you're about to work in
|
||||
|
||||
If either doesn't exist, **proceed silently**. Don't flag their absence; don't
|
||||
suggest creating them upfront. `/domain-modeling` (reached via
|
||||
`/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily
|
||||
when terms or decisions actually get resolved. As of 2026-10-20 neither exists;
|
||||
this page is the pointer, not a promise.
|
||||
|
||||
## Ownership against this vault
|
||||
|
||||
This repo already carries `serverAppConfigs-docs/` — the operational knowledge
|
||||
base and, per repo law, the owner of operational facts (endpoint shapes, Eras,
|
||||
Laws, runbooks). `GLOSSARY.md` / `docs/adr/` hold **design vocabulary and
|
||||
decisions**. Where a term is defined in both, the vault page that owns the fact
|
||||
wins — one fact, one owner. A glossary entry restating an endpoint number is
|
||||
the bug, not the second source.
|
||||
|
||||
## Use the glossary's vocabulary
|
||||
|
||||
When output names a domain concept (an issue title, a refactor proposal, a
|
||||
hypothesis, a test name), use the term as defined in `GLOSSARY.md`. Don't drift
|
||||
to synonyms the glossary explicitly avoids.
|
||||
|
||||
If the concept you need isn't in the glossary yet, that's a signal: either
|
||||
you're inventing language the project doesn't use (reconsider) or there's a
|
||||
real gap (note it for `/domain-modeling`).
|
||||
|
||||
## Flag ADR conflicts
|
||||
|
||||
If output contradicts an existing ADR, surface it explicitly rather than
|
||||
silently overriding:
|
||||
|
||||
> _Contradicts ADR-0007 (…), but worth reopening because…_
|
||||
|
||||
## Related
|
||||
|
||||
- [[Issue Tracker]] — where specs and tickets built on this vocabulary are published.
|
||||
- [[Agent Docs and Edits]] — the edit-safety rules that apply while touching any doc in this checkout.
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
type: reference
|
||||
title: Issue Tracker
|
||||
description: "How agent skills read and write Gitea issues here: credential-safe curl, label IDs, and wayfinding without sub-issues."
|
||||
tags: [agent, gitea, issues, triage, api]
|
||||
timestamp: 2026-10-20T00:00:00Z
|
||||
---
|
||||
|
||||
# Issue Tracker
|
||||
|
||||
Issues and specs for this repo live as Gitea issues in `dank/serverAppConfigs`
|
||||
on `http://192.168.0.143:3000` (Dankclaw). There is no `gh`/`glab` CLI here —
|
||||
the door is the GitHub-compatible REST API driven with `curl`. The
|
||||
mattpocock-lineage skills (`/triage`, `/to-tickets`, `/to-spec`, `/wayfinder`)
|
||||
read this page as their tracker contract. The git workflow around issues and
|
||||
PRs (branches, PR-only merges, attribution footer) is owned by
|
||||
[[Deployment Flow]]; this page owns only the API shape.
|
||||
|
||||
## Auth
|
||||
|
||||
The token lives in the git credential file `~/.config/secrets/gitea`: one
|
||||
line in git-credential URL form, `http://user:TOKEN@192.168.0.143`. Extract it
|
||||
without echoing:
|
||||
|
||||
```bash
|
||||
tok=$(sed -nE 's#^http://[^:/@]+:([^@]+)@192\.168\.0\.143.*#\1#p' ~/.config/secrets/gitea | head -1)
|
||||
```
|
||||
|
||||
**A token on a command line is world-readable in `/proc/<pid>/cmdline`** (the
|
||||
same leak class AGENTS.md records for HF tokens). Never pass it with `-H` —
|
||||
put it in a mode-600 curl config and pass `-K`:
|
||||
|
||||
```bash
|
||||
cfg=$(mktemp); chmod 600 "$cfg"
|
||||
printf 'header = "Authorization: token %s"\n' "$tok" > "$cfg"
|
||||
# ... every curl below uses -K "$cfg"; rm -f "$cfg" when done
|
||||
```
|
||||
|
||||
**GETs also accept HTTP Basic; writes do not** — a POST/PATCH with Basic auth
|
||||
is refused with `422 {"message":"token is required"}` (measured 2026-10-20).
|
||||
Use the `Authorization: token` header for everything.
|
||||
|
||||
## Conventions
|
||||
|
||||
`$API=http://192.168.0.143:3000/api/v1/repos/dank/serverAppConfigs`
|
||||
|
||||
- **Create an issue**: `curl -s -K "$cfg" -X POST -H 'Content-Type: application/json' -d "$(jq -n --arg t "$TITLE" --rawfile b "$BODY" '{title:$t, body:$b}')" $API/issues`
|
||||
- **Read an issue**: `curl -s -K "$cfg" $API/issues/<number>` (fields: `number`, `title`, `body`, `labels[].name`, `state`)
|
||||
- **List issues**: `curl -s -K "$cfg" "$API/issues?state=open&limit=50"` — add `&labels=<id>` to filter
|
||||
- **Comment**: `curl -s -K "$cfg" -X POST -d "$(jq -n --rawfile b "$BODY" '{body:$b}')" $API/issues/<number>/comments`
|
||||
- **Apply labels**: `POST $API/issues/<number>/labels` with `{"labels":[<label-ID numbers>]}` — Gitea wants numeric label **IDs**, not names; resolve them from `GET $API/labels` (see [[Triage Labels]] for which labels exist)
|
||||
- **Remove a label**: `DELETE $API/issues/<number>/labels/<label-id>`
|
||||
- **Close**: `PATCH $API/issues/<number>` with `{"state":"closed"}`
|
||||
- **Blocking relationships**: this instance has no native sub-issues or dependencies — put `Part of #<parent>` / `Blocked by: #<n>, #<n>` lines at the top of the child body. A ticket is unblocked when every listed blocker is closed.
|
||||
- **Attribution footer (repo law)**: every issue body you open ends with `cwd: <working directory> · session: <omp session id>` — full rule in [[Deployment Flow]].
|
||||
|
||||
**PRs as a request surface: no.** Issues and PRs have separate number spaces on
|
||||
Gitea; a bare `#42` is an issue, a PR is spelled "PR #42".
|
||||
|
||||
## When a skill says "publish to the issue tracker"
|
||||
|
||||
Create a Gitea issue as above.
|
||||
|
||||
## When a skill says "fetch the relevant ticket"
|
||||
|
||||
`GET $API/issues/<number>` and read `title` + `body` + `labels[].name` +
|
||||
comments.
|
||||
|
||||
## Wayfinding operations
|
||||
|
||||
Used by `/wayfinder`. The **map** is a single issue labelled `wayfinder:map`,
|
||||
holding the Notes / Decisions-so-far / Fog body.
|
||||
|
||||
- **Map**: create an issue, apply the `wayfinder:map` label (create it via `POST $API/labels` `{"name":"wayfinder:map","color":"#6f42c1"}` if missing).
|
||||
- **Child ticket**: an issue carrying `Part of #<map>` at the top of its body, plus a `wayfinder:<type>` label (`research`/`prototype`/`grilling`/`task`).
|
||||
- **Blocking**: the `Blocked by:` body line (see Conventions).
|
||||
- **Frontier**: list open issues (`?state=open`), drop any with an open `Blocked by` target or an assignee; lowest `number` wins.
|
||||
- **Claim**: `POST $API/issues/<n>/assignees` with `{"assignees":["dank"]}` — actually assign it or it stays in the frontier; the session's first write.
|
||||
- **Resolve**: post the answer as a comment, `PATCH` to `closed`, then update the map issue's body (GET → edit Decisions-so-far → PATCH) with a context pointer.
|
||||
|
||||
## Related
|
||||
|
||||
- [[Triage Labels]] — the role strings these operations apply.
|
||||
- [[Domain Docs]] — what a skill must read before touching the codebase.
|
||||
- [[SSH and Remote Access]] — reaching the host when an issue needs live evidence.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: reference
|
||||
title: Triage Labels
|
||||
description: "The five canonical triage roles mapped to this repo's Gitea label strings, plus the numeric-ID quirk."
|
||||
tags: [agent, gitea, triage, labels]
|
||||
timestamp: 2026-10-20T00:00:00Z
|
||||
---
|
||||
|
||||
# Triage Labels
|
||||
|
||||
The mattpocock-lineage skills speak in terms of five canonical triage roles.
|
||||
This page maps those roles to the actual label strings used in this repo's
|
||||
tracker ([[Issue Tracker]] owns the API shape).
|
||||
|
||||
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
||||
| -------------------------- | -------------------- | ---------------------------------------- |
|
||||
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
||||
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
||||
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
||||
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
||||
| `wontfix` | `wontfix` | Will not be actioned |
|
||||
|
||||
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the
|
||||
corresponding label string from this table. Edit the "Label in our tracker"
|
||||
column to match whatever vocabulary you actually use.
|
||||
|
||||
## Instance quirks (Dankclaw Gitea)
|
||||
|
||||
- Labels are applied by **numeric ID**, never by name: `POST /issues/<n>/labels`
|
||||
with `{"labels":[<id>]}`; resolve names via `GET /labels`.
|
||||
- The repo also carries the pre-existing `human-only` label from the manual era
|
||||
(e.g. the podcast-destination issues). `ready-for-human` maps to itself; the
|
||||
`(HUMAN ONLY)` title prefixes on existing issues stay as they are — the label
|
||||
is what the skills read.
|
||||
- An issue with **no** triage label is simply untriaged; absence is not a state.
|
||||
|
||||
## Related
|
||||
|
||||
- [[Issue Tracker]] — the auth-safe curl shape that applies these labels.
|
||||
@@ -6,6 +6,9 @@ docs get edited safely, and how the agent's home is deployed.
|
||||
- [[OMP Harness]] — The omp harness as configured here: compaction chain arithmetic, per-model sampler routing, and the context-lean audits.
|
||||
- [[Agent Docs and Edits]] — Two self-inflicted doc damage classes from parallel agent edits, with the audit checks and recovery order for each.
|
||||
- [[Command Center Dotfiles]] — How the dotfiles repo deploys: stow-equivalent packages, full-replica rsync, the pre-overwrite audit, transport gotchas.
|
||||
- [[Issue Tracker]] — How agent skills read and write Gitea issues here: credential-safe curl, label IDs, and wayfinding without sub-issues.
|
||||
- [[Triage Labels]] — The five canonical triage roles mapped to this repo's Gitea label strings, plus the numeric-ID quirk.
|
||||
- [[Domain Docs]] — Consumer rules for GLOSSARY.md and docs/adr/, and where this vault out-ranks them on operational facts.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ tags:
|
||||
- netavark
|
||||
- rootless
|
||||
- dankclaw
|
||||
timestamp: 2026-09-02
|
||||
timestamp: 2026-10-19
|
||||
---
|
||||
|
||||
# Container Network Namespaces
|
||||
@@ -88,8 +88,53 @@ Verified outcomes of that recipe on 2026-09-02: from inside `hermes-dashboard`
|
||||
published) and `:9119` (its own published port) connect; `:8080` and `:8787`
|
||||
are **refused instantly** even though both listeners are bound to `0.0.0.0` in
|
||||
the host netns. Probes to `host.containers.internal` (`169.254.1.2`) from that
|
||||
network time out rather than refuse — the alias is an address on the *default*
|
||||
`podman` network, and this container is not on it.
|
||||
network time out rather than refuse — but see the 2026-10-19 re-measure below,
|
||||
where the same alias answers from another `dankclaw_default` container.
|
||||
|
||||
### 2026-10-19: which published ports a rootless container can actually dial
|
||||
|
||||
Probed from inside `open-notebook` (rootless, on `dankclaw_default` **and** the
|
||||
private `systemd-open-notebook`), each address aimed at a live listener:
|
||||
|
||||
| Dial | Result | Mechanism |
|
||||
|---|---|---|
|
||||
| `192.168.0.143:3000` (Gitea) | **200** | published by a **rootless** container — rootless egress crosses the host netfilter where netavark installed that DNAT |
|
||||
| `192.168.0.143:7770` (`frank-dragoon`) | refused | the container is **rootless on podman's default `pasta` netns** (the root unit's `User=dank` runs `podman run`, so it lands in dank's storage). Pasta forwards the published port in the host netns — the host and the LAN both answer 200 — but installs no netavark DNAT for traffic arriving from a rootless bridge, so the same address is refused from a container. This is NOT a root/rootless rule-set split: `podman ps` as `dank` lists it |
|
||||
| `host.containers.internal:7770` (`169.254.1.2`) | **200** | the pasta forwarder is bound `0.0.0.0` on the host, so the gateway alias reaches it — and the alias is link-local, which is the dead end below |
|
||||
| `192.168.0.143:7771` (`frank-zealot`) | refused | host-netns listener, unpublished — the rule above, reconfirmed |
|
||||
| `10.89.1.1:7771` | refused | the bridge gateway address belongs to the rootlesskit netns, not to the host |
|
||||
| `host.containers.internal:7771` (`169.254.1.2`) | **200** | the gateway alias is the one address that carries a rootless container to a host-netns listener. Why `hermes-dashboard` timed out on this alias in 2026-09-02 while `open-notebook` answers today is **not established**; the difference to check first is the second network and where the default route lands |
|
||||
| `192.168.0.235:7770` (`frank-archon`) | **200** | a different machine: plain routing, no DNAT in the path |
|
||||
|
||||
The consequence that changed designs: **a rootless container's menu of local model
|
||||
endpoints was only `frank-tts` (container DNS, same network) and `frank-archon` (LAN
|
||||
address on another host).** A `pasta` endpoint has no address an app container can
|
||||
use: the LAN address is refused, and the one alias that answers is link-local,
|
||||
which some applications refuse to store at all ([[Open Notebook]] meets exactly
|
||||
that refusal). Pasta cannot be *added to* either —
|
||||
|
||||
```
|
||||
podman run --network pasta --network dankclaw_default …
|
||||
Error: cannot set multiple networks without bridge network mode, selected mode pasta
|
||||
```
|
||||
|
||||
so the only shape that makes such an endpoint nameable is **moving it onto
|
||||
`dankclaw_default`** (`FRANK_DRAGOON_NETWORK` in `scripts/frank-dragoon.env`,
|
||||
2026-10-19). For frank-dragoon that is not an exposure change: the engine's
|
||||
unauthenticated token port binds the container's **own loopback**
|
||||
(`/proc/net/tcp` on 2026-10-19: `127.0.0.1:8730`, `0.0.0.0:8731`), so only the
|
||||
API port becomes name-resolvable, which is the same trust the published 7770
|
||||
already carries on the LAN. `frank-zealot` (7771) stays a host-netns binary and
|
||||
remains unreachable except through the link-local alias.
|
||||
|
||||
The move is verified from the consumer side, so the table above reads as the
|
||||
**pre-move** census and not as today's menu: inside `open-notebook`,
|
||||
`getent hosts frank-dragoon-halogen` → `10.89.1.39`, `GET :8731/v1/models`
|
||||
lists both served models, and the engine's log carries the app's
|
||||
`POST /v1/embeddings 200` from that address (2026-10-19) — see
|
||||
[[Open Notebook]] for what runs over it. What the table still owns is the
|
||||
pasta rule itself: nothing about pasta's forwarding changed, the endpoint was
|
||||
moved out of it.
|
||||
|
||||
## Operational rules
|
||||
|
||||
@@ -98,9 +143,13 @@ network time out rather than refuse — the alias is an address on the *default*
|
||||
host — and never the reverse either: the deployer's health probe runs on the
|
||||
host, so it sees host-netns listeners that the container itself cannot dial.
|
||||
- To give a bridge-net container access to a host-netns listener, the options
|
||||
are (a) move it to `Network=host`, or (b) publish the needed port. Option (b)
|
||||
makes that port reachable from every netns and from the LAN — an exposure
|
||||
change that needs operator approval before deployment, not a silent fix.
|
||||
are (a) move it to `Network=host`, or (b) publish the needed port — but
|
||||
publishing is advertised **only inside the Podman that owns the container**:
|
||||
a root container's published port stays invisible to a rootless consumer
|
||||
(2026-10-19 table above), so the fix must be made on the same root/rootless
|
||||
side as the consumer. Either way it is an exposure change — a published port
|
||||
also answers on the LAN — and needs operator approval before deployment, not
|
||||
a silent fix.
|
||||
- `chrome-devtools-protocol` is intentionally `Network=host` with a loopback-only
|
||||
bind so the browser tool stays unreachable from the LAN; do not "tidy" it onto
|
||||
a bridge network — its two consumers (gateway, webui) are host-netns and reach
|
||||
|
||||
@@ -32,7 +32,7 @@ render needs the app installed, not this endpoint.
|
||||
| --- | --- |
|
||||
| Host | Dankclaw (`192.168.0.143`), rootless podman, user unit `frank-tts.service` |
|
||||
| Port | **7772**, published (`PublishPort=7772:7772`) — the rest of the `frank-*` port family is in [[Dankclaw Stack]] |
|
||||
| Routes | `GET /health`, `GET /v1/audio/voices`, `POST /v1/audio/speech`; everything else 404, and speech is POST-only |
|
||||
| Routes | `GET /health`, `GET /v1/audio/voices`, `POST /v1/audio/speech`; everything else 404, and speech is POST-only. The request's `model` field is **ignored** (measured 2026-10-19: `voxcpm2` and `tts-1` both synthesize the same way) — the voice comes solely from `voice`, so a consumer that must name a model may name it anything |
|
||||
| Image | `localhost/franksdank/frank-tts:latest`, built from `dankclaw/voiceservice/Containerfile`, `Pull=never` |
|
||||
| Networks | `dankclaw_default` first (it owns the default route and the consumers), then `frank-tts-engine.network` (10.89.5.0/24, the bridge to the engine) |
|
||||
| Voice packs | `/home/dank/voices` mounted read-only at `/voices` — host state, never repo state |
|
||||
@@ -121,10 +121,12 @@ touching either tenant, because a change to one is a change to both. Co-tenancy
|
||||
`frank-dragoon` live on the card (its first token and decode rates unchanged with a synthesis
|
||||
in flight); both gate recipes are §5 of `report/tts-npu-spike-verdict-2026-10-19.md`.
|
||||
|
||||
**The Strix Halo NPU is not a door at all**: XDNA2 executes only precompiled AIE partition
|
||||
programs, and AMD's own Linux runtime opens `/dev/accel/accel0`, succeeds at the ioctls, then
|
||||
declines this model family's graph without emitting a single log line. The measured record and
|
||||
the model-side contract live in `report/` at the repo root
|
||||
**The NPU is a door only for a model the vendor has already compiled for it.** XDNA2 runs nothing but
|
||||
precompiled AIE partition programs, and AMD's own Linux runtime opens `/dev/accel/accel0`, succeeds at the
|
||||
ioctls, then declines this model family's graph without emitting a single log line. That is why TTS stays on
|
||||
the GPU — and it is not a general veto on the device: halogen ships its own precompiled catalog (embeddings,
|
||||
decisions, rerankers) that does run there, which is what [[frank-dragoon]]'s NPU-lane section is about. The
|
||||
measured record and the model-side contract live in `report/` at the repo root
|
||||
(`tts-npu-spike-verdict-2026-10-19.md`).
|
||||
|
||||
## Files
|
||||
|
||||
@@ -6,25 +6,26 @@ tags:
|
||||
- open-notebook
|
||||
- podcasts
|
||||
- quadlet
|
||||
timestamp: 2026-10-18
|
||||
timestamp: 2026-10-19
|
||||
---
|
||||
|
||||
# Open Notebook
|
||||
|
||||
open-notebook ([lfnovo/open-notebook](https://github.com/lfnovo/open-notebook)) is the notebook layer that turns existing notes and sources into a **spoken episode**: an outline, then a dialogue transcript, then one synthesized clip per speaker turn, concatenated with ffmpeg into an MP3. In the fleet it is the consumer that closes the loop Frank's audio stack was built for — the script is written by **frank-zealot**, the voices are rendered by **frank-tts**, and nothing leaves the LAN.
|
||||
open-notebook ([lfnovo/open-notebook](https://github.com/lfnovo/open-notebook)) is the notebook layer that turns existing notes and sources into a **spoken episode**: an outline, then a dialogue transcript, then one synthesized clip per speaker turn, concatenated with ffmpeg into an MP3. In the fleet it is the consumer that closes the loop Frank's audio stack was built for — the script is written by **frank-archon**, the voices are rendered by **frank-tts**, and nothing leaves the LAN.
|
||||
|
||||
**It is NOT deployed.** This page documents repo-owned configuration — the Quadlet units, the env file, the deployer path, the homepage card — and the contract those files were written against. On 2026-10-18 no `open-notebook` or `open-notebook-surrealdb` container exists on Dankclaw (`podman ps -a` census, 2026-10-18), and the homepage card ships `wip: "down"` with `showLink: false`, so the card is hidden and reported `wip-down`. Re-censused 2026-10-19 and unchanged: no `*notebook*` unit in `systemctl --user list-unit-files`, nothing installed under `~/.config/containers/systemd/` by that name, no container in `podman ps` — the repo's three quadlet files have never been installed on the host. Deploying it is a deliberate next step, not a pending one.
|
||||
**Deployed 2026-10-19.** Both units installed and `active`, both containers healthy, SurrealDB migrated to v25 on first boot, and the first episode rendered end to end: a note in, an outline, 23 dialogue turns, 23 clips, and one **747-second MP3 on the host bind, produced in 301 seconds** — about 2.5× real time with every concurrency knob at 1. The download route answers `200 audio/mpeg` with the bearer token and **401 without it**. It is guarded like every other user service — `https://notebook.franksdank.cloud`, Anubis → Authentik → the app's own login — and the API is no longer published on the LAN at all.
|
||||
|
||||
**Host:** Dankclaw (rootless Podman, user `dank`) — **Ports:** 8502 UI and 5056 API, published on the LAN, **no vhost and no Caddy route** — **Deployer:** `open-notebook` (one service, two units) — **Docs:** https://github.com/lfnovo/open-notebook
|
||||
**Host:** Dankclaw (rootless Podman, user `dank`) — **User path:** `https://notebook.franksdank.cloud` (guarded) — **Published:** `0.0.0.0:8502` (the only upstream Caddy dials) and `127.0.0.1:5056` (the deployer's health gate, nothing else) — **Deployer:** `open-notebook` (one service, two units) — **Docs:** https://github.com/lfnovo/open-notebook
|
||||
|
||||
## Facts (verified 2026-10-18 against the pinned images, upstream source, and the live host)
|
||||
## Facts (images and upstream source verified 2026-10-18; live-host columns re-read 2026-10-19 after deploy)
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| App image | `docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212` (`open-notebook.container:79`) — the tag upstream's own `docker-compose.yml:27` pins for the multi-container shape. `v1-latest` is a moving tag, so the manifest-list digest (resolved from the registry 2026-10-18) is what makes this reproducible |
|
||||
| Database image | `docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723` (`open-notebook-surrealdb.container:66`) — that digest is **SurrealDB 2.7.0** (image built 2026-09-23; measured by running the digest: `surreal version`) |
|
||||
| Do NOT use | the `-single` tags (`v1-latest-single`). That variant bundles SurrealDB inside the app image and is deprecated upstream; here it would give the host a second, hidden database |
|
||||
| Ports | container 8502 (Next.js UI) + 5055 (FastAPI API) → published `0.0.0.0:8502:8502` and `0.0.0.0:5056:5055` (`open-notebook.container:83-84`). **Host 5055 is taken**: seerr publishes `127.0.0.1:5055` (`dankclaw/seerr/quadlet/seerr.container`, and `ss -ltn` on Dankclaw shows `127.0.0.1:5055` live, 2026-10-18). The API therefore leaves on 5056, which is what forces `API_URL` (below) |
|
||||
| Ports | container 8502 (Next.js UI) + 5055 (FastAPI API) → published `0.0.0.0:8502:8502` and `127.0.0.1:5056:5055` (`open-notebook.container`). **Host 5055 is taken**: seerr publishes `127.0.0.1:5055` (`dankclaw/seerr/quadlet/seerr.container`, and `ss -ltn` on Dankclaw shows it live). The API therefore leaves on 5056, and on loopback only: the vhost is its sole user path and the image's own Next.js layer rewrites `/api/*` to it, so nothing outside the container needs it |
|
||||
| User path | `https://notebook.franksdank.cloud` → `anubis:8923` → post-auth listener `@notebook` → `192.168.0.143:8502` (`dankclaw/Caddyfile`); Authentik provider `notebook-proxy` (`external_host` the vhost with its trailing slash, `internal_host http://caddy:8017`); LAN record in `dankclaw/pihole/addn-hosts` |
|
||||
| Database port | 8000 inside its own namespace, **never published** (`open-notebook-surrealdb.container` has no `PublishPort`). Upstream publishes `127.0.0.1:8000` for debugging (`docker-compose.yml:18`); this unit does not, and the seam asserts that it stays that way |
|
||||
| Data | `open-notebook-data:/app/data` (named volume: uploads, LangGraph SQLite checkpoints, extraction caches) **plus** the interim host bind `/home/dank/media/podcasts:/app/data/podcasts` (`open-notebook.container:87-88`); database in the `open-notebook-db` named volume |
|
||||
| Networks | app: `dankclaw_default` first, then the private `systemd-open-notebook` (10.89.4.0/24, `open-notebook.network:33`); database: the private bridge only. Order is load-bearing — the generated `--network` flags keep file order, so the default route rides the network where `host.containers.internal` is proven |
|
||||
@@ -33,6 +34,19 @@ open-notebook ([lfnovo/open-notebook](https://github.com/lfnovo/open-notebook))
|
||||
| Env | `scripts/open-notebook.env` (repo, knobs) then `/home/dank/Containers/open-notebook/env` (host, secrets, mode 600) — two `EnvironmentFile=` lines per unit, repo file **first** because the last file wins |
|
||||
| Deployer | `deploy_open_notebook` in `scripts/deploy-remote.sh`: seed secrets → ensure both digest pins are in local storage → install both units + the network → `mkdir -p /home/dank/media/podcasts` → restart database, wait for its container health → restart app → `wait_for_health open-notebook http://127.0.0.1:5056/health 180` |
|
||||
|
||||
## The guard chain, and the single origin
|
||||
|
||||
`notebook.franksdank.cloud` follows the fleet's exposure policy in this order, and the order is the law ([[Network Architecture]]):
|
||||
|
||||
1. **Provider first.** Authentik proxy provider `notebook-proxy` existed before any route pointed at the app: `external_host https://notebook.franksdank.cloud/` (exact vhost, trailing slash), `internal_host http://caddy:8017`, the three flows copied from `embedded-outpost`, Application `Open Notebook` (`notebook-guard`), and the provider attached to the embedded outpost's provider set. Flip the vhost first and authenticated traffic lands on the outpost's error page. Verify after the change that the outpost logged no `Failed to fetch outpost from API` (the null-flow wedge) and that an already-guarded vhost still answers.
|
||||
2. **Then the route, in one deploy:** the guarded site block (`reverse_proxy anubis:8923` with `header_up X-Real-Ip {remote_host}` — the whole request, so no path ever reaches the app unchallenged) plus the post-auth `@notebook` handler on `http://:8017`, plus the LAN record in `dankclaw/pihole/addn-hosts`. The public Porkbun A record is a host action, not a repo file.
|
||||
3. **One upstream, not two.** `reverse_proxy 192.168.0.143:8502`: the image's own Next.js layer rewrites `/api/*` to the FastAPI beside it (`next.config.ts`, `INTERNAL_API_URL` default `http://localhost:5055`), so the API needs no route and no outward face — hence `127.0.0.1:5056`. Caddy dials the published LAN port rather than a container name because this Caddy lives in root's Podman and cannot see a rootless container at all ([[Container Network Namespaces]]). SSE (`/api/search/ask`, chat) streams through the same bare proxy that carries OpenWebUI's chat at `ai.franksdank.cloud`; no `flush_interval` or timeout directive is needed, and adding a route timeout is precisely what would cut it.
|
||||
4. **`API_URL` must name the vhost.** `GET /config` (the Next route) answers in priority order: `API_URL` env → autodetect from `Host` + `X-Forwarded-Proto` → `http://localhost:5055`. Autodetect appends **`:5055`**, a port that belongs to seerr on this host, so leaving `API_URL` unset hands the browser an address nothing answers and every call fails under a UI that loaded fine. `CORS_ORIGINS` is set to the same single origin only to keep the app from defaulting to `*`. `scripts/open-notebook.env` carries both.
|
||||
|
||||
The app's own login stays on behind the chain. It is not redundant: publishing the API on loopback does **not** hide it from this host's other containers, which can dial `open-notebook:5055` by name on `dankclaw_default`. The chain decides *who from outside* gets in; `OPEN_NOTEBOOK_PASSWORD` decides what anything that already reached the port may do.
|
||||
|
||||
**Single tenant — there are no user accounts, so this is an admin-side tool.** Upstream ships one global password and nothing else: `PasswordAuthMiddleware` compares `Bearer <OPEN_NOTEBOOK_PASSWORD>`, and the SurrealDB schema has no user/account/owner table (verified in the running image, 2026-10-20). One shared password therefore means **one shared workspace** — the guard chain can gate the door but cannot partition the data, and Authentik identity is never consumed. Multi-user is *approved but unbuilt* upstream (issue **#712**, "design phase, targeted post-v2"); OIDC/SSO was declined for now (**#607** closed). Real per-person isolation means **one deployment per person**, not a flag to flip. For that reason the homepage lists open-notebook under **admin**, not as a user-facing app.
|
||||
|
||||
## What the image actually runs
|
||||
|
||||
One container, three processes under supervisord (`supervisord.conf` upstream): `uvicorn api.main:app --port 5055` (`:12`), the job worker (`:25`), and the Next.js frontend (`:39`). Two consequences the config depends on:
|
||||
@@ -44,25 +58,50 @@ Also from the image itself: `WORKDIR /app` and `EXPOSE 8502 5055` (`Dockerfile:8
|
||||
|
||||
`DATA_FOLDER` is hard-coded `"./data"` (`open_notebook/config.py:5`) and `PODCASTS_FOLDER` is `"./data/podcasts"` (`:19`); the database stores each episode's audio path **relative** to that folder (`episodes/<uuid>/audio/<uuid>.mp3`), and the download route is `GET /api/podcasts/episodes/{id}/audio`. That is why covering one subdirectory with a bind mount is sufficient — nothing in the database has to change if the mount moves.
|
||||
|
||||
## Provisioning the providers (after first boot; UI or REST, never a unit)
|
||||
## What is provisioned (REST, 2026-10-19)
|
||||
|
||||
Credentials are stored in the database, encrypted, and are **not expressible in env or a Quadlet file**. The sequence is one-time, in **Manage → Models**, and both entries use provider type **OpenAI Compatible** (upstream: any server that implements `/v1/audio/speech` works this way — `docs/5-CONFIGURATION/local-tts.md:5`).
|
||||
Credentials live in the database, encrypted with `OPEN_NOTEBOOK_ENCRYPTION_KEY`, and are **not expressible in env or a Quadlet file** — the UI (**Manage → Models**) or the REST API are the only doors. Both use provider type `openai_compatible`, because any server that answers `/v1/audio/speech` or `/v1/chat/completions` works through it (`docs/5-CONFIGURATION/local-tts.md:5`).
|
||||
|
||||
1. **TTS credential** — base URL **`http://frank-tts:7772/v1`**. The `/v1` is part of the URL: the client posts to `<base_url>/audio/speech`, so a base URL without `/v1` 404s on every dialogue line (`local-tts.md:72-80`, the base-URL table, always carries `/v1`). `frank-tts` resolves by container name because the app is attached to `dankclaw_default`, the network frank-tts's unit joins (`dankclaw/voiceservice/quadlet/frank-tts.container:51-52`). If frank-tts is ever moved off that network, the published-port form is `http://192.168.0.143:7772/v1` — a published port is dialable at the host's LAN address from any netns. The API-key field stays empty; frank-tts does not authenticate.
|
||||
2. **LLM credential** — base URL **`http://host.containers.internal:7771/v1`**, model row `frank-zealot`. frank-zealot is a **host process** on `0.0.0.0:7771` (`ss -ltn` on Dankclaw shows the listener with no container owning it, 2026-10-18), and on this netavark host a bridge container reaches the host's LAN IP only at a *published* port — which is why Prometheus scrapes the same endpoint as `host.containers.internal:7771` (`shared/prometheus/prometheus.yml:89-111`, "not their own LAN addresses"). See [[Container Network Namespaces]]. `http://192.168.0.143:7771/v1` is the variant to try only if the gateway name ever stops resolving; it is expected to be refused from a bridge netns.
|
||||
3. **Model rows are created by name.** Discovery is optional (see the next section): type the model id and confirm the add. One base URL per credential, so the TTS and LLM credentials are necessarily two separate configurations.
|
||||
| Record | Value |
|
||||
|--------|-------|
|
||||
| Credential `frank-tts` | modality `text_to_speech`, base URL `http://frank-tts:7772/v1`, no API key (the shim does not authenticate) |
|
||||
| Credential `frank-archon` | modality `language`, base URL `http://192.168.0.235:7770/v1` |
|
||||
| Credential `frank-dragoon NPU` | modality `embedding`, base URL `http://frank-dragoon-halogen:8731/v1`, dummy API key (the lane does not authenticate) — a container name on `dankclaw_default`, which is the address shape that clears the URL guard below |
|
||||
| Model row `voxcpm2` | type `text_to_speech` on the frank-tts credential. The name is decorative: frank-tts ignores the request's `model` field and synthesizes whatever `voice` names ([[Frank TTS]]) |
|
||||
| Model row `frank-archon` | type `language`; `PUT /api/models/defaults` points chat, transformation and large-context at it |
|
||||
| Model row `qwen3-embedding-0.6b` | type `embedding` on the NPU-lane credential; `default_embedding_model` points at it, so every note save embeds through the XDNA2 lane ([[frank-dragoon]]) |
|
||||
| Speaker profile `dank-duo` | two speakers, Frank → `voice_id: narrator1`, Comrade → `narrator2`, `voice_model` = the TTS row |
|
||||
| Episode profile `dank-daily` | `num_segments: 4`, `speaker_config` = `dank-duo`, outline and transcript LLM = the archon row, briefing that ends "Never invent numbers." |
|
||||
|
||||
**Verify reachability before saving**, from inside the container, because both addresses are netns-dependent:
|
||||
Order that works over REST: credentials → model rows (`POST /api/models` with `credential: <credential id>`) → `PUT /api/models/defaults` → speaker profile (`voice_model: <model id>`) → episode profile (`speaker_config` / `outline_llm` / `transcript_llm`). `POST /api/models/{id}/test` proves each row: the language row answered with a real completion from frank-archon; the TTS row reached frank-tts and came back `unknown voice 'alloy'; available voices: narrator1, narrator2, narrator3` — the app's own test path hard-codes an OpenAI voice, so that message *is* the proof the leg works.
|
||||
|
||||
**`POST /api/podcasts/generate` takes profile NAMES, not record ids.** `episode_profile: "dank-daily"` works; the `episode_profile:…` id returns a bare `500 Failed to submit podcast generation job`, and the actual reason (`Episode profile '<id>' not found`) appears only in `podman logs open-notebook`.
|
||||
|
||||
**The scriptwriter is frank-archon, not the planned frank-zealot, because two rules collide on the address.** Measured from inside the app's netns 2026-10-19:
|
||||
|
||||
- `192.168.0.143:7771` (frank-zealot, a host-netns listener) and `192.168.0.143:7770` (frank-dragoon, a **root**-published port) both refuse the connection; `host.containers.internal:7771` answers. Full census: [[Container Network Namespaces]].
|
||||
- The app refuses to **store** that one working address: its URL guard rejects any base URL that resolves inside link-local `169.254.0.0/16` (`open_notebook/utils/url_validation.py`, "Link-local addresses are not allowed (metadata endpoints)"), unconditionally — no env var touches it, and `AddHost=` cannot dodge it because the check runs on the resolved address.
|
||||
|
||||
`http://192.168.0.235:7770/v1` — another machine, plain RFC1918 — was, when this was first written, the only fleet LLM address that passed both tests; a container name on `dankclaw_default` passes them as well (the embedding credential below is stored that way), but frank-archon stays the scriptwriter for the reason in the next paragraph. Cost: frank-archon runs `--max-num-seqs 2` and the operator session itself lives there, so a render is a slow-down for whoever else is talking to that endpoint, not a stall; `ESPERANTO_LLM_TIMEOUT=180` covers an outline or transcript call comfortably.
|
||||
|
||||
**frank-dragoon can write transcripts, but only greedily — and open-notebook cannot ask for greedy.** Measured 2026-10-19 against the live endpoint: `response_format` as `json_schema` or `json_object` returns schema-valid JSON at `temperature: 0` and a **400** at any sampled temperature — halogen decodes greedy under a schema, and because its sampler default is server-side, omitting `temperature` is sampled, not greedy. That 400 is the trap: esperanto's `is_json_schema_unsupported_error` matches the words *not implemented* in it, so `_SchemaFallbackModel` in `podcast_creator/nodes.py` reads it as "this endpoint has no schema support", downgrades to `json_object`, gets the same 400, and **fails the run** — esperanto's own rescue (drop `response_format` entirely) fires only on the different message `'response_format.type' must be 'json_schema'`. The full law is on [[frank-dragoon]]. And the fix is not reachable from here: a model record is `{credential, name, provider, type}` and a model response adds only timestamps (`GET /openapi.json`, `ModelCreate`/`ModelResponse`) — **there is no per-row config field**, no way to attach `temperature: 0` to one row from the UI or the API. Making `frank-dragoon` the writer would therefore need something in the path that rewrites request bodies, which is a heavier tenant than the contention it removes. **frank-archon stays the scriptwriter.** Reachability stopped being the objection on 2026-10-19 — the endpoint joined `dankclaw_default`, which is exactly why the embedding lane below is dialable by name — but no network change reaches the no-per-row-config wall.
|
||||
|
||||
**Embeddings run on the XDNA2 lane — provisioned 2026-10-19.** `qwen3-embedding-0.6b` answers `/v1/embeddings` at `http://frank-dragoon-halogen:8731/v1`, the flash container's own bridge address; a container name on `dankclaw_default` resolves to `10.89.1.39`, plain RFC1918, so it clears the URL guard that ruled out `host.containers.internal` above. The lane's shape (one program, the token ceiling, what it costs the Flash model) is owned by [[frank-dragoon]]; the two shapes that bite on the app side are open-notebook's own:
|
||||
|
||||
- **`PUT /api/models/defaults` stores the record ID, not the name.** `"default_embedding_model": "qwen3-embedding-0.6b"` is accepted and echoed back, and then every embed dies in the worker with `Model with ID qwen3-embedding-0.6b not found`. The value has to be the row's `model:…` id — the other defaults in that same object are ids, so mirror them.
|
||||
- **`POST /api/embed` answers 500 while succeeding.** Upstream's `EmbedResponse.command_id` declares `str` and the worker hands it a `RecordID`, so FastAPI raises on serialization *after* the vector is written. The truth is the worker line `Successfully embedded note … in 0.19s`, never the HTTP code. Saving a note (`POST /api/notes`) submits `embed_note` by itself, so that route is only needed to backfill old rows.
|
||||
|
||||
Proof from the app's own paths, 2026-10-19: `POST /api/models/{id}/test` → `Embedding dimensions: 1024`; two notes with nothing in common, then `POST /api/search` with `type: "vector"` (the enum is `text` or `vector`; the score field is `similarity`) — "a yellow waterbird beside a glowing beacon tower" put the grebe note at **0.659** and the boiler note at 0.292, and "quarterly depreciation schedule for industrial boilers" inverted it to **0.906** / 0.318. Embed cost 0.19–0.43 s per note. Semantic search, source ingestion and notebook-context questions are live; podcast generation never needed any of it.
|
||||
|
||||
**Secrets never go in a command line.** The API's bearer token *is* `OPEN_NOTEBOOK_PASSWORD`, and a `-H "Authorization: Bearer …"` argument is world-readable in `/proc/<pid>/cmdline`; put it in a mode-600 curl config and pass `-K`:
|
||||
|
||||
```bash
|
||||
podman exec open-notebook curl -sS http://frank-tts:7772/health
|
||||
podman exec open-notebook curl -sS http://host.containers.internal:7771/v1/models
|
||||
PW=$(grep -m1 '^OPEN_NOTEBOOK_PASSWORD=' /home/dank/Containers/open-notebook/env | cut -d= -f2- | tr -d '"')
|
||||
cfg=$(mktemp); chmod 600 "$cfg"; printf 'header = "Authorization: Bearer %s"\n' "$PW" > "$cfg"
|
||||
curl -s -K "$cfg" http://127.0.0.1:5056/api/models | head -c 400
|
||||
rm -f "$cfg"
|
||||
```
|
||||
|
||||
**The blocker moved; it did not vanish.** frank-tts and frank-tts-engine are **deployed** (2026-10-18) and answer on 7772, so the voice leg works end to end — `podman exec open-notebook curl -sS http://frank-tts:7772/health` is the check to run the day the app exists. What still blocks a render is open-notebook itself: not installed on this host yet. When it is, the two provider rows above are the only wiring, and the seeded speaker profiles carrying OpenAI voice names are the first trap.
|
||||
|
||||
**Why frank-zealot and not frank-dragoon:** podcast scriptwriting asks the model for a JSON-schema response (with a plain-JSON fallback). The decision rests on each endpoint's documented request surface ([[frank-zealot]], [[frank-dragoon]]): zealot's is a known-good target for that call shape, dragoon's structured-output behaviour is **unverified**, so it is the unproven alternative, not the fallback. `ESPERANTO_LLM_TIMEOUT=180` (the app's own default) covers an outline or transcript call on zealot comfortably; upstream's note is to keep it under 600 so a slow call fails with a clear error instead of a UI timeout.
|
||||
|
||||
## Speakers and voices: 1–4, and the IDs are free text
|
||||
|
||||
A **speaker profile** carries one to four speakers (`docs/3-USER-GUIDE/creating-podcasts.md:27`), each with a personality and a **Voice ID that is typed exactly and passed through to the TTS request untouched** (`:29`) — there is no whitelist, no enum, no validation against a voice list. So the fleet's voice-pack names work verbatim:
|
||||
@@ -92,9 +131,11 @@ Defaults are `TTS_BATCH_SIZE=5` and `OPEN_NOTEBOOK_WORKER_MAX_TASKS=5` (`docs/5-
|
||||
|
||||
The clock next to it: `ESPERANTO_TTS_TIMEOUT` (`:91`) is patience **per dialogue line**, not per episode — a three-minute episode is dozens of separate POSTs, and the tail of a long turn on a CPU engine goes past upstream's 300 s default. It is set to **660, one round-trip outside `SHIM_TIMEOUT=600`** in `scripts/frank-tts.env` (frank-tts's own per-request ceiling): nested budgets go inside-out — the same rule that file states for `LLAMA_TIMEOUT` — so a frank-tts give-up arrives labelled as frank-tts's error instead of the worker's own timeout, and re-measuring frank-tts cannot silently strand this number below it. An abandoned request does not cancel the engine process; it only strands the episode in "generating".
|
||||
|
||||
What the guard costs, measured on that first render (the deployed shape): 23 dialogue clips, one POST each, **10–11 s per clip** with both knobs at 1 → 301 s of wall clock for 747 s of speech, i.e. **2.5× real time**, box still responsive. That ratio is the budget to hold any future TTS engine to: halve the per-clip time, halve the episode.
|
||||
|
||||
## Secrets, and the difference between blank and unset
|
||||
|
||||
`OPEN_NOTEBOOK_PASSWORD` unset means **authentication off** (`environment-reference.md:46`) — every API route, including the episode-audio download, answers anyone who can reach the published port. That is a real exposure on a LAN-published service with no vhost and no guard chain in front of it, which is why `deploy_open_notebook` refuses to start either unit unless the seeded host file carries a value for all four secret keys (`OPEN_NOTEBOOK_ENCRYPTION_KEY`, `OPEN_NOTEBOOK_PASSWORD`, `SURREAL_PASSWORD`, `SURREAL_PASS`). `OPEN_NOTEBOOK_ENCRYPTION_KEY` is separately load-bearing: it is **required to save any provider credential**, and changing it later makes stored keys undecryptable (`:44`).
|
||||
`OPEN_NOTEBOOK_PASSWORD` unset means **authentication off** (`environment-reference.md:46`) — every API route, including the episode-audio download, answers anyone who can reach it. Moving the API to loopback did not remove that exposure, it only removed the LAN from it: any container on `dankclaw_default` dials `open-notebook:5055` by name, so an unset password is an open episode library to every container on this host, guarded or not. That is why `deploy_open_notebook` refuses to start either unit unless the seeded host file carries a value for all four secret keys (`OPEN_NOTEBOOK_ENCRYPTION_KEY`, `OPEN_NOTEBOOK_PASSWORD`, `SURREAL_PASSWORD`, `SURREAL_PASS`). `OPEN_NOTEBOOK_ENCRYPTION_KEY` is separately load-bearing: it is **required to save any provider credential**, and changing it later makes stored keys undecryptable (`:44`).
|
||||
|
||||
The tracked file declares the secrets **empty** and the host file supplies them; podman applies `--env-file` arguments in order and the last one wins (verified against the generated `ExecStart` line, 2026-10-18). Reordering the two `EnvironmentFile=` lines in a unit would blank every credential — that is the trap the seam's ordering assertion exists for.
|
||||
|
||||
@@ -102,7 +143,7 @@ The database has the same trap in a different spelling: the app reads `SURREAL_P
|
||||
|
||||
## Episode destination: interim, and reversible (issue #7)
|
||||
|
||||
**Episode files currently exist in two places by design.** The app's own state stays in the `open-notebook-data` named volume; finished audio additionally lands on the host, because `/home/dank/media/podcasts` is bind-mounted over `/app/data/podcasts` (`open-notebook.container:88`). That is the **interim** answer to where episodes live: the destination is an open decision tracked as **Gitea issue #7, labelled `human-only` — agents must not work it.** Until Frank settles it, episode MP3s are ordinary files in his home directory, readable without `podman unshare`, which the database volume would require (measured with the pinned SurrealDB image).
|
||||
**Episode files currently exist in two places by design.** The app's own state stays in the `open-notebook-data` named volume; everything the render produces additionally lands on the host, because `/home/dank/media/podcasts` is bind-mounted over `/app/data/podcasts` (`open-notebook.container:88`). The first render confirmed the layout: `episodes/<uuid>/clips/0001.mp3 … 00NN.mp3` (one file per dialogue line, written as the worker goes) and `episodes/<uuid>/audio/<uuid>.mp3` (the ffmpeg concatenation). A half-finished render is therefore visible and greppable from the host without `podman unshare`. That is the **interim** answer to where episodes live: the destination is an open decision tracked as **Gitea issue #7, labelled `human-only` — agents must not work it.** Until Frank settles it, episode MP3s are ordinary files in his home directory.
|
||||
|
||||
Reversibility is one line: delete the `Volume=/home/dank/media/podcasts:/app/data/podcasts` line and the episodes go back to living solely inside the named volume. Nothing else in the config assumes the bind. See [[Storage Policy]] for the repo's storage conventions this interim state deliberately bends.
|
||||
|
||||
@@ -118,10 +159,11 @@ podman pull docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f7
|
||||
|
||||
## Rollback
|
||||
|
||||
- **Nothing deployed (today's state):** revert the commits. There is no host state to undo — no unit, no container, no volume, no `/home/dank/media/podcasts` directory beyond what the deployer would create.
|
||||
- **Deployed:** `systemctl --user stop open-notebook.service open-notebook-surrealdb.service`, then move the three files (`open-notebook.container`, `open-notebook-surrealdb.container`, `open-notebook.network`) out of `~/.config/containers/systemd/` into `~/.AGENTTRASH/` — never `rm` — and `systemctl --user daemon-reload`. The generated units disappear with their sources.
|
||||
- **Take it off the air, in the reverse of the deploy order:** delete the guarded site block and the `@notebook` handler from `dankclaw/Caddyfile` and deploy `caddy`; then in Authentik delete the `notebook-guard` Application and the `notebook-proxy` provider and drop it from the embedded outpost's provider set. A **route with no provider 502s** while a provider with no route is inert — that asymmetry is why the chain unwinds Caddy-first.
|
||||
- **Stop the app:** `systemctl --user stop open-notebook.service open-notebook-surrealdb.service`, then move the three files (`open-notebook.container`, `open-notebook-surrealdb.container`, `open-notebook.network`) out of `~/.config/containers/systemd/` into `~/.AGENTTRASH/` — never `rm` — and `systemctl --user daemon-reload`. The generated units disappear with their sources.
|
||||
- **Kept on purpose:** the `open-notebook-data` and `open-notebook-db` named volumes and `/home/dank/media/podcasts`. Episode audio and the notebook database are user data; removing them is a separate, deliberate act, and episode files are host files precisely so they can outlive the container.
|
||||
- **Homepage:** the card is `wip: "down"` + `showLink: false`, so retiring the service needs no card surgery beyond setting it back to that state.
|
||||
- **Provider credentials are database rows**, encrypted with `OPEN_NOTEBOOK_ENCRYPTION_KEY`; no repo file mirrors them. Regenerating that key while the database volume survives makes every stored credential undecryptable and forces re-entry — so a rollback that keeps the volume keeps the key file with it.
|
||||
- **Homepage:** the card test in `tests/open_notebook.bats` pins the live state (`showLink: true`, link = the vhost), so retiring the service edits that test in the same commit as the card flip back to `wip: "down"`.
|
||||
|
||||
## Citations
|
||||
|
||||
@@ -130,3 +172,8 @@ podman pull docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f7
|
||||
- Registry (2026-10-18): `lfnovo/open_notebook` tags (`v1-latest`, `latest`, the `-single` set) and manifest-list digests; `surrealdb/surrealdb` `v2` → 2.7.0
|
||||
- Local measurements (2026-10-18, workstation, pinned digests): `surreal start --help` (credential and bind options carry `[env: SURREAL_*]`); running the pinned SurrealDB digest with env-only credentials (credential init in the log, correct login authenticates, wrong password refused, `/surreal is-ready` → `OK` exit 0, named volume owned by the mapped uid); the v1-compatible Quadlet generator dry-run of all three unit files (two `--env-file` in order, two `--network` in order, named volume + host bind, both health probes, `--pull never`)
|
||||
- Host evidence (Dankclaw, 2026-10-18, read-only): `ss -ltn` (`127.0.0.1:5055` held by seerr, `0.0.0.0:7771` a host listener, nothing on 7772/8502/5056/8000), `podman ps -a` (no open-notebook container, `dankclaw_default` present), subnet registry from `dankclaw/open-webui/quadlet/open-webui.network`
|
||||
- Deploy + provisioning (Dankclaw, 2026-10-19): first-boot log — migrations 1→25, `Credentials were provided, and no root users were found. The root user 'open_notebook' will be created`, `api`/`worker`/`frontend` all RUNNING under supervisord; `GET :8502/config` → the `API_URL` value; `GET /api/providers` (the `openai_compatible` modality list); `GET /openapi.json` schemas for `CreateCredentialRequest` / `ModelCreate` / `SpeakerProfileCreate` / `PodcastGenerateRequest`; the POSTs listed above; the note-save worker error `No embedding model configured.`
|
||||
- Embedding lane (Dankclaw, 2026-10-19): `POST /api/credentials` → `credential:7fdqz09tt0c4xp68x7cm`, `POST /api/models` → `model:p3ki598dydfinu93tbny`, `POST /api/models/{id}/test` → `Embedding dimensions: 1024`; worker lines `Successfully embedded note … in 0.43s` / `0.19s`; the name-not-id failure `Model with ID qwen3-embedding-0.6b not found`; the `/api/embed` 500 body (`EmbedResponse.command_id`, `input_value=RecordID(table_name=command…)`); `POST /api/search` `similarity` 0.6590/0.2921 and 0.9059/0.3176 for the two-note pair; `podman logs frank-dragoon-halogen` showing `POST /v1/embeddings 200` from `10.89.1.21`. Both probe notes deleted afterwards.
|
||||
- First render (2026-10-19): job `command:9rdojq5ne7dj9caicq20`, `execution_time: 301.21 s`, `episode:7ft03thj98sn0ek9xf29`; `episodes/d5fed29b-33e9-40ac-b46a-e0537c56c256/clips/0001.mp3 … 0023.mp3`; `…/audio/d5fed29b-….mp3` = 11,957,420 bytes, mp3 24 kHz mono, `ffprobe` duration 747.31 s; `GET /api/podcasts/episodes/{id}/audio` → 200 `audio/mpeg` with the bearer, 401 without.
|
||||
- Upstream code read inside the pinned image (2026-10-19): `frontend/next.config.ts:26-43` (the `/api/*` rewrite and the one-port claim), `frontend/src/app/config/route.ts:122-173` (`/config` priority order; autodetect appends `:5055`), `frontend/src/app/api/_sse-proxy.ts` (SSE proxied with `no-transform`), `open_notebook/utils/url_validation.py` (link-local refusal, `is_blocked_ip`, no environment override anywhere in the file).
|
||||
- Guard chain (2026-10-19): provider `notebook-proxy` created through `podman exec -i authentik-server ak shell` with the flows copied from `embedded-outpost` (pk 1), Application `Open Notebook`/`notebook-guard`, embedded-outpost provider set 9 → 10; afterwards the outpost logged zero `Failed to fetch outpost from API` and `seerr`/`grafana` still answered 200. Before the route existed, `notebook.franksdank.cloud` resolved to nothing (`000`).
|
||||
@@ -267,6 +267,12 @@ The silent re-auth path was proven end-to-end: after deleting
|
||||
`qa-e2e-admin`'s rows, the e2e admin spec ran against the stale storage
|
||||
state and passed with no login prompt (2 tests, 2.9 s).
|
||||
|
||||
## Login-session lifetime (how often you re-authenticate)
|
||||
|
||||
How long a login stays valid is the **`default-authentication-login` stage's `session_duration`** (`UserLoginStage`) — not a global setting, and not `SESSION_COOKIE_AGE` (that is the *pre-login* session). Raised to **`days=7`** on 2026-10-20 (was stage value `0` = the 24 h framework default). It is an **absolute** window, never sliding: authentik implements `SESSION_SAVE_EVERY_REQUEST` nowhere, so activity does not extend it and no supported knob makes it one.
|
||||
|
||||
**`session_duration` is a STRING** in authentik's idiom (`"days=7"`, `"seconds=30"`, `"hours=12"`). A Python `timedelta` stores `"7 days, 0:00:00"`, which `authentik.lib.utils.time.timedelta_from_string` rejects with `ValueError` and login then crashes computing the expiry — so set it with **`scripts/authentik-set-login-session-duration.sh`** (validates the form first). Any non-zero value applies to *every* login (the "remember me" box only adds `remember_me_offset` on top) and a positive `set_expiry` also overrides `SESSION_EXPIRE_AT_BROWSER_CLOSE`, so closing the browser no longer logs you out; **existing sessions keep their old expiry** — delete the user's `authentik_core_session`/`…proxysession` rows and re-login to force it now. This is **DB state, not repo-managed**: a blueprint re-import or rebuild reverts the stage to `0`, so re-run the script after one. The other two login stages (`default-source-*`) drive external-source/enrollment logins and stay at the framework default.
|
||||
|
||||
## Guard-chain flip recipe (adding a guarded vhost)
|
||||
|
||||
Move a public vhost from `reverse_proxy <app>:<port>` onto the chain:
|
||||
|
||||
@@ -4,7 +4,7 @@ description: "Pi-hole is the LAN DNS server for the homelab."
|
||||
tags:
|
||||
- infrastructure
|
||||
- pi-hole
|
||||
timestamp: 2026-08-28
|
||||
timestamp: 2026-10-19
|
||||
---
|
||||
|
||||
# Pi-hole
|
||||
@@ -46,6 +46,8 @@ The file format is hosts-style (`<IP> <name> [<name> ...]`), one A record per Ca
|
||||
|
||||
**Pitfall (2026-08-24):** the first quadlet mounted the file into `/etc/dnsmasq.d/`. FTL never read it; names only resolved from the query cache. A fresh name returned NXDOMAIN. Mount into `/etc/pihole/hosts/`, not `/etc/dnsmasq.d/`.
|
||||
|
||||
**Pitfall (2026-10-19): FTL can come up with the record file mounted and still serve none of it.** A plain `systemctl --user restart pihole` (16:25, inside the open-notebook deploy window) left every `franksdank.cloud` name NXDOMAIN for LAN clients while **external names kept answering** — from FTL's cache, which is exactly what makes the failure read like a client or an upstream problem. **The trigger is a container restart, not a boot**, and that matters for where the check belongs: `uptime -s` reads 2026-10-18 01:24 (two days of uptime) while `journalctl --user -u pihole.service` shows `Stopping pihole.service` at 16:25:23 and the container's `StartedAt` at 16:25:27 — so verify after **any** pihole restart, deploy-triggered or manual, not only after a reboot. The instrument is on the box: `getent hosts grafana.franksdank.cloud` (or `dig +short @192.168.0.143 <name>`) answering empty means FTL is holding no local records at all; here the file was present and readable inside the container (`-rw-rw-r-- … /etc/pihole/hosts/addn-hosts`, 44 records) and `dns.hosts` in `pihole.toml` was intact. `podman exec pihole pihole restartdns reload` had every name answering within seconds. The mechanism is **not established** — the same mounts and the same config read fine before and after the reload — so treat it as a load failure of the record sources at FTL start, not as a lost or unmounted file. What it cost: the whole Playwright gate, 18 specs across every vhost failing `ERR_NAME_NOT_RESOLVED` while the fleet itself was healthy ([[E2E Suite]]).
|
||||
|
||||
## Upstream
|
||||
|
||||
The slirp netns has no useful `resolv.conf`. The env file pins the upstreams: `DNS1=1.1.1.1`, `DNS2=1.0.0.1` (mirrors the retired `dnsmasq.conf` `server=` lines). Every query that is not a local record goes to those resolvers. The zone apex and unknown subdomains fall through to Porkbun.
|
||||
@@ -74,6 +76,8 @@ Both prerequisites survive the dnsmasq → Pi-hole swap. Do not remove them.
|
||||
| Upstream queries fail (local names still answer) | `DNS1`/`DNS2` in the env file are missing or wrong |
|
||||
| Port 53 `bind: permission denied` crash loop after a reboot | sysctl conflict: the value must be 53 at boot, and remember `/etc/sysctl.conf` is applied **after** all of `sysctl.d/`. Redeploy pihole — `deploy_pihole` reinstalls the drop-in, neutralizes conflicting drop-ins, and comments out a stale `/etc/sysctl.conf` line |
|
||||
| Single-label name (for example `dankclaw`) answers `127.0.1.1` | The rootless container `/etc/hosts` carries the host entry (`127.0.1.1 <host>`), and FTL serves it. Use the FQDN, or add an `/etc/hosts` entry on the client |
|
||||
| Every `franksdank.cloud` name is NXDOMAIN for LAN clients while external names still answer | FTL holds no local records: `getent hosts <name>` on Dankclaw comes back empty → `podman exec pihole pihole restartdns reload` (the 2026-10-19 pitfall under **Records**) |
|
||||
| One client gets nothing while every other client is fine | FTL rate-limiting that client: `podman exec pihole grep Rate-limiting /var/log/pihole/FTL.log`. A `10.0.2.x` client address is a pasta container's DNS forwarder, not a machine |
|
||||
|
||||
## See Also
|
||||
|
||||
|
||||
@@ -43,10 +43,12 @@ Dankclaw is an AMD APU + RTX 3090 inference host. It ran CIRU ROCmFPX llama.cpp
|
||||
| Device | /dev/accel/accel0 |
|
||||
| Driver | amdxdna 0.7.0, in-tree — bound since 2026-10-18 (`lspci -k -s c6:00.1` → `Kernel driver in use: amdxdna`; journal 2026-10-18 01:25:14 `Initialized amdxdna_accel_driver 0.7.0 for 0000:c6:00.1 on minor 0`) |
|
||||
| Firmware | 1.1.2.65 (`/usr/lib/firmware/amdnpu/17f0_11/npu_7.sbin` → `npu.sbin.1.1.2.65.zst`, loaded at boot 2026-10-18 01:25:13) |
|
||||
| Runtime | None installed. FastFlowLM 0.9.45 + XRT (installed 2026-08-16) was **retired 2026-09-11** with the `amd_iommu=off` decision — see [[dankclaw-iommu-translation-off]]; the package remains at `/home/dank/GitPackages/fastflowlm_0.9.45_ubuntu26.04_amd64.deb` |
|
||||
| Status | Not in service — no NPU endpoint is deployed. The device itself is usable since the 2026-10-18 `iommu=pt` boot (`/dev/accel/accel0` present, `crw-rw---- root:render 261,0`); Gitea issue #3 tracks enablement. See |
|
||||
| Runtime | Distro XRT only: `libxrt2` + `libxrt-npu2` 1:2.21.75 supply `libxrt_coreutil.so.2`, `libxrt_core.so.2` and the NPU plugin `libxrt_driver_xdna.so.2` under `/usr/lib/x86_64-linux-gnu`; **no `/opt/xilinx/xrt`** (census 2026-10-19), no Ryzen AI Python stack. FastFlowLM 0.9.45 + AMD's XRT (installed 2026-08-16) was **retired 2026-09-11** with the `amd_iommu=off` decision — see [[dankclaw-iommu-translation-off]]; the package remains at `/home/dank/GitPackages/fastflowlm_0.9.45_ubuntu26.04_amd64.deb` |
|
||||
| Status | Not in service — no NPU consumer is deployed. The device itself is usable since the 2026-10-18 `iommu=pt` boot (`/dev/accel/accel0` present, `crw-rw---- root:render 261,0`); Gitea issue #3 tracks enablement. The only runtime here that ships precompiled programs this device can execute is halogen's — see [[frank-dragoon]] §The NPU lane |
|
||||
|
||||
The NPU endpoint is retired (2026-09-11; ). The `frank-zealot` name now serves **Ternary-Bonsai-2-27B-PQ2_0** on the RTX 3090 via the llama.cpp PrismML fork (see ); the Gemma4-12B QAT rung it once pointed at is retired history (, 2026-09-17 → 10-01).
|
||||
This section is device inventory, not a payload record. The name `frank-zealot` served the FLM NPU endpoint until
|
||||
2026-09-11 and has served Bonsai-2 on the RTX 3090 through the llama.cpp PrismML fork since; the checkpoint, the
|
||||
shape and every era are on [[frank-zealot]], and any figure restated here is the next thing to go stale.
|
||||
|
||||
### ROCm
|
||||
|
||||
|
||||
@@ -251,21 +251,20 @@ Knobs, in the order worth trying them (radiance GUIDE §4.5):
|
||||
the prefill stager's smallest pass — the startup line names it, and below it every
|
||||
routed layer reads its pooled experts synchronously, which costs the prefill far
|
||||
more than sharing saves. Counts as `engine_prefill_chunks_capped_total`.
|
||||
- `--max-num-batched-tokens` — the blunt rung, and the only one older pins have. The
|
||||
vendor's curve on a 48-layer MoE at 200k on two cards: chunk 512 → 1750 t/s, 1024
|
||||
→ 2200, 2048 → 2706 (a 185k prompt: 138 s against 91 s), so halving the stall
|
||||
costs ~19 % of prefill. Raising it past the interval buys nothing and loses expert
|
||||
residency to the arena.
|
||||
- `--prefill-span N` — the inverse trade: a *lone* prefill carries N intervals a step
|
||||
while every shared step is planned at a smaller shared budget, which on an
|
||||
expert-tiered box lends that arena back to the expert slab. Vendor TP2
|
||||
expert-tiered TTFT deltas vs a plain 2048 budget: −6.1 % at 2k, +2.3 % at 18k,
|
||||
+49.1 % at 250k — long prompts only.
|
||||
- `--max-num-batched-tokens` — the blunt rung, and the only one older pins have. The vendor's own
|
||||
curve prices **halving the shared chunk at roughly a fifth of prefill throughput** (a 48-layer MoE
|
||||
at 200k on two cards — knob pricing, not this endpoint's number, so treat it as the order of
|
||||
magnitude a chunk decision costs). Raising it past the checkpoint interval buys nothing and loses
|
||||
expert residency to the arena.
|
||||
- `--prefill-span N` — the inverse trade: a *lone* prefill carries N intervals a step while every
|
||||
shared step is planned at a smaller shared budget, which on an expert-tiered box lends that arena
|
||||
back to the expert slab. The vendor's own measurement says the win is **long-prompt-only**: nothing
|
||||
at short prompts, a large TTFT gain at the far end of the window.
|
||||
|
||||
Two traps. **The arena/slab coupling makes a shared step doubly expensive on an
|
||||
expert-tiered shape**: the full-plan arena takes VRAM the resident expert slab would
|
||||
have had (320 MiB/card at TP2 for a 4096 budget), so the decode rows inside that step
|
||||
also read experts over the link — a smaller shared budget helps twice. And **re-check
|
||||
expert-tiered shape**: the full-plan arena takes VRAM the resident expert slab would have had, so the
|
||||
decode rows inside that step also read experts over the link — a smaller shared budget helps twice.
|
||||
And **re-check
|
||||
`prefix_hit_rate` / `prefix_evictions` after any chunk change**: the arena and the KV
|
||||
pool are the same memory, and the failure looks like turn two taking as long as turn
|
||||
one.
|
||||
@@ -276,8 +275,9 @@ Availability is a version question, not a config question: `--busy-prefill-chunk
|
||||
tree, and check the source clone's depth before dating a flag from `git log -S`
|
||||
(a `--depth 1` clone reports its own tip for every search).
|
||||
|
||||
Run on: [[frank-archon]] (radiance 1.0.13, TP2 `expert_tiered`, budget 2048 = interval
|
||||
2048) — the measured census and the verdict for this fleet's shape are that page's.
|
||||
Run on: [[frank-archon]] — the census was taken on its 3-slot radiance 1.0.13 era (budget 2048 =
|
||||
interval 2048); the live 1.8.1 shape carries the two chunk knobs. The measured census and the verdict
|
||||
for this fleet's shape are that page's.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -215,9 +215,10 @@ is in `render`, so a quadlet needs `AddDevice=/dev/accel/accel0` +
|
||||
`GroupAdd=keep-groups`. **No NPU endpoint is deployed** — stays the
|
||||
history of the retired endpoint; what changed is only its precondition.
|
||||
|
||||
**Deliberately deferred:** the `pp_dpm_fclk` pin (level 7, 2000 MHz) from the issue's
|
||||
co-run step. Pinning the fabric clock changes iGPU timing for every workload, and
|
||||
nothing co-runs on the NPU yet; it goes in when an NPU endpoint actually exists.
|
||||
**The `pp_dpm_fclk` pin (level 7, 2000 MHz) went in on 2026-10-19**, with the first thing that needed it: the
|
||||
vendor's root host unit, installed and enabled by `frank-dragoon`'s deployer while its NPU lane is named. Pinning
|
||||
the fabric clock changes iGPU timing for every workload, which is exactly why it waited for a co-run to exist; the
|
||||
law, the hold and the serve-side re-check live in [[frank-dragoon]].
|
||||
PerfOpt still needs Linux 7.4 — `7.0.0-38-generic` has zero `perfopt` symbols and the
|
||||
26.04 archive caps at `7.0.0-38.38`.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
type: reference
|
||||
title: "frank-archon on Dankserver — radiance (StillDeadcode) 1.0.13, publisher Qwen3.8-Flash-Next iq4r .rad, 2× R9700 TP=2 (ACTIVE)"
|
||||
title: "frank-archon on Dankserver — radiance (StillDeadcode) 1.8.1, publisher Qwen3.8-Flash-Next fp8-iq4r-moe .rad, 2× R9700 TP=2 (ACTIVE)"
|
||||
description: "The radiance endpoint: live shape and harness contract, every era with its rollback path, and the laws it taught."
|
||||
tags:
|
||||
- llm
|
||||
@@ -12,7 +12,7 @@ tags:
|
||||
- tp2
|
||||
- expert-offload
|
||||
- endpoint
|
||||
timestamp: 2026-10-18
|
||||
timestamp: 2026-10-19
|
||||
---
|
||||
|
||||
# frank-archon — radiance (StillDeadcode), 2× R9700 TP=2, Dankserver :7770
|
||||
@@ -20,8 +20,9 @@ timestamp: 2026-10-18
|
||||
The radiance engine (C++/HIP, `libr4d` kernels built for gfx1201, no Python in the serving
|
||||
path) driving `podman run` straight from the digest-pinned image under a **root** systemd
|
||||
unit; container name = unit name. Host Dankserver (192.168.0.235), port 7770, both Radeon AI
|
||||
PRO R9700s. Capacity contract **3 conversations × 200,000 tokens at fp8 KV**; a payload swap
|
||||
is a `MODEL_FILE` line plus a re-download — see Eras.
|
||||
PRO R9700s. Capacity contract **2 conversations × 200,000 tokens at fp8 KV** — at 1.8.1 that pair
|
||||
is the **demand signal** the budget resolver sizes the cache against, not a KV allocation (Laws).
|
||||
A payload swap is a `MODEL_FILE` line plus a re-download — see Eras.
|
||||
|
||||
## Current shape
|
||||
|
||||
@@ -44,14 +45,20 @@ declared size + sha256) as the transient unit `iq4r-fetch` (log
|
||||
`/home/dank/models/StillDeadcode/…` keeps card, LICENSE and recipe as provenance, and only
|
||||
`.rad` files are mounted.
|
||||
|
||||
**Runtime** — `stilldeadcode/radiance@sha256:4c3f484564bb2872784e46b0a59cbd71becc9c1e746e301a3596ffae253c322e`
|
||||
= pin **1.0.13** (Codeberg `v1.0.13`, commit `53282fa`): a 287 MiB image in dank's
|
||||
**rootless** storage, host needs nothing but the amdgpu driver. Pin the INDEX manifest digest,
|
||||
never the image ID (`69d05724…` is the config digest and sends `podman run` to the network).
|
||||
The serve script's gate is that digest ref itself — `podman image exists name@sha256:…` in one
|
||||
command (the 2026-09-30 no-pipe/grep-race lesson) — so a retagged look-alike or a newer
|
||||
`latest` cannot satisfy it. 1.2.0 is a rejected serve pin (Laws); the
|
||||
`--vram-weights-mib`/`--vram-kv-mib` pair is accepted by both pins, which is the rollback symmetry.
|
||||
**Runtime** — `stilldeadcode/radiance@sha256:d590e300234bf6d2f942a0b8d456d183153fd808f28820e6c2fa69c9389178ed`
|
||||
= pin **1.8.1** (adopted 2026-10-18). The version oracle is the boot's own `STARTUP REPORT --
|
||||
radiance 1.8.1` line, because `podman inspect` reports only the digest. The image lives in dank's
|
||||
**rootless** storage and the host needs nothing but the amdgpu driver. Pin the INDEX manifest
|
||||
digest, never the image ID (`69d05724…` is the config digest and sends `podman run` to the
|
||||
network). The serve script's gate is that digest ref itself — `podman image exists name@sha256:…`
|
||||
in one command (the 2026-09-30 no-pipe/grep-race lesson) — so a retagged look-alike or a newer
|
||||
`latest` cannot satisfy it. 1.2.0 is a rejected serve pin (Laws). **Two CLI breaks ride this pin,
|
||||
and both are boot refusals, never silently-ignored flags:** the `--vram-weights-mib`/`--vram-kv-mib`
|
||||
pair died at 1.3.0 (the resolver derives the split) and `--prefix-cache-dir` /
|
||||
`--prefix-cache-disk-mib` died with the disk tier. A rollback to 1.0.13 (`sha256:4c3f4845…`) is
|
||||
therefore a trio rewrite, not a digest swap — the Eras row carries it, and
|
||||
`tests/frank_archon_unit.bats` asserts the retired knobs are ABSENT from env and argv so a stale
|
||||
line cannot reach a boot.
|
||||
|
||||
**Trio** — `scripts/frank-archon.env` (every number, with its arithmetic in the header)
|
||||
`scripts/serve-frank-archon.sh`, `scripts/frank-archon.service`. Gates run in order: image
|
||||
@@ -64,8 +71,10 @@ the name registered, and every one of those identities holds this GPU. Container
|
||||
(publisher-vetted, `docs/DOCKER.md`): `--network host`, `--init` (so the engine releases the
|
||||
cards on SIGTERM), `--security-opt seccomp=unconfined` (the default profile refuses io_uring —
|
||||
the PLE table's reads — and the NUMA calls the ROCm runtime makes for host memory), `--ulimit
|
||||
memlock=-1:-1`, `/models` ro + `/data` rw + the tune cache at `/opt/radiance/share/radiance/tune`
|
||||
template as a single-file mount, then `exec podman wait`: the script's lifetime IS the
|
||||
memlock=-1:-1`, `/models` ro + the rad-tune cache at
|
||||
`/opt/radiance/share/radiance/tune` + the template as a single-file mount (there is **no `/data`**
|
||||
any more: it left with the prefix disk tier, and `FRANK_ARCHON_STATE_DIR` is now only the host-side
|
||||
directory that holds `tune/`), then `exec podman wait`: the script's lifetime IS the
|
||||
container's, so a crash is a nonzero `ExecStart` and the restart policy is real.
|
||||
Unit: `After=network-online.target`, `WantedBy=multi-user.target`, `User=dank`
|
||||
`LimitMEMLOCK=infinity`, `StartLimitIntervalSec=0`, `OOMScoreAdjust=-500` (the last process the
|
||||
@@ -79,27 +88,33 @@ on stop. Boot budget 1800 s (the publisher's compose allows a 15-minute `start_p
|
||||
| Knob (env name, `FRANK_ARCHON_` dropped) | Live value |
|
||||
|---|---|
|
||||
| TP / wire | `TP=2`, `TP_WIRE=wht6` — libr4d one-shot all-reduce, not RCCL; `min-kb` stays the engine default |
|
||||
| Slots / window | `MAX_SEQS=3`, `MAX_MODEL_LEN=200000` (native 262,144; longer is refused, never truncated) |
|
||||
| KV dtype | `KV_DTYPE=fp8` — the only dtype that funds 3 slots (Laws) |
|
||||
| VRAM pair | `VRAM_WEIGHTS_MIB=25600` + `VRAM_KV_MIB=5120` = 30,720 of the 30,969 ceiling; `GPU_HEADROOM_MIB=96` |
|
||||
| Slots / window | `MAX_SEQS=2`, `MAX_MODEL_LEN=200000` (native 262,144; longer is refused, never truncated) — this pair is the **cache bound**, not a KV allocation |
|
||||
| KV dtype | `KV_DTYPE=fp8` — the only dtype that funds the slot count (Laws) |
|
||||
| VRAM split | **derived at boot** from `GPU_HEADROOM_MIB=96` + the cache bound + `HOST_POOL_MIB`; naming `VRAM_WEIGHTS_MIB`/`VRAM_KV_MIB` is a boot refusal (removed at 1.3.0) |
|
||||
| Expert tier | `PLACEMENT=expert_tiered`, `HOST_POOL_MIB=44928` **per rank** |
|
||||
| PLE | `NGRAM_PLACEMENT=ram` (47.7 GiB pinned; refuses boot on a short memlock) |
|
||||
| Spec / batch | — |
|
||||
| Prefix cache | `PREFIX_HOST_MIB=8192` + `PREFIX_DISK_MIB=65536` under `STATE_DIR=/home/dank/radiance-state` (mounted rw at `/data`) |
|
||||
| Spec / batch | `SPEC_TOKENS=2` — MTP depth 2, the `.rad` carries the model's own `mtp.*` head (`mtp.fc_hidden`/`fc_embedding`/`draft_head`); `MAX_BATCHED_TOKENS=2048` (MNBT — 2560 collapses decode, Laws) |
|
||||
| Prefix cache | `PREFIX_CACHE_MIB=8192`, pinned **host tier only** — the 64 GiB disk tier is gone, so an evicted prefix is re-prefilled; `STATE_DIR=/home/dank/radiance-state` now exists only to hold the rad-tune cache |
|
||||
| Template | `TEMPLATE=scripts/qwen38-froggeric-v22.5.jinja` (28,234 B) via `--override-chat-template` — froggeric's fixed Qwen3.8 template (the repo root's `chat_template.jinja`, vendored); the checkpoint's own template is baked into the `.rad` by rad-convert and this overrides it, and radiance derives the reasoning and tool-call reply format from it |
|
||||
| Step sharing | `BUSY_PREFILL_CHUNK=auto` (a shared chunk reserves a row plus the draft window per waiting decoder) · `PREFILL_INTERLEAVE=0` — held at zero until the decode-vs-prefill buckets ask for it |
|
||||
| TTM gate | `TTM_MIN_MIB=150000`, `TTM_TARGET_MIB=204800` |
|
||||
| Boot | `RAD_MIN_MIB=110000` deployer floor; the listener binds only after the full load (health ~100 s, budget 1800 s) |
|
||||
|
||||
Boot ledger for this shape (2026-10-15, pin 1.0.13): pool 5.00 GiB backs **607,740 tokens**
|
||||
(`kv_attn` fp8 page 26.00 KiB, `kv_qsa_bkey` 3.25 KiB), `kv_gdn_state` 54 MiB × 6 slots, slab
|
||||
**17,548 slots** with 4.22 GiB of KV lent to it, sampler **9 rows**, template 28,234 B, vision
|
||||
live (≤ 4,194,304 px, 16,384 patches/pass).
|
||||
Boot ledger for the live shape (2026-10-19 11:10, pin 1.8.1, `MAX_SEQS=2`, `HOST_POOL_MIB=44928`):
|
||||
the resolver prints `elastic 26.73 GiB` → **experts 23.73 GiB (89 %) / kv cache 2.76 GiB (10 %)**,
|
||||
`vram_weights 27.31 GiB`, `host_pool 44.47 GiB`. `kv_attn` (13 layers, 4-token blocks) pages
|
||||
**26.00 KiB** → 91,301 blocks = **365,204 tokens = 1.8 full-length sessions = 91 % of the
|
||||
2 × 200,000 worst case** (the resolver never funds 100 %, see Laws); `mover` lends 2,146 slots
|
||||
(2.54 GiB) out of the cache to the expert slab; sampler **9 rows**, template 28,234 B, vision live
|
||||
(≤ 4,194,304 px, 16,384 patches/pass).
|
||||
|
||||
The boot **ledger**, not this arithmetic, is the referee: radiance states every budget before
|
||||
anything is allocated (weights vs KV, the host pool's coverage of the worst case, the PLE pin,
|
||||
the P2P answer per pair) — read it after every resize, and unlike every predecessor there is no
|
||||
per-boot VRAM planner to second-guess. For comparison, the card's own suggested run line on 32
|
||||
GB cards is `--host-pool-mib 12288 --expert-vs-cache-ratio 0.82` with seqs 8 and spec 3; the
|
||||
per-boot VRAM planner to second-guess. For comparison, the publisher's own suggested run line on 32
|
||||
GB cards was `--host-pool-mib 12288 --expert-vs-cache-ratio 0.82` with seqs 8 and spec 3 — a
|
||||
1.0.x-era line, and `--expert-vs-cache-ratio` was removed at 1.2.0, so today it is a capacity floor
|
||||
quoted from the card, not a run line this pin accepts; the
|
||||
shape above is the fleet's measured one. `FRANK_ARCHON_EXTRA_ARGS` is the boot-ladder escape
|
||||
hatch (unset → no extra argv; it is expanded unquoted on purpose, so keep multi-flag values as
|
||||
one string).
|
||||
@@ -130,6 +145,7 @@ marked "re-fetch" is gone from `/home/dank/models/`, which today holds only `Sti
|
||||
|
||||
| Window | Payload | Runtime | Shape | Replaced because | Rollback |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-10-15 → 10-19 | same `qwen3.8-next-flash-fp8-iq4r-moe.rad` (payload unchanged) | radiance **1.0.13**, `sha256:4c3f4845…` | 3 × 200,000 fp8 KV bought with the **explicit** pair `--vram-weights-mib 25600 --vram-kv-mib 5120`; host pool 44,928 → 45,568 MiB a rank; prefix cache 8 GiB host + 64 GiB disk | decode turned to sludge beside any long prompt — step contention, 18.7 ms decode-only steps against 317 ms steps carrying a prefill chunk (Laws) — and the only two knobs that fix it, `--busy-prefill-chunk` and `--prefill-interleave`, exist only after this pin | `FRANK_ARCHON_IMAGE` back to `sha256:4c3f4845…` **plus a trio rewrite**: restore the pair and the host+disk prefix knobs, delete `BUSY_PREFILL_CHUNK`/`PREFILL_INTERLEAVE`. A digest swap alone is a boot refusal (Runtime) |
|
||||
| 2026-10-08 → 10-15 | in-house `qwen4exp-swift15-w4dn8-i8-hc8m.rad`, 116,416 MiB (Swift1.5 abliterated BF16, GPTQ w4 experts + 24 bf16 protected, KLD 0.0131 / top-1 97.92 %) | radiance 1.0.13, same digest | 3 × 200,000 fp8 KV, slab 25,600 / pool 5,120 — the shape the live file still carries | the engine author's own container is the supported path and his quality ceiling; the finetune's refusal-bending left the endpoint | `.rad` purged 2026-10-17 (trash-staged then emptied, Frank's approval). Re-download `frank8465/Swift-1.5-Qwen3.8-Flash-Next-GPTQ-W4A8-Radiance-AMD-R9700` → `qwen4exp-w4nl64-i8-hc8m.rad`, 122,071,973,888 B, LFS sha256 `09dd9306…`, via the iq4r fetch script's hfget/verify pattern retargeted at that repo; point `MODEL_FILE` at it, redeploy. Re-**convert** is dead: the BF16 source was `rm`'d 2026-10-15 (`kld-ref`, grams, corpora and `/home/dank/logs/swift15_*.log` survive) |
|
||||
| 2026-10-05 → 10-08 | `qwen4exp-fp8-native.rad`, 177,109 MiB (173.0 GiB) — native modelopt block-FP8, no requantisation | radiance 1.0.7 line, same digest | — | native FP8 experts (56.4 GiB/rank) ate a 40 GiB pool and left KV at 3,543 MiB = 386,904 tokens, **1.93 sessions** — context economics, not quality; and the host pool could not be trimmed to pay for it (36,864 refused the boot) | rebuild-only: `scripts/download-frank-archon.sh` (official `Qwen/Qwen3.8-Flash-Next-FP8`, 131 shards, 172.8 GB, ungated) + `scripts/build-frank-archon-rad.sh` (no-recipe convert under the descriptor law); raw **and** `.rad` were `rm`'d 2026-10-15 (Frank's call, to free the volume for the iq4r download) |
|
||||
| 2026-10-05 (same day) | `orcarouter/Qwen3.8-Flash-Next-Uncensored-FP8` safetensors, 131 shards | FreeToken PR #587 head `77a7201` (`rocm714_torch214`), image `localhost/frank-primary-freetoken:pr587-77a7201` | — | TP=2 cannot boot on ROCm: the per-rank `pynccl.cu` JIT cannot resolve `cuda_runtime.h` for gfx1201, so the second card idled | git tag `frank-archon-freetoken-pr587-20261005` (trio + `scripts/docker/frank-primary-freetoken.Dockerfile`, which exists only there); image still in rootless storage; payload needs a re-fetch, and that repo 403s until the gate is accepted on the account |
|
||||
@@ -161,7 +177,8 @@ vllm-radiance era (this table opens on 09-26; the era page claims 10-10).
|
||||
2×**, precisely because the bkey plane is dtype-fixed. Secondary saving: a prefix-cache entry
|
||||
tracks the attention plane (110.50 KiB at bf16 → 58.50 at fp8, per the `kv tier transfer`
|
||||
lines), so the same 8 GiB host tier parks ~5.8 sessions of 200k at fp8 where it parked ~2.9 at bf16.
|
||||
- **Pool sizing law.** `pool_mib ≥ tokens × 7.3125 KiB + 110 × seqs + 448` — the dtype-
|
||||
- **Pool sizing law (the explicit-pair era — at 1.8.1 the cache is derived, so the resolver law below
|
||||
owns it).** `pool_mib ≥ tokens × 7.3125 KiB + 110 × seqs + 448` — the dtype-
|
||||
independent terms are also in the pool: `kv_gdn_state` 54 MiB × 2 copies per sequence (+conv/
|
||||
tail/ple ≈ 110 MiB a sequence) and the prefix checkpoint region 8 × 55.99 MiB. Deployed: 3 ×
|
||||
200,000 at fp8 = 4,285 + 330 + 448 = **5,063 MiB**, pool 5,120, which predicts (5120 − 330 −
|
||||
@@ -174,13 +191,15 @@ vllm-radiance era (this table opens on 09-26; the era page claims 10-10).
|
||||
pool of 3,543 gave 386,904 (1.93 sessions), and the four-slot fp8 boot of 2026-10-13 19:21
|
||||
backed 843,276. The ledger's `backs N sequences all at 200000 tokens at once` line is the
|
||||
referee — a pair this tight is read off the boot, never assumed, and the concurrent probe is
|
||||
its regression test. Fourth full-height conversation needs 6,601 MiB of pool: refusing it is
|
||||
Frank's explicit call, and serving it is a `--vram-kv-mib` change, not a code change.
|
||||
its regression test. A fourth full-height conversation needs 6,601 MiB of pool: refusing it is
|
||||
Frank's explicit call, and under that pin serving it was a `--vram-kv-mib` change, not a code change.
|
||||
At 1.8.1 the same decision is `MAX_SEQS` — the resolver buys the cache the demand signal implies.
|
||||
- **The hard limit is per request.** The engine treats `kv group tokens >= max-model-len` as a
|
||||
HARD limit — "one request longer than N tokens is accepted by the API and then refused as too
|
||||
large to ever fit — this is a hard limit, not a backing ratio" — so the pool is sized for
|
||||
`seqs × max-model-len`, not for one sequence. Radiance never truncates, at any size.
|
||||
- **One VRAM budget per card:** `weights + kv ≤ 30,969 MiB`, read off the engine's own budget
|
||||
- **One VRAM budget per card** (the arithmetic below is the pair era's; the ceiling survives, the knobs
|
||||
do not): `weights + kv ≤ 30,969 MiB`, read off the engine's own budget
|
||||
table (boot 2026-10-13 at pair 24000/5120): card 32,624 MiB, budgets claimed 29,120, and
|
||||
"*3.42 GiB is neither budgeted nor accounted — the driver's context, the activation arena and
|
||||
`--gpu-headroom-mib` come out of it; anything past those is throughput on the floor*", with
|
||||
@@ -188,12 +207,34 @@ vllm-radiance era (this table opens on 09-26; the era page claims 10-10).
|
||||
static region). Reserve above the engine's floor = 2,058 − 209 = 1,849, ceiling 29,120 + 1,849
|
||||
= 30,969; the deployed pair lands 458 MiB above that floor. The 2026-10-11 bound of 29,120
|
||||
("31,856 − 1,710 outside both budgets − keep 1 GiB free") was a hand-picked reserve, not the
|
||||
engine's wall. Boot with the explicit `--vram-weights-mib`/`--vram-kv-mib` pair, never
|
||||
`--kv-cache-size-mib`; with the disk tier engaged the engine also forces ~9.6 GiB of weight
|
||||
buffers + arena onto the weights side (~29.6 GiB/card effective).
|
||||
engine's wall. Under that pin, boot with the explicit `--vram-weights-mib`/`--vram-kv-mib` pair,
|
||||
never `--kv-cache-size-mib`; with the disk tier engaged the engine also forced ~9.6 GiB of weight
|
||||
buffers + arena onto the weights side (~29.6 GiB/card effective). **Neither flag exists at 1.8.1:**
|
||||
the same ceiling is enforced by the resolver, and passing the pair is a boot refusal.
|
||||
- **At 1.8.1 the split belongs to the resolver, and it funds ~90 % of the worst case, never 100 %.**
|
||||
The engine takes `--gpu-headroom-mib`, the cache bound (`--max-model-len` × `--max-num-seqs`) and
|
||||
`--host-pool-mib`, prints `claimable → elastic → experts / kv cache` before allocating anything, and
|
||||
sizes the expert plane from the model's own weight footprint plus slack — not from a number typed by
|
||||
hand. Three boots of evidence:
|
||||
|
||||
| shape | demand | cache given | backing |
|
||||
|---|---|---|---|
|
||||
| 3 × 200,000, host pool 44,928 | 600,000 tok | 4.20 GiB | 93 % (2.8 sessions) |
|
||||
| 3 × 200,000, host pool 45,568 | 600,000 tok | **4.20 GiB — byte-identical** | 93 % |
|
||||
| 2 × 200,000, host pool 44,928 (live) | 400,000 tok | 2.76 GiB = 365,204 tok | 91 % (1.8 sessions) |
|
||||
|
||||
So `--max-num-seqs` is a **demand signal** for the resolver and an admission cap for the scheduler —
|
||||
never a KV allocation — and no slot count fully funds the worst case. Growing host RAM by less than a
|
||||
slab step (640 MiB) moved nothing: the expert plane's claim decides what is left for KV, so cache is
|
||||
grown through slots × window or the dtype, and experts are grown by not leaving elastic pool idle.
|
||||
`--host-pool-mib` is still load-bearing the other way — it is where the expert rows the cards cannot
|
||||
hold get pinned, and under-sizing it has always been a boot failure (36,864 refused a boot in the fp8
|
||||
era). The two-slot boot's own behavioural check: three concurrent conversations all answer `200` —
|
||||
the third **queues** (`queue_cap: 16`), it is not refused; the 400 class is a single request over
|
||||
`--max-model-len`, or three concurrent full-height conversations against a 1.8-session cache.
|
||||
- **Idle KV VRAM is the expert cache's spillover — the price is paid twice.** `mover: 2275
|
||||
slots can be lent out of the KV cache (2.69 GiB)`, `arena: lends 740 MiB while steps are <=
|
||||
256 tokens`; the lend grows with the pool (2.69 GiB from 3.46, 6.08 from 6.912, 4.22 today)
|
||||
256 tokens`; the lend grows with the pool (2.69 GiB from 3.46, 6.08 from 6.912, 4.22 in the pair era)
|
||||
so a bigger KV pool steals weight residency from the slab AND from the lendable cache. Priors
|
||||
on this card/model: stealing 2.14 GiB of weights to buy `MAX_MODEL_LEN 262144` cost 2.5 %
|
||||
decode; the other direction (`--expert-vs-cache-ratio` 0.82 → 0.84, +2.5 GiB of weights) cost
|
||||
@@ -239,8 +280,8 @@ vllm-radiance era (this table opens on 09-26; the era page claims 10-10).
|
||||
- **MNBT 2048.** 2560 collapses decode to 2.6-7.7 t/s (MTP resonance); the w4 file tier streams
|
||||
in place only at MNBT ≤ 2048 and the activation arena scales with the chunk (32768 → 33.3
|
||||
GiB, refused).
|
||||
- **Decode pays for the chunk it shares a step with.** Census of the live engine
|
||||
(radiance 1.0.13, `/stats` + `/metrics`, cumulative over the 2026-10-15 boot):
|
||||
- **Decode pays for the chunk it shares a step with.** Census of the live engine taken at the
|
||||
3-slot shape on 1.0.13 (`/stats` + `/metrics`, cumulative over the 2026-10-15 boot):
|
||||
1,435,585 decode-only steps at **18.7 ms** each against 127,991 prefill+mixed steps
|
||||
at **317 ms** — 60 % of engine wall-clock spent inside 8 % of the steps, because the
|
||||
chunk is `min(remaining, checkpoint interval 2048, MNBT 2048)` = 2048 rows and every
|
||||
@@ -252,28 +293,33 @@ vllm-radiance era (this table opens on 09-26; the era page claims 10-10).
|
||||
busy-prefill window sits at the idle baseline (58-64 vs 77-85 t/s, the residual
|
||||
confounded by co-scheduling), so no lingering expert-residency penalty at 2-minute
|
||||
resolution, and `preemptions: 0` rules out preempt-and-re-prefill. Method:
|
||||
[[LLM Performance Diagnosis]] §9.
|
||||
- **The knobs for it are not in this pin.** `--busy-prefill-chunk` and
|
||||
`--prefill-interleave` (radiance GUIDE §4.5 — a shared chunk's cap, and N decode-only
|
||||
steps after each chunk) are absent from 1.0.13: proven on the running container
|
||||
(`podman exec frank-archon radiance --help` prints no `prefill`/`chunk` flag), and the
|
||||
2026-10-18 source tree carries both. The only crank this pin has is MNBT, and that is
|
||||
an arm and not an edit: the vendor prices a 1024 chunk at −19 % prefill to halve the
|
||||
stall, and this page's own MNBT law already knows 2560 collapses decode (MTP
|
||||
resonance). An image bump is gated by the 1.2.0 numerics dead end below, so any try
|
||||
re-scores draft acceptance before it is trusted.
|
||||
- **Slots cost KV and rows, never speed.** `--max-num-seqs` carries no radiance constraint: it
|
||||
adds 110 MiB of recurrent state per slot and declares the fused TP all-reduce over `seqs ×
|
||||
(1 + spec)` rows, and `wht6` is bit-exact up to 25 rows (single-sequence decode and any step
|
||||
of ≤ 25 rows is exact; only busier steps are lossy) — 3 × 3 = 9 today. Measured 2026-10-06:
|
||||
seqs 2 and seqs 3 are statistically tied (30.9-33.1 t/s, inside the ±3 % floor). The "seqs 2
|
||||
cannot boot" law belonged to the retired tcclaviger fork's `torch.compile` shape
|
||||
specialization, and radiance booted seqs 2 on its first fp8-native start.
|
||||
**Same era, separate law: a dequantize-to-compute path bills host RAM twice.** On the
|
||||
EMULATION backend an offload request of **118 GiB** landed as **~238 GiB** of real host RAM —
|
||||
the dequantized copy rides on top of the pinned weights, and the doubling is invisible in the
|
||||
weight count. Read the engine log for `Using 'EMULATION' Mxfp4 MoE backend` before concluding
|
||||
"doesn't fit"; the container-side procedure and the kernel gate are .
|
||||
[[LLM Performance Diagnosis]] §9. The law is shape-independent; the fix lives at 1.8.1 (next law),
|
||||
and nobody has re-run the census since the migration — so the fix is ordered, **not verified**.
|
||||
- **The knobs for it arrived with 1.8.1, and only one of them is on.** `--busy-prefill-chunk` caps the
|
||||
shared chunk so a step with waiting decoders takes a smaller piece (live: `auto`, the engine picks;
|
||||
the explicit value is 512), and `--prefill-interleave N` inserts N decode-only steps after a chunk
|
||||
step taken beside decoders. `interleave` is deliberately **0** — the env comment holds it there until
|
||||
the decode-vs-prefill buckets ask for it, and those buckets have not been read since the migration.
|
||||
The vendor prices a 1024 chunk at about a fifth off prefill throughput to halve the stall; that is a
|
||||
knob for choosing tomorrow's chunk, not a measurement of this shape. MNBT remains an arm and not an
|
||||
edit: 2560 collapses decode through MTP resonance. Any chunk change re-scores draft acceptance before
|
||||
it is trusted.
|
||||
- **Slots cost KV, rows, and window — never speed.** `--max-num-seqs` carries no radiance constraint: it
|
||||
adds ≈110 MiB of recurrent state per slot (the live `kv_full_backing` arithmetic prices them exactly),
|
||||
declares the fused TP all-reduce over `seqs × (1 + spec)` rows, and `wht6` is bit-exact up to 25 rows
|
||||
(single-sequence decode and any step of ≤ 25 rows is exact; only busier steps are lossy) — 2 × 3 = 6
|
||||
today. Measured 2026-10-06: seqs 2 and seqs 3 are statistically tied (30.9-33.1 t/s, inside the ±3 %
|
||||
floor). Measured 2026-10-19 at 1.8.1: the third slot's real price is **KV, not rows** — dropping it
|
||||
moved the resolver's cache from 4.20 GiB to 2.76 GiB and the backing from 2.8 to 1.8 sessions, while
|
||||
the ≈4 GiB of host RAM it freed came back unbudgeted, landing in elastic pool that turned into a bigger
|
||||
mover lend instead of cache (resolver law above). The old "seqs 2 cannot boot" law belonged to the
|
||||
retired tcclaviger fork's `torch.compile` shape specialization, and radiance booted seqs 2 on its first
|
||||
fp8-native start.
|
||||
- **A dequantize-to-compute path bills host RAM twice.** On the EMULATION backend an offload request
|
||||
of **118 GiB** landed as **~238 GiB** of real host RAM — the dequantized copy rides on top of the
|
||||
pinned weights, and the doubling is invisible in the weight count. Read the engine log for
|
||||
`Using 'EMULATION' Mxfp4 MoE backend` before concluding "doesn't fit"; a page that says a payload
|
||||
"does not fit" without that line has not read the runtime.
|
||||
- **One encoding per expert table** (`rad_arch_declare`): a whole expert kept bf16 is the only
|
||||
thing legal beside the table, so a split tier (down=fp8, gate_up=w4) is unservable.
|
||||
- **Descriptor-selection law.** The plugin is picked by the exact `(arch_id, quant descriptor)`
|
||||
|
||||
@@ -10,7 +10,7 @@ tags:
|
||||
- gfx1151
|
||||
- halogen
|
||||
- endpoint
|
||||
timestamp: 2026-10-18
|
||||
timestamp: 2026-10-19
|
||||
---
|
||||
|
||||
# frank-dragoon — halogen-flash-server 0.16.2, native v2 abliterated (Dankclaw iGPU)
|
||||
@@ -197,7 +197,16 @@ created it — earlier topologies in .
|
||||
`chat_template_kwargs.preserve_thinking`, which decided whether `<think>` blocks stayed in recorded
|
||||
assistant messages. Sampler defaults 1.0/0.95/20 are server-side, so an absent temperature is NOT
|
||||
greedy (halogen 0.1.4 and w4b decoded greedily); unknown sampling fields are a 400, out-of-range
|
||||
values are refused not clamped, and `response_format` is unsupported.
|
||||
values are refused not clamped.
|
||||
- **`response_format` works, but only greedy** (measured 2026-10-19 on this pin): `json_schema` and `json_object`
|
||||
both answer schema-valid JSON at `temperature: 0`; at any sampled temperature both draw `400 "response_format
|
||||
json_schema / json_object decodes greedy in this release: send temperature 0 (or omit it). Sampling under a schema
|
||||
is not implemented yet"`. **Omitting `temperature` does not save you** — the sampler default is server-side 1.0, so
|
||||
"absent" means sampled. Clients that pattern-match that string for "endpoint cannot do schemas" (esperanto's
|
||||
`is_json_schema_unsupported_error` fires on the words *not implemented*) downgrade to `json_object`, draw the same
|
||||
400, and fail outright; a structured-output consumer MUST send `temperature: 0`. Escape hatch that keeps sampling:
|
||||
ask for JSON in the prompt and send no `response_format` — the model returns parseable JSON anyway, and the
|
||||
tolerant client-side extractor handles a prose wrapper.
|
||||
- Over-ceiling prompt → 400 naming the exact overage, rejected by preflight in <1 s, so probing the
|
||||
ceiling is free; over-cap `max_tokens` → 400 naming the cap; `max_tokens`, `max_completion_tokens` and
|
||||
`max_output_tokens` all work and must agree.
|
||||
@@ -210,6 +219,66 @@ created it — earlier topologies in .
|
||||
- Deploy waits for :7770 to be free: a rootless container binds it at CREATE and exits 126, and a
|
||||
leftover Quadlet beside the root unit guarantees the race.
|
||||
|
||||
## The NPU lane (XDNA2, armed by one env var)
|
||||
|
||||
XDNA2 executes only vendor-precompiled AIE partition programs — that is the wall the TTS attempt died at
|
||||
([[Frank TTS]]) — and flash-server ships those programs. `HALOGEN_NPU_MODELS=<names>` starts a second engine **in the
|
||||
same container behind the same port**, and a request picks one by its `model`; leave the variable out and the server
|
||||
is exactly what runs today. The pinned 0.16.2 image already carries the five-program catalog
|
||||
(`/opt/halogen/npu/models.txt`: `decider-0.8b`, `qwen3-embedding-0.6b`, `qwen3-reranker-0.6b`, `qwen3guard-gen-0.6b`,
|
||||
`qwen3.5-2b`) and `docs/NPU.md` at tag `v0.16.2` documents it, so **`/v1/embeddings` is one env var and three mounts
|
||||
away — no runtime bump needed.** Wider catalog: `qwen3-embedding-4b` and `decider-4b` from 0.17.4, `embeddinggemma-2`
|
||||
(text ≤ 8,192 tokens, pictures and audio in one vector space) and `wemm-embedding-4b` from 0.18.3.
|
||||
|
||||
Embedding contract on that lane: `POST /v1/embeddings`, `model: "qwen3-embedding-0.6b"`, `input` a list of **texts**
|
||||
(not token ids), 1024 unit-length floats back, the Qwen instruct prefix goes inside the string
|
||||
(`Instruct: …\nQuery:…`), `base64` accepted, and LangChain's `OpenAIEmbeddings` needs
|
||||
`check_embedding_ctx_length=False`.
|
||||
|
||||
Four host prerequisites, each refused **by name** in the serve script and mirrored in `deploy_llm_dragoon` — a
|
||||
precondition that surfaces as an engine crash-loop is a gate that was never written. All four are satisfied and the
|
||||
lane is **armed and serving** since 2026-10-19 (`HALOGEN_NPU_MODELS=qwen3-embedding-0.6b`):
|
||||
|
||||
1. `--device /dev/accel/accel0` **and** a node the container's namespace can open. `/dev/accel/accel0` is
|
||||
`root:render 0660` and a rootless namespace maps exactly one uid/gid, so the engine's own first words are
|
||||
*"this process cannot open it (crw-rw---- nobody:nogroup)"*. `--group-add keep-groups` — the vendor's advice and
|
||||
the ComfyUI pattern for `/dev/kfd` — is **dead in this shape**: measured 2026-10-19 from a `dank` shell already
|
||||
in `render`, with and without the flag the node stayed `nobody:nogroup` and the open was refused; an unmapped
|
||||
gid cannot be granted across a namespace that does not map it. The door is the udev mode
|
||||
(`dankclaw/udev/98-halogen-npu-access.rules`, installed and re-triggered by the deployer, `0666` like the GPU
|
||||
compute nodes beside it); the serve script refuses any node that is not other-accessible, so the fault cannot
|
||||
return as a crash-loop.
|
||||
2. The host's XRT reachable inside: there is **no `/opt/xilinx/xrt`** here — distro packages put the three files the
|
||||
container needs (`libxrt_coreutil.so.2`, `libxrt_core.so.2`, `libxrt_driver_xdna.so.2`) in
|
||||
`/usr/lib/x86_64-linux-gnu`, where all three exist.
|
||||
3. **The GPU fabric clock held at its top level, or the engine refuses to start.** GPU and NPU work share the die:
|
||||
while `pp_dpm_fclk` is changing speed, concurrent GPU work can hang the machine and corrupt NPU results. The
|
||||
engine holds the clock itself only when it runs as root with `/sys` mounted; `frank-dragoon` is a **rootless**
|
||||
`podman run` under `User=dank`, so the path is the vendor's root host unit (`deploy/host/halogen-fabric-clock`) —
|
||||
which is exactly the `pp_dpm_fclk` level-7 pin deferred in [[dankclaw-iommu-translation-off]] with the words "it
|
||||
goes in when an NPU endpoint actually exists". Held: about a watt. Unheld: the hang. The deployer installs and
|
||||
enables that unit whenever the lane is named, and `halogen-fabric-clock status` reports `held (manual)` here as of
|
||||
2026-10-19; the serve script re-reads the same two sysfs files at every start and refuses by name if the top level
|
||||
is not the starred one, because a hold done by hand is a countdown to the next reboot.
|
||||
4. The programs are staged under `$FRANK_DRAGOON_NPU_DIR`, which **must sit inside the payload dir**
|
||||
(`/home/dank/models/halogen-v2-abliterated/npu`): the payload's read-only mount is the container's only handle on
|
||||
`/models`. A nested bind at `/models/npu` cannot work — the image ships no `/models`, so runc has to `mkdir` the
|
||||
mountpoint through a read-only parent and dies at container init (measured 2026-10-19: 52 crash-loop restarts with
|
||||
chat down before the mount shape was fixed). `HALOGEN_NPU_DIR` is no escape; the shipped entrypoint rejects it by
|
||||
name ("our NPU models live under `/models/npu/<id>/`"). Nothing downloads at boot — `HALOGEN_DOWNLOAD` stays
|
||||
unset, so the container opens no outbound connection; `scripts/download-frank-dragoon-npu.sh` pins every file by
|
||||
size and sha256 against the catalog and the payload mount carries the tree in.
|
||||
|
||||
Armed, the boot ledger reads (`/home/dank/logs/frank_dragoon.log`, 2026-10-19): the GPU fabric clock is held at its top level (2000 MHz) **before** the NPU starts; `/dev/accel/accel0` is `crw-rw-rw- nobody:nogroup` under driver `amdxdna` with the distro XRT and its NPU plugin; the program arrives as `15 files checked against this image's record`; then `npu_api: qwen3-embedding-0.6b (embeddings) on the NPU engine at 127.0.0.1:8740 (buckets u0…u9; inputs up to 4096 tokens)` and `up to 64 passes queued … up to 7200 s`. Callers never see `:8740` — the lane answers on the same `:8731` as the Flash model and a request picks the engine by its `model` field, so `/v1/models` lists `frank-dragoon` **and** `qwen3-embedding-0.6b`. The fleet's consumer is open-notebook's embedding default ([[Open Notebook]]); that page owns the app-side shapes (the URL guard, defaults-by-id, the `/api/embed` 500 that still writes the vector).
|
||||
|
||||
Price: the programs and their weights sit in host RAM beside the locked weights ("a few GB in all" for the default
|
||||
set; ~1.1 GiB for `embeddinggemma-2`), and **the Flash model decodes slower while the NPU works** — same memory bus,
|
||||
same power budget. Measured 2026-10-19 with one program armed and the endpoint idle, the whole container holds
|
||||
**1.55 GiB** of host RSS (`podman stats`), so this lane is not what the RAM law above is negotiating against; a
|
||||
multi-model set is where that question returns. If the lane is widened, prefer **0.18.8 or newer**: that release
|
||||
budgets NPU memory before asking for it, fixing kernel errors from several NPU models under a concurrent mix
|
||||
**on the stock kernel driver** — the driver this box runs.
|
||||
|
||||
## Files
|
||||
|
||||
- `scripts/frank-dragoon.env` · `scripts/serve-frank-dragoon.sh` (gates, RAM pin-gate wait, foreground
|
||||
|
||||
@@ -130,7 +130,10 @@ is a locator: one row, one clause, no numbers.
|
||||
|---|---|
|
||||
| [[Agent Docs and Edits]] | Two self-inflicted doc damage classes from parallel agent edits, with the audit checks and recovery order for each. |
|
||||
| [[Command Center Dotfiles]] | How the dotfiles repo deploys: stow-equivalent packages, full-replica rsync, the pre-overwrite audit, transport gotchas. |
|
||||
| [[Domain Docs]] | Consumer rules for GLOSSARY.md and docs/adr/, and where this vault out-ranks them on operational facts. |
|
||||
| [[Issue Tracker]] | How agent skills read and write Gitea issues here: credential-safe curl, label IDs, and wayfinding without sub-issues. |
|
||||
| [[OMP Harness]] | The omp harness as configured here: compaction chain arithmetic, per-model sampler routing, and the context-lean audits. |
|
||||
| [[Triage Labels]] | The five canonical triage roles mapped to this repo's Gitea label strings, plus the numeric-ID quirk. |
|
||||
|
||||
## Research and methodology
|
||||
|
||||
|
||||
@@ -30,15 +30,15 @@
|
||||
},
|
||||
{
|
||||
"name": "open-notebook",
|
||||
"category": "user",
|
||||
"category": "admin",
|
||||
"section": "AI",
|
||||
"description": "Podcast generator — notes in, spoken episode out: script written by frank-zealot, voices rendered by frank-tts. No public route; LAN UI only once it is deployed.",
|
||||
"url": "",
|
||||
"description": "Podcast generator — notes in, spoken episode out: script written by frank-archon, voices rendered by frank-tts. Guarded twice: Authentik at the edge, then its own login. Single shared password, no per-user accounts — admin-only.",
|
||||
"url": "http://notebook.franksdank.cloud",
|
||||
"docs": "",
|
||||
"probe": "127.0.0.1:8502",
|
||||
"method": "http",
|
||||
"wip": "down",
|
||||
"showLink": false,
|
||||
"wip": false,
|
||||
"showLink": true,
|
||||
"art": "/img/apps/mono-on.svg"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -247,3 +247,21 @@ REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
@test "the fabric-clock host unit re-dispatches frank-dragoon and stages nothing else" {
|
||||
# The hold is the NPU lane's boot precondition and the deployer installs and
|
||||
# gates it, so a change to it has to reach the endpoint. The tracked boot
|
||||
# line under the same directory is a hand-taken reboot decision and must
|
||||
# never plan a restart, and staging the NPU model files is host-side state
|
||||
# like every other download script.
|
||||
run python3 "$PLANNER" --target dankclaw --paths \
|
||||
dankclaw/host/halogen-fabric-clock dankclaw/host/halogen-fabric-clock.service
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "frank-dragoon" ]
|
||||
run python3 "$PLANNER" --target dankclaw --paths dankclaw/host/grub
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
run python3 "$PLANNER" --target dankclaw --paths scripts/download-frank-dragoon-npu.sh
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
}
|
||||
@@ -27,6 +27,10 @@ setup() {
|
||||
ENV_FILE="$ROOT/scripts/frank-dragoon.env"
|
||||
UNIT="$ROOT/scripts/frank-dragoon.service"
|
||||
DEPLOYER="$ROOT/scripts/deploy-remote.sh"
|
||||
HOST_UNIT="$ROOT/dankclaw/host/halogen-fabric-clock"
|
||||
HOST_SERVICE="$ROOT/dankclaw/host/halogen-fabric-clock.service"
|
||||
NPU_FETCH="$ROOT/scripts/download-frank-dragoon-npu.sh"
|
||||
NPU_RULE="$ROOT/dankclaw/udev/98-halogen-npu-access.rules"
|
||||
BUILD="$ROOT/scripts/build-rocmfpx.sh"
|
||||
TEST_TMP="$(mktemp -d)"
|
||||
touch "$TEST_TMP/empty.env"
|
||||
@@ -167,6 +171,14 @@ good_env() {
|
||||
kv_gib=$(( pool * 28 * 1024 / 1073741824 ))
|
||||
arena_tenths=$(( 38 + 84 * mt / 16384 ))
|
||||
total_gib=$(( 62 + kv_gib + arena_tenths / 10 + 2 + reserve + 15 ))
|
||||
# The NPU lane bills extra pinned host RAM per named model (the vendor's
|
||||
# budget note: ~2 GiB for an embedding/decider/rerank-sized program, more
|
||||
# for FLUX-class ones), and the lane is named in the shipped env, so the
|
||||
# ledger must still close on a 121 GiB unified-memory host.
|
||||
npu_models=0
|
||||
npu="$(sed -nE 's/^HALOGEN_NPU_MODELS=(.*)$/\1/p' "$ENV_FILE")"
|
||||
[ -n "$npu" ] && npu_models="$(echo "${npu//,/ }" | wc -w)"
|
||||
total_gib=$(( total_gib + 2 * npu_models ))
|
||||
[ "$total_gib" -le 121 ]
|
||||
}
|
||||
|
||||
@@ -339,6 +351,18 @@ $(cat "$TEST_TMP/serve.log")"
|
||||
grep -qF "LLM_DRAGOON_TOK_FILES=\"\${LLM_DRAGOON_TOK_FILES:-$tokf}\"" "$DEPLOYER"
|
||||
bytes="$(sed -nE 's/^FRANK_DRAGOON_CHECKPOINT_BYTES=([0-9]+)$/\1/p' "$ENV_FILE")"
|
||||
grep -qF "LLM_DRAGOON_CKPT_BYTES=\"\${LLM_DRAGOON_CKPT_BYTES:-$bytes}\"" "$DEPLOYER"
|
||||
# The NPU twins: the lane switches on the same value on both sides. A blank
|
||||
# env line with a named deployer default (or the reverse) is the 2026-10-01
|
||||
# disagreement again — one side gates a lane the other does not build.
|
||||
npu_dir="$(sed -nE 's/^FRANK_DRAGOON_NPU_DIR=(.*)$/\1/p' "$ENV_FILE")"
|
||||
npu_models="$(sed -nE 's/^HALOGEN_NPU_MODELS=(.*)$/\1/p' "$ENV_FILE")"
|
||||
grep -qF "LLM_DRAGOON_NPU_DIR=\"\${LLM_DRAGOON_NPU_DIR:-$npu_dir}\"" "$DEPLOYER"
|
||||
grep -qF "LLM_DRAGOON_NPU_MODELS=\"\${LLM_DRAGOON_NPU_MODELS:-$npu_models}\"" "$DEPLOYER"
|
||||
# The deployer's own copy of the payload dir is what the lane's containment
|
||||
# gate compares against — a stale twin would wave a bad staging path through.
|
||||
grep -qF "LLM_DRAGOON_MODEL_DIR=\"\${LLM_DRAGOON_MODEL_DIR:-$dir}\"" "$DEPLOYER"
|
||||
net="$(sed -nE 's/^FRANK_DRAGOON_NETWORK=(.*)$/\1/p' "$ENV_FILE")"
|
||||
grep -qF "LLM_DRAGOON_NETWORK=\"\${LLM_DRAGOON_NETWORK:-$net}\"" "$DEPLOYER"
|
||||
}
|
||||
|
||||
@test "the deployer points a missing artifact at the v2abl fetch script" {
|
||||
@@ -383,3 +407,257 @@ $(cat "$TEST_TMP/serve.log")"
|
||||
grep -qE 'GPU_TARGET=.*gfx1151' "$BUILD"
|
||||
grep -qE 'DLLAMA_BUILD_WEBUI=OFF' "$BUILD"
|
||||
}
|
||||
|
||||
# --- the NPU lane (XDNA2) ----------------------------------------------------
|
||||
# Opt-in lane added 2026-10-19: halogen runs the vendor's precompiled AIE
|
||||
# programs for the models HALOGEN_NPU_MODELS names on the NPU inside the SAME
|
||||
# container (they answer /v1/embeddings on 7770). Two things make this seam
|
||||
# worth pinning: the lane must be genuinely OFF when the variable is blank (the
|
||||
# rollback is one line, so the render must show nothing lane-specific), and
|
||||
# every host precondition the engine needs — the accel node, the distro XRT's
|
||||
# three libraries, the staged model files, the held GPU fabric clock — must be
|
||||
# refused BY NAME instead of surfacing as an engine crash-loop.
|
||||
|
||||
npu_fixture() {
|
||||
# 0666 like the installed node: a rootless container's namespace cannot open
|
||||
# the stock root:render 0660 mode, and the serve gate checks exactly that.
|
||||
touch "$TEST_TMP/accel0"
|
||||
chmod 666 "$TEST_TMP/accel0"
|
||||
mkdir -p "$TEST_TMP/xrt"
|
||||
for l in libxrt_coreutil.so.2 libxrt_core.so.2 libxrt_driver_xdna.so.2; do
|
||||
touch "$TEST_TMP/xrt/$l"
|
||||
done
|
||||
mkdir -p "$TEST_TMP/npu/qwen3-embedding-0.6b/devices"
|
||||
mkdir -p "$TEST_TMP/sys/class/drm/card1/device"
|
||||
printf '0: 400Mhz \n7: 2000Mhz *\n' > "$TEST_TMP/sys/class/drm/card1/device/pp_dpm_fclk"
|
||||
printf 'manual\n' > "$TEST_TMP/sys/class/drm/card1/device/power_dpm_force_performance_level"
|
||||
NPU_ENV=(
|
||||
"HALOGEN_NPU_MODELS=qwen3-embedding-0.6b"
|
||||
"FRANK_DRAGOON_NPU_DIR=$TEST_TMP/npu"
|
||||
"FRANK_DRAGOON_DEV_ACCEL=$TEST_TMP/accel0"
|
||||
"FRANK_DRAGOON_XRT_DIR=$TEST_TMP/xrt"
|
||||
"FRANK_DRAGOON_SYSFS=$TEST_TMP/sys"
|
||||
)
|
||||
}
|
||||
|
||||
# run_with_log <args...> — run the serve script and fold its own stderr plus
|
||||
# the podman stub's argv (which the script redirects into the log) into
|
||||
# $output, so both refusal text and render assertions read one variable.
|
||||
run_with_log() {
|
||||
run env PATH="$TEST_TMP/bin:$PATH" "${GOOD_ENV[@]}" "${NPU_ENV[@]}" "$@" "$SERVE"
|
||||
if [ -f "$TEST_TMP/serve.log" ]; then
|
||||
output="$output
|
||||
$(cat "$TEST_TMP/serve.log")"
|
||||
fi
|
||||
}
|
||||
|
||||
@test "the lane is off when HALOGEN_NPU_MODELS is blank: no device, no mounts, no engine env" {
|
||||
# The rollback is blanking one env line, so "off" has to be observable in
|
||||
# the command podman would run — not just implied by an absent log line.
|
||||
podman_stub
|
||||
good_env
|
||||
run_with_log HALOGEN_NPU_MODELS=
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"--publish 7770:8731"* ]]
|
||||
! grep -q '/models/npu' "$TEST_TMP/serve.log"
|
||||
! grep -q 'HALOGEN_NPU_MODELS' "$TEST_TMP/serve.log"
|
||||
! grep -q 'xilinx' "$TEST_TMP/serve.log"
|
||||
! grep -q 'accel0' "$TEST_TMP/serve.log"
|
||||
}
|
||||
|
||||
@test "the lane arms the accel node, the staged model dir and each XRT library twice" {
|
||||
# Each library mounts at the image's XRT path AND at its own path: the
|
||||
# distro libraries find each other where they were installed, and an
|
||||
# /opt/xilinx/xrt of symlinks into the system dir dangles inside the
|
||||
# container (the vendor calls that out by name).
|
||||
podman_stub
|
||||
good_env
|
||||
npu_fixture
|
||||
run_with_log
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"--device $TEST_TMP/accel0"* ]]
|
||||
# The lane's files ride the payload mount: /models is a read-only bind and
|
||||
# runc cannot mkdir a nested /models/npu mountpoint through it (measured
|
||||
# 2026-10-19), so the staging dir lives INSIDE the payload dir and gets no
|
||||
# -v of its own.
|
||||
[[ "$output" == *"-v $TEST_TMP:/models:ro"* ]]
|
||||
[[ "$output" != *":/models/npu"* ]]
|
||||
[[ "$output" == *"-e HALOGEN_NPU_MODELS=qwen3-embedding-0.6b"* ]]
|
||||
for l in libxrt_coreutil.so.2 libxrt_core.so.2 libxrt_driver_xdna.so.2; do
|
||||
[[ "$output" == *"-v $TEST_TMP/xrt/$l:/opt/xilinx/xrt/lib/$l:ro"* ]]
|
||||
[[ "$output" == *"-v $TEST_TMP/xrt/$l:$TEST_TMP/xrt/$l:ro"* ]]
|
||||
done
|
||||
# The Flash payload still rides the same container: the lane is additive.
|
||||
[[ "$output" == *"-e HALOGEN_CHECKPOINT=/models/qwen.hgn"* ]]
|
||||
}
|
||||
|
||||
@test "the lane refuses a staging dir outside the payload dir and says why" {
|
||||
# A staging dir outside the payload dir needs a nested bind the read-only
|
||||
# /models mount cannot host: runc dies at container init, which is AFTER the
|
||||
# deployer has stopped the old unit — so the shape is refused here, before
|
||||
# anything is rendered.
|
||||
podman_stub
|
||||
good_env
|
||||
npu_fixture
|
||||
run_with_log FRANK_DRAGOON_NPU_DIR=/elsewhere/npu
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"/elsewhere/npu"*"outside the payload dir"* ]]
|
||||
[[ "$output" != *"--device"* ]]
|
||||
}
|
||||
|
||||
@test "the lane refuses a model that is not staged and names the fetch script" {
|
||||
# The engine cannot fetch these at boot here (/models is read-only and
|
||||
# HALOGEN_DOWNLOAD stays unset), so an un-staged name must stop the unit
|
||||
# before podman runs, with the command that fixes it.
|
||||
podman_stub
|
||||
good_env
|
||||
npu_fixture
|
||||
rm -rf "$TEST_TMP/npu/qwen3-embedding-0.6b"
|
||||
run_with_log
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"qwen3-embedding-0.6b"*"not staged"* ]]
|
||||
[[ "$output" == *"download-frank-dragoon-npu.sh"* ]]
|
||||
}
|
||||
|
||||
@test "the lane refuses without the accel node and says what to check" {
|
||||
podman_stub
|
||||
good_env
|
||||
npu_fixture
|
||||
rm "$TEST_TMP/accel0"
|
||||
run_with_log
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"$TEST_TMP/accel0 missing"* ]]
|
||||
[[ "$output" == *"iommu=pt"* ]]
|
||||
}
|
||||
|
||||
@test "the lane refuses an NPU node the container namespace could not open, and names the rule" {
|
||||
# Stock Debian mode is root:render 0660. The engine's own first words on such
|
||||
# a node are "this process cannot open it (crw-rw---- nobody:nogroup)" and no
|
||||
# flag fixes a mode an unmapped gid cannot cross (keep-groups measured dead
|
||||
# 2026-10-19), so the unit refuses instead of crash-looping 189 times.
|
||||
podman_stub
|
||||
good_env
|
||||
npu_fixture
|
||||
chmod 660 "$TEST_TMP/accel0"
|
||||
run_with_log
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"$TEST_TMP/accel0 is 660"* ]]
|
||||
[[ "$output" == *"98-halogen-npu-access.rules"* ]]
|
||||
[[ "$output" != *"--device"* ]]
|
||||
}
|
||||
|
||||
@test "the NPU access rule is the accel counterpart of the GPU compute rule" {
|
||||
[ -f "$NPU_RULE" ]
|
||||
grep -qE '^KERNEL=="accel\[0-9\]\*", SUBSYSTEM=="accel", MODE="0666"$' "$NPU_RULE"
|
||||
# The deployer installs the rule and re-triggers the node BEFORE it trusts
|
||||
# the mode, so a machine that never had it self-heals instead of refusing.
|
||||
grep -qE '98-halogen-npu-access\.rules' "$DEPLOYER"
|
||||
grep -qE 'udevadm control --reload-rules' "$DEPLOYER"
|
||||
grep -qE 'udevadm trigger --sysname-match=' "$DEPLOYER"
|
||||
}
|
||||
|
||||
@test "the lane refuses when an XRT NPU library is missing" {
|
||||
podman_stub
|
||||
good_env
|
||||
npu_fixture
|
||||
rm "$TEST_TMP/xrt/libxrt_driver_xdna.so.2"
|
||||
run_with_log
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"libxrt_driver_xdna.so.2 missing"* ]]
|
||||
}
|
||||
|
||||
@test "the lane refuses while the fabric clock is moving" {
|
||||
# The engine itself refuses to start in this state (GPU+NPU co-run hangs
|
||||
# Strix Halo while FCLK changes speed) but cannot see host sysfs from
|
||||
# inside the container — the proof has to happen on the host side.
|
||||
podman_stub
|
||||
good_env
|
||||
npu_fixture
|
||||
printf 'auto\n' > "$TEST_TMP/sys/class/drm/card1/device/power_dpm_force_performance_level"
|
||||
run_with_log
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"not held"* ]]
|
||||
[[ "$output" == *"sudo halogen-fabric-clock hold"* ]]
|
||||
}
|
||||
|
||||
@test "the lane refuses when no fabric-clock control can be read" {
|
||||
# A host whose sysfs exposes nothing cannot PROVE the hold, and a silent
|
||||
# pass here is a boot that dies in the engine instead.
|
||||
podman_stub
|
||||
good_env
|
||||
npu_fixture
|
||||
rm -rf "$TEST_TMP/sys/class/drm"
|
||||
run_with_log
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"cannot prove"* ]]
|
||||
}
|
||||
|
||||
@test "the lane also passes when the clock is held at the high level" {
|
||||
# The vendor's status accepts `high` as well as `manual` + the top fclk
|
||||
# level; refusing a correctly-held host would block a deploy for nothing.
|
||||
podman_stub
|
||||
good_env
|
||||
npu_fixture
|
||||
printf 'high\n' > "$TEST_TMP/sys/class/drm/card1/device/power_dpm_force_performance_level"
|
||||
run_with_log
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"-e HALOGEN_NPU_MODELS=qwen3-embedding-0.6b"* ]]
|
||||
}
|
||||
|
||||
@test "the deployer gates the same lane artifacts and installs the fabric-clock unit" {
|
||||
grep -qE 'LLM_DRAGOON_NPU_MODELS' "$DEPLOYER"
|
||||
grep -qE 'install -m 0755 dankclaw/host/halogen-fabric-clock' "$DEPLOYER"
|
||||
grep -qE 'install -m 0644 dankclaw/host/halogen-fabric-clock\.service' "$DEPLOYER"
|
||||
grep -qE 'halogen-fabric-clock status' "$DEPLOYER"
|
||||
grep -qE 'download-frank-dragoon-npu\.sh' "$DEPLOYER"
|
||||
# The lane's own health gate is a real embedding, not a 200 on /health.
|
||||
grep -qE '/v1/embeddings' "$DEPLOYER"
|
||||
# Parity with the serve gate: a staging dir outside the payload dir is
|
||||
# refused before the restart, not left to runc.
|
||||
grep -qE 'outside the payload dir' "$DEPLOYER"
|
||||
}
|
||||
|
||||
@test "the fabric-clock host unit is the vendor shape and boots on its own" {
|
||||
[ -x "$HOST_UNIT" ]
|
||||
grep -qE '^ (hold\)|release\)|status\))' "$HOST_UNIT"
|
||||
grep -qE 'pp_dpm_fclk' "$HOST_UNIT"
|
||||
grep -qE '^Type=oneshot$' "$HOST_SERVICE"
|
||||
grep -qE '^WantedBy=.*multi-user\.target' "$HOST_SERVICE"
|
||||
# A resume hands the clock back to the driver, so the unit must re-run then.
|
||||
grep -qE '^WantedBy=.*suspend\.target' "$HOST_SERVICE"
|
||||
grep -qE '^ExecStart=.*/usr/local/sbin/halogen-fabric-clock hold$' "$HOST_SERVICE"
|
||||
}
|
||||
|
||||
@test "the NPU fetch script pins the vendor catalog by size and sha256" {
|
||||
# XDNA2 runs only AIE-compiled programs, so the vendor's per-model repo is
|
||||
# the only source; the engine re-checks size+sha256 against its own catalog
|
||||
# at boot, so this manifest has to agree with it or the boot refuses.
|
||||
[ -x "$NPU_FETCH" ]
|
||||
grep -qE 'curl -fL' "$NPU_FETCH"
|
||||
grep -qE 'qwen3-embedding-0\.6b\.hnpw 813032248 [0-9a-f]{64}' "$NPU_FETCH"
|
||||
grep -qE 'sha256sum' "$NPU_FETCH"
|
||||
# curl -f alone is not enough on Hugging Face: the resolve URL redirects
|
||||
# and curl drops Range across it (the sibling 62 GiB fetch, 2026-10-18).
|
||||
grep -qE 'url_effective' "$NPU_FETCH"
|
||||
}
|
||||
|
||||
@test "the container network is podman's default when unnamed and the app bridge when named" {
|
||||
# The app-to-endpoint leg hangs on this: pasta is a private netns nothing
|
||||
# else can join (podman refuses a second network, the published port is
|
||||
# refused from app containers, and host.containers.internal is link-local,
|
||||
# which open-notebook refuses to store), so reaching the NPU endpoints by
|
||||
# name means the container lives on dankclaw_default. Both arms are
|
||||
# observable in the command podman would run.
|
||||
podman_stub
|
||||
good_env
|
||||
run_with_log FRANK_DRAGOON_NETWORK=
|
||||
[ "$status" -eq 0 ]
|
||||
! grep -q -- '--network' "$TEST_TMP/serve.log"
|
||||
run_with_log FRANK_DRAGOON_NETWORK=dankclaw_default
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"--network dankclaw_default"* ]]
|
||||
# The deployer refuses a network that does not exist rather than starting a
|
||||
# unit that restart-loops on it.
|
||||
grep -qE 'podman network exists' "$DEPLOYER"
|
||||
grep -qE '^FRANK_DRAGOON_NETWORK=dankclaw_default$' "$ENV_FILE"
|
||||
}
|
||||
+74
-32
@@ -17,6 +17,7 @@ NET="$REPO_ROOT/$UNIT_DIR/open-notebook.network"
|
||||
ENVF="$REPO_ROOT/scripts/open-notebook.env"
|
||||
PLANNER="$REPO_ROOT/scripts/deploy-plan.py"
|
||||
REMOTE="$REPO_ROOT/scripts/deploy-remote.sh"
|
||||
CADDY="$REPO_ROOT/dankclaw/Caddyfile"
|
||||
# The host-side path the units read (they run on Dankclaw, not here), spelled
|
||||
# out once so the ordering test below compares against a real constant.
|
||||
HOST_TUNABLES="EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env"
|
||||
@@ -68,22 +69,58 @@ DB_PIN="sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723"
|
||||
! grep -qE '^PublishPort=.*:8000(:|$)' "$APP"
|
||||
}
|
||||
|
||||
@test "the app publishes its UI and moves the API off the host port seerr holds" {
|
||||
# onb_vhost — the host Caddy routes to the UI's 8502. Derived, never restated:
|
||||
# the API origin and the homepage link have to agree with whatever the Caddyfile
|
||||
# actually routes, so renaming the vhost there moves this seam instead of
|
||||
# leaving it blessing a stale name.
|
||||
onb_vhost() {
|
||||
awk '
|
||||
/^[[:space:]]*@[A-Za-z0-9_-]+[[:space:]]+host[[:space:]]/ { h = $3 }
|
||||
/^[[:space:]]*reverse_proxy[[:space:]]+192\.168\.0\.143:8502$/ { if (h) { print h; exit } }
|
||||
' "$CADDY"
|
||||
}
|
||||
|
||||
@test "the app publishes the UI for the guard chain and keeps the API off the LAN" {
|
||||
# 8502 is the single upstream the post-auth listener dials, so it is the
|
||||
# only port that has to answer on a LAN interface.
|
||||
grep -qxF 'PublishPort=0.0.0.0:8502:8502' "$APP"
|
||||
[ "$(grep -cE '^PublishPort=0\.0\.0\.0:' "$APP")" = "1" ] || {
|
||||
echo "the UI port is not the only wildcard publish: $(grep -E '^PublishPort=' "$APP")"; false; }
|
||||
# seerr already binds 127.0.0.1:5055 on this host, so the API cannot leave
|
||||
# the container on the host port upstream uses.
|
||||
publish="$(grep -E '^PublishPort=[^:]+:[0-9]+:5055$' "$APP")"
|
||||
[ -n "$publish" ] || { echo "no PublishPort mapping to the API's 5055"; false; }
|
||||
mapping="${publish#PublishPort=}" # e.g. 0.0.0.0:5056:5055
|
||||
host_port="${mapping#*:}"
|
||||
host_port="${host_port%%:*}"
|
||||
[ "$host_port" != "5055" ] || {
|
||||
echo "the API is published on host 5055, which seerr already holds"; false; }
|
||||
# The frontend auto-detects the API as <host it was opened on>:5055, so a
|
||||
# remap is only correct if API_URL names the published port.
|
||||
# the container on the port upstream uses; and it needs no outward face at
|
||||
# all, because the image's Next.js layer rewrites /api/* to it. Loopback is
|
||||
# where the deployer's health gate dials it.
|
||||
grep -qxF 'PublishPort=127.0.0.1:5056:5055' "$APP"
|
||||
# One origin serves UI and API, so API_URL names the guarded vhost rather
|
||||
# than a host:port. Left to itself the frontend auto-detects `<host>:5055`;
|
||||
# a LAN-origin API_URL takes the browser around the guard chain, where its
|
||||
# authenticated session does not ride.
|
||||
api_url="$(sed -n 's/^API_URL=//p' "$ENVF")"
|
||||
[ "$api_url" = "http://192.168.0.143:${host_port}" ] || {
|
||||
echo "API_URL ($api_url) does not name the published API port ($host_port)"; false; }
|
||||
vhost="$(onb_vhost)"
|
||||
[ -n "$vhost" ] || { echo "no Caddy route to the UI port was found"; false; }
|
||||
[ "$api_url" = "https://${vhost}" ] || {
|
||||
echo "API_URL ($api_url) is not the guarded origin https://${vhost}"; false; }
|
||||
# Same-origin traffic needs no CORS at all, but while CORS_ORIGINS is unset
|
||||
# the app warns and defaults to `*` in front of every episode's audio.
|
||||
cors="$(sed -n 's/^CORS_ORIGINS=//p' "$ENVF")"
|
||||
[ "$cors" = "https://${vhost}" ] || {
|
||||
echo "CORS_ORIGINS ($cors) is not the single guarded origin https://${vhost}"; false; }
|
||||
}
|
||||
|
||||
@test "the vhost is guarded and the app login stays behind it" {
|
||||
vhost="$(onb_vhost)"
|
||||
# The vhost itself proxies the WHOLE request to Anubis — an unmatched path
|
||||
# must never reach the app unchallenged.
|
||||
awk -v v="$vhost" '
|
||||
$0 == v " {" { inblock = 1; seen = 0 }
|
||||
inblock && /reverse_proxy anubis:8923/ { seen = 1 }
|
||||
inblock && /^}/ { exit }
|
||||
END { exit !seen }
|
||||
' "$CADDY" || { echo "$vhost does not proxy everything through Anubis"; false; }
|
||||
# And the app's own password is still required, so a route that ever bypasses
|
||||
# the chain does not hand out the episode library: the deployer refuses to
|
||||
# start the units while the secret file leaves it blank.
|
||||
grep -qF 'OPEN_NOTEBOOK_PASSWORD' "$ENVF"
|
||||
}
|
||||
|
||||
@test "the pair shares one network and the app finds the database by container name" {
|
||||
@@ -170,18 +207,19 @@ DB_PIN="sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723"
|
||||
! grep -qE '^Exec=.*--(pass|user)' "$DB"
|
||||
}
|
||||
|
||||
@test "both health probes are argument-only and name real paths" {
|
||||
for unit in "$APP" "$DB"; do
|
||||
cmd="$(sed -n 's/^HealthCmd=//p' "$unit")"
|
||||
[ -n "$cmd" ] || { echo "$(basename "$unit") has no HealthCmd"; false; }
|
||||
# The quadlet generator splits HealthCmd on spaces, so anything needing
|
||||
# a shell (quotes, pipes, &&) would be silently mis-sent.
|
||||
! printf '%s' "$cmd" | grep -qE '["&;|`]'
|
||||
done
|
||||
# The API's own route, exempt from its password middleware upstream.
|
||||
@test "the database probe can run in a shell-less image and the API probe names the health route" {
|
||||
# podman wraps a BARE HealthCmd as CMD-SHELL (measured on this host's podman
|
||||
# 5.7), and the SurrealDB image is distroless — no /bin/sh — so a bare
|
||||
# command there is a probe that can only ever fail: the database answers
|
||||
# perfectly while the health status stays `unhealthy` and the deploy gate
|
||||
# times out. Only the exec-form JSON array runs in that image.
|
||||
db_cmd="$(sed -n 's/^HealthCmd=//p' "$DB")"
|
||||
[ "$db_cmd" = '["/surreal","is-ready"]' ] || {
|
||||
echo "database probe is not exec-form JSON: $db_cmd"; false; }
|
||||
# The API image ships curl and a shell, so a bare command is fine there; it
|
||||
# must name the route that is exempt from the password middleware, on the
|
||||
# CONTAINER port (the probe runs inside the netns).
|
||||
grep -qxF 'HealthCmd=curl -fsS http://127.0.0.1:5055/health' "$APP"
|
||||
# The distroless database image has no shell and no curl; it ships a probe.
|
||||
grep -qxF 'HealthCmd=/surreal is-ready' "$DB"
|
||||
}
|
||||
|
||||
@test "the deployer gates open-notebook on the health path that actually answers" {
|
||||
@@ -244,21 +282,25 @@ DB_PIN="sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723"
|
||||
grep -qF 'dankclaw:open-notebook|' "$REPO_ROOT/scripts/deploy.sh"
|
||||
}
|
||||
|
||||
@test "the homepage card exists and stays hidden until the service is deployed" {
|
||||
python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY'
|
||||
@test "the homepage card links the guarded vhost now that the service is live" {
|
||||
python3 - "$REPO_ROOT/shared/homepage/services.json" "$(onb_vhost)" <<'PY'
|
||||
import json, os, sys
|
||||
data = json.load(open(sys.argv[1]))
|
||||
vhost = sys.argv[2]
|
||||
cards = [s for s in data if s["name"] == "open-notebook"]
|
||||
assert len(cards) == 1, f"expected exactly one open-notebook card, got {len(cards)}"
|
||||
card = cards[0]
|
||||
assert card["category"] == "user" and card["section"] == "AI", card
|
||||
# category moved to "admin": single shared password, no per-user accounts, so it
|
||||
# is positioned as an admin-side tool, not a user-facing app (see the vault page).
|
||||
assert card["category"] == "admin" and card["section"] == "AI", card
|
||||
assert card["probe"] == "127.0.0.1:8502", card["probe"]
|
||||
assert card["method"] == "http"
|
||||
# Not deployed yet: the card is reported wip-down and carries no link, so the
|
||||
# page shows no dead button (the Seerr precedent).
|
||||
assert card.get("wip") == "down", card.get("wip")
|
||||
assert card.get("showLink") is False, card.get("showLink")
|
||||
assert not card.get("url"), "a hidden card must not carry a url"
|
||||
# Deployed and guarded: the card is live and its link is the vhost, so the
|
||||
# button on the page reaches the app through the guard chain. Card links carry
|
||||
# the fleet's http:// form everywhere; Caddy answers the redirect to HTTPS.
|
||||
assert not card.get("wip"), f"card should be live, got wip={card.get('wip')!r}"
|
||||
assert card.get("showLink") is True, card.get("showLink")
|
||||
assert card.get("url") == f"http://{vhost}", card.get("url")
|
||||
art = card["art"].lstrip("/")
|
||||
assert os.path.exists(os.path.join(os.path.dirname(sys.argv[1]), art)), art
|
||||
PY
|
||||
|
||||
Reference in new issue
Block a user