1 Commits
Author SHA1 Message Date
homelab-updaterandClaude Opus 5.5 9045ee0b25 fix(deploy): keep VM103's disk from filling with images and build cache
ci / check (push) Successful in 1m37s
ci / publish (push) Skipped
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:07:45 +02:00
57 changed files with 128 additions and 591 deletions

No files matched your search

+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.293
ARG CLAUDE_CODE_VERSION=2.1.267
# The codex CLI for the model sidecar, pinned and updated deliberately (docs/DESIGN.md, "Wykonawcy modeli").
ARG CODEX_VERSION=0.156.1
+13 -17
View File
@@ -1,36 +1,32 @@
# Stan zadania — faza H (wygoda na co dzień)
# Stan zadania — nowoczesny frontend (Faza G)
## Cel
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”.
Bardziej nowoczesny i graficzny frontend: okna/karty, wykresy z danych, które API już zwraca. Plan i status: `docs/IMPLEMENTATION.md`, „Faza G”.
## Decyzje
- 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.
- 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.
## Zrobione
- 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%).
- 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ą).
## Pozostałe
- Właściciel loguje się raz w przeglądarce i na telefonie (potwierdzenie Keycloak → aplikacja).
- Przegląd na żywo w przeglądarce (oba motywy, 375 px i desktop), poprawki wyglądu po przeglądzie.
- Ze strumienia Git/Arcane: migracja klientów Influx przed włączeniem aktualizacji.
- 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 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`.
Właściciel ogląda nowe UI na https://upgrade.mariuszpacek.pl i zgłasza poprawki.
## Walidacja
`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).
`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`.
+1 -3
View File
@@ -63,7 +63,7 @@ services:
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.4@sha256:b1b2021fe8f4004573e8d690dec6c7bb29cc44364572cf8510a05bf3a0ae2ded
restart: unless-stopped
user: "65532:65532"
# Not read_only: compose writes an inline config (auth-emails) only into a writable container filesystem.
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
environment:
@@ -76,8 +76,6 @@ 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
+3 -3
View File
@@ -156,9 +156,9 @@ if ! TAG="$tag" docker compose pull; then
restore_compose
exit 1
fi
# 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
TAG="$tag" docker compose up -d --remove-orphans
if wait_until_up; then
# Only the TAG line changes: .env also holds HA_TOKEN and other settings.
if [[ -n "$tag_arg" ]]; then
touch .env
+4 -4
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**: `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.
- **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.
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*`.
@@ -191,11 +191,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 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.
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.
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`.
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`.
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.
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.
`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” (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)”.
- 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)”.
- 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,5 +180,3 @@ 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.
+3 -35
View File
@@ -89,38 +89,6 @@ 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();
@@ -281,17 +249,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 haiku look at the app in an observe session and hands over the plan with its digest', async () => {
it('has opus 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: 'haiku', 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: 'opus', 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: 'haiku', profile: 'observe', sessionToken: 'observe-token' });
expect(model.requests[0]).toMatchObject({ executor: 'opus', 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' });
+5 -10
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_ATTEMPTS, VERIFY_INTERVAL_MS, verifyChecks } from '../domain/verify.ts';
import { nextVerifyAction, 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 { modelOr, toJson } from '../domain/steps.ts';
import { 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,12 +229,7 @@ export function createComponentOperationExecutor(deps: ComponentOperationExecuto
for (let attempt = 1; ; ) {
const inspected = await executor.inspect({ project, service }, signal);
if (inspected.outcome !== 'done') {
// 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;
return { event: 'unhealthy', result: { healthy: false, reason: 'inspect_unavailable', detail: inspected.detail } };
}
const { container } = inspected.result;
const http = httpUrl === null || expectStatus === null ? null : await probe(httpUrl, expectStatus);
@@ -285,7 +280,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: modelOr(record['releaseNotesExecutor']),
model: record['releaseNotesExecutor'] === 'opus' ? 'opus' : 'sonnet',
group: async () => (groupMembers === undefined ? [] : [...await groupMembers(app, signal)]),
}, signal);
@@ -321,7 +316,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 = modelOr(record['migrateExecutor']);
const executorName = record['migrateExecutor'] === 'sonnet' ? 'sonnet' : 'opus';
const nextSteps = asRecord(notes['report'])['nextSteps'];
const prompt = buildMigrationPlanPrompt({
app, project, service,
-12
View File
@@ -716,18 +716,6 @@ 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 = [
+1 -4
View File
@@ -588,10 +588,7 @@ 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.
// 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);
const unmasked = 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, postUpgradeWhen: 'when_needed', notes: null, apply: true, ...over,
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, 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('haiku');
expect(asked.executor).toBe('sonnet');
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, 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');
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');
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');
+4 -5
View File
@@ -2,7 +2,6 @@ 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';
@@ -16,8 +15,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 for the check after an update,
* migrateExecutor for a repair (each haiku by default).
* 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.
*/
export function createModelActionPrompts(deps: ModelActionPromptDeps): ModelActionPrompts {
const configOf = (component: string): AppConfig => {
@@ -42,7 +41,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: modelOr(config.postUpgradeExecutor), prompt };
return { executor: config.postUpgradeExecutor === 'opus' ? 'opus' : 'sonnet', prompt };
},
async repair(request, signal) {
const { config, group, skill } = await facts(request.component, signal);
@@ -51,7 +50,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: modelOr(config.migrateExecutor), prompt };
return { executor: config.migrateExecutor === 'sonnet' ? 'sonnet' : 'opus', prompt };
},
};
}
+1 -2
View File
@@ -6,7 +6,6 @@ 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
@@ -31,7 +30,7 @@ export interface ClaudeDeps {
}
export interface ClaudeRunRequest {
readonly model: ModelName;
readonly model: 'sonnet' | 'opus';
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, postUpgradeWhen: 'when_needed' as const, notes: null, apply: false,
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, 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, postUpgradeWhen: 'when_needed',
postUpgradeExecutor: null,
notes: null,
apply: true,
...over,
+3 -4
View File
@@ -4,7 +4,6 @@ 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
@@ -15,8 +14,8 @@ export interface ReleaseNotesRead {
}
export type ReleaseNotesAssessment =
| { 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 };
| { 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 };
export interface ReleaseNotesQuestion {
readonly app: string;
@@ -28,7 +27,7 @@ export interface ReleaseNotesQuestion {
readonly versionChange: string;
readonly auto: string;
readonly promptHint: string | null;
readonly model: ModelName;
readonly model: 'sonnet' | 'opus';
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, postUpgradeWhen: 'when_needed', notes: null, apply: true,
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, 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']);
+6 -8
View File
@@ -8,8 +8,7 @@ import type {
WorkflowInstanceStore,
} from '../domain/ports.ts';
import type { WorkflowInstance } from '../domain/workflow.ts';
import { modelOr } from '../domain/steps.ts';
import type { AppConfig, JsonValue, ModelName } from '../domain/steps.ts';
import type { AppConfig, JsonValue } from '../domain/steps.ts';
import { buildComponentUpdateSnapshot, buildSystemSnapshot } from '../domain/workflow-snapshot-builder.ts';
import { appWorkflowId } from './workflow-seed.ts';
@@ -124,13 +123,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: ModelName) => {
const profile = (model: 'sonnet' | 'opus') => {
const latest = deps.modelProfileStore.getLatestVersion(model);
return latest === null ? null : { profileId: latest.profileId, version: latest.version };
};
// The app's choice, else the default model.
const postUpgrade = modelOr(config.postUpgradeExecutor);
const repair = modelOr(config.migrateExecutor);
// 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';
return {
backupRequest: { project: config.project },
applyDryRunRequest: { dryRun: true },
@@ -140,8 +139,7 @@ function realRunRequests(deps: WorkflowRunDeps, config: AppConfig): Record<strin
repairFullRequest: { reason: 'owner_requested', access: 'full' },
rollbackRequest: {},
migrateRequest: {},
// 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 },
postUpgradeRequest: {},
postUpgradeProfile: profile(postUpgrade),
repairProfile: profile(repair),
};
+6 -24
View File
@@ -104,14 +104,13 @@ 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' | 'image_only' = 'none'): ComponentOperationRunner {
const plan = migration === 'image_only' ? { ...MIGRATION_PLAN, composeYaml: null, inputs: [] } : MIGRATION_PLAN;
function fakeOperations(tags: 'candidate_within_auto' | 'candidate_above_auto' = 'candidate_within_auto', migration: 'none' | 'needed' = 'none'): ComponentOperationRunner {
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 !== 'none'
? { event: 'migration_needed', result: { plan, planDigest: 'd'.repeat(64), summary: plan.summary, model: 'opus', costUsd: 0.3, actions: [] } }
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: [] } }
: { event: 'no_migration', result: { plan: null, summary: 'Nothing to migrate.' } },
image_resolve: {
event: 'resolved',
@@ -147,7 +146,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, postUpgradeWhen: 'when_needed', notes: null, apply: true,
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, 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.
@@ -254,7 +253,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: 'haiku' }, when: 'off', reasons: [] });
expect(docker.requests.find((request) => request.action === 'model.postUpgrade')).toMatchObject({ image: PINNED, backupId: BACKUP_ID, profile: { profileId: 'sonnet' } });
});
it('rolls the app back when the owner chooses rollback after the model asked for action', async () => {
@@ -322,27 +321,10 @@ 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'] }, reasons: ['approved_update', 'major', 'migration'],
});
expect(docker.requests.find((request) => request.action === 'model.postUpgrade')).toMatchObject({ migration: { summary: MIGRATION_PLAN.summary, stepsAfter: ['hl ps'] } });
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');
+3 -3
View File
@@ -60,10 +60,10 @@ describe('ensureUpdatePolicy', () => {
});
describe('ensureDefaultModelProfiles', () => {
it('adds read-only Claude profiles for the haiku, sonnet and opus executors once', () => {
it('adds read-only Claude profiles for the sonnet and opus executors once', () => {
const store = createSqliteModelProfileStore(openDatabase(':memory:'));
expect(ensureDefaultModelProfiles(store, now)).toEqual(['haiku', 'sonnet', 'opus']);
expect(ensureDefaultModelProfiles(store, now)).toEqual(['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(['haiku', 'sonnet']);
expect(ensureDefaultModelProfiles(store, now)).toEqual(['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 } });
});
+4 -5
View File
@@ -12,7 +12,6 @@ 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 };
@@ -29,7 +28,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: ModelName): ModelProfileDocumentV1 => ({
const FIRST_DEFAULT_PROFILE = (model: 'sonnet' | 'opus'): ModelProfileDocumentV1 => ({
schemaVersion: 1, provider: 'claude', model, mode: 'readonly', timeoutMs: 300_000, maxCostUsd: null, tools: [], systemPrompt: null, context: null,
});
@@ -37,17 +36,17 @@ const FIRST_DEFAULT_PROFILE = (model: ModelName): 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: ModelName): ModelProfileDocumentV1 => ({ ...FIRST_DEFAULT_PROFILE(model), timeoutMs: 30 * 60_000 });
const DEFAULT_PROFILE = (model: 'sonnet' | 'opus'): ModelProfileDocumentV1 => ({ ...FIRST_DEFAULT_PROFILE(model), timeoutMs: 30 * 60_000 });
/**
* App configs name 'haiku', 'sonnet' and 'opus' as executors; a model request without a pinned profile uses the profile of that
* App configs name '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 ModelName.options) {
for (const model of ['sonnet', 'opus'] as const) {
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 });
+1 -1
View File
@@ -16,7 +16,7 @@ const bazarr: AppConfig = {
releaseNotesExecutor: 'sonnet',
migrateExecutor: null,
promptHint: null,
postUpgradeExecutor: null, postUpgradeWhen: 'when_needed',
postUpgradeExecutor: null,
notes: null,
apply: false,
};
+2 -5
View File
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { AutoLevel, Executor, ModelName, PostUpgradeWhen } from './steps.ts';
import { AutoLevel, Executor } 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 = ModelName;
const ModelChoice = z.enum(['sonnet', 'opus']);
const cleared = (value: unknown) => (typeof value === 'string' && value.trim() === '' ? null : value);
@@ -41,7 +41,6 @@ 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
@@ -62,8 +61,6 @@ 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(),
});
+1 -2
View File
@@ -88,8 +88,7 @@ export const MigrateRequest = z.strictObject({
image: PinnedImage,
expect_manifest_sha256: Sha256,
backup_id: BackupId,
/** null: the plan keeps the current compose.yaml; only the service's image changes. */
compose_yaml: z.string().min(1).max(65_536).nullable(),
compose_yaml: z.string().min(1).max(65_536),
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>;
+1 -2
View File
@@ -1,6 +1,5 @@
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).
@@ -17,7 +16,7 @@ export type ModelSidecarRunResult =
| { readonly ok: false; readonly kind: ModelFailure; readonly detail: string; readonly costUsd?: number | null };
export const ModelRunRequest = z.strictObject({
executor: ModelName,
executor: z.enum(['sonnet', 'opus']),
profile: z.enum(['readonly', 'observe', 'tools']),
prompt: z.string().min(1).max(400_000),
output_schema: z.record(z.string(), z.unknown()),
+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, postUpgradeWhen: 'when_needed',
postUpgradeExecutor: null,
notes: null,
apply: false,
});
+3 -6
View File
@@ -18,7 +18,6 @@ import type {
SessionOpenRequest,
SessionOpenResult,
SessionLogResult,
AgentLogsRequest,
AgentTextResult,
SessionLogEntry,
SessionSummary,
@@ -26,7 +25,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, ModelName } from './steps.ts';
import type { AppConfig, Executor, JsonValue } 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';
@@ -404,8 +403,6 @@ 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 {
@@ -479,14 +476,14 @@ export interface ModelActionPrompts {
readonly migration?: { readonly summary: string; readonly stepsAfter: readonly string[] };
},
signal?: AbortSignal,
): Promise<{ readonly executor: ModelName; readonly prompt: string }>;
): Promise<{ readonly executor: 'sonnet' | 'opus'; 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: ModelName; readonly prompt: string }>;
): Promise<{ readonly executor: 'sonnet' | 'opus'; readonly prompt: string }>;
}
/** Runs a model (the `claude` CLI logged in with the owner's account, in the model sidecar). Throws ModelError. */
+1 -33
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { buildPostUpgradePrompt, POST_UPGRADE_ANSWER_SCHEMA, PostUpgradeAnswer, suspiciousLogLines } from './post-upgrade.ts';
import { buildPostUpgradePrompt, POST_UPGRADE_ANSWER_SCHEMA, PostUpgradeAnswer } from './post-upgrade.ts';
const context = {
app: 'nextcloud',
@@ -58,35 +58,3 @@ 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']);
});
});
+1 -14
View File
@@ -2,20 +2,7 @@ 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. 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);
}
// allows and lists the rest for the owner. Per app, off by default: then the chain stays scripts only.
export const PostUpgradeAnswer = z.object({
outcome: z
+2 -13
View File
@@ -1,6 +1,4 @@
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
@@ -130,8 +128,7 @@ export const DockerMigrateRequest = z.strictObject({
image: PinnedImage,
backupId: BackupId,
expectedManifestSha256: Sha256,
/** null when the plan keeps the compose file as it is apart from the image. */
composeYaml: z.string().min(1).max(65_536).nullable(),
composeYaml: z.string().min(1).max(65_536),
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),
});
@@ -185,10 +182,6 @@ 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>;
@@ -329,7 +322,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: ModelName,
model: z.enum(['sonnet', 'opus']),
costUsd: z.number().nonnegative(),
});
@@ -361,10 +354,6 @@ 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>;
+1 -16
View File
@@ -3,24 +3,10 @@ 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.)
/** 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 const Executor = z.enum(['script', 'sonnet', 'opus']);
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`. */
@@ -73,7 +59,6 @@ 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. Off per app; by default only for an approved or major update, a migration, or errors in the logs.' },
'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.' },
};
export const COMPONENT_OPERATION_DESCRIPTIONS: Readonly<Record<ComponentOperationId, BlockDescription>> = {
-14
View File
@@ -27,18 +27,4 @@ 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);
});
});
+23 -32
View File
@@ -22,43 +22,34 @@ 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[] {
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;
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 [];
});
}
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);
}
@@ -7,7 +7,6 @@ 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';
@@ -84,15 +83,6 @@ 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,
@@ -145,7 +135,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' || plan.composeYaml === null)) result['composeYaml'] = plan.composeYaml;
if (!('composeYaml' in result) && typeof plan.composeYaml === 'string') 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 }));
}
@@ -161,7 +151,6 @@ 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') {
+1 -2
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, PostUpgradeWhen } from '../domain/steps.ts';
import { AutoLevel, Executor, Json } from '../domain/steps.ts';
import {
WorkflowDraft,
PublishedWorkflowVersion,
@@ -65,7 +65,6 @@ export const AppConfigResponse = z.object({
migrateExecutor: Executor.nullable(),
promptHint: z.string().nullable(),
postUpgradeExecutor: Executor.nullable(),
postUpgradeWhen: PostUpgradeWhen,
notes: z.string().nullable(),
apply: z.boolean(),
});
-13
View File
@@ -95,19 +95,6 @@ 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,6 +74,5 @@ 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),
};
}
-41
View File
@@ -137,47 +137,6 @@ 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)));
+1 -26
View File
@@ -1,5 +1,4 @@
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';
@@ -15,16 +14,11 @@ 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',
@@ -64,8 +58,6 @@ 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) {
@@ -73,25 +65,8 @@ export function createHttpRegistryClient(options: HttpRegistryOptions = {}): Reg
const what = `${location.registry}/${location.path}`;
let token: string | null = null;
/** `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> {
async function request(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> = {};
+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 (`haiku`, `sonnet`, `opus`: the
* `profileRef` of a run's snapshot, or else the latest profile named like the request's executor (`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,58 +82,6 @@ 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 })
@@ -221,18 +169,6 @@ 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,8 +6,7 @@ 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, suspiciousLogLines } from '../domain/post-upgrade.ts';
import type { PostUpgradeReason } from '../domain/post-upgrade.ts';
import { POST_UPGRADE_ANSWER_SCHEMA, PostUpgradeAnswer } 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 {
@@ -16,8 +15,6 @@ 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: {
@@ -87,31 +84,6 @@ 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);
@@ -169,10 +141,9 @@ 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 plannedCompose = parsed.composeYaml;
const composeYaml = plannedCompose === null ? null : parsed.inputs
const composeYaml = parsed.inputs
.filter((input) => input.target === 'compose')
.reduce((text, input) => text.replaceAll(`<input:${input.name}>`, values[input.name] ?? ''), plannedCompose);
.reduce((text, input) => text.replaceAll(`<input:${input.name}>`, values[input.name] ?? ''), parsed.composeYaml);
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,
@@ -224,17 +195,6 @@ 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
@@ -248,7 +208,6 @@ 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;
}
+3 -4
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, post_upgrade_when, notes, apply, project)
release_notes_executor, migrate_executor, prompt_hint, post_upgrade_executor, notes, apply, project)
VALUES (:app, :enabled, :position, :service, :imageRepo, :tagPattern, :auto, :httpUrl, :expectStatus,
:releaseNotesExecutor, :migrateExecutor, :promptHint, :postUpgradeExecutor, :postUpgradeWhen, :notes, :apply, :project)
:releaseNotesExecutor, :migrateExecutor, :promptHint, :postUpgradeExecutor, :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, post_upgrade_when = excluded.post_upgrade_when, notes = excluded.notes, apply = excluded.apply, project = excluded.project`,
post_upgrade_executor = excluded.post_upgrade_executor, notes = excluded.notes, apply = excluded.apply, project = excluded.project`,
).run({
app: config.app,
enabled: config.enabled ? 1 : 0,
@@ -45,7 +45,6 @@ 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,
+1 -14
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', 'post_upgrade_when',
'release_notes_executor', 'migrate_executor', 'prompt_hint', 'notes', 'apply', 'project', 'post_upgrade_executor',
]);
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,19 +120,6 @@ 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,46 +802,6 @@ 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 {
+1 -3
View File
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { AutoLevel, Executor, PostUpgradeWhen } from '../../domain/steps.ts';
import { AutoLevel, Executor } 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,7 +20,6 @@ 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),
@@ -41,7 +40,6 @@ 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, postUpgradeWhen: 'when_needed',
postUpgradeExecutor: null,
notes: null,
apply: true,
});
+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: 'haiku', migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, postUpgradeWhen: 'when_needed', notes: null, apply: false, project: 'bazarr' });
expect(first).toEqual({ ...one, enabled: true, position: 1, releaseNotesExecutor: 'sonnet', migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, notes: null, apply: false, project: 'bazarr' });
expect(second).toMatchObject({ app: 'sonarr', enabled: false, position: 2, notes: 'x' });
});
+2 -3
View File
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { parseProjectName } from '../domain/compose.ts';
import { AutoLevel, DEFAULT_MODEL, Executor, PostUpgradeWhen } from '../domain/steps.ts';
import { AutoLevel, Executor } from '../domain/steps.ts';
import type { AppConfig } from '../domain/steps.ts';
import { compileTagPattern } from '../domain/tags.ts';
@@ -19,11 +19,10 @@ 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(DEFAULT_MODEL),
releaseNotesExecutor: Executor.default('sonnet'),
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),
+1 -3
View File
@@ -42,8 +42,6 @@ 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 {
@@ -120,7 +118,7 @@ const runner = createWorkflowRunner({
functionCatalog,
functionRuntime: createFunctionRuntimeClient({ baseUrl: config.functionRuntimeUrl }),
componentOperations: createComponentOperationExecutor({
registry: createHttpRegistryClient({ rateLimitWaitsMs: REGISTRY_RATE_LIMIT_WAITS_MS }),
registry: createHttpRegistryClient(),
hostBackup,
backupVmid: config.backupVmid,
clock: systemClock,
-6
View File
@@ -17,7 +17,6 @@ import type {
SessionOpenRequest,
SessionOpenResult,
SessionLogResult,
AgentLogsRequest,
AgentTextResult,
SessionSummary,
} from '../domain/executor-api.ts';
@@ -136,7 +135,6 @@ 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]>;
@@ -206,8 +204,4 @@ 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, postUpgradeWhen: 'when_needed',
postUpgradeExecutor: null,
notes: null,
apply: false,
});
-12
View File
@@ -206,18 +206,6 @@ 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)');
+4 -7
View File
@@ -17,7 +17,6 @@ interface Draft {
releaseNotesExecutor: string;
migrateExecutor: string;
postUpgradeExecutor: string;
postUpgradeWhen: string;
promptHint: string;
notes: string;
}
@@ -32,7 +31,6 @@ 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 ?? '',
});
@@ -104,7 +102,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' | 'postUpgradeWhen', label: string, options: readonly string[]) => (
const select = (key: 'auto' | 'releaseNotesExecutor' | 'migrateExecutor' | 'postUpgradeExecutor', label: string, options: readonly string[]) => (
<label>
{label}
<select name={key} value={draft[key]} disabled={pending} onChange={(event) => set(key, event.currentTarget.value)}>
@@ -155,10 +153,9 @@ function ConfigForm({ initial }: { initial: AppConfigView }) {
</div>
{text('httpUrl', 'Check URL', 'empty = none')}
{text('expectStatus', 'Expected HTTP status')}
{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'])}
{select('releaseNotesExecutor', 'Release notes executor', ['sonnet', 'opus'])}
{select('migrateExecutor', 'Repair executor', ['off', 'sonnet', 'opus'])}
{select('postUpgradeExecutor', 'Post-upgrade check executor', ['off', 'sonnet', 'opus'])}
{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>haiku</code>, <code>sonnet</code> or <code>opus</code>: the profile of that name decides which sidecar runs it
Apps pick a model as <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>