Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9045ee0b25 |
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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' });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 } });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -16,7 +16,7 @@ const bazarr: AppConfig = {
|
||||
releaseNotesExecutor: 'sonnet',
|
||||
migrateExecutor: null,
|
||||
promptHint: null,
|
||||
postUpgradeExecutor: null, postUpgradeWhen: 'when_needed',
|
||||
postUpgradeExecutor: null,
|
||||
notes: null,
|
||||
apply: false,
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,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()),
|
||||
|
||||
@@ -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
@@ -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,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']);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>> = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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') {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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)' });
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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,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> = {};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user