Compare commits
34
Commits
foundations
...
master
No files matched your search
@@ -6,6 +6,8 @@ data
|
||||
docs
|
||||
tools
|
||||
deploy
|
||||
!deploy/compose.yaml
|
||||
!deploy/update.sh
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# CI: `npm run check` on every push; on master/foundations also build the image and push it to the Gitea registry.
|
||||
# It does not deploy: VM103 pulls the image with deploy/update.sh (docs/DEPLOY.md).
|
||||
# CI: `npm run check` on every push; on master/foundations and on release tags (v…) also build the image and push it to
|
||||
# the Gitea registry. It does not deploy: that is the deploy workflow, started by hand (docs/DEPLOY.md).
|
||||
name: ci
|
||||
|
||||
on:
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
publish:
|
||||
needs: check
|
||||
if: github.ref_name == 'master' || github.ref_name == 'foundations'
|
||||
if: github.ref_name == 'master' || github.ref_name == 'foundations' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -53,3 +53,5 @@ jobs:
|
||||
# Push only these refs: the daemon is the VM's, so `--all-tags` would also push the local :rollback tag from update.sh.
|
||||
for ref in "${refs[@]}"; do docker push "${ref}"; done
|
||||
docker logout "${REGISTRY}"
|
||||
# Builds run on the VM's daemon: without this its build cache grows until the disk is full.
|
||||
docker builder prune -f --filter until=48h >/dev/null 2>&1 || true
|
||||
@@ -0,0 +1,61 @@
|
||||
# Deploys one published image to VM103, only when started by hand: Actions → deploy → Run workflow, or the Gitea API.
|
||||
# The new image's own deploy/update.sh does the work against /opt/homelab/compose/homelab-upgrade, so its guards stay:
|
||||
# it refuses while a workflow run is in the middle of a step, copies the database first, installs the image's
|
||||
# compose.yaml, waits for healthy and goes back to the previous image and compose file if the new version does not come up.
|
||||
name: deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Image to deploy: a release tag (v2026.10.1), a full commit sha, or latest (master)"
|
||||
required: true
|
||||
default: latest
|
||||
type: string
|
||||
force:
|
||||
description: "Restart even while a workflow run is in the middle of a step, or without a database copy"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
REGISTRY: git.mariuszpacek.pl
|
||||
DEPLOY_DIR: /opt/homelab/compose/homelab-upgrade
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# Only the reviewed definition on master may restart production.
|
||||
if: github.ref_name == 'master'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check the version
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
[[ "$VERSION" =~ ^(latest|v[0-9][0-9A-Za-z._-]{0,40}|[0-9a-f]{40})$ ]] || { echo "version must be latest, a v… tag or a full commit sha, not: $VERSION"; exit 2; }
|
||||
- name: Docker CLI available (talks to the VM daemon through the mounted socket)
|
||||
run: command -v docker >/dev/null || (apt-get update -qq && apt-get install -y -qq docker.io >/dev/null); docker version --format '{{.Client.Version}} -> {{.Server.Version}}'
|
||||
- name: Pull the image
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="${REGISTRY}/${GITHUB_REPOSITORY_OWNER}/homelab-upgrade:${VERSION}"
|
||||
docker pull -q "$image"
|
||||
echo "revision: $(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")"
|
||||
- name: Deploy
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
FORCE: ${{ inputs.force }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="${REGISTRY}/${GITHUB_REPOSITORY_OWNER}/homelab-upgrade:${VERSION}"
|
||||
args=("$VERSION" --install)
|
||||
[ "$FORCE" = true ] && args+=(--force)
|
||||
# root for the Docker socket and the root-owned compose directory; the same path inside, so compose resolves
|
||||
# the project (name, .env, paths) exactly as on the VM.
|
||||
docker run --rm --user 0 --entrypoint /app/deploy/update.sh \
|
||||
-e DEPLOY_DIR="$DEPLOY_DIR" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v "$DEPLOY_DIR:$DEPLOY_DIR" \
|
||||
"$image" "${args[@]}"
|
||||
+4
-2
@@ -5,7 +5,7 @@ ARG NODE_IMAGE=node:24-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d
|
||||
ARG DOCKER_CLI_IMAGE=docker:28.5.2-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d
|
||||
|
||||
# The claude CLI for the model sidecar, pinned and updated deliberately (docs/DESIGN.md, "Wykonawcy modeli").
|
||||
ARG CLAUDE_CODE_VERSION=2.1.267
|
||||
ARG CLAUDE_CODE_VERSION=2.1.293
|
||||
# The codex CLI for the model sidecar, pinned and updated deliberately (docs/DESIGN.md, "Wykonawcy modeli").
|
||||
ARG CODEX_VERSION=0.156.1
|
||||
|
||||
@@ -48,6 +48,8 @@ COPY --from=codex /opt/codex /opt/codex
|
||||
COPY bin ./bin
|
||||
COPY src ./src
|
||||
COPY config ./config
|
||||
RUN chmod -R a+rX ./src ./config ./bin
|
||||
# The deploy workflow runs this image's own update.sh against VM103's compose directory (docs/DEPLOY.md).
|
||||
COPY deploy/compose.yaml deploy/update.sh ./deploy/
|
||||
RUN chmod -R a+rX ./src ./config ./bin ./deploy
|
||||
USER node
|
||||
CMD ["node", "src/main/server.ts"]
|
||||
+18
-15
@@ -1,33 +1,36 @@
|
||||
# Stan zadania — publikacja Git/Arcane
|
||||
# Stan zadania — faza H (wygoda na co dzień)
|
||||
|
||||
## Cel
|
||||
|
||||
Opublikować poprawki z foundations i scalić je do master zgodnie ze zleceniem właściciela.
|
||||
Aplikacja wygodna na co dzień: stan „tu i teraz”, historia wersji, cisza nocna i poranny raport, decyzja z telefonu, szybka nawigacja, logowanie Keycloak z dostępem spoza LAN. Plan i status: `docs/IMPLEMENTATION.md`, „Faza H”.
|
||||
|
||||
## Decyzje
|
||||
|
||||
- Właściciel zatwierdził commit, push i merge do master. Potwierdził konfigurację konta Arcane przez Keycloak dla `pacekm90@gmail.com`; nie weryfikowano jej niezależnie.
|
||||
- Compose: prywatne homelab-compose, master. Executor publikuje Git, Arcane wdraża, verify ocenia wynik; rollback przez git revert. Env, klucze i webhooki pozostają lokalne.
|
||||
- Harmonogramy Europe/Warsaw; krytyczne aplikacje piątek/sobota wieczorem. InfluxDB wyłączony do migracji klientów; Dockge wycofany. Arcane/homelab-upgrade nie synchronizują samych siebie, upgrade-lab direct.
|
||||
- Logowanie: oauth2-proxy (usługa `auth`) jako forwardAuth Traefika, Keycloak realm `home`, klient `homelab-upgrade` (założony przez właściciela), wpuszczany tylko `AUTH_ALLOWED_EMAILS`; sekrety w `.env` na VM103 (root 600). Publiczne tylko `manifest.webmanifest` i `icon.svg`.
|
||||
- Cisza nocna 01:00–07:30, raport o 7:30 (strefa harmonogramów); ustawienia w UI (`#/settings`).
|
||||
- Wdrożenia tylko przyciskiem `deploy` (Actions albo MCP `dispatch_workflow`), nigdy przez Arcane.
|
||||
|
||||
## Zrobione
|
||||
|
||||
- Repo Compose opublikowane: 19 projektów i archiwum Dockge. Autosync i webhooki 18 projektów Arcane; osobne deploy keys RW/RO i przypięte host keys. Token instalacyjny unieważniony (API 401).
|
||||
- Publikacja Git dla apply/migrate/rollback i hl write/up; kontrola SHA, brudnego checkoutu i sekretów inline. Lokalny env przed pushem, env-only rollback przez redeploy. Pilot Jackett i git revert zweryfikowane na VM.
|
||||
- Env 10 projektów przekonwertowane z raw przy identycznym docker compose config. Regexy poprawione, 20 harmonogramów zapisanych i porównanych przez API, 18 aktywnych.
|
||||
- Czyszczenie Dockera: utrzymanie stdin dial-stdio i obsługa chunked; próby obu endpointów udane. Cron: jeden formatter i kandydaci według godzin UTC, obsługa DST i ułamkowych stref. API harmonogramów około 0,34 s, idle CPU około 0,05%.
|
||||
- VM: server homelab-upgrade:git-arcane-20260930-r4, executor ...-r2, pozostałe usługi na b42c3d6. Ostatnia kontrola: wszystkie zdrowe.
|
||||
- Dockge zatrzymany bez usuwania wolumenów. Compose/appdata i 29 kopii (31 ścieżek) zachowane w /srv/appdata/to_delete/20260930; lokalny cache Python w ignorowanym to_delete.
|
||||
- H1–H6 na `master` i na VM103: migawka hosta, historia wersji, raport i cisza nocna, decyzje z telefonu, PWA, `Ctrl+K`, logowanie Keycloak.
|
||||
- Poprawki po pierwszych nocach: migracja z `composeYaml: null` (executor bierze bieżący compose i przypina obraz; `d00c8ad`, Gitea 28.0.0 zaktualizowana), `verify` czeka na zniknięcie starego kontenera przy przebudowie przez Arcane zamiast od razu wołać `model.repair` (`1b8bfc4`).
|
||||
- `version_unknown` u jackett/radarr/sonarr (5–6.10) to był HTTP 429 z ghcr.io przy liście tagów, nie wersja: klient rejestru ponawia po 429 (driver 30 s / 2 min / 5 min, `Retry-After` do 5 min; serwer bez czekania), wdrożone `e060a59`.
|
||||
- Podwójny wpis modelu w runie: węzeł końcowy powtarzał wynik `post_upgrade`; liczony raz (`bcd144f`).
|
||||
- Krok modelu po aktualizacji (`c17b170`): `postUpgradeExecutor: null` znów wyłącza (port workflow czytał null jako sonnet), nowe `postUpgradeWhen` (`when_needed` domyślnie / `always`, migracja 25). `when_needed`: model tylko przy aktualizacji zatwierdzonej przez właściciela, major, migracji albo błędach w ostatnich 200 liniach logów (sesja observe, bez modelu). Na VM103 wyłączone dla 11 aplikacji (arr, jackett, bazarr, jellyfin, ombi, deluge, orcaslicer, homepage, trilium, nextcloud-redis), reszta sonnet + `when_needed`.
|
||||
- Haiku 5.5 jako trzeci model (`haiku`): domyślny dla notatek wydania (szukanie + ocena), naprawy, planu migracji i sprawdzenia po aktualizacji, gdy aplikacja nie wybrała modelu; profil `haiku` w seedzie, migracja 26 (przebudowa `app_config` dla CHECK), CLI w obrazie 2.1.293 (pierwsza wersja znająca `claude-haiku-5-5`). Wdrożone `b29aec3` (8.10); na VM103 wszystkie 22 aplikacje przełączone z `sonnet` na `haiku` (wyłączone zostały wyłączone).
|
||||
- Homelab-compose: whitelist deluge przepuszcza bramę sieci `proxy` (`172.16.0.1/32`), inaczej `verify` dostawał 403.
|
||||
- Proxmox: pełny dysk 30.09 zawiesił pmxcfs (brak vzdump 1–2.10); po restarcie usług PVE kopie działają. VM103: przycięte obrazy (dysk 89% → 64%).
|
||||
|
||||
## Pozostałe
|
||||
|
||||
- Publikacja foundations i scalenie/push master; następnie sprawdzenie CI. Przejście z lokalnych obrazów hotfix na obraz CI wymaga osobnego uruchomienia update.sh.
|
||||
- Migracja klientów Influx przed włączeniem aktualizacji. Dawne zadania właściciela: push HA na telefonie i logowanie Codex. Stary homelab-modernization pozostaje potrzebny przez odwołania w skillach ops i wyłączonej jednostce systemd.
|
||||
- Właściciel loguje się raz w przeglądarce i na telefonie (potwierdzenie Keycloak → aplikacja).
|
||||
- Ze strumienia Git/Arcane: migracja klientów Influx przed włączeniem aktualizacji.
|
||||
- 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
|
||||
|
||||
Wypchnąć foundations, scalić do master i wypchnąć master; sprawdzić wynik CI.
|
||||
W pierwszym runie z modelem sprawdzić, że alias `haiku` w CLI 2.1.293 to Haiku 5.5 (`modelUsage` w odpowiedzi `claude`; próba 8.10 trafiła na limit sesji konta) i czy jakość ocen wystarcza. Dalej obserwować 429 i `when_needed`.
|
||||
|
||||
## Walidacja
|
||||
|
||||
npm run check: 104 pliki, 1240 testów PASS. Testy Git na lokalnych repozytoriach; publikacja/rollback, harmonogramy, API i zdrowie kontenerów sprawdzone na VM w poprzednim etapie.
|
||||
`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).
|
||||
+2
-1
@@ -18,5 +18,6 @@
|
||||
{"app": "ombi", "service": "ombi", "imageRepo": "linuxserver/ombi", "tagPattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://ombi.mariuszpacek.pl/", "expectStatus": 200},
|
||||
{"app": "orcaslicer", "service": "orcaslicer", "imageRepo": "linuxserver/orcaslicer", "tagPattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+-ls[0-9]+$", "auto": "minor", "httpUrl": "https://orca.mariuszpacek.pl/", "expectStatus": 401},
|
||||
{"app": "radarr", "service": "radarr", "imageRepo": "ghcr.io/linuxserver/radarr", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://radarr.mariuszpacek.pl/", "expectStatus": 302},
|
||||
{"app": "sonarr", "service": "sonarr", "imageRepo": "ghcr.io/linuxserver/sonarr", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://sonarr.mariuszpacek.pl/", "expectStatus": 302}
|
||||
{"app": "sonarr", "service": "sonarr", "imageRepo": "ghcr.io/linuxserver/sonarr", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://sonarr.mariuszpacek.pl/", "expectStatus": 302},
|
||||
{"app": "trilium", "service": "trilium", "imageRepo": "triliumnext/trilium", "tagPattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$", "auto": "patch", "httpUrl": "https://trilium.mariuszpacek.pl/api/health-check", "expectStatus": 200, "notes": "0.x: every minor migrates the database one way"}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
# Trilium — VM103
|
||||
|
||||
Usługa `trilium`, obraz `triliumnext/trilium` — TriliumNext, utrzymywany fork `zadam/trilium` (oryginał w trybie utrzymania). Domena `trilium.mariuszpacek.pl` (także `notes.mariuszpacek.pl`), port 8080 tylko w sieci `proxy`, bez portu na hoście. Whitelist traefika jak w bazarr (LAN, VPN, `172.16.0.1/32` dla drivera). Aplikacja ma własne logowanie hasłem.
|
||||
Dane: `/home/node/trilium-data` ← `/srv/appdata/trilium/data` (`document.db` SQLite, `config.ini`, `backup/`, `log/`), właściciel `1000:1000`, tryb `700`. Entrypoint startuje jako root, robi `chown -R node:node /home/node` i uruchamia serwer jako `node` (uid 1000) — nie ustawiaj `user:` w compose.
|
||||
Obraz ma healthcheck (`/api/health-check`) i etykietę OCI `org.opencontainers.image.version` z tagiem.
|
||||
Źródła: [wydania](https://github.com/TriliumNext/Trilium/releases), [dokumentacja](https://docs.triliumnotes.org/), [Docker](https://docs.triliumnotes.org/user-guide/setup/server/installation/docker).
|
||||
|
||||
Wersje: tagi `vX.Y.Z` (bez `-rc`, `main`, `sha-*`, `latest`). Seria 0.x: każdy minor to wydanie z nowymi funkcjami i zwykle z jednokierunkową migracją bazy — stąd auto `patch`, a minor czeka na decyzję właściciela.
|
||||
Migracja bazy przy starcie w jednej transakcji; przed nią Trilium sam zapisuje `backup/backup-before-migration.db`. Historia: 2026-09-30 baza przeniesiona z `/fast-vm/docker/trilium/mariusz` na Proxmoksie (Trilium 0.60, dbVersion 214) i zmigrowana na v0.106.0 (dbVersion 240); kopia sprzed tej migracji: `backup/backup-before-migration-v060.db`.
|
||||
|
||||
Kopia: codzienny vzdump VM103 obejmuje `/srv/appdata`; workflow kopiuje compose przed zmianą. Dodatkowa kopia bazy nie jest wymagana, bo Trilium robi ją sam przed migracją.
|
||||
|
||||
Odbiór: kontener `healthy` na przypiętym obrazie; `/api/health-check` przez HTTPS → 200; w logu `Migration to version N has been successful` (jeśli była migracja) i `All consistency checks passed`; brak `Error` przy starcie. Treść notatek sprawdza właściciel po zalogowaniu.
|
||||
STOP: log pokazuje nieudaną migrację (`migration failed`, crash przy starcie) albo błędy spójności bazy — nie naprawiaj bazy, opisz i zostaw decyzję właścicielowi.
|
||||
Powrót: rollback przywraca compose sprzed aktualizacji, ale starsza wersja odmówi otwarcia bazy po migracji. Wtedy trzeba przy zatrzymanym kontenerze przywrócić `document.db` z `backup/backup-before-migration.db` (albo z vzdumpu) — tylko za zgodą właściciela.
|
||||
@@ -120,6 +120,12 @@
|
||||
"cron": "0 17 * * 6",
|
||||
"mode": "real",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"workflowId": "update-trilium",
|
||||
"cron": "0 16 * * 0",
|
||||
"mode": "real",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+63
-3
@@ -37,13 +37,15 @@ services:
|
||||
traefik.http.routers.homelab-upgrade.rule: 'Host(`upgrade.mariuszpacek.pl`)'
|
||||
traefik.http.routers.homelab-upgrade.entrypoints: "web-secured"
|
||||
traefik.http.routers.homelab-upgrade.priority: "100"
|
||||
traefik.http.routers.homelab-upgrade.middlewares: "homelab-upgrade-lan"
|
||||
traefik.http.routers.homelab-upgrade.middlewares: "homelab-upgrade-login"
|
||||
traefik.http.routers.homelab-upgrade.tls: "true"
|
||||
traefik.http.routers.homelab-upgrade.tls.certresolver: "http"
|
||||
traefik.http.routers.homelab-upgrade.service: "homelab-upgrade"
|
||||
traefik.http.services.homelab-upgrade.loadbalancer.server.port: "8080"
|
||||
# No login yet: LAN only.
|
||||
traefik.http.middlewares.homelab-upgrade-lan.ipwhitelist.sourcerange: "192.168.0.1/24,10.0.0.0/16"
|
||||
# Every request asks the auth service first; without a session the browser goes to Keycloak (service auth below).
|
||||
traefik.http.middlewares.homelab-upgrade-login.forwardauth.address: "http://homelab-upgrade-auth:4180/"
|
||||
traefik.http.middlewares.homelab-upgrade-login.forwardauth.trustForwardHeader: "true"
|
||||
traefik.http.middlewares.homelab-upgrade-login.forwardauth.authResponseHeaders: "X-Auth-Request-Email,X-Auth-Request-User"
|
||||
# Tile on dash.mariuszpacek.pl: homepage discovers it through its own Docker connection (my-docker), no edit of its files.
|
||||
homepage.group: "Infra"
|
||||
homepage.name: "Upgrade"
|
||||
@@ -53,6 +55,59 @@ services:
|
||||
homepage.server: "my-docker"
|
||||
homepage.container: "homelab-upgrade-server-1"
|
||||
|
||||
# Login in front of the app (oauth2-proxy, Traefik forwardAuth with a static upstream): a request without a session
|
||||
# is sent to Keycloak (realm home), and only the e-mail addresses in AUTH_ALLOWED_EMAILS get in. The client secret,
|
||||
# the cookie secret and the addresses live in .env on VM103 (docs/DEPLOY.md, "Logowanie"). The session cookie lasts
|
||||
# 7 days and is refreshed hourly. The manifest and the icon stay public: a phone fetches them without the cookie.
|
||||
auth:
|
||||
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.
|
||||
cap_drop: [ALL]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
environment:
|
||||
OAUTH2_PROXY_PROVIDER: keycloak-oidc
|
||||
OAUTH2_PROXY_OIDC_ISSUER_URL: https://login.mariuszpacek.pl/realms/home
|
||||
OAUTH2_PROXY_CLIENT_ID: ${AUTH_CLIENT_ID:-homelab-upgrade}
|
||||
OAUTH2_PROXY_CLIENT_SECRET: ${AUTH_CLIENT_SECRET:?AUTH_CLIENT_SECRET is not set in .env}
|
||||
OAUTH2_PROXY_COOKIE_SECRET: ${AUTH_COOKIE_SECRET:?AUTH_COOKIE_SECRET is not set in .env}
|
||||
OAUTH2_PROXY_CODE_CHALLENGE_METHOD: S256
|
||||
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
|
||||
OAUTH2_PROXY_SKIP_PROVIDER_BUTTON: "true"
|
||||
OAUTH2_PROXY_SKIP_AUTH_ROUTES: "GET=^/manifest\\.webmanifest$$,GET=^/icon\\.svg$$"
|
||||
OAUTH2_PROXY_COOKIE_NAME: _homelab_upgrade
|
||||
OAUTH2_PROXY_COOKIE_SECURE: "true"
|
||||
OAUTH2_PROXY_COOKIE_EXPIRE: 168h
|
||||
OAUTH2_PROXY_COOKIE_REFRESH: 1h
|
||||
OAUTH2_PROXY_SET_XAUTHREQUEST: "true"
|
||||
OAUTH2_PROXY_SHOW_DEBUG_ON_ERROR: "false"
|
||||
configs:
|
||||
- source: auth-emails
|
||||
target: /etc/oauth2-proxy/emails
|
||||
networks:
|
||||
proxy:
|
||||
# A name no other project on the proxy network uses: Traefik's forwardAuth address points at it.
|
||||
aliases: [homelab-upgrade-auth]
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.docker.network: "proxy"
|
||||
# The sign-in callback and sign-out go straight to the auth service.
|
||||
traefik.http.routers.homelab-upgrade-auth.rule: 'Host(`upgrade.mariuszpacek.pl`) && PathPrefix(`/oauth2/`)'
|
||||
traefik.http.routers.homelab-upgrade-auth.entrypoints: "web-secured"
|
||||
traefik.http.routers.homelab-upgrade-auth.priority: "200"
|
||||
traefik.http.routers.homelab-upgrade-auth.tls: "true"
|
||||
traefik.http.routers.homelab-upgrade-auth.tls.certresolver: "http"
|
||||
traefik.http.routers.homelab-upgrade-auth.service: "homelab-upgrade-auth"
|
||||
traefik.http.services.homelab-upgrade-auth.loadbalancer.server.port: "4180"
|
||||
|
||||
# The driver: runs queued workflow runs one step at a time and pushes to the owner's phone when a run waits for them,
|
||||
# fails, or a real run ends. It finds the running containers by their compose labels (docker ps through the socket proxy
|
||||
# below, GET only) and asks the registries what is newer. It reads no compose project file and changes nothing itself:
|
||||
@@ -205,6 +260,11 @@ services:
|
||||
cap_drop: [ALL]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
|
||||
configs:
|
||||
# One address per line; nobody else gets in, whatever else Keycloak's realm holds.
|
||||
auth-emails:
|
||||
content: ${AUTH_ALLOWED_EMAILS:?AUTH_ALLOWED_EMAILS is not set in .env}
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
name: proxy
|
||||
|
||||
+92
-9
@@ -3,25 +3,37 @@
|
||||
# Lives next to compose.yaml on VM103 (/opt/homelab/compose/homelab-upgrade). See docs/DEPLOY.md.
|
||||
#
|
||||
# ./update.sh deploy the tag from .env (TAG=..., default "latest")
|
||||
# ./update.sh <tag> deploy <tag> (a branch name or a commit sha) and remember it in .env
|
||||
# ./update.sh --force skip the "a workflow run is in the middle of a step" guard
|
||||
# ./update.sh <tag> deploy <tag> (a release tag, a branch name or a commit sha) and remember it in .env
|
||||
# ./update.sh --force skip the "a workflow run is in the middle of a step" guard and a failed database copy
|
||||
# ./update.sh --install first put this script's compose.yaml and update.sh into DEPLOY_DIR: the deploy workflow
|
||||
# runs the copy inside the new image (/app/deploy) against the VM's directory
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$(readlink -f "$0")")"
|
||||
script_dir="$(dirname "$(readlink -f "$0")")"
|
||||
cd "${DEPLOY_DIR:-$script_dir}"
|
||||
|
||||
image_repo=git.mariuszpacek.pl/mariusz.pacek/homelab-upgrade
|
||||
healthy_timeout=${HEALTHY_TIMEOUT:-90}
|
||||
backups_kept=${BACKUPS_KEPT:-5}
|
||||
images_kept=${IMAGES_KEPT:-3}
|
||||
|
||||
tag_arg=""
|
||||
force=0
|
||||
install=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--force) force=1 ;;
|
||||
--install) install=1 ;;
|
||||
-*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
*) tag_arg="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ((install == 1)) && [[ "$(pwd -P)" == "$(readlink -f "$script_dir")" ]]; then
|
||||
echo "--install needs DEPLOY_DIR set to another directory: a script cannot replace itself while it runs" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
env_tag() {
|
||||
[[ -f .env ]] && sed -n 's/^TAG=//p' .env | tail -n 1 || true
|
||||
}
|
||||
@@ -42,6 +54,59 @@ step_in_progress() {
|
||||
esac
|
||||
}
|
||||
|
||||
# A consistent online copy of the database through SQLite's backup API, taken by the running server (it owns /data);
|
||||
# the newest $backups_kept copies stay.
|
||||
backup_database() {
|
||||
docker compose exec -T server node -e "
|
||||
const { DatabaseSync, backup } = require('node:sqlite');
|
||||
const { readdirSync, rmSync } = require('node:fs');
|
||||
const prefix = 'queue.sqlite.before-deploy-';
|
||||
const name = prefix + new Date().toISOString().replace(/[-:]/g, '').replace(/[.][0-9]+Z\$/, 'Z');
|
||||
const db = new DatabaseSync('/data/queue.sqlite', { readOnly: true });
|
||||
backup(db, '/data/' + name).then(() => {
|
||||
db.close();
|
||||
for (const old of readdirSync('/data').filter((file) => file.startsWith(prefix)).sort().reverse().slice($backups_kept)) rmSync('/data/' + old);
|
||||
console.log('database copy: /data/' + name);
|
||||
}, (error) => { console.error(error.message); process.exit(1); });
|
||||
"
|
||||
}
|
||||
|
||||
# The new compose.yaml and update.sh come from the image being deployed; the old compose file is kept, so a version
|
||||
# that does not come up goes back together with the compose file it ran with.
|
||||
install_files() {
|
||||
cp -p compose.yaml compose.yaml.previous
|
||||
install -m 600 "$script_dir/compose.yaml" compose.yaml
|
||||
install -m 755 "$script_dir/update.sh" update.sh
|
||||
if cmp -s compose.yaml.previous compose.yaml; then
|
||||
echo "compose.yaml unchanged"
|
||||
else
|
||||
echo "compose.yaml changed:"
|
||||
diff -u compose.yaml.previous compose.yaml || true
|
||||
fi
|
||||
}
|
||||
|
||||
restore_compose() {
|
||||
if ((install == 1)) && [[ -f compose.yaml.previous ]]; then
|
||||
echo "restoring the previous compose.yaml" >&2
|
||||
cp -p compose.yaml.previous compose.yaml
|
||||
fi
|
||||
}
|
||||
|
||||
# Each CI build and each deploy leaves an image of this app on the VM (over a gigabyte each), and a full disk stops
|
||||
# every app on it. Kept: the one running, the previous one (to go back) and the newest $images_kept; a failure here
|
||||
# never fails the deploy.
|
||||
prune_images() {
|
||||
local running id count=0
|
||||
running=$(docker inspect --format '{{.Image}}' "$(service_container server)" 2>/dev/null || true)
|
||||
# `docker images` lists the newest first; an image with several tags appears once per tag.
|
||||
for id in $(docker images --no-trunc --format '{{.ID}}' "$image_repo" | awk '!seen[$0]++'); do
|
||||
count=$((count + 1))
|
||||
[[ "$id" == "$running" || "$id" == "$previous_image" ]] && continue
|
||||
((count <= images_kept)) && continue
|
||||
docker image rm -f "$id" >/dev/null 2>&1 || true
|
||||
done
|
||||
}
|
||||
|
||||
wait_until_up() {
|
||||
local deadline=$((SECONDS + healthy_timeout)) status
|
||||
while ((SECONDS < deadline)); do
|
||||
@@ -50,9 +115,10 @@ wait_until_up() {
|
||||
sleep 3
|
||||
done
|
||||
[[ "$status" == healthy ]] || return 1
|
||||
sleep 5 # the driver and the socket proxy have no healthcheck: they must still be running, and not restarting, after a few seconds
|
||||
sleep 5 # services without a healthcheck must still be running, and not restarting, after a few seconds
|
||||
local service
|
||||
for service in driver docker-proxy executor model-claude model-codex function-runtime; do
|
||||
for service in $(docker compose config --services); do
|
||||
[[ "$service" == server ]] && continue
|
||||
[[ "$(docker inspect --format '{{.State.Status}} {{.RestartCount}}' "$(service_container "$service")" 2>/dev/null)" == "running 0" ]] || return 1
|
||||
done
|
||||
# The executor has a healthcheck of its own; give it the rest of the time to pass it.
|
||||
@@ -71,25 +137,42 @@ if ((force == 0)) && [[ -n "$(service_container server)" ]] && step_in_progress;
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if [[ -n "$(service_container server)" ]] && backup_database; then
|
||||
:
|
||||
elif ((force == 0)); then
|
||||
echo "could not copy the database (is the server running?); fix it or use --force" >&2
|
||||
exit 5
|
||||
else
|
||||
echo "warning: no database copy (--force)" >&2
|
||||
fi
|
||||
|
||||
previous_image=$(docker inspect --format '{{.Image}}' "$(service_container server)" 2>/dev/null || true)
|
||||
|
||||
echo "pulling $image_repo:$tag"
|
||||
TAG="$tag" docker compose pull
|
||||
TAG="$tag" docker compose up -d --remove-orphans
|
||||
((install == 1)) && install_files
|
||||
|
||||
if wait_until_up; then
|
||||
echo "pulling $image_repo:$tag"
|
||||
if ! TAG="$tag" docker compose pull; then
|
||||
echo "could not pull $image_repo:$tag; nothing was restarted" >&2
|
||||
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
|
||||
# Only the TAG line changes: .env also holds HA_TOKEN and other settings.
|
||||
if [[ -n "$tag_arg" ]]; then
|
||||
touch .env
|
||||
{ grep -v '^TAG=' .env || true; printf 'TAG=%s\n' "$tag"; } > .env.new && chmod --reference=.env .env.new && mv .env.new .env
|
||||
fi
|
||||
revision=$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$(service_container server)")
|
||||
prune_images
|
||||
echo "ok: $image_repo:$tag revision ${revision:-unknown}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "new version did not come up healthy" >&2
|
||||
docker compose logs --tail 30 >&2 || true
|
||||
restore_compose
|
||||
if [[ -n "$previous_image" ]]; then
|
||||
echo "rolling back to $previous_image" >&2
|
||||
docker tag "$previous_image" "$image_repo:rollback"
|
||||
|
||||
+48
-13
@@ -5,7 +5,7 @@ Stan na 2026-09-19: na VM103 działa łańcuch prawdziwej aktualizacji przez sid
|
||||
| | |
|
||||
|---|---|
|
||||
| Adres | https://upgrade.mariuszpacek.pl (traefik, `web-secured`, certyfikat `http`, wildcard DNS na 192.168.0.139) |
|
||||
| Dostęp | tylko LAN: middleware `ipwhitelist` `192.168.0.1/24,10.0.0.0/16`; **appka nie ma logowania** |
|
||||
| Dostęp | z LAN i z internetu, **tylko po zalogowaniu Keycloakiem** (realm `home`, usługa `auth` = oauth2-proxy jako forwardAuth Traefika; sekcja „Logowanie”) |
|
||||
| Manifest | `/opt/homelab/compose/homelab-upgrade/compose.yaml` (kopia `deploy/compose.yaml`) |
|
||||
| Obraz | `git.mariuszpacek.pl/mariusz.pacek/homelab-upgrade:<tag>` z rejestru Gitei, budowany przez Gitea Actions (etykieta `org.opencontainers.image.revision` = sha commita) |
|
||||
| Dashboard | kafelek „Upgrade” w grupie Infra na https://dash.mariuszpacek.pl z etykiet `homepage.*` na `server` (homepage czyta je przez swoje połączenie z Dockerem; jego pliki nie są zmieniane) |
|
||||
@@ -21,20 +21,55 @@ Port 8080 na hoście VM jest zajęty przez inną usługę, dlatego appka nie pub
|
||||
|
||||
## Aktualizacja
|
||||
|
||||
Obieg: `git push` → Gitea Actions (`.gitea/workflows/ci.yaml`): `npm run check`, a na `master` i `foundations` także build i push obrazu do rejestru Gitei → na VM103 jedna komenda:
|
||||
Obieg: `git push` → Gitea Actions (`.gitea/workflows/ci.yaml`): `npm run check`, a na `master`, `foundations` i tagach wydań `v…` także build i push obrazu do rejestru Gitei → wdrożenie **tylko ręcznie**, przyciskiem albo przez API. Arcane nie zarządza tym projektem.
|
||||
|
||||
Wydanie: tag `v<rok>.<miesiąc>.<numer>` (np. `v2026.10.1`) na commicie z `master`, najlepiej jako release w Gitei; CI buduje obraz `:v2026.10.1` (i `:<sha>`). Stopka pokazuje wtedy `v2026.10.1 · build N`.
|
||||
|
||||
Wdrożenie: Gitea → repo → Actions → **deploy** → *Run workflow* (gałąź `master`), pole `version`: tag wydania, pełny sha albo `latest` (ostatni `master`); `force` tylko świadomie. To samo przez API/MCP: `dispatch_workflow` z `workflow_id: deploy.yaml`, `ref: master`, `inputs: { version, force }`; wynik i log w runie.
|
||||
|
||||
Workflow (`.gitea/workflows/deploy.yaml`) działa tylko z definicji na `master`: sprawdza format wersji, pobiera obraz i uruchamia **skrypt z tego obrazu** (`/app/deploy/update.sh --install`) na katalogu `/opt/homelab/compose/homelab-upgrade` przez socket Dockera. `update.sh`:
|
||||
|
||||
1. odmawia (kod 3), gdy instancja silnika jest w stanie `running` (run na bramce lub w kolejce restart przeżywa, przerwany węzeł wznawia się w UI przyciskiem „Retry the step”);
|
||||
2. robi spójną kopię bazy przez działający serwer (`/data/queue.sqlite.before-deploy-<czas>`, zostaje 5 najnowszych); bez kopii odmawia (kod 5);
|
||||
3. z `--install` wgrywa `compose.yaml` i `update.sh` z obrazu (poprzedni compose zostaje jako `compose.yaml.previous`, diff w logu);
|
||||
4. `docker compose pull` (gdy obrazu nie ma: przywraca compose i kończy bez restartu) i `up -d`;
|
||||
5. czeka do 90 s na `healthy` serwera i executora oraz na stabilne pozostałe usługi; gdy nowa wersja nie wstaje, wraca do poprzedniego obrazu (lokalny tag `:rollback`) i poprzedniego `compose.yaml`, nie zmienia `.env` i kończy się kodem 1.
|
||||
|
||||
`--force` pomija krok 1 i pozwala na brak kopii z kroku 2. Wycofanie to ponowne wdrożenie starszej wersji tym samym przyciskiem. Stare obrazy zostają na VM.
|
||||
|
||||
Awaryjnie, gdy Gitea lub runner nie działa, ten sam skrypt ręcznie na VM103:
|
||||
|
||||
```bash
|
||||
cd /opt/homelab/compose/homelab-upgrade
|
||||
./update.sh # tag z .env (domyślnie latest)
|
||||
./update.sh foundations # inny tag (gałąź albo pełny sha) i zapamiętanie go w .env
|
||||
./update.sh --force # pomija blokadę „trwa krok / węzeł runu”
|
||||
./update.sh v2026.10.1 # inna wersja (tag, gałąź albo pełny sha) i zapamiętanie jej w .env
|
||||
./update.sh --force # pomija blokadę „trwa krok / węzeł runu” i brak kopii bazy
|
||||
```
|
||||
|
||||
`update.sh` (kopia `deploy/update.sh`): odmawia restartu, gdy driver ma krok starego łańcucha w stanie `running` albo instancja nowego silnika jest w stanie `running` (kod 3; run czekający na bramce lub w kolejce restart przeżywa, a przerwany węzeł wznawia się w UI przyciskiem „Retry the step”); robi `docker compose pull` i `up -d`; czeka do 90 s na `healthy` serwera i sprawdza, że driver i `docker-proxy` działają i się nie restartowały. Gdy nowa wersja nie wstaje, wraca do poprzedniego obrazu (lokalny tag `:rollback`), nie zmienia `.env` i kończy się kodem 1. Stare obrazy zostają na VM, więc wycofanie do dowolnego commita to `./update.sh <sha>`.
|
||||
Ręczny `update.sh` nie wgrywa `compose.yaml`; po zmianie `deploy/compose.yaml` wdrażaj przyciskiem albo skopiuj plik.
|
||||
|
||||
CI niczego nie wdraża: deploy jest świadomym krokiem na VM (na razie bez timera, bo restart w trakcie kroku byłby groźny dopiero po dodaniu prawdziwych handlerów).
|
||||
Przy zmianie schematu bazy nowa wersja migruje bazę przy starcie (migracje są transakcyjne; nowszej bazy niż kod odmawia otwarcia). Wycofanie do starszego obrazu po zmianie schematu nie zadziała, bo starszy kod odmówi otwarcia nowszej bazy: wtedy przywraca się kopię `queue.sqlite.before-deploy-*` z kroku 2.
|
||||
|
||||
Przy zmianie schematu bazy nowa wersja migruje bazę przy starcie (migracje są transakcyjne; nowszej bazy niż kod odmawia otwarcia). Wycofanie do starszego obrazu po zmianie schematu nie zadziała, bo starszy kod odmówi otwarcia nowszej bazy: przed takim wdrożeniem zrób kopię `queue.sqlite*`.
|
||||
### Logowanie (Keycloak)
|
||||
|
||||
Usługa `auth` (oauth2-proxy, `deploy/compose.yaml`) stoi przed aplikacją: Traefik pyta ją o każde zapytanie (forwardAuth); bez sesji przeglądarka idzie do `https://login.mariuszpacek.pl/realms/home`, po powrocie wpuszczane są tylko adresy z `AUTH_ALLOWED_EMAILS`. Sesja trwa 7 dni (odświeżana co godzinę), wylogowanie: `https://upgrade.mariuszpacek.pl/oauth2/sign_out`. Publiczne zostają tylko `manifest.webmanifest` i `icon.svg`.
|
||||
|
||||
Jednorazowo w Keycloaku (realm `home`):
|
||||
|
||||
1. Clients → Create client: typ OpenID Connect, Client ID `homelab-upgrade`; Client authentication **on**, Standard flow **on**, Direct access grants off.
|
||||
2. Valid redirect URIs `https://upgrade.mariuszpacek.pl/oauth2/callback`, Web origins `https://upgrade.mariuszpacek.pl`.
|
||||
3. Client scopes → `homelab-upgrade-dedicated` → Add mapper → By configuration → **Audience**: Included Client Audience `homelab-upgrade`, Add to access token on (oauth2-proxy sprawdza audience).
|
||||
4. Credentials → skopiuj Client secret.
|
||||
|
||||
W `/opt/homelab/compose/homelab-upgrade/.env` na VM103 (root, 600):
|
||||
|
||||
```bash
|
||||
AUTH_CLIENT_SECRET=<client secret z Keycloaka>
|
||||
AUTH_COOKIE_SECRET=<openssl rand -base64 32 | tr -- '+/' '-_'>
|
||||
AUTH_ALLOWED_EMAILS=<adres e-mail konta w Keycloaku>
|
||||
```
|
||||
|
||||
Bez tych trzech wartości `docker compose` odmawia (wdrożenie kończy się bez restartu, `update.sh` przywraca poprzedni `compose.yaml`).
|
||||
|
||||
### Jednorazowa konfiguracja
|
||||
|
||||
@@ -43,7 +78,7 @@ Przy zmianie schematu bazy nowa wersja migruje bazę przy starcie (migracje są
|
||||
3. **Sekret repo** `REGISTRY_TOKEN` (repo → Settings → Actions → Secrets): token `write:package`.
|
||||
4. **Runner** (`deploy/runner/`, na VM103 w `/opt/homelab/compose/gitea-runner/`; dane w `/srv/appdata/gitea-runner/data`). Token rejestracyjny: repo → Settings → Actions → Runners → Create new runner (token na poziomie repo zamiast całej instancji). Runner ma `docker.sock`, czyli praktycznie root na VM: rejestruj go tylko dla tego repo.
|
||||
5. **`docker login`** na VM103: pomijamy, bo pull jest anonimowy. Gdyby pakiet stał się prywatny: `docker login git.mariuszpacek.pl -u mariusz.pacek` tokenem `read:package`.
|
||||
6. **Przełączenie z lokalnego obrazu**: skopiuj `deploy/compose.yaml` i `deploy/update.sh` do `/opt/homelab/compose/homelab-upgrade/`, po pierwszym zielonym buildzie na `foundations` uruchom `./update.sh foundations`. Kopie trzeba odświeżyć ręcznie, gdy te dwa pliki się zmienią.
|
||||
6. **Przełączenie z lokalnego obrazu**: skopiuj `deploy/compose.yaml` i `deploy/update.sh` do `/opt/homelab/compose/homelab-upgrade/`, po pierwszym zielonym buildzie uruchom `./update.sh <sha>`. Później oba pliki wgrywa workflow deploy.
|
||||
|
||||
Ograniczenia, które warto znać: job dostaje repo z `http://192.168.0.139:13080` (port Gitei na VM), bo whitelist traefika nie obejmuje sieci jobów; `docker push` idzie z demona VM, więc źródłowy adres to 192.168.0.139 z whitelisty Gitei.
|
||||
|
||||
@@ -63,7 +98,7 @@ Notatki wydań nie są ustawieniem: są w **skillu** projektu (Apps → aplikacj
|
||||
|
||||
Jedno ustawienie w Apps → aplikacja, domyślnie wyłączone:
|
||||
|
||||
- **Post-upgrade check executor**: `sonnet` / `opus` = po udanym `verify` model sprawdza, czy aktualizacja wymaga czynności poinstalacyjnych (czyta ocenę notatek, skill i stan usługi; co robić po aktualizacji, można dopisać w skillu). Model wykona tylko to, co dopuszcza `config/agent-exec.json` (dokładne komendy, bez wildcardów); reszta trafia do właściciela jako STOP `migration_required`. Dziś lista ma tylko komendy Nextclouda: kontrola odbiorcza ze skilla (tylko odczyt: `php occ status` z `--output=json` i bez, `php occ config:app:get core lastcron`, `php occ app:list`, `pidof crond` w usłudze `cron`) oraz to, co po upgrade brakuje w bazie (`php occ db:add-missing-indices`, `db:add-missing-columns`, `db:add-missing-primary-keys`; użytkownik `www-data`, usługa `app`). Model widzi listę przez `hl exec-rules` i nie zgaduje. Dla pozostałych aplikacji ten krok tylko wypisze, co zrobić ręcznie.
|
||||
- **Post-upgrade check executor**: `haiku` (domyślny) / `sonnet` / `opus` = po udanym `verify` model sprawdza, czy aktualizacja wymaga czynności poinstalacyjnych (czyta ocenę notatek, skill i stan usługi; co robić po aktualizacji, można dopisać w skillu). Model wykona tylko to, co dopuszcza `config/agent-exec.json` (dokładne komendy, bez wildcardów); reszta trafia do właściciela jako STOP `migration_required`. Dziś lista ma tylko komendy Nextclouda: kontrola odbiorcza ze skilla (tylko odczyt: `php occ status` z `--output=json` i bez, `php occ config:app:get core lastcron`, `php occ app:list`, `pidof crond` w usłudze `cron`) oraz to, co po upgrade brakuje w bazie (`php occ db:add-missing-indices`, `db:add-missing-columns`, `db:add-missing-primary-keys`; użytkownik `www-data`, usługa `app`). Model widzi listę przez `hl exec-rules` i nie zgaduje. Dla pozostałych aplikacji ten krok tylko wypisze, co zrobić ręcznie.
|
||||
|
||||
Schemat bazy przechodzi na v10 (v8 dodał `post_upgrade_executor`, v9 zdejmuje niepotrzebną `release_notes_source`, v10 dodaje niepodłączony jeszcze katalog i instancje nowego silnika workflow); starszy obraz jej nie otworzy, więc przed wdrożeniem kopia `queue.sqlite*`.
|
||||
|
||||
@@ -156,13 +191,13 @@ Ręczny powrót do dowolnej kopii: STOP w kolejce → rollback, albo z kroku z `
|
||||
|
||||
## Arcane i repo compose
|
||||
|
||||
Repozytorium `ssh://git@git.mariuszpacek.pl:2244/mariusz.pacek/homelab-compose.git` jest prywatne, na `master`, zawiera 19 katalogów projektów z samym `compose.yaml`, archiwum `to_delete/dockge` oraz `.gitignore`; pliki `.env` i sekrety nie są śledzone. Główny checkout VM103: `/srv/appdata/homelab-upgrade/git/repo`. Dziesięć compose używających `env_file` w formacie `raw` skonwertowano przez `tools/compose-env-compat.py`; pełny wynik `docker compose config` porównano ściśle, wartości lokalne pozostały bez zmian.
|
||||
Repozytorium `ssh://git@git.mariuszpacek.pl:2244/mariusz.pacek/homelab-compose.git` jest prywatne, na `master`, zawiera 20 katalogów projektów z samym `compose.yaml`, archiwum `to_delete/dockge` oraz `.gitignore`; pliki `.env` i sekrety nie są śledzone. Główny checkout VM103: `/srv/appdata/homelab-upgrade/git/repo`. Dziesięć compose używających `env_file` w formacie `raw` skonwertowano przez `tools/compose-env-compat.py`; pełny wynik `docker compose config` porównano ściśle, wartości lokalne pozostały bez zmian.
|
||||
|
||||
Arcane v2.13.1 działa pod `https://arcane.mariuszpacek.pl`. Projekty używają Git Sync w trybie **Deploy** (w UI: Pull), ścieżka `<projekt>/compose.yaml`, `syncDirectory=false`, interwał 5 minut. Autosync jest włączony dla wszystkich 18 projektów; stosy Arcane i `homelab-upgrade` są wyłączone, a `upgrade-lab` działa bezpośrednio. Wszystkie manifesty projektów zawierają etykietę `com.getarcaneapp.arcane.updater: "false"`. Arcane sam wykonuje redeploy projektu po zmianie compose, więc executor w trybie git nie uruchamia `pull/up`.
|
||||
Arcane v2.13.1 działa pod `https://arcane.mariuszpacek.pl`. Projekty używają Git Sync w trybie **Deploy** (w UI: Pull), ścieżka `<projekt>/compose.yaml`, `syncDirectory=false`, interwał 5 minut. Autosync jest włączony dla wszystkich 19 projektów; stosy Arcane i `homelab-upgrade` są wyłączone, a `upgrade-lab` działa bezpośrednio. Wszystkie manifesty projektów zawierają etykietę `com.getarcaneapp.arcane.updater: "false"`. Arcane sam wykonuje redeploy projektu po zmianie compose, więc executor w trybie git nie uruchamia `pull/up`.
|
||||
|
||||
Początkowe synchronizacje 18 projektów zachowały SHA plików env i nie zmieniły startów kontenerów; Arcane może utworzyć pusty `.env`. Źródła nie wymagały restartu podczas importu. Test Jackett przez API executora potwierdził ścieżkę komentarz → commit `47c2b7f` → push → udany sync Arcane i HTTP 301; rollback executora wykonał `git revert`, przywrócił pierwotny SHA i zakończył się powodzeniem.
|
||||
Początkowe synchronizacje 18 projektów (i Trilium, dołączonego 2026-09-30) zachowały SHA plików env i nie zmieniły startów kontenerów; Arcane może utworzyć pusty `.env`. Źródła nie wymagały restartu podczas importu. Test Jackett przez API executora potwierdził ścieżkę komentarz → commit `47c2b7f` → push → udany sync Arcane i HTTP 301; rollback executora wykonał `git revert`, przywrócił pierwotny SHA i zakończył się powodzeniem.
|
||||
|
||||
`config/workflow-schedules.json` rozkłada starty w strefie `Europe/Warsaw` (czas polski): poniedziałek Bazarr 19:00, Jackett 21:00; wtorek Radarr 19:00, Sonarr 21:00; środa Deluge 19:00, Ombi 21:00; czwartek Homepage 19:00, OrcaSlicer 21:00; piątek Gitea 18:00, baza 19:00, Keycloak 20:00, baza 22:00; sobota Home Assistant 18:00, Nextcloud 20:00, baza 22:00, Redis 23:00; niedziela Grafana 18:00, Jellyfin 20:00. InfluxDB i Dockge są wyłączone.
|
||||
`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.
|
||||
|
||||
Konfiguracja prywatna `/srv/appdata/homelab-upgrade/git-config/deployments.json` jest montowana jako `/git-config` tylko do odczytu, a `GIT_DEPLOYMENTS_FILE=/git-config/deployments.json`. Schemat v1: `repoPath=/git/repo`, `branch=master`, `sshKeyPath=/git-ssh/id_ed25519`, `knownHostsPath=/git-ssh/known_hosts`; projekty mają tryb `direct` albo `git`, `webhookUrl` i opcjonalny `redeployWebhookUrl`. Executor ma checkout do zapisu, katalog `git-ssh` tylko do odczytu oraz sieci `executor` i `default`; git i OpenSSH są częścią obrazu. Klucz executora z prawem zapisu znajduje się w `/srv/appdata/homelab-upgrade/git-ssh/id_ed25519`, klucz Arcane tylko do odczytu w `/srv/appdata/arcane/git-ssh/id_ed25519`. `known_hosts` przypina trzy publiczne algorytmy Gitei porównane z plikiem `.pub` kontenera. Nie umieszczaj tokenów ani webhook URL zawierających sekrety w dokumentacji, repo ani logach.
|
||||
|
||||
|
||||
+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” (sonnet/opus), `verify` sam kolejkuje `repair` (jedna próba; drugi nieudany `verify` = STOP dla właściciela). Na dowolnym STOP właściciel może wybrać „Repair (model)”.
|
||||
- Krok `repair`: gdy `verify` nie przeszedł, a aplikacja ma „Migration executor” (haiku/sonnet/opus; bez wyboru haiku), `verify` sam kolejkuje `repair` (jedna próba; drugi nieudany `verify` = STOP dla właściciela). Na dowolnym STOP właściciel może wybrać „Repair (model)”.
|
||||
- Kontener `model`: niezmodyfikowany `claude` CLI (przypięta wersja w obrazie) zalogowany kontem właściciela (`CLAUDE_CONFIG_DIR=/claude`). Bez `docker.sock`, bez plików projektów i bazy.
|
||||
- Model działa tylko przez narzędzie `hl` w sesji executora otwartej na jeden projekt: `ps`, `logs`, `compose` (sekrety jako `<secret:KLUCZ>`), `write`, `pull`, `up`, `restart`, `exec-rules` (lista komend, które `exec` w ogóle przyjmie: model patrzy, zamiast zgadywać), `exec` w kontenerach tego projektu. Egzekwowane w kodzie (`domain/agent-guard.ts`): zapis compose sprawdzany listą zakazów z „Granic” względem manifestu z otwarcia sesji; wyjścia maskowane wartościami sekretów projektu; hook `PreToolUse` przepuszcza w Bash tylko `hl`, a Read/Edit/Write tylko w katalogu sesji; trasy drivera w executorze i `/run` modelu odpowiadają tylko adresowi drivera. Każde wywołanie jest w logu sesji i w wyniku kroku.
|
||||
- Wyjątek za zgodą właściciela (2026-09-23): na bramce po nieudanej naprawie przycisk „Repair with full access” uruchamia jedną ponowną naprawę w sesji `full`. W niej, tylko w tym projekcie, `exec` przyjmuje dowolną komendę i użytkownika, `run` uruchamia jednorazowy kontener usługi z jej wolumenami (np. `chown` katalogu danych przy crash-loopie), a `write` sprawdza tylko poprawność compose, bez listy zakazów. Maskowanie sekretów, log sesji, kopia i rollback działają dalej. To jedna próba na run: po niej zostaje rollback albo skip.
|
||||
|
||||
@@ -156,3 +156,29 @@ Decyzje: zostajemy przy własnym silniku (BPMN nie daje gwarancji projektu: jede
|
||||
- **8** widoki na telefonie: nawigacja się zawija, tabele chowają kolumny drugorzędne poniżej 720 px, przyciski decyzji na pełną szerokość, niższy graf.
|
||||
- **9** Arcane i repo compose: 9a–9e wykonane. Prywatne repo `homelab-compose` na `master` zawiera 19 projektów i archiwum Dockge; Arcane v2.13.1 ma włączony autosync co 5 minut dla 18 projektów, a executor obsługuje publikację i rollback git oraz `hl write/up`. Dockge zatrzymano, a jego compose/appdata i 29 starych kopii przeniesiono do `/srv/appdata/to_delete/20260930`; 20 wpisów harmonogramu zapisanych i sprawdzonych przez API.
|
||||
- Podglądy harmonogramów: `nextCronRuns` oblicza kandydatów według godziny UTC z jednym formatterem Intl zamiast tworzyć formatter dla każdego kandydata; zachowane są obsługa DST, rozdzielczość 30 minut i strefy takie jak Asia/Kathmandu. Hotfix wdrożono na lokalnym obrazie; przejście na obraz CI wymaga `update.sh`. `npm run check`: 104 pliki, 1240 testów PASS.
|
||||
|
||||
### Faza G — nowoczesny, graficzny frontend (zatwierdzona przez właściciela 2026-09-30)
|
||||
|
||||
Decyzje: sidebar + karty, ciemny motyw domyślny z przełącznikiem; wykresy w ECharts (rejestrowane tylko potrzebne części, osobny chunk ładowany leniwie); własne okno potwierdzeń, panel boczny i toasty zamiast `window.confirm`; bez zmian w backendzie — agregacje w przeglądarce z istniejących endpointów (`src/web/stats.ts`), budowniczki opcji wykresów jako czyste funkcje (`src/web/charts/options.ts`), kolory z tokenów CSS. Gałąź `frontend-refresh`.
|
||||
|
||||
- **G0** powłoka: sidebar z ikonami (na telefonie górny pasek), tokeny powierzchni/cieni, `Card`, `StatTile`, `Icon`, przełącznik motywu.
|
||||
- **G1** okna: `DialogProvider` (`useConfirm`, `useToast`), `Drawer`; podgląd runu w panelu z listy Runs.
|
||||
- **G2** ECharts i agregacje: runy dziennie wg statusu, wskaźnik sukcesu z okresem poprzednim, czasy trwania, koszt dziennie wg modelu; paleta kategorii sprawdzona pod daltonizm.
|
||||
- **G3** Overview: kafle KPI, aktywność 30 dni, harmonogramy na 7 dni.
|
||||
- **G4** Apps: karty projektów (wersje, 10 ostatnich runów, następny run), przełącznik karty/tabela; strona aplikacji z wykresem czasu trwania runów.
|
||||
- **G5** strona runu: Gantt kroków (klik zaznacza węzeł), historia w oknie.
|
||||
- **G6** Models: koszt dziennie wg modelu, udział modeli, wyniki wg rodzaju pytania; edycja profilu w panelu bocznym.
|
||||
- **G7** porządki stylów, dokumentacja. Przegląd na żywo w przeglądarce jeszcze nie zrobiony (brak przeglądarki na hoście).
|
||||
|
||||
### Faza H — wygoda na co dzień (zatwierdzona przez właściciela 2026-09-30)
|
||||
|
||||
Decyzje: logowanie Keycloak dla wszystkich (oauth2-proxy jako forwardAuth Traefika) i dostęp spoza LAN; cisza nocna 01:00–07:30 (wieczorne awarie pushują od razu), w ciszy nic nie dzwoni, bramki i błędy trafiają do porannego raportu o 7:30; godziny zmienia się w UI. Etapy wdrażane przyciskiem `deploy` bez osobnej zgody.
|
||||
|
||||
- **H1** stan „tu i teraz”: driver co minutę zbiera `docker ps` kontenerów compose (przez proxy tylko do odczytu) i rekord vzdump, zapisuje migawkę w `settings`; Overview pokazuje zdrowie aplikacji i wiek kopii VM.
|
||||
- **H2** historia wersji aplikacji (z runów: z czego na co, którym runem, naprawy i rollbacki) i oznaczenie aplikacji, które często wymagają uwagi.
|
||||
- **H3** ustawienia powiadomień w UI, cisza nocna i poranny raport (push + karta „Ostatnia noc” na Overview).
|
||||
- **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.
|
||||
Generated
+26
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/static": "^10.1.4",
|
||||
"cytoscape": "^3.34.3",
|
||||
"echarts": "^6.1.0",
|
||||
"fastify": "^5.12.5",
|
||||
"monaco-editor": "^0.56.0",
|
||||
"preact": "^10.29.8",
|
||||
@@ -2337,6 +2338,16 @@
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
|
||||
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.432",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.432.tgz",
|
||||
@@ -4308,6 +4319,12 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -4759,6 +4776,15 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
|
||||
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/static": "^10.1.4",
|
||||
"cytoscape": "^3.34.3",
|
||||
"echarts": "^6.1.0",
|
||||
"fastify": "^5.12.5",
|
||||
"monaco-editor": "^0.56.0",
|
||||
"preact": "^10.29.8",
|
||||
|
||||
@@ -89,6 +89,38 @@ describe('component-operation-executor: verify', () => {
|
||||
expect(calls).toBe(4);
|
||||
});
|
||||
|
||||
it('retries while compose still has the old container next to the new one, then passes', async () => {
|
||||
const clock = new FakeClock(NOW);
|
||||
const executor = new FakeExecutorClient();
|
||||
let calls = 0;
|
||||
executor.on('inspect', () => {
|
||||
calls += 1;
|
||||
return calls <= 2
|
||||
? { outcome: 'refused', reason: 'multiple_containers', detail: 'service "jackett" of "jackett" has 2 containers' }
|
||||
: { outcome: 'done', result: inspectResult({ state: 'running', health: 'healthy', image: PINNED }) };
|
||||
});
|
||||
const app = createComponentOperationExecutor({ clock, executor, backupVmid: 103 });
|
||||
|
||||
const outcome = await settle(app.execute('verify', 1, verifyInput()), clock);
|
||||
expect(outcome).toMatchObject({ event: 'healthy', result: { healthy: true, attempts: 3 } });
|
||||
});
|
||||
|
||||
it('gives up on a service that keeps two containers after the usual attempts, and at once on any other refusal', async () => {
|
||||
const clock = new FakeClock(NOW);
|
||||
const executor = new FakeExecutorClient();
|
||||
executor.on('inspect', { outcome: 'refused', reason: 'multiple_containers', detail: 'service "jackett" of "jackett" has 2 containers' });
|
||||
const app = createComponentOperationExecutor({ clock, executor, backupVmid: 103 });
|
||||
|
||||
const outcome = await settle(app.execute('verify', 1, verifyInput()), clock);
|
||||
expect(outcome).toMatchObject({ event: 'unhealthy', result: { healthy: false, reason: 'inspect_unavailable', attempts: 24 } });
|
||||
|
||||
const other = new FakeExecutorClient();
|
||||
other.on('inspect', { outcome: 'refused', reason: 'unknown_service', detail: 'no service' });
|
||||
const once = await settle(createComponentOperationExecutor({ clock, executor: other, backupVmid: 103 }).execute('verify', 1, verifyInput()), clock);
|
||||
expect(once).toMatchObject({ event: 'unhealthy', result: { healthy: false, reason: 'inspect_unavailable', detail: 'no service' } });
|
||||
expect(other.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('gives up after 24 unhealthy attempts, 5s apart, once the container is no longer starting', async () => {
|
||||
const clock = new FakeClock(NOW);
|
||||
const executor = new FakeExecutorClient();
|
||||
@@ -249,17 +281,17 @@ describe('component operation executor: migration_plan', () => {
|
||||
.on('sessionCompose', { outcome: 'done', result: { text: 'services:\n influxdb:\n image: influxdb:1.8\n' } })
|
||||
.on('closeSession', { outcome: 'done', result: { project: 'grafana', service: 'influxdb', manifest_changed: false, manifest_sha256: 'a'.repeat(64), log: [{ at: NOW, op: 'compose', ok: true, detail: 'read' }] } });
|
||||
|
||||
it('has opus look at the app in an observe session and hands over the plan with its digest', async () => {
|
||||
it('has haiku look at the app in an observe session and hands over the plan with its digest', async () => {
|
||||
const model = new FakeModelExecutor().reply({ output: plan, costUsd: 0.4 });
|
||||
const executor = observer();
|
||||
const done = await createComponentOperationExecutor({ backupVmid: 103, clock: new FakeClock(NOW), skills, model, executor }).execute('migration_plan', 1, context);
|
||||
|
||||
expect(done).toMatchObject({
|
||||
event: 'migration_needed',
|
||||
result: { plan: { needed: true, inputs: [{ name: 'ADMIN_PASSWORD' }] }, model: 'opus', costUsd: 0.4, actions: ['compose: read'], composeBefore: 'services:\n influxdb:\n image: influxdb:1.8\n' },
|
||||
result: { plan: { needed: true, inputs: [{ name: 'ADMIN_PASSWORD' }] }, model: 'haiku', costUsd: 0.4, actions: ['compose: read'], composeBefore: 'services:\n influxdb:\n image: influxdb:1.8\n' },
|
||||
});
|
||||
expect((done.result as { planDigest: string }).planDigest).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(model.requests[0]).toMatchObject({ executor: 'opus', profile: 'observe', sessionToken: 'observe-token' });
|
||||
expect(model.requests[0]).toMatchObject({ executor: 'haiku', profile: 'observe', sessionToken: 'observe-token' });
|
||||
expect(model.requests[0]?.prompt).toContain('Before update: create an admin user');
|
||||
expect(executor.calls.map((call) => call.op)).toEqual(['openSession', 'sessionCompose', 'closeSession']);
|
||||
expect(executor.calls[0]?.request).toEqual({ project: 'grafana', service: 'influxdb', mode: 'observe' });
|
||||
|
||||
@@ -4,7 +4,7 @@ import { resolveImageRef } from './ops/image-resolve.ts';
|
||||
import { backupVerdict, HostBackupRecord } from '../domain/backup-check.ts';
|
||||
import { isWithinAuto } from '../domain/version.ts';
|
||||
import { describeError } from '../domain/mask.ts';
|
||||
import { nextVerifyAction, VERIFY_INTERVAL_MS, verifyChecks } from '../domain/verify.ts';
|
||||
import { nextVerifyAction, VERIFY_ATTEMPTS, VERIFY_INTERVAL_MS, verifyChecks } from '../domain/verify.ts';
|
||||
import type { VerifyObservation } from '../domain/verify.ts';
|
||||
import type { Clock, ExecutorClient, ModelExecutor, PrivilegedActionRun, RegistryClient, SkillStore } from '../domain/ports.ts';
|
||||
import type { SessionLogEntry } from '../domain/executor-api.ts';
|
||||
@@ -15,7 +15,7 @@ import { buildMigrationPlanPrompt, MIGRATION_PLAN_SCHEMA, MigrationPlan, migrati
|
||||
import { ModelError } from '../domain/ports.ts';
|
||||
import type { GroupMember } from './ops/group.ts';
|
||||
import type { ComponentOperationId } from '../domain/workflow-component-operation.ts';
|
||||
import { toJson } from '../domain/steps.ts';
|
||||
import { modelOr, toJson } from '../domain/steps.ts';
|
||||
import type { JsonValue } from '../domain/steps.ts';
|
||||
import { validateImage } from '../domain/compose-edit.ts';
|
||||
import { splitImage } from '../domain/image-ref.ts';
|
||||
@@ -229,7 +229,12 @@ export function createComponentOperationExecutor(deps: ComponentOperationExecuto
|
||||
for (let attempt = 1; ; ) {
|
||||
const inspected = await executor.inspect({ project, service }, signal);
|
||||
if (inspected.outcome !== 'done') {
|
||||
return { event: 'unhealthy', result: { healthy: false, reason: 'inspect_unavailable', detail: inspected.detail } };
|
||||
// While compose (or Arcane, for a Git project) recreates the service, the old container still sits next to the new one.
|
||||
const transient = inspected.outcome === 'refused' && inspected.reason === 'multiple_containers' && attempt < VERIFY_ATTEMPTS;
|
||||
if (!transient) return { event: 'unhealthy', result: { healthy: false, reason: 'inspect_unavailable', detail: inspected.detail, attempts: attempt } };
|
||||
attempt += 1;
|
||||
await deps.clock.sleep(VERIFY_INTERVAL_MS, signal);
|
||||
continue;
|
||||
}
|
||||
const { container } = inspected.result;
|
||||
const http = httpUrl === null || expectStatus === null ? null : await probe(httpUrl, expectStatus);
|
||||
@@ -280,7 +285,7 @@ async function releaseNotes(deps: ComponentOperationExecutorDeps, record: Record
|
||||
versionChange: firstString(record['change'], selection['change'], 'unknown'),
|
||||
auto: firstString(record['auto'], 'none'),
|
||||
promptHint: typeof record['promptHint'] === 'string' ? record['promptHint'] : null,
|
||||
model: record['releaseNotesExecutor'] === 'opus' ? 'opus' : 'sonnet',
|
||||
model: modelOr(record['releaseNotesExecutor']),
|
||||
group: async () => (groupMembers === undefined ? [] : [...await groupMembers(app, signal)]),
|
||||
}, signal);
|
||||
|
||||
@@ -316,7 +321,7 @@ async function migrationPlan(deps: ComponentOperationExecutorDeps, record: Recor
|
||||
const project = asString(record['project']);
|
||||
const service = asString(record['service']);
|
||||
const app = firstString(record['component'], record['app'], project);
|
||||
const executorName = record['migrateExecutor'] === 'sonnet' ? 'sonnet' : 'opus';
|
||||
const executorName = modelOr(record['migrateExecutor']);
|
||||
const nextSteps = asRecord(notes['report'])['nextSteps'];
|
||||
const prompt = buildMigrationPlanPrompt({
|
||||
app, project, service,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isQuiet, sendDigestIfDue } from './digest.ts';
|
||||
import { createSqliteNotificationSettingsStore } from '../infra/sqlite/notification-settings-store.ts';
|
||||
import { openDatabase } from '../infra/sqlite/open.ts';
|
||||
import { FakeClock } from '../testing/fake-clock.ts';
|
||||
import { RecordingNotifier } from '../testing/fakes.ts';
|
||||
|
||||
// 05:30 UTC is 07:30 in Warsaw (summer time).
|
||||
function setup(start: string) {
|
||||
const clock = new FakeClock(start);
|
||||
const settings = createSqliteNotificationSettingsStore(openDatabase(':memory:'));
|
||||
const notifier = new RecordingNotifier();
|
||||
const failed = { app: 'bazarr', workflowId: 'update-bazarr', status: 'failed', mode: 'real' as const, current: '1', candidate: '2', running: '1', outcome: 'Verify failed', step: null, updatedAt: '2026-09-30T01:00:00.000Z' };
|
||||
return { clock, settings, notifier, deps: { clock, timeZone: 'Europe/Warsaw', settings, notifier, runs: () => [failed], down: () => ['grafana'] } };
|
||||
}
|
||||
|
||||
describe('sendDigestIfDue', () => {
|
||||
it('sends the report once a day from 07:30 on, with what happened since the last one', async () => {
|
||||
const { clock, notifier, settings, deps } = setup('2026-09-30T05:29:00.000Z');
|
||||
expect(await sendDigestIfDue(deps)).toBe(false);
|
||||
await clock.advance(60_000);
|
||||
expect(await sendDigestIfDue(deps)).toBe(true);
|
||||
expect(await sendDigestIfDue(deps)).toBe(false);
|
||||
expect(notifier.sent).toEqual([
|
||||
{ runId: 'digest-2026-09-30', title: 'Night report: 2 need you', detail: 'Failed: bazarr (Verify failed)\nDown now: grafana', link: '#/overview' },
|
||||
]);
|
||||
expect(settings.lastDigest()).toEqual({ day: '2026-09-30', at: '2026-09-30T05:30:00.000Z' });
|
||||
});
|
||||
|
||||
it('tries again next time when the push fails', async () => {
|
||||
const { notifier, settings, deps } = setup('2026-09-30T06:00:00.000Z');
|
||||
notifier.failWith = new Error('HA down');
|
||||
await expect(sendDigestIfDue(deps)).rejects.toThrow('HA down');
|
||||
expect(settings.lastDigest()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isQuiet', () => {
|
||||
it('follows the owner’s quiet hours in the schedules’ time zone', () => {
|
||||
expect(isQuiet(setup('2026-09-29T23:30:00.000Z').deps)).toBe(true); // 01:30 in Warsaw
|
||||
expect(isQuiet(setup('2026-09-29T20:30:00.000Z').deps)).toBe(false); // 22:30 in Warsaw
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { zonedMinute } from '../domain/cron.ts';
|
||||
import { digestDue, inQuietHours, nightDigest } from '../domain/notification-settings.ts';
|
||||
import type { DigestRun } from '../domain/notification-settings.ts';
|
||||
import type { Clock, Notifier, NotificationSettingsStore } from '../domain/ports.ts';
|
||||
|
||||
export interface DigestDeps {
|
||||
readonly clock: Clock;
|
||||
/** The schedules' time zone: quiet hours and the report time are wall-clock there. */
|
||||
readonly timeZone: string;
|
||||
readonly settings: NotificationSettingsStore;
|
||||
readonly runs: () => readonly DigestRun[];
|
||||
/** Enabled apps whose container is down right now (the driver's latest look). */
|
||||
readonly down: () => readonly string[];
|
||||
readonly notifier: Notifier;
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/** True while the owner's quiet hours hold pushes back. */
|
||||
export function isQuiet(deps: Pick<DigestDeps, 'clock' | 'timeZone' | 'settings'>): boolean {
|
||||
return inQuietHours(deps.settings.get(), zonedMinute(deps.clock.now(), deps.timeZone));
|
||||
}
|
||||
|
||||
/** Sends the morning report when it is due; true when it went out. A failed push is not recorded, so it is tried again. */
|
||||
export async function sendDigestIfDue(deps: DigestDeps): Promise<boolean> {
|
||||
const now = deps.clock.now();
|
||||
const at = zonedMinute(now, deps.timeZone);
|
||||
const last = deps.settings.lastDigest();
|
||||
if (!digestDue(deps.settings.get(), at, last?.day ?? null)) return false;
|
||||
const day = at.key.slice(0, 10);
|
||||
const digest = nightDigest({ runs: deps.runs(), since: last?.at ?? new Date(now.getTime() - DAY_MS).toISOString(), down: deps.down() });
|
||||
await deps.notifier.notify({ runId: `digest-${day}`, title: digest.title, detail: digest.detail, link: '#/overview' });
|
||||
deps.settings.recordDigest(day, now.toISOString());
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Checks once per `intervalMs` whether the report is due, until `signal` aborts. */
|
||||
export async function runDigestLoop(deps: DigestDeps & { readonly intervalMs: number; readonly signal: AbortSignal; readonly onError: (cause: unknown) => void }): Promise<void> {
|
||||
while (!deps.signal.aborted) {
|
||||
try {
|
||||
await sendDigestIfDue(deps);
|
||||
} catch (cause) {
|
||||
deps.onError(cause);
|
||||
}
|
||||
try {
|
||||
await deps.clock.sleep(deps.intervalMs, deps.signal);
|
||||
} catch {
|
||||
return; // stopped
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -716,6 +716,18 @@ services:
|
||||
expect(JSON.stringify(result)).not.toContain('old-db-secret');
|
||||
});
|
||||
|
||||
it('without a new compose file pins the image in the current one, secrets untouched', async () => {
|
||||
const { context, request } = await prepared();
|
||||
|
||||
const result = await context.executor.migrate({ ...request, compose_yaml: null, env: {} });
|
||||
|
||||
const written = WITH_SECRET.replace(`image: ${OLD}`, `image: ${NEW}`);
|
||||
expect(written).not.toBe(WITH_SECRET);
|
||||
expect(result).toMatchObject({ outcome: 'done', result: { state: 'applied', image: NEW, applied_manifest_sha256: sha(written), env_keys: [] } });
|
||||
expect(readFileSync(context.manifest, 'utf8')).toBe(written);
|
||||
expect(readFileSync(join(context.dir, '.env'), 'utf8')).toBe(ENV);
|
||||
});
|
||||
|
||||
it('refuses an unknown secret, an unfilled input, a changed manifest, and leaves everything as it was', async () => {
|
||||
const { context, request, envCandidate } = await prepared();
|
||||
const cases = [
|
||||
|
||||
@@ -588,7 +588,10 @@ export function createComposeExecutor(deps: ComposeExecutorDeps): ComposeExecuto
|
||||
return refused('manifest_changed', `compose.yaml of "${project}" is ${short(current)}, not ${short(request.expect_manifest_sha256)} as planned`);
|
||||
}
|
||||
// The plan was written against the masked file (hl compose): its <secret:KEY> are this file's values.
|
||||
const unmasked = unmaskComposeSecrets(request.compose_yaml, maskComposeSecrets(manifest.value.toString('utf8')).originals);
|
||||
// Without a new file the migration keeps the current one (read just now, so it matches the backup) and only pins the image.
|
||||
const unmasked = request.compose_yaml === null
|
||||
? { ok: true as const, text: manifest.value.toString('utf8') }
|
||||
: unmaskComposeSecrets(request.compose_yaml, maskComposeSecrets(manifest.value.toString('utf8')).originals);
|
||||
if (!unmasked.ok) return refused('invalid_manifest', `the migration's compose.yaml has an ${unmasked.reason}`);
|
||||
if (/<input:[A-Za-z0-9_]+>/.test(unmasked.text)) return refused('invalid_manifest', "the migration's compose.yaml still has an <input:NAME> the owner did not fill");
|
||||
const pinned = rewriteImage(unmasked.text, service, image);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { collectHostStatus, runHostStatusLoop } from './host-status.ts';
|
||||
import type { HostStatus } from '../domain/host-status.ts';
|
||||
import { FakeCommandRunner } from '../testing/fakes.ts';
|
||||
import { FakeClock } from '../testing/fake-clock.ts';
|
||||
|
||||
const PS = ['docker', 'ps', '-a', '--filter', 'label=com.docker.compose.project', '--format', '{{json .}}'].join(' ');
|
||||
const APPS = [{ app: 'jellyfin', project: 'jellyfin', service: 'jellyfin', enabled: true }];
|
||||
const running = JSON.stringify({ Labels: 'com.docker.compose.project=jellyfin,com.docker.compose.service=jellyfin', State: 'running', Status: 'Up 1 hour (healthy)' });
|
||||
|
||||
function deps(runner: FakeCommandRunner, clock: FakeClock, backup: string | null) {
|
||||
return { runner, clock, apps: () => APPS, readBackup: () => Promise.resolve(backup), backupVmid: 103 };
|
||||
}
|
||||
|
||||
describe('collectHostStatus', () => {
|
||||
it('lists every app with its container and the newest VM backup', async () => {
|
||||
const clock = new FakeClock('2026-09-30T06:00:00.000Z');
|
||||
const runner = new FakeCommandRunner().on(PS, { exitCode: 0, stdout: running, stderr: '' });
|
||||
const record = JSON.stringify({ vmid: 103, storage: 'pbs', latest: { volid: 'pbs:backup/vm/103/2026-09-30T00:00:00Z', ctime: Date.parse('2026-09-30T00:00:00Z') / 1000 }, checked_at: '2026-09-30T05:00:00Z' });
|
||||
expect(await collectHostStatus(deps(runner, clock, record))).toEqual({
|
||||
checkedAt: '2026-09-30T06:00:00.000Z',
|
||||
apps: [{ app: 'jellyfin', enabled: true, state: 'running', health: 'healthy' }],
|
||||
backup: { ok: true, createdAt: '2026-09-30T00:00:00.000Z', ageHours: 6, volid: 'pbs:backup/vm/103/2026-09-30T00:00:00Z' },
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('says why the containers are unknown and keeps a bad backup record as a finding', async () => {
|
||||
const clock = new FakeClock();
|
||||
const runner = new FakeCommandRunner().on(PS, { exitCode: 1, stdout: '', stderr: 'proxy down' });
|
||||
const status = await collectHostStatus(deps(runner, clock, '{"vmid": "x"}'));
|
||||
expect(status.apps).toEqual([]);
|
||||
expect(status.error).toContain('docker ps');
|
||||
expect(status.backup?.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('has no backup entry without a record', async () => {
|
||||
const runner = new FakeCommandRunner().on(PS, { exitCode: 0, stdout: '', stderr: '' });
|
||||
expect((await collectHostStatus(deps(runner, new FakeClock(), null))).backup).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runHostStatusLoop', () => {
|
||||
it('stores a fresh status every interval until stopped', async () => {
|
||||
const clock = new FakeClock();
|
||||
const runner = new FakeCommandRunner().on(PS, { exitCode: 0, stdout: running, stderr: '' });
|
||||
const stored: HostStatus[] = [];
|
||||
const stop = new AbortController();
|
||||
const loop = runHostStatusLoop({ ...deps(runner, clock, null), store: { put: (status) => stored.push(status) }, intervalMs: 60_000, signal: stop.signal, onError: () => undefined });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(stored).toHaveLength(1);
|
||||
await clock.advance(60_000);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(stored).toHaveLength(2);
|
||||
stop.abort();
|
||||
await loop;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { backupVerdict, HostBackupRecord } from '../domain/backup-check.ts';
|
||||
import { appHealth, parseLabeledDockerPs } from '../domain/host-status.ts';
|
||||
import type { BackupStatus, HostStatus } from '../domain/host-status.ts';
|
||||
import type { Clock, CommandRunner } from '../domain/ports.ts';
|
||||
import { describeError } from '../domain/mask.ts';
|
||||
|
||||
export interface HostStatusDeps {
|
||||
/** Commands see only a read-only socket proxy: `docker ps` is a GET. */
|
||||
readonly runner: CommandRunner;
|
||||
readonly clock: Clock;
|
||||
readonly apps: () => readonly { readonly app: string; readonly project: string; readonly service: string; readonly enabled: boolean }[];
|
||||
/** The Proxmox timer's record of the newest vzdump; null when there is none. */
|
||||
readonly readBackup: () => Promise<string | null>;
|
||||
readonly backupVmid: number;
|
||||
}
|
||||
|
||||
const PS_ARGS = ['ps', '-a', '--filter', 'label=com.docker.compose.project', '--format', '{{json .}}'];
|
||||
|
||||
async function backupStatus(deps: HostStatusDeps): Promise<BackupStatus | null> {
|
||||
try {
|
||||
const raw = await deps.readBackup();
|
||||
if (raw === null) return null;
|
||||
const record = HostBackupRecord.safeParse(JSON.parse(raw));
|
||||
if (!record.success) return { ok: false, detail: 'the host backup record is not readable' };
|
||||
const verdict = backupVerdict(record.data, { vmid: deps.backupVmid, now: deps.clock.now() });
|
||||
return verdict.ok ? { ok: true, createdAt: verdict.result.created_at, ageHours: verdict.result.age_hours, volid: verdict.result.volid } : { ok: false, detail: verdict.detail };
|
||||
} catch (cause) {
|
||||
return { ok: false, detail: `the host backup record is not readable: ${describeError(cause)}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** One look at VM103: the containers of every configured app and the newest VM backup. Never throws. */
|
||||
export async function collectHostStatus(deps: HostStatusDeps, signal?: AbortSignal): Promise<HostStatus> {
|
||||
const checkedAt = deps.clock.now().toISOString();
|
||||
const backup = await backupStatus(deps);
|
||||
try {
|
||||
const ps = await deps.runner.run({ command: 'docker', args: PS_ARGS, ...(signal === undefined ? {} : { signal }) });
|
||||
if (ps.exitCode !== 0) return { checkedAt, apps: [], backup, error: `docker ps exited with ${String(ps.exitCode)}: ${ps.stderr.trim().slice(0, 200)}` };
|
||||
return { checkedAt, apps: appHealth(parseLabeledDockerPs(ps.stdout), deps.apps()), backup, error: null };
|
||||
} catch (cause) {
|
||||
return { checkedAt, apps: [], backup, error: `docker ps failed: ${describeError(cause)}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** Looks again every `intervalMs` and stores what it saw, until `signal` aborts. */
|
||||
export async function runHostStatusLoop(
|
||||
deps: HostStatusDeps & {
|
||||
readonly store: { put(status: HostStatus): void };
|
||||
readonly intervalMs: number;
|
||||
readonly signal: AbortSignal;
|
||||
readonly onError: (cause: unknown) => void;
|
||||
},
|
||||
): Promise<void> {
|
||||
while (!deps.signal.aborted) {
|
||||
try {
|
||||
deps.store.put(await collectHostStatus(deps, deps.signal));
|
||||
} catch (cause) {
|
||||
deps.onError(cause);
|
||||
}
|
||||
try {
|
||||
await deps.clock.sleep(deps.intervalMs, deps.signal);
|
||||
} catch {
|
||||
return; // stopped
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const PINNED = `lscr.io/linuxserver/jackett:v0.24.2644-ls36@sha256:${'e'.repeat(
|
||||
const config = (over: Partial<AppConfig> = {}): AppConfig => ({
|
||||
app: 'jackett', project: 'jackett', enabled: true, position: 1, service: 'jackett', imageRepo: 'lscr.io/linuxserver/jackett',
|
||||
tagPattern: '^v.*$', auto: 'patch', httpUrl: 'http://jackett:9117', expectStatus: 200, releaseNotesExecutor: 'sonnet',
|
||||
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, notes: null, apply: true, ...over,
|
||||
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, postUpgradeWhen: 'when_needed', notes: null, apply: true, ...over,
|
||||
});
|
||||
|
||||
function prompts(app: AppConfig) {
|
||||
@@ -24,16 +24,16 @@ describe('model action prompts', () => {
|
||||
it('asks for the post-upgrade check what the old chain asks, with the versions, the group and the skill', async () => {
|
||||
const asked = await prompts(config()).postUpgrade(target);
|
||||
|
||||
expect(asked.executor).toBe('sonnet');
|
||||
expect(asked.executor).toBe('haiku');
|
||||
expect(asked.prompt).toContain('v0.24.2627-ls34');
|
||||
expect(asked.prompt).toContain('v0.24.2644-ls36');
|
||||
expect(asked.prompt).toContain('flaresolverr');
|
||||
expect(asked.prompt).toContain('Jackett keeps its indexers in /config.');
|
||||
});
|
||||
|
||||
it('uses the models the app chose, opus by default for a repair', async () => {
|
||||
expect((await prompts(config()).repair({ ...target, reason: 'verify_failed' })).executor).toBe('opus');
|
||||
expect((await prompts(config({ postUpgradeExecutor: 'opus', migrateExecutor: 'sonnet' })).postUpgrade(target)).executor).toBe('opus');
|
||||
it('uses the models the app chose, haiku by default for a repair', async () => {
|
||||
expect((await prompts(config()).repair({ ...target, reason: 'verify_failed' })).executor).toBe('haiku');
|
||||
expect((await prompts(config({ postUpgradeExecutor: 'opus', postUpgradeWhen: 'when_needed', migrateExecutor: 'sonnet' })).postUpgrade(target)).executor).toBe('opus');
|
||||
const repair = await prompts(config({ migrateExecutor: 'sonnet' })).repair({ ...target, previousImage: null, reason: 'verify_failed: unhealthy' });
|
||||
expect(repair.executor).toBe('sonnet');
|
||||
expect(repair.prompt).toContain('verify_failed: unhealthy');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { splitImage } from '../domain/image-ref.ts';
|
||||
import { buildPostUpgradePrompt } from '../domain/post-upgrade.ts';
|
||||
import { buildRepairPrompt } from '../domain/repair.ts';
|
||||
import type { AppConfigStore, ModelActionPrompts, SkillStore } from '../domain/ports.ts';
|
||||
import { modelOr } from '../domain/steps.ts';
|
||||
import type { AppConfig } from '../domain/steps.ts';
|
||||
import type { GroupMember } from './ops/group.ts';
|
||||
|
||||
@@ -15,8 +16,8 @@ const tagOf = (image: string | null): string => (image === null ? 'unknown' : sp
|
||||
|
||||
/**
|
||||
* The workflow engine's model actions ask what the old chain asks (domain/post-upgrade.ts, domain/repair.ts), with the
|
||||
* app's compose group and skill, and the model the app chose: postUpgradeExecutor (sonnet by default) for the check
|
||||
* after an update, migrateExecutor (opus by default) for a repair.
|
||||
* app's compose group and skill, and the model the app chose: postUpgradeExecutor for the check after an update,
|
||||
* migrateExecutor for a repair (each haiku by default).
|
||||
*/
|
||||
export function createModelActionPrompts(deps: ModelActionPromptDeps): ModelActionPrompts {
|
||||
const configOf = (component: string): AppConfig => {
|
||||
@@ -41,7 +42,7 @@ export function createModelActionPrompts(deps: ModelActionPromptDeps): ModelActi
|
||||
update: { current: tagOf(request.previousImage), candidate: tagOf(request.image), pinned: request.image },
|
||||
group, skill, migration: request.migration,
|
||||
});
|
||||
return { executor: config.postUpgradeExecutor === 'opus' ? 'opus' : 'sonnet', prompt };
|
||||
return { executor: modelOr(config.postUpgradeExecutor), prompt };
|
||||
},
|
||||
async repair(request, signal) {
|
||||
const { config, group, skill } = await facts(request.component, signal);
|
||||
@@ -50,7 +51,7 @@ export function createModelActionPrompts(deps: ModelActionPromptDeps): ModelActi
|
||||
update: { current: tagOf(request.previousImage), candidate: tagOf(request.image), pinned: request.image },
|
||||
httpUrl: config.httpUrl, expectStatus: config.expectStatus, group, skill, fullAccess: request.fullAccess === true,
|
||||
});
|
||||
return { executor: config.migrateExecutor === 'sonnet' ? 'sonnet' : 'opus', prompt };
|
||||
return { executor: modelOr(config.migrateExecutor), prompt };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { STRUCTURED_OUTPUT } from '../../domain/agent-guard.ts';
|
||||
import { describeError, truncate } from '../../domain/mask.ts';
|
||||
import type { CommandRunner, ModelFailure } from '../../domain/ports.ts';
|
||||
import type { ModelSidecarRunResult } from '../../domain/model-api.ts';
|
||||
import type { ModelName } from '../../domain/steps.ts';
|
||||
|
||||
// Runs the unmodified `claude` CLI, logged in with the owner's own account (docs/DESIGN.md, "Wykonawcy modeli"): print mode,
|
||||
// JSON answer checked against a schema, a fresh empty working directory (no CLAUDE.md, no settings of anyone else), and for
|
||||
@@ -30,7 +31,7 @@ export interface ClaudeDeps {
|
||||
}
|
||||
|
||||
export interface ClaudeRunRequest {
|
||||
readonly model: 'sonnet' | 'opus';
|
||||
readonly model: ModelName;
|
||||
readonly profile: 'readonly' | 'observe' | 'tools';
|
||||
readonly prompt: string;
|
||||
readonly outputSchema: unknown;
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('privileged action dispatcher', () => {
|
||||
const current = {
|
||||
app: 'jackett', project: 'jackett', enabled: true, position: 1, service: 'jackett', imageRepo: 'linuxserver/jackett',
|
||||
tagPattern: '.*', auto: 'patch' as const, httpUrl: null, expectStatus: 200, releaseNotesExecutor: 'sonnet' as const,
|
||||
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, notes: null, apply: false,
|
||||
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, postUpgradeWhen: 'when_needed' as const, notes: null, apply: false,
|
||||
};
|
||||
const liveConfig = { get: () => current, list: () => [current], upsert: () => undefined };
|
||||
const backupPolicy = PrivilegedActionPolicy.parse({
|
||||
|
||||
@@ -22,7 +22,7 @@ const config = (over: Partial<AppConfig> = {}): AppConfig => ({
|
||||
releaseNotesExecutor: 'sonnet',
|
||||
migrateExecutor: null,
|
||||
promptHint: null,
|
||||
postUpgradeExecutor: null,
|
||||
postUpgradeExecutor: null, postUpgradeWhen: 'when_needed',
|
||||
notes: null,
|
||||
apply: true,
|
||||
...over,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ModelError } from '../domain/ports.ts';
|
||||
import type { ModelExecutor, SkillStore } from '../domain/ports.ts';
|
||||
import { buildReleaseNotesPrompt, RELEASE_NOTES_ANSWER_SCHEMA, ReleaseNotesAnswer } from '../domain/release-notes.ts';
|
||||
import type { ReleaseNotesContext } from '../domain/release-notes.ts';
|
||||
import type { ModelName } from '../domain/steps.ts';
|
||||
|
||||
// Before an update a model finds the release notes itself (web search and fetch, the owner's list of trusted sources as
|
||||
// a preference) and lays its assessment in front of the owner (requirements, breaking changes, what to do before and
|
||||
@@ -14,8 +15,8 @@ export interface ReleaseNotesRead {
|
||||
}
|
||||
|
||||
export type ReleaseNotesAssessment =
|
||||
| { readonly kind: 'failed'; readonly why: string; readonly notes: ReleaseNotesRead; readonly model: 'sonnet' | 'opus'; readonly costUsd: number }
|
||||
| { readonly kind: 'assessed'; readonly report: AgentReport; readonly notes: ReleaseNotesRead; readonly model: 'sonnet' | 'opus'; readonly costUsd: number };
|
||||
| { readonly kind: 'failed'; readonly why: string; readonly notes: ReleaseNotesRead; readonly model: ModelName; readonly costUsd: number }
|
||||
| { readonly kind: 'assessed'; readonly report: AgentReport; readonly notes: ReleaseNotesRead; readonly model: ModelName; readonly costUsd: number };
|
||||
|
||||
export interface ReleaseNotesQuestion {
|
||||
readonly app: string;
|
||||
@@ -27,7 +28,7 @@ export interface ReleaseNotesQuestion {
|
||||
readonly versionChange: string;
|
||||
readonly auto: string;
|
||||
readonly promptHint: string | null;
|
||||
readonly model: 'sonnet' | 'opus';
|
||||
readonly model: ModelName;
|
||||
readonly group: () => Promise<ReleaseNotesContext['group']>;
|
||||
}
|
||||
|
||||
|
||||
@@ -82,4 +82,11 @@ describe('notifyRun', () => {
|
||||
expect(await notifyRun(deps, 'nope')).toBe(false);
|
||||
expect(notifier.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('holds the push in quiet hours', async () => {
|
||||
const notifier = new RecordingNotifier();
|
||||
const deps = { get: () => instance('failed'), report: () => report({}), notifier, quiet: () => true };
|
||||
expect(await notifyRun(deps, 'run-1')).toBe(false);
|
||||
expect(notifier.sent).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -61,15 +61,21 @@ export function runNotification(instance: WorkflowInstance, report: RunReport):
|
||||
}
|
||||
}
|
||||
|
||||
/** Pushes the run's news, if any; true when a push went out. A failed push is the caller's to log. */
|
||||
/** Pushes the run's news, if any, outside quiet hours; true when a push went out. A failed push is the caller's to log. */
|
||||
export async function notifyRun(
|
||||
deps: { readonly get: (instanceId: string) => WorkflowInstance | null; readonly report: (instance: WorkflowInstance) => RunReport; readonly notifier: Notifier },
|
||||
deps: {
|
||||
readonly get: (instanceId: string) => WorkflowInstance | null;
|
||||
readonly report: (instance: WorkflowInstance) => RunReport;
|
||||
readonly notifier: Notifier;
|
||||
/** True in the owner's quiet hours: nothing rings, the morning report covers the run (app/digest.ts). */
|
||||
readonly quiet?: () => boolean;
|
||||
},
|
||||
instanceId: string,
|
||||
): Promise<boolean> {
|
||||
const instance = deps.get(instanceId);
|
||||
if (instance === null) return false;
|
||||
const notification = runNotification(instance, deps.report(instance));
|
||||
if (notification === null) return false;
|
||||
if (notification === null || deps.quiet?.() === true) return false;
|
||||
await deps.notifier.notify(notification);
|
||||
return true;
|
||||
}
|
||||
@@ -44,7 +44,7 @@ async function setup() {
|
||||
appConfigStore.upsert({
|
||||
app: 'jackett', project: 'jackett', enabled: true, position: 1, service: 'jackett', imageRepo: 'linuxserver/jackett',
|
||||
tagPattern: 'version-*', auto: 'patch', httpUrl: null, expectStatus: 200, releaseNotesExecutor: 'script',
|
||||
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, notes: null, apply: true,
|
||||
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, postUpgradeWhen: 'when_needed', notes: null, apply: true,
|
||||
});
|
||||
skillStore.upsert({ project: 'jackett', content: 'Jackett torrent proxy', updatedAt: now });
|
||||
await ensureAppWorkflows({ workflowCatalog, functionCatalog, functionRuntime: passingRuntime, extensions, newId: () => crypto.randomUUID(), now: () => now }, ['jackett']);
|
||||
|
||||
@@ -8,7 +8,8 @@ import type {
|
||||
WorkflowInstanceStore,
|
||||
} from '../domain/ports.ts';
|
||||
import type { WorkflowInstance } from '../domain/workflow.ts';
|
||||
import type { AppConfig, JsonValue } from '../domain/steps.ts';
|
||||
import { modelOr } from '../domain/steps.ts';
|
||||
import type { AppConfig, JsonValue, ModelName } from '../domain/steps.ts';
|
||||
import { buildComponentUpdateSnapshot, buildSystemSnapshot } from '../domain/workflow-snapshot-builder.ts';
|
||||
import { appWorkflowId } from './workflow-seed.ts';
|
||||
|
||||
@@ -123,13 +124,13 @@ export function startSystemRun(deps: WorkflowRunDeps, workflowId: string): Start
|
||||
* project and service), so only what the context cannot say is here: apply's dry-run flag and the model profiles.
|
||||
*/
|
||||
function realRunRequests(deps: WorkflowRunDeps, config: AppConfig): Record<string, JsonValue> {
|
||||
const profile = (model: 'sonnet' | 'opus') => {
|
||||
const profile = (model: ModelName) => {
|
||||
const latest = deps.modelProfileStore.getLatestVersion(model);
|
||||
return latest === null ? null : { profileId: latest.profileId, version: latest.version };
|
||||
};
|
||||
// The same models the old chain uses: the app's choice, else sonnet checks after an update and opus repairs.
|
||||
const postUpgrade = config.postUpgradeExecutor === 'opus' ? 'opus' : 'sonnet';
|
||||
const repair = config.migrateExecutor === 'sonnet' ? 'sonnet' : 'opus';
|
||||
// The app's choice, else the default model.
|
||||
const postUpgrade = modelOr(config.postUpgradeExecutor);
|
||||
const repair = modelOr(config.migrateExecutor);
|
||||
return {
|
||||
backupRequest: { project: config.project },
|
||||
applyDryRunRequest: { dryRun: true },
|
||||
@@ -139,7 +140,8 @@ function realRunRequests(deps: WorkflowRunDeps, config: AppConfig): Record<strin
|
||||
repairFullRequest: { reason: 'owner_requested', access: 'full' },
|
||||
rollbackRequest: {},
|
||||
migrateRequest: {},
|
||||
postUpgradeRequest: {},
|
||||
// Without a model chosen the check is off (domain/steps.ts); with one, the app says when it runs.
|
||||
postUpgradeRequest: { when: config.postUpgradeExecutor === null ? 'off' : config.postUpgradeWhen },
|
||||
postUpgradeProfile: profile(postUpgrade),
|
||||
repairProfile: profile(repair),
|
||||
};
|
||||
|
||||
@@ -104,13 +104,14 @@ const MIGRATION_PLAN = {
|
||||
stepsBefore: [], stepsAfter: ['hl ps'], rollback: 'Restore the backup.',
|
||||
};
|
||||
|
||||
function fakeOperations(tags: 'candidate_within_auto' | 'candidate_above_auto' = 'candidate_within_auto', migration: 'none' | 'needed' = 'none'): ComponentOperationRunner {
|
||||
function fakeOperations(tags: 'candidate_within_auto' | 'candidate_above_auto' = 'candidate_within_auto', migration: 'none' | 'needed' | 'image_only' = 'none'): ComponentOperationRunner {
|
||||
const plan = migration === 'image_only' ? { ...MIGRATION_PLAN, composeYaml: null, inputs: [] } : MIGRATION_PLAN;
|
||||
const results: Record<string, { event: string; result: JsonValue }> = {
|
||||
image_version: { event: 'version_detected', result: { event: 'version_detected', detectedVersion: '0.22.0', imageVersionState: 'detected' } },
|
||||
image_tags: { event: tags, result: { event: tags, current: '0.22.0', candidate: 'version-v0.22.1', change: tags === 'candidate_within_auto' ? 'patch' : 'major' } },
|
||||
release_notes: { event: 'no_source', result: { sources: [] } },
|
||||
migration_plan: migration === 'needed'
|
||||
? { event: 'migration_needed', result: { plan: MIGRATION_PLAN, planDigest: 'd'.repeat(64), summary: MIGRATION_PLAN.summary, model: 'opus', costUsd: 0.3, actions: [] } }
|
||||
migration_plan: migration !== 'none'
|
||||
? { event: 'migration_needed', result: { plan, planDigest: 'd'.repeat(64), summary: plan.summary, model: 'opus', costUsd: 0.3, actions: [] } }
|
||||
: { event: 'no_migration', result: { plan: null, summary: 'Nothing to migrate.' } },
|
||||
image_resolve: {
|
||||
event: 'resolved',
|
||||
@@ -146,7 +147,7 @@ async function setup(
|
||||
appConfigStore.upsert({
|
||||
app: 'jackett', project: 'jackett', enabled: true, position: 1, service: 'jackett', imageRepo: 'linuxserver/jackett',
|
||||
tagPattern: 'version-*', auto: 'patch', httpUrl: null, expectStatus: 200, releaseNotesExecutor: 'script',
|
||||
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, notes: null, apply: true,
|
||||
migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, postUpgradeWhen: 'when_needed', notes: null, apply: true,
|
||||
});
|
||||
skillStore.upsert({ project: 'jackett', content: 'Jackett torrent proxy', updatedAt: now });
|
||||
// The real sandbox: the template functions run in bwrap exactly as on VM103.
|
||||
@@ -253,7 +254,7 @@ describe('workflow runner', () => {
|
||||
{ image: PINNED, backupId: BACKUP_ID, expectedManifestSha256: MANIFEST, dryRun: true },
|
||||
{ image: PINNED, backupId: BACKUP_ID, expectedManifestSha256: MANIFEST, dryRun: false },
|
||||
]);
|
||||
expect(docker.requests.find((request) => request.action === 'model.postUpgrade')).toMatchObject({ image: PINNED, backupId: BACKUP_ID, profile: { profileId: 'sonnet' } });
|
||||
expect(docker.requests.find((request) => request.action === 'model.postUpgrade')).toMatchObject({ image: PINNED, backupId: BACKUP_ID, profile: { profileId: 'haiku' }, when: 'off', reasons: [] });
|
||||
});
|
||||
|
||||
it('rolls the app back when the owner chooses rollback after the model asked for action', async () => {
|
||||
@@ -321,10 +322,27 @@ describe('workflow runner', () => {
|
||||
action: 'docker.migrate', version: 1, component: 'jackett', project: 'jackett', service: 'jackett', image: PINNED, backupId: BACKUP_ID,
|
||||
expectedManifestSha256: MANIFEST, composeYaml: MIGRATION_PLAN.composeYaml, planDigest: 'd'.repeat(64), inputs: [{ name: 'ADMIN_PASSWORD', target: 'env' }],
|
||||
});
|
||||
expect(docker.requests.find((request) => request.action === 'model.postUpgrade')).toMatchObject({ migration: { summary: MIGRATION_PLAN.summary, stepsAfter: ['hl ps'] } });
|
||||
expect(docker.requests.find((request) => request.action === 'model.postUpgrade')).toMatchObject({
|
||||
migration: { summary: MIGRATION_PLAN.summary, stepsAfter: ['hl ps'] }, reasons: ['approved_update', 'major', 'migration'],
|
||||
});
|
||||
expect(instanceStore.getReplayStatus('run-1').status).toBe('ok');
|
||||
});
|
||||
|
||||
it('runs an approved migration that keeps the compose file: the executor only pins the new image', async () => {
|
||||
const { runner, instanceStore, docker, runDeps } = await setup(fakeOperations('candidate_above_auto', 'image_only'));
|
||||
startWorkflowRun(runDeps, 'jackett', 'real');
|
||||
await runUntilIdle(runner);
|
||||
const instance = instanceStore.get('run-1')!;
|
||||
expect(instanceStore.submitOwnerDecision({
|
||||
decision: { schemaVersion: 1, decisionId: crypto.randomUUID(), signal: 'owner-decision.v1', instanceId: 'run-1', waitNodeId: 'gate_migration', action: 'approve', note: null },
|
||||
expectedLifecycleSeq: instance.projection.seq, expectedExecutionSeq: instanceStore.getReplayStatus('run-1').replaySeq, now,
|
||||
}).ok).toBe(true);
|
||||
|
||||
expect(await runUntilIdle(runner)).toContain('apply_migration');
|
||||
expect(docker.requests.find((request) => request.action === 'docker.migrate')).toMatchObject({ composeYaml: null, inputs: [] });
|
||||
expect(instanceStore.get('run-1')?.projection.status).toBe('succeeded');
|
||||
});
|
||||
|
||||
it('goes the usual way above the auto threshold when the model finds no migration', async () => {
|
||||
const { runner, runDeps } = await setup(fakeOperations('candidate_above_auto'));
|
||||
startShadowRun(runDeps, 'jackett');
|
||||
|
||||
@@ -60,10 +60,10 @@ describe('ensureUpdatePolicy', () => {
|
||||
});
|
||||
|
||||
describe('ensureDefaultModelProfiles', () => {
|
||||
it('adds read-only Claude profiles for the sonnet and opus executors once', () => {
|
||||
it('adds read-only Claude profiles for the haiku, sonnet and opus executors once', () => {
|
||||
const store = createSqliteModelProfileStore(openDatabase(':memory:'));
|
||||
|
||||
expect(ensureDefaultModelProfiles(store, now)).toEqual(['sonnet', 'opus']);
|
||||
expect(ensureDefaultModelProfiles(store, now)).toEqual(['haiku', 'sonnet', 'opus']);
|
||||
expect(ensureDefaultModelProfiles(store, now)).toEqual([]);
|
||||
expect(store.getLatestVersion('opus')?.document).toMatchObject({ provider: 'claude', model: 'opus', mode: 'readonly', timeoutMs: 1_800_000 });
|
||||
});
|
||||
@@ -74,7 +74,7 @@ describe('ensureDefaultModelProfiles', () => {
|
||||
store.publishVersion({ profileId: 'sonnet', expectedVersion: 1, document: first('sonnet'), now });
|
||||
store.publishVersion({ profileId: 'opus', expectedVersion: 1, document: first('opus', { provider: 'codex' }), now });
|
||||
|
||||
expect(ensureDefaultModelProfiles(store, now)).toEqual(['sonnet']);
|
||||
expect(ensureDefaultModelProfiles(store, now)).toEqual(['haiku', 'sonnet']);
|
||||
expect(store.getLatestVersion('sonnet')).toMatchObject({ version: 2, document: { timeoutMs: 1_800_000 } });
|
||||
expect(store.getLatestVersion('opus')).toMatchObject({ version: 1, document: { provider: 'codex', timeoutMs: 300_000 } });
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../domain/workflow-update.ts';
|
||||
import { createWorkflowCatalogApp, createWorkflowGraphValidator } from './workflow-catalog.ts';
|
||||
import { createWorkflowFunctionCatalogApp } from './workflow-function-catalog.ts';
|
||||
import { ModelName } from '../domain/steps.ts';
|
||||
|
||||
/** Each app gets its own update workflow, first published as a copy of the component-update template. */
|
||||
export { appWorkflowId };
|
||||
@@ -28,7 +29,7 @@ export function ensureUpdatePolicy(store: PrivilegedActionStore, now: string): b
|
||||
}
|
||||
|
||||
/** A default profile's first document; one still exactly like it was never changed by the owner. */
|
||||
const FIRST_DEFAULT_PROFILE = (model: 'sonnet' | 'opus'): ModelProfileDocumentV1 => ({
|
||||
const FIRST_DEFAULT_PROFILE = (model: ModelName): ModelProfileDocumentV1 => ({
|
||||
schemaVersion: 1, provider: 'claude', model, mode: 'readonly', timeoutMs: 300_000, maxCostUsd: null, tools: [], systemPrompt: null, context: null,
|
||||
});
|
||||
|
||||
@@ -36,17 +37,17 @@ const FIRST_DEFAULT_PROFILE = (model: 'sonnet' | 'opus'): ModelProfileDocumentV1
|
||||
* The default profile: the model sidecar's own limit (30 min, MODEL_TIMEOUT_MS), since a repair session takes long; the
|
||||
* dispatcher stops a run at its profile's timeout.
|
||||
*/
|
||||
const DEFAULT_PROFILE = (model: 'sonnet' | 'opus'): ModelProfileDocumentV1 => ({ ...FIRST_DEFAULT_PROFILE(model), timeoutMs: 30 * 60_000 });
|
||||
const DEFAULT_PROFILE = (model: ModelName): ModelProfileDocumentV1 => ({ ...FIRST_DEFAULT_PROFILE(model), timeoutMs: 30 * 60_000 });
|
||||
|
||||
/**
|
||||
* App configs name 'sonnet' and 'opus' as executors; a model request without a pinned profile uses the profile of that
|
||||
* App configs name 'haiku', 'sonnet' and 'opus' as executors; a model request without a pinned profile uses the profile of that
|
||||
* name, and a workflow snapshot pins one for each. Adds the missing ones as Claude profiles, and moves a profile the owner
|
||||
* never changed from its first document to the current default; the owner edits them on the Models page (the provider
|
||||
* picks the Claude or Codex sidecar).
|
||||
*/
|
||||
export function ensureDefaultModelProfiles(store: ModelProfileStore, now: string): readonly string[] {
|
||||
const added: string[] = [];
|
||||
for (const model of ['sonnet', 'opus'] as const) {
|
||||
for (const model of ModelName.options) {
|
||||
const latest = store.getLatestVersion(model);
|
||||
if (latest !== null && !isDeepStrictEqual(latest.document, FIRST_DEFAULT_PROFILE(model))) continue;
|
||||
const result = store.publishVersion({ profileId: model, expectedVersion: (latest?.version ?? 0) + 1, document: DEFAULT_PROFILE(model), now });
|
||||
|
||||
@@ -16,7 +16,7 @@ const bazarr: AppConfig = {
|
||||
releaseNotesExecutor: 'sonnet',
|
||||
migrateExecutor: null,
|
||||
promptHint: null,
|
||||
postUpgradeExecutor: null,
|
||||
postUpgradeExecutor: null, postUpgradeWhen: 'when_needed',
|
||||
notes: null,
|
||||
apply: false,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { AutoLevel, Executor } from './steps.ts';
|
||||
import { AutoLevel, Executor, ModelName, PostUpgradeWhen } from './steps.ts';
|
||||
import type { AppConfig } from './steps.ts';
|
||||
import { compileTagPattern } from './tags.ts';
|
||||
|
||||
@@ -15,7 +15,7 @@ export function countWords(text: string): number {
|
||||
}
|
||||
|
||||
/** Model steps run on a model; `script` is not a choice for them. */
|
||||
const ModelChoice = z.enum(['sonnet', 'opus']);
|
||||
const ModelChoice = ModelName;
|
||||
|
||||
const cleared = (value: unknown) => (typeof value === 'string' && value.trim() === '' ? null : value);
|
||||
|
||||
@@ -41,6 +41,7 @@ export const AppConfigPatch = z
|
||||
migrateExecutor: ModelChoice.nullable(),
|
||||
promptHint: z.preprocess(cleared, z.string().trim().max(500).nullable()),
|
||||
postUpgradeExecutor: ModelChoice.nullable(),
|
||||
postUpgradeWhen: PostUpgradeWhen,
|
||||
notes: z.preprocess(
|
||||
cleared,
|
||||
z
|
||||
@@ -61,6 +62,8 @@ export const AppConfigValue = z.strictObject({
|
||||
service: z.string().min(1), imageRepo: z.string().min(1), tagPattern: z.string(), auto: AutoLevel,
|
||||
httpUrl: z.string().nullable(), expectStatus: z.number().int(), releaseNotesExecutor: Executor,
|
||||
migrateExecutor: Executor.nullable(), promptHint: z.string().nullable(), postUpgradeExecutor: Executor.nullable(),
|
||||
// Revisions from before 2026-10-07 have none: the same default as the app_config column (migration 25).
|
||||
postUpgradeWhen: PostUpgradeWhen.default('when_needed'),
|
||||
notes: z.string().nullable(), apply: z.boolean(),
|
||||
});
|
||||
|
||||
|
||||
@@ -88,7 +88,8 @@ export const MigrateRequest = z.strictObject({
|
||||
image: PinnedImage,
|
||||
expect_manifest_sha256: Sha256,
|
||||
backup_id: BackupId,
|
||||
compose_yaml: z.string().min(1).max(65_536),
|
||||
/** null: the plan keeps the current compose.yaml; only the service's image changes. */
|
||||
compose_yaml: z.string().min(1).max(65_536).nullable(),
|
||||
env: z.record(z.string().regex(/^[A-Z][A-Z0-9_]{0,63}$/), z.string().max(4096).regex(/^[^\n\r]*$/, 'one line')),
|
||||
});
|
||||
export type MigrateRequest = z.infer<typeof MigrateRequest>;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { appHealth, downApps, parseLabeledDockerPs, worstHealth } from './host-status.ts';
|
||||
import type { LabeledContainer } from './host-status.ts';
|
||||
|
||||
const row = (project: string, service: string, state: string, status: string) =>
|
||||
JSON.stringify({ Labels: `com.docker.compose.project=${project},traefik.rule=Host(\`a,b\`),com.docker.compose.service=${service}`, State: state, Status: status, Image: 'img' });
|
||||
|
||||
describe('parseLabeledDockerPs', () => {
|
||||
it('reads the compose project and service from the labels, and health from the status', () => {
|
||||
const rows = parseLabeledDockerPs([row('grafana', 'grafana', 'running', 'Up 2 days (healthy)'), row('grafana', 'influxdb', 'restarting', 'Restarting (1) 3 seconds ago')].join('\n'));
|
||||
expect(rows).toEqual([
|
||||
{ project: 'grafana', service: 'grafana', state: 'running', health: 'healthy' },
|
||||
{ project: 'grafana', service: 'influxdb', state: 'restarting', health: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips containers that compose did not start', () => {
|
||||
expect(parseLabeledDockerPs(JSON.stringify({ Labels: 'other=1', State: 'running', Status: 'Up' }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appHealth', () => {
|
||||
const apps = [
|
||||
{ app: 'grafana', project: 'grafana', service: 'grafana', enabled: true },
|
||||
{ app: 'grafana-influxdb', project: 'grafana', service: 'influxdb', enabled: false },
|
||||
{ app: 'jellyfin', project: 'jellyfin', service: 'jellyfin', enabled: true },
|
||||
{ app: 'ombi', project: 'ombi', service: 'ombi', enabled: true },
|
||||
];
|
||||
|
||||
it('gives every app the state of its service: missing when no container, restarting before running, the worst health', () => {
|
||||
const rows: LabeledContainer[] = [
|
||||
{ project: 'grafana', service: 'grafana', state: 'running', health: 'healthy' },
|
||||
{ project: 'grafana', service: 'influxdb', state: 'exited', health: null },
|
||||
{ project: 'ombi', service: 'ombi', state: 'running', health: 'healthy' },
|
||||
{ project: 'ombi', service: 'ombi', state: 'restarting', health: null },
|
||||
];
|
||||
expect(appHealth(rows, apps)).toEqual([
|
||||
{ app: 'grafana', enabled: true, state: 'running', health: 'healthy' },
|
||||
{ app: 'grafana-influxdb', enabled: false, state: 'exited', health: null },
|
||||
{ app: 'jellyfin', enabled: true, state: 'missing', health: null },
|
||||
{ app: 'ombi', enabled: true, state: 'restarting', health: 'healthy' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downApps', () => {
|
||||
it('names enabled apps that do not run or run unhealthy', () => {
|
||||
const apps = [
|
||||
{ app: 'a', enabled: true, state: 'running', health: null },
|
||||
{ app: 'b', enabled: true, state: 'running', health: 'unhealthy' as const },
|
||||
{ app: 'c', enabled: true, state: 'missing', health: null },
|
||||
{ app: 'd', enabled: false, state: 'exited', health: null },
|
||||
];
|
||||
expect(downApps({ apps })).toEqual(['b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('worstHealth', () => {
|
||||
it('orders unhealthy over starting over healthy over no check', () => {
|
||||
expect(worstHealth([null, 'healthy', 'starting'])).toBe('starting');
|
||||
expect(worstHealth(['healthy', 'unhealthy'])).toBe('unhealthy');
|
||||
expect(worstHealth([null])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { z } from 'zod';
|
||||
import { healthFromStatus } from './compose.ts';
|
||||
|
||||
// What runs on VM103 right now, as the driver last saw it: the containers of every configured app and the age of the
|
||||
// newest VM backup. The driver writes it, the server shows it; nothing decides on it.
|
||||
|
||||
export const ContainerHealth = z.enum(['healthy', 'unhealthy', 'starting']);
|
||||
export type ContainerHealth = z.infer<typeof ContainerHealth>;
|
||||
|
||||
export const AppHealth = z.object({
|
||||
app: z.string(),
|
||||
enabled: z.boolean(),
|
||||
/** Docker's state of the service's container (`running`, `restarting`, `exited`…), or `missing` when there is none. */
|
||||
state: z.string(),
|
||||
health: ContainerHealth.nullable(),
|
||||
});
|
||||
export type AppHealth = z.infer<typeof AppHealth>;
|
||||
|
||||
export const BackupStatus = z.discriminatedUnion('ok', [
|
||||
z.object({ ok: z.literal(true), createdAt: z.string(), ageHours: z.number(), volid: z.string() }),
|
||||
z.object({ ok: z.literal(false), detail: z.string() }),
|
||||
]);
|
||||
export type BackupStatus = z.infer<typeof BackupStatus>;
|
||||
|
||||
export const HostStatus = z.object({
|
||||
checkedAt: z.string(),
|
||||
apps: z.array(AppHealth),
|
||||
/** Null when the driver has no backup record to read. */
|
||||
backup: BackupStatus.nullable(),
|
||||
/** Why the containers could not be listed; the apps list is then empty. */
|
||||
error: z.string().nullable(),
|
||||
});
|
||||
export type HostStatus = z.infer<typeof HostStatus>;
|
||||
|
||||
export interface LabeledContainer {
|
||||
readonly project: string;
|
||||
readonly service: string;
|
||||
readonly state: string;
|
||||
readonly health: ContainerHealth | null;
|
||||
}
|
||||
|
||||
// Label values may contain commas (traefik rules), so each compose label is matched by its full name at an entry start.
|
||||
const PROJECT_LABEL = /(?:^|,)com\.docker\.compose\.project=([^,]*)/;
|
||||
const SERVICE_LABEL = /(?:^|,)com\.docker\.compose\.service=([^,]*)/;
|
||||
|
||||
const PsRow = z.object({ Labels: z.string().default(''), State: z.string().default('unknown'), Status: z.string().default('') });
|
||||
|
||||
/** Rows of `docker ps -a --format '{{json .}}'`, one JSON object per line; containers without compose labels are left out. */
|
||||
export function parseLabeledDockerPs(raw: string): LabeledContainer[] {
|
||||
const out: LabeledContainer[] = [];
|
||||
for (const line of raw.split('\n')) {
|
||||
if (line.trim() === '') continue;
|
||||
const row = PsRow.parse(JSON.parse(line));
|
||||
const project = PROJECT_LABEL.exec(row.Labels)?.[1];
|
||||
const service = SERVICE_LABEL.exec(row.Labels)?.[1];
|
||||
if (project === undefined || service === undefined) continue;
|
||||
out.push({ project, service, state: row.State, health: healthFromStatus(row.Status) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const HEALTH_RANK: Readonly<Record<ContainerHealth, number>> = { healthy: 1, starting: 2, unhealthy: 3 };
|
||||
|
||||
export function worstHealth(values: readonly (ContainerHealth | null)[]): ContainerHealth | null {
|
||||
let worst: ContainerHealth | null = null;
|
||||
for (const value of values) {
|
||||
if (value !== null && (worst === null || HEALTH_RANK[value] > HEALTH_RANK[worst])) worst = value;
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
// A restarting container outweighs a running one of the same service; otherwise the first container's state stands.
|
||||
function worstState(states: readonly string[]): string {
|
||||
if (states.length === 0) return 'missing';
|
||||
if (states.includes('restarting')) return 'restarting';
|
||||
return states.every((state) => state === 'running') ? 'running' : (states.find((state) => state !== 'running') ?? 'unknown');
|
||||
}
|
||||
|
||||
/** Enabled apps whose container is not running, or runs unhealthy. */
|
||||
export function downApps(status: Pick<HostStatus, 'apps'>): string[] {
|
||||
return status.apps.filter((app) => app.enabled && (app.state !== 'running' || app.health === 'unhealthy')).map((app) => app.app);
|
||||
}
|
||||
|
||||
/** Every configured app with the state of its compose service's containers, in the apps' order. */
|
||||
export function appHealth(
|
||||
containers: readonly LabeledContainer[],
|
||||
apps: readonly { readonly app: string; readonly project: string; readonly service: string; readonly enabled: boolean }[],
|
||||
): AppHealth[] {
|
||||
return apps.map((app) => {
|
||||
const own = containers.filter((container) => container.project === app.project && container.service === app.service);
|
||||
return { app: app.app, enabled: app.enabled, state: worstState(own.map((container) => container.state)), health: worstHealth(own.map((container) => container.health)) };
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import type { ModelFailure } from './ports.ts';
|
||||
import { ModelName } from './steps.ts';
|
||||
|
||||
// The model sidecar's API: the driver asks it to run one prompt (readonly, or with the hl tool in a repair session).
|
||||
|
||||
@@ -16,7 +17,7 @@ export type ModelSidecarRunResult =
|
||||
| { readonly ok: false; readonly kind: ModelFailure; readonly detail: string; readonly costUsd?: number | null };
|
||||
|
||||
export const ModelRunRequest = z.strictObject({
|
||||
executor: z.enum(['sonnet', 'opus']),
|
||||
executor: ModelName,
|
||||
profile: z.enum(['readonly', 'observe', 'tools']),
|
||||
prompt: z.string().min(1).max(400_000),
|
||||
output_schema: z.record(z.string(), z.unknown()),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_NOTIFICATION_SETTINGS, digestDue, inQuietHours, nightDigest } from './notification-settings.ts';
|
||||
|
||||
const at = (time: string, day = '2026-09-30') => ({ hour: Number(time.slice(0, 2)), minute: Number(time.slice(3)), key: `${day}T${time}` });
|
||||
|
||||
describe('inQuietHours', () => {
|
||||
it('holds pushes from 01:00 to 07:30 by default, and not on the evening before', () => {
|
||||
const s = DEFAULT_NOTIFICATION_SETTINGS;
|
||||
expect(inQuietHours(s, at('00:59'))).toBe(false);
|
||||
expect(inQuietHours(s, at('01:00'))).toBe(true);
|
||||
expect(inQuietHours(s, at('07:29'))).toBe(true);
|
||||
expect(inQuietHours(s, at('07:30'))).toBe(false);
|
||||
expect(inQuietHours(s, at('22:00'))).toBe(false);
|
||||
});
|
||||
|
||||
it('wraps over midnight and is off when both ends are equal', () => {
|
||||
const late = { ...DEFAULT_NOTIFICATION_SETTINGS, quietFrom: '22:00', quietUntil: '06:00' };
|
||||
expect(inQuietHours(late, at('23:15'))).toBe(true);
|
||||
expect(inQuietHours(late, at('05:59'))).toBe(true);
|
||||
expect(inQuietHours(late, at('12:00'))).toBe(false);
|
||||
expect(inQuietHours({ ...late, quietUntil: '22:00' }, at('23:00'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('digestDue', () => {
|
||||
it('is due once a day from the report time on', () => {
|
||||
const s = DEFAULT_NOTIFICATION_SETTINGS;
|
||||
expect(digestDue(s, at('07:29'), null)).toBe(false);
|
||||
expect(digestDue(s, at('07:30'), null)).toBe(true);
|
||||
expect(digestDue(s, at('09:00'), '2026-09-29')).toBe(true);
|
||||
expect(digestDue(s, at('09:00'), '2026-09-30')).toBe(false);
|
||||
expect(digestDue({ ...s, digestEnabled: false }, at('09:00'), null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nightDigest', () => {
|
||||
const run = (overrides: Record<string, unknown>) => ({
|
||||
app: 'sonarr', workflowId: 'update-sonarr', status: 'succeeded', mode: 'real' as const, current: '4.0.1', candidate: '4.0.2', running: '4.0.2', outcome: 'Updated', step: null, updatedAt: '2026-09-30T02:00:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('lists what needs the owner first, then what changed since the last report', () => {
|
||||
const digest = nightDigest({
|
||||
since: '2026-09-29T05:30:00.000Z',
|
||||
runs: [
|
||||
run({}),
|
||||
run({ app: 'radarr', running: '5.1', current: '5.1', candidate: '5.2', outcome: 'Rolled back' }),
|
||||
run({ app: 'bazarr', status: 'failed', outcome: 'Verify failed', running: '1.0', current: '1.0' }),
|
||||
run({ app: 'jellyfin', status: 'waiting', step: 'Owner decides', updatedAt: '2026-09-28T00:00:00.000Z' }),
|
||||
run({ app: 'ombi', mode: 'shadow' as const }),
|
||||
run({ app: 'old', updatedAt: '2026-09-20T00:00:00.000Z' }),
|
||||
],
|
||||
down: ['grafana'],
|
||||
});
|
||||
expect(digest.title).toBe('Night report: 3 need you');
|
||||
expect(digest.detail.split('\n')).toEqual([
|
||||
'Waiting for you: jellyfin (Owner decides)',
|
||||
'Failed: bazarr (Verify failed)',
|
||||
'Down now: grafana',
|
||||
'Updated: sonarr 4.0.1 → 4.0.2',
|
||||
'Not updated: radarr (Rolled back)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('says all is well when nothing happened', () => {
|
||||
expect(nightDigest({ since: '2026-09-29T05:30:00.000Z', runs: [], down: [] })).toEqual({ title: 'Night report: all good', detail: 'No real update, nothing failed, nothing waits for you.', needsYou: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// When the owner's phone may ring and what the morning report says. Quiet hours hold every push (the report covers what
|
||||
// happened in them); the evening before is not quiet, so a failure then still rings. Times are wall-clock in the
|
||||
// schedules' time zone.
|
||||
|
||||
const Time = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'must be HH:MM');
|
||||
|
||||
export const NotificationSettings = z.object({
|
||||
quietFrom: Time,
|
||||
quietUntil: Time,
|
||||
digestAt: Time,
|
||||
digestEnabled: z.boolean(),
|
||||
});
|
||||
export type NotificationSettings = z.infer<typeof NotificationSettings>;
|
||||
|
||||
export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = { quietFrom: '01:00', quietUntil: '07:30', digestAt: '07:30', digestEnabled: true };
|
||||
|
||||
interface WallClock {
|
||||
readonly hour: number;
|
||||
readonly minute: number;
|
||||
/** `YYYY-MM-DDTHH:MM` (domain/cron.ts zonedMinute): its day names the report. */
|
||||
readonly key: string;
|
||||
}
|
||||
|
||||
const minutes = (time: string): number => Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5));
|
||||
|
||||
export function inQuietHours(settings: NotificationSettings, at: WallClock): boolean {
|
||||
const from = minutes(settings.quietFrom);
|
||||
const until = minutes(settings.quietUntil);
|
||||
const now = at.hour * 60 + at.minute;
|
||||
if (from === until) return false;
|
||||
return from < until ? now >= from && now < until : now >= from || now < until;
|
||||
}
|
||||
|
||||
/** The report goes out once a day, at its time or at the first look after it (the driver may have been down). */
|
||||
export function digestDue(settings: NotificationSettings, at: WallClock, lastDay: string | null): boolean {
|
||||
return settings.digestEnabled && at.hour * 60 + at.minute >= minutes(settings.digestAt) && lastDay !== at.key.slice(0, 10);
|
||||
}
|
||||
|
||||
export interface DigestRun {
|
||||
readonly app: string | null;
|
||||
readonly workflowId: string;
|
||||
readonly status: string;
|
||||
readonly mode: 'shadow' | 'real';
|
||||
readonly current: string | null;
|
||||
readonly candidate: string | null;
|
||||
readonly running: string | null;
|
||||
readonly outcome: string | null;
|
||||
readonly step: string | null;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Digest {
|
||||
readonly title: string;
|
||||
readonly detail: string;
|
||||
/** Runs waiting at a gate, failures and apps down: what the owner should look at. */
|
||||
readonly needsYou: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The morning report: what waits for the owner and what is broken first, then what changed since the last report.
|
||||
* A run still waiting counts whenever it started; the rest only when it ended after `since`. Dry runs change nothing.
|
||||
*/
|
||||
export function nightDigest(input: { readonly runs: readonly DigestRun[]; readonly since: string; readonly down: readonly string[] }): Digest {
|
||||
const since = Date.parse(input.since);
|
||||
const name = (run: DigestRun) => run.app ?? run.workflowId;
|
||||
const recent = input.runs.filter((run) => Date.parse(run.updatedAt) > since);
|
||||
const waiting = input.runs.filter((run) => run.status === 'waiting').map((run) => `${name(run)} (${run.step ?? 'decision'})`);
|
||||
const failed = recent.filter((run) => run.status === 'failed').map((run) => `${name(run)} (${run.outcome ?? 'failed'})`);
|
||||
const real = recent.filter((run) => run.mode === 'real' && run.status === 'succeeded');
|
||||
const updated = real.filter((run) => run.running !== null && run.running !== run.current).map((run) => `${name(run)} ${run.current ?? '?'} → ${run.running ?? '?'}`);
|
||||
const kept = real.filter((run) => run.running === null || run.running === run.current).map((run) => `${name(run)} (${run.outcome ?? 'not updated'})`);
|
||||
const lines = [
|
||||
waiting.length > 0 ? `Waiting for you: ${waiting.join(', ')}` : null,
|
||||
failed.length > 0 ? `Failed: ${failed.join(', ')}` : null,
|
||||
input.down.length > 0 ? `Down now: ${input.down.join(', ')}` : null,
|
||||
updated.length > 0 ? `Updated: ${updated.join(', ')}` : null,
|
||||
kept.length > 0 ? `Not updated: ${kept.join(', ')}` : null,
|
||||
].filter((line): line is string => line !== null);
|
||||
const needsYou = waiting.length + failed.length + input.down.length;
|
||||
return {
|
||||
title: needsYou > 0 ? `Night report: ${String(needsYou)} need you` : 'Night report: all good',
|
||||
detail: lines.length > 0 ? lines.join('\n') : 'No real update, nothing failed, nothing waits for you.',
|
||||
needsYou,
|
||||
};
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const config = (app: string, position: number, enabled = true): AppConfig => ({
|
||||
releaseNotesExecutor: 'sonnet',
|
||||
migrateExecutor: null,
|
||||
promptHint: null,
|
||||
postUpgradeExecutor: null,
|
||||
postUpgradeExecutor: null, postUpgradeWhen: 'when_needed',
|
||||
notes: null,
|
||||
apply: false,
|
||||
});
|
||||
|
||||
+22
-3
@@ -18,6 +18,7 @@ import type {
|
||||
SessionOpenRequest,
|
||||
SessionOpenResult,
|
||||
SessionLogResult,
|
||||
AgentLogsRequest,
|
||||
AgentTextResult,
|
||||
SessionLogEntry,
|
||||
SessionSummary,
|
||||
@@ -25,7 +26,9 @@ import type {
|
||||
import type { PreflightReport } from './preflight.ts';
|
||||
import type { Skill, SkillVersion } from './skill.ts';
|
||||
import type { AppConfigRevision } from './app-config.ts';
|
||||
import type { AppConfig, Executor, JsonValue } from './steps.ts';
|
||||
import type { AppConfig, Executor, JsonValue, ModelName } from './steps.ts';
|
||||
import type { HostStatus } from './host-status.ts';
|
||||
import type { NotificationSettings } from './notification-settings.ts';
|
||||
import type { AppendableWorkflowInstanceEventInput, PublishedWorkflowVersion, WorkflowDraft, WorkflowDocument, WorkflowInstance, WorkflowInstanceCreateInput, WorkflowInstanceEvent, WorkflowInstanceStatus, WorkflowValidation } from './workflow.ts';
|
||||
import type { ExecutionCommand, NodeAttempt, ReplayStatus, WorkflowExecutionProjection } from './workflow-execution.ts';
|
||||
import type { ExecutionFinalizeOutcome } from './workflow-execution-ledger.ts';
|
||||
@@ -401,6 +404,8 @@ export interface ExecutorClient {
|
||||
sessionLog(request: SessionCloseRequest, signal?: AbortSignal): Promise<ExecutorCall<SessionLogResult>>;
|
||||
/** The project's compose.yaml as the session sees it (secrets masked as `<secret:NAME>`). */
|
||||
sessionCompose(request: SessionCloseRequest, signal?: AbortSignal): Promise<ExecutorCall<AgentTextResult>>;
|
||||
/** A service's last log lines as the session reads them (secrets masked), like the model's `hl logs`. */
|
||||
sessionLogs(request: SessionCloseRequest & AgentLogsRequest, signal?: AbortSignal): Promise<ExecutorCall<AgentTextResult>>;
|
||||
}
|
||||
|
||||
export interface ModelRequest {
|
||||
@@ -433,6 +438,20 @@ export interface ModelResult {
|
||||
export type ModelFailure = 'rate_limited' | 'auth_failed' | 'invalid_output' | 'unavailable' | 'busy';
|
||||
|
||||
/** The owner's trusted release notes sources, one list for all apps (http(s) pages or `github:owner/repo`). */
|
||||
/** The driver's latest look at VM103 (domain/host-status.ts); null before its first look. */
|
||||
export interface HostStatusStore {
|
||||
get(): HostStatus | null;
|
||||
put(status: HostStatus): void;
|
||||
}
|
||||
|
||||
/** The owner's quiet hours and morning report (domain/notification-settings.ts), and when the last report went out. */
|
||||
export interface NotificationSettingsStore {
|
||||
get(): NotificationSettings;
|
||||
put(settings: NotificationSettings, now: string): void;
|
||||
lastDigest(): { readonly day: string; readonly at: string } | null;
|
||||
recordDigest(day: string, at: string): void;
|
||||
}
|
||||
|
||||
export interface ReleaseNotesSourcesStore {
|
||||
get(): string[];
|
||||
put(sources: readonly string[], now: string): void;
|
||||
@@ -460,14 +479,14 @@ export interface ModelActionPrompts {
|
||||
readonly migration?: { readonly summary: string; readonly stepsAfter: readonly string[] };
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ readonly executor: 'sonnet' | 'opus'; readonly prompt: string }>;
|
||||
): Promise<{ readonly executor: ModelName; readonly prompt: string }>;
|
||||
repair(
|
||||
request: {
|
||||
readonly component: string; readonly project: string; readonly service: string; readonly image: string; readonly reason: string; readonly previousImage: string | null;
|
||||
readonly fullAccess?: boolean;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ readonly executor: 'sonnet' | 'opus'; readonly prompt: string }>;
|
||||
): Promise<{ readonly executor: ModelName; readonly prompt: string }>;
|
||||
}
|
||||
|
||||
/** Runs a model (the `claude` CLI logged in with the owner's account, in the model sidecar). Throws ModelError. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildPostUpgradePrompt, POST_UPGRADE_ANSWER_SCHEMA, PostUpgradeAnswer } from './post-upgrade.ts';
|
||||
import { buildPostUpgradePrompt, POST_UPGRADE_ANSWER_SCHEMA, PostUpgradeAnswer, suspiciousLogLines } from './post-upgrade.ts';
|
||||
|
||||
const context = {
|
||||
app: 'nextcloud',
|
||||
@@ -58,3 +58,35 @@ describe('PostUpgradeAnswer', () => {
|
||||
expect(POST_UPGRADE_ANSWER_SCHEMA).toMatchObject({ type: 'object', required: ['outcome', 'summary', 'actions'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('suspiciousLogLines', () => {
|
||||
it('finds errors, fatal ones, panics, exceptions and failed migrations', () => {
|
||||
const logs = [
|
||||
'[migrations] started',
|
||||
'[migrations] no migrations found',
|
||||
'2026-10-07 09:27:01 INFO Starting Sonarr',
|
||||
'2026-10-07 09:27:02 ERROR Unable to connect to indexer',
|
||||
'level=fatal msg="database is locked"',
|
||||
'panic: runtime error: index out of range',
|
||||
'System.NullReferenceException: Object reference not set',
|
||||
'Traceback (most recent call last):',
|
||||
'Migration failed: column already exists',
|
||||
].join('\n');
|
||||
expect(suspiciousLogLines(logs)).toEqual([
|
||||
'2026-10-07 09:27:02 ERROR Unable to connect to indexer',
|
||||
'level=fatal msg="database is locked"',
|
||||
'panic: runtime error: index out of range',
|
||||
'System.NullReferenceException: Object reference not set',
|
||||
'Traceback (most recent call last):',
|
||||
'Migration failed: column already exists',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a clean start, and lines that only say there are no errors, find nothing', () => {
|
||||
expect(suspiciousLogLines('[migrations] no migrations found\nStarted in 2.1 s, 0 errors\nno errors found\n')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps at most the first lines it is asked for', () => {
|
||||
expect(suspiciousLogLines(Array.from({ length: 30 }, (_, index) => `ERROR ${String(index)}`).join('\n'), 3)).toEqual(['ERROR 0', 'ERROR 1', 'ERROR 2']);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,20 @@ import { z } from 'zod';
|
||||
|
||||
// The step after a successful update (owner, 2026-09-20): some apps ask for actions once the new version runs (Nextcloud: missing
|
||||
// database indices, occ repair). A model looks at what the release notes analysis and the app's skill say, does what the tool
|
||||
// allows and lists the rest for the owner. Per app, off by default: then the chain stays scripts only.
|
||||
// allows and lists the rest for the owner. Per app, off by default: then the chain stays scripts only. When on, by default only
|
||||
// when the update may ask for something (owner, 2026-10-07): one the owner approved, a major one, a migration, or errors in the logs.
|
||||
|
||||
/** Why a post-upgrade check runs the model. */
|
||||
export const PostUpgradeReason = z.enum(['approved_update', 'major', 'migration', 'log_errors']);
|
||||
export type PostUpgradeReason = z.infer<typeof PostUpgradeReason>;
|
||||
|
||||
const SUSPICIOUS = /\b(error|fatal|panic|traceback|critical)\b|exception\b|migrations? failed/i;
|
||||
const NO_ERRORS = /\b(no|0) errors?\b/i;
|
||||
|
||||
/** The log lines after a start that look like trouble, the first `max` of them. */
|
||||
export function suspiciousLogLines(logs: string, max = 10): string[] {
|
||||
return logs.split('\n').map((line) => line.trim()).filter((line) => SUSPICIOUS.test(line) && !NO_ERRORS.test(line)).slice(0, max);
|
||||
}
|
||||
|
||||
export const PostUpgradeAnswer = z.object({
|
||||
outcome: z
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { PostUpgradeReason } from './post-upgrade.ts';
|
||||
import { ModelName } from './steps.ts';
|
||||
|
||||
/**
|
||||
* The executor is intentionally a closed vocabulary. Adding an action is a
|
||||
@@ -128,7 +130,8 @@ export const DockerMigrateRequest = z.strictObject({
|
||||
image: PinnedImage,
|
||||
backupId: BackupId,
|
||||
expectedManifestSha256: Sha256,
|
||||
composeYaml: z.string().min(1).max(65_536),
|
||||
/** null when the plan keeps the compose file as it is apart from the image. */
|
||||
composeYaml: z.string().min(1).max(65_536).nullable(),
|
||||
planDigest: Sha256,
|
||||
inputs: z.array(z.strictObject({ name: z.string().regex(/^[A-Z][A-Z0-9_]{0,63}$/), target: z.enum(['env', 'compose']) })).max(20),
|
||||
});
|
||||
@@ -182,6 +185,10 @@ export const ModelPostUpgradeRequest = z.strictObject({
|
||||
profile: ProfileReference,
|
||||
/** After an approved migration (docker.migrate): its summary and the hl steps its plan runs once the app is up. */
|
||||
migration: z.strictObject({ summary: z.string().max(2000), stepsAfter: z.array(z.string().min(1).max(500)).max(30) }).optional(),
|
||||
/** The app's setting: off = no model, when_needed = only with a reason (or errors in the logs). Older requests have none: always. */
|
||||
when: z.enum(['off', 'when_needed', 'always']).optional(),
|
||||
/** What in this run asks for a look: an update the owner approved, a major one, a migration. */
|
||||
reasons: z.array(PostUpgradeReason).max(4).optional(),
|
||||
});
|
||||
export type ModelPostUpgradeRequest = z.infer<typeof ModelPostUpgradeRequest>;
|
||||
|
||||
@@ -322,7 +329,7 @@ const ModelActionReport = z.strictObject({
|
||||
outcome: z.string().min(1).max(64),
|
||||
summary: z.string().max(4000),
|
||||
actions: z.array(z.string().max(500)).max(50),
|
||||
model: z.enum(['sonnet', 'opus']),
|
||||
model: ModelName,
|
||||
costUsd: z.number().nonnegative(),
|
||||
});
|
||||
|
||||
@@ -354,6 +361,10 @@ export const ModelPostUpgradeResult = z.strictObject({
|
||||
manifestChanged: z.boolean(),
|
||||
manifestSha256: Sha256,
|
||||
report: ModelActionReport.optional(),
|
||||
/** Why the model ran; absent on results from before 2026-10-07. */
|
||||
reasons: z.array(PostUpgradeReason).max(4).optional(),
|
||||
/** The model did not run: the app has it off, or nothing in the run or the logs asked for it. */
|
||||
skipped: z.enum(['off', 'not_needed']).optional(),
|
||||
}).strict();
|
||||
export type ModelPostUpgradeResult = z.infer<typeof ModelPostUpgradeResult>;
|
||||
|
||||
|
||||
+16
-1
@@ -3,10 +3,24 @@ import { z } from 'zod';
|
||||
// Shared vocabulary: model executors, auto levels, reason codes, JSON and an app's configuration. The zod schema is the
|
||||
// single source of the type. (The file is named after the retired step queue it started with.)
|
||||
|
||||
export const Executor = z.enum(['script', 'sonnet', 'opus']);
|
||||
/** The Claude models a model step runs on; the profile of the same name picks the sidecar (Models page). */
|
||||
export const ModelName = z.enum(['haiku', 'sonnet', 'opus']);
|
||||
export type ModelName = z.infer<typeof ModelName>;
|
||||
/** The model of a model step whose app chose none (owner, 2026-10-07: haiku is cheap and good enough). */
|
||||
export const DEFAULT_MODEL: ModelName = 'haiku';
|
||||
/** The app's model choice, or the default when it names none (null, `script`, anything else). */
|
||||
export const modelOr = (choice: unknown): ModelName => {
|
||||
const parsed = ModelName.safeParse(choice);
|
||||
return parsed.success ? parsed.data : DEFAULT_MODEL;
|
||||
};
|
||||
|
||||
export const Executor = z.enum(['script', ...ModelName.options]);
|
||||
export type Executor = z.infer<typeof Executor>;
|
||||
|
||||
export const AutoLevel = z.enum(['none', 'patch', 'minor', 'major']);
|
||||
/** When an app's post-upgrade check runs its model: only when the update may ask for something, or after every update. */
|
||||
export const PostUpgradeWhen = z.enum(['when_needed', 'always']);
|
||||
export type PostUpgradeWhen = z.infer<typeof PostUpgradeWhen>;
|
||||
export type AutoLevel = z.infer<typeof AutoLevel>;
|
||||
|
||||
/** Why an operation stopped or failed (app/ops); unknown cause = `unexpected`. */
|
||||
@@ -59,6 +73,7 @@ export interface AppConfig {
|
||||
readonly promptHint: string | null;
|
||||
/** null = after an update only scripts check it; a model here checks what the update asks to be done afterwards. */
|
||||
readonly postUpgradeExecutor: Executor | null;
|
||||
readonly postUpgradeWhen: PostUpgradeWhen;
|
||||
readonly notes: string | null;
|
||||
/** Real updates through the executor (backup, apply, verify). Off = only dry runs. */
|
||||
readonly apply: boolean;
|
||||
|
||||
@@ -21,7 +21,7 @@ export const PRIVILEGED_ACTION_DESCRIPTIONS: Readonly<Record<PrivilegedActionId,
|
||||
'docker.diskUsage': { title: 'Check the disk', kind: 'read', summary: 'Executor reads the free space of the Docker filesystem and what images and build cache could give back.' },
|
||||
'docker.imagePrune': { title: 'Remove unused images', kind: 'change', summary: 'Executor removes Docker images no container uses (and the build cache when asked); volumes are never touched.' },
|
||||
'model.repair': { title: 'Model repairs the app', kind: 'model', summary: 'A model reads logs and the compose file and may change them to make the app healthy again.' },
|
||||
'model.postUpgrade': { title: 'Model checks the update', kind: 'model', summary: 'A model checks the updated app (hl ps, hl logs), may restart it, and asks the owner if something needs a decision.' },
|
||||
'model.postUpgrade': { title: 'Model checks the update', kind: 'model', summary: 'A model checks the updated app (hl ps, hl logs), may restart it, and asks the owner if something needs a decision. Off per app; by default only for an approved or major update, a migration, or errors in the logs.' },
|
||||
};
|
||||
|
||||
export const COMPONENT_OPERATION_DESCRIPTIONS: Readonly<Record<ComponentOperationId, BlockDescription>> = {
|
||||
|
||||
@@ -27,4 +27,18 @@ describe('workflowModelCalls', () => {
|
||||
expect(calls[2]).toMatchObject({ instanceId: 'run-1', workflowId: 'update-radarr', summary: 'Clean start.', actions: ['hl ps'], at: '2026-09-22T21:35:00.000Z' });
|
||||
expect(totalCostUsd(calls)).toBeCloseTo(0.82);
|
||||
});
|
||||
|
||||
it('counts a model run once when a later node carries its result on, as the terminal node does', () => {
|
||||
const postUpgrade = { action: 'model.postUpgrade', state: 'completed', report: { outcome: 'nothing_to_do', summary: 'Clean start.', actions: ['hl ps'], model: 'sonnet', costUsd: 0.2 } };
|
||||
const notes = { verdict: 'attention', summary: 'A migration runs.', model: 'opus', costUsd: 0.12 };
|
||||
const calls = workflowModelCalls({ id: 'run-1', workflowId: 'update-radarr' }, [
|
||||
attempt(1, 'release_notes', notes),
|
||||
attempt(2, 'gate', notes),
|
||||
attempt(3, 'post_upgrade', postUpgrade),
|
||||
attempt(4, 'terminal_updated', postUpgrade),
|
||||
]);
|
||||
|
||||
expect(calls.map((call) => call.nodeId)).toEqual(['release_notes', 'post_upgrade']);
|
||||
expect(totalCostUsd(calls)).toBeCloseTo(0.32);
|
||||
});
|
||||
});
|
||||
@@ -22,34 +22,43 @@ const cost = (value: unknown): number => (typeof value === 'number' && Number.is
|
||||
/**
|
||||
* The model runs a workflow run made, read from its node attempts: a model action's result carries `report`
|
||||
* (app/privileged-action-executor-adapter), the release notes operation's result carries `verdict` and `model`.
|
||||
* A node that passes the record on (a terminal node's result is its input) repeats that result; the same call counts once.
|
||||
*/
|
||||
export function workflowModelCalls(instance: { readonly id: string; readonly workflowId: string }, attempts: readonly NodeAttempt[]): WorkflowModelCall[] {
|
||||
return attempts.flatMap((attempt): WorkflowModelCall[] => {
|
||||
const result = record(attempt.result);
|
||||
const at = attempt.finishedAt ?? attempt.startedAt;
|
||||
const base = { instanceId: instance.id, workflowId: instance.workflowId, nodeId: attempt.nodeId, at };
|
||||
const action = text(result['action']);
|
||||
if (action === 'model.postUpgrade' || action === 'model.repair') {
|
||||
const report = record(result['report']);
|
||||
if (Object.keys(report).length === 0) return [];
|
||||
return [{
|
||||
...base, kind: action === 'model.repair' ? 'repair' : 'postUpgrade', model: text(report['model']),
|
||||
outcome: text(report['outcome']) ?? 'unknown', summary: text(report['summary']) ?? '',
|
||||
actions: Array.isArray(report['actions']) ? report['actions'].filter((item): item is string => typeof item === 'string') : [],
|
||||
costUsd: cost(report['costUsd']),
|
||||
}];
|
||||
}
|
||||
// Only a release notes result that reached a model names one; "no source" and unreadable notes cost nothing.
|
||||
if ('verdict' in result && text(result['model']) !== null) {
|
||||
return [{
|
||||
...base, kind: 'releaseNotes', model: text(result['model']), outcome: text(result['verdict']) ?? 'unknown',
|
||||
summary: text(result['summary']) ?? '', actions: [], costUsd: cost(result['costUsd']),
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
const seen = new Set<string>();
|
||||
return attempts.flatMap((attempt) => modelCallOf(instance, attempt)).filter(({ nodeId: _nodeId, at: _at, ...call }) => {
|
||||
const key = JSON.stringify(call);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function modelCallOf(instance: { readonly id: string; readonly workflowId: string }, attempt: NodeAttempt): WorkflowModelCall[] {
|
||||
const result = record(attempt.result);
|
||||
const at = attempt.finishedAt ?? attempt.startedAt;
|
||||
const base = { instanceId: instance.id, workflowId: instance.workflowId, nodeId: attempt.nodeId, at };
|
||||
const action = text(result['action']);
|
||||
if (action === 'model.postUpgrade' || action === 'model.repair') {
|
||||
const report = record(result['report']);
|
||||
if (Object.keys(report).length === 0) return [];
|
||||
return [{
|
||||
...base, kind: action === 'model.repair' ? 'repair' : 'postUpgrade', model: text(report['model']),
|
||||
outcome: text(report['outcome']) ?? 'unknown', summary: text(report['summary']) ?? '',
|
||||
actions: Array.isArray(report['actions']) ? report['actions'].filter((item): item is string => typeof item === 'string') : [],
|
||||
costUsd: cost(report['costUsd']),
|
||||
}];
|
||||
}
|
||||
// Only a release notes result that reached a model names one; "no source" and unreadable notes cost nothing.
|
||||
if ('verdict' in result && text(result['model']) !== null) {
|
||||
return [{
|
||||
...base, kind: 'releaseNotes', model: text(result['model']), outcome: text(result['verdict']) ?? 'unknown',
|
||||
summary: text(result['summary']) ?? '', actions: [], costUsd: cost(result['costUsd']),
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function totalCostUsd(calls: readonly WorkflowModelCall[]): number {
|
||||
return calls.reduce((sum, call) => sum + call.costUsd, 0);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PrivilegedActionVersion,
|
||||
} from './privileged-action.ts';
|
||||
import type { PrivilegedActionPolicy as PrivilegedActionPolicyType, PrivilegedActionResult as ActionResult } from './privileged-action.ts';
|
||||
import type { PostUpgradeReason } from './post-upgrade.ts';
|
||||
import { privilegedActionIdentityMatches } from './privileged-action-invocation.ts';
|
||||
import type { JsonValue } from './steps.ts';
|
||||
import { bindWorkflowResult, bindingInput, DataBindingV1 } from './workflow-data-binding.ts';
|
||||
@@ -83,6 +84,15 @@ export function resultEvent(result: ActionResult): string {
|
||||
export function createPrivilegedActionExtensionRegistry(policy?: unknown): WorkflowExtensionRegistry {
|
||||
const parsedPolicy = policy === undefined ? null : PrivilegedActionPolicy.safeParse(policy);
|
||||
const policyValue = parsedPolicy === null ? undefined : parsedPolicy.success ? parsedPolicy.data : null;
|
||||
/** What in the run asks for a post-upgrade look: release notes are read only for an update above the app's auto level. */
|
||||
function postUpgradeReasons(context: JsonValue, migrated: boolean): PostUpgradeReason[] {
|
||||
const reasons: PostUpgradeReason[] = [];
|
||||
if (isRecord(jsonPointer(context, '/releaseNotes'))) reasons.push('approved_update');
|
||||
if (jsonPointer(context, '/imageSelection/change') === 'major') reasons.push('major');
|
||||
if (migrated) reasons.push('migration');
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function deriveMergedSelected(
|
||||
parsedConfig: PrivilegedActionNodeConfig,
|
||||
context: JsonValue,
|
||||
@@ -135,7 +145,7 @@ function deriveMergedSelected(
|
||||
const plan = jsonPointer(context, '/migrationPlan/plan');
|
||||
const digest = jsonPointer(context, '/migrationPlan/planDigest');
|
||||
if (isRecord(plan)) {
|
||||
if (!('composeYaml' in result) && typeof plan.composeYaml === 'string') result['composeYaml'] = plan.composeYaml;
|
||||
if (!('composeYaml' in result) && (typeof plan.composeYaml === 'string' || plan.composeYaml === null)) result['composeYaml'] = plan.composeYaml;
|
||||
if (!('inputs' in result) && Array.isArray(plan.inputs)) {
|
||||
result['inputs'] = plan.inputs.filter(isRecord).map((input) => ({ name: input.name ?? null, target: input.target ?? null }));
|
||||
}
|
||||
@@ -151,6 +161,7 @@ function deriveMergedSelected(
|
||||
result['migration'] = { summary: plan.summary, stepsAfter: plan.stepsAfter };
|
||||
}
|
||||
}
|
||||
if (action === 'model.postUpgrade' && !('reasons' in result)) result['reasons'] = postUpgradeReasons(context, 'migration' in result);
|
||||
|
||||
// Profile & Reason for model actions
|
||||
if (action === 'model.repair' || action === 'model.postUpgrade') {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { HostStatus } from '../domain/host-status.ts';
|
||||
import { NotificationSettings } from '../domain/notification-settings.ts';
|
||||
import { z } from 'zod';
|
||||
import { SkillContent } from '../domain/skill.ts';
|
||||
import { AutoLevel, Executor, Json } from '../domain/steps.ts';
|
||||
import { AutoLevel, Executor, Json, PostUpgradeWhen } from '../domain/steps.ts';
|
||||
import {
|
||||
WorkflowDraft,
|
||||
PublishedWorkflowVersion,
|
||||
@@ -63,6 +65,7 @@ export const AppConfigResponse = z.object({
|
||||
migrateExecutor: Executor.nullable(),
|
||||
promptHint: z.string().nullable(),
|
||||
postUpgradeExecutor: Executor.nullable(),
|
||||
postUpgradeWhen: PostUpgradeWhen,
|
||||
notes: z.string().nullable(),
|
||||
apply: z.boolean(),
|
||||
});
|
||||
@@ -383,3 +386,7 @@ export type ModelProfilePublishRequest = z.infer<typeof ModelProfilePublishReque
|
||||
|
||||
/** GET/PUT /api/release-notes-sources: the owner's trusted release notes sources for all apps. */
|
||||
export const ReleaseNotesSourcesResponse = z.object({ sources: z.array(z.string()) });
|
||||
/** GET /api/host-status: the driver's latest look at VM103, null before its first. */
|
||||
export const HostStatusResponse = z.object({ status: HostStatus.nullable() });
|
||||
/** GET/PUT /api/notification-settings: quiet hours and the morning report, in the schedules' time zone. */
|
||||
export const NotificationSettingsResponse = z.object({ settings: NotificationSettings, timeZone: z.string(), lastDigestAt: z.string().nullable() });
|
||||
+21
-1
@@ -24,7 +24,7 @@ import type {
|
||||
WorkflowRunInputStore,
|
||||
ModelActivityStore,
|
||||
WorkflowScheduleStore,
|
||||
ReleaseNotesSourcesStore,
|
||||
HostStatusStore, NotificationSettingsStore, ReleaseNotesSourcesStore,
|
||||
} from '../domain/ports.ts';
|
||||
import { compileTagPattern, tagShapePattern } from '../domain/tags.ts';
|
||||
import { buildAppOverview } from '../domain/overview.ts';
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
import { buildWorkflowTimeline, decisionContext } from '../domain/workflow-timeline.ts';
|
||||
import { saveSchedule, scheduleNextRuns } from '../app/workflow-schedules.ts';
|
||||
import type { MigrationDecisionContext } from '../domain/workflow-timeline.ts';
|
||||
import { NotificationSettings } from '../domain/notification-settings.ts';
|
||||
import { totalCostUsd, workflowModelCalls } from '../domain/workflow-model-calls.ts';
|
||||
import { createWorkflowExtensions } from '../domain/workflow-extensions.ts';
|
||||
|
||||
@@ -99,6 +100,10 @@ export interface ServerDeps {
|
||||
readonly modelProfiles: ModelProfileStore;
|
||||
/** The owner's trusted release notes sources for all apps. */
|
||||
readonly releaseNotesSources: ReleaseNotesSourcesStore;
|
||||
/** The driver's latest look at VM103's containers and backup; unset: the overview shows none. */
|
||||
readonly hostStatus?: Pick<HostStatusStore, 'get'>;
|
||||
/** Quiet hours and the morning report; the driver reads them. */
|
||||
readonly notificationSettings: NotificationSettingsStore;
|
||||
/** What the owner typed into a migration form. */
|
||||
readonly workflowRunInputs: WorkflowRunInputStore;
|
||||
/** The models' executor session logs per node attempt (written by the driver); unset: the run page shows none. */
|
||||
@@ -913,6 +918,21 @@ export async function buildServer(deps: ServerDeps): Promise<FastifyInstance> {
|
||||
});
|
||||
|
||||
app.get('/api/release-notes-sources', (_request, reply) => reply.send({ sources: deps.releaseNotesSources.get() }));
|
||||
app.get('/api/host-status', (_request, reply) => reply.send({ status: deps.hostStatus?.get() ?? null }));
|
||||
|
||||
const notificationSettings = () => ({
|
||||
settings: deps.notificationSettings.get(),
|
||||
timeZone: deps.schedules.timeZone,
|
||||
lastDigestAt: deps.notificationSettings.lastDigest()?.at ?? null,
|
||||
});
|
||||
app.get('/api/notification-settings', (_request, reply) => reply.send(notificationSettings()));
|
||||
app.put('/api/notification-settings', (request, reply) => {
|
||||
const body = NotificationSettings.strict().safeParse(request.body);
|
||||
if (!body.success) return fail(reply, 400, 'invalid_request', issues(body.error));
|
||||
deps.notificationSettings.put(body.data, clock.now().toISOString());
|
||||
hub.notify();
|
||||
return reply.send(notificationSettings());
|
||||
});
|
||||
|
||||
app.put('/api/release-notes-sources', (request, reply) => {
|
||||
const body = z.strictObject({ sources: ReleaseNotesSources }).safeParse(request.body);
|
||||
|
||||
@@ -95,6 +95,19 @@ describe('HTTP executor client failures', () => {
|
||||
expect(seen).toEqual([{ url: 'http://executor:8081/agent/compose', authorization: `Bearer ${'a'.repeat(32)}` }]);
|
||||
});
|
||||
|
||||
it('reads a service’s logs with the session token, like the model’s hl logs', async () => {
|
||||
const seen: { url: string; authorization: string | null; body: unknown }[] = [];
|
||||
const client = createHttpExecutorClient({
|
||||
baseUrl: 'http://executor:8081',
|
||||
fetch: (input, init) => {
|
||||
seen.push({ url: input instanceof Request ? input.url : input.toString(), authorization: new Headers(init?.headers).get('authorization'), body: JSON.parse(init?.body as string) });
|
||||
return answering({ outcome: 'done', result: { text: 'started\n' } })();
|
||||
},
|
||||
});
|
||||
expect(await client.sessionLogs({ token: 'a'.repeat(32), service: 'sonarr', tail: 200 })).toEqual({ outcome: 'done', result: { text: 'started\n' } });
|
||||
expect(seen).toEqual([{ url: 'http://executor:8081/agent/logs', authorization: `Bearer ${'a'.repeat(32)}`, body: { service: 'sonarr', tail: 200 } }]);
|
||||
});
|
||||
|
||||
it('an unreachable executor is unavailable, naming where it looked', async () => {
|
||||
const client = createHttpExecutorClient({ baseUrl: 'http://executor:8081', fetch: () => Promise.reject(new TypeError('fetch failed', { cause: { code: 'ECONNREFUSED' } })) });
|
||||
expect(await client.inspect({ project: 'jackett', service: 'jackett' })).toEqual({ outcome: 'unavailable', detail: 'executor unreachable at http://executor:8081/inspect: fetch failed (ECONNREFUSED)' });
|
||||
|
||||
@@ -74,5 +74,6 @@ export function createHttpExecutorClient(options: HttpExecutorClientOptions): Ex
|
||||
closeSession: (request, signal) => post('/session/close', request, SessionCloseResponse, signal),
|
||||
sessionLog: (request, signal) => post('/session/log', request, SessionLogResponse, signal),
|
||||
sessionCompose: ({ token }, signal) => post('/agent/compose', {}, AgentTextResponse, signal, token),
|
||||
sessionLogs: ({ token, ...request }, signal) => post('/agent/logs', request, AgentTextResponse, signal, token),
|
||||
};
|
||||
}
|
||||
@@ -137,6 +137,47 @@ describe('createHttpRegistryClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP 429', () => {
|
||||
const tooMany = (headers: Record<string, string> = {}) => new Response('', { status: 429, headers });
|
||||
|
||||
it('waits and asks again, so a short rate limit does not fail the lookup', async () => {
|
||||
const waits: number[] = [];
|
||||
const { fetchFn, calls } = fakeRegistry({ [HUB]: [tooMany(), tooMany(), json({ tags: ['1'] })] });
|
||||
const client = createHttpRegistryClient({ fetch: fetchFn, rateLimitWaitsMs: [5_000, 30_000], sleep: (ms) => (waits.push(ms), Promise.resolve()) });
|
||||
expect(await client.listTags('nextcloud')).toEqual(['1']);
|
||||
expect(waits).toEqual([5_000, 30_000]);
|
||||
expect(calls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('waits as long as Retry-After asks when that is longer, up to five minutes', async () => {
|
||||
const waits: number[] = [];
|
||||
const { fetchFn } = fakeRegistry({ [HUB]: [tooMany({ 'retry-after': '20' }), tooMany({ 'retry-after': '3600' }), json({ tags: [] })] });
|
||||
await createHttpRegistryClient({ fetch: fetchFn, rateLimitWaitsMs: [5_000, 30_000], sleep: (ms) => (waits.push(ms), Promise.resolve()) }).listTags('nextcloud');
|
||||
expect(waits).toEqual([20_000, 300_000]);
|
||||
});
|
||||
|
||||
it('does not ask again unless pauses are given', async () => {
|
||||
const { fetchFn, calls } = fakeRegistry({ [HUB]: tooMany() });
|
||||
await createHttpRegistryClient({ fetch: fetchFn }).listTags('nextcloud').catch(() => undefined);
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('gives up as rate_limited once the pauses are used', async () => {
|
||||
const { fetchFn, calls } = fakeRegistry({ [HUB]: tooMany() });
|
||||
const error = await createHttpRegistryClient({ fetch: fetchFn, rateLimitWaitsMs: [1] }).listTags('nextcloud').catch((cause: unknown) => cause);
|
||||
expect((error as RegistryError).kind).toBe('rate_limited');
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('an abort during the pause is rethrown as it is', async () => {
|
||||
const controller = new AbortController();
|
||||
const { fetchFn } = fakeRegistry({ [HUB]: tooMany() });
|
||||
const running = createHttpRegistryClient({ fetch: fetchFn, rateLimitWaitsMs: [5_000] }).listTags('nextcloud', controller.signal);
|
||||
setTimeout(() => controller.abort(new Error('step time limit')), 20);
|
||||
await expect(running).rejects.toThrow('step time limit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('time', () => {
|
||||
const hanging = (_input: string | URL | Request, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => init?.signal?.addEventListener('abort', () => reject(init.signal?.reason as Error)));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
import { findPlatformEntry, parseImageRef, platformNames } from '../domain/image-ref.ts';
|
||||
import type { IndexEntry } from '../domain/image-ref.ts';
|
||||
@@ -14,11 +15,16 @@ export interface HttpRegistryOptions {
|
||||
readonly maxPages?: number;
|
||||
/** Largest response body we read (tag pages, manifests, image configs). */
|
||||
readonly maxBodyBytes?: number;
|
||||
/** Pauses before asking again after HTTP 429, one retry per entry; none by default. ghcr.io answers 429 now and then and is fine later. */
|
||||
readonly rateLimitWaitsMs?: readonly number[];
|
||||
readonly sleep?: (ms: number, signal: AbortSignal | undefined) => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_MAX_PAGES = 50;
|
||||
const DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024;
|
||||
/** A longer `Retry-After` than this is not waited out. */
|
||||
const MAX_RATE_LIMIT_WAIT_MS = 300_000;
|
||||
|
||||
const MANIFEST_ACCEPT = [
|
||||
'application/vnd.oci.image.index.v1+json',
|
||||
@@ -58,6 +64,8 @@ export function createHttpRegistryClient(options: HttpRegistryOptions = {}): Reg
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
|
||||
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
||||
const rateLimitWaitsMs = options.rateLimitWaitsMs ?? [];
|
||||
const sleep = options.sleep ?? ((ms: number, signal: AbortSignal | undefined) => delay(ms, undefined, signal === undefined ? {} : { signal }));
|
||||
|
||||
/** One repository on one registry: the token is fetched once and reused by every request. */
|
||||
function openSession(location: RepoLocation, signal: AbortSignal | undefined) {
|
||||
@@ -65,8 +73,25 @@ export function createHttpRegistryClient(options: HttpRegistryOptions = {}): Reg
|
||||
const what = `${location.registry}/${location.path}`;
|
||||
let token: string | null = null;
|
||||
|
||||
/** One request with the caller's abort and our own time limit. A caller abort is rethrown untouched. */
|
||||
/** `request`, asked again after HTTP 429 as long as `rateLimitWaitsMs` has pauses left; the last 429 is returned. */
|
||||
async function request(url: string, authorization: string | null, accept?: string): Promise<Response> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const response = await requestOnce(url, authorization, accept);
|
||||
const wait = rateLimitWaitsMs[attempt];
|
||||
if (response.status !== 429 || wait === undefined) return response;
|
||||
const retryAfterMs = Number(response.headers.get('retry-after')) * 1000;
|
||||
const pause = Number.isFinite(retryAfterMs) && retryAfterMs > wait ? Math.min(retryAfterMs, MAX_RATE_LIMIT_WAIT_MS) : wait;
|
||||
void response.body?.cancel().catch(() => undefined);
|
||||
try {
|
||||
await sleep(pause, signal);
|
||||
} catch (cause) {
|
||||
throw signal?.aborted === true ? (signal.reason as Error) : cause;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One request with the caller's abort and our own time limit. A caller abort is rethrown untouched. */
|
||||
async function requestOnce(url: string, authorization: string | null, accept?: string): Promise<Response> {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface ModelDispatcherOptions {
|
||||
|
||||
/**
|
||||
* Sends each model request to the sidecar of its profile's provider (Claude, Codex). The profile is the pinned
|
||||
* `profileRef` of a run's snapshot, or else the latest profile named like the request's executor (`sonnet`, `opus`: the
|
||||
* `profileRef` of a run's snapshot, or else the latest profile named like the request's executor (`haiku`, `sonnet`, `opus`: the
|
||||
* app's configuration names the model by profile id). There is no default provider and no fallback: an unknown
|
||||
* profile or a provider without a sidecar is an error, and one provider's failure never moves the request to another.
|
||||
* The profile's timeout and cost cap apply to the run.
|
||||
|
||||
@@ -82,6 +82,58 @@ describe('executor-backed privileged action adapter', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('when the post-upgrade model runs', () => {
|
||||
const nothingToDo = () => new FakeModelExecutor().reply({ output: { outcome: 'nothing_to_do', summary: 'Nothing to do.', actions: [] }, costUsd: 0.2 });
|
||||
const withLogs = (text: string) => session().on('sessionLogs', { outcome: 'done', result: { text } });
|
||||
|
||||
it('off: no session and no model, the hash comes from the service', async () => {
|
||||
const client = new FakeExecutorClient()
|
||||
.on('inspect', { outcome: 'done', result: { project: 'jackett', service: 'jackett', manifest_sha256: 'd'.repeat(64), env_sha256: null, manifest_image: PINNED, container: null } });
|
||||
const model = nothingToDo();
|
||||
const result = await createExecutorBackedPrivilegedActionExecutor({ executor: client, modelExecutor: model, prompts: prompts() }).execute({ ...postUpgrade, when: 'off', reasons: ['major'] });
|
||||
|
||||
expect(result).toEqual({ ...target, action: 'model.postUpgrade', version: 1, state: 'completed', manifestChanged: false, manifestSha256: 'd'.repeat(64), reasons: ['major'], skipped: 'off' });
|
||||
expect(model.requests).toEqual([]);
|
||||
expect(client.calls.map((call) => call.op)).toEqual(['inspect']);
|
||||
});
|
||||
|
||||
it('when needed, with nothing in the run asking and clean logs: reads the logs and stops there', async () => {
|
||||
const client = withLogs('[migrations] no migrations found\nStarted.\n');
|
||||
const model = nothingToDo();
|
||||
const result = await createExecutorBackedPrivilegedActionExecutor({ executor: client, modelExecutor: model, prompts: prompts() }).execute({ ...postUpgrade, when: 'when_needed', reasons: [] });
|
||||
|
||||
expect(result).toMatchObject({ state: 'completed', manifestChanged: false, manifestSha256: 'c'.repeat(64), reasons: [], skipped: 'not_needed' });
|
||||
expect(result).not.toHaveProperty('report');
|
||||
expect(model.requests).toEqual([]);
|
||||
expect(client.calls.map((call) => [call.op, call.request])).toEqual([
|
||||
['openSession', { project: 'jackett', service: 'jackett', mode: 'observe' }],
|
||||
['sessionLogs', { token: 'tok', service: 'jackett', tail: 200 }],
|
||||
['closeSession', { token: 'tok' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('when needed, errors in the logs (or logs it cannot read) bring the model in', async () => {
|
||||
for (const client of [withLogs('Started.\nERROR Unable to open database\n'), session().on('sessionLogs', { outcome: 'failed', detail: 'no container', manifest_replaced: false })]) {
|
||||
const model = nothingToDo();
|
||||
const result = await createExecutorBackedPrivilegedActionExecutor({ executor: client, modelExecutor: model, prompts: prompts() }).execute({ ...postUpgrade, when: 'when_needed', reasons: [] });
|
||||
|
||||
expect(result).toMatchObject({ state: 'completed', reasons: ['log_errors'], report: { outcome: 'nothing_to_do', costUsd: 0.2 } });
|
||||
expect(result).not.toHaveProperty('skipped');
|
||||
expect(model.requests).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('when needed, a reason from the run brings the model in without reading the logs first', async () => {
|
||||
const client = session();
|
||||
const model = nothingToDo();
|
||||
const result = await createExecutorBackedPrivilegedActionExecutor({ executor: client, modelExecutor: model, prompts: prompts() }).execute({ ...postUpgrade, when: 'when_needed', reasons: ['approved_update', 'major'] });
|
||||
|
||||
expect(result).toMatchObject({ state: 'completed', reasons: ['approved_update', 'major'], report: { outcome: 'nothing_to_do' } });
|
||||
expect(model.requests).toHaveLength(1);
|
||||
expect(client.calls.map((call) => call.op)).not.toContain('sessionLogs');
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the manifest hash from the service when the session gave no summary', async () => {
|
||||
const client = session()
|
||||
.on('closeSession', { outcome: 'failed', detail: 'gone', manifest_replaced: false })
|
||||
@@ -169,6 +221,18 @@ describe('executor-backed privileged action adapter', () => {
|
||||
expect(store.stored).toEqual({});
|
||||
});
|
||||
|
||||
it('sends no compose file when the plan keeps the current one', async () => {
|
||||
const client = new FakeExecutorClient().on('migrate', {
|
||||
outcome: 'done',
|
||||
result: { project: 'grafana', service: 'influxdb', state: 'applied', image: PINNED, manifest_sha256: 'a'.repeat(64), applied_manifest_sha256: 'b'.repeat(64), env_keys: [] },
|
||||
});
|
||||
const adapter = createExecutorBackedPrivilegedActionExecutor({ executor: client, runInputs: inputs({}) });
|
||||
|
||||
await adapter.execute({ ...request, composeYaml: null, inputs: [] }, undefined, { instanceId: 'run-1', nodeAttemptOrdinal: 4 });
|
||||
|
||||
expect(client.calls[0]?.request).toMatchObject({ compose_yaml: null, env: {} });
|
||||
});
|
||||
|
||||
it('refuses to run with a value missing, and keeps the values when the executor refused', async () => {
|
||||
const client = new FakeExecutorClient().on('migrate', { outcome: 'refused', reason: 'invalid_manifest', detail: 'bad' });
|
||||
await expect(createExecutorBackedPrivilegedActionExecutor({ executor: client, runInputs: inputs({ ADMIN_USER: 'admin' }) }).execute(request, undefined, { instanceId: 'run-1', nodeAttemptOrdinal: 4 }))
|
||||
|
||||
@@ -6,7 +6,8 @@ import type { SessionSummary } from '../domain/executor-api.ts';
|
||||
import { watchModelSession } from '../app/model-activity.ts';
|
||||
import type { ModelActivitySink } from '../app/model-activity.ts';
|
||||
import { REPAIR_ANSWER_SCHEMA, RepairAnswer } from '../domain/repair.ts';
|
||||
import { POST_UPGRADE_ANSWER_SCHEMA, PostUpgradeAnswer } from '../domain/post-upgrade.ts';
|
||||
import { POST_UPGRADE_ANSWER_SCHEMA, PostUpgradeAnswer, suspiciousLogLines } from '../domain/post-upgrade.ts';
|
||||
import type { PostUpgradeReason } from '../domain/post-upgrade.ts';
|
||||
|
||||
/** Drops closing tags the model sometimes leaves at the end of its summary (`…</summary>\n</invoke>`). */
|
||||
function cleanSummary(summary: string): string {
|
||||
@@ -15,6 +16,8 @@ function cleanSummary(summary: string): string {
|
||||
|
||||
/** The session log operations a repair result may list (ModelRepairResult.actions). */
|
||||
const SESSION_OPERATIONS: ReadonlySet<string> = new Set(['ps', 'logs', 'compose', 'write', 'pull', 'up', 'restart', 'exec-rules', 'exec', 'run']);
|
||||
/** How many of the service's last log lines a post-upgrade check scans before it decides whether a model looks. */
|
||||
const POST_UPGRADE_LOG_LINES = 200;
|
||||
|
||||
/** The closed driver-side mapping. It owns no command construction and only the executor client can reach Docker. */
|
||||
export function createExecutorBackedPrivilegedActionExecutor(deps: {
|
||||
@@ -84,6 +87,31 @@ export function createExecutorBackedPrivilegedActionExecutor(deps: {
|
||||
return { output, costUsd, failure, model, manifestChanged: summary?.manifest_changed ?? false, manifestSha256, actions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a post-upgrade check needs its model, and why. When needed only: a reason from the run, else the service's
|
||||
* last log lines read in an observe session (no model); errors there, or logs it cannot read, bring the model in.
|
||||
*/
|
||||
async function postUpgradeNeed(
|
||||
request: Extract<PrivilegedActionRequest, { readonly action: 'model.postUpgrade' }>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ readonly run: true; readonly reasons: readonly PostUpgradeReason[] } | { readonly run: false; readonly skipped: 'off' | 'not_needed'; readonly manifestSha256: string | null }> {
|
||||
const reasons = request.reasons ?? [];
|
||||
if (request.when === 'off') return { run: false, skipped: 'off', manifestSha256: null };
|
||||
if (request.when !== 'when_needed' || reasons.length > 0) return { run: true, reasons };
|
||||
const session = completed(request.action, await deps.executor.openSession({ project: request.project, service: request.service, mode: 'observe' }, signal));
|
||||
const close = () => deps.executor.closeSession({ token: session.token }, signal);
|
||||
let logs: ExecutorCall<{ readonly text: string }>;
|
||||
try {
|
||||
logs = await deps.executor.sessionLogs({ token: session.token, service: request.service, tail: POST_UPGRADE_LOG_LINES }, signal);
|
||||
} catch (cause) {
|
||||
await close().catch(() => undefined);
|
||||
throw cause;
|
||||
}
|
||||
const closed = await close();
|
||||
if (logs.outcome !== 'done' || suspiciousLogLines(logs.result.text).length > 0) return { run: true, reasons: ['log_errors'] };
|
||||
return { run: false, skipped: 'not_needed', manifestSha256: closed.outcome === 'done' ? closed.result.manifest_sha256 : null };
|
||||
}
|
||||
|
||||
return {
|
||||
async execute(request, signal, run) {
|
||||
const parsed = PrivilegedActionRequest.parse(request);
|
||||
@@ -141,9 +169,10 @@ export function createExecutorBackedPrivilegedActionExecutor(deps: {
|
||||
const values = deps.runInputs.values(run.instanceId);
|
||||
const missing = parsed.inputs.filter((input) => values[input.name] === undefined).map((input) => input.name);
|
||||
if (missing.length > 0) throw new Error(`docker.migrate is missing the owner's values for ${missing.join(', ')}`);
|
||||
const composeYaml = parsed.inputs
|
||||
const plannedCompose = parsed.composeYaml;
|
||||
const composeYaml = plannedCompose === null ? null : parsed.inputs
|
||||
.filter((input) => input.target === 'compose')
|
||||
.reduce((text, input) => text.replaceAll(`<input:${input.name}>`, values[input.name] ?? ''), parsed.composeYaml);
|
||||
.reduce((text, input) => text.replaceAll(`<input:${input.name}>`, values[input.name] ?? ''), plannedCompose);
|
||||
const env = Object.fromEntries(parsed.inputs.filter((input) => input.target === 'env').map((input) => [input.name, values[input.name] ?? '']));
|
||||
const answer = await deps.executor.migrate({
|
||||
project: parsed.project, service: parsed.service, image: parsed.image, expect_manifest_sha256: parsed.expectedManifestSha256,
|
||||
@@ -195,6 +224,17 @@ export function createExecutorBackedPrivilegedActionExecutor(deps: {
|
||||
break;
|
||||
}
|
||||
case 'model.postUpgrade': {
|
||||
const need = await postUpgradeNeed(parsed, signal);
|
||||
if (!need.run) {
|
||||
const manifestSha256 = need.manifestSha256
|
||||
?? completed(parsed.action, await deps.executor.inspect({ project: parsed.project, service: parsed.service }, signal)).manifest_sha256;
|
||||
result = {
|
||||
action: parsed.action, version: 1, component: parsed.component, project: parsed.project, service: parsed.service,
|
||||
image: parsed.image, backupId: parsed.backupId, state: 'completed', manifestChanged: false, manifestSha256,
|
||||
reasons: parsed.reasons ?? [], skipped: need.skipped,
|
||||
};
|
||||
break;
|
||||
}
|
||||
const asked = await ask(parsed, 'postUpgrade', signal, run);
|
||||
const answer = PostUpgradeAnswer.safeParse(asked.output);
|
||||
const report = answer.success
|
||||
@@ -208,6 +248,7 @@ export function createExecutorBackedPrivilegedActionExecutor(deps: {
|
||||
state: done ? 'completed' : 'action_required',
|
||||
manifestChanged: asked.manifestChanged, manifestSha256: asked.manifestSha256,
|
||||
report: { ...report, model: asked.model, costUsd: asked.costUsd },
|
||||
reasons: need.reasons,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -21,16 +21,16 @@ export function createSqliteAppConfigStore(db: DatabaseSync): VersionedAppConfig
|
||||
const previousConfig = previous === undefined ? null : parseAppConfig(previous);
|
||||
db.prepare(
|
||||
`INSERT INTO app_config (app, enabled, position, service, image_repo, tag_pattern, auto, http_url, expect_status,
|
||||
release_notes_executor, migrate_executor, prompt_hint, post_upgrade_executor, notes, apply, project)
|
||||
release_notes_executor, migrate_executor, prompt_hint, post_upgrade_executor, post_upgrade_when, notes, apply, project)
|
||||
VALUES (:app, :enabled, :position, :service, :imageRepo, :tagPattern, :auto, :httpUrl, :expectStatus,
|
||||
:releaseNotesExecutor, :migrateExecutor, :promptHint, :postUpgradeExecutor, :notes, :apply, :project)
|
||||
:releaseNotesExecutor, :migrateExecutor, :promptHint, :postUpgradeExecutor, :postUpgradeWhen, :notes, :apply, :project)
|
||||
ON CONFLICT (app) DO UPDATE SET
|
||||
enabled = excluded.enabled, position = excluded.position, service = excluded.service,
|
||||
image_repo = excluded.image_repo, tag_pattern = excluded.tag_pattern, auto = excluded.auto,
|
||||
http_url = excluded.http_url, expect_status = excluded.expect_status,
|
||||
release_notes_executor = excluded.release_notes_executor, migrate_executor = excluded.migrate_executor,
|
||||
prompt_hint = excluded.prompt_hint,
|
||||
post_upgrade_executor = excluded.post_upgrade_executor, notes = excluded.notes, apply = excluded.apply, project = excluded.project`,
|
||||
post_upgrade_executor = excluded.post_upgrade_executor, post_upgrade_when = excluded.post_upgrade_when, notes = excluded.notes, apply = excluded.apply, project = excluded.project`,
|
||||
).run({
|
||||
app: config.app,
|
||||
enabled: config.enabled ? 1 : 0,
|
||||
@@ -45,6 +45,7 @@ export function createSqliteAppConfigStore(db: DatabaseSync): VersionedAppConfig
|
||||
migrateExecutor: config.migrateExecutor,
|
||||
promptHint: config.promptHint,
|
||||
postUpgradeExecutor: config.postUpgradeExecutor,
|
||||
postUpgradeWhen: config.postUpgradeWhen,
|
||||
notes: config.notes,
|
||||
apply: config.apply ? 1 : 0,
|
||||
project: config.project,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { openDatabase } from './open.ts';
|
||||
import { createSqliteHostStatusStore } from './host-status-store.ts';
|
||||
|
||||
describe('host status store', () => {
|
||||
it('keeps only the latest look', () => {
|
||||
const store = createSqliteHostStatusStore(openDatabase(':memory:'));
|
||||
expect(store.get()).toBeNull();
|
||||
const first = { checkedAt: '2026-09-30T06:00:00.000Z', apps: [], backup: null, error: 'docker ps failed' };
|
||||
const second = { checkedAt: '2026-09-30T06:01:00.000Z', apps: [{ app: 'a', enabled: true, state: 'running', health: null }], backup: { ok: false as const, detail: 'none' }, error: null };
|
||||
store.put(first);
|
||||
store.put(second);
|
||||
expect(store.get()).toEqual(second);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import { HostStatus } from '../../domain/host-status.ts';
|
||||
import type { HostStatusStore } from '../../domain/ports.ts';
|
||||
|
||||
const KEY = 'hostStatus';
|
||||
|
||||
export function createSqliteHostStatusStore(db: DatabaseSync): HostStatusStore {
|
||||
return {
|
||||
get() {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = :key').get({ key: KEY });
|
||||
// A value an older or newer driver wrote in another shape shows as "not looked yet" until the next look.
|
||||
return row === undefined ? null : (HostStatus.safeParse(JSON.parse(String(row['value']))).data ?? null);
|
||||
},
|
||||
put(status) {
|
||||
db.prepare(`INSERT INTO settings (key, value, updated_at) VALUES (:key, :value, :now)
|
||||
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
|
||||
.run({ key: KEY, value: JSON.stringify(HostStatus.parse(status)), now: status.checkedAt });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -20,7 +20,7 @@ describe('migrate', () => {
|
||||
]);
|
||||
expect(columns(db, 'app_config')).toEqual([
|
||||
'app', 'enabled', 'position', 'service', 'image_repo', 'tag_pattern', 'auto', 'http_url', 'expect_status',
|
||||
'release_notes_executor', 'migrate_executor', 'prompt_hint', 'notes', 'apply', 'project', 'post_upgrade_executor',
|
||||
'release_notes_executor', 'migrate_executor', 'prompt_hint', 'notes', 'apply', 'project', 'post_upgrade_executor', 'post_upgrade_when',
|
||||
]);
|
||||
expect(columns(db, 'workflows')).toEqual(['id', 'name', 'created_at']);
|
||||
expect(columns(db, 'workflow_drafts')).toEqual(['workflow_id', 'revision', 'document', 'validation_revision', 'validation_document', 'diagnostics']);
|
||||
@@ -120,6 +120,19 @@ describe('migrate', () => {
|
||||
expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: 19 });
|
||||
});
|
||||
|
||||
it('v26 accepts haiku as an app model and keeps every app config verbatim', () => {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
migrate(db, MIGRATIONS.slice(0, 25));
|
||||
db.exec(`INSERT INTO app_config (app, service, image_repo, release_notes_executor, migrate_executor, post_upgrade_executor, post_upgrade_when, notes, apply, project)
|
||||
VALUES ('bazarr', 'bazarr', 'lscr.io/bazarr', 'opus', 'sonnet', NULL, 'always', 'n', 1, 'arr')`);
|
||||
const before = db.prepare('SELECT * FROM app_config').all();
|
||||
migrate(db, MIGRATIONS.slice(0, 26));
|
||||
expect(db.prepare('SELECT * FROM app_config').all()).toEqual(before);
|
||||
db.exec("INSERT INTO app_config (app, service, image_repo, migrate_executor, post_upgrade_executor) VALUES ('new', 'new', 'r', 'haiku', 'haiku')");
|
||||
expect(db.prepare("SELECT release_notes_executor FROM app_config WHERE app = 'new'").get()).toEqual({ release_notes_executor: 'haiku' });
|
||||
expect(() => db.exec("UPDATE app_config SET release_notes_executor = 'gpt' WHERE app = 'new'")).toThrow(/CHECK/);
|
||||
});
|
||||
|
||||
it('v7 adds a nullable progress to steps already there', () => {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
migrate(db, MIGRATIONS.slice(0, 6));
|
||||
|
||||
@@ -802,6 +802,46 @@ export const MIGRATIONS: readonly Migration[] = [
|
||||
) STRICT;
|
||||
`,
|
||||
},
|
||||
{
|
||||
// When an app's post-upgrade check runs its model (owner, 2026-10-07): by default only when the update may ask for something.
|
||||
version: 25,
|
||||
sql: `
|
||||
ALTER TABLE app_config ADD COLUMN post_upgrade_when TEXT NOT NULL DEFAULT 'when_needed' CHECK (post_upgrade_when IN ('when_needed', 'always'));
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Haiku as a model choice and the default release notes model (owner, 2026-10-07). SQLite cannot change a CHECK in
|
||||
// place, so the table is rebuilt; the retired steps table keeps its old one, nothing writes it any more.
|
||||
version: 26,
|
||||
sql: `
|
||||
ALTER TABLE app_config RENAME TO app_config_v25;
|
||||
CREATE TABLE app_config (
|
||||
app TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
service TEXT NOT NULL,
|
||||
image_repo TEXT NOT NULL,
|
||||
tag_pattern TEXT NOT NULL DEFAULT '.',
|
||||
auto TEXT NOT NULL DEFAULT 'patch' CHECK (auto IN ('none', 'patch', 'minor', 'major')),
|
||||
http_url TEXT,
|
||||
expect_status INTEGER NOT NULL DEFAULT 200,
|
||||
release_notes_executor TEXT NOT NULL DEFAULT 'haiku' CHECK (release_notes_executor IN ('script', 'haiku', 'sonnet', 'opus')),
|
||||
migrate_executor TEXT CHECK (migrate_executor IN ('script', 'haiku', 'sonnet', 'opus')),
|
||||
prompt_hint TEXT,
|
||||
notes TEXT,
|
||||
apply INTEGER NOT NULL DEFAULT 0 CHECK (apply IN (0, 1)),
|
||||
project TEXT NOT NULL DEFAULT '',
|
||||
post_upgrade_executor TEXT CHECK (post_upgrade_executor IN ('script', 'haiku', 'sonnet', 'opus')),
|
||||
post_upgrade_when TEXT NOT NULL DEFAULT 'when_needed' CHECK (post_upgrade_when IN ('when_needed', 'always'))
|
||||
) STRICT;
|
||||
INSERT INTO app_config (app, enabled, position, service, image_repo, tag_pattern, auto, http_url, expect_status,
|
||||
release_notes_executor, migrate_executor, prompt_hint, notes, apply, project, post_upgrade_executor, post_upgrade_when)
|
||||
SELECT app, enabled, position, service, image_repo, tag_pattern, auto, http_url, expect_status,
|
||||
release_notes_executor, migrate_executor, prompt_hint, notes, apply, project, post_upgrade_executor, post_upgrade_when
|
||||
FROM app_config_v25;
|
||||
DROP TABLE app_config_v25;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
function schemaVersion(db: DatabaseSync): number {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { openDatabase } from './open.ts';
|
||||
import { createSqliteNotificationSettingsStore } from './notification-settings-store.ts';
|
||||
import { DEFAULT_NOTIFICATION_SETTINGS } from '../../domain/notification-settings.ts';
|
||||
|
||||
describe('notification settings store', () => {
|
||||
it('starts from the defaults, keeps the owner’s settings and the last report', () => {
|
||||
const store = createSqliteNotificationSettingsStore(openDatabase(':memory:'));
|
||||
expect(store.get()).toEqual(DEFAULT_NOTIFICATION_SETTINGS);
|
||||
expect(store.lastDigest()).toBeNull();
|
||||
store.put({ ...DEFAULT_NOTIFICATION_SETTINGS, quietFrom: '00:30' }, '2026-09-30T08:00:00.000Z');
|
||||
store.recordDigest('2026-09-30', '2026-09-30T05:30:00.000Z');
|
||||
expect(store.get().quietFrom).toBe('00:30');
|
||||
expect(store.lastDigest()).toEqual({ day: '2026-09-30', at: '2026-09-30T05:30:00.000Z' });
|
||||
expect(() => store.put({ ...DEFAULT_NOTIFICATION_SETTINGS, digestAt: '7:30' }, '2026-09-30T08:00:00.000Z')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import { z } from 'zod';
|
||||
import { DEFAULT_NOTIFICATION_SETTINGS, NotificationSettings } from '../../domain/notification-settings.ts';
|
||||
import type { NotificationSettingsStore } from '../../domain/ports.ts';
|
||||
|
||||
const SETTINGS = 'notificationSettings';
|
||||
const LAST_DIGEST = 'lastDigest';
|
||||
const LastDigest = z.object({ day: z.string(), at: z.string() });
|
||||
|
||||
export function createSqliteNotificationSettingsStore(db: DatabaseSync): NotificationSettingsStore {
|
||||
const read = (key: string): unknown => {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = :key').get({ key });
|
||||
return row === undefined ? undefined : JSON.parse(String(row['value']));
|
||||
};
|
||||
const write = (key: string, value: unknown, now: string) => {
|
||||
db.prepare(`INSERT INTO settings (key, value, updated_at) VALUES (:key, :value, :now)
|
||||
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
|
||||
.run({ key, value: JSON.stringify(value), now });
|
||||
};
|
||||
return {
|
||||
// Until the owner saves their own, the defaults apply.
|
||||
get: () => NotificationSettings.safeParse(read(SETTINGS)).data ?? DEFAULT_NOTIFICATION_SETTINGS,
|
||||
put: (settings, now) => write(SETTINGS, NotificationSettings.parse(settings), now),
|
||||
lastDigest: () => LastDigest.safeParse(read(LAST_DIGEST)).data ?? null,
|
||||
recordDigest: (day, at) => write(LAST_DIGEST, { day, at }, at),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { AutoLevel, Executor } from '../../domain/steps.ts';
|
||||
import { AutoLevel, Executor, PostUpgradeWhen } from '../../domain/steps.ts';
|
||||
import type { AppConfig, JsonValue } from '../../domain/steps.ts';
|
||||
|
||||
// Rows are validated where they leave the database, so a corrupted or foreign row fails loudly here
|
||||
@@ -20,6 +20,7 @@ const AppConfigRow = z
|
||||
migrate_executor: Executor.nullable(),
|
||||
prompt_hint: z.string().nullable(),
|
||||
post_upgrade_executor: Executor.nullable(),
|
||||
post_upgrade_when: PostUpgradeWhen,
|
||||
notes: z.string().nullable(),
|
||||
apply: z.number(),
|
||||
project: z.string().min(1),
|
||||
@@ -40,6 +41,7 @@ const AppConfigRow = z
|
||||
migrateExecutor: row.migrate_executor,
|
||||
promptHint: row.prompt_hint,
|
||||
postUpgradeExecutor: row.post_upgrade_executor,
|
||||
postUpgradeWhen: row.post_upgrade_when,
|
||||
notes: row.notes,
|
||||
apply: row.apply === 1,
|
||||
}),
|
||||
|
||||
@@ -119,7 +119,7 @@ async function setupEnvironment() {
|
||||
releaseNotesExecutor: 'sonnet',
|
||||
migrateExecutor: null,
|
||||
promptHint: null,
|
||||
postUpgradeExecutor: null,
|
||||
postUpgradeExecutor: null, postUpgradeWhen: 'when_needed',
|
||||
notes: null,
|
||||
apply: true,
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ const one = { app: 'bazarr', service: 'bazarr', imageRepo: 'linuxserver/bazarr',
|
||||
describe('parseAppsFile', () => {
|
||||
it('fills the defaults: enabled, models off, real updates off, positions from the file order', () => {
|
||||
const [first, second] = parseAppsFile([one, { ...one, app: 'sonarr', service: 'sonarr', enabled: false, notes: 'x' }]);
|
||||
expect(first).toEqual({ ...one, enabled: true, position: 1, releaseNotesExecutor: 'sonnet', migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, notes: null, apply: false, project: 'bazarr' });
|
||||
expect(first).toEqual({ ...one, enabled: true, position: 1, releaseNotesExecutor: 'haiku', migrateExecutor: null, promptHint: null, postUpgradeExecutor: null, postUpgradeWhen: 'when_needed', notes: null, apply: false, project: 'bazarr' });
|
||||
expect(second).toMatchObject({ app: 'sonarr', enabled: false, position: 2, notes: 'x' });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { parseProjectName } from '../domain/compose.ts';
|
||||
import { AutoLevel, Executor } from '../domain/steps.ts';
|
||||
import { AutoLevel, DEFAULT_MODEL, Executor, PostUpgradeWhen } from '../domain/steps.ts';
|
||||
import type { AppConfig } from '../domain/steps.ts';
|
||||
import { compileTagPattern } from '../domain/tags.ts';
|
||||
|
||||
@@ -19,10 +19,11 @@ const AppEntry = z.strictObject({
|
||||
auto: AutoLevel,
|
||||
httpUrl: z.string().nullable().default(null),
|
||||
expectStatus: z.number().int().min(100).max(599).default(200),
|
||||
releaseNotesExecutor: Executor.default('sonnet'),
|
||||
releaseNotesExecutor: Executor.default(DEFAULT_MODEL),
|
||||
migrateExecutor: Executor.nullable().default(null),
|
||||
promptHint: z.string().nullable().default(null),
|
||||
postUpgradeExecutor: Executor.nullable().default(null),
|
||||
postUpgradeWhen: PostUpgradeWhen.default('when_needed'),
|
||||
notes: z.string().nullable().default(null),
|
||||
/** Real updates stay off until the owner turns them on in the app. */
|
||||
apply: z.boolean().default(false),
|
||||
|
||||
+41
-2
@@ -14,6 +14,11 @@ import { openDatabase } from '../infra/sqlite/open.ts';
|
||||
import { createSqliteAppConfigStore } from '../infra/sqlite/app-config-store.ts';
|
||||
import { createSqliteSkillStore } from '../infra/sqlite/skill-store.ts';
|
||||
import { createSqliteReleaseNotesSourcesStore } from '../infra/sqlite/release-notes-sources-store.ts';
|
||||
import { createSqliteHostStatusStore } from '../infra/sqlite/host-status-store.ts';
|
||||
import { runHostStatusLoop } from '../app/host-status.ts';
|
||||
import { isQuiet, runDigestLoop } from '../app/digest.ts';
|
||||
import { downApps } from '../domain/host-status.ts';
|
||||
import { createSqliteNotificationSettingsStore } from '../infra/sqlite/notification-settings-store.ts';
|
||||
import { systemClock } from '../infra/system-clock.ts';
|
||||
import { createComponentOperationExecutor } from '../app/component-operation-executor.ts';
|
||||
import { groupMembers } from '../app/ops/group.ts';
|
||||
@@ -37,6 +42,8 @@ import { missingSkills, readSkillFiles } from './skills-file.ts';
|
||||
import { createWorkflowExtensions } from '../domain/workflow-extensions.ts';
|
||||
|
||||
const logger = createConsoleLogger();
|
||||
/** ghcr.io answers 429 now and then; the schedule is spread out, so a run can wait minutes for the tag list. */
|
||||
const REGISTRY_RATE_LIMIT_WAITS_MS = [30_000, 120_000, 300_000];
|
||||
|
||||
let config;
|
||||
try {
|
||||
@@ -113,7 +120,7 @@ const runner = createWorkflowRunner({
|
||||
functionCatalog,
|
||||
functionRuntime: createFunctionRuntimeClient({ baseUrl: config.functionRuntimeUrl }),
|
||||
componentOperations: createComponentOperationExecutor({
|
||||
registry: createHttpRegistryClient(),
|
||||
registry: createHttpRegistryClient({ rateLimitWaitsMs: REGISTRY_RATE_LIMIT_WAITS_MS }),
|
||||
hostBackup,
|
||||
backupVmid: config.backupVmid,
|
||||
clock: systemClock,
|
||||
@@ -147,7 +154,38 @@ const runner = createWorkflowRunner({
|
||||
runInputs,
|
||||
});
|
||||
|
||||
const notifyDeps = { get: (id: string) => instanceStore.get(id), report: (instance: WorkflowInstance) => reportRun(instanceStore, instance), notifier };
|
||||
const notificationSettings = createSqliteNotificationSettingsStore(db);
|
||||
const hostStatusStore = createSqliteHostStatusStore(db);
|
||||
const quietDeps = { clock: systemClock, timeZone: config.scheduleTimeZone, settings: notificationSettings };
|
||||
// In the owner's quiet hours nothing rings; the morning report covers those runs.
|
||||
const notifyDeps = { get: (id: string) => instanceStore.get(id), report: (instance: WorkflowInstance) => reportRun(instanceStore, instance), notifier, quiet: () => isQuiet(quietDeps) };
|
||||
|
||||
// The morning report: what waits for the owner, what failed or is down, what changed since the last report.
|
||||
const digestLoop = runDigestLoop({
|
||||
...quietDeps,
|
||||
notifier,
|
||||
runs: () => instanceStore.listInstances({}).map((instance) => ({ ...reportRun(instanceStore, instance), workflowId: instance.workflowId, status: instance.projection.status, updatedAt: instance.updatedAt })),
|
||||
down: () => {
|
||||
const status = hostStatusStore.get();
|
||||
return status === null ? [] : downApps(status);
|
||||
},
|
||||
intervalMs: 60_000,
|
||||
signal: shutdown.signal,
|
||||
onError: (cause) => logger.error('digest.failed', { error: cause instanceof Error ? cause.message : String(cause) }),
|
||||
});
|
||||
|
||||
// Once a minute: what runs on VM103 and how old the newest VM backup is, for the overview (read-only, through the proxy).
|
||||
const hostStatusLoop = runHostStatusLoop({
|
||||
runner: processRunner,
|
||||
clock: systemClock,
|
||||
apps: () => appConfig.list(),
|
||||
readBackup: hostBackup.read,
|
||||
backupVmid: config.backupVmid,
|
||||
store: hostStatusStore,
|
||||
intervalMs: 60_000,
|
||||
signal: shutdown.signal,
|
||||
onError: (cause) => logger.error('host_status.failed', { error: cause instanceof Error ? cause.message : String(cause) }),
|
||||
});
|
||||
|
||||
await runWorkflowLoop(runner, {
|
||||
signal: shutdown.signal,
|
||||
@@ -164,6 +202,7 @@ await runWorkflowLoop(runner, {
|
||||
if (result.kind === 'finalized' || result.kind === 'cancelled') removeFinishedInputs(runInputs);
|
||||
},
|
||||
});
|
||||
await Promise.all([hostStatusLoop, digestLoop]);
|
||||
db.close();
|
||||
|
||||
/** Never lets a cleanup failure stop the loop: the inputs are only sensitive, not load-bearing. */
|
||||
|
||||
@@ -6,6 +6,8 @@ import { buildServer } from '../http/server.ts';
|
||||
import { dataVersion, openDatabase } from '../infra/sqlite/open.ts';
|
||||
import { createSqliteAppConfigStore } from '../infra/sqlite/app-config-store.ts';
|
||||
import { createSqliteSkillStore } from '../infra/sqlite/skill-store.ts';
|
||||
import { createSqliteNotificationSettingsStore } from '../infra/sqlite/notification-settings-store.ts';
|
||||
import { createSqliteHostStatusStore } from '../infra/sqlite/host-status-store.ts';
|
||||
import { createSqliteReleaseNotesSourcesStore } from '../infra/sqlite/release-notes-sources-store.ts';
|
||||
import { createSqliteWorkflowCatalogStore } from '../infra/sqlite/workflow-catalog-store.ts';
|
||||
import { createSqliteWorkflowLayoutStore } from '../infra/sqlite/workflow-layout-store.ts';
|
||||
@@ -76,6 +78,8 @@ const app = await buildServer({
|
||||
privilegedActionStore,
|
||||
modelProfiles,
|
||||
releaseNotesSources: createSqliteReleaseNotesSourcesStore(db),
|
||||
hostStatus: createSqliteHostStatusStore(db),
|
||||
notificationSettings: createSqliteNotificationSettingsStore(db),
|
||||
workflowRunInputs: createSqliteWorkflowRunInputStore(db),
|
||||
modelActivity: createSqliteModelActivityStore(db),
|
||||
startWorkflowRun: (name, mode) => startWorkflowRun(runDeps, name, mode),
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
SessionOpenRequest,
|
||||
SessionOpenResult,
|
||||
SessionLogResult,
|
||||
AgentLogsRequest,
|
||||
AgentTextResult,
|
||||
SessionSummary,
|
||||
} from '../domain/executor-api.ts';
|
||||
@@ -135,6 +136,7 @@ interface ExecutorOps {
|
||||
closeSession: [SessionCloseRequest, SessionSummary];
|
||||
sessionLog: [SessionCloseRequest, SessionLogResult];
|
||||
sessionCompose: [SessionCloseRequest, AgentTextResult];
|
||||
sessionLogs: [SessionCloseRequest & AgentLogsRequest, AgentTextResult];
|
||||
}
|
||||
type ExecutorOp = keyof ExecutorOps;
|
||||
type ExecutorScript<K extends ExecutorOp> = (request: ExecutorOps[K][0]) => ExecutorCall<ExecutorOps[K][1]>;
|
||||
@@ -204,4 +206,8 @@ export class FakeExecutorClient implements ExecutorClient {
|
||||
sessionCompose(request: SessionCloseRequest) {
|
||||
return this.#call('sessionCompose', request);
|
||||
}
|
||||
|
||||
sessionLogs(request: SessionCloseRequest & AgentLogsRequest) {
|
||||
return this.#call('sessionLogs', request);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { createIsolatedFunctionRuntime } from '../infra/runtime/isolated-functio
|
||||
import { openDatabase } from '../infra/sqlite/open.ts';
|
||||
import { createSqliteAppConfigStore } from '../infra/sqlite/app-config-store.ts';
|
||||
import { createSqliteModelProfileStore } from '../infra/sqlite/model-profile-store.ts';
|
||||
import { createSqliteNotificationSettingsStore } from '../infra/sqlite/notification-settings-store.ts';
|
||||
import { createSqliteHostStatusStore } from '../infra/sqlite/host-status-store.ts';
|
||||
import { createSqliteReleaseNotesSourcesStore } from '../infra/sqlite/release-notes-sources-store.ts';
|
||||
import { createSqlitePrivilegedActionStore } from '../infra/sqlite/privileged-action-store.ts';
|
||||
import { createSqliteSkillStore } from '../infra/sqlite/skill-store.ts';
|
||||
@@ -34,6 +36,8 @@ export function sqliteServerDeps(db: DatabaseSync = openDatabase(':memory:')) {
|
||||
privilegedActionStore: createSqlitePrivilegedActionStore(db),
|
||||
modelProfiles: createSqliteModelProfileStore(db),
|
||||
releaseNotesSources: createSqliteReleaseNotesSourcesStore(db),
|
||||
hostStatus: createSqliteHostStatusStore(db),
|
||||
notificationSettings: createSqliteNotificationSettingsStore(db),
|
||||
workflowRunInputs: createSqliteWorkflowRunInputStore(db),
|
||||
modelActivity: createSqliteModelActivityStore(db),
|
||||
schedules: { store: createSqliteWorkflowScheduleStore(db), timeZone: 'Europe/Warsaw' },
|
||||
|
||||
@@ -25,7 +25,7 @@ export function seedTestApps(appConfig: AppConfigStore): void {
|
||||
releaseNotesExecutor: 'sonnet',
|
||||
migrateExecutor: null,
|
||||
promptHint: null,
|
||||
postUpgradeExecutor: null,
|
||||
postUpgradeExecutor: null, postUpgradeWhen: 'when_needed',
|
||||
notes: null,
|
||||
apply: false,
|
||||
});
|
||||
|
||||
@@ -18,11 +18,15 @@ import {
|
||||
ModelCallsResponse,
|
||||
ModelProfileListResponse,
|
||||
ReleaseNotesSourcesResponse,
|
||||
HostStatusResponse,
|
||||
NotificationSettingsResponse,
|
||||
ModelProfileSummaryResponse,
|
||||
TagPreviewResponse,
|
||||
} from '../http/contract.ts';
|
||||
import type { AppConfigPatch, ModelProfilePublishRequest } from '../http/contract.ts';
|
||||
|
||||
type NotificationSettingsResponse = z.infer<typeof NotificationSettingsResponse>;
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
@@ -115,6 +119,9 @@ export const api = {
|
||||
modelCalls: (limit = 50) => call(ModelCallsResponse, `/api/model-calls?limit=${String(limit)}`),
|
||||
modelProfiles: () => call(ModelProfileListResponse, '/api/model-profiles'),
|
||||
releaseNotesSources: () => call(ReleaseNotesSourcesResponse, '/api/release-notes-sources'),
|
||||
hostStatus: () => call(HostStatusResponse, '/api/host-status'),
|
||||
notificationSettings: () => call(NotificationSettingsResponse, '/api/notification-settings'),
|
||||
saveNotificationSettings: (settings: NotificationSettingsResponse['settings']) => call(NotificationSettingsResponse, '/api/notification-settings', settings, 'PUT'),
|
||||
saveReleaseNotesSources: (sources: readonly string[]) => call(ReleaseNotesSourcesResponse, '/api/release-notes-sources', { sources }, 'PUT'),
|
||||
modelProfileVersions: (id: string) => call(z.array(ModelProfileSummaryResponse), `/api/model-profiles/${encodeURIComponent(id)}/versions`),
|
||||
modelProfileVersion: (id: string, version: number) => call(ModelProfileSummaryResponse, `/api/model-profiles/${encodeURIComponent(id)}/versions/${String(version)}`),
|
||||
|
||||
+99
-1
@@ -35,6 +35,7 @@ class FakeEventSource {
|
||||
let server: FastifyInstance;
|
||||
let appConfig: AppConfigStore;
|
||||
let schedules: ReturnType<typeof sqliteServerDeps>['schedules'];
|
||||
let hostStatus: ReturnType<typeof sqliteServerDeps>['hostStatus'];
|
||||
let requests: { method: string; url: string; body?: unknown }[];
|
||||
let container: HTMLElement;
|
||||
|
||||
@@ -42,7 +43,7 @@ beforeEach(async () => {
|
||||
FakeEventSource.instances = [];
|
||||
requests = [];
|
||||
const deps = sqliteServerDeps();
|
||||
({ appConfig, schedules } = deps);
|
||||
({ appConfig, schedules, hostStatus } = deps);
|
||||
seedTestApps(appConfig);
|
||||
const registry = new FakeRegistryClient({ 'demo/bazarr': ['1.0.0', '1.1.0', '1.2.0'] });
|
||||
server = await buildServer({ ...deps, registry, build: { branch: 'foundations', number: 6, revision: 'de5ee92a1b2c3d4e5f60718293a4b5c6d7e8f901' } });
|
||||
@@ -57,6 +58,7 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
window.location.hash = '';
|
||||
window.localStorage.clear(); // the apps layout the owner chose
|
||||
container = document.createElement('div');
|
||||
document.body.replaceChildren(container);
|
||||
render(<App />, container);
|
||||
@@ -88,6 +90,16 @@ const button = (root: ParentNode, label: string) => {
|
||||
return found;
|
||||
};
|
||||
|
||||
/** The apps page opens on cards; the table is one click away. */
|
||||
async function showAppsTable() {
|
||||
const toggle = await eventually(() => {
|
||||
const found = [...container.querySelectorAll('.segmented button')].find((candidate) => candidate.textContent.trim() === 'Table');
|
||||
if (!(found instanceof HTMLButtonElement)) throw new Error('no layout switch');
|
||||
return found;
|
||||
});
|
||||
toggle.click();
|
||||
}
|
||||
|
||||
const nextTask = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** Like a person: type, and let the UI render before the next click. */
|
||||
@@ -126,6 +138,7 @@ describe('live updates', () => {
|
||||
|
||||
it('refreshes the view when the server announces a change, without any click', async () => {
|
||||
window.location.hash = '#/apps';
|
||||
await showAppsTable();
|
||||
await eventually(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(4));
|
||||
appConfig.upsert({ ...appConfig.get('grafana')!, enabled: false });
|
||||
expect(text()).not.toContain('disabled'); // nothing told the page yet
|
||||
@@ -193,6 +206,18 @@ describe('app configuration', () => {
|
||||
await eventually(() => expect((requests.filter((r) => r.method === 'POST').at(-1)!.body as Record<string, unknown>)['postUpgradeExecutor']).toBeNull());
|
||||
});
|
||||
|
||||
it('sets when the post-upgrade model runs, once a model is chosen', async () => {
|
||||
await open();
|
||||
expect(form()!.querySelector('[name="postUpgradeWhen"]')).toBeNull();
|
||||
await choose(field('postUpgradeExecutor'), 'sonnet');
|
||||
expect(field<HTMLSelectElement>('postUpgradeWhen').value).toBe('when_needed');
|
||||
await choose(field('postUpgradeWhen'), 'always');
|
||||
button(form()!, 'Save').click();
|
||||
await eventually(() => expect(form()!.textContent).toContain('Saved'));
|
||||
expect(requests.find((r) => r.method === 'POST')!.body).toEqual({ postUpgradeExecutor: 'sonnet', postUpgradeWhen: 'always' });
|
||||
expect((await server.inject('/api/apps/bazarr/config')).json()).toMatchObject({ postUpgradeWhen: 'always' });
|
||||
});
|
||||
|
||||
it('checks the change in the browser before sending it', async () => {
|
||||
await open();
|
||||
await type(field('tagPattern'), '(?P<v>x)');
|
||||
@@ -248,6 +273,7 @@ describe('skill', () => {
|
||||
describe('other views', () => {
|
||||
it('shows every app with its latest run, and an app’s runs and schedule', async () => {
|
||||
window.location.hash = '#/apps';
|
||||
await showAppsTable();
|
||||
await eventually(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(4));
|
||||
const row = [...container.querySelectorAll('tbody tr')].find((r) => r.textContent.includes('grafana'))!;
|
||||
expect(row.textContent).toContain('never ran');
|
||||
@@ -274,6 +300,7 @@ describe('other views', () => {
|
||||
it('shows an app’s next scheduled run on the apps list', async () => {
|
||||
schedules.store.put({ workflowId: 'update-grafana', cron: '0 3 * * *', mode: 'shadow', enabled: true, now: '2026-09-18T01:00:00.000Z' });
|
||||
window.location.hash = '#/apps';
|
||||
await showAppsTable();
|
||||
await eventually(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(4));
|
||||
const row = await eventually(() => {
|
||||
const found = [...container.querySelectorAll('tbody tr')].find((r) => r.textContent.includes('grafana'));
|
||||
@@ -286,6 +313,7 @@ describe('other views', () => {
|
||||
it('shows the components of one project together, the later ones under the first', async () => {
|
||||
appConfig.upsert({ ...appConfig.get('grafana')!, app: 'grafana-influxdb', service: 'influxdb', imageRepo: 'influxdb', position: 99 });
|
||||
window.location.hash = '#/apps';
|
||||
await showAppsTable();
|
||||
await eventually(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(5));
|
||||
const rows = [...container.querySelectorAll('tbody tr')].map((row) => row.querySelector('td')?.textContent ?? '');
|
||||
const at = rows.findIndex((text) => text.startsWith('grafana'));
|
||||
@@ -293,6 +321,76 @@ describe('other views', () => {
|
||||
expect(container.querySelector<HTMLAnchorElement>(`tbody tr:nth-child(${String(at + 2)}) a`)?.getAttribute('href')).toBe('#/apps/grafana-influxdb');
|
||||
});
|
||||
|
||||
it('shows apps as cards by default, a project’s components on one card, and remembers the table', async () => {
|
||||
appConfig.upsert({ ...appConfig.get('grafana')!, app: 'grafana-influxdb', service: 'influxdb', imageRepo: 'influxdb', position: 99 });
|
||||
window.location.hash = '#/apps';
|
||||
const cards = await eventually(() => {
|
||||
const found = container.querySelectorAll('.app-card');
|
||||
expect(found).toHaveLength(4);
|
||||
return [...found];
|
||||
});
|
||||
const grafana = cards.find((card) => card.querySelector('.app-card-title a')?.textContent === 'grafana')!;
|
||||
expect(grafana.querySelector('.app-member a')?.getAttribute('href')).toBe('#/apps/grafana-influxdb');
|
||||
expect(grafana.textContent).toContain('never ran');
|
||||
|
||||
await showAppsTable();
|
||||
await eventually(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(5));
|
||||
render(null, container);
|
||||
render(<App />, container);
|
||||
await eventually(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(5));
|
||||
});
|
||||
|
||||
it('shows on the overview which apps are down and how old the VM backup is', async () => {
|
||||
const now = Date.now();
|
||||
hostStatus.put({
|
||||
checkedAt: new Date(now).toISOString(),
|
||||
apps: [
|
||||
{ app: 'grafana', enabled: true, state: 'exited', health: null },
|
||||
{ app: 'bazarr', enabled: true, state: 'running', health: 'healthy' },
|
||||
],
|
||||
backup: { ok: true, createdAt: new Date(now - 5 * 3_600_000).toISOString(), ageHours: 5, volid: 'pbs:vm/103' },
|
||||
error: null,
|
||||
});
|
||||
window.location.hash = '#/overview';
|
||||
await eventually(() => expect(text()).toContain('1/2'));
|
||||
expect(text()).toContain('grafana exited');
|
||||
expect(text()).toContain('5 h ago');
|
||||
window.location.hash = '#/apps';
|
||||
await eventually(() => expect(container.querySelector('.health-down')?.getAttribute('title')).toBe('down: exited'));
|
||||
});
|
||||
|
||||
it('saves the quiet hours and shows the morning report on the overview', async () => {
|
||||
window.location.hash = '#/settings';
|
||||
const from = await eventually(() => {
|
||||
const found = container.querySelectorAll<HTMLInputElement>('input[type="time"]')[0];
|
||||
if (found === undefined) throw new Error('no time field');
|
||||
return found;
|
||||
});
|
||||
expect(from.value).toBe('01:00');
|
||||
await type(from, '00:30');
|
||||
button(container, 'Save').click();
|
||||
await eventually(() => expect(text()).toContain('Notification settings saved.'));
|
||||
expect((await server.inject('/api/notification-settings')).json()).toMatchObject({ settings: { quietFrom: '00:30', quietUntil: '07:30' }, timeZone: 'Europe/Warsaw' });
|
||||
|
||||
window.location.hash = '#/overview';
|
||||
await eventually(() => expect(text()).toContain('All good'));
|
||||
expect(text()).toContain('No real update, nothing failed, nothing waits for you.');
|
||||
});
|
||||
|
||||
it('jumps to an app from the palette opened with Ctrl+K', async () => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }));
|
||||
const input = await eventually(() => {
|
||||
const found = container.querySelector<HTMLInputElement>('.palette input');
|
||||
if (found === null) throw new Error('no palette');
|
||||
return found;
|
||||
});
|
||||
await eventually(() => expect(container.querySelector('.palette-list')?.textContent).toContain('bazarr'));
|
||||
await type(input, 'bazarr app');
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
await eventually(() => expect(window.location.hash).toBe('#/apps/bazarr'));
|
||||
expect(container.querySelector('.palette')).toBeNull();
|
||||
});
|
||||
|
||||
it('says so for an unknown page', async () => {
|
||||
window.location.hash = '#/nowhere';
|
||||
await eventually(() => expect(text()).toContain('Page not found'));
|
||||
|
||||
+53
-21
@@ -7,6 +7,11 @@ import type { LiveStatus } from './live.ts';
|
||||
import { formatBuild } from './format.ts';
|
||||
import type { BuildInfo } from '../http/contract.ts';
|
||||
import { hrefFor, parseRoute } from './route.ts';
|
||||
import { useTheme } from './theme.ts';
|
||||
import { Icon } from './components/ui.tsx';
|
||||
import { DialogProvider } from './components/dialogs.tsx';
|
||||
import { CommandPalette, useCommandPalette } from './components/command-palette.tsx';
|
||||
import type { IconName } from './components/ui.tsx';
|
||||
import type { Route } from './route.ts';
|
||||
import { AppDetailView, AppsView } from './views/apps.tsx';
|
||||
import { OverviewView } from './views/overview.tsx';
|
||||
@@ -17,6 +22,7 @@ import { FunctionEditorView } from './views/function-editor.tsx';
|
||||
import { WorkflowInstancesView } from './views/instances.tsx';
|
||||
import { InstanceTimelineView } from './views/instance-timeline.tsx';
|
||||
import { ModelsView } from './views/models.tsx';
|
||||
import { SettingsView } from './views/settings.tsx';
|
||||
|
||||
function useRoute(): Route {
|
||||
const [route, setRoute] = useState(() => parseRoute(window.location.hash));
|
||||
@@ -31,10 +37,11 @@ function useRoute(): Route {
|
||||
|
||||
const LIVE_LABEL: Record<LiveStatus, string> = { connecting: 'connecting…', live: 'live', offline: 'offline — retrying' };
|
||||
|
||||
function Nav({ route, status }: { route: Route; status: LiveStatus }) {
|
||||
function Sidebar({ route, status, onSearch }: { route: Route; status: LiveStatus; onSearch: () => void }) {
|
||||
const gates = useResource(() => api.workflowInstances({ status: 'waiting' }), []);
|
||||
const { theme, toggle } = useTheme();
|
||||
const waiting = gates.data?.length ?? 0;
|
||||
const link = (view: Route['view'], label: string, target: Parameters<typeof hrefFor>[0], count = 0) => (
|
||||
const link = (view: Route['view'], label: string, icon: IconName, target: Parameters<typeof hrefFor>[0], count = 0) => (
|
||||
<a
|
||||
href={hrefFor(target)}
|
||||
aria-current={
|
||||
@@ -47,25 +54,40 @@ function Nav({ route, status }: { route: Route; status: LiveStatus }) {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{label}
|
||||
<Icon name={icon} />
|
||||
<span class="nav-label">{label}</span>
|
||||
{count > 0 ? <span class="count">{count}</span> : null}
|
||||
</a>
|
||||
);
|
||||
return (
|
||||
<header class="topbar">
|
||||
<span class="brand">Homelab upgrade</span>
|
||||
<nav>
|
||||
{link('overview', 'Overview', { view: 'overview' }, waiting)}
|
||||
{link('apps', 'Apps', { view: 'apps' })}
|
||||
{link('runs', 'Runs', { view: 'runs' })}
|
||||
{link('workflows', 'Workflows', { view: 'workflows' })}
|
||||
{link('functions', 'Functions', { view: 'functions' })}
|
||||
{link('models', 'Models', { view: 'models' })}
|
||||
</nav>
|
||||
<span class={`live live-${status}`} title="Live updates from the server">
|
||||
{LIVE_LABEL[status]}
|
||||
<aside class="sidebar">
|
||||
<span class="brand">
|
||||
<span class="brand-mark" aria-hidden="true" />
|
||||
Homelab upgrade
|
||||
</span>
|
||||
</header>
|
||||
<button type="button" class="search-button" onClick={onSearch} title="Search (Ctrl+K)">
|
||||
<Icon name="search" size={16} />
|
||||
<span class="nav-label">Search</span>
|
||||
<kbd>Ctrl K</kbd>
|
||||
</button>
|
||||
<nav>
|
||||
{link('overview', 'Overview', 'overview', { view: 'overview' }, waiting)}
|
||||
{link('apps', 'Apps', 'apps', { view: 'apps' })}
|
||||
{link('runs', 'Runs', 'runs', { view: 'runs' })}
|
||||
{link('workflows', 'Workflows', 'workflows', { view: 'workflows' })}
|
||||
{link('functions', 'Functions', 'functions', { view: 'functions' })}
|
||||
{link('models', 'Models', 'models', { view: 'models' })}
|
||||
{link('settings', 'Settings', 'settings', { view: 'settings' })}
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
<span class={`live live-${status}`} title="Live updates from the server">
|
||||
{LIVE_LABEL[status]}
|
||||
</span>
|
||||
<button type="button" class="icon-button" onClick={toggle} title={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}>
|
||||
<Icon name={theme === 'dark' ? 'sun' : 'moon'} />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,6 +117,8 @@ function View({ route }: { route: Route }): ComponentChildren {
|
||||
return <FunctionEditorView functionId={route.functionId} version={route.version} />;
|
||||
case 'models':
|
||||
return <ModelsView />;
|
||||
case 'settings':
|
||||
return <SettingsView />;
|
||||
case 'not_found':
|
||||
return (
|
||||
<section>
|
||||
@@ -133,13 +157,21 @@ function Footer() {
|
||||
export function App() {
|
||||
const route = useRoute();
|
||||
const { revision, status, refresh } = useLive();
|
||||
const [paletteOpen, setPaletteOpen] = useCommandPalette();
|
||||
return (
|
||||
<LiveContext.Provider value={{ revision, refresh }}>
|
||||
<Nav route={route} status={status} />
|
||||
<main>
|
||||
<View route={route} />
|
||||
</main>
|
||||
<Footer />
|
||||
<DialogProvider>
|
||||
<div class="shell">
|
||||
<Sidebar route={route} status={status} onSearch={() => setPaletteOpen(true)} />
|
||||
<div class="content">
|
||||
<main>
|
||||
<View route={route} />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
{paletteOpen ? <CommandPalette onClose={() => setPaletteOpen(false)} /> : null}
|
||||
</DialogProvider>
|
||||
</LiveContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as echarts from 'echarts/core';
|
||||
import { BarChart, CustomChart, LineChart, PieChart, ScatterChart } from 'echarts/charts';
|
||||
import { DataZoomComponent, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent } from 'echarts/components';
|
||||
import { SVGRenderer } from 'echarts/renderers';
|
||||
|
||||
// Only the parts the views draw; this module is loaded on demand, so the charts live in their own chunk.
|
||||
echarts.use([BarChart, CustomChart, LineChart, PieChart, ScatterChart, DataZoomComponent, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, SVGRenderer]);
|
||||
|
||||
export { echarts };
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { activityOption, costOption, durationOption, formatUsd, ganttOption, ganttRows, modelColors, outcomesOption, scheduleOption } from './options.ts';
|
||||
import type { Palette } from './options.ts';
|
||||
|
||||
const PALETTE: Palette = {
|
||||
text: '#fff',
|
||||
muted: '#999',
|
||||
line: '#333',
|
||||
surface: '#111',
|
||||
accent: '#00f',
|
||||
status: { succeeded: 'green', failed: 'red', waiting: 'amber', running: 'blue', cancelled: 'grey' },
|
||||
series: ['s1', 's2', 's3'],
|
||||
};
|
||||
|
||||
type Bar = { name: string; data: number[]; itemStyle: { color: string; borderRadius: number | number[] } };
|
||||
|
||||
describe('activityOption', () => {
|
||||
it('stacks only the statuses that occur, grey between green and red, and rounds the top of the stack', () => {
|
||||
const option = activityOption(
|
||||
[
|
||||
{ day: '2026-09-29', succeeded: 2, failed: 1, cancelled: 0, waiting: 0, running: 0 },
|
||||
{ day: '2026-09-30', succeeded: 1, failed: 0, cancelled: 1, waiting: 1, running: 0 },
|
||||
],
|
||||
PALETTE,
|
||||
);
|
||||
const series = option.series as Bar[];
|
||||
expect(series.map((item) => [item.name, item.itemStyle.color])).toEqual([
|
||||
['Done', 'green'],
|
||||
['Cancelled', 'grey'],
|
||||
['Failed', 'red'],
|
||||
['Waiting for you', 'amber'],
|
||||
]);
|
||||
expect(series[0]!.data).toEqual([2, 1]);
|
||||
expect(series.map((item) => item.itemStyle.borderRadius)).toEqual([0, 0, 0, [4, 4, 0, 0]]);
|
||||
expect((option.xAxis as { data: string[] }).data).toEqual(['09-29', '09-30']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modelColors', () => {
|
||||
it('gives each model the same hue whatever it spent', () => {
|
||||
const colors = modelColors(['sonnet', 'haiku', 'opus'], PALETTE);
|
||||
expect([...colors.entries()]).toEqual([
|
||||
['haiku', 's1'],
|
||||
['opus', 's2'],
|
||||
['sonnet', 's3'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('costOption', () => {
|
||||
it('stacks spend per model with the model’s own hue', () => {
|
||||
const option = costOption(
|
||||
{
|
||||
days: ['2026-09-29', '2026-09-30'],
|
||||
series: [
|
||||
{ model: 'sonnet', values: [0.3, 0.2], total: 0.5 },
|
||||
{ model: 'haiku', values: [0, 0.01], total: 0.01 },
|
||||
],
|
||||
total: 0.51,
|
||||
},
|
||||
PALETTE,
|
||||
);
|
||||
const series = option.series as Bar[];
|
||||
expect(series.map((item) => [item.name, item.itemStyle.color])).toEqual([
|
||||
['sonnet', 's2'],
|
||||
['haiku', 's1'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUsd', () => {
|
||||
it('shows cents precisely under a dollar', () => {
|
||||
expect(formatUsd(0)).toBe('$0');
|
||||
expect(formatUsd(0.0123)).toBe('$0.012');
|
||||
expect(formatUsd(12.345)).toBe('$12.35');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduleOption', () => {
|
||||
it('puts each run on its workflow’s row within the window, dry and real apart', () => {
|
||||
const now = Date.parse('2026-09-30T00:00:00.000Z');
|
||||
const option = scheduleOption(
|
||||
[
|
||||
{ workflowId: 'update-a', at: '2026-10-01T03:00:00.000Z', mode: 'shadow' },
|
||||
{ workflowId: 'update-b', at: '2026-10-02T03:00:00.000Z', mode: 'real' },
|
||||
{ workflowId: 'update-a', at: '2026-11-01T03:00:00.000Z', mode: 'shadow' }, // past the window
|
||||
],
|
||||
{ now, days: 7 },
|
||||
PALETTE,
|
||||
);
|
||||
const series = option.series as { name: string; data: [number, string][] }[];
|
||||
expect(series.map((item) => [item.name, item.data])).toEqual([
|
||||
['Dry run', [[Date.parse('2026-10-01T03:00:00.000Z'), 'update-a']]],
|
||||
['Real', [[Date.parse('2026-10-02T03:00:00.000Z'), 'update-b']]],
|
||||
]);
|
||||
expect((option.yAxis as { data: string[] }).data).toEqual(['update-b', 'update-a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('durationOption', () => {
|
||||
it('draws one bar per run in minutes, colored by how it ended', () => {
|
||||
const option = durationOption(
|
||||
[
|
||||
{ id: 'a', startedAt: '2026-09-28T03:00:00.000Z', status: 'succeeded', minutes: 4.256 },
|
||||
{ id: 'b', startedAt: '2026-09-29T03:00:00.000Z', status: 'failed', minutes: 12 },
|
||||
],
|
||||
PALETTE,
|
||||
);
|
||||
const [series] = option.series as { data: { value: number; itemStyle: { color: string } }[] }[];
|
||||
expect(series!.data.map((bar) => [bar.value, bar.itemStyle.color])).toEqual([
|
||||
[4.26, 'green'],
|
||||
[12, 'red'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ganttOption', () => {
|
||||
it('puts nodes on rows in the order the run reached them, with one bar per attempt', () => {
|
||||
const bars = [
|
||||
{ nodeId: 'plan', start: 0, end: 1000, status: 'succeeded' as const, open: false },
|
||||
{ nodeId: 'verify', start: 1000, end: 2000, status: 'failed' as const, open: false },
|
||||
{ nodeId: 'verify', start: 3000, end: 4000, status: 'succeeded' as const, open: false },
|
||||
{ nodeId: 'gate', start: 4000, end: 9000, status: 'waiting' as const, open: true },
|
||||
];
|
||||
expect(ganttRows(bars)).toEqual(['plan', 'verify', 'gate']);
|
||||
const option = ganttOption(bars, (nodeId) => nodeId.toUpperCase(), PALETTE);
|
||||
expect((option.yAxis as { data: string[] }).data).toEqual(['PLAN', 'VERIFY', 'GATE']);
|
||||
const [series] = option.series as { data: { value: number[]; itemStyle: { color: string } }[] }[];
|
||||
expect(series!.data.map((item) => [item.value, item.itemStyle.color])).toEqual([
|
||||
[[0, 0, 1000], 'green'],
|
||||
[[1, 1000, 2000], 'red'],
|
||||
[[1, 3000, 4000], 'green'],
|
||||
[[2, 4000, 9000], 'amber'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('outcomesOption', () => {
|
||||
it('counts outcomes per kind of question, in the verdict colors', () => {
|
||||
const option = outcomesOption(
|
||||
[
|
||||
{ kind: 'releaseNotes', outcome: 'clear' },
|
||||
{ kind: 'releaseNotes', outcome: 'blocked' },
|
||||
{ kind: 'repair', outcome: 'clear' },
|
||||
{ kind: 'repair', outcome: 'odd' },
|
||||
],
|
||||
PALETTE,
|
||||
);
|
||||
expect((option.yAxis as { data: string[] }).data).toEqual(['Release notes', 'Repair']);
|
||||
const series = option.series as Bar[];
|
||||
expect(series.map((item) => [item.name, item.data, item.itemStyle.color])).toEqual([
|
||||
['blocked', [1, 0], 'amber'],
|
||||
['clear', [1, 1], 'green'],
|
||||
['odd', [0, 1], '#999'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import type { AttemptStatus, CostPerDay, DayRuns, GanttBar, RunBucket } from '../stats.ts';
|
||||
import { runStatusLabel } from '../labels.ts';
|
||||
|
||||
/** Colors a chart needs, read from the theme's CSS tokens by the chart component. */
|
||||
export interface Palette {
|
||||
readonly text: string;
|
||||
readonly muted: string;
|
||||
readonly line: string;
|
||||
readonly surface: string;
|
||||
readonly accent: string;
|
||||
readonly status: Readonly<Record<RunBucket, string>>;
|
||||
/** Categorical hues in fixed order. */
|
||||
readonly series: readonly string[];
|
||||
}
|
||||
|
||||
export interface ChartClick {
|
||||
readonly dataIndex: number;
|
||||
readonly seriesIndex: number;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
// Neighbours in the stack keep green and red apart (the grey sits between), for colour-blind readers.
|
||||
const STACK_ORDER: readonly RunBucket[] = ['succeeded', 'cancelled', 'failed', 'waiting', 'running'];
|
||||
const BUCKET_LABEL: Readonly<Record<RunBucket, string>> = {
|
||||
succeeded: runStatusLabel('succeeded'),
|
||||
cancelled: runStatusLabel('cancelled'),
|
||||
failed: runStatusLabel('failed'),
|
||||
waiting: runStatusLabel('waiting'),
|
||||
running: 'In progress',
|
||||
};
|
||||
|
||||
/** `09-30` from `2026-09-30`: the year is the same across a month's chart. */
|
||||
export function shortDay(day: string): string {
|
||||
return day.slice(5);
|
||||
}
|
||||
|
||||
export function formatUsd(value: number): string {
|
||||
return value === 0 ? '$0' : `$${value < 1 ? value.toFixed(3) : value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function axisBase(palette: Palette) {
|
||||
return {
|
||||
axisLine: { lineStyle: { color: palette.line } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: palette.muted },
|
||||
splitLine: { lineStyle: { color: palette.line, opacity: 0.6 } },
|
||||
};
|
||||
}
|
||||
|
||||
function tooltipBase(palette: Palette) {
|
||||
return {
|
||||
backgroundColor: palette.surface,
|
||||
borderColor: palette.line,
|
||||
textStyle: { color: palette.text },
|
||||
extraCssText: 'border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,.25);',
|
||||
};
|
||||
}
|
||||
|
||||
function legendBase(palette: Palette) {
|
||||
return { top: 0, icon: 'roundRect', itemWidth: 10, itemHeight: 10, textStyle: { color: palette.muted } };
|
||||
}
|
||||
|
||||
/** Runs per day, stacked by what they are now. */
|
||||
export function activityOption(days: readonly DayRuns[], palette: Palette): EChartsOption {
|
||||
const used = STACK_ORDER.filter((bucket) => days.some((day) => day[bucket] > 0));
|
||||
const top = used.at(-1);
|
||||
return {
|
||||
animationDuration: 500,
|
||||
grid: { left: 8, right: 8, top: 36, bottom: 4, containLabel: true },
|
||||
legend: { ...legendBase(palette), data: used.map((bucket) => BUCKET_LABEL[bucket]) },
|
||||
tooltip: { ...tooltipBase(palette), trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
xAxis: { type: 'category', data: days.map((day) => shortDay(day.day)), ...axisBase(palette), splitLine: { show: false } },
|
||||
yAxis: { type: 'value', minInterval: 1, ...axisBase(palette) },
|
||||
series: used.map((bucket) => ({
|
||||
name: BUCKET_LABEL[bucket],
|
||||
type: 'bar',
|
||||
stack: 'runs',
|
||||
barMaxWidth: 18,
|
||||
data: days.map((day) => day[bucket]),
|
||||
itemStyle: {
|
||||
color: palette.status[bucket],
|
||||
borderColor: palette.surface,
|
||||
borderWidth: 1,
|
||||
borderRadius: bucket === top ? [4, 4, 0, 0] : 0,
|
||||
},
|
||||
emphasis: { focus: 'series' },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** A small line under a number: its shape over the days, nothing else. */
|
||||
export function sparklineOption(values: readonly number[], color: string, palette: Palette, format: (value: number) => string = String): EChartsOption {
|
||||
return {
|
||||
animationDuration: 500,
|
||||
grid: { left: 0, right: 0, top: 4, bottom: 0 },
|
||||
tooltip: { ...tooltipBase(palette), trigger: 'axis', valueFormatter: (value) => format(Number(value)) },
|
||||
xAxis: { type: 'category', show: false, boundaryGap: false, data: values.map((_, index) => String(index)) },
|
||||
yAxis: { type: 'value', show: false, min: 0 },
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: [...values],
|
||||
smooth: 0.3,
|
||||
showSymbol: false,
|
||||
lineStyle: { color, width: 2 },
|
||||
areaStyle: { color, opacity: 0.15 },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A model's color follows its name, not its rank this month: models sorted by name take the hues in order, so a model
|
||||
* keeps its color while spend moves.
|
||||
*/
|
||||
export function modelColors(models: readonly string[], palette: Palette): ReadonlyMap<string, string> {
|
||||
const sorted = [...new Set(models)].sort();
|
||||
return new Map(sorted.map((model, index) => [model, palette.series[index % Math.max(palette.series.length, 1)] ?? palette.muted]));
|
||||
}
|
||||
|
||||
/** Model spend per day, stacked by model. */
|
||||
export function costOption(cost: CostPerDay, palette: Palette): EChartsOption {
|
||||
const colors = modelColors(cost.series.map((item) => item.model), palette);
|
||||
const top = cost.series.at(-1)?.model;
|
||||
return {
|
||||
animationDuration: 500,
|
||||
grid: { left: 8, right: 8, top: 36, bottom: 4, containLabel: true },
|
||||
legend: { ...legendBase(palette), data: cost.series.map((item) => item.model) },
|
||||
tooltip: { ...tooltipBase(palette), trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (value) => formatUsd(Number(value)) },
|
||||
xAxis: { type: 'category', data: cost.days.map(shortDay), ...axisBase(palette), splitLine: { show: false } },
|
||||
yAxis: { type: 'value', ...axisBase(palette), axisLabel: { color: palette.muted, formatter: (value: number) => formatUsd(value) } },
|
||||
series: cost.series.map((item) => ({
|
||||
name: item.model,
|
||||
type: 'bar',
|
||||
stack: 'cost',
|
||||
barMaxWidth: 18,
|
||||
data: [...item.values],
|
||||
itemStyle: { color: colors.get(item.model), borderColor: palette.surface, borderWidth: 1, borderRadius: item.model === top ? [4, 4, 0, 0] : 0 },
|
||||
emphasis: { focus: 'series' },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Each model's share of all spend. */
|
||||
export function modelShareOption(totals: readonly { readonly model: string; readonly costUsd: number; readonly calls: number }[], palette: Palette): EChartsOption {
|
||||
const colors = modelColors(totals.map((item) => item.model), palette);
|
||||
return {
|
||||
animationDuration: 500,
|
||||
tooltip: { ...tooltipBase(palette), trigger: 'item', valueFormatter: (value) => formatUsd(Number(value)) },
|
||||
legend: { ...legendBase(palette), top: 'bottom', type: 'scroll' },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['52%', '78%'],
|
||||
center: ['50%', '44%'],
|
||||
padAngle: 2,
|
||||
itemStyle: { borderRadius: 6, borderColor: palette.surface, borderWidth: 2 },
|
||||
label: { show: false },
|
||||
data: totals.map((item) => ({ name: item.model, value: item.costUsd, itemStyle: { color: colors.get(item.model) } })),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScheduledRun {
|
||||
readonly workflowId: string;
|
||||
readonly at: string;
|
||||
readonly mode: 'shadow' | 'real';
|
||||
}
|
||||
|
||||
/** When each scheduled workflow runs next over the coming days: one row per workflow, dry and real runs in two hues. */
|
||||
export function scheduleOption(runs: readonly ScheduledRun[], window: { readonly now: number; readonly days: number }, palette: Palette): EChartsOption {
|
||||
const workflows = [...new Set(runs.map((run) => run.workflowId))].sort().reverse();
|
||||
const end = window.now + window.days * 24 * 60 * 60 * 1000;
|
||||
const modes = [
|
||||
{ mode: 'shadow' as const, name: 'Dry run', color: palette.series[0] ?? palette.accent },
|
||||
{ mode: 'real' as const, name: 'Real', color: palette.series[1] ?? palette.accent },
|
||||
].filter((entry) => runs.some((run) => run.mode === entry.mode));
|
||||
return {
|
||||
animationDuration: 400,
|
||||
grid: { left: 8, right: 16, top: 36, bottom: 4, containLabel: true },
|
||||
legend: { ...legendBase(palette), data: modes.map((entry) => entry.name) },
|
||||
tooltip: {
|
||||
...tooltipBase(palette),
|
||||
trigger: 'item',
|
||||
formatter: (params) => {
|
||||
const item = Array.isArray(params) ? params[0] : params;
|
||||
const [at, workflowId] = (item?.value ?? [0, '']) as [number, string];
|
||||
return `${workflowId}<br/>${new Date(at).toLocaleString('sv-SE', { dateStyle: 'short', timeStyle: 'short' })}`;
|
||||
},
|
||||
},
|
||||
xAxis: { type: 'time', min: window.now, max: end, ...axisBase(palette), axisLabel: { color: palette.muted, hideOverlap: true } },
|
||||
yAxis: { type: 'category', data: workflows, ...axisBase(palette), splitLine: { show: true, lineStyle: { color: palette.line, opacity: 0.4 } } },
|
||||
series: modes.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: 'scatter',
|
||||
symbolSize: 12,
|
||||
itemStyle: { color: entry.color, borderColor: palette.surface, borderWidth: 2 },
|
||||
data: runs
|
||||
.filter((run) => run.mode === entry.mode && Date.parse(run.at) <= end)
|
||||
.map((run) => [Date.parse(run.at), run.workflowId]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export interface RunDuration {
|
||||
readonly id: string;
|
||||
readonly startedAt: string;
|
||||
readonly status: RunBucket;
|
||||
readonly minutes: number;
|
||||
}
|
||||
|
||||
/** How long each ended run took, oldest on the left, colored by how it ended. */
|
||||
export function durationOption(runs: readonly RunDuration[], palette: Palette): EChartsOption {
|
||||
return {
|
||||
animationDuration: 500,
|
||||
grid: { left: 8, right: 8, top: 12, bottom: 4, containLabel: true },
|
||||
tooltip: {
|
||||
...tooltipBase(palette),
|
||||
trigger: 'item',
|
||||
formatter: (params) => {
|
||||
const item = Array.isArray(params) ? params[0] : params;
|
||||
const run = runs[item?.dataIndex ?? 0];
|
||||
return run === undefined ? '' : `${run.startedAt.slice(0, 16).replace('T', ' ')}<br/>${BUCKET_LABEL[run.status]} · ${run.minutes.toFixed(1)} min`;
|
||||
},
|
||||
},
|
||||
xAxis: { type: 'category', data: runs.map((run) => shortDay(run.startedAt.slice(0, 10))), ...axisBase(palette), splitLine: { show: false } },
|
||||
yAxis: { type: 'value', name: 'min', nameTextStyle: { color: palette.muted }, ...axisBase(palette) },
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
barMaxWidth: 22,
|
||||
data: runs.map((run) => ({ value: Number(run.minutes.toFixed(2)), itemStyle: { color: palette.status[run.status], borderRadius: [4, 4, 0, 0] } })),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const ATTEMPT_LABEL: Readonly<Record<AttemptStatus, string>> = { running: 'Running', succeeded: 'Done', failed: 'Failed', waiting: 'Waiting for you' };
|
||||
const ATTEMPT_COLOR: Readonly<Record<AttemptStatus, RunBucket>> = { running: 'running', succeeded: 'succeeded', failed: 'failed', waiting: 'waiting' };
|
||||
|
||||
function formatSpan(ms: number): string {
|
||||
if (ms < 1000) return `${String(Math.round(ms))} ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
|
||||
return `${(ms / 60_000).toFixed(1)} min`;
|
||||
}
|
||||
|
||||
/** Rows in the order the run first reached each node, top to bottom. */
|
||||
export function ganttRows(bars: readonly GanttBar[]): string[] {
|
||||
return [...new Set(bars.map((bar) => bar.nodeId))];
|
||||
}
|
||||
|
||||
/** The run's steps over time: one row per node, one bar per attempt, colored by how it went. */
|
||||
export function ganttOption(bars: readonly GanttBar[], label: (nodeId: string) => string, palette: Palette): EChartsOption {
|
||||
const rows = ganttRows(bars);
|
||||
const rowOf = new Map(rows.map((nodeId, index) => [nodeId, index]));
|
||||
return {
|
||||
animationDuration: 400,
|
||||
grid: { left: 8, right: 16, top: 8, bottom: 4, containLabel: true },
|
||||
tooltip: {
|
||||
...tooltipBase(palette),
|
||||
trigger: 'item',
|
||||
formatter: (params) => {
|
||||
const item = Array.isArray(params) ? params[0] : params;
|
||||
const bar = bars[item?.dataIndex ?? 0];
|
||||
if (bar === undefined) return '';
|
||||
return `<strong>${label(bar.nodeId)}</strong><br/>${ATTEMPT_LABEL[bar.status]} · ${bar.open ? 'for ' : ''}${formatSpan(bar.end - bar.start)}`;
|
||||
},
|
||||
},
|
||||
xAxis: { type: 'time', min: 'dataMin', max: 'dataMax', ...axisBase(palette), axisLabel: { color: palette.muted, hideOverlap: true } },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
inverse: true,
|
||||
data: rows.map(label),
|
||||
...axisBase(palette),
|
||||
axisLabel: { color: palette.text, width: 180, overflow: 'truncate' },
|
||||
splitLine: { show: true, lineStyle: { color: palette.line, opacity: 0.4 } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'custom',
|
||||
encode: { x: [1, 2], y: 0 },
|
||||
renderItem: (_params, api) => {
|
||||
const row = api.value(0);
|
||||
const from = api.coord([api.value(1), row]);
|
||||
const to = api.coord([api.value(2), row]);
|
||||
const size = api.size?.([0, 1]);
|
||||
const height = (Array.isArray(size) ? (size[1] ?? 20) : 20) * 0.56;
|
||||
const x = from[0] ?? 0;
|
||||
const y = (from[1] ?? 0) - height / 2;
|
||||
// An instant step still shows as a sliver the owner can hover and click.
|
||||
return { type: 'rect', shape: { x, y, width: Math.max((to[0] ?? x) - x, 4), height, r: 4 }, style: { fill: api.visual('color') as string } };
|
||||
},
|
||||
data: bars.map((bar) => ({
|
||||
value: [rowOf.get(bar.nodeId) ?? 0, bar.start, bar.end],
|
||||
itemStyle: { color: palette.status[ATTEMPT_COLOR[bar.status]], opacity: bar.open ? 0.75 : 1 },
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const KIND_LABEL: Readonly<Record<string, string>> = { releaseNotes: 'Release notes', postUpgrade: 'After the update', repair: 'Repair' };
|
||||
// The model's verdicts in the colors the run page gives them.
|
||||
const OUTCOME_COLOR: Readonly<Record<string, RunBucket>> = { clear: 'succeeded', updated: 'succeeded', attention: 'running', blocked: 'waiting', failed: 'failed' };
|
||||
|
||||
/** What the models concluded, per kind of question: one bar per kind, stacked by outcome. */
|
||||
export function outcomesOption(calls: readonly { readonly kind: string; readonly outcome: string }[], palette: Palette): EChartsOption {
|
||||
const kinds = [...new Set(calls.map((call) => call.kind))];
|
||||
const outcomes = [...new Set(calls.map((call) => call.outcome))].sort();
|
||||
const count = (kind: string, outcome: string) => calls.filter((call) => call.kind === kind && call.outcome === outcome).length;
|
||||
return {
|
||||
animationDuration: 500,
|
||||
grid: { left: 8, right: 16, top: 36, bottom: 4, containLabel: true },
|
||||
legend: { ...legendBase(palette), data: outcomes },
|
||||
tooltip: { ...tooltipBase(palette), trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
xAxis: { type: 'value', minInterval: 1, ...axisBase(palette) },
|
||||
yAxis: { type: 'category', inverse: true, data: kinds.map((kind) => KIND_LABEL[kind] ?? kind), ...axisBase(palette), axisLabel: { color: palette.text } },
|
||||
series: outcomes.map((outcome) => {
|
||||
const bucket = OUTCOME_COLOR[outcome];
|
||||
return {
|
||||
name: outcome,
|
||||
type: 'bar',
|
||||
stack: 'outcomes',
|
||||
barMaxWidth: 22,
|
||||
data: kinds.map((kind) => count(kind, outcome)),
|
||||
itemStyle: { color: bucket === undefined ? palette.muted : palette.status[bucket], borderColor: palette.surface, borderWidth: 1 },
|
||||
emphasis: { focus: 'series' },
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { runModeLabel, runStatusLabel } from './labels.ts';
|
||||
import { formatInstant, formatRelative } from './format.ts';
|
||||
import { useNow } from './hooks.ts';
|
||||
|
||||
/** A workflow run's status in words, colored by status. */
|
||||
export function RunBadge({ status }: { status: string }) {
|
||||
@@ -26,3 +28,13 @@ export function ErrorNote({ message }: { message: string }) {
|
||||
export function Empty({ children }: { children: ComponentChildren }) {
|
||||
return <p class="empty">{children}</p>;
|
||||
}
|
||||
|
||||
/** An instant as "5 min ago" or "in 3 h", with the full time on hover. */
|
||||
export function When({ iso }: { readonly iso: string | null }) {
|
||||
const now = useNow();
|
||||
return (
|
||||
<time dateTime={iso ?? undefined} title={formatInstant(iso)}>
|
||||
{formatRelative(iso, now)}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import type { EChartsType } from 'echarts/core';
|
||||
import type { ChartClick, Palette } from '../charts/options.ts';
|
||||
|
||||
function token(styles: CSSStyleDeclaration, name: string): string {
|
||||
return styles.getPropertyValue(name).trim();
|
||||
}
|
||||
|
||||
/** The theme's colors as the charts need them, read from the CSS tokens so both themes stay in one place. */
|
||||
function readPalette(): Palette {
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
text: token(styles, '--text'),
|
||||
muted: token(styles, '--muted'),
|
||||
line: token(styles, '--line'),
|
||||
surface: token(styles, '--surface'),
|
||||
accent: token(styles, '--accent'),
|
||||
status: {
|
||||
succeeded: token(styles, '--ok'),
|
||||
failed: token(styles, '--error'),
|
||||
waiting: token(styles, '--stop'),
|
||||
running: token(styles, '--running'),
|
||||
cancelled: token(styles, '--queued'),
|
||||
},
|
||||
series: [1, 2, 3, 4, 5, 6, 7, 8].map((slot) => token(styles, `--series-${String(slot)}`)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Bumps when the owner switches the theme, so charts redraw with the other colors. */
|
||||
function useThemeTick(): number {
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const onChange = () => setTick((n) => n + 1);
|
||||
window.addEventListener('themechange', onChange);
|
||||
return () => window.removeEventListener('themechange', onChange);
|
||||
}, []);
|
||||
return tick;
|
||||
}
|
||||
|
||||
/**
|
||||
* An ECharts chart. ECharts loads on first use in its own chunk. Without ResizeObserver (jsdom in tests) the chart is
|
||||
* left out: every chart repeats numbers the page shows elsewhere, so nothing is lost.
|
||||
*/
|
||||
export function Chart({
|
||||
option,
|
||||
label,
|
||||
height = 220,
|
||||
onClick,
|
||||
}: {
|
||||
readonly option: (palette: Palette) => EChartsOption;
|
||||
readonly label: string;
|
||||
readonly height?: number;
|
||||
readonly onClick?: (click: ChartClick) => void;
|
||||
}) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const chart = useRef<EChartsType | null>(null);
|
||||
const lastOption = useRef('');
|
||||
const clickRef = useRef(onClick);
|
||||
clickRef.current = onClick;
|
||||
const [ready, setReady] = useState(false);
|
||||
const themeTick = useThemeTick();
|
||||
const supported = typeof ResizeObserver !== 'undefined';
|
||||
|
||||
useEffect(() => {
|
||||
if (!supported) return undefined;
|
||||
let disposed = false;
|
||||
let observer: ResizeObserver | null = null;
|
||||
void import('../charts/echarts.ts').then(({ echarts }) => {
|
||||
const element = container.current;
|
||||
if (disposed || element === null) return;
|
||||
const instance = echarts.init(element, null, { renderer: 'svg' });
|
||||
instance.on('click', (params) => {
|
||||
clickRef.current?.({ dataIndex: params.dataIndex, seriesIndex: params.seriesIndex ?? 0, name: params.name });
|
||||
});
|
||||
observer = new ResizeObserver(() => instance.resize());
|
||||
observer.observe(element);
|
||||
chart.current = instance;
|
||||
setReady(true);
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
observer?.disconnect();
|
||||
chart.current?.dispose();
|
||||
chart.current = null;
|
||||
};
|
||||
}, [supported]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || chart.current === null) return;
|
||||
const built = option(readPalette());
|
||||
// Views re-render on every live event; only a different picture is drawn again (and animated).
|
||||
const key = JSON.stringify(built) + String(themeTick);
|
||||
if (key === lastOption.current) return;
|
||||
lastOption.current = key;
|
||||
chart.current.setOption(built, { notMerge: true });
|
||||
});
|
||||
|
||||
return <div ref={container} class="chart" role="img" aria-label={label} style={{ height: `${String(height)}px`, cursor: onClick === undefined ? undefined : 'pointer' }} />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterItems } from './command-palette.tsx';
|
||||
|
||||
describe('filterItems', () => {
|
||||
const items = [
|
||||
{ label: 'Overview', hint: 'page', href: '#/overview', icon: 'overview' as const },
|
||||
{ label: 'bazarr', hint: 'app · bazarr', href: '#/apps/bazarr', icon: 'apps' as const },
|
||||
{ label: 'bazarr: Failed', hint: 'run · Verify failed', href: '#/runs/r1', icon: 'runs' as const },
|
||||
];
|
||||
|
||||
it('keeps items with every word of the query, case aside, in their order', () => {
|
||||
expect(filterItems(items, 'BAZ').map((item) => item.href)).toEqual(['#/apps/bazarr', '#/runs/r1']);
|
||||
expect(filterItems(items, 'baz run').map((item) => item.href)).toEqual(['#/runs/r1']);
|
||||
expect(filterItems(items, ' ')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
||||
import { api } from '../api.ts';
|
||||
import { runStatusLabel } from '../labels.ts';
|
||||
import { hrefFor } from '../route.ts';
|
||||
import { Icon } from './ui.tsx';
|
||||
import type { IconName } from './ui.tsx';
|
||||
|
||||
export interface PaletteItem {
|
||||
readonly label: string;
|
||||
readonly hint: string;
|
||||
readonly href: string;
|
||||
readonly icon: IconName;
|
||||
}
|
||||
|
||||
const PAGES: readonly PaletteItem[] = [
|
||||
{ label: 'Overview', hint: 'page', href: hrefFor({ view: 'overview' }), icon: 'overview' },
|
||||
{ label: 'Apps', hint: 'page', href: hrefFor({ view: 'apps' }), icon: 'apps' },
|
||||
{ label: 'Runs', hint: 'page', href: hrefFor({ view: 'runs' }), icon: 'runs' },
|
||||
{ label: 'Workflows', hint: 'page', href: hrefFor({ view: 'workflows' }), icon: 'workflows' },
|
||||
{ label: 'Functions', hint: 'page', href: hrefFor({ view: 'functions' }), icon: 'functions' },
|
||||
{ label: 'Models', hint: 'page', href: hrefFor({ view: 'models' }), icon: 'models' },
|
||||
{ label: 'Settings', hint: 'page', href: hrefFor({ view: 'settings' }), icon: 'settings' },
|
||||
];
|
||||
|
||||
const RECENT_RUNS = 15;
|
||||
const SHOWN = 12;
|
||||
|
||||
/** Every word of the query must appear in the item's label or hint; the order of the items stays. */
|
||||
export function filterItems(items: readonly PaletteItem[], query: string): PaletteItem[] {
|
||||
const words = query.toLowerCase().split(/\s+/).filter((word) => word !== '');
|
||||
return items.filter((item) => {
|
||||
const text = `${item.label} ${item.hint}`.toLowerCase();
|
||||
return words.every((word) => text.includes(word));
|
||||
});
|
||||
}
|
||||
|
||||
/** Everything the palette can jump to; a list that fails to load is left out rather than failing the palette. */
|
||||
async function loadItems(): Promise<PaletteItem[]> {
|
||||
const [apps, runs, workflows, functions] = await Promise.allSettled([api.apps(), api.workflowInstances(), api.workflows(), api.functions()]);
|
||||
return [
|
||||
...PAGES,
|
||||
...(apps.status === 'fulfilled' ? apps.value.map((app) => ({ label: app.app, hint: `app · ${app.project}`, href: hrefFor({ view: 'app', app: app.app }), icon: 'apps' as const })) : []),
|
||||
...(runs.status === 'fulfilled'
|
||||
? runs.value.slice(0, RECENT_RUNS).map((run) => ({
|
||||
label: `${run.app ?? run.workflowId}: ${runStatusLabel(run.status)}`,
|
||||
hint: `run · ${run.outcome ?? run.step ?? run.id}`,
|
||||
href: hrefFor({ view: 'run', runId: run.id }),
|
||||
icon: 'runs' as const,
|
||||
}))
|
||||
: []),
|
||||
...(workflows.status === 'fulfilled' ? workflows.value.map((wf) => ({ label: wf.workflowId, hint: `workflow · ${wf.name}`, href: hrefFor({ view: 'workflow', workflowId: wf.workflowId }), icon: 'workflows' as const })) : []),
|
||||
...(functions.status === 'fulfilled' ? functions.value.map((fn) => ({ label: fn.functionId, hint: `function · ${fn.name}`, href: hrefFor({ view: 'function', functionId: fn.functionId }), icon: 'functions' as const })) : []),
|
||||
];
|
||||
}
|
||||
|
||||
/** Opens on Ctrl+K (⌘K) anywhere, or `/` outside a text field; the sidebar's search button opens it too. */
|
||||
export function useCommandPalette(): [boolean, (open: boolean) => void] {
|
||||
const [open, setOpen] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
const typing = event.target instanceof HTMLElement && (event.target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName));
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
setOpen((current) => !current);
|
||||
} else if (event.key === '/' && !typing) {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
return [open, setOpen];
|
||||
}
|
||||
|
||||
export function CommandPalette({ onClose }: { readonly onClose: () => void }) {
|
||||
const [items, setItems] = useState<readonly PaletteItem[]>(PAGES);
|
||||
const [query, setQuery] = useState('');
|
||||
const [active, setActive] = useState(0);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
useLayoutEffect(() => input.current?.focus(), []);
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
void loadItems().then((loaded) => {
|
||||
if (current) setItems(loaded);
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, []);
|
||||
const shown = filterItems(items, query).slice(0, SHOWN);
|
||||
const go = (item: PaletteItem | undefined) => {
|
||||
if (item === undefined) return;
|
||||
window.location.hash = item.href;
|
||||
onClose();
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setActive((index) => Math.min(index + 1, shown.length - 1));
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setActive((index) => Math.max(index - 1, 0));
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
go(shown[active]);
|
||||
} else if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div class="modal-backdrop palette-backdrop" onClick={(event) => event.target === event.currentTarget && onClose()}>
|
||||
<div class="palette" role="dialog" aria-modal="true" aria-label="Go to">
|
||||
<div class="palette-input">
|
||||
<Icon name="search" />
|
||||
<input
|
||||
ref={input}
|
||||
type="text"
|
||||
placeholder="Go to an app, a run, a workflow or a page…"
|
||||
value={query}
|
||||
onInput={(event) => {
|
||||
setQuery((event.target as HTMLInputElement).value);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={onKey}
|
||||
aria-label="Search"
|
||||
/>
|
||||
<kbd>Esc</kbd>
|
||||
</div>
|
||||
{shown.length === 0 ? (
|
||||
<p class="palette-empty">Nothing matches.</p>
|
||||
) : (
|
||||
<ul class="palette-list" role="listbox">
|
||||
{shown.map((item, index) => (
|
||||
<li key={item.href} role="option" aria-selected={index === active} class={index === active ? 'active' : undefined} onMouseEnter={() => setActive(index)} onClick={() => go(item)}>
|
||||
<Icon name={item.icon} size={16} />
|
||||
<span class="palette-label">{item.label}</span>
|
||||
<span class="palette-hint">{item.hint}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -130,6 +130,9 @@ export function CytoscapeGraph({
|
||||
const positionsRef = useRef<Record<string, WorkflowLayoutNodePosition>>(layout?.positions ?? computeDefaultDagLayout(document));
|
||||
const [expandedSubworkflows] = useState<readonly string[]>(layout?.expandedSubworkflows ?? []);
|
||||
const [renderUnavailable, setRenderUnavailable] = useState(false);
|
||||
// Once the canvas is known to be missing, later renders do not try again: a failed start clears the container,
|
||||
// which would wipe the fallback list rendered into it.
|
||||
const unavailableRef = useRef(false);
|
||||
|
||||
const errorsByNode = new Map<string, number>();
|
||||
for (const diag of diagnostics) {
|
||||
@@ -154,7 +157,7 @@ export function CytoscapeGraph({
|
||||
}, [layout, document]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cyContainerRef.current) return;
|
||||
if (!cyContainerRef.current || unavailableRef.current) return;
|
||||
|
||||
const positions = positionsRef.current;
|
||||
const edges = edgesOf(document);
|
||||
@@ -243,6 +246,7 @@ export function CytoscapeGraph({
|
||||
});
|
||||
} catch {
|
||||
// No usable canvas 2D context (e.g. a headless test environment): fall back to a plain node list.
|
||||
unavailableRef.current = true;
|
||||
setRenderUnavailable(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { createContext } from 'preact';
|
||||
import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { Icon } from './ui.tsx';
|
||||
|
||||
export interface ConfirmRequest {
|
||||
readonly title: string;
|
||||
readonly message?: ComponentChildren;
|
||||
readonly confirmLabel?: string;
|
||||
/** Changes the app or ends something for good: the button is red and Enter does not confirm by default. */
|
||||
readonly danger?: boolean;
|
||||
}
|
||||
|
||||
export type ToastTone = 'ok' | 'error';
|
||||
|
||||
interface Dialogs {
|
||||
/** Asks the owner in a window; resolves true only when they confirm. */
|
||||
readonly confirm: (request: ConfirmRequest) => Promise<boolean>;
|
||||
/** A short note in the corner that goes away on its own. */
|
||||
readonly toast: (text: string, tone?: ToastTone) => void;
|
||||
}
|
||||
|
||||
// Outside a provider nothing can be confirmed, so nothing risky runs by accident.
|
||||
const DialogContext = createContext<Dialogs>({ confirm: () => Promise.resolve(false), toast: () => undefined });
|
||||
|
||||
export const useConfirm = () => useContext(DialogContext).confirm;
|
||||
export const useToast = () => useContext(DialogContext).toast;
|
||||
|
||||
const TOAST_MS = 4000;
|
||||
|
||||
interface Pending {
|
||||
readonly request: ConfirmRequest;
|
||||
readonly resolve: (ok: boolean) => void;
|
||||
}
|
||||
|
||||
interface Toast {
|
||||
readonly id: number;
|
||||
readonly tone: ToastTone;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
/** Closes on Escape while `active`; listens from the first paint, before a plain effect would run. */
|
||||
function useEscape(active: boolean, onEscape: () => void) {
|
||||
useLayoutEffect(() => {
|
||||
if (!active) return undefined;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onEscape();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [active, onEscape]);
|
||||
}
|
||||
|
||||
function ConfirmDialog({ request, onAnswer }: { readonly request: ConfirmRequest; readonly onAnswer: (ok: boolean) => void }) {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||
const cancel = useCallback(() => onAnswer(false), [onAnswer]);
|
||||
useEscape(true, cancel);
|
||||
useEffect(() => {
|
||||
(request.danger === true ? cancelRef : confirmRef).current?.focus();
|
||||
}, [request]);
|
||||
return (
|
||||
<div class="modal-backdrop" onClick={(event) => event.target === event.currentTarget && cancel()}>
|
||||
<div class={`modal-dialog${request.danger === true ? ' modal-danger' : ''}`} role="alertdialog" aria-modal="true" aria-labelledby="confirm-title">
|
||||
<div class="modal-title">
|
||||
{request.danger === true ? (
|
||||
<span class="modal-icon">
|
||||
<Icon name="alert" />
|
||||
</span>
|
||||
) : null}
|
||||
<h2 id="confirm-title">{request.title}</h2>
|
||||
</div>
|
||||
{request.message === undefined ? null : <div class="modal-message">{request.message}</div>}
|
||||
<div class="modal-actions">
|
||||
<button type="button" ref={cancelRef} onClick={cancel} data-dialog="cancel">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" ref={confirmRef} class={request.danger === true ? 'danger' : 'primary'} onClick={() => onAnswer(true)} data-dialog="confirm">
|
||||
{request.confirmLabel ?? 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Holds the confirmation window and the toasts for everything under it. */
|
||||
export function DialogProvider({ children }: { readonly children: ComponentChildren }) {
|
||||
const [pending, setPending] = useState<Pending | null>(null);
|
||||
const pendingRef = useRef<Pending | null>(null);
|
||||
const [toasts, setToasts] = useState<readonly Toast[]>([]);
|
||||
const nextToast = useRef(0);
|
||||
|
||||
const confirm = useCallback(
|
||||
(request: ConfirmRequest) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
pendingRef.current?.resolve(false); // a newer question replaces an unanswered one
|
||||
pendingRef.current = { request, resolve };
|
||||
setPending(pendingRef.current);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const answer = useCallback((ok: boolean) => {
|
||||
pendingRef.current?.resolve(ok);
|
||||
pendingRef.current = null;
|
||||
setPending(null);
|
||||
}, []);
|
||||
const toast = useCallback((text: string, tone: ToastTone = 'ok') => {
|
||||
nextToast.current += 1;
|
||||
const id = nextToast.current;
|
||||
setToasts((current) => [...current, { id, tone, text }]);
|
||||
setTimeout(() => setToasts((current) => current.filter((item) => item.id !== id)), TOAST_MS);
|
||||
}, []);
|
||||
const value = useMemo(() => ({ confirm, toast }), [confirm, toast]);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={value}>
|
||||
{children}
|
||||
{pending === null ? null : <ConfirmDialog request={pending.request} onAnswer={answer} />}
|
||||
<div class="toasts" aria-live="polite">
|
||||
{toasts.map((item) => (
|
||||
<div key={item.id} class={`toast toast-${item.tone}`} role={item.tone === 'error' ? 'alert' : 'status'}>
|
||||
<Icon name={item.tone === 'ok' ? 'check' : 'alert'} size={16} />
|
||||
{item.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** A panel that slides in from the right over the page; closes on Escape, the backdrop or its close button. */
|
||||
export function Drawer({ title, onClose, children }: { readonly title: ComponentChildren; readonly onClose: () => void; readonly children: ComponentChildren }) {
|
||||
useEscape(true, onClose);
|
||||
return (
|
||||
<div class="drawer-backdrop" onClick={(event) => event.target === event.currentTarget && onClose()}>
|
||||
<aside class="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title">
|
||||
<header class="drawer-head">
|
||||
<h2 id="drawer-title">{title}</h2>
|
||||
<button type="button" class="icon-button" onClick={onClose} title="Close">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="drawer-body">{children}</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { describeNode } from '../../domain/workflow-node-descriptions.ts';
|
||||
import { COMPONENT_OPERATION_DESCRIPTIONS, PRIVILEGED_ACTION_DESCRIPTIONS } from '../../domain/workflow-catalog-descriptions.ts';
|
||||
import { api } from '../api.ts';
|
||||
import { useResource } from '../hooks.ts';
|
||||
import { useConfirm } from './dialogs.tsx';
|
||||
import { addNode, dataSource, nodeEvents, removeNode, setConfigField, setOwnerActions, setRoute } from '../node-edit.ts';
|
||||
import type { NewNodeKind } from '../node-edit.ts';
|
||||
import { hrefFor } from '../route.ts';
|
||||
@@ -61,6 +62,7 @@ export function NodeForm({ document, node, readOnly, onChange, onSelect }: {
|
||||
readonly onSelect: (nodeId: string | null) => void;
|
||||
}) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const confirm = useConfirm();
|
||||
const described = describeNode(node, document);
|
||||
const config = node.type === 'extension' ? record(node.config) : {};
|
||||
const binding = record(config['binding']);
|
||||
@@ -152,10 +154,12 @@ export function NodeForm({ document, node, readOnly, onChange, onSelect }: {
|
||||
|
||||
{!readOnly && (
|
||||
<button type="button" class="btn-sm" onClick={() => {
|
||||
if (!window.confirm(`Remove the block “${described.title}” and the routes into it?`)) return;
|
||||
const removed = removeNode(document, node.nodeId);
|
||||
if (!removed.ok) setError(removed.reason);
|
||||
else { onChange(removed.document); onSelect(null); }
|
||||
void confirm({ title: `Remove the block “${described.title}”?`, message: 'The routes into it are removed too.', confirmLabel: 'Remove', danger: true }).then((ok) => {
|
||||
if (!ok) return;
|
||||
const removed = removeNode(document, node.nodeId);
|
||||
if (!removed.ok) setError(removed.reason);
|
||||
else { onChange(removed.document); onSelect(null); }
|
||||
});
|
||||
}}>Remove this block</button>
|
||||
)}
|
||||
{error && <p class="error-note">{error}</p>}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { ComponentChildren } from 'preact';
|
||||
|
||||
/** Stroke icons on a 24×24 grid; one path string per icon keeps them inline and dependency-free. */
|
||||
const ICONS = {
|
||||
overview: 'M3 3h7v9H3zM14 3h7v5h-7zM14 12h7v9h-7zM3 16h7v5H3z',
|
||||
apps: 'M21 8 12 3 3 8v8l9 5 9-5zM3 8l9 5 9-5M12 13v8',
|
||||
runs: 'M22 12h-4l-3 9L9 3l-3 9H2',
|
||||
workflows: 'M6 3v12M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 9a9 9 0 0 1-9 9',
|
||||
functions: 'M16 18l6-6-6-6M8 6l-6 6 6 6',
|
||||
models: 'M9 3v2M15 3v2M9 19v2M15 19v2M3 9h2M3 15h2M19 9h2M19 15h2M7 5h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zM9 9h6v6H9z',
|
||||
sun: 'M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4',
|
||||
moon: 'M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z',
|
||||
clock: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 6v6l4 2',
|
||||
alert: 'M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0zM12 9v4M12 17h.01',
|
||||
check: 'M20 6 9 17l-5-5',
|
||||
close: 'M18 6 6 18M6 6l12 12',
|
||||
arrow: 'M5 12h14M12 5l7 7-7 7',
|
||||
coin: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM15 9.5a3 2.5 0 0 0-3-1.5c-1.7 0-3 .9-3 2s1.3 2 3 2 3 .9 3 2-1.3 2-3 2a3 2.5 0 0 1-3-1.5M12 6v2M12 16v2',
|
||||
upgrade: 'M12 19V5M5 12l7-7 7 7',
|
||||
grid: 'M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z',
|
||||
list: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
|
||||
settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6',
|
||||
bell: 'M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 0 1-3.4 0',
|
||||
search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.3-4.3',
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof ICONS;
|
||||
|
||||
export function Icon({ name, size = 18 }: { readonly name: IconName; readonly size?: number }) {
|
||||
return (
|
||||
<svg class="icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d={ICONS[name]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** A window on the page: a titled surface with optional actions on the right of its header. */
|
||||
export function Card({
|
||||
title,
|
||||
icon,
|
||||
actions,
|
||||
tone,
|
||||
class: extra,
|
||||
children,
|
||||
}: {
|
||||
readonly title?: ComponentChildren;
|
||||
readonly icon?: IconName;
|
||||
readonly actions?: ComponentChildren;
|
||||
readonly tone?: 'attention';
|
||||
readonly class?: string;
|
||||
readonly children: ComponentChildren;
|
||||
}) {
|
||||
const classes = ['panel', tone === undefined ? '' : `panel-${tone}`, extra ?? ''].filter((part) => part !== '').join(' ');
|
||||
return (
|
||||
<section class={classes}>
|
||||
{title === undefined && actions === undefined ? null : (
|
||||
<header class="panel-head">
|
||||
{title === undefined ? <span /> : (
|
||||
<h2>
|
||||
{icon === undefined ? null : <Icon name={icon} />}
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{actions === undefined ? null : <div class="panel-actions">{actions}</div>}
|
||||
</header>
|
||||
)}
|
||||
<div class="panel-body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export type Tone = 'ok' | 'error' | 'stop' | 'running' | 'accent' | 'neutral';
|
||||
|
||||
/** One number that matters, with what it means and, optionally, how it moved and a small chart under it. */
|
||||
export function StatTile({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
tone = 'neutral',
|
||||
hint,
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
readonly label: string;
|
||||
readonly value: ComponentChildren;
|
||||
readonly icon: IconName;
|
||||
readonly tone?: Tone;
|
||||
readonly hint?: ComponentChildren;
|
||||
readonly href?: string;
|
||||
readonly children?: ComponentChildren;
|
||||
}) {
|
||||
const body = (
|
||||
<>
|
||||
<span class="stat-icon">
|
||||
<Icon name={icon} size={16} />
|
||||
</span>
|
||||
<span class="stat-label">{label}</span>
|
||||
<strong class="stat-value">{value}</strong>
|
||||
{hint === undefined ? null : <span class="stat-hint">{hint}</span>}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
return href === undefined ? <div class={`stat stat-${tone}`}>{body}</div> : <a class={`stat stat-${tone}`} href={href}>{body}</a>;
|
||||
}
|
||||
+21
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatBuild, formatCost, formatDuration, formatInstant, humanizeCode, nodeRunStates, runVersions, traversedEdges } from './format.ts';
|
||||
import { formatBuild, formatCost, formatDuration, formatInstant, formatRelative, humanizeCode, nodeRunStates, runVersions, traversedEdges } from './format.ts';
|
||||
|
||||
describe('formatInstant', () => {
|
||||
it('shows date and time in the given zone', () => {
|
||||
@@ -122,3 +122,23 @@ describe('traversedEdges', () => {
|
||||
expect(traversedEdges({ a: 'done', b: 'done', c: 'current' }, edges)).toEqual([true, true, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRelative', () => {
|
||||
const now = Date.parse('2026-09-30T12:00:00.000Z');
|
||||
it.each([
|
||||
['2026-09-30T11:59:40.000Z', 'just now'],
|
||||
['2026-09-30T11:55:00.000Z', '5 min ago'],
|
||||
['2026-09-30T09:00:00.000Z', '3 h ago'],
|
||||
['2026-09-29T10:00:00.000Z', 'yesterday'],
|
||||
['2026-09-27T12:00:00.000Z', '3 days ago'],
|
||||
['2026-09-30T14:30:00.000Z', 'in 3 h'],
|
||||
['2026-10-01T12:00:00.000Z', 'tomorrow'],
|
||||
['2026-09-10T12:00:00.000Z', '2026-09-10'],
|
||||
])('%s -> %s', (iso, text) => {
|
||||
expect(formatRelative(iso, now, 'UTC')).toBe(text);
|
||||
});
|
||||
|
||||
it('shows a dash for nothing', () => {
|
||||
expect(formatRelative(null, now)).toBe('—');
|
||||
});
|
||||
});
|
||||
@@ -100,3 +100,24 @@ export function runVersions(run: { readonly running: string | null; readonly can
|
||||
if (run === null) return { running: null, available: null };
|
||||
return { running: run.running, available: run.candidate !== null && run.candidate !== run.running ? run.candidate : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* `5 min ago`, `in 3 h`, `yesterday`: how far an instant is from now, for lists read at a glance. Past a week it is the
|
||||
* date (`2026-09-18`). The full time belongs in the element's title.
|
||||
*/
|
||||
export function formatRelative(iso: string | null, now: number, timeZone?: string): string {
|
||||
if (iso === null) return DASH;
|
||||
const time = Date.parse(iso);
|
||||
if (Number.isNaN(time)) return DASH;
|
||||
const diff = time - now;
|
||||
const minutes = Math.round(Math.abs(diff) / 60_000);
|
||||
const say = (amount: string) => (diff < 0 ? `${amount} ago` : `in ${amount}`);
|
||||
if (minutes < 1) return diff < 0 ? 'just now' : 'in a moment';
|
||||
if (minutes < 60) return say(`${String(minutes)} min`);
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return say(`${String(hours)} h`);
|
||||
const days = Math.round(hours / 24);
|
||||
if (days === 1) return diff < 0 ? 'yesterday' : 'tomorrow';
|
||||
if (days < 7) return say(`${String(days)} days`);
|
||||
return new Intl.DateTimeFormat('sv-SE', { dateStyle: 'short', ...(timeZone === undefined ? {} : { timeZone }) }).format(time);
|
||||
}
|
||||
@@ -38,3 +38,13 @@ export function useResource<T>(load: () => Promise<T>, deps: readonly unknown[])
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/** The current time, refreshed every `everyMs`, so relative times ("5 min ago") keep up without a reload. */
|
||||
export function useNow(everyMs = 60_000): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), everyMs);
|
||||
return () => clearInterval(timer);
|
||||
}, [everyMs]);
|
||||
return now;
|
||||
}
|
||||
+8
-1
@@ -2,8 +2,15 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Homelab upgrade</title>
|
||||
<meta name="theme-color" content="#0d1117" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { render } from 'preact';
|
||||
import { App } from './app.tsx';
|
||||
import './styles.css';
|
||||
import { applyInitialTheme } from './theme.ts';
|
||||
|
||||
applyInitialTheme();
|
||||
|
||||
const root = document.getElementById('app');
|
||||
if (root === null) throw new Error('missing #app element');
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7c9cff"/><stop offset="1" stop-color="#2dd4bf"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="#0d1117"/><rect x="8" y="8" width="48" height="48" rx="12" fill="url(#g)"/><path d="M32 44V20M22 30l10-10 10 10" fill="none" stroke="#0d1117" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
|
After Width: | Height: | Size: 476 B |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "Homelab upgrade",
|
||||
"short_name": "Upgrade",
|
||||
"start_url": "./#/overview",
|
||||
"display": "standalone",
|
||||
"background_color": "#0d1117",
|
||||
"theme_color": "#0d1117",
|
||||
"icons": [{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }]
|
||||
}
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user