Author SHA1 Message Date
homelab-updaterandClaude Opus 5.5 42ac6bf9b6 deploy: cap the forwardAuth response size for Traefik v3
ci / check (push) Successful in 1m42s
ci / publish (push) Successful in 1m34s
Traefik v3 warns that forwardAuth reads the auth service's answer with
no size limit. oauth2-proxy answers with a few KB, so 1 MiB is ample.
The label would have been rejected by the old v2.9, which skips a
container with unknown fields; Traefik on VM103 is v3.7.16 now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:06:50 +01:00
homelab-updaterandClaude Sonnet 5.5 3dfac076ee fix(seed): an untouched draft no longer holds a workflow back from the new template
ci / check (push) Successful in 1m44s
ci / publish (push) Successful in 1m28s
jellyfin stayed on the first template because it had an unedited copy of v1 as a draft.
A draft the owner changed still keeps the workflow where it is. No new dependencies.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 13:51:33 +02:00
homelab-updaterandClaude Sonnet 5.5 77a7027065 fix(workflow): verify once more when the repair gives up, before asking the owner
ci / check (push) Successful in 1m42s
ci / publish (push) Successful in 1m33s
The app can come up on its own while the model fails (orcaslicer 22.10: slow recreate, then an expired Claude login).
Adds tools/vm-api.sh, a read-only GET into the running app on VM103. No new dependencies.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 13:16:18 +02:00
homelab-updaterandClaude Sonnet 5.5 81334a6201 docs: Claude sign-in from Settings in DEPLOY and TASK_STATE
ci / check (push) Successful in 2m33s
ci / publish (push) Successful in 3m8s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:02:05 +02:00
homelab-updaterandClaude Sonnet 5.5 8ea9f884f1 feat(models): sign in to the Claude sidecar from Settings, with account switching
The sidecar keeps one 'claude auth login' between two calls (address, then pasted code); the web app proxies /api/model-auth.
No new dependencies.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:02:05 +02:00
homelab-updaterandClaude Opus 5.5 765a1e0eee docs: TASK_STATE after deploying haiku
ci / check (push) Successful in 1m36s
ci / publish (push) Successful in 1m27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:19:51 +02:00
homelab-updaterandClaude Opus 5.5 b29aec3dee docs: TASK_STATE after adding haiku
ci / check (push) Successful in 1m39s
ci / publish (push) Successful in 1m43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:57:25 +02:00
homelab-updaterandClaude Opus 5.5 964f2ad815 feat(models): add haiku as a model choice and make it the default
Haiku 5.5 is cheaper and close enough to Sonnet for the release notes
search and assessment, the repair, the migration plan and the
post-upgrade check, so an app that names no model now gets haiku.

- ModelName (haiku|sonnet|opus) is the one vocabulary; modelOr picks the
  default where the old code fell back to sonnet or opus.
- A default "haiku" profile is seeded next to sonnet and opus.
- Migration 26 rebuilds app_config so its CHECKs accept haiku and its
  release notes default is haiku; existing rows are copied verbatim.
- claude CLI 2.1.293: the first release that knows claude-haiku-5-5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:57:25 +02:00
homelab-updaterandClaude Opus 5.5 4b9fa93ed0 docs: TASK_STATE after the post-upgrade check change
ci / check (push) Successful in 1m32s
ci / publish (push) Successful in 1m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:59:38 +02:00
homelab-updaterandClaude Opus 5.5 c17b170739 feat(post-upgrade): run the model only when the update may ask for something
ci / check (push) Successful in 1m33s
ci / publish (push) Successful in 1m35s
The post-upgrade model ran after every update: 17 of 18 checks found nothing,
mostly LinuxServer rebuilds of the same app version. And "off" did not turn it
off: the workflow port read a null executor as sonnet.

- A null postUpgradeExecutor is off again: no session, no model.
- New per-app postUpgradeWhen (migration 25): when_needed (default) or always.
- when_needed runs the model for an update the owner approved (release notes
  read), a major one or a migration; otherwise it reads the service's last 200
  log lines in an observe session and calls the model only on errors there.
- ExecutorClient.sessionLogs reads /agent/logs with the session token, like
  sessionCompose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:54:48 +02:00
