compose.prod.yml mirrors /opt/xenon/compose.yml on the macmini. jobs/ holds the scheduled-job entries (launchd plists + crontab.example). The macmini system zone is Asia/Hong_Kong; every job fires at both candidate HKT times and runs only in the matching New York hour.
| Job | Entry | Runs from | Schedule (ET) | Lock | Creds | Volumes | Logs |
|---|---|---|---|---|---|---|---|
| nav-flex-refresh | xenon-nav-flex-refresh (pyproject script, xenon.jobs.nav_flex_refresh:main) |
api image via `/bin/sh -c '[ "$(TZ=America/New_York date +%H)" = 17 ] | exit 0; exec docker-compose -f /opt/xenon/compose.yml run --rm -T --no-deps --name xenon-job-nav-flex-refresh --label ...watchtower.enable=false api xenon-nav-flex-refresh` | daily 17:30 ET = 05:30 HKT (EDT) / 06:30 HKT (EST); fires at both, /bin/sh -c guard runs only when NY hour is 17 |
launchd single instance + fixed container name (Docker name conflict); dead same-name container removed first with docker rm (no force) |
api env_file (.env, web.env): DATABASE_URL, IB_FLEX_TOKEN, IB_FLEX_NAV_QUERY_ID, XENON_TRADING_MODE=live, XENON_LIVE_ACCOUNT |
|
| refresh-core-test | scripts/infra/refresh-core-test.sh (pg_dump core_dev, pg_restore core_test) |
HOST-NATIVE: needs pg_dump/pg_restore/psql, the api image has only libpq5. Script copied to /opt/xenon/bin (no checkout) | daily 04:00 ET = 16:00 HKT (EDT) / 17:00 HKT (EST); fires at both, guard runs only when NY hour is 04 | the script takes an atomic mkdir lock itself (all paths: launchd, cron, manual); second run exits 3; script refuses SRC==DST and any DST that is core_dev or lacks "test" | PGPASSFILE=/opt/xenon/.pgpass-refresh (chmod 600); does not read .env | none | /var/log/xenon/refresh-core-test.{log,stdout.log,stderr.log} |
| flex-divergence | xenon-flex-divergence-check --apply (pyproject script, xenon.jobs.flex_divergence_check:main) |
api image via /bin/sh -c '[ "$(TZ=America/New_York date +%H)" = 07 ] || exit 0; docker rm xenon-job-flex-divergence ...; exec docker-compose -f /opt/xenon/compose.yml run --rm -T --no-deps --name xenon-job-flex-divergence ... api xenon-flex-divergence-check --apply' |
daily 07:30 ET = 19:30 HKT (EDT) / 20:30 HKT (EST); compares the previous New York day; feeds the /admin Flex Divergence tile | same as nav-flex-refresh (launchd single instance + fixed container name) | api env_file: DATABASE_URL, IB_FLEX_TOKEN, IB_FLEX_QUERY_ID, XENON_TRADING_MODE, XENON_BROKER_ACCOUNT; Flex unset = {"skipped": true}, exit 0 |
api service | /var/log/xenon/flex-divergence.{stdout,stderr}.log |
| Futu nightly deal sync | CLI xenon-futu-history-sync exists, BUT the 16:30 ET weekday run is already an in-process asyncio loop in the api (server.py::_maybe_start_futu_history_loop, default on, XENON_FUTU_HISTORY_LOOP=0 disables; syncs the live Futu scope only (Futu has no history for paper), through FUTU_OPEND_HOST, on a worker thread) |
not scheduled externally | n/a | n/a | n/a | n/a | api container logs |
Files: deploy/jobs/com.xenon.nav-flex-refresh.plist, deploy/jobs/com.xenon.refresh-core-test.plist, deploy/jobs/com.xenon.flex-divergence.plist, deploy/jobs/crontab.example (cron alternative; use ONE scheduler per job).
Why run --rm api and not a new service: api already has the right env_file, volumes, and image. No ofelia/cron container, no new service. run does not publish ports (no --service-ports).
Why the Watchtower label override: run copies the service labels, including watchtower.enable=true. Without the override Watchtower could recreate the one-off container mid-run on a new image.
| Gateway | Environment | XENON_IB_CLIENT_ID_BASE |
Host / supervisor |
|---|---|---|---|
Live, mini :4001 |
prod | 0 (this compose file) |
/opt/xenon Docker stack, Watchtower |
Live, mini :4001 (read-only) or paper, laptop :4002 |
dev | 100 (scripts/infra/dev.sh, docker-compose.yml) |
MacBook |
| any | second dev stack | another base (200-800, step 100) |
— or stopped |
Pool sync/orders/data = base+3/4/5 (orders places, cancels and modifies every Xenon order), relay base+10..19, auto base+20..49 (read/data only). The api refuses a base that does not match its database. api has stop_grace_period: 60s and Watchtower WATCHTOWER_TIMEOUT: 60s so the api can disconnect from IB before it is killed. Cutover, stale-socket check and ORDERS_HALTED recovery: docs/runbooks/pr10-order-owner-cutover.md.
Names and booleans only: never print secret values. docker-compose below is /opt/homebrew/bin/docker-compose; run export PATH=/opt/homebrew/bin:$PATH first. The repo copy is on the laptop; the mini path is /opt/xenon.
A. Staged compose validation and backup
-
From the laptop:
scp deploy/compose.prod.yml macmini:/opt/xenon/compose.new.yml. -
On the mini, first put the webhook variable in place. Check whether it is already defined, printing only a count:
grep -c '^WATCHTOWER_NOTIFICATION_URL=' /opt/xenon/.env(expect1). If it prints0, addWATCHTOWER_NOTIFICATION_URL=<current ntfy URL from the live compose>to/opt/xenon/.env(an unquoted value; never echo it; step 3 checks the effective value). Also confirmGHCR_USER,GHCR_PAT,XENON_INTERNAL_API_TOKENexist there (names only:grep -c '^NAME=' .env). -
cd /opt/xenon && docker-compose -f compose.new.yml config -q(must exit 0; needs.env,web.envand the variables from step 2). Then check that the effective webhook is a non-empty string, printing only a boolean:docker-compose -f compose.new.yml config --format json | jq -e '.services.watchtower.environment.WATCHTOWER_NOTIFICATION_URL | type == "string" and (gsub("\\s"; "") | length > 0)'printstrueand exits 0. Stop onfalseor a non-zero exit. Then check the PR-10 values as rendered, printing only a boolean:docker-compose -f compose.new.yml config --format json | jq -e '(.services.api.environment.XENON_IB_CLIENT_ID_BASE == "0") and (.services.realtime.environment.XENON_IB_CLIENT_ID_BASE == "0") and (.services.api.environment.IB_REALTIME_STATUS_URL == "http://realtime:8765/status") and (.services.watchtower.environment.WATCHTOWER_TIMEOUT == "60s") and (.services.api.stop_grace_period | IN("60s", "1m0s"))'printstrue. (The rendered form of the 60s duration differs between docker-compose versions; both forms are accepted.) Stop onfalse. -
Back up:
cp -p compose.yml compose.yml.bak-$(date +%Y%m%d%H%M). -
Diff without printing the webhook URL:
diff <(grep -v WATCHTOWER_NOTIFICATION_URL compose.yml) <(grep -v WATCHTOWER_NOTIFICATION_URL compose.new.yml). Expected differences (PR-10 staging), and nothing else:- header and comment lines;
api.environment.XENON_IB_CLIENT_ID_BASE: "0"andrealtime.environment.XENON_IB_CLIENT_ID_BASE: "0"added;api.environment.IB_REALTIME_STATUS_URL: http://realtime:8765/statusadded;api.stop_grace_period: 60sadded;watchtower.environment.WATCHTOWER_TIMEOUT: 60sadded;WATCHTOWER_NOTIFICATION_URLbecomes${WATCHTOWER_NOTIFICATION_URL}interpolation (filtered out of this diff by design; step 3 checked the effective value).
Explain every other difference before you continue; if you cannot, stop.
-
Stop here. Leave
compose.new.ymlstaged and validated; do not rundocker-compose up -dnow.up -drecreates the api on its current image, and--pull neverdoes not prevent that recreate or the IB reconnect, so it must not run before the gateway socket check. Apply the file inside the controlled sequence:docs/runbooks/remote-deploy.md§ Standard release flow, step 8 (mv compose.new.yml compose.ymlright beforedocker-compose up -d --pull never api web realtime, afterdocker-compose stop -t 60 api realtimeand the socket check). With no release in flight, run that same sequence by hand: stopapi realtimewith-t 60, run the check indocs/runbooks/pr10-order-owner-cutover.md§ 4, thenmvandup -d --pull never api web realtime; resume Watchtower only as in remote-deploy step 10.
B. Rollback
- Use the controlled sequence, never a bare
up -d: pause Watchtower (docker stop xenon-watchtower-1),docker-compose stop -t 60 api realtime, run the socket check indocs/runbooks/pr10-order-owner-cutover.md§ 4, thencp -p compose.yml.bak-<stamp> compose.yml && docker-compose up -d --pull never api web realtime. Resume Watchtower only as indocs/runbooks/remote-deploy.md§ Standard release flow, step 10. Restoring the file does not pin an image: the services run:latest. - Rolling back across PR-10 (single order owner): follow
docs/runbooks/pr10-order-owner-cutover.md§ 7 (pause Watchtower, dispose of orders placed by clientId 4, pin and verify the previous image, stop/check sockets/start). - Jobs:
launchctl bootout gui/$(id -u)/com.xenon.nav-flex-refreshand the same forcom.xenon.refresh-core-testandcom.xenon.flex-divergence;rmthe three plists from~/Library/LaunchAgents.
C. Post-up checks
docker-compose ps: api, web, realtimehealthy/Up; watchtowerUponly after remote-deploy step 10 resumed it.curl -fsS http://127.0.0.1:8321/healthandcurl -fsSI http://127.0.0.1:3000/ | head -1.- Watchtower:
docker logs xenon-watchtower-1 --tail 20shows no auth or webhook error. Confirm the ntfy topic receives a notification on the next real update (the template is silent on no-op cycles); one way: after the next release, check the topic.
D. Migrator/schema gate (every release that carries a migration)
release.yml pushes :VER and then promotes :latest for api, web, realtime and migrator in one run. Watchtower recreates api as soon as :latest moves, and it never runs the migrator. So hold Watchtower for the whole release and migrate before any api is recreated. Never rely on Watchtower for schema. docs/runbooks/remote-deploy.md § Standard release flow is the full sequence; this section is the migrator part of it.
- BEFORE pushing the release tag: pause Watchtower and confirm it is stopped (
docker stop xenon-watchtower-1;docker inspect -f '{{.State.Running}}' xenon-watchtower-1printsfalse). While it is stopped nothing deploys; the running api stays on the old image. - After the
Releaserun of the tag commit showssuccess(gh run list --workflow Release --commit <sha>), pull the migrator by its immutable version tag (VER= the release version without thev,REV=git rev-parse vVER^{commit}on the laptop), together with the app images, as in remote-deploy step 5. The version label must equalVERand the revision label must equalREV:docker pull ghcr.io/moremeds/xenon-migrator:VERdocker image inspect ghcr.io/moremeds/xenon-migrator:VER --format '{{index .Config.Labels "org.opencontainers.image.version"}} {{index .Config.Labels "org.opencontainers.image.revision"}} {{json .RepoDigests}}'- The digest in
RepoDigestsmust equaldocker buildx imagetools inspect ghcr.io/moremeds/xenon-migrator:VER(or the migrator digest in/opt/xenon/release-manifest.json, if that file lists it). - Then alias it:
docker tag ghcr.io/moremeds/xenon-migrator:VER ghcr.io/moremeds/xenon-migrator:latest. The compose migrator runs:latest, so only the verified alias is ever executed. Never pull a floating:latestfor the migrator.
- The digest in
docker-compose --profile migrate run --rm --pull never migrator alembic current, then... alembic heads. If they differ, rundocker-compose --profile migrate run --rm --pull never migratoronce (this upgrades the schema;--pull neverkeeps the verified alias) and repeatalembic currentuntil it equals the head. This is the only migrator run in the release; remote-deploy step 6 points here and does not run it again.- Only after the schema is at head, continue with remote-deploy steps 7-9 (
docker-compose stop -t 60 api realtime, the gateway socket check,docker-compose up -d --pull never api web realtime, smoke), then step 10 resumes Watchtower only when registry:latestis this release. Then section C. Do not pull or run the migrator again. - If the migration fails: leave Watchtower stopped (the old api keeps running on the old schema), and fix forward or restore before restarting it.
E. Before ANY job run (scheduled install or manual): read the effective settings inside the container. web.env overrides .env for the api service, so read them from the container, not from the files. Names and booleans only. The operator exports the expected account first (export EXPECTED_ACCOUNT=<the intended live account, the U...31 account>; a plain assignment is not passed to -e EXPECTED_ACCOUNT); the container compares by exact equality and prints a boolean:
cd /opt/xenon && docker-compose -f compose.yml run --rm -T --no-deps -e EXPECTED_ACCOUNT --label com.centurylinklabs.watchtower.enable=false api python -c '
import os
from urllib.parse import urlparse
db = urlparse(os.environ.get("DATABASE_URL", "")).path.lstrip("/")
acct = os.environ.get("XENON_BROKER_ACCOUNT") or os.environ.get("XENON_LIVE_ACCOUNT") or ""
expected = os.environ.get("EXPECTED_ACCOUNT", "")
print("db_name_is_core_dev:", db == "core_dev")
print("trading_mode_is_live:", os.environ.get("XENON_TRADING_MODE") == "live")
print("account_masked:", (acct[:1] + "..." + acct[-2:]) if acct else "MISSING")
print("account_matches_expected:", bool(expected) and acct == expected)
print("read_only_absent_or_0:", os.environ.get("XENON_READ_ONLY", "0") in ("", "0"))
print("flex_token_set:", bool(os.environ.get("IB_FLEX_TOKEN")), "nav_query_id_set:", bool(os.environ.get("IB_FLEX_NAV_QUERY_ID")))
'
Every boolean line must read True (account_masked shows the masked form). Also confirm the CLI exists: ... api sh -c 'command -v xenon-nav-flex-refresh'. Stop if any value is wrong.
F. nav-flex-refresh: manual unguarded run and evidence (REAL WRITES)
cd /opt/xenon && docker rm xenon-job-nav-flex-refresh 2>/dev/null; docker-compose -f /opt/xenon/compose.yml run --rm -T --no-deps --name xenon-job-nav-flex-refresh --label com.centurylinklabs.watchtower.enable=false api xenon-nav-flex-refresh
Effects: calls the live IB Flex web service with the live token; UPSERTs xenon.nav_history rows (source='close') and xenon.ib_cash_flow rows into the DB named by the container env (step E). Evidence the CLI ran: output lines xenon-nav-flex-refresh: mode=live account=... and fetched N NAV rows, exit code 0 (1 empty fetch, 2 not configured, 3 read-only), and NO PG persist failed line. The CLI prints "fetched N NAV rows" even when the DB write failed, so also check storage afterwards (read-only):
PGOPTIONS="-c default_transaction_read_only=on" psql -h 100.66.147.98 -U xenon_dev core_dev -v acct="$EXPECTED_ACCOUNT" -f - <<'SQL'
SELECT max(date), count(*), max(source)
FROM xenon.nav_history
WHERE broker = 'IB' AND account_env = 'live' AND broker_account = :'acct' AND source = 'close';
SQL
(Expect max(date) to be the last trading day and count greater than 0. An empty result for the scope is a failure even if the CLI exited 0.) If the Flex token is expired (spec F6, unverified) the exit code is 1.
launchctl kickstart gui/$(id -u)/com.xenon.nav-flex-refreshis NOT a test of the job: outside 17:00-17:59 New York time the guard exits 0 before Docker starts and nothing runs. Use the unguarded command above.
G. core_test refresh
- Prerequisite: the
xenon_devrole ownscore_testand itsxenonandeventsschemas (docs/runbooks/dev-prod-db-cutover.mdstep 3). That step is pending as of 2026-10-21; until it is done, the real run fails (the pre-drop and the restore need ownership). The dry run needs only thecore_devread grants. - Copy
scripts/infra/refresh-core-test.shto/opt/xenon/bin/./opt/xenon/.pgpass-refresh(chmod 600) holds thexenon_devpassword forlocalhost:5432. The script dumps only-n xenon -n events. - Dry (reads core_dev, writes nothing):
PGHOST=localhost PGUSER=xenon_dev PGPASSFILE=/opt/xenon/.pgpass-refresh /opt/xenon/bin/refresh-core-test.sh --dry. Evidence: log lineDRY: pg_dump of core_dev succeeded. - Grant check (read-only, as
xenon_devon the mini):PGHOST=localhost PGUSER=xenon_dev PGPASSFILE=/opt/xenon/.pgpass-refresh psql core_dev -c "SELECT has_table_privilege('xenon_dev','xenon.order_submissions','INSERT,UPDATE,DELETE')"must printf. - Real (REAL WRITES: DROPs the
xenonandeventsschemas in core_test and restores core_dev into it): same command without the dry argument. Evidence:=== refresh ok: core_dev -> core_test ===in/var/log/xenon/refresh-core-test.log, exit 0. The script refuses any destination that is core_dev or lacks "test", and a second concurrent run exits 3. A stale lock (crashed run) is never reclaimed automatically: the log line prints the recovery. Check the pid withps -p <pid>and that norefresh-core-test.shis running, thenrm -rf /tmp/xenon-refresh-core-test.lock.dby hand. - Same kickstart caveat: outside 04:00-04:59 New York time the plist guard makes kickstart a no-op.
H. Install schedule: sudo mkdir -p /var/log/xenon && sudo chown $(whoami) /var/log/xenon; copy both plists to ~/Library/LaunchAgents, plutil -lint, launchctl bootstrap gui/$(id -u) <plist>. Do NOT change the host time zone (Asia/Hong_Kong; shared host). Check the LaunchAgent can reach Docker: launchctl asuser $(id -u) docker ps (unverified path).
I. Recovery from a failed job start (fixed container name)
- Symptom:
Conflict. The container name "/xenon-job-nav-flex-refresh" is already in use. docker ps -a --filter name=xenon-job-nav-flex-refresh --format '{{.ID}} {{.Status}}'thendocker inspect -f '{{.State.Status}} {{.State.ExitCode}} {{.State.Error}}' xenon-job-nav-flex-refresh.running: a job is active. Leave it alone; wait for it.createdorexitedordead: readdocker logs xenon-job-nav-flex-refresh --tail 50, thendocker rm xenon-job-nav-flex-refresh(no-f).- The scheduled command already runs
docker rm xenon-job-nav-flex-refreshbefore each run.docker rmcannot remove a running container, so an active job is never touched and still blocks a second run through the name conflict.
Unverified: that docker is on the LaunchAgent PATH (/opt/homebrew/bin:/usr/local/bin are set), the real behavior of docker rm on the mini's Docker version, and the effective-settings snippet inside the real image (written from the code, not run in the image).