homelab-updaterandClaude Opus 5.5 bcd144fbdc fix(runs): count a model run once when the terminal node carries its result on
ci / check (push) Successful in 1m35s
ci / publish (push) Successful in 1m33s
A terminal node's result is the record it received, so after post_upgrade the run
listed the same post-upgrade check twice and counted its cost twice (run view, run
list and model costs). Identical calls now count once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:31:30 +02:00
homelab-updaterandClaude Opus 5.5 d05b6c27f3 docs: TASK_STATE after the registry rate limit fix
ci / check (push) Successful in 1m34s
ci / publish (push) Successful in 1m25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:34:18 +02:00
homelab-updaterandClaude Opus 5.5 e060a59b67 fix(registry): wait out a ghcr.io rate limit instead of reporting the version unknown
ci / check (push) Successful in 1m38s
ci / publish (push) Successful in 1m25s
ghcr.io answered 429 to the tag list for jackett, radarr and sonarr, and image_tags
turned that straight into version_unknown with only "skip" left. The registry client
now asks again after 429, honouring Retry-After up to five minutes; the driver waits
30 s, 2 min and 5 min, the server does not wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:28:08 +02:00
homelab-updaterandClaude Opus 5.5 8966fec9cf docs: TASK_STATE after the migrate and verify fixes
ci / check (push) Successful in 1m35s
ci / publish (push) Successful in 1m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:55:43 +02:00
homelab-updaterandClaude Opus 5.5 1b8bfc4446 fix(verify): wait out the old container while the service is recreated
ci / check (push) Successful in 2m31s
ci / publish (push) Successful in 3m9s
For a Git project Arcane recreates the service while verify polls, and
for a moment compose has the old container next to the new one. The
executor refuses to inspect that (multiple_containers), and verify
failed at once, so a healthy update went to model.repair (deluge,
homeassistant, nextcloud, nextcloud-redis). That refusal now counts as
an ordinary failed attempt within the usual 24 x 5 s; any other refusal
still fails at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:31:33 +02:00
homelab-updaterandClaude Opus 5.5 d00c8ad4aa fix(migrate): run an approved migration that keeps the compose file
ci / check (push) Successful in 1m35s
ci / publish (push) Successful in 1m29s
A migration plan may leave composeYaml null ("only the image changes"),
but the docker.migrate request required a compose file, so building the
request failed (invalid_privileged_action_request) right after the
backup. Gitea 1.27.3 -> 28.0.0 stopped there. The request now carries
null and the executor keeps the current compose.yaml (read at apply
time, matching the backup) and only pins the new image. The plan's
earlier snapshot is not used: another update of the same project may
have changed the file since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:51:59 +02:00
homelab-updaterandClaude Opus 5.5 2e3d61982f docs: TASK_STATE and phase H status after deploying the login
ci / check (push) Successful in 1m34s
ci / publish (push) Successful in 1m22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:06:22 +02:00
homelab-updaterandClaude Opus 5.5 6cf584894d fix(deploy): start the auth service, and roll back a start that fails part-way
ci / check (push) Successful in 1m36s
ci / publish (push) Successful in 1m22s
Compose writes an inline config only into a writable container, so the
auth service is no longer read_only; it trusts X-Forwarded-* only from
the Docker networks. The first deploy of the login stopped at compose up
and update.sh exited there, leaving the app without its auth backend
and the driver not started: a failed up now goes back to the previous
compose file and image like a start that does not come up healthy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:02:01 +02:00
homelab-updaterandClaude Opus 5.5 7131ca9b12 fix(deploy): keep VM103's disk from filling with images and build cache
ci / check (push) Successful in 1m33s
ci / publish (push) Successful in 1m25s
Every CI build and deploy left a 1.2 GB image of this app on the VM and
the build cache kept growing, until the root disk was full and apt in
CI failed. After a good deploy update.sh keeps the running image, the
previous one and the three newest; the publish job prunes build cache
older than two days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:44:30 +02:00
homelab-updaterandClaude Opus 5.5 4b6b179525 docs: Trilium connected to Arcane Git Sync
ci / check (push) Failing after 6s
ci / publish (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:59:56 +02:00
75 changed files with 1291 additions and 163 deletions

No files matched your search

+2
View File
@@ -53,3 +53,5 @@ jobs:
# Push only these refs: the daemon is the VM's, so `--all-tags` would also push the local :rollback tag from update.sh.
for ref in "${refs[@]}"; do docker push "${ref}"; done
docker logout "${REGISTRY}"
# Builds run on the VM's daemon: without this its build cache grows until the disk is full.
docker builder prune -f --filter until=48h >/dev/null 2>&1 || true
+1 -1
View File
@@ -10,7 +10,7 @@ Gdzie co zapisywać (żeby dokumentacja nie rosła bez końca): `docs/DESIGN.md`
- Ten host to Proxmox (Debian 11) — tylko do rozwoju i testów. Docelowo kod działa na VM103 w Dockerze.
- Node 24 przez nvm (`.nvmrc`). W powłoce nieinteraktywnej przed `node`/`npm`: `. ~/.nvm/nvm.sh`.
- Nic nie instalujesz globalnie. Nie ruszasz Proxmoxa, VM103 ani działających kontenerów.
- Nic nie instalujesz globalnie. Nie ruszasz Proxmoxa, VM103 ani działających kontenerów — poza odczytem (właściciel zezwolił 2026-10-23). Stan działającej appki (runy, osie czasu, modele) czytasz przez `tools/vm-api.sh /api/...` (GET przez SSH na VM103, z pominięciem Traefika i Keycloaka), np. `/api/workflow-instances?app=<app>` i `/api/workflow-instances/<id>/timeline`; ograniczaj wyjście (`| head -c`, wyciągaj węzły). Wdrożenia nadal tylko na polecenie.
- Skryptów ze starego repo, które mutują (`apply.sh`, `rollback.sh`, `backup.sh`), nie uruchamiasz bez zlecenia właściciela. Testy idą na fejkach i zapisanych fixture'ach.
- Sekrety (`.env`, klucze, tokeny) nigdy w repo, logach ani wyjściu.
- Remote: `ssh://git@git.mariuszpacek.pl:2244/mariusz.pacek/homelab-upgrade.git`, gałąź `master`. Commity małe i opisowe; commit/push tylko na prośbę właściciela.
+1 -1
View File
@@ -5,7 +5,7 @@ ARG NODE_IMAGE=node:24-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d
ARG DOCKER_CLI_IMAGE=docker:28.5.2-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d
# The claude CLI for the model sidecar, pinned and updated deliberately (docs/DESIGN.md, "Wykonawcy modeli").
ARG CLAUDE_CODE_VERSION=2.1.267
ARG CLAUDE_CODE_VERSION=2.1.293
# The codex CLI for the model sidecar, pinned and updated deliberately (docs/DESIGN.md, "Wykonawcy modeli").
ARG CODEX_VERSION=0.156.1
+23 -13
View File
@@ -1,32 +1,42 @@
# Stan zadania — nowoczesny frontend (Faza G)
# Stan zadania — faza H (wygoda na co dzień)
## Cel
Bardziej nowoczesny i graficzny frontend: okna/karty, wykresy z danych, które API już zwraca. Plan i status: `docs/IMPLEMENTATION.md`, „Faza G”.
Aplikacja wygodna na co dzień: stan „tu i teraz”, historia wersji, cisza nocna i poranny raport, decyzja z telefonu, szybka nawigacja, logowanie Keycloak z dostępem spoza LAN. Plan i status: `docs/IMPLEMENTATION.md`, „Faza H”.
## Decyzje
- Sidebar + karty, ciemny motyw domyślny z przełącznikiem (`src/web/theme.ts`).
- ECharts (`src/web/charts/echarts.ts`, osobny chunk), opcje wykresów jako czyste funkcje w `src/web/charts/options.ts`, agregacje w `src/web/stats.ts`; kolory z tokenów CSS, kategorie `--series-1..8`.
- Okna: `src/web/components/dialogs.tsx` (`useConfirm`, `useToast`, `Drawer`); `window.confirm` nie jest już używany.
- Bez zmian w backendzie; lista runów pobierana w całości — przy dużej liczbie runów rozważyć endpoint agregatów.
- Logowanie: oauth2-proxy (usługa `auth`) jako forwardAuth Traefika, Keycloak realm `home`, klient `homelab-upgrade` (założony przez właściciela), wpuszczany tylko `AUTH_ALLOWED_EMAILS`; sekrety w `.env` na VM103 (root 600). Publiczne tylko `manifest.webmanifest` i `icon.svg`.
- Cisza nocna 01:00–07:30, raport o 7:30 (strefa harmonogramów); ustawienia w UI (`#/settings`).
- Wdrożenia tylko przyciskiem `deploy` (Actions albo MCP `dispatch_workflow`), nigdy przez Arcane.
## Zrobione
- G0–G7 scalone do `master` (`aec96b2`, fast-forward z `frontend-refresh`) i wdrożone 2026-09-30 na VM103: `./update.sh aec96b2…`, wszystkie usługi na obrazie CI (build 174), bez obrazów hotfix; `compose.yaml` na VM = `deploy/compose.yaml`. Kopie przed wdrożeniem: `/srv/appdata/homelab-upgrade/data/queue.sqlite.before-phase-g-*`, `/srv/appdata/homelab-upgrade/compose.yaml.before-phase-g-*`.
- Wdrożenie przyciskiem: workflow `deploy` (Actions → deploy → Run workflow z `master`, albo `dispatch_workflow` przez MCP) uruchamia `update.sh` z wdrażanego obrazu (`--install`: kopia bazy, `compose.yaml` z obrazu, powrót do poprzedniego obrazu i compose). Tagi `v*` budują obrazy wydań. Sprawdzone na VM103: run 206 wdrożył `136f9d2` (build 177), run 207 odrzucił niepoprawną wersję przed jakąkolwiek zmianą. Opis: `docs/DEPLOY.md`, „Aktualizacja”.
- Gitea (`homelab-compose`, `gitea/compose.yaml`): router `gitea-web-lan` bez limitu zapytań dla LAN, VPN i sieci Dockera (`ClientIP`, priorytet 200); limit Traefika zostaje dla internetu. Powód: CI dostawało 429 przy `docker login`.
- Graf Cytoscape nie ponawia nieudanego startu canvas przy każdym renderze (wcześniej czyścił listę zastępczą).
- Logowanie Claude'a z UI (`8ea9f88`, wdrożone 23.10): `#/settings` → „Anthropic account” (status konta, „Sign in” / „Sign in with another account” → link + wklejony kod, „Sign out”); sidecar `model-claude` ma `/auth/*`, serwer WWW proxuje `/api/model-auth*`.
- Orcaslicer 22.10 (run `feeb48d6`): `verify_initial` nie doczekał się przebudowy kontenera (140 s, `image_mismatch`), a `repair` padł na wygasłym OAuth Claude'a (`auth_failed`, 0 USD), więc run czekał na właściciela mimo udanej aktualizacji. Poprawka: gdy `repair`/`repair_full` się poddaje, workflow robi jeszcze jeden `verify` (`verify_after_gave_up`; zdrowa appka → `terminal_updated`, inaczej bramka jak dotąd); czas `verify` bez zmian. Szablon ma nowy digest.
- Jellyfin został na v1 (35 węzłów), bo miał nietknięty szkic (kopia v1), a seed pomijał workflow ze szkicem; teraz nietknięty szkic (digest = ostatnia wersja) nie blokuje przeniesienia na szablon, edytowany nadal tak.
- Odczyt działającej appki: `tools/vm-api.sh /api/...` (GET przez SSH na VM103, bez Traefika i Keycloaka), zob. AGENTS.md.
- H1–H6 na `master` i na VM103: migawka hosta, historia wersji, raport i cisza nocna, decyzje z telefonu, PWA, `Ctrl+K`, logowanie Keycloak.
- Poprawki po pierwszych nocach: migracja z `composeYaml: null` (executor bierze bieżący compose i przypina obraz; `d00c8ad`, Gitea 28.0.0 zaktualizowana), `verify` czeka na zniknięcie starego kontenera przy przebudowie przez Arcane zamiast od razu wołać `model.repair` (`1b8bfc4`).
- `version_unknown` u jackett/radarr/sonarr (5–6.10) to był HTTP 429 z ghcr.io przy liście tagów, nie wersja: klient rejestru ponawia po 429 (driver 30 s / 2 min / 5 min, `Retry-After` do 5 min; serwer bez czekania), wdrożone `e060a59`.
- Podwójny wpis modelu w runie: węzeł końcowy powtarzał wynik `post_upgrade`; liczony raz (`bcd144f`).
- Krok modelu po aktualizacji (`c17b170`): `postUpgradeExecutor: null` znów wyłącza (port workflow czytał null jako sonnet), nowe `postUpgradeWhen` (`when_needed` domyślnie / `always`, migracja 25). `when_needed`: model tylko przy aktualizacji zatwierdzonej przez właściciela, major, migracji albo błędach w ostatnich 200 liniach logów (sesja observe, bez modelu). Na VM103 wyłączone dla 11 aplikacji (arr, jackett, bazarr, jellyfin, ombi, deluge, orcaslicer, homepage, trilium, nextcloud-redis), reszta sonnet + `when_needed`.
- Haiku 5.5 jako trzeci model (`haiku`): domyślny dla notatek wydania (szukanie + ocena), naprawy, planu migracji i sprawdzenia po aktualizacji, gdy aplikacja nie wybrała modelu; profil `haiku` w seedzie, migracja 26 (przebudowa `app_config` dla CHECK), CLI w obrazie 2.1.293 (pierwsza wersja znająca `claude-haiku-5-5`). Wdrożone `b29aec3` (8.10); na VM103 wszystkie 22 aplikacje przełączone z `sonnet` na `haiku` (wyłączone zostały wyłączone).
- Homelab-compose: whitelist deluge przepuszcza bramę sieci `proxy` (`172.16.0.1/32`), inaczej `verify` dostawał 403.
- Proxmox: pełny dysk 30.09 zawiesił pmxcfs (brak vzdump 1–2.10); po restarcie usług PVE kopie działają. VM103: przycięte obrazy (dysk 89% → 64%).
## Pozostałe
- Przegląd na żywo w przeglądarce (oba motywy, 375 px i desktop), poprawki wyglądu po przeglądzie.
- Właściciel loguje się raz w przeglądarce i na telefonie (potwierdzenie Keycloak → aplikacja).
- Ze strumienia Git/Arcane: migracja klientów Influx przed włączeniem aktualizacji.
- Zewnętrzny monitoring dostępności (na później): `verify` sprawdza tylko od środka (VM103, źródło `172.16.0.1`), więc nie widzi błędów whitelisty ani ścieżki publicznej (Ombi 16.10: Krosno zablokowane, `verify` zielony). Plan: Mikrus (87 zł/3 lata, bez Dockera) ze skryptem `curl` + cron, domeny i oczekiwane kody z `config/apps.json`, powiadomienie tylko przy zmianie stanu (ntfy albo Telegram), ostrzeżenie o certyfikacie. Do ustalenia: adres wychodzący Mikrusa (dopisać do whitelist), kanał powiadomień, skrypt w `deploy/`. Czeka na zakup serwera.
- Do rozważenia: uproszczenie przebiegów (ustawienia per aplikacja zamiast edycji grafu, deklaratywny warunek zamiast funkcji TS), wyłączenie godzinnego sprawdzania aktualizacji obrazów w Arcane (dubluje nasz system), bramka „verify failed” bez opcji „accept”, sprzątanie `# saved as ./compose.yaml` z planów migracji, automatyczny `pg_dump` przed migracją bazy.
## Następny krok
Właściciel ogląda nowe UI na https://upgrade.mariuszpacek.pl i zgłasza poprawki.
W pierwszym runie z modelem sprawdzić, że alias `haiku` w CLI 2.1.293 to Haiku 5.5 (`modelUsage` w odpowiedzi `claude`; próba 8.10 trafiła na limit sesji konta) i czy jakość ocen wystarcza. Dalej obserwować 429 i `when_needed`.
## Walidacja
`npm run check` zielone: 107 plików, 1260+ testów. Testy agregacji i opcji wykresów: `npx vitest run src/web/stats.test.ts src/web/charts`.
`npm run check`: 1320 testów PASS. `deploy/update.sh` sprawdzany na atrapie `docker` (instalacja, kopia bazy, nieudany start i nieudane `up`, przycinanie obrazów).
+8 -1
View File
@@ -24,6 +24,8 @@ services:
environment:
TZ: Europe/Warsaw
FUNCTION_RUNTIME_URL: http://function-runtime:8085
# Settings → Anthropic account signs the Claude sidecar in (only /auth/* is open to this service).
MODEL_CLAUDE_URL: http://model-claude:8083
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/api/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
interval: 30s
@@ -46,6 +48,8 @@ services:
traefik.http.middlewares.homelab-upgrade-login.forwardauth.address: "http://homelab-upgrade-auth:4180/"
traefik.http.middlewares.homelab-upgrade-login.forwardauth.trustForwardHeader: "true"
traefik.http.middlewares.homelab-upgrade-login.forwardauth.authResponseHeaders: "X-Auth-Request-Email,X-Auth-Request-User"
# Cap the auth service's answer, which Traefik v3 holds in memory; oauth2-proxy pages are a few KB.
traefik.http.middlewares.homelab-upgrade-login.forwardauth.maxResponseBodySize: "1048576"
# Tile on dash.mariuszpacek.pl: homepage discovers it through its own Docker connection (my-docker), no edit of its files.
homepage.group: "Infra"
homepage.name: "Upgrade"
@@ -63,7 +67,7 @@ services:
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.4@sha256:b1b2021fe8f4004573e8d690dec6c7bb29cc44364572cf8510a05bf3a0ae2ded
restart: unless-stopped
user: "65532:65532"
read_only: true
# Not read_only: compose writes an inline config (auth-emails) only into a writable container filesystem.
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
environment:
@@ -76,6 +80,8 @@ services:
OAUTH2_PROXY_REDIRECT_URL: https://upgrade.mariuszpacek.pl/oauth2/callback
OAUTH2_PROXY_HTTP_ADDRESS: 0.0.0.0:4180
OAUTH2_PROXY_REVERSE_PROXY: "true"
# Only Traefik (on the Docker proxy network) may say who the client is through X-Forwarded-*.
OAUTH2_PROXY_TRUSTED_PROXY_IPS: 172.16.0.0/12
OAUTH2_PROXY_UPSTREAMS: static://202
OAUTH2_PROXY_EMAIL_DOMAINS: "*"
OAUTH2_PROXY_AUTHENTICATED_EMAILS_FILE: /etc/oauth2-proxy/emails
@@ -193,6 +199,7 @@ services:
MODEL_PORT: "8083"
HL_URL: http://executor:8081
DRIVER_HOST: driver
AUTH_HOST: server
volumes:
- /srv/appdata/homelab-upgrade/claude:/claude
networks: [default, executor]
+20 -3
View File
@@ -15,6 +15,7 @@ cd "${DEPLOY_DIR:-$script_dir}"
image_repo=git.mariuszpacek.pl/mariusz.pacek/homelab-upgrade
healthy_timeout=${HEALTHY_TIMEOUT:-90}
backups_kept=${BACKUPS_KEPT:-5}
images_kept=${IMAGES_KEPT:-3}
tag_arg=""
force=0
@@ -91,6 +92,21 @@ restore_compose() {
fi
}
# Each CI build and each deploy leaves an image of this app on the VM (over a gigabyte each), and a full disk stops
# every app on it. Kept: the one running, the previous one (to go back) and the newest $images_kept; a failure here
# never fails the deploy.
prune_images() {
local running id count=0
running=$(docker inspect --format '{{.Image}}' "$(service_container server)" 2>/dev/null || true)
# `docker images` lists the newest first; an image with several tags appears once per tag.
for id in $(docker images --no-trunc --format '{{.ID}}' "$image_repo" | awk '!seen[$0]++'); do
count=$((count + 1))
[[ "$id" == "$running" || "$id" == "$previous_image" ]] && continue
((count <= images_kept)) && continue
docker image rm -f "$id" >/dev/null 2>&1 || true
done
}
wait_until_up() {
local deadline=$((SECONDS + healthy_timeout)) status
while ((SECONDS < deadline)); do
@@ -140,15 +156,16 @@ if ! TAG="$tag" docker compose pull; then
restore_compose
exit 1
fi
TAG="$tag" docker compose up -d --remove-orphans
if wait_until_up; then
# A start that fails part-way (compose refuses a service, a container does not start) goes back like one that does
# not come up healthy.
if TAG="$tag" docker compose up -d --remove-orphans && wait_until_up; then
# Only the TAG line changes: .env also holds HA_TOKEN and other settings.
if [[ -n "$tag_arg" ]]; then
touch .env
{ grep -v '^TAG=' .env || true; printf 'TAG=%s\n' "$tag"; } > .env.new && chmod --reference=.env .env.new && mv .env.new .env
fi
revision=$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$(service_container server)")
prune_images
echo "ok: $image_repo:$tag revision ${revision:-unknown}"
exit 0
fi
+6 -10
View File
@@ -98,7 +98,7 @@ Notatki wydań nie są ustawieniem: są w **skillu** projektu (Apps → aplikacj
Jedno ustawienie w Apps → aplikacja, domyślnie wyłączone:
- **Post-upgrade check executor**: `sonnet` / `opus` = po udanym `verify` model sprawdza, czy aktualizacja wymaga czynności poinstalacyjnych (czyta ocenę notatek, skill i stan usługi; co robić po aktualizacji, można dopisać w skillu). Model wykona tylko to, co dopuszcza `config/agent-exec.json` (dokładne komendy, bez wildcardów); reszta trafia do właściciela jako STOP `migration_required`. Dziś lista ma tylko komendy Nextclouda: kontrola odbiorcza ze skilla (tylko odczyt: `php occ status` z `--output=json` i bez, `php occ config:app:get core lastcron`, `php occ app:list`, `pidof crond` w usłudze `cron`) oraz to, co po upgrade brakuje w bazie (`php occ db:add-missing-indices`, `db:add-missing-columns`, `db:add-missing-primary-keys`; użytkownik `www-data`, usługa `app`). Model widzi listę przez `hl exec-rules` i nie zgaduje. Dla pozostałych aplikacji ten krok tylko wypisze, co zrobić ręcznie.
- **Post-upgrade check executor**: `haiku` (domyślny) / `sonnet` / `opus` = po udanym `verify` model sprawdza, czy aktualizacja wymaga czynności poinstalacyjnych (czyta ocenę notatek, skill i stan usługi; co robić po aktualizacji, można dopisać w skillu). Model wykona tylko to, co dopuszcza `config/agent-exec.json` (dokładne komendy, bez wildcardów); reszta trafia do właściciela jako STOP `migration_required`. Dziś lista ma tylko komendy Nextclouda: kontrola odbiorcza ze skilla (tylko odczyt: `php occ status` z `--output=json` i bez, `php occ config:app:get core lastcron`, `php occ app:list`, `pidof crond` w usłudze `cron`) oraz to, co po upgrade brakuje w bazie (`php occ db:add-missing-indices`, `db:add-missing-columns`, `db:add-missing-primary-keys`; użytkownik `www-data`, usługa `app`). Model widzi listę przez `hl exec-rules` i nie zgaduje. Dla pozostałych aplikacji ten krok tylko wypisze, co zrobić ręcznie.
Schemat bazy przechodzi na v10 (v8 dodał `post_upgrade_executor`, v9 zdejmuje niepotrzebną `release_notes_source`, v10 dodaje niepodłączony jeszcze katalog i instancje nowego silnika workflow); starszy obraz jej nie otworzy, więc przed wdrożeniem kopia `queue.sqlite*`.
@@ -160,12 +160,8 @@ Ręczny powrót do dowolnej kopii: STOP w kolejce → rollback, albo z kroku z `
### Jednorazowo (właściciel)
1. **Katalog logowania modelu** na VM103: `install -d -o 1000 -g 1000 -m 0700 /srv/appdata/homelab-upgrade/claude`.
2. **Logowanie `claude`** (Twoim kontem, raz; dane zostają w tym katalogu):
```bash
cd /opt/homelab/compose/homelab-upgrade && docker compose exec -it model-claude /opt/claude/bin/claude
# w CLI: /login, potem /exit
```
Wygasłe logowanie kończy `repair` błędem `auth_failed` (push); wtedy powtórz ten krok.
2. **Logowanie `claude`** (Twoim kontem; dane zostają w tym katalogu): w aplikacji, `#/settings` → „Anthropic account” → „Sign in” (albo „Sign in with another account” przy zmianie konta). Otwierasz stronę logowania Anthropic, logujesz się i wklejasz pokazany kod. Awaryjnie w terminalu: `docker compose exec -it model-claude /opt/claude/bin/claude`, w CLI `/login`, `/exit`.
Wygasłe logowanie kończy `repair` błędem `auth_failed` (push); wtedy zaloguj się ponownie w tym samym miejscu. Trasy `/auth/*` sidecara są otwarte dla `driver` i `server` (`AUTH_HOST`), reszta tylko dla `driver`.
3. **Katalog logowania Codeksa** na VM103: `install -d -o 1000 -g 1000 -m 0700 /srv/appdata/homelab-upgrade/codex`.
4. **Logowanie `codex`** (Twoim kontem, raz; dane zostają w tym katalogu):
```bash
@@ -191,11 +187,11 @@ Ręczny powrót do dowolnej kopii: STOP w kolejce → rollback, albo z kroku z `
## Arcane i repo compose
Repozytorium `ssh://git@git.mariuszpacek.pl:2244/mariusz.pacek/homelab-compose.git` jest prywatne, na `master`, zawiera 19 katalogów projektów z samym `compose.yaml`, archiwum `to_delete/dockge` oraz `.gitignore`; pliki `.env` i sekrety nie są śledzone. Główny checkout VM103: `/srv/appdata/homelab-upgrade/git/repo`. Dziesięć compose używających `env_file` w formacie `raw` skonwertowano przez `tools/compose-env-compat.py`; pełny wynik `docker compose config` porównano ściśle, wartości lokalne pozostały bez zmian.
Repozytorium `ssh://git@git.mariuszpacek.pl:2244/mariusz.pacek/homelab-compose.git` jest prywatne, na `master`, zawiera 20 katalogów projektów z samym `compose.yaml`, archiwum `to_delete/dockge` oraz `.gitignore`; pliki `.env` i sekrety nie są śledzone. Główny checkout VM103: `/srv/appdata/homelab-upgrade/git/repo`. Dziesięć compose używających `env_file` w formacie `raw` skonwertowano przez `tools/compose-env-compat.py`; pełny wynik `docker compose config` porównano ściśle, wartości lokalne pozostały bez zmian.
Arcane v2.13.1 działa pod `https://arcane.mariuszpacek.pl`. Projekty używają Git Sync w trybie **Deploy** (w UI: Pull), ścieżka `<projekt>/compose.yaml`, `syncDirectory=false`, interwał 5 minut. Autosync jest włączony dla wszystkich 18 projektów; stosy Arcane i `homelab-upgrade` są wyłączone, a `upgrade-lab` działa bezpośrednio. Wszystkie manifesty projektów zawierają etykietę `com.getarcaneapp.arcane.updater: "false"`. Arcane sam wykonuje redeploy projektu po zmianie compose, więc executor w trybie git nie uruchamia `pull/up`.
Arcane v2.13.1 działa pod `https://arcane.mariuszpacek.pl`. Projekty używają Git Sync w trybie **Deploy** (w UI: Pull), ścieżka `<projekt>/compose.yaml`, `syncDirectory=false`, interwał 5 minut. Autosync jest włączony dla wszystkich 19 projektów; stosy Arcane i `homelab-upgrade` są wyłączone, a `upgrade-lab` działa bezpośrednio. Wszystkie manifesty projektów zawierają etykietę `com.getarcaneapp.arcane.updater: "false"`. Arcane sam wykonuje redeploy projektu po zmianie compose, więc executor w trybie git nie uruchamia `pull/up`.
Początkowe synchronizacje 18 projektów zachowały SHA plików env i nie zmieniły startów kontenerów; Arcane może utworzyć pusty `.env`. Źródła nie wymagały restartu podczas importu. Test Jackett przez API executora potwierdził ścieżkę komentarz → commit `47c2b7f` → push → udany sync Arcane i HTTP 301; rollback executora wykonał `git revert`, przywrócił pierwotny SHA i zakończył się powodzeniem.
Początkowe synchronizacje 18 projektów (i Trilium, dołączonego 2026-09-30) zachowały SHA plików env i nie zmieniły startów kontenerów; Arcane może utworzyć pusty `.env`. Źródła nie wymagały restartu podczas importu. Test Jackett przez API executora potwierdził ścieżkę komentarz → commit `47c2b7f` → push → udany sync Arcane i HTTP 301; rollback executora wykonał `git revert`, przywrócił pierwotny SHA i zakończył się powodzeniem.
`config/workflow-schedules.json` rozkłada starty w strefie `Europe/Warsaw` (czas polski): poniedziałek Bazarr 19:00, Jackett 21:00; wtorek Radarr 19:00, Sonarr 21:00; środa Deluge 19:00, Ombi 21:00; czwartek Homepage 19:00, OrcaSlicer 21:00; piątek Gitea 18:00, baza 19:00, Keycloak 20:00, baza 22:00; sobota Home Assistant 18:00, Nextcloud 20:00, baza 22:00, Redis 23:00; niedziela Trilium 16:00, Grafana 18:00, Jellyfin 20:00. InfluxDB i Dockge są wyłączone.
+1 -1
View File
@@ -95,7 +95,7 @@ Projekt compose może mieć kilka usług z własnymi obrazami (grafana + influxd
## Model naprawia (decyzja właściciela, 2026-09-19 wieczór)
„Skrypt robi upgrade, dopóki wszystko przechodzi; kiedy STOP albo kontener nie wstał, wchodzi model, patrzy i naprawia.”
- Krok `repair`: gdy `verify` nie przeszedł, a aplikacja ma „Migration executor” (sonnet/opus), `verify` sam kolejkuje `repair` (jedna próba; drugi nieudany `verify` = STOP dla właściciela). Na dowolnym STOP właściciel może wybrać „Repair (model)”.
- Krok `repair`: gdy `verify` nie przeszedł, a aplikacja ma „Migration executor” (haiku/sonnet/opus; bez wyboru haiku), `verify` sam kolejkuje `repair` (jedna próba; drugi nieudany `verify` = STOP dla właściciela). Na dowolnym STOP właściciel może wybrać „Repair (model)”.
- Kontener `model`: niezmodyfikowany `claude` CLI (przypięta wersja w obrazie) zalogowany kontem właściciela (`CLAUDE_CONFIG_DIR=/claude`). Bez `docker.sock`, bez plików projektów i bazy.
- Model działa tylko przez narzędzie `hl` w sesji executora otwartej na jeden projekt: `ps`, `logs`, `compose` (sekrety jako `<secret:KLUCZ>`), `write`, `pull`, `up`, `restart`, `exec-rules` (lista komend, które `exec` w ogóle przyjmie: model patrzy, zamiast zgadywać), `exec` w kontenerach tego projektu. Egzekwowane w kodzie (`domain/agent-guard.ts`): zapis compose sprawdzany listą zakazów z „Granic” względem manifestu z otwarcia sesji; wyjścia maskowane wartościami sekretów projektu; hook `PreToolUse` przepuszcza w Bash tylko `hl`, a Read/Edit/Write tylko w katalogu sesji; trasy drivera w executorze i `/run` modelu odpowiadają tylko adresowi drivera. Każde wywołanie jest w logu sesji i w wyniku kroku.
- Wyjątek za zgodą właściciela (2026-09-23): na bramce po nieudanej naprawie przycisk „Repair with full access” uruchamia jedną ponowną naprawę w sesji `full`. W niej, tylko w tym projekcie, `exec` przyjmuje dowolną komendę i użytkownika, `run` uruchamia jednorazowy kontener usługi z jej wolumenami (np. `chown` katalogu danych przy crash-loopie), a `write` sprawdza tylko poprawność compose, bez listy zakazów. Maskowanie sekretów, log sesji, kopia i rollback działają dalej. To jedna próba na run: po niej zostaje rollback albo skip.
+2
View File
@@ -180,3 +180,5 @@ Decyzje: logowanie Keycloak dla wszystkich (oauth2-proxy jako forwardAuth Traefi
- **H4** decyzja z telefonu: strona runu z bramką na górze i przyciskami przyklejonymi do dołu ekranu; push otwiera ją bezpośrednio.
- **H5** czasy względne, szybkie wyszukiwanie `Ctrl+K`, dry run wszystkich aplikacji z dostępną aktualizacją.
- **H6** logowanie Keycloak i zdjęcie ograniczenia do LAN.
Status 2026-09-30: H1–H6 wdrożone na VM103 (ostatnio `6cf5848`). Przy pierwszym wdrożeniu H6 compose odrzucił inline config w usłudze `read_only`, a `update.sh` przerwał się bez wycofania (ok. 12 min awarii, wycofane przyciskiem do `fc68d30`); poprawione: `auth` bez `read_only`, nieudane `up -d` wraca do poprzedniego obrazu i compose. Tego samego dnia dysk VM103 zapełnił się obrazami aplikacji i cache buildów CI: `update.sh` zostawia teraz uruchomiony, poprzedni i 3 najnowsze obrazy, CI czyści cache starszy niż 2 dni.
+35 -3
View File
@@ -89,6 +89,38 @@ describe('component-operation-executor: verify', () => {
expect(calls).toBe(4);
});
it('retries while compose still has the old container next to the new one, then passes', async () => {
const clock = new FakeClock(NOW);
const executor = new FakeExecutorClient();
let calls = 0;
executor.on('inspect', () => {
calls += 1;
return calls <= 2
? { outcome: 'refused', reason: 'multiple_containers', detail: 'service "jackett" of "jackett" has 2 containers' }
: { outcome: 'done', result: inspectResult({ state: 'running', health: 'healthy', image: PINNED }) };
});
const app = createComponentOperationExecutor({ clock, executor, backupVmid: 103 });
const outcome = await settle(app.execute('verify', 1, verifyInput()), clock);
expect(outcome).toMatchObject({ event: 'healthy', result: { healthy: true, attempts: 3 } });
});
it('gives up on a service that keeps two containers after the usual attempts, and at once on any other refusal', async () => {
const clock = new FakeClock(NOW);
const executor = new FakeExecutorClient();
executor.on('inspect', { outcome: 'refused', reason: 'multiple_containers', detail: 'service "jackett" of "jackett" has 2 containers' });
const app = createComponentOperationExecutor({ clock, executor, backupVmid: 103 });
const outcome = await settle(app.execute('verify', 1, verifyInput()), clock);
expect(outcome).toMatchObject({ event: 'unhealthy', result: { healthy: false, reason: 'inspect_unavailable', attempts: 24 } });
const other = new FakeExecutorClient();
other.on('inspect', { outcome: 'refused', reason: 'unknown_service', detail: 'no service' });
const once = await settle(createComponentOperationExecutor({ clock, executor: other, backupVmid: 103 }).execute('verify', 1, verifyInput()), clock);
expect(once).toMatchObject({ event: 'unhealthy', result: { healthy: false, reason: 'inspect_unavailable', detail: 'no service' } });
expect(other.calls).toHaveLength(1);
});
it('gives up after 24 unhealthy attempts, 5s apart, once the container is no longer starting', async () => {
const clock = new FakeClock(NOW);
const executor = new FakeExecutorClient();
@@ -249,17 +281,17 @@ describe('component operation executor: migration_plan', () => {
.on('sessionCompose', { outcome: 'done', result: { text: 'services:\n influxdb:\n image: influxdb:1.8\n' } })
.on('closeSession', { outcome: 'done', result: { project: 'grafana', service: 'influxdb', manifest_changed: false, manifest_sha256: 'a'.repeat(64), log: [{ at: NOW, op: 'compose', ok: true, detail: 'read' }] } });
it('has opus look at the app in an observe session and hands over the plan with its digest', async () => {
it('has haiku look at the app in an observe session and hands over the plan with its digest', async () => {
const model = new FakeModelExecutor().reply({ output: plan, costUsd: 0.4 });
const executor = observer();
const done = await createComponentOperationExecutor({ backupVmid: 103, clock: new FakeClock(NOW), skills, model, executor }).execute('migration_plan', 1, context);
expect(done).toMatchObject({
event: 'migration_needed',
result: { plan: { needed: true, inputs: [{ name: 'ADMIN_PASSWORD' }] }, model: 'opus', costUsd: 0.4, actions: ['compose: read'], composeBefore: 'services:\n influxdb:\n image: influxdb:1.8\n' },
result: { plan: { needed: true, inputs: [{ name: 'ADMIN_PASSWORD' }] }, model: 'haiku', costUsd: 0.4, actions: ['compose: read'], composeBefore: 'services:\n influxdb:\n image: influxdb:1.8\n' },
});
expect((done.result as { planDigest: string }).planDigest).toMatch(/^[0-9a-f]{64}$/);
expect(model.requests[0]).toMatchObject({ executor: 'opus', profile: 'observe', sessionToken: 'observe-token' });
expect(model.requests[0]).toMatchObject({ executor: 'haiku', profile: 'observe', sessionToken: 'observe-token' });
expect(model.requests[0]?.prompt).toContain('Before update: create an admin user');
expect(executor.calls.map((call) => call.op)).toEqual(['openSession', 'sessionCompose', 'closeSession']);
expect(executor.calls[0]?.request).toEqual({ project: 'grafana', service: 'influxdb', mode: 'observe' });
+10 -5
View File
@@ -4,7 +4,7 @@ import { resolveImageRef } from './ops/image-resolve.ts';
import { backupVerdict, HostBackupRecord } from '../domain/backup-check.ts';
import { isWithinAuto } from '../domain/version.ts';
import { describeError } from '../domain/mask.ts';
import { nextVerifyAction, VERIFY_INTERVAL_MS, verifyChecks } from '../domain/verify.ts';
import { nextVerifyAction, VERIFY_ATTEMPTS, VERIFY_INTERVAL_MS, verifyChecks } from '../domain/verify.ts';
import type { VerifyObservation } from '../domain/verify.ts';
import type { Clock, ExecutorClient, ModelExecutor, PrivilegedActionRun, RegistryClient, SkillStore } from '../domain/ports.ts';
import type { SessionLogEntry } from '../domain/executor-api.ts';
@@ -15,7 +15,7 @@ import { buildMigrationPlanPrompt, MIGRATION_PLAN_SCHEMA, MigrationPlan, migrati
import { ModelError } from '../domain/ports.ts';
import type { GroupMember } from './ops/group.ts';
import type { ComponentOperationId } from '../domain/workflow-component-operation.ts';
import { toJson } from '../domain/steps.ts';
import { modelOr, toJson } from '../domain/steps.ts';
import type { JsonValue } from '../domain/steps.ts';
import { validateImage } from '../domain/compose-edit.ts';
import { splitImage } from '../domain/image-ref.ts';
@@ -229,7 +229,12 @@ export function createComponentOperationExecutor(deps: ComponentOperationExecuto
for (let attempt = 1; ; ) {
const inspected = await executor.inspect({ project, service }, signal);
if (inspected.outcome !== 'done') {
return { event: 'unhealthy', result: { healthy: false, reason: 'inspect_unavailable', detail: inspected.detail } };
// While compose (or Arcane, for a Git project) recreates the service, the old container still sits next to the new one.
const transient = inspected.outcome === 'refused' && inspected.reason === 'multiple_containers' && attempt < VERIFY_ATTEMPTS;
if (!transient) return { event: 'unhealthy', result: { healthy: false, reason: 'inspect_unavailable', detail: inspected.detail, attempts: attempt } };
attempt += 1;
await deps.clock.sleep(VERIFY_INTERVAL_MS, signal);
continue;
}
const { container } = inspected.result;
const http = httpUrl === null || expectStatus === null ? null : await probe(httpUrl, expectStatus);
@@ -280,7 +285,7 @@ async function releaseNotes(deps: ComponentOperationExecutorDeps, record: Record
versionChange: firstString(record['change'], selection['change'], 'unknown'),
auto: firstString(record['auto'], 'none'),
promptHint: typeof record['promptHint'] === 'string' ? record['promptHint'] : null,
model: record['releaseNotesExecutor'] === 'opus' ? 'opus' : 'sonnet',
model: modelOr(record['releaseNotesExecutor']),
group: async () => (groupMembers === undefined ? [] : [...await groupMembers(app, signal)]),
}, signal);
@@ -316,7 +321,7 @@ async function migrationPlan(deps: ComponentOperationExecutorDeps, record: Recor
const project = asString(record['project']);
const service = asString(record['service']);
const app = firstString(record['component'], record['app'], project);
const executorName = record['migrateExecutor'] === 'sonnet' ? 'sonnet' : 'opus';
const executorName = modelOr(record['migrateExecutor']);
const nextSteps = asRecord(notes['report'])['nextSteps'];
const prompt = buildMigrationPlanPrompt({
app, project, service,
+12
View File
@@ -716,6 +716,18 @@ services:
expect(JSON.stringify(result)).not.toContain('old-db-secret');
});
it('without a new compose file pins the image in the current one, secrets untouched', async () => {
const { context, request } = await prepared();
const result = await context.executor.migrate({ ...request, compose_yaml: null, env: {} });
const written = WITH_SECRET.replace(`image: ${OLD}`, `image: ${NEW}`);
expect(written).not.toBe(WITH_SECRET);
expect(result).toMatchObject({ outcome: 'done', result: { state: 'applied', image: NEW, applied_manifest_sha256: sha(written), env_keys: [] } });
expect(readFileSync(context.manifest, 'utf8')).toBe(written);
expect(readFileSync(join(context.dir, '.env'), 'utf8')).toBe(ENV);
});
it('refuses an unknown secret, an unfilled input, a changed manifest, and leaves everything as it was', async () => {
const { context, request, envCandidate } = await prepared();
const cases = [
+4 -1
View File
@@ -588,7 +588,10 @@ export function createComposeExecutor(deps: ComposeExecutorDeps): ComposeExecuto
return refused('manifest_changed', `compose.yaml of "${project}" is ${short(current)}, not ${short(request.expect_manifest_sha256)} as planned`);
}
// The plan was written against the masked file (hl compose): its <secret:KEY> are this file's values.
const unmasked = unmaskComposeSecrets(request.compose_yaml, maskComposeSecrets(manifest.value.toString('utf8')).originals);
// Without a new file the migration keeps the current one (read just now, so it matches the backup) and only pins the image.
const unmasked = request.compose_yaml === null
? { ok: true as const, text: manifest.value.toString('utf8') }
: unmaskComposeSecrets(request.compose_yaml, maskComposeSecrets(manifest.value.toString('utf8')).originals);
if (!unmasked.ok) return refused('invalid_manifest', `the migration's compose.yaml has an ${unmasked.reason}`);
if (/<input:[A-Za-z0-9_]+>/.test(unmasked.text)) return refused('invalid_manifest', "the migration's compose.yaml still has an <input:NAME> the owner did not fill");
const pinned = rewriteImage(unmasked.text, service, image);
+5 -5
View File
@@ -7,7 +7,7 @@ const PINNED = `lscr.io/linuxserver/jackett:v0.24.2644-ls36@sha256:${'e'.repeat(
const config = (over: Partial<AppConfig> = {}): AppConfig => ({
app: 'jackett', project: 'jackett', enabled: true, position: 1, service: 'jackett', imageRepo: 'lscr.io/linuxserver/jackett',
tagPattern: '^v.*$', auto: 'patch', httpUrl: 'http://jackett:9117', expectStatus: 200, releaseNotesExecutor: 'sonnet',
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, notes: null, apply: true, ...over,
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, postUpgradeWhen: 'when_needed', notes: null, apply: true, ...over,
});
function prompts(app: AppConfig) {
@@ -24,16 +24,16 @@ describe('model action prompts', () => {
it('asks for the post-upgrade check what the old chain asks, with the versions, the group and the skill', async () => {
const asked = await prompts(config()).postUpgrade(target);
expect(asked.executor).toBe('sonnet');
expect(asked.executor).toBe('haiku');
expect(asked.prompt).toContain('v0.24.2627-ls34');
expect(asked.prompt).toContain('v0.24.2644-ls36');
expect(asked.prompt).toContain('flaresolverr');
expect(asked.prompt).toContain('Jackett keeps its indexers in /config.');
});
it('uses the models the app chose, opus by default for a repair', async () => {
expect((await prompts(config()).repair({ ...target, reason: 'verify_failed' })).executor).toBe('opus');
expect((await prompts(config({ postUpgradeExecutor: 'opus', migrateExecutor: 'sonnet' })).postUpgrade(target)).executor).toBe('opus');
it('uses the models the app chose, haiku by default for a repair', async () => {
expect((await prompts(config()).repair({ ...target, reason: 'verify_failed' })).executor).toBe('haiku');
expect((await prompts(config({ postUpgradeExecutor: 'opus', postUpgradeWhen: 'when_needed', migrateExecutor: 'sonnet' })).postUpgrade(target)).executor).toBe('opus');
const repair = await prompts(config({ migrateExecutor: 'sonnet' })).repair({ ...target, previousImage: null, reason: 'verify_failed: unhealthy' });
expect(repair.executor).toBe('sonnet');
expect(repair.prompt).toContain('verify_failed: unhealthy');
+5 -4
View File
@@ -2,6 +2,7 @@ import { splitImage } from '../domain/image-ref.ts';
import { buildPostUpgradePrompt } from '../domain/post-upgrade.ts';
import { buildRepairPrompt } from '../domain/repair.ts';
import type { AppConfigStore, ModelActionPrompts, SkillStore } from '../domain/ports.ts';
import { modelOr } from '../domain/steps.ts';
import type { AppConfig } from '../domain/steps.ts';
import type { GroupMember } from './ops/group.ts';
@@ -15,8 +16,8 @@ const tagOf = (image: string | null): string => (image === null ? 'unknown' : sp
/**
* The workflow engine's model actions ask what the old chain asks (domain/post-upgrade.ts, domain/repair.ts), with the
* app's compose group and skill, and the model the app chose: postUpgradeExecutor (sonnet by default) for the check
* after an update, migrateExecutor (opus by default) for a repair.
* app's compose group and skill, and the model the app chose: postUpgradeExecutor for the check after an update,
* migrateExecutor for a repair (each haiku by default).
*/
export function createModelActionPrompts(deps: ModelActionPromptDeps): ModelActionPrompts {
const configOf = (component: string): AppConfig => {
@@ -41,7 +42,7 @@ export function createModelActionPrompts(deps: ModelActionPromptDeps): ModelActi
update: { current: tagOf(request.previousImage), candidate: tagOf(request.image), pinned: request.image },
group, skill, migration: request.migration,
});
return { executor: config.postUpgradeExecutor === 'opus' ? 'opus' : 'sonnet', prompt };
return { executor: modelOr(config.postUpgradeExecutor), prompt };
},
async repair(request, signal) {
const { config, group, skill } = await facts(request.component, signal);
@@ -50,7 +51,7 @@ export function createModelActionPrompts(deps: ModelActionPromptDeps): ModelActi
update: { current: tagOf(request.previousImage), candidate: tagOf(request.image), pinned: request.image },
httpUrl: config.httpUrl, expectStatus: config.expectStatus, group, skill, fullAccess: request.fullAccess === true,
});
return { executor: config.migrateExecutor === 'sonnet' ? 'sonnet' : 'opus', prompt };
return { executor: modelOr(config.migrateExecutor), prompt };
},
};
}
+2 -1
View File
@@ -6,6 +6,7 @@ import { STRUCTURED_OUTPUT } from '../../domain/agent-guard.ts';
import { describeError, truncate } from '../../domain/mask.ts';
import type { CommandRunner, ModelFailure } from '../../domain/ports.ts';
import type { ModelSidecarRunResult } from '../../domain/model-api.ts';
import type { ModelName } from '../../domain/steps.ts';
// Runs the unmodified `claude` CLI, logged in with the owner's own account (docs/DESIGN.md, "Wykonawcy modeli"): print mode,
// JSON answer checked against a schema, a fresh empty working directory (no CLAUDE.md, no settings of anyone else), and for
@@ -30,7 +31,7 @@ export interface ClaudeDeps {
}
export interface ClaudeRunRequest {
readonly model: 'sonnet' | 'opus';
readonly model: ModelName;
readonly profile: 'readonly' | 'observe' | 'tools';
readonly prompt: string;
readonly outputSchema: unknown;
+1 -1
View File
@@ -88,7 +88,7 @@ describe('privileged action dispatcher', () => {
const current = {
app: 'jackett', project: 'jackett', enabled: true, position: 1, service: 'jackett', imageRepo: 'linuxserver/jackett',
tagPattern: '.*', auto: 'patch' as const, httpUrl: null, expectStatus: 200, releaseNotesExecutor: 'sonnet' as const,
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, notes: null, apply: false,
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, postUpgradeWhen: 'when_needed' as const, notes: null, apply: false,
};
const liveConfig = { get: () => current, list: () => [current], upsert: () => undefined };
const backupPolicy = PrivilegedActionPolicy.parse({
+1 -1
View File
@@ -22,7 +22,7 @@ const config = (over: Partial<AppConfig> = {}): AppConfig => ({
releaseNotesExecutor: 'sonnet',
migrateExecutor: null,
promptHint: null,
postUpgradeExecutor: null,
postUpgradeExecutor: null, postUpgradeWhen: 'when_needed',
notes: null,
apply: true,
...over,
+4 -3
View File
@@ -4,6 +4,7 @@ import { ModelError } from '../domain/ports.ts';
import type { ModelExecutor, SkillStore } from '../domain/ports.ts';
import { buildReleaseNotesPrompt, RELEASE_NOTES_ANSWER_SCHEMA, ReleaseNotesAnswer } from '../domain/release-notes.ts';
import type { ReleaseNotesContext } from '../domain/release-notes.ts';
import type { ModelName } from '../domain/steps.ts';
// Before an update a model finds the release notes itself (web search and fetch, the owner's list of trusted sources as
// a preference) and lays its assessment in front of the owner (requirements, breaking changes, what to do before and
@@ -14,8 +15,8 @@ export interface ReleaseNotesRead {
}
export type ReleaseNotesAssessment =
| { readonly kind: 'failed'; readonly why: string; readonly notes: ReleaseNotesRead; readonly model: 'sonnet' | 'opus'; readonly costUsd: number }
| { readonly kind: 'assessed'; readonly report: AgentReport; readonly notes: ReleaseNotesRead; readonly model: 'sonnet' | 'opus'; readonly costUsd: number };
| { readonly kind: 'failed'; readonly why: string; readonly notes: ReleaseNotesRead; readonly model: ModelName; readonly costUsd: number }
| { readonly kind: 'assessed'; readonly report: AgentReport; readonly notes: ReleaseNotesRead; readonly model: ModelName; readonly costUsd: number };
export interface ReleaseNotesQuestion {
readonly app: string;
@@ -27,7 +28,7 @@ export interface ReleaseNotesQuestion {
readonly versionChange: string;
readonly auto: string;
readonly promptHint: string | null;
readonly model: 'sonnet' | 'opus';
readonly model: ModelName;
readonly group: () => Promise<ReleaseNotesContext['group']>;
}
+1 -1
View File
@@ -44,7 +44,7 @@ async function setup() {
appConfigStore.upsert({
app: 'jackett', project: 'jackett', enabled: true, position: 1, service: 'jackett', imageRepo: 'linuxserver/jackett',
tagPattern: 'version-*', auto: 'patch', httpUrl: null, expectStatus: 200, releaseNotesExecutor: 'script',
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, notes: null, apply: true,
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, postUpgradeWhen: 'when_needed', notes: null, apply: true,
});
skillStore.upsert({ project: 'jackett', content: 'Jackett torrent proxy', updatedAt: now });
await ensureAppWorkflows({ workflowCatalog, functionCatalog, functionRuntime: passingRuntime, extensions, newId: () => crypto.randomUUID(), now: () => now }, ['jackett']);
+8 -6
View File
@@ -8,7 +8,8 @@ import type {
WorkflowInstanceStore,
} from '../domain/ports.ts';
import type { WorkflowInstance } from '../domain/workflow.ts';
import type { AppConfig, JsonValue } from '../domain/steps.ts';
import { modelOr } from '../domain/steps.ts';
import type { AppConfig, JsonValue, ModelName } from '../domain/steps.ts';
import { buildComponentUpdateSnapshot, buildSystemSnapshot } from '../domain/workflow-snapshot-builder.ts';
import { appWorkflowId } from './workflow-seed.ts';
@@ -123,13 +124,13 @@ export function startSystemRun(deps: WorkflowRunDeps, workflowId: string): Start
* project and service), so only what the context cannot say is here: apply's dry-run flag and the model profiles.
*/
function realRunRequests(deps: WorkflowRunDeps, config: AppConfig): Record<string, JsonValue> {
const profile = (model: 'sonnet' | 'opus') => {
const profile = (model: ModelName) => {
const latest = deps.modelProfileStore.getLatestVersion(model);
return latest === null ? null : { profileId: latest.profileId, version: latest.version };
};
// The same models the old chain uses: the app's choice, else sonnet checks after an update and opus repairs.
const postUpgrade = config.postUpgradeExecutor === 'opus' ? 'opus' : 'sonnet';
const repair = config.migrateExecutor === 'sonnet' ? 'sonnet' : 'opus';
// The app's choice, else the default model.
const postUpgrade = modelOr(config.postUpgradeExecutor);
const repair = modelOr(config.migrateExecutor);
return {
backupRequest: { project: config.project },
applyDryRunRequest: { dryRun: true },
@@ -139,7 +140,8 @@ function realRunRequests(deps: WorkflowRunDeps, config: AppConfig): Record<strin
repairFullRequest: { reason: 'owner_requested', access: 'full' },
rollbackRequest: {},
migrateRequest: {},
postUpgradeRequest: {},
// Without a model chosen the check is off (domain/steps.ts); with one, the app says when it runs.
postUpgradeRequest: { when: config.postUpgradeExecutor === null ? 'off' : config.postUpgradeWhen },
postUpgradeProfile: profile(postUpgrade),
repairProfile: profile(repair),
};
+39 -8
View File
@@ -104,13 +104,14 @@ const MIGRATION_PLAN = {
stepsBefore: [], stepsAfter: ['hl ps'], rollback: 'Restore the backup.',
};
function fakeOperations(tags: 'candidate_within_auto' | 'candidate_above_auto' = 'candidate_within_auto', migration: 'none' | 'needed' = 'none'): ComponentOperationRunner {
function fakeOperations(tags: 'candidate_within_auto' | 'candidate_above_auto' = 'candidate_within_auto', migration: 'none' | 'needed' | 'image_only' = 'none'): ComponentOperationRunner {
const plan = migration === 'image_only' ? { ...MIGRATION_PLAN, composeYaml: null, inputs: [] } : MIGRATION_PLAN;
const results: Record<string, { event: string; result: JsonValue }> = {
image_version: { event: 'version_detected', result: { event: 'version_detected', detectedVersion: '0.22.0', imageVersionState: 'detected' } },
image_tags: { event: tags, result: { event: tags, current: '0.22.0', candidate: 'version-v0.22.1', change: tags === 'candidate_within_auto' ? 'patch' : 'major' } },
release_notes: { event: 'no_source', result: { sources: [] } },
migration_plan: migration === 'needed'
? { event: 'migration_needed', result: { plan: MIGRATION_PLAN, planDigest: 'd'.repeat(64), summary: MIGRATION_PLAN.summary, model: 'opus', costUsd: 0.3, actions: [] } }
migration_plan: migration !== 'none'
? { event: 'migration_needed', result: { plan, planDigest: 'd'.repeat(64), summary: plan.summary, model: 'opus', costUsd: 0.3, actions: [] } }
: { event: 'no_migration', result: { plan: null, summary: 'Nothing to migrate.' } },
image_resolve: {
event: 'resolved',
@@ -146,7 +147,7 @@ async function setup(
appConfigStore.upsert({
app: 'jackett', project: 'jackett', enabled: true, position: 1, service: 'jackett', imageRepo: 'linuxserver/jackett',
tagPattern: 'version-*', auto: 'patch', httpUrl: null, expectStatus: 200, releaseNotesExecutor: 'script',
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, notes: null, apply: true,
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, postUpgradeWhen: 'when_needed', notes: null, apply: true,
});
skillStore.upsert({ project: 'jackett', content: 'Jackett torrent proxy', updatedAt: now });
// The real sandbox: the template functions run in bwrap exactly as on VM103.
@@ -253,7 +254,7 @@ describe('workflow runner', () => {
{ image: PINNED, backupId: BACKUP_ID, expectedManifestSha256: MANIFEST, dryRun: true },
{ image: PINNED, backupId: BACKUP_ID, expectedManifestSha256: MANIFEST, dryRun: false },
]);
expect(docker.requests.find((request) => request.action === 'model.postUpgrade')).toMatchObject({ image: PINNED, backupId: BACKUP_ID, profile: { profileId: 'sonnet' } });
expect(docker.requests.find((request) => request.action === 'model.postUpgrade')).toMatchObject({ image: PINNED, backupId: BACKUP_ID, profile: { profileId: 'haiku' }, when: 'off', reasons: [] });
});
it('rolls the app back when the owner chooses rollback after the model asked for action', async () => {
@@ -282,11 +283,11 @@ describe('workflow runner', () => {
let verifies = 0;
const { runner, instanceStore, docker, runDeps } = await setup({
execute: (operation, version, input, signal) =>
operation === 'verify' && ++verifies === 1 ? Promise.resolve({ event: 'unhealthy', result: { healthy: false } }) : operations.execute(operation, version, input, signal),
operation === 'verify' && ++verifies <= 2 ? Promise.resolve({ event: 'unhealthy', result: { healthy: false } }) : operations.execute(operation, version, input, signal),
});
startWorkflowRun(runDeps, 'jackett', 'real');
expect((await runUntilIdle(runner)).slice(-3)).toEqual(['verify_initial', 'repair', 'gate_repair_failed']);
expect((await runUntilIdle(runner)).slice(-4)).toEqual(['verify_initial', 'repair', 'verify_after_gave_up', 'gate_repair_failed']);
const instance = instanceStore.get('run-1')!;
expect(instanceStore.submitOwnerDecision({
decision: { schemaVersion: 1, decisionId: crypto.randomUUID(), signal: 'owner-decision.v1', instanceId: 'run-1', waitNodeId: 'gate_repair_failed', action: 'repair', note: null },
@@ -301,6 +302,19 @@ describe('workflow runner', () => {
expect(instanceStore.get('run-1')?.projection.status).toBe('succeeded');
});
it('checks again when the model gives up and ends updated if the app came up meanwhile', async () => {
const operations = fakeOperations();
let verifies = 0;
const { runner, instanceStore, runDeps } = await setup({
execute: (operation, version, input, signal) =>
operation === 'verify' && ++verifies === 1 ? Promise.resolve({ event: 'unhealthy', result: { healthy: false } }) : operations.execute(operation, version, input, signal),
});
startWorkflowRun(runDeps, 'jackett', 'real');
expect((await runUntilIdle(runner)).slice(-4)).toEqual(['verify_initial', 'repair', 'verify_after_gave_up', 'terminal_updated']);
expect(instanceStore.get('run-1')?.projection.status).toBe('succeeded');
});
it('runs an approved migration instead of the plain apply: plan, gate, backup, migrate, verify', async () => {
const { runner, instanceStore, docker, runDeps } = await setup(fakeOperations('candidate_above_auto', 'needed'));
startWorkflowRun(runDeps, 'jackett', 'real');
@@ -321,10 +335,27 @@ describe('workflow runner', () => {
action: 'docker.migrate', version: 1, component: 'jackett', project: 'jackett', service: 'jackett', image: PINNED, backupId: BACKUP_ID,
expectedManifestSha256: MANIFEST, composeYaml: MIGRATION_PLAN.composeYaml, planDigest: 'd'.repeat(64), inputs: [{ name: 'ADMIN_PASSWORD', target: 'env' }],
});
expect(docker.requests.find((request) => request.action === 'model.postUpgrade')).toMatchObject({ migration: { summary: MIGRATION_PLAN.summary, stepsAfter: ['hl ps'] } });
expect(docker.requests.find((request) => request.action === 'model.postUpgrade')).toMatchObject({
migration: { summary: MIGRATION_PLAN.summary, stepsAfter: ['hl ps'] }, reasons: ['approved_update', 'major', 'migration'],
});
expect(instanceStore.getReplayStatus('run-1').status).toBe('ok');
});
it('runs an approved migration that keeps the compose file: the executor only pins the new image', async () => {
const { runner, instanceStore, docker, runDeps } = await setup(fakeOperations('candidate_above_auto', 'image_only'));
startWorkflowRun(runDeps, 'jackett', 'real');
await runUntilIdle(runner);
const instance = instanceStore.get('run-1')!;
expect(instanceStore.submitOwnerDecision({
decision: { schemaVersion: 1, decisionId: crypto.randomUUID(), signal: 'owner-decision.v1', instanceId: 'run-1', waitNodeId: 'gate_migration', action: 'approve', note: null },
expectedLifecycleSeq: instance.projection.seq, expectedExecutionSeq: instanceStore.getReplayStatus('run-1').replaySeq, now,
}).ok).toBe(true);
expect(await runUntilIdle(runner)).toContain('apply_migration');
expect(docker.requests.find((request) => request.action === 'docker.migrate')).toMatchObject({ composeYaml: null, inputs: [] });
expect(instanceStore.get('run-1')?.projection.status).toBe('succeeded');
});
it('goes the usual way above the auto threshold when the model finds no migration', async () => {
const { runner, runDeps } = await setup(fakeOperations('candidate_above_auto'));
startShadowRun(runDeps, 'jackett');
+24 -3
View File
@@ -60,10 +60,10 @@ describe('ensureUpdatePolicy', () => {
});
describe('ensureDefaultModelProfiles', () => {
it('adds read-only Claude profiles for the sonnet and opus executors once', () => {
it('adds read-only Claude profiles for the haiku, sonnet and opus executors once', () => {
const store = createSqliteModelProfileStore(openDatabase(':memory:'));
expect(ensureDefaultModelProfiles(store, now)).toEqual(['sonnet', 'opus']);
expect(ensureDefaultModelProfiles(store, now)).toEqual(['haiku', 'sonnet', 'opus']);
expect(ensureDefaultModelProfiles(store, now)).toEqual([]);
expect(store.getLatestVersion('opus')?.document).toMatchObject({ provider: 'claude', model: 'opus', mode: 'readonly', timeoutMs: 1_800_000 });
});
@@ -74,7 +74,7 @@ describe('ensureDefaultModelProfiles', () => {
store.publishVersion({ profileId: 'sonnet', expectedVersion: 1, document: first('sonnet'), now });
store.publishVersion({ profileId: 'opus', expectedVersion: 1, document: first('opus', { provider: 'codex' }), now });
expect(ensureDefaultModelProfiles(store, now)).toEqual(['sonnet']);
expect(ensureDefaultModelProfiles(store, now)).toEqual(['haiku', 'sonnet']);
expect(store.getLatestVersion('sonnet')).toMatchObject({ version: 2, document: { timeoutMs: 1_800_000 } });
expect(store.getLatestVersion('opus')).toMatchObject({ version: 1, document: { provider: 'codex', timeoutMs: 300_000 } });
});
@@ -136,6 +136,27 @@ describe('ensureAppWorkflows', () => {
expect(again.workflows).toEqual([]);
});
it('moves a workflow whose draft is an untouched copy of its version, and leaves one with an edited draft', async () => {
const { deps, workflowCatalog } = setup();
await ensureAppWorkflows(deps, ['jackett', 'radarr']);
for (const app of ['jackett', 'radarr']) {
const cloned = workflowCatalog.clonePublishedVersion({ sourceWorkflowId: `update-${app}`, sourceVersion: 1, workflowId: `update-${app}`, name: `Update ${app}`, now });
if (!cloned.ok) throw new Error(cloned.reason);
}
// radarr's draft is being edited by the owner.
const edited = { ...COMPONENT_UPDATE_WORKFLOW_DOCUMENT_V2, nodes: [...COMPONENT_UPDATE_WORKFLOW_DOCUMENT_V2.nodes].reverse() };
expect(workflowCatalog.saveDraft({ workflowId: 'update-radarr', expectedRevision: 1, document: edited }).ok).toBe(true);
const next = { ...COMPONENT_UPDATE_WORKFLOW_DOCUMENT_V2, nodes: [...COMPONENT_UPDATE_WORKFLOW_DOCUMENT_V2.nodes.slice(1), COMPONENT_UPDATE_WORKFLOW_DOCUMENT_V2.nodes[0]!] };
const moved = await ensureAppWorkflows({ ...deps, template: { document: next, digests: [...COMPONENT_UPDATE_TEMPLATE_DIGESTS, workflowDocumentDigest(next)] } }, ['jackett', 'radarr']);
expect(moved).toEqual({ functions: [], workflows: ['update-jackett'], edited: [], failures: [] });
expect(workflowCatalog.listPublishedVersions?.('update-jackett')).toHaveLength(2);
expect(workflowCatalog.getDraft('update-jackett')).toBeNull();
expect(workflowCatalog.listPublishedVersions?.('update-radarr')).toHaveLength(1);
expect(workflowCatalog.getDraft('update-radarr')).not.toBeNull();
});
it('reports a function whose cases fail and skips the workflows that need it', async () => {
const failingRuntime: FunctionTestExecutor = {
execute: () => Promise.resolve({ event: 'error', summary: 'bwrap: sandbox unavailable', data: {} }),
+18 -10
View File
@@ -12,6 +12,7 @@ import {
} from '../domain/workflow-update.ts';
import { createWorkflowCatalogApp, createWorkflowGraphValidator } from './workflow-catalog.ts';
import { createWorkflowFunctionCatalogApp } from './workflow-function-catalog.ts';
import { ModelName } from '../domain/steps.ts';
/** Each app gets its own update workflow, first published as a copy of the component-update template. */
export { appWorkflowId };
@@ -28,7 +29,7 @@ export function ensureUpdatePolicy(store: PrivilegedActionStore, now: string): b
}
/** A default profile's first document; one still exactly like it was never changed by the owner. */
const FIRST_DEFAULT_PROFILE = (model: 'sonnet' | 'opus'): ModelProfileDocumentV1 => ({
const FIRST_DEFAULT_PROFILE = (model: ModelName): ModelProfileDocumentV1 => ({
schemaVersion: 1, provider: 'claude', model, mode: 'readonly', timeoutMs: 300_000, maxCostUsd: null, tools: [], systemPrompt: null, context: null,
});
@@ -36,17 +37,17 @@ const FIRST_DEFAULT_PROFILE = (model: 'sonnet' | 'opus'): ModelProfileDocumentV1
* The default profile: the model sidecar's own limit (30 min, MODEL_TIMEOUT_MS), since a repair session takes long; the
* dispatcher stops a run at its profile's timeout.
*/
const DEFAULT_PROFILE = (model: 'sonnet' | 'opus'): ModelProfileDocumentV1 => ({ ...FIRST_DEFAULT_PROFILE(model), timeoutMs: 30 * 60_000 });
const DEFAULT_PROFILE = (model: ModelName): ModelProfileDocumentV1 => ({ ...FIRST_DEFAULT_PROFILE(model), timeoutMs: 30 * 60_000 });
/**
* App configs name 'sonnet' and 'opus' as executors; a model request without a pinned profile uses the profile of that
* App configs name 'haiku', 'sonnet' and 'opus' as executors; a model request without a pinned profile uses the profile of that
* name, and a workflow snapshot pins one for each. Adds the missing ones as Claude profiles, and moves a profile the owner
* never changed from its first document to the current default; the owner edits them on the Models page (the provider
* picks the Claude or Codex sidecar).
*/
export function ensureDefaultModelProfiles(store: ModelProfileStore, now: string): readonly string[] {
const added: string[] = [];
for (const model of ['sonnet', 'opus'] as const) {
for (const model of ModelName.options) {
const latest = store.getLatestVersion(model);
if (latest !== null && !isDeepStrictEqual(latest.document, FIRST_DEFAULT_PROFILE(model))) continue;
const result = store.publishVersion({ profileId: model, expectedVersion: (latest?.version ?? 0) + 1, document: DEFAULT_PROFILE(model), now });
@@ -113,8 +114,11 @@ export async function ensureAppWorkflows(deps: WorkflowSeedDeps, apps: readonly
edited.push(workflowId);
continue;
}
if (!functionsReady || deps.workflowCatalog.getDraft(workflowId) !== null) continue;
const reason = upgradeWorkflow(deps, workflowId, app, latest!.version, template.document);
if (!functionsReady) continue;
// A draft the owner is editing stays; an untouched copy of the version (opened and left) must not hold the workflow back.
const draft = deps.workflowCatalog.getDraft(workflowId);
if (draft !== null && workflowDocumentDigest(draft.document) !== digest) continue;
const reason = upgradeWorkflow(deps, workflowId, app, latest!.version, template.document, draft?.revision ?? null);
if (reason === null) workflows.push(workflowId);
else failures.push({ id: workflowId, reason });
continue;
@@ -131,14 +135,18 @@ export async function ensureAppWorkflows(deps: WorkflowSeedDeps, apps: readonly
}
/** Publishes the current template as the next version of an app's workflow that is still on an older template. */
function upgradeWorkflow(deps: WorkflowSeedDeps, workflowId: string, app: string, latestVersion: number, document: WorkflowDocumentV2): string | null {
function upgradeWorkflow(deps: WorkflowSeedDeps, workflowId: string, app: string, latestVersion: number, document: WorkflowDocumentV2, draftRevision: number | null): string | null {
const workflowApp = createWorkflowCatalogApp(
deps.workflowCatalog,
createWorkflowGraphValidator({ workflowId, version: latestVersion + 1 }, [], deps.extensions),
);
const cloned = workflowApp.clonePublishedVersion({ sourceWorkflowId: workflowId, sourceVersion: latestVersion, workflowId, name: `Update ${app}`, now: deps.now() });
if (!cloned.ok) return `draft: ${cloned.reason}`;
const saved = workflowApp.saveDraft({ workflowId, expectedRevision: cloned.draft.revision, document });
let revision = draftRevision;
if (revision === null) {
const cloned = workflowApp.clonePublishedVersion({ sourceWorkflowId: workflowId, sourceVersion: latestVersion, workflowId, name: `Update ${app}`, now: deps.now() });
if (!cloned.ok) return `draft: ${cloned.reason}`;
revision = cloned.draft.revision;
}
const saved = workflowApp.saveDraft({ workflowId, expectedRevision: revision, document });
if (!saved.ok) return `draft: ${saved.reason}`;
return validateAndPublish(deps, workflowApp, workflowId, saved.draft.revision);
}
+1 -1
View File
@@ -16,7 +16,7 @@ const bazarr: AppConfig = {
releaseNotesExecutor: 'sonnet',
migrateExecutor: null,
promptHint: null,
postUpgradeExecutor: null,
postUpgradeExecutor: null, postUpgradeWhen: 'when_needed',
notes: null,
apply: false,
};
+5 -2
View File
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { AutoLevel, Executor } from './steps.ts';
import { AutoLevel, Executor, ModelName, PostUpgradeWhen } from './steps.ts';
import type { AppConfig } from './steps.ts';
import { compileTagPattern } from './tags.ts';
@@ -15,7 +15,7 @@ export function countWords(text: string): number {
}
/** Model steps run on a model; `script` is not a choice for them. */
const ModelChoice = z.enum(['sonnet', 'opus']);
const ModelChoice = ModelName;
const cleared = (value: unknown) => (typeof value === 'string' && value.trim() === '' ? null : value);
@@ -41,6 +41,7 @@ export const AppConfigPatch = z
migrateExecutor: ModelChoice.nullable(),
promptHint: z.preprocess(cleared, z.string().trim().max(500).nullable()),
postUpgradeExecutor: ModelChoice.nullable(),
postUpgradeWhen: PostUpgradeWhen,
notes: z.preprocess(
cleared,
z
@@ -61,6 +62,8 @@ export const AppConfigValue = z.strictObject({
service: z.string().min(1), imageRepo: z.string().min(1), tagPattern: z.string(), auto: AutoLevel,
httpUrl: z.string().nullable(), expectStatus: z.number().int(), releaseNotesExecutor: Executor,
migrateExecutor: Executor.nullable(), promptHint: z.string().nullable(), postUpgradeExecutor: Executor.nullable(),
// Revisions from before 2026-10-07 have none: the same default as the app_config column (migration 25).
postUpgradeWhen: PostUpgradeWhen.default('when_needed'),
notes: z.string().nullable(), apply: z.boolean(),
});
+2 -1
View File
@@ -88,7 +88,8 @@ export const MigrateRequest = z.strictObject({
image: PinnedImage,
expect_manifest_sha256: Sha256,
backup_id: BackupId,
compose_yaml: z.string().min(1).max(65_536),
/** null: the plan keeps the current compose.yaml; only the service's image changes. */
compose_yaml: z.string().min(1).max(65_536).nullable(),
env: z.record(z.string().regex(/^[A-Z][A-Z0-9_]{0,63}$/), z.string().max(4096).regex(/^[^\n\r]*$/, 'one line')),
});
export type MigrateRequest = z.infer<typeof MigrateRequest>;
+2 -1
View File
@@ -1,5 +1,6 @@
import { z } from 'zod';
import type { ModelFailure } from './ports.ts';
import { ModelName } from './steps.ts';
// The model sidecar's API: the driver asks it to run one prompt (readonly, or with the hl tool in a repair session).
@@ -16,7 +17,7 @@ export type ModelSidecarRunResult =
| { readonly ok: false; readonly kind: ModelFailure; readonly detail: string; readonly costUsd?: number | null };
export const ModelRunRequest = z.strictObject({
executor: z.enum(['sonnet', 'opus']),
executor: ModelName,
profile: z.enum(['readonly', 'observe', 'tools']),
prompt: z.string().min(1).max(400_000),
output_schema: z.record(z.string(), z.unknown()),
+37
View File
@@ -0,0 +1,37 @@
import { z } from 'zod';
// The owner's Anthropic account behind the Claude sidecar: who is signed in, and the browser sign-in that replaces it.
export const ModelAuthStatus = z.object({
loggedIn: z.boolean(),
email: z.string().nullable(),
organization: z.string().nullable(),
/** How the CLI is signed in (`claude.ai` subscription, `console` API billing, …); null when not signed in. */
method: z.string().nullable(),
});
export type ModelAuthStatus = z.infer<typeof ModelAuthStatus>;
/** The sign-in page the owner opens in a browser; it ends in a code to paste back. */
export const ModelAuthLoginStart = z.object({ url: z.string().url() });
export type ModelAuthLoginStart = z.infer<typeof ModelAuthLoginStart>;
export const ModelAuthCodeRequest = z.strictObject({ code: z.string().trim().min(8).max(2048) });
export type ModelAuthCodeRequest = z.infer<typeof ModelAuthCodeRequest>;
/** The sidecar's side: all failures are exceptions with a message the owner can read. */
export interface ModelAuth {
status(): Promise<ModelAuthStatus>;
/** Starts a sign-in (dropping one in progress) and returns the page to open. */
startLogin(): Promise<ModelAuthLoginStart>;
/** Finishes the sign-in with the code the page showed; returns the new status. */
submitCode(code: string): Promise<ModelAuthStatus>;
cancelLogin(): Promise<void>;
logout(): Promise<ModelAuthStatus>;
}
export class ModelAuthError extends Error {
constructor(message: string) {
super(message);
this.name = 'ModelAuthError';
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ const config = (app: string, position: number, enabled = true): AppConfig => ({
releaseNotesExecutor: 'sonnet',
migrateExecutor: null,
promptHint: null,
postUpgradeExecutor: null,
postUpgradeExecutor: null, postUpgradeWhen: 'when_needed',
notes: null,
apply: false,
});
+6 -3
View File
@@ -18,6 +18,7 @@ import type {
SessionOpenRequest,
SessionOpenResult,
SessionLogResult,
AgentLogsRequest,
AgentTextResult,
SessionLogEntry,
SessionSummary,
@@ -25,7 +26,7 @@ import type {
import type { PreflightReport } from './preflight.ts';
import type { Skill, SkillVersion } from './skill.ts';
import type { AppConfigRevision } from './app-config.ts';
import type { AppConfig, Executor, JsonValue } from './steps.ts';
import type { AppConfig, Executor, JsonValue, ModelName } from './steps.ts';
import type { HostStatus } from './host-status.ts';
import type { NotificationSettings } from './notification-settings.ts';
import type { AppendableWorkflowInstanceEventInput, PublishedWorkflowVersion, WorkflowDraft, WorkflowDocument, WorkflowInstance, WorkflowInstanceCreateInput, WorkflowInstanceEvent, WorkflowInstanceStatus, WorkflowValidation } from './workflow.ts';
@@ -403,6 +404,8 @@ export interface ExecutorClient {
sessionLog(request: SessionCloseRequest, signal?: AbortSignal): Promise<ExecutorCall<SessionLogResult>>;
/** The project's compose.yaml as the session sees it (secrets masked as `<secret:NAME>`). */
sessionCompose(request: SessionCloseRequest, signal?: AbortSignal): Promise<ExecutorCall<AgentTextResult>>;
/** A service's last log lines as the session reads them (secrets masked), like the model's `hl logs`. */
sessionLogs(request: SessionCloseRequest & AgentLogsRequest, signal?: AbortSignal): Promise<ExecutorCall<AgentTextResult>>;
}
export interface ModelRequest {
@@ -476,14 +479,14 @@ export interface ModelActionPrompts {
readonly migration?: { readonly summary: string; readonly stepsAfter: readonly string[] };
},
signal?: AbortSignal,
): Promise<{ readonly executor: 'sonnet' | 'opus'; readonly prompt: string }>;
): Promise<{ readonly executor: ModelName; readonly prompt: string }>;
repair(
request: {
readonly component: string; readonly project: string; readonly service: string; readonly image: string; readonly reason: string; readonly previousImage: string | null;
readonly fullAccess?: boolean;
},
signal?: AbortSignal,
): Promise<{ readonly executor: 'sonnet' | 'opus'; readonly prompt: string }>;
): Promise<{ readonly executor: ModelName; readonly prompt: string }>;
}
/** Runs a model (the `claude` CLI logged in with the owner's account, in the model sidecar). Throws ModelError. */
+33 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { buildPostUpgradePrompt, POST_UPGRADE_ANSWER_SCHEMA, PostUpgradeAnswer } from './post-upgrade.ts';
import { buildPostUpgradePrompt, POST_UPGRADE_ANSWER_SCHEMA, PostUpgradeAnswer, suspiciousLogLines } from './post-upgrade.ts';
const context = {
app: 'nextcloud',
@@ -58,3 +58,35 @@ describe('PostUpgradeAnswer', () => {
expect(POST_UPGRADE_ANSWER_SCHEMA).toMatchObject({ type: 'object', required: ['outcome', 'summary', 'actions'] });
});
});
describe('suspiciousLogLines', () => {
it('finds errors, fatal ones, panics, exceptions and failed migrations', () => {
const logs = [
'[migrations] started',
'[migrations] no migrations found',
'2026-10-07 09:27:01 INFO Starting Sonarr',
'2026-10-07 09:27:02 ERROR Unable to connect to indexer',
'level=fatal msg="database is locked"',
'panic: runtime error: index out of range',
'System.NullReferenceException: Object reference not set',
'Traceback (most recent call last):',
'Migration failed: column already exists',
].join('\n');
expect(suspiciousLogLines(logs)).toEqual([
'2026-10-07 09:27:02 ERROR Unable to connect to indexer',
'level=fatal msg="database is locked"',
'panic: runtime error: index out of range',
'System.NullReferenceException: Object reference not set',
'Traceback (most recent call last):',
'Migration failed: column already exists',
]);
});
it('a clean start, and lines that only say there are no errors, find nothing', () => {
expect(suspiciousLogLines('[migrations] no migrations found\nStarted in 2.1 s, 0 errors\nno errors found\n')).toEqual([]);
});
it('keeps at most the first lines it is asked for', () => {
expect(suspiciousLogLines(Array.from({ length: 30 }, (_, index) => `ERROR ${String(index)}`).join('\n'), 3)).toEqual(['ERROR 0', 'ERROR 1', 'ERROR 2']);
});
});
+14 -1
View File
@@ -2,7 +2,20 @@ import { z } from 'zod';
// The step after a successful update (owner, 2026-09-20): some apps ask for actions once the new version runs (Nextcloud: missing
// database indices, occ repair). A model looks at what the release notes analysis and the app's skill say, does what the tool
// allows and lists the rest for the owner. Per app, off by default: then the chain stays scripts only.
// allows and lists the rest for the owner. Per app, off by default: then the chain stays scripts only. When on, by default only
// when the update may ask for something (owner, 2026-10-07): one the owner approved, a major one, a migration, or errors in the logs.
/** Why a post-upgrade check runs the model. */
export const PostUpgradeReason = z.enum(['approved_update', 'major', 'migration', 'log_errors']);
export type PostUpgradeReason = z.infer<typeof PostUpgradeReason>;
const SUSPICIOUS = /\b(error|fatal|panic|traceback|critical)\b|exception\b|migrations? failed/i;
const NO_ERRORS = /\b(no|0) errors?\b/i;
/** The log lines after a start that look like trouble, the first `max` of them. */
export function suspiciousLogLines(logs: string, max = 10): string[] {
return logs.split('\n').map((line) => line.trim()).filter((line) => SUSPICIOUS.test(line) && !NO_ERRORS.test(line)).slice(0, max);
}
export const PostUpgradeAnswer = z.object({
outcome: z
+13 -2
View File
@@ -1,4 +1,6 @@
import { z } from 'zod';
import { PostUpgradeReason } from './post-upgrade.ts';
import { ModelName } from './steps.ts';
/**
* The executor is intentionally a closed vocabulary. Adding an action is a
@@ -128,7 +130,8 @@ export const DockerMigrateRequest = z.strictObject({
image: PinnedImage,
backupId: BackupId,
expectedManifestSha256: Sha256,
composeYaml: z.string().min(1).max(65_536),
/** null when the plan keeps the compose file as it is apart from the image. */
composeYaml: z.string().min(1).max(65_536).nullable(),
planDigest: Sha256,
inputs: z.array(z.strictObject({ name: z.string().regex(/^[A-Z][A-Z0-9_]{0,63}$/), target: z.enum(['env', 'compose']) })).max(20),
});
@@ -182,6 +185,10 @@ export const ModelPostUpgradeRequest = z.strictObject({
profile: ProfileReference,
/** After an approved migration (docker.migrate): its summary and the hl steps its plan runs once the app is up. */
migration: z.strictObject({ summary: z.string().max(2000), stepsAfter: z.array(z.string().min(1).max(500)).max(30) }).optional(),
/** The app's setting: off = no model, when_needed = only with a reason (or errors in the logs). Older requests have none: always. */
when: z.enum(['off', 'when_needed', 'always']).optional(),
/** What in this run asks for a look: an update the owner approved, a major one, a migration. */
reasons: z.array(PostUpgradeReason).max(4).optional(),
});
export type ModelPostUpgradeRequest = z.infer<typeof ModelPostUpgradeRequest>;
@@ -322,7 +329,7 @@ const ModelActionReport = z.strictObject({
outcome: z.string().min(1).max(64),
summary: z.string().max(4000),
actions: z.array(z.string().max(500)).max(50),
model: z.enum(['sonnet', 'opus']),
model: ModelName,
costUsd: z.number().nonnegative(),
});
@@ -354,6 +361,10 @@ export const ModelPostUpgradeResult = z.strictObject({
manifestChanged: z.boolean(),
manifestSha256: Sha256,
report: ModelActionReport.optional(),
/** Why the model ran; absent on results from before 2026-10-07. */
reasons: z.array(PostUpgradeReason).max(4).optional(),
/** The model did not run: the app has it off, or nothing in the run or the logs asked for it. */
skipped: z.enum(['off', 'not_needed']).optional(),
}).strict();
export type ModelPostUpgradeResult = z.infer<typeof ModelPostUpgradeResult>;
+16 -1
View File
@@ -3,10 +3,24 @@ import { z } from 'zod';
// Shared vocabulary: model executors, auto levels, reason codes, JSON and an app's configuration. The zod schema is the
// single source of the type. (The file is named after the retired step queue it started with.)
export const Executor = z.enum(['script', 'sonnet', 'opus']);
/** The Claude models a model step runs on; the profile of the same name picks the sidecar (Models page). */
export const ModelName = z.enum(['haiku', 'sonnet', 'opus']);
export type ModelName = z.infer<typeof ModelName>;
/** The model of a model step whose app chose none (owner, 2026-10-07: haiku is cheap and good enough). */
export const DEFAULT_MODEL: ModelName = 'haiku';
/** The app's model choice, or the default when it names none (null, `script`, anything else). */
export const modelOr = (choice: unknown): ModelName => {
const parsed = ModelName.safeParse(choice);
return parsed.success ? parsed.data : DEFAULT_MODEL;
};
export const Executor = z.enum(['script', ...ModelName.options]);
export type Executor = z.infer<typeof Executor>;
export const AutoLevel = z.enum(['none', 'patch', 'minor', 'major']);
/** When an app's post-upgrade check runs its model: only when the update may ask for something, or after every update. */
export const PostUpgradeWhen = z.enum(['when_needed', 'always']);
export type PostUpgradeWhen = z.infer<typeof PostUpgradeWhen>;
export type AutoLevel = z.infer<typeof AutoLevel>;
/** Why an operation stopped or failed (app/ops); unknown cause = `unexpected`. */
@@ -59,6 +73,7 @@ export interface AppConfig {
readonly promptHint: string | null;
/** null = after an update only scripts check it; a model here checks what the update asks to be done afterwards. */
readonly postUpgradeExecutor: Executor | null;
readonly postUpgradeWhen: PostUpgradeWhen;
readonly notes: string | null;
/** Real updates through the executor (backup, apply, verify). Off = only dry runs. */
readonly apply: boolean;
+1 -1
View File
@@ -21,7 +21,7 @@ export const PRIVILEGED_ACTION_DESCRIPTIONS: Readonly<Record<PrivilegedActionId,
'docker.diskUsage': { title: 'Check the disk', kind: 'read', summary: 'Executor reads the free space of the Docker filesystem and what images and build cache could give back.' },
'docker.imagePrune': { title: 'Remove unused images', kind: 'change', summary: 'Executor removes Docker images no container uses (and the build cache when asked); volumes are never touched.' },
'model.repair': { title: 'Model repairs the app', kind: 'model', summary: 'A model reads logs and the compose file and may change them to make the app healthy again.' },
'model.postUpgrade': { title: 'Model checks the update', kind: 'model', summary: 'A model checks the updated app (hl ps, hl logs), may restart it, and asks the owner if something needs a decision.' },
'model.postUpgrade': { title: 'Model checks the update', kind: 'model', summary: 'A model checks the updated app (hl ps, hl logs), may restart it, and asks the owner if something needs a decision. Off per app; by default only for an approved or major update, a migration, or errors in the logs.' },
};
export const COMPONENT_OPERATION_DESCRIPTIONS: Readonly<Record<ComponentOperationId, BlockDescription>> = {
+14
View File
@@ -27,4 +27,18 @@ describe('workflowModelCalls', () => {
expect(calls[2]).toMatchObject({ instanceId: 'run-1', workflowId: 'update-radarr', summary: 'Clean start.', actions: ['hl ps'], at: '2026-09-22T21:35:00.000Z' });
expect(totalCostUsd(calls)).toBeCloseTo(0.82);
});
it('counts a model run once when a later node carries its result on, as the terminal node does', () => {
const postUpgrade = { action: 'model.postUpgrade', state: 'completed', report: { outcome: 'nothing_to_do', summary: 'Clean start.', actions: ['hl ps'], model: 'sonnet', costUsd: 0.2 } };
const notes = { verdict: 'attention', summary: 'A migration runs.', model: 'opus', costUsd: 0.12 };
const calls = workflowModelCalls({ id: 'run-1', workflowId: 'update-radarr' }, [
attempt(1, 'release_notes', notes),
attempt(2, 'gate', notes),
attempt(3, 'post_upgrade', postUpgrade),
attempt(4, 'terminal_updated', postUpgrade),
]);
expect(calls.map((call) => call.nodeId)).toEqual(['release_notes', 'post_upgrade']);
expect(totalCostUsd(calls)).toBeCloseTo(0.32);
});
});
+32 -23
View File
@@ -22,34 +22,43 @@ const cost = (value: unknown): number => (typeof value === 'number' && Number.is
/**
* The model runs a workflow run made, read from its node attempts: a model action's result carries `report`
* (app/privileged-action-executor-adapter), the release notes operation's result carries `verdict` and `model`.
* A node that passes the record on (a terminal node's result is its input) repeats that result; the same call counts once.
*/
export function workflowModelCalls(instance: { readonly id: string; readonly workflowId: string }, attempts: readonly NodeAttempt[]): WorkflowModelCall[] {
return attempts.flatMap((attempt): WorkflowModelCall[] => {
const result = record(attempt.result);
const at = attempt.finishedAt ?? attempt.startedAt;
const base = { instanceId: instance.id, workflowId: instance.workflowId, nodeId: attempt.nodeId, at };
const action = text(result['action']);
if (action === 'model.postUpgrade' || action === 'model.repair') {
const report = record(result['report']);
if (Object.keys(report).length === 0) return [];
return [{
...base, kind: action === 'model.repair' ? 'repair' : 'postUpgrade', model: text(report['model']),
outcome: text(report['outcome']) ?? 'unknown', summary: text(report['summary']) ?? '',
actions: Array.isArray(report['actions']) ? report['actions'].filter((item): item is string => typeof item === 'string') : [],
costUsd: cost(report['costUsd']),
}];
}
// Only a release notes result that reached a model names one; "no source" and unreadable notes cost nothing.
if ('verdict' in result && text(result['model']) !== null) {
return [{
...base, kind: 'releaseNotes', model: text(result['model']), outcome: text(result['verdict']) ?? 'unknown',
summary: text(result['summary']) ?? '', actions: [], costUsd: cost(result['costUsd']),
}];
}
return [];
const seen = new Set<string>();
return attempts.flatMap((attempt) => modelCallOf(instance, attempt)).filter(({ nodeId: _nodeId, at: _at, ...call }) => {
const key = JSON.stringify(call);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function modelCallOf(instance: { readonly id: string; readonly workflowId: string }, attempt: NodeAttempt): WorkflowModelCall[] {
const result = record(attempt.result);
const at = attempt.finishedAt ?? attempt.startedAt;
const base = { instanceId: instance.id, workflowId: instance.workflowId, nodeId: attempt.nodeId, at };
const action = text(result['action']);
if (action === 'model.postUpgrade' || action === 'model.repair') {
const report = record(result['report']);
if (Object.keys(report).length === 0) return [];
return [{
...base, kind: action === 'model.repair' ? 'repair' : 'postUpgrade', model: text(report['model']),
outcome: text(report['outcome']) ?? 'unknown', summary: text(report['summary']) ?? '',
actions: Array.isArray(report['actions']) ? report['actions'].filter((item): item is string => typeof item === 'string') : [],
costUsd: cost(report['costUsd']),
}];
}
// Only a release notes result that reached a model names one; "no source" and unreadable notes cost nothing.
if ('verdict' in result && text(result['model']) !== null) {
return [{
...base, kind: 'releaseNotes', model: text(result['model']), outcome: text(result['verdict']) ?? 'unknown',
summary: text(result['summary']) ?? '', actions: [], costUsd: cost(result['costUsd']),
}];
}
return [];
}
export function totalCostUsd(calls: readonly WorkflowModelCall[]): number {
return calls.reduce((sum, call) => sum + call.costUsd, 0);
}
+1
View File
@@ -28,6 +28,7 @@ const TEMPLATE_NODES: Readonly<Record<string, BlockDescription>> = {
repair_full: { title: 'Model repairs with full access', kind: 'model', summary: 'The owner lifted the limits: the model may run any command in the project\'s containers and one-off containers, and write any valid compose.yaml.' },
verify_after_full_repair: { title: 'Check again after full-access repair', kind: 'read', summary: 'Checks the container state and the app URL after the full-access repair.' },
gate_full_repair_failed: { title: 'Full-access repair failed', kind: 'gate', summary: 'Even with full access the app is not healthy; the owner rolls back or leaves it.' },
verify_after_gave_up: { title: 'Check again after a failed repair', kind: 'read', summary: 'The model gave up; checks the container state and the app URL once more, in case the app came up by itself.' },
verify_after_repair: { title: 'Check again after repair', kind: 'read', summary: 'Checks the container state and the app URL after the model\'s repair.' },
gate_version_unknown: { title: 'Version unknown', kind: 'gate', summary: 'The running version could not be read; the owner can only skip this run.' },
gate_migration: { title: 'Approve the migration', kind: 'gate', summary: 'A model prepared a migration; the owner reviews it, gives its inputs and approves or skips it.' },
@@ -7,6 +7,7 @@ import {
PrivilegedActionVersion,
} from './privileged-action.ts';
import type { PrivilegedActionPolicy as PrivilegedActionPolicyType, PrivilegedActionResult as ActionResult } from './privileged-action.ts';
import type { PostUpgradeReason } from './post-upgrade.ts';
import { privilegedActionIdentityMatches } from './privileged-action-invocation.ts';
import type { JsonValue } from './steps.ts';
import { bindWorkflowResult, bindingInput, DataBindingV1 } from './workflow-data-binding.ts';
@@ -83,6 +84,15 @@ export function resultEvent(result: ActionResult): string {
export function createPrivilegedActionExtensionRegistry(policy?: unknown): WorkflowExtensionRegistry {
const parsedPolicy = policy === undefined ? null : PrivilegedActionPolicy.safeParse(policy);
const policyValue = parsedPolicy === null ? undefined : parsedPolicy.success ? parsedPolicy.data : null;
/** What in the run asks for a post-upgrade look: release notes are read only for an update above the app's auto level. */
function postUpgradeReasons(context: JsonValue, migrated: boolean): PostUpgradeReason[] {
const reasons: PostUpgradeReason[] = [];
if (isRecord(jsonPointer(context, '/releaseNotes'))) reasons.push('approved_update');
if (jsonPointer(context, '/imageSelection/change') === 'major') reasons.push('major');
if (migrated) reasons.push('migration');
return reasons;
}
function deriveMergedSelected(
parsedConfig: PrivilegedActionNodeConfig,
context: JsonValue,
@@ -135,7 +145,7 @@ function deriveMergedSelected(
const plan = jsonPointer(context, '/migrationPlan/plan');
const digest = jsonPointer(context, '/migrationPlan/planDigest');
if (isRecord(plan)) {
if (!('composeYaml' in result) && typeof plan.composeYaml === 'string') result['composeYaml'] = plan.composeYaml;
if (!('composeYaml' in result) && (typeof plan.composeYaml === 'string' || plan.composeYaml === null)) result['composeYaml'] = plan.composeYaml;
if (!('inputs' in result) && Array.isArray(plan.inputs)) {
result['inputs'] = plan.inputs.filter(isRecord).map((input) => ({ name: input.name ?? null, target: input.target ?? null }));
}
@@ -151,6 +161,7 @@ function deriveMergedSelected(
result['migration'] = { summary: plan.summary, stepsAfter: plan.stepsAfter };
}
}
if (action === 'model.postUpgrade' && !('reasons' in result)) result['reasons'] = postUpgradeReasons(context, 'migration' in result);
// Profile & Reason for model actions
if (action === 'model.repair' || action === 'model.postUpgrade') {
+16 -2
View File
@@ -414,7 +414,20 @@ export const COMPONENT_UPDATE_WORKFLOW_DOCUMENT_V2: WorkflowDocumentV2 = {
componentPointer: '/component',
binding: { schemaVersion: 1, inputPointer: '/repairRequest', outputPointer: '/repair' },
},
routes: { repaired: 'verify_after_repair', gave_up: 'gate_repair_failed' },
routes: { repaired: 'verify_after_repair', gave_up: 'verify_after_gave_up' },
},
{
// The app may have come up on its own while the model tried (a slow recreate, or a model that could not even start).
nodeId: 'verify_after_gave_up',
type: 'extension',
kind: 'componentOperation',
schemaVersion: 1,
config: {
operation: 'verify',
version: 1,
binding: { schemaVersion: 1, inputPointer: '', outputPointer: '/verifyAfterGaveUp' },
},
routes: { healthy: 'terminal_updated', unhealthy: 'gate_repair_failed' },
},
{
nodeId: 'repair_full',
@@ -427,7 +440,7 @@ export const COMPONENT_UPDATE_WORKFLOW_DOCUMENT_V2: WorkflowDocumentV2 = {
componentPointer: '/component',
binding: { schemaVersion: 1, inputPointer: '/repairFullRequest', outputPointer: '/repairFull' },
},
routes: { repaired: 'verify_after_full_repair', gave_up: 'gate_full_repair_failed' },
routes: { repaired: 'verify_after_full_repair', gave_up: 'verify_after_full_repair' },
},
{
nodeId: 'verify_after_full_repair',
@@ -569,4 +582,5 @@ export const COMPONENT_UPDATE_TEMPLATE_DIGESTS: readonly string[] = [
'b02adacd0f04c6182de0754ee0b49c9bd5ac9f63b9c7a7dd128182b9af161fa7',
'ef8603ae8d3e05ce27ef63df1ce4d7cb3f5e0616c41ffc150fd101f7cd08b257',
'c171a27984de5df6de11f6e70f4c2fb99bb402b763485da3ac70862eda0ffec5',
'a614c13e7bfbe8f3494d25f04a8f7b888509b41fa2ae56ea7ea336589777e5ec',
];
+4 -1
View File
@@ -2,7 +2,7 @@ import { HostStatus } from '../domain/host-status.ts';
import { NotificationSettings } from '../domain/notification-settings.ts';
import { z } from 'zod';
import { SkillContent } from '../domain/skill.ts';
import { AutoLevel, Executor, Json } from '../domain/steps.ts';
import { AutoLevel, Executor, Json, PostUpgradeWhen } from '../domain/steps.ts';
import {
WorkflowDraft,
PublishedWorkflowVersion,
@@ -65,6 +65,7 @@ export const AppConfigResponse = z.object({
migrateExecutor: Executor.nullable(),
promptHint: z.string().nullable(),
postUpgradeExecutor: Executor.nullable(),
postUpgradeWhen: PostUpgradeWhen,
notes: z.string().nullable(),
apply: z.boolean(),
});
@@ -300,6 +301,8 @@ export const WorkflowModelCallResponse = z.object({
});
export type WorkflowModelCallResponse = z.infer<typeof WorkflowModelCallResponse>;
export { ModelAuthLoginStart, ModelAuthStatus } from '../domain/model-auth.ts';
/** GET /api/model-calls: the newest model runs of workflow runs, and what each model cost over them. */
export const ModelCallsResponse = z.object({
calls: z.array(WorkflowModelCallResponse),
+41
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it } from 'vitest';
import type { ClaudeRunRequest } from '../app/model/claude.ts';
import { createHttpModelClient } from '../infra/http-model-client.ts';
import { ModelAuthError } from '../domain/model-auth.ts';
import { ModelError } from '../domain/ports.ts';
import type { AddressInfo } from 'node:net';
import { buildModelServer } from './model-server.ts';
@@ -24,6 +25,46 @@ async function live(run: Run, heartbeatMs?: number) {
const RUN_BODY = JSON.stringify({ executor: 'sonnet', profile: 'tools', prompt: 'x', output_schema: { type: 'object' } });
describe('model sidecar sign-in routes', () => {
const auth = {
status: () => Promise.resolve({ loggedIn: true, email: 'me@example.com', organization: null, method: 'claude.ai' }),
startLogin: () => Promise.resolve({ url: 'https://claude.com/x' }),
submitCode: () => Promise.reject(new ModelAuthError('the code was not accepted')),
cancelLogin: () => Promise.resolve(),
logout: () => Promise.resolve({ loggedIn: false, email: null, organization: null, method: null }),
};
const run: Run = () => Promise.resolve({ ok: true, output: {}, costUsd: 0, turns: 1 });
it('is 404 without a sign-in', async () => {
const app = await buildModelServer({ timeoutMs: 60_000, run });
closers.push(() => app.close());
expect((await app.inject('/auth/status')).statusCode).toBe(404);
});
it('lets the web app use /auth/* but not /run, and the driver both', async () => {
const app = await buildModelServer({ timeoutMs: 60_000, run, auth, isDriver: () => Promise.resolve(false), mayManageLogin: () => Promise.resolve(true) });
closers.push(() => app.close());
expect((await app.inject('/auth/status')).json()).toMatchObject({ email: 'me@example.com' });
expect((await app.inject({ method: 'POST', url: '/auth/login' })).json()).toEqual({ url: 'https://claude.com/x' });
expect((await app.inject({ method: 'POST', url: '/run', payload: {} })).statusCode).toBe(403);
});
it('keeps /auth/* from callers that may not manage the login', async () => {
const app = await buildModelServer({ timeoutMs: 60_000, run, auth, isDriver: () => Promise.resolve(false), mayManageLogin: () => Promise.resolve(false) });
closers.push(() => app.close());
expect((await app.inject('/auth/status')).statusCode).toBe(403);
});
it('answers a refused code with 409 and its message, and a missing code with 400', async () => {
const app = await buildModelServer({ timeoutMs: 60_000, run, auth });
closers.push(() => app.close());
const refused = await app.inject({ method: 'POST', url: '/auth/code', payload: { code: 'abcdefgh' } });
expect(refused.statusCode).toBe(409);
expect(refused.json()).toMatchObject({ message: 'the code was not accepted' });
expect((await app.inject({ method: 'POST', url: '/auth/code', payload: {} })).statusCode).toBe(400);
});
});
describe('model sidecar over HTTP', () => {
it('answers /run only to the driver when an address guard is configured', async () => {
const app = await buildModelServer({ timeoutMs: 60_000, run: () => Promise.resolve({ ok: true, output: {}, costUsd: 0, turns: 1 }), isDriver: () => Promise.resolve(false) });
+32 -3
View File
@@ -1,7 +1,9 @@
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import type { FastifyInstance, FastifyReply } from 'fastify';
import type { ClaudeRunRequest } from '../app/model/claude.ts';
import { ModelRunRequest } from '../domain/model-api.ts';
import { ModelAuthCodeRequest, ModelAuthError } from '../domain/model-auth.ts';
import type { ModelAuth } from '../domain/model-auth.ts';
import { answerWhenDone } from './keep-alive.ts';
import type { ModelRunResponse, ModelSidecarRunResult } from '../domain/model-api.ts';
@@ -19,6 +21,10 @@ export interface ModelServerDeps {
readonly heartbeatMs?: number;
readonly logger?: boolean;
readonly isDriver?: (address: string) => Promise<boolean>;
/** Who may use /auth/* (the driver and the web app); defaults to `isDriver`. */
readonly mayManageLogin?: (address: string) => Promise<boolean>;
/** Sign-in of the owner's account; without it /auth/* is 404. */
readonly auth?: ModelAuth;
}
export async function buildModelServer(deps: ModelServerDeps): Promise<FastifyInstance> {
@@ -27,12 +33,35 @@ export async function buildModelServer(deps: ModelServerDeps): Promise<FastifyIn
let busy = false;
app.addHook('onRequest', async (request, reply) => {
if (deps.isDriver === undefined || request.url === '/health') return;
if (!(await deps.isDriver(request.socket.remoteAddress ?? ''))) {
if (request.url === '/health') return;
const allowed = request.url.startsWith('/auth/') ? (deps.mayManageLogin ?? deps.isDriver) : deps.isDriver;
if (allowed === undefined) return;
if (!(await allowed(request.socket.remoteAddress ?? ''))) {
await reply.code(403).send({ error: 'forbidden', message: 'only the driver may call this' });
}
});
const { auth } = deps;
if (auth !== undefined) {
const guarded = async (reply: FastifyReply, work: () => Promise<unknown>) => {
try {
return await work();
} catch (cause) {
if (!(cause instanceof ModelAuthError)) throw cause;
return reply.code(409).send({ error: 'login_failed', message: cause.message });
}
};
app.get('/auth/status', (_request, reply) => guarded(reply, () => auth.status()));
app.post('/auth/login', (_request, reply) => guarded(reply, () => auth.startLogin()));
app.post('/auth/code', (request, reply) => {
const parsed = ModelAuthCodeRequest.safeParse(request.body);
if (!parsed.success) return reply.code(400).send({ error: 'bad_request', message: 'paste the code the sign-in page showed' });
return guarded(reply, () => auth.submitCode(parsed.data.code));
});
app.post('/auth/cancel', (_request, reply) => guarded(reply, async () => (await auth.cancelLogin(), { ok: true })));
app.post('/auth/logout', (_request, reply) => guarded(reply, () => auth.logout()));
}
app.post('/run', async (request, reply) => {
const parsed = ModelRunRequest.safeParse(request.body);
if (!parsed.success) return reply.code(400).send({ error: 'bad_request', message: parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ') });
+36 -1
View File
@@ -4,13 +4,15 @@ import { join } from 'node:path';
import type { FastifyInstance, InjectOptions } from 'fastify';
import { afterEach, describe, expect, it } from 'vitest';
import type { z } from 'zod';
import { ModelAuthError } from '../domain/model-auth.ts';
import type { ModelAuth, ModelAuthStatus } from '../domain/model-auth.ts';
import type { AppConfigStore } from '../domain/ports.ts';
import { RegistryError } from '../domain/ports.ts';
import { seedTestApps } from '../testing/test-apps.ts';
import { sqliteServerDeps } from '../testing/server-deps.ts';
import { FakeRegistryClient } from '../testing/fakes.ts';
import type { ChangeHub } from './change-hub.ts';
import { AppConfigResponse, AppsResponse, BuildResponse, ErrorResponse, TagPreviewResponse } from './contract.ts';
import { AppConfigResponse, AppsResponse, BuildResponse, ErrorResponse, ModelAuthLoginStart, ModelAuthStatus as ModelAuthStatusResponse, TagPreviewResponse } from './contract.ts';
import { buildServer } from './server.ts';
import type { ServerDeps } from './server.ts';
@@ -109,6 +111,39 @@ describe('apps', () => {
});
});
describe('Anthropic account', () => {
const SIGNED_IN: ModelAuthStatus = { loggedIn: true, email: 'me@example.com', organization: null, method: 'claude.ai' };
const fakeAuth = (overrides: Partial<ModelAuth> = {}): ModelAuth => ({
status: () => Promise.resolve(SIGNED_IN),
startLogin: () => Promise.resolve({ url: 'https://claude.com/oauth/authorize?x=1' }),
submitCode: () => Promise.resolve(SIGNED_IN),
cancelLogin: () => Promise.resolve(),
logout: () => Promise.resolve({ ...SIGNED_IN, loggedIn: false, email: null, method: null }),
...overrides,
});
it('is 503 without a Claude sidecar', async () => {
const { app } = await fixture();
expect(await json(app, ErrorResponse, '/api/model-auth', 503)).toMatchObject({ error: 'unavailable' });
});
it('shows the account, starts a sign-in, finishes it with the code and signs out', async () => {
const seen: string[] = [];
const { app } = await fixture({ claudeAuth: fakeAuth({ submitCode: (code) => (seen.push(code), Promise.resolve(SIGNED_IN)) }) });
expect(await json(app, ModelAuthStatusResponse, '/api/model-auth')).toMatchObject({ loggedIn: true, email: 'me@example.com' });
expect(await json(app, ModelAuthLoginStart, { method: 'POST', url: '/api/model-auth/login', payload: {} })).toEqual({ url: 'https://claude.com/oauth/authorize?x=1' });
await json(app, ModelAuthStatusResponse, { method: 'POST', url: '/api/model-auth/code', payload: { code: ' abcdefgh#xyz ' } });
expect(seen).toEqual(['abcdefgh#xyz']);
expect(await json(app, ModelAuthStatusResponse, { method: 'POST', url: '/api/model-auth/logout', payload: {} })).toMatchObject({ loggedIn: false });
});
it('is 400 for a missing code and 502 with the sidecar\'s message when the sign-in fails', async () => {
const { app } = await fixture({ claudeAuth: fakeAuth({ submitCode: () => Promise.reject(new ModelAuthError('the code was not accepted: start again')) }) });
expect(await json(app, ErrorResponse, { method: 'POST', url: '/api/model-auth/code', payload: { code: '' } }, 400)).toMatchObject({ error: 'invalid_request' });
expect(await json(app, ErrorResponse, { method: 'POST', url: '/api/model-auth/code', payload: { code: 'abcdefgh' } }, 502)).toMatchObject({ message: 'the code was not accepted: start again' });
});
});
describe('tag preview', () => {
const preview = (app: string, pattern: string) => ({ method: 'POST' as const, url: `/api/apps/${app}/tag-preview`, payload: { pattern } });
+27
View File
@@ -55,6 +55,8 @@ import {
} from '../domain/workflow-graph.ts';
import { OwnerDecisionV1, OWNER_DECISION_SIGNAL, OwnerDecisionAction } from '../domain/owner-decision.ts';
import { MODEL_PROFILE_ID_REGEX } from '../domain/model-profile.ts';
import { ModelAuthCodeRequest, ModelAuthError } from '../domain/model-auth.ts';
import type { ModelAuth } from '../domain/model-auth.ts';
import { ReleaseNotesSources } from '../domain/release-notes.ts';
import {
FunctionOutcome,
@@ -116,6 +118,8 @@ export interface ServerDeps {
readonly startSystemRun?: (workflowId: string) => StartRunResult;
/** Read-only registry access for the tag pattern preview; without it that endpoint is 503. */
readonly registry?: RegistryClient;
/** The Claude sidecar's sign-in of the owner's account; without it /api/model-auth is 503. */
readonly claudeAuth?: ModelAuth;
}
const UNKNOWN_BUILD: BuildInfo = { branch: null, number: null, revision: null };
@@ -730,6 +734,29 @@ export async function buildServer(deps: ServerDeps): Promise<FastifyInstance> {
return reply.send({ ...timeline, modelCalls, costUsd: totalCostUsd(modelCalls), modelSessions });
});
// The owner's Anthropic account behind the Claude sidecar: status, a browser sign-in finished with a pasted code, sign-out.
{
const withAuth = async (reply: FastifyReply, work: (auth: ModelAuth) => Promise<unknown>) => {
const auth = deps.claudeAuth;
if (!auth) return fail(reply, 503, 'unavailable', 'the Claude sidecar is not configured');
try {
return await work(auth);
} catch (cause) {
if (!(cause instanceof ModelAuthError)) throw cause;
return fail(reply, 502, 'model_auth_failed', cause.message);
}
};
app.get('/api/model-auth', (_request, reply) => withAuth(reply, (auth) => auth.status()));
app.post('/api/model-auth/login', (_request, reply) => withAuth(reply, (auth) => auth.startLogin()));
app.post('/api/model-auth/code', (request, reply) => {
const body = ModelAuthCodeRequest.safeParse(request.body);
if (!body.success) return fail(reply, 400, 'invalid_request', 'paste the code the sign-in page showed');
return withAuth(reply, (auth) => auth.submitCode(body.data.code));
});
app.post('/api/model-auth/cancel', (_request, reply) => withAuth(reply, async (auth) => (await auth.cancelLogin(), { ok: true })));
app.post('/api/model-auth/logout', (_request, reply) => withAuth(reply, (auth) => auth.logout()));
}
const ModelCallsQuery = z.object({ limit: z.coerce.number().int().min(1).max(500).default(50) });
// The model runs of the latest workflow runs, newest first, and per model how many and what they cost.
+13
View File
@@ -95,6 +95,19 @@ describe('HTTP executor client failures', () => {
expect(seen).toEqual([{ url: 'http://executor:8081/agent/compose', authorization: `Bearer ${'a'.repeat(32)}` }]);
});
it('reads a service’s logs with the session token, like the model’s hl logs', async () => {
const seen: { url: string; authorization: string | null; body: unknown }[] = [];
const client = createHttpExecutorClient({
baseUrl: 'http://executor:8081',
fetch: (input, init) => {
seen.push({ url: input instanceof Request ? input.url : input.toString(), authorization: new Headers(init?.headers).get('authorization'), body: JSON.parse(init?.body as string) });
return answering({ outcome: 'done', result: { text: 'started\n' } })();
},
});
expect(await client.sessionLogs({ token: 'a'.repeat(32), service: 'sonarr', tail: 200 })).toEqual({ outcome: 'done', result: { text: 'started\n' } });
expect(seen).toEqual([{ url: 'http://executor:8081/agent/logs', authorization: `Bearer ${'a'.repeat(32)}`, body: { service: 'sonarr', tail: 200 } }]);
});
it('an unreachable executor is unavailable, naming where it looked', async () => {
const client = createHttpExecutorClient({ baseUrl: 'http://executor:8081', fetch: () => Promise.reject(new TypeError('fetch failed', { cause: { code: 'ECONNREFUSED' } })) });
expect(await client.inspect({ project: 'jackett', service: 'jackett' })).toEqual({ outcome: 'unavailable', detail: 'executor unreachable at http://executor:8081/inspect: fetch failed (ECONNREFUSED)' });
+1
View File
@@ -74,5 +74,6 @@ export function createHttpExecutorClient(options: HttpExecutorClientOptions): Ex
closeSession: (request, signal) => post('/session/close', request, SessionCloseResponse, signal),
sessionLog: (request, signal) => post('/session/log', request, SessionLogResponse, signal),
sessionCompose: ({ token }, signal) => post('/agent/compose', {}, AgentTextResponse, signal, token),
sessionLogs: ({ token, ...request }, signal) => post('/agent/logs', request, AgentTextResponse, signal, token),
};
}
+38
View File
@@ -0,0 +1,38 @@
import { ModelAuthError, ModelAuthLoginStart, ModelAuthStatus } from '../domain/model-auth.ts';
import type { ModelAuth } from '../domain/model-auth.ts';
import { describeError } from '../domain/mask.ts';
// The web app's side of the sidecar's /auth/*: the owner's sign-in. Failures are ModelAuthError with a readable message.
export function createHttpModelAuthClient(options: { readonly baseUrl: string; readonly fetch?: typeof fetch }): ModelAuth {
const doFetch = options.fetch ?? fetch;
async function request<T>(schema: { parse(value: unknown): T }, path: string, method: 'GET' | 'POST', body?: unknown): Promise<T> {
let response: Response;
try {
response = await doFetch(new URL(path, options.baseUrl), {
method,
...(body === undefined ? {} : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),
signal: AbortSignal.timeout(90_000),
});
} catch (cause) {
throw new ModelAuthError(`model sidecar unreachable: ${describeError(cause)}`);
}
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
const message = (payload as { message?: unknown } | null)?.message;
throw new ModelAuthError(typeof message === 'string' ? message : `model sidecar answered HTTP ${String(response.status)}`);
}
try {
return schema.parse(payload);
} catch {
throw new ModelAuthError('model sidecar answered in an unexpected shape');
}
}
return {
status: () => request(ModelAuthStatus, '/auth/status', 'GET'),
startLogin: () => request(ModelAuthLoginStart, '/auth/login', 'POST', {}),
submitCode: (code) => request(ModelAuthStatus, '/auth/code', 'POST', { code }),
cancelLogin: async () => void (await request({ parse: (value) => value }, '/auth/cancel', 'POST', {})),
logout: () => request(ModelAuthStatus, '/auth/logout', 'POST', {}),
};
}
+41
View File
@@ -137,6 +137,47 @@ describe('createHttpRegistryClient', () => {
});
});
describe('HTTP 429', () => {
const tooMany = (headers: Record<string, string> = {}) => new Response('', { status: 429, headers });
it('waits and asks again, so a short rate limit does not fail the lookup', async () => {
const waits: number[] = [];
const { fetchFn, calls } = fakeRegistry({ [HUB]: [tooMany(), tooMany(), json({ tags: ['1'] })] });
const client = createHttpRegistryClient({ fetch: fetchFn, rateLimitWaitsMs: [5_000, 30_000], sleep: (ms) => (waits.push(ms), Promise.resolve()) });
expect(await client.listTags('nextcloud')).toEqual(['1']);
expect(waits).toEqual([5_000, 30_000]);
expect(calls).toHaveLength(3);
});
it('waits as long as Retry-After asks when that is longer, up to five minutes', async () => {
const waits: number[] = [];
const { fetchFn } = fakeRegistry({ [HUB]: [tooMany({ 'retry-after': '20' }), tooMany({ 'retry-after': '3600' }), json({ tags: [] })] });
await createHttpRegistryClient({ fetch: fetchFn, rateLimitWaitsMs: [5_000, 30_000], sleep: (ms) => (waits.push(ms), Promise.resolve()) }).listTags('nextcloud');
expect(waits).toEqual([20_000, 300_000]);
});
it('does not ask again unless pauses are given', async () => {
const { fetchFn, calls } = fakeRegistry({ [HUB]: tooMany() });
await createHttpRegistryClient({ fetch: fetchFn }).listTags('nextcloud').catch(() => undefined);
expect(calls).toHaveLength(1);
});
it('gives up as rate_limited once the pauses are used', async () => {
const { fetchFn, calls } = fakeRegistry({ [HUB]: tooMany() });
const error = await createHttpRegistryClient({ fetch: fetchFn, rateLimitWaitsMs: [1] }).listTags('nextcloud').catch((cause: unknown) => cause);
expect((error as RegistryError).kind).toBe('rate_limited');
expect(calls).toHaveLength(2);
});
it('an abort during the pause is rethrown as it is', async () => {
const controller = new AbortController();
const { fetchFn } = fakeRegistry({ [HUB]: tooMany() });
const running = createHttpRegistryClient({ fetch: fetchFn, rateLimitWaitsMs: [5_000] }).listTags('nextcloud', controller.signal);
setTimeout(() => controller.abort(new Error('step time limit')), 20);
await expect(running).rejects.toThrow('step time limit');
});
});
describe('time', () => {
const hanging = (_input: string | URL | Request, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => init?.signal?.addEventListener('abort', () => reject(init.signal?.reason as Error)));
+26 -1
View File
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
import { z } from 'zod';
import { findPlatformEntry, parseImageRef, platformNames } from '../domain/image-ref.ts';
import type { IndexEntry } from '../domain/image-ref.ts';
@@ -14,11 +15,16 @@ export interface HttpRegistryOptions {
readonly maxPages?: number;
/** Largest response body we read (tag pages, manifests, image configs). */
readonly maxBodyBytes?: number;
/** Pauses before asking again after HTTP 429, one retry per entry; none by default. ghcr.io answers 429 now and then and is fine later. */
readonly rateLimitWaitsMs?: readonly number[];
readonly sleep?: (ms: number, signal: AbortSignal | undefined) => Promise<void>;
}
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_PAGES = 50;
const DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024;
/** A longer `Retry-After` than this is not waited out. */
const MAX_RATE_LIMIT_WAIT_MS = 300_000;
const MANIFEST_ACCEPT = [
'application/vnd.oci.image.index.v1+json',
@@ -58,6 +64,8 @@ export function createHttpRegistryClient(options: HttpRegistryOptions = {}): Reg
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
const rateLimitWaitsMs = options.rateLimitWaitsMs ?? [];
const sleep = options.sleep ?? ((ms: number, signal: AbortSignal | undefined) => delay(ms, undefined, signal === undefined ? {} : { signal }));
/** One repository on one registry: the token is fetched once and reused by every request. */
function openSession(location: RepoLocation, signal: AbortSignal | undefined) {
@@ -65,8 +73,25 @@ export function createHttpRegistryClient(options: HttpRegistryOptions = {}): Reg
const what = `${location.registry}/${location.path}`;
let token: string | null = null;
/** One request with the caller's abort and our own time limit. A caller abort is rethrown untouched. */
/** `request`, asked again after HTTP 429 as long as `rateLimitWaitsMs` has pauses left; the last 429 is returned. */
async function request(url: string, authorization: string | null, accept?: string): Promise<Response> {
for (let attempt = 0; ; attempt++) {
const response = await requestOnce(url, authorization, accept);
const wait = rateLimitWaitsMs[attempt];
if (response.status !== 429 || wait === undefined) return response;
const retryAfterMs = Number(response.headers.get('retry-after')) * 1000;
const pause = Number.isFinite(retryAfterMs) && retryAfterMs > wait ? Math.min(retryAfterMs, MAX_RATE_LIMIT_WAIT_MS) : wait;
void response.body?.cancel().catch(() => undefined);
try {
await sleep(pause, signal);
} catch (cause) {
throw signal?.aborted === true ? (signal.reason as Error) : cause;
}
}
}
/** One request with the caller's abort and our own time limit. A caller abort is rethrown untouched. */
async function requestOnce(url: string, authorization: string | null, accept?: string): Promise<Response> {
const timeout = AbortSignal.timeout(timeoutMs);
const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
const headers: Record<string, string> = {};
+86
View File
@@ -0,0 +1,86 @@
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ModelAuthError } from '../../domain/model-auth.ts';
import { createClaudeAuth, parseAuthStatus } from './claude-auth.ts';
// A stand-in for the CLI: `auth status` reads state.json, `auth login` prints the sign-in address in a terminal hyperlink
// (as the real one does) and waits for a code on stdin, `auth logout` forgets the account.
const FAKE_CLAUDE = `#!/usr/bin/env node
const fs = require('node:fs');
const state = process.env.CLAUDE_CONFIG_DIR + '/state.json';
const [, , group, action] = process.argv;
if (group === 'auth' && action === 'status') {
process.stdout.write(fs.existsSync(state) ? fs.readFileSync(state, 'utf8') : JSON.stringify({ loggedIn: false, authMethod: 'none' }));
} else if (group === 'auth' && action === 'logout') {
fs.rmSync(state, { force: true });
} else if (group === 'auth' && action === 'login') {
const url = 'https://claude.com/cai/oauth/authorize?state=abc';
process.stdout.write('Opening browser to sign in…\\nIf the browser did not open, visit: \\u001b]8;;' + url + '\\u0007' + url + '\\u001b]8;;\\u0007\\nPaste code here if prompted > ');
process.stdin.once('data', (chunk) => {
if (chunk.toString().trim() === 'good-code') fs.writeFileSync(state, JSON.stringify({ loggedIn: true, authMethod: 'claude.ai', email: 'new@example.com', orgName: 'New Org' }));
process.exit(0);
});
}
`;
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'claude-auth-'));
await writeFile(join(dir, 'claude'), FAKE_CLAUDE);
await chmod(join(dir, 'claude'), 0o755);
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
const make = () => createClaudeAuth({ claudeBin: join(dir, 'claude'), configDir: dir, path: process.env['PATH'] ?? '', urlTimeoutMs: 5000, codeTimeoutMs: 5000 });
describe('parseAuthStatus', () => {
it('reads the signed-in account', () => {
expect(parseAuthStatus('{"loggedIn":true,"authMethod":"claude.ai","email":"a@b.c","orgName":"Org"}')).toEqual({ loggedIn: true, email: 'a@b.c', organization: 'Org', method: 'claude.ai' });
});
it('treats anything else as signed out', () => {
for (const out of ['', 'oops', '[]', '{"loggedIn":false,"authMethod":"none"}']) expect(parseAuthStatus(out).loggedIn).toBe(false);
});
});
describe('Claude sign-in', () => {
it('starts as signed out', async () => {
expect(await make().status()).toEqual({ loggedIn: false, email: null, organization: null, method: null });
});
it('returns the sign-in address, then signs in with the pasted code', async () => {
const auth = make();
expect(await auth.startLogin()).toEqual({ url: 'https://claude.com/cai/oauth/authorize?state=abc' });
expect(await auth.submitCode('good-code')).toEqual({ loggedIn: true, email: 'new@example.com', organization: 'New Org', method: 'claude.ai' });
});
it('rejects a wrong code and needs a new start', async () => {
const auth = make();
await auth.startLogin();
await expect(auth.submitCode('wrong-code')).rejects.toThrow(/not accepted/);
await expect(auth.submitCode('good-code')).rejects.toThrow(ModelAuthError);
});
it('refuses a code when no sign-in was started or it was cancelled', async () => {
const auth = make();
await expect(auth.submitCode('good-code')).rejects.toThrow(/no sign-in in progress/);
await auth.startLogin();
await auth.cancelLogin();
await expect(auth.submitCode('good-code')).rejects.toThrow(/no sign-in in progress/);
});
it('signs out', async () => {
const auth = make();
await auth.startLogin();
await auth.submitCode('good-code');
expect((await auth.logout()).loggedIn).toBe(false);
});
it('says so when the CLI is missing', async () => {
const auth = createClaudeAuth({ claudeBin: join(dir, 'nope'), configDir: dir, path: '' });
await expect(auth.status()).rejects.toThrow(/cannot run claude/);
});
});
+126
View File
@@ -0,0 +1,126 @@
import { spawn } from 'node:child_process';
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
import { ModelAuthError } from '../../domain/model-auth.ts';
import type { ModelAuth, ModelAuthStatus } from '../../domain/model-auth.ts';
// `claude auth login` without a terminal prints the sign-in URL and waits for the code the page shows on stdin
// ("Paste code here"). This keeps that one process between the two HTTP calls.
export interface ClaudeAuthOptions {
readonly claudeBin: string;
/** CLAUDE_CONFIG_DIR, also HOME: where the login lives. */
readonly configDir: string;
readonly path: string;
/** How long to wait for the URL / for the CLI to accept the code. */
readonly urlTimeoutMs?: number;
readonly codeTimeoutMs?: number;
}
// Printable ASCII only: the address ends at the first space or terminal control character (the CLI wraps it in a hyperlink escape).
const URL_PATTERN = /https:\/\/[\x21-\x7e]+/;
const SIGNED_OUT: ModelAuthStatus = { loggedIn: false, email: null, organization: null, method: null };
function text(value: unknown): string | null {
return typeof value === 'string' && value !== '' ? value : null;
}
export function parseAuthStatus(stdout: string): ModelAuthStatus {
let raw: unknown;
try {
raw = JSON.parse(stdout);
} catch {
return SIGNED_OUT;
}
if (typeof raw !== 'object' || raw === null) return SIGNED_OUT;
const fields = raw as Record<string, unknown>;
if (fields['loggedIn'] !== true) return SIGNED_OUT;
return { loggedIn: true, email: text(fields['email']), organization: text(fields['orgName']) ?? text(fields['organization']), method: text(fields['authMethod']) };
}
export function createClaudeAuth(options: ClaudeAuthOptions): ModelAuth {
const urlTimeoutMs = options.urlTimeoutMs ?? 20_000;
const codeTimeoutMs = options.codeTimeoutMs ?? 45_000;
const env = { PATH: options.path, HOME: options.configDir, CLAUDE_CONFIG_DIR: options.configDir, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', BROWSER: 'true' };
let pending: { readonly child: ChildProcessWithoutNullStreams; readonly exited: Promise<number> } | null = null;
const launch = (args: readonly string[]) => spawn(options.claudeBin, [...args], { cwd: options.configDir, env, stdio: ['pipe', 'pipe', 'pipe'] });
async function oneShot(args: readonly string[]): Promise<string> {
const child = launch(args);
let out = '';
child.stdout.on('data', (chunk: Buffer) => (out += chunk.toString('utf8')));
child.stdin.end();
await new Promise<void>((resolve, reject) => {
child.once('error', reject);
child.once('close', () => resolve());
}).catch((cause: unknown) => {
throw new ModelAuthError(`cannot run claude: ${cause instanceof Error ? cause.message : String(cause)}`);
});
return out;
}
const status = async (): Promise<ModelAuthStatus> => parseAuthStatus(await oneShot(['auth', 'status', '--json']));
function drop(): void {
pending?.child.kill('SIGTERM');
pending = null;
}
return {
status,
async startLogin() {
drop();
const child = launch(['auth', 'login', '--claudeai']);
child.stdin.on('error', () => undefined);
const exited = new Promise<number>((resolve) => child.once('close', (code) => resolve(code ?? -1)));
child.once('error', () => undefined);
pending = { child, exited };
let output = '';
const url = await new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => reject(new ModelAuthError('claude printed no sign-in address in time')), urlTimeoutMs);
const look = (chunk: Buffer) => {
output += chunk.toString('utf8');
const found = URL_PATTERN.exec(output);
if (found !== null && output.length > found.index + found[0].length) {
clearTimeout(timer);
resolve(found[0]);
}
};
child.stdout.on('data', look);
child.stderr.on('data', look);
void exited.then(() => {
clearTimeout(timer);
reject(new ModelAuthError('claude stopped before it printed a sign-in address'));
});
}).catch((cause: unknown) => {
drop();
throw cause;
});
return { url };
},
async submitCode(code) {
const current = pending;
if (current === null) throw new ModelAuthError('no sign-in in progress: start again');
pending = null;
current.child.stdin.write(`${code.trim()}\n`);
const timeout = new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), codeTimeoutMs).unref());
const result = await Promise.race([current.exited, timeout]);
if (result === 'timeout') {
current.child.kill('SIGTERM');
throw new ModelAuthError('claude did not accept the code in time: start again');
}
const now = await status();
if (!now.loggedIn) throw new ModelAuthError('the code was not accepted: start again and paste the new code');
return now;
},
cancelLogin() {
drop();
return Promise.resolve();
},
async logout() {
drop();
await oneShot(['auth', 'logout']);
return status();
},
};
}
+1 -1
View File
@@ -16,7 +16,7 @@ export interface ModelDispatcherOptions {
/**
* Sends each model request to the sidecar of its profile's provider (Claude, Codex). The profile is the pinned
* `profileRef` of a run's snapshot, or else the latest profile named like the request's executor (`sonnet`, `opus`: the
* `profileRef` of a run's snapshot, or else the latest profile named like the request's executor (`haiku`, `sonnet`, `opus`: the
* app's configuration names the model by profile id). There is no default provider and no fallback: an unknown
* profile or a provider without a sidecar is an error, and one provider's failure never moves the request to another.
* The profile's timeout and cost cap apply to the run.
@@ -82,6 +82,58 @@ describe('executor-backed privileged action adapter', () => {
}
});
describe('when the post-upgrade model runs', () => {
const nothingToDo = () => new FakeModelExecutor().reply({ output: { outcome: 'nothing_to_do', summary: 'Nothing to do.', actions: [] }, costUsd: 0.2 });
const withLogs = (text: string) => session().on('sessionLogs', { outcome: 'done', result: { text } });
it('off: no session and no model, the hash comes from the service', async () => {
const client = new FakeExecutorClient()
.on('inspect', { outcome: 'done', result: { project: 'jackett', service: 'jackett', manifest_sha256: 'd'.repeat(64), env_sha256: null, manifest_image: PINNED, container: null } });
const model = nothingToDo();
const result = await createExecutorBackedPrivilegedActionExecutor({ executor: client, modelExecutor: model, prompts: prompts() }).execute({ ...postUpgrade, when: 'off', reasons: ['major'] });
expect(result).toEqual({ ...target, action: 'model.postUpgrade', version: 1, state: 'completed', manifestChanged: false, manifestSha256: 'd'.repeat(64), reasons: ['major'], skipped: 'off' });
expect(model.requests).toEqual([]);
expect(client.calls.map((call) => call.op)).toEqual(['inspect']);
});
it('when needed, with nothing in the run asking and clean logs: reads the logs and stops there', async () => {
const client = withLogs('[migrations] no migrations found\nStarted.\n');
const model = nothingToDo();
const result = await createExecutorBackedPrivilegedActionExecutor({ executor: client, modelExecutor: model, prompts: prompts() }).execute({ ...postUpgrade, when: 'when_needed', reasons: [] });
expect(result).toMatchObject({ state: 'completed', manifestChanged: false, manifestSha256: 'c'.repeat(64), reasons: [], skipped: 'not_needed' });
expect(result).not.toHaveProperty('report');
expect(model.requests).toEqual([]);
expect(client.calls.map((call) => [call.op, call.request])).toEqual([
['openSession', { project: 'jackett', service: 'jackett', mode: 'observe' }],
['sessionLogs', { token: 'tok', service: 'jackett', tail: 200 }],
['closeSession', { token: 'tok' }],
]);
});
it('when needed, errors in the logs (or logs it cannot read) bring the model in', async () => {
for (const client of [withLogs('Started.\nERROR Unable to open database\n'), session().on('sessionLogs', { outcome: 'failed', detail: 'no container', manifest_replaced: false })]) {
const model = nothingToDo();
const result = await createExecutorBackedPrivilegedActionExecutor({ executor: client, modelExecutor: model, prompts: prompts() }).execute({ ...postUpgrade, when: 'when_needed', reasons: [] });
expect(result).toMatchObject({ state: 'completed', reasons: ['log_errors'], report: { outcome: 'nothing_to_do', costUsd: 0.2 } });
expect(result).not.toHaveProperty('skipped');
expect(model.requests).toHaveLength(1);
}
});
it('when needed, a reason from the run brings the model in without reading the logs first', async () => {
const client = session();
const model = nothingToDo();
const result = await createExecutorBackedPrivilegedActionExecutor({ executor: client, modelExecutor: model, prompts: prompts() }).execute({ ...postUpgrade, when: 'when_needed', reasons: ['approved_update', 'major'] });
expect(result).toMatchObject({ state: 'completed', reasons: ['approved_update', 'major'], report: { outcome: 'nothing_to_do' } });
expect(model.requests).toHaveLength(1);
expect(client.calls.map((call) => call.op)).not.toContain('sessionLogs');
});
});
it('takes the manifest hash from the service when the session gave no summary', async () => {
const client = session()
.on('closeSession', { outcome: 'failed', detail: 'gone', manifest_replaced: false })
@@ -169,6 +221,18 @@ describe('executor-backed privileged action adapter', () => {
expect(store.stored).toEqual({});
});
it('sends no compose file when the plan keeps the current one', async () => {
const client = new FakeExecutorClient().on('migrate', {
outcome: 'done',
result: { project: 'grafana', service: 'influxdb', state: 'applied', image: PINNED, manifest_sha256: 'a'.repeat(64), applied_manifest_sha256: 'b'.repeat(64), env_keys: [] },
});
const adapter = createExecutorBackedPrivilegedActionExecutor({ executor: client, runInputs: inputs({}) });
await adapter.execute({ ...request, composeYaml: null, inputs: [] }, undefined, { instanceId: 'run-1', nodeAttemptOrdinal: 4 });
expect(client.calls[0]?.request).toMatchObject({ compose_yaml: null, env: {} });
});
it('refuses to run with a value missing, and keeps the values when the executor refused', async () => {
const client = new FakeExecutorClient().on('migrate', { outcome: 'refused', reason: 'invalid_manifest', detail: 'bad' });
await expect(createExecutorBackedPrivilegedActionExecutor({ executor: client, runInputs: inputs({ ADMIN_USER: 'admin' }) }).execute(request, undefined, { instanceId: 'run-1', nodeAttemptOrdinal: 4 }))
@@ -6,7 +6,8 @@ import type { SessionSummary } from '../domain/executor-api.ts';
import { watchModelSession } from '../app/model-activity.ts';
import type { ModelActivitySink } from '../app/model-activity.ts';
import { REPAIR_ANSWER_SCHEMA, RepairAnswer } from '../domain/repair.ts';
import { POST_UPGRADE_ANSWER_SCHEMA, PostUpgradeAnswer } from '../domain/post-upgrade.ts';
import { POST_UPGRADE_ANSWER_SCHEMA, PostUpgradeAnswer, suspiciousLogLines } from '../domain/post-upgrade.ts';
import type { PostUpgradeReason } from '../domain/post-upgrade.ts';
/** Drops closing tags the model sometimes leaves at the end of its summary (`…</summary>\n</invoke>`). */
function cleanSummary(summary: string): string {
@@ -15,6 +16,8 @@ function cleanSummary(summary: string): string {
/** The session log operations a repair result may list (ModelRepairResult.actions). */
const SESSION_OPERATIONS: ReadonlySet<string> = new Set(['ps', 'logs', 'compose', 'write', 'pull', 'up', 'restart', 'exec-rules', 'exec', 'run']);
/** How many of the service's last log lines a post-upgrade check scans before it decides whether a model looks. */
const POST_UPGRADE_LOG_LINES = 200;
/** The closed driver-side mapping. It owns no command construction and only the executor client can reach Docker. */
export function createExecutorBackedPrivilegedActionExecutor(deps: {
@@ -84,6 +87,31 @@ export function createExecutorBackedPrivilegedActionExecutor(deps: {
return { output, costUsd, failure, model, manifestChanged: summary?.manifest_changed ?? false, manifestSha256, actions };
}
/**
* Whether a post-upgrade check needs its model, and why. When needed only: a reason from the run, else the service's
* last log lines read in an observe session (no model); errors there, or logs it cannot read, bring the model in.
*/
async function postUpgradeNeed(
request: Extract<PrivilegedActionRequest, { readonly action: 'model.postUpgrade' }>,
signal?: AbortSignal,
): Promise<{ readonly run: true; readonly reasons: readonly PostUpgradeReason[] } | { readonly run: false; readonly skipped: 'off' | 'not_needed'; readonly manifestSha256: string | null }> {
const reasons = request.reasons ?? [];
if (request.when === 'off') return { run: false, skipped: 'off', manifestSha256: null };
if (request.when !== 'when_needed' || reasons.length > 0) return { run: true, reasons };
const session = completed(request.action, await deps.executor.openSession({ project: request.project, service: request.service, mode: 'observe' }, signal));
const close = () => deps.executor.closeSession({ token: session.token }, signal);
let logs: ExecutorCall<{ readonly text: string }>;
try {
logs = await deps.executor.sessionLogs({ token: session.token, service: request.service, tail: POST_UPGRADE_LOG_LINES }, signal);
} catch (cause) {
await close().catch(() => undefined);
throw cause;
}
const closed = await close();
if (logs.outcome !== 'done' || suspiciousLogLines(logs.result.text).length > 0) return { run: true, reasons: ['log_errors'] };
return { run: false, skipped: 'not_needed', manifestSha256: closed.outcome === 'done' ? closed.result.manifest_sha256 : null };
}
return {
async execute(request, signal, run) {
const parsed = PrivilegedActionRequest.parse(request);
@@ -141,9 +169,10 @@ export function createExecutorBackedPrivilegedActionExecutor(deps: {
const values = deps.runInputs.values(run.instanceId);
const missing = parsed.inputs.filter((input) => values[input.name] === undefined).map((input) => input.name);
if (missing.length > 0) throw new Error(`docker.migrate is missing the owner's values for ${missing.join(', ')}`);
const composeYaml = parsed.inputs
const plannedCompose = parsed.composeYaml;
const composeYaml = plannedCompose === null ? null : parsed.inputs
.filter((input) => input.target === 'compose')
.reduce((text, input) => text.replaceAll(`<input:${input.name}>`, values[input.name] ?? ''), parsed.composeYaml);
.reduce((text, input) => text.replaceAll(`<input:${input.name}>`, values[input.name] ?? ''), plannedCompose);
const env = Object.fromEntries(parsed.inputs.filter((input) => input.target === 'env').map((input) => [input.name, values[input.name] ?? '']));
const answer = await deps.executor.migrate({
project: parsed.project, service: parsed.service, image: parsed.image, expect_manifest_sha256: parsed.expectedManifestSha256,
@@ -195,6 +224,17 @@ export function createExecutorBackedPrivilegedActionExecutor(deps: {
break;
}
case 'model.postUpgrade': {
const need = await postUpgradeNeed(parsed, signal);
if (!need.run) {
const manifestSha256 = need.manifestSha256
?? completed(parsed.action, await deps.executor.inspect({ project: parsed.project, service: parsed.service }, signal)).manifest_sha256;
result = {
action: parsed.action, version: 1, component: parsed.component, project: parsed.project, service: parsed.service,
image: parsed.image, backupId: parsed.backupId, state: 'completed', manifestChanged: false, manifestSha256,
reasons: parsed.reasons ?? [], skipped: need.skipped,
};
break;
}
const asked = await ask(parsed, 'postUpgrade', signal, run);
const answer = PostUpgradeAnswer.safeParse(asked.output);
const report = answer.success
@@ -208,6 +248,7 @@ export function createExecutorBackedPrivilegedActionExecutor(deps: {
state: done ? 'completed' : 'action_required',
manifestChanged: asked.manifestChanged, manifestSha256: asked.manifestSha256,
report: { ...report, model: asked.model, costUsd: asked.costUsd },
reasons: need.reasons,
};
break;
}
+4 -3
View File
@@ -21,16 +21,16 @@ export function createSqliteAppConfigStore(db: DatabaseSync): VersionedAppConfig
const previousConfig = previous === undefined ? null : parseAppConfig(previous);
db.prepare(
`INSERT INTO app_config (app, enabled, position, service, image_repo, tag_pattern, auto, http_url, expect_status,
release_notes_executor, migrate_executor, prompt_hint, post_upgrade_executor, notes, apply, project)
release_notes_executor, migrate_executor, prompt_hint, post_upgrade_executor, post_upgrade_when, notes, apply, project)
VALUES (:app, :enabled, :position, :service, :imageRepo, :tagPattern, :auto, :httpUrl, :expectStatus,
:releaseNotesExecutor, :migrateExecutor, :promptHint, :postUpgradeExecutor, :notes, :apply, :project)
:releaseNotesExecutor, :migrateExecutor, :promptHint, :postUpgradeExecutor, :postUpgradeWhen, :notes, :apply, :project)
ON CONFLICT (app) DO UPDATE SET
enabled = excluded.enabled, position = excluded.position, service = excluded.service,
image_repo = excluded.image_repo, tag_pattern = excluded.tag_pattern, auto = excluded.auto,
http_url = excluded.http_url, expect_status = excluded.expect_status,
release_notes_executor = excluded.release_notes_executor, migrate_executor = excluded.migrate_executor,
prompt_hint = excluded.prompt_hint,
post_upgrade_executor = excluded.post_upgrade_executor, notes = excluded.notes, apply = excluded.apply, project = excluded.project`,
post_upgrade_executor = excluded.post_upgrade_executor, post_upgrade_when = excluded.post_upgrade_when, notes = excluded.notes, apply = excluded.apply, project = excluded.project`,
).run({
app: config.app,
enabled: config.enabled ? 1 : 0,
@@ -45,6 +45,7 @@ export function createSqliteAppConfigStore(db: DatabaseSync): VersionedAppConfig
migrateExecutor: config.migrateExecutor,
promptHint: config.promptHint,
postUpgradeExecutor: config.postUpgradeExecutor,
postUpgradeWhen: config.postUpgradeWhen,
notes: config.notes,
apply: config.apply ? 1 : 0,
project: config.project,
+14 -1
View File
@@ -20,7 +20,7 @@ describe('migrate', () => {
]);
expect(columns(db, 'app_config')).toEqual([
'app', 'enabled', 'position', 'service', 'image_repo', 'tag_pattern', 'auto', 'http_url', 'expect_status',
'release_notes_executor', 'migrate_executor', 'prompt_hint', 'notes', 'apply', 'project', 'post_upgrade_executor',
'release_notes_executor', 'migrate_executor', 'prompt_hint', 'notes', 'apply', 'project', 'post_upgrade_executor', 'post_upgrade_when',
]);
expect(columns(db, 'workflows')).toEqual(['id', 'name', 'created_at']);
expect(columns(db, 'workflow_drafts')).toEqual(['workflow_id', 'revision', 'document', 'validation_revision', 'validation_document', 'diagnostics']);
@@ -120,6 +120,19 @@ describe('migrate', () => {
expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 });
});
it('v26 accepts haiku as an app model and keeps every app config verbatim', () => {
const db = new DatabaseSync(':memory:');
migrate(db, MIGRATIONS.slice(0, 25));
db.exec(`INSERT INTO app_config (app, service, image_repo, release_notes_executor, migrate_executor, post_upgrade_executor, post_upgrade_when, notes, apply, project)
VALUES ('bazarr', 'bazarr', 'lscr.io/bazarr', 'opus', 'sonnet', NULL, 'always', 'n', 1, 'arr')`);
const before = db.prepare('SELECT * FROM app_config').all();
migrate(db, MIGRATIONS.slice(0, 26));
expect(db.prepare('SELECT * FROM app_config').all()).toEqual(before);
db.exec("INSERT INTO app_config (app, service, image_repo, migrate_executor, post_upgrade_executor) VALUES ('new', 'new', 'r', 'haiku', 'haiku')");
expect(db.prepare("SELECT release_notes_executor FROM app_config WHERE app = 'new'").get()).toEqual({ release_notes_executor: 'haiku' });
expect(() => db.exec("UPDATE app_config SET release_notes_executor = 'gpt' WHERE app = 'new'")).toThrow(/CHECK/);
});
it('v7 adds a nullable progress to steps already there', () => {
const db = new DatabaseSync(':memory:');
migrate(db, MIGRATIONS.slice(0, 6));
+40
View File
@@ -802,6 +802,46 @@ export const MIGRATIONS: readonly Migration[] = [
) STRICT;
`,
},
{
// When an app's post-upgrade check runs its model (owner, 2026-10-07): by default only when the update may ask for something.
version: 25,
sql: `
ALTER TABLE app_config ADD COLUMN post_upgrade_when TEXT NOT NULL DEFAULT 'when_needed' CHECK (post_upgrade_when IN ('when_needed', 'always'));
`,
},
{
// Haiku as a model choice and the default release notes model (owner, 2026-10-07). SQLite cannot change a CHECK in
// place, so the table is rebuilt; the retired steps table keeps its old one, nothing writes it any more.
version: 26,
sql: `
ALTER TABLE app_config RENAME TO app_config_v25;
CREATE TABLE app_config (
app TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
position INTEGER NOT NULL DEFAULT 0,
service TEXT NOT NULL,
image_repo TEXT NOT NULL,
tag_pattern TEXT NOT NULL DEFAULT '.',
auto TEXT NOT NULL DEFAULT 'patch' CHECK (auto IN ('none', 'patch', 'minor', 'major')),
http_url TEXT,
expect_status INTEGER NOT NULL DEFAULT 200,
release_notes_executor TEXT NOT NULL DEFAULT 'haiku' CHECK (release_notes_executor IN ('script', 'haiku', 'sonnet', 'opus')),
migrate_executor TEXT CHECK (migrate_executor IN ('script', 'haiku', 'sonnet', 'opus')),
prompt_hint TEXT,
notes TEXT,
apply INTEGER NOT NULL DEFAULT 0 CHECK (apply IN (0, 1)),
project TEXT NOT NULL DEFAULT '',
post_upgrade_executor TEXT CHECK (post_upgrade_executor IN ('script', 'haiku', 'sonnet', 'opus')),
post_upgrade_when TEXT NOT NULL DEFAULT 'when_needed' CHECK (post_upgrade_when IN ('when_needed', 'always'))
) STRICT;
INSERT INTO app_config (app, enabled, position, service, image_repo, tag_pattern, auto, http_url, expect_status,
release_notes_executor, migrate_executor, prompt_hint, notes, apply, project, post_upgrade_executor, post_upgrade_when)
SELECT app, enabled, position, service, image_repo, tag_pattern, auto, http_url, expect_status,
release_notes_executor, migrate_executor, prompt_hint, notes, apply, project, post_upgrade_executor, post_upgrade_when
FROM app_config_v25;
DROP TABLE app_config_v25;
`,
},
];
function schemaVersion(db: DatabaseSync): number {
+3 -1
View File
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { AutoLevel, Executor } from '../../domain/steps.ts';
import { AutoLevel, Executor, PostUpgradeWhen } from '../../domain/steps.ts';
import type { AppConfig, JsonValue } from '../../domain/steps.ts';
// Rows are validated where they leave the database, so a corrupted or foreign row fails loudly here
@@ -20,6 +20,7 @@ const AppConfigRow = z
migrate_executor: Executor.nullable(),
prompt_hint: z.string().nullable(),
post_upgrade_executor: Executor.nullable(),
post_upgrade_when: PostUpgradeWhen,
notes: z.string().nullable(),
apply: z.number(),
project: z.string().min(1),
@@ -40,6 +41,7 @@ const AppConfigRow = z
migrateExecutor: row.migrate_executor,
promptHint: row.prompt_hint,
postUpgradeExecutor: row.post_upgrade_executor,
postUpgradeWhen: row.post_upgrade_when,
notes: row.notes,
apply: row.apply === 1,
}),
@@ -119,7 +119,7 @@ async function setupEnvironment() {
releaseNotesExecutor: 'sonnet',
migrateExecutor: null,
promptHint: null,
postUpgradeExecutor: null,
postUpgradeExecutor: null, postUpgradeWhen: 'when_needed',
notes: null,
apply: true,
});
@@ -1374,6 +1374,13 @@ function createStepRunner(instanceStore: ReturnType<typeof createSqliteWorkflowI
},
});
// verify_after_gave_up: still down -> unhealthy
runner.step('verify_after_gave_up', {
type: 'event',
event: 'unhealthy',
result: { event: 'unhealthy', healthy: false, containerState: 'exited', containerHealth: 'unhealthy', imageMatches: true },
});
// gate_repair_failed: wait
runner.step('gate_repair_failed', {
type: 'wait',
+1 -1
View File
@@ -8,7 +8,7 @@ const one = { app: 'bazarr', service: 'bazarr', imageRepo: 'linuxserver/bazarr',
describe('parseAppsFile', () => {
it('fills the defaults: enabled, models off, real updates off, positions from the file order', () => {
const [first, second] = parseAppsFile([one, { ...one, app: 'sonarr', service: 'sonarr', enabled: false, notes: 'x' }]);
expect(first).toEqual({ ...one, enabled: true, position: 1, releaseNotesExecutor: 'sonnet', migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, notes: null, apply: false, project: 'bazarr' });
expect(first).toEqual({ ...one, enabled: true, position: 1, releaseNotesExecutor: 'haiku', migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, postUpgradeWhen: 'when_needed', notes: null, apply: false, project: 'bazarr' });
expect(second).toMatchObject({ app: 'sonarr', enabled: false, position: 2, notes: 'x' });
});
+3 -2
View File
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { parseProjectName } from '../domain/compose.ts';
import { AutoLevel, Executor } from '../domain/steps.ts';
import { AutoLevel, DEFAULT_MODEL, Executor, PostUpgradeWhen } from '../domain/steps.ts';
import type { AppConfig } from '../domain/steps.ts';
import { compileTagPattern } from '../domain/tags.ts';
@@ -19,10 +19,11 @@ const AppEntry = z.strictObject({
auto: AutoLevel,
httpUrl: z.string().nullable().default(null),
expectStatus: z.number().int().min(100).max(599).default(200),
releaseNotesExecutor: Executor.default('sonnet'),
releaseNotesExecutor: Executor.default(DEFAULT_MODEL),
migrateExecutor: Executor.nullable().default(null),
promptHint: z.string().nullable().default(null),
postUpgradeExecutor: Executor.nullable().default(null),
postUpgradeWhen: PostUpgradeWhen.default('when_needed'),
notes: z.string().nullable().default(null),
/** Real updates stay off until the owner turns them on in the app. */
apply: z.boolean().default(false),
+4
View File
@@ -159,6 +159,8 @@ const ModelEnv = z.object({
MODEL_TIMEOUT_MS: z.coerce.number().int().min(60_000).default(30 * 60_000),
/** Only this host may call /run. Empty permits local test/development callers. */
DRIVER_HOST: z.string().default('driver'),
/** The web app may also manage the sign-in (/auth/*), nothing else. Empty = only the driver. */
AUTH_HOST: z.string().default('server'),
});
export interface ModelConfig {
@@ -170,6 +172,7 @@ export interface ModelConfig {
readonly hlUrl: string;
readonly timeoutMs: number;
readonly driverHost: string | null;
readonly authHost: string | null;
}
export function loadModelConfig(env: Readonly<Record<string, string | undefined>>): ModelConfig {
@@ -185,5 +188,6 @@ export function loadModelConfig(env: Readonly<Record<string, string | undefined>
hlUrl: values.HL_URL,
timeoutMs: values.MODEL_TIMEOUT_MS,
driverHost: values.DRIVER_HOST === '' ? null : values.DRIVER_HOST,
authHost: values.AUTH_HOST === '' ? null : values.AUTH_HOST,
};
}
+3 -1
View File
@@ -42,6 +42,8 @@ import { missingSkills, readSkillFiles } from './skills-file.ts';
import { createWorkflowExtensions } from '../domain/workflow-extensions.ts';
const logger = createConsoleLogger();
/** ghcr.io answers 429 now and then; the schedule is spread out, so a run can wait minutes for the tag list. */
const REGISTRY_RATE_LIMIT_WAITS_MS = [30_000, 120_000, 300_000];
let config;
try {
@@ -118,7 +120,7 @@ const runner = createWorkflowRunner({
functionCatalog,
functionRuntime: createFunctionRuntimeClient({ baseUrl: config.functionRuntimeUrl }),
componentOperations: createComponentOperationExecutor({
registry: createHttpRegistryClient(),
registry: createHttpRegistryClient({ rateLimitWaitsMs: REGISTRY_RATE_LIMIT_WAITS_MS }),
hostBackup,
backupVmid: config.backupVmid,
clock: systemClock,
+19 -12
View File
@@ -1,10 +1,11 @@
// Process 4: the model sidecar. Runs the unmodified `claude` CLI logged in with the owner's account (CLAUDE_CONFIG_DIR, a
// private volume; the owner logs in once: `docker compose exec -it model claude` → /login). No Docker socket, no project
// private volume; the owner signs in from the app: Settings → Anthropic account, /auth/*). No Docker socket, no project
// files: in a repair session the model acts only through `hl` on the executor. The driver is its only caller.
import { runClaude } from '../app/model/claude.ts';
import { lookup } from 'node:dns/promises';
import { buildModelServer } from '../http/model-server.ts';
import { createConsoleLogger } from '../infra/console-logger.ts';
import { createClaudeAuth } from '../infra/model/claude-auth.ts';
import { createProcessRunner } from '../infra/process-runner.ts';
import { loadModelConfig } from './config.ts';
@@ -28,17 +29,21 @@ const deps = {
guardCommand: 'node /app/src/main/model-guard.ts',
};
let driverAddresses: { readonly at: number; readonly addresses: ReadonlySet<string> } | null = null;
const isDriver =
config.driverHost === null
? undefined
: async (address: string): Promise<boolean> => {
if (driverAddresses === null || Date.now() - driverAddresses.at > 30_000) {
const found = await lookup(config.driverHost ?? '', { all: true }).catch(() => []);
driverAddresses = { at: Date.now(), addresses: new Set(found.map((entry) => entry.address)) };
}
return driverAddresses.addresses.has(address.replace(/^::ffff:/, ''));
};
// Callers are told apart by the address their host name resolves to (looked up at most every 30 s).
function allowHosts(hosts: readonly (string | null)[]): ((address: string) => Promise<boolean>) | undefined {
const names = hosts.filter((host): host is string => host !== null);
if (names.length === 0) return undefined;
let known: { readonly at: number; readonly addresses: ReadonlySet<string> } | null = null;
return async (address) => {
if (known === null || Date.now() - known.at > 30_000) {
const found = await Promise.all(names.map((name) => lookup(name, { all: true }).catch(() => [])));
known = { at: Date.now(), addresses: new Set(found.flat().map((entry) => entry.address)) };
}
return known.addresses.has(address.replace(/^::ffff:/, ''));
};
}
const isDriver = config.driverHost === null ? undefined : allowHosts([config.driverHost]);
const mayManageLogin = config.driverHost === null ? undefined : allowHosts([config.driverHost, config.authHost]);
const app = await buildModelServer({
timeoutMs: config.timeoutMs,
@@ -50,6 +55,8 @@ const app = await buildModelServer({
return result;
},
...(isDriver === undefined ? {} : { isDriver }),
...(mayManageLogin === undefined ? {} : { mayManageLogin }),
auth: createClaudeAuth({ claudeBin: config.claudeBin, configDir: config.configDir, path: deps.path }),
});
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => void app.close());
await app.listen({ host: config.host, port: config.port });
+2
View File
@@ -18,6 +18,7 @@ import { createSqliteModelProfileStore } from '../infra/sqlite/model-profile-sto
import { createSqliteWorkflowRunInputStore } from '../infra/sqlite/workflow-run-input-store.ts';
import { createSqliteModelActivityStore } from '../infra/sqlite/model-activity-store.ts';
import { createFunctionRuntimeClient } from '../infra/runtime/function-runtime-client.ts';
import { createHttpModelAuthClient } from '../infra/http-model-auth-client.ts';
import { createHttpRegistryClient } from '../infra/http-registry.ts';
import { systemClock } from '../infra/system-clock.ts';
import { ensureAppWorkflows, ensureDefaultModelProfiles, ensureUpdatePolicy } from '../app/workflow-seed.ts';
@@ -86,6 +87,7 @@ const app = await buildServer({
startSystemRun: (workflowId) => startSystemRun(runDeps, workflowId),
schedules: { store: scheduleStore, timeZone: config.scheduleTimeZone },
registry: createHttpRegistryClient(),
...(config.modelUrls.claude === null ? {} : { claudeAuth: createHttpModelAuthClient({ baseUrl: config.modelUrls.claude }) }),
clock: systemClock,
hub: createChangeHub(),
dataVersion: () => dataVersion(db),
+6
View File
@@ -17,6 +17,7 @@ import type {
SessionOpenRequest,
SessionOpenResult,
SessionLogResult,
AgentLogsRequest,
AgentTextResult,
SessionSummary,
} from '../domain/executor-api.ts';
@@ -135,6 +136,7 @@ interface ExecutorOps {
closeSession: [SessionCloseRequest, SessionSummary];
sessionLog: [SessionCloseRequest, SessionLogResult];
sessionCompose: [SessionCloseRequest, AgentTextResult];
sessionLogs: [SessionCloseRequest & AgentLogsRequest, AgentTextResult];
}
type ExecutorOp = keyof ExecutorOps;
type ExecutorScript<K extends ExecutorOp> = (request: ExecutorOps[K][0]) => ExecutorCall<ExecutorOps[K][1]>;
@@ -204,4 +206,8 @@ export class FakeExecutorClient implements ExecutorClient {
sessionCompose(request: SessionCloseRequest) {
return this.#call('sessionCompose', request);
}
sessionLogs(request: SessionCloseRequest & AgentLogsRequest) {
return this.#call('sessionLogs', request);
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ export function seedTestApps(appConfig: AppConfigStore): void {
releaseNotesExecutor: 'sonnet',
migrateExecutor: null,
promptHint: null,
postUpgradeExecutor: null,
postUpgradeExecutor: null, postUpgradeWhen: 'when_needed',
notes: null,
apply: false,
});
+7
View File
@@ -15,6 +15,8 @@ import {
WorkflowInstanceListResponse,
WorkflowListResponse,
WorkflowTimelineResponse,
ModelAuthLoginStart,
ModelAuthStatus,
ModelCallsResponse,
ModelProfileListResponse,
ReleaseNotesSourcesResponse,
@@ -116,6 +118,11 @@ export const api = {
call(z.object({ ok: z.boolean(), instance: z.unknown() }), `/api/workflow-instances/${encodeURIComponent(id)}/resume`, { expectedLifecycleSeq }),
cancelWorkflowInstance: (id: string, expectedLifecycleSeq: number) =>
call(z.object({ ok: z.boolean(), instance: z.unknown() }), `/api/workflow-instances/${encodeURIComponent(id)}/cancel`, { expectedLifecycleSeq }),
modelAuth: () => call(ModelAuthStatus, '/api/model-auth'),
startModelLogin: () => call(ModelAuthLoginStart, '/api/model-auth/login', {}),
submitModelLoginCode: (code: string) => call(ModelAuthStatus, '/api/model-auth/code', { code }),
cancelModelLogin: () => call(z.object({ ok: z.boolean() }), '/api/model-auth/cancel', {}),
modelLogout: () => call(ModelAuthStatus, '/api/model-auth/logout', {}),
modelCalls: (limit = 50) => call(ModelCallsResponse, `/api/model-calls?limit=${String(limit)}`),
modelProfiles: () => call(ModelProfileListResponse, '/api/model-profiles'),
releaseNotesSources: () => call(ReleaseNotesSourcesResponse, '/api/release-notes-sources'),
+42
View File
@@ -206,6 +206,18 @@ describe('app configuration', () => {
await eventually(() => expect((requests.filter((r) => r.method === 'POST').at(-1)!.body as Record<string, unknown>)['postUpgradeExecutor']).toBeNull());
});
it('sets when the post-upgrade model runs, once a model is chosen', async () => {
await open();
expect(form()!.querySelector('[name="postUpgradeWhen"]')).toBeNull();
await choose(field('postUpgradeExecutor'), 'sonnet');
expect(field<HTMLSelectElement>('postUpgradeWhen').value).toBe('when_needed');
await choose(field('postUpgradeWhen'), 'always');
button(form()!, 'Save').click();
await eventually(() => expect(form()!.textContent).toContain('Saved'));
expect(requests.find((r) => r.method === 'POST')!.body).toEqual({ postUpgradeExecutor: 'sonnet', postUpgradeWhen: 'always' });
expect((await server.inject('/api/apps/bazarr/config')).json()).toMatchObject({ postUpgradeWhen: 'always' });
});
it('checks the change in the browser before sending it', async () => {
await open();
await type(field('tagPattern'), '(?P<v>x)');
@@ -365,6 +377,36 @@ describe('other views', () => {
expect(text()).toContain('No real update, nothing failed, nothing waits for you.');
});
it('switches the Anthropic account: sign-in page, pasted code, new account shown', async () => {
let account: string | null = 'old@example.com';
const status = () => ({ loggedIn: account !== null, email: account, organization: null, method: account === null ? null : 'claude.ai' });
const codes: string[] = [];
server = await buildServer({
...sqliteServerDeps(),
claudeAuth: {
status: () => Promise.resolve(status()),
startLogin: () => Promise.resolve({ url: 'https://claude.com/oauth/authorize?x=1' }),
submitCode: (code) => (codes.push(code), account = 'new@example.com', Promise.resolve(status())),
cancelLogin: () => Promise.resolve(),
logout: () => (account = null, Promise.resolve(status())),
},
});
window.location.hash = '#/settings';
await eventually(() => expect(text()).toContain('old@example.com'));
button(container, 'Sign in with another account').click();
const link = await eventually(() => {
const found = container.querySelector<HTMLAnchorElement>('a[href^="https://claude.com/oauth/authorize"]');
if (found === null) throw new Error('no sign-in link');
return found;
});
expect(link.target).toBe('_blank');
await type(container.querySelector<HTMLInputElement>('input[autocomplete="off"]') as HTMLInputElement, 'abcdefgh#state');
button(container, 'Finish sign-in').click();
await eventually(() => expect(text()).toContain('new@example.com'));
expect(codes).toEqual(['abcdefgh#state']);
expect(text()).not.toContain('Finish sign-in');
});
it('jumps to an app from the palette opened with Ctrl+K', async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }));
const input = await eventually(() => {
+7 -4
View File
@@ -17,6 +17,7 @@ interface Draft {
releaseNotesExecutor: string;
migrateExecutor: string;
postUpgradeExecutor: string;
postUpgradeWhen: string;
promptHint: string;
notes: string;
}
@@ -31,6 +32,7 @@ const toDraft = (config: AppConfigView): Draft => ({
releaseNotesExecutor: config.releaseNotesExecutor,
migrateExecutor: config.migrateExecutor ?? 'off',
postUpgradeExecutor: config.postUpgradeExecutor ?? 'off',
postUpgradeWhen: config.postUpgradeWhen,
promptHint: config.promptHint ?? '',
notes: config.notes ?? '',
});
@@ -102,7 +104,7 @@ function ConfigForm({ initial }: { initial: AppConfigView }) {
<input type="text" name={key} value={draft[key]} disabled={pending} onInput={(event) => set(key, event.currentTarget.value)} />
</label>
);
const select = (key: 'auto' | 'releaseNotesExecutor' | 'migrateExecutor' | 'postUpgradeExecutor', label: string, options: readonly string[]) => (
const select = (key: 'auto' | 'releaseNotesExecutor' | 'migrateExecutor' | 'postUpgradeExecutor' | 'postUpgradeWhen', label: string, options: readonly string[]) => (
<label>
{label}
<select name={key} value={draft[key]} disabled={pending} onChange={(event) => set(key, event.currentTarget.value)}>
@@ -153,9 +155,10 @@ function ConfigForm({ initial }: { initial: AppConfigView }) {
</div>
{text('httpUrl', 'Check URL', 'empty = none')}
{text('expectStatus', 'Expected HTTP status')}
{select('releaseNotesExecutor', 'Release notes executor', ['sonnet', 'opus'])}
{select('migrateExecutor', 'Repair executor', ['off', 'sonnet', 'opus'])}
{select('postUpgradeExecutor', 'Post-upgrade check executor', ['off', 'sonnet', 'opus'])}
{select('releaseNotesExecutor', 'Release notes executor', ['haiku', 'sonnet', 'opus'])}
{select('migrateExecutor', 'Repair executor', ['off', 'haiku', 'sonnet', 'opus'])}
{select('postUpgradeExecutor', 'Post-upgrade check executor', ['off', 'haiku', 'sonnet', 'opus'])}
{draft.postUpgradeExecutor === 'off' ? null : select('postUpgradeWhen', 'Post-upgrade check runs', ['when_needed', 'always'])}
{text('promptHint', 'Prompt hint')}
{text('notes', 'Notes', 'up to 10 words')}
<div class="actions">
+1 -1
View File
@@ -176,7 +176,7 @@ export function ModelsView() {
</button>
</div>
<p class="muted">
Apps pick a model as <code>sonnet</code> or <code>opus</code>: the profile of that name decides which sidecar runs it
Apps pick a model as <code>haiku</code>, <code>sonnet</code> or <code>opus</code>: the profile of that name decides which sidecar runs it
(Provider: Claude or Codex), how long it may take and what it may cost. A run keeps the profile version it started with.
</p>
+98 -2
View File
@@ -1,10 +1,10 @@
import { useState } from 'preact/hooks';
import { useContext, useState } from 'preact/hooks';
import { api } from '../api.ts';
import { ErrorNote, Loading } from '../components.tsx';
import { Card } from '../components/ui.tsx';
import { useToast } from '../components/dialogs.tsx';
import { formatInstant } from '../format.ts';
import { useResource } from '../hooks.ts';
import { LiveContext, useResource } from '../hooks.ts';
type Settings = Awaited<ReturnType<typeof api.notificationSettings>>['settings'];
@@ -69,6 +69,101 @@ function NotificationsForm({ initial, timeZone, lastDigestAt }: { readonly initi
);
}
type AuthStatus = Awaited<ReturnType<typeof api.modelAuth>>;
/** Who the Claude sidecar is signed in as, and the sign-in that replaces it: open the page, sign in, paste the code it shows. */
function AnthropicAccount() {
const { data, error } = useResource(() => api.modelAuth(), []);
const { refresh } = useContext(LiveContext);
const [loginUrl, setLoginUrl] = useState<string | null>(null);
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<string | null>(null);
const toast = useToast();
const attempt = async (work: () => Promise<void>) => {
setBusy(true);
setProblem(null);
try {
await work();
} catch (cause) {
setProblem(cause instanceof Error ? cause.message : 'Something went wrong');
} finally {
setBusy(false);
}
};
const start = () => attempt(async () => setLoginUrl((await api.startModelLogin()).url));
const cancel = () => attempt(async () => {
await api.cancelModelLogin();
setLoginUrl(null);
setCode('');
});
const finish = (event: Event) => {
event.preventDefault();
return attempt(async () => {
const status = await api.submitModelLoginCode(code);
setLoginUrl(null);
setCode('');
toast(`Signed in as ${status.email ?? 'the new account'}.`);
refresh();
});
};
const signOut = () => attempt(async () => {
await api.modelLogout();
toast('Signed out.');
refresh();
});
const status: AuthStatus | null = data;
return (
<Card title="Anthropic account" icon="models">
{error === null ? null : <ErrorNote message={error} />}
{status === null ? (error === null ? <Loading /> : null) : (
<p>
{status.loggedIn ? (
<>
Signed in as <strong>{status.email ?? 'an unknown account'}</strong>
{status.organization === null ? '' : ` (${status.organization})`}
{status.method === null ? '' : ` · ${status.method}`}
</>
) : (
<strong>Not signed in: model runs with Claude fail until you sign in.</strong>
)}
</p>
)}
{loginUrl === null ? (
<div class="actions">
<button type="button" class="primary" disabled={busy || status === null} onClick={() => void start()}>
{status?.loggedIn === true ? 'Sign in with another account' : 'Sign in'}
</button>
{status?.loggedIn === true && (
<button type="button" disabled={busy} onClick={() => void signOut()}>
Sign out
</button>
)}
</div>
) : (
<form class="settings-form" onSubmit={(event) => void finish(event)}>
<p class="muted">
1. <a href={loginUrl} target="_blank" rel="noreferrer noopener">Open the Anthropic sign-in page</a> and sign in with the account you want. 2. Paste the code it shows here.
</p>
<label>
Code
<input type="text" value={code} autocomplete="off" spellcheck={false} required onInput={(event) => setCode((event.target as HTMLInputElement).value)} />
</label>
<div class="actions">
<button type="submit" class="primary" disabled={busy || code.trim().length < 8}>
{busy ? 'Checking…' : 'Finish sign-in'}
</button>
<button type="button" disabled={busy} onClick={() => void cancel()}>
Cancel
</button>
</div>
</form>
)}
{problem === null ? null : <ErrorNote message={problem} />}
</Card>
);
}
export function SettingsView() {
const { data, error } = useResource(() => api.notificationSettings(), []);
return (
@@ -83,6 +178,7 @@ export function SettingsView() {
{error === null ? null : <ErrorNote message={error} />}
{data === null ? (error === null ? <Loading /> : null) : <NotificationsForm key={JSON.stringify(data.settings)} initial={data.settings} timeZone={data.timeZone} lastDigestAt={data.lastDigestAt} />}
</Card>
<AnthropicAccount />
</section>
);
}
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Read-only look into the running app on VM103: GET <path> on the server container's own port, past Traefik and Keycloak.
# Usage: tools/vm-api.sh /api/workflow-instances?app=orcaslicer (output: the JSON body)
set -euo pipefail
path="${1:?usage: vm-api.sh /api/<path>}"
case "$path" in /api/*) ;; *) echo "only /api/* paths" >&2; exit 2 ;; esac
case "$path" in *[\'\"\`\$\\\ ]*) echo "path has characters that are not allowed" >&2; exit 2 ;; esac
exec timeout 60 ssh -o BatchMode=yes -o ConnectTimeout=8 root@192.168.0.139 \
"docker exec homelab-upgrade-server-1 node -e \"fetch('http://127.0.0.1:8080${path}').then((r)=>r.text()).then((t)=>process.stdout.write(t))\""