This directory holds the VM-side counterpart to the admin Engine console in the web
app. The Oracle Cloud (OCI) VM runs the AssetFrame engine (scripts/scheduler/run/run_daily.py) on a
schedule and on demand, and reports status back to the web app.
The VM has NO inbound ports — it never accepts a connection. It coordinates with the web app only through three Neon tables, all over outbound Postgres/HTTPS:
| Table | VM does | Web app does |
|---|---|---|
generation_requests |
polls + claims + writes status/run_id/error | enqueues a manual scoped run, can set cancel_requested |
engine_runs |
inserts one row per run; updates status/results/log | reads for live run status + history |
engine_state (singleton id=1) |
heartbeats last_heartbeat_at, sets current_run_id |
reads heartbeat ("online?"), sets automation_paused |
Two processes do the work, both via scripts/coordination/engine_ops.py (the shared DB + run layer):
scripts/scheduler/service/poller.py— long-lived systemd service. Every 30s: heartbeat → claim the oldest queuedgeneration_requestsrow → run it (trigger='manual'). Manual requests run even whenautomation_pausedis true — enqueuing is an explicit admin action.scripts/scheduler/service/scheduled_run.py— systemd oneshot, fired by a timer at 04:00 UTC. Heartbeat → ifautomation_paused, log + record a skip + exit 0; else run the full due batch (trigger='schedule', scope{all_due:true}→run_daily.py --mode production).
run_daily.py is serialised behind a file lock (<repo>/.run.lock) so the timer and the
poller never run it concurrently. Cancellation is co-operative: the web app sets
generation_requests.cancel_requested; an in-flight run polls it and terminates.
- In the OCI console → Compute → Instances → Create instance.
- Shape: VM.Standard.A1.Flex (Ampere ARM, Always-Free eligible). 2 OCPU / 12 GB is plenty; 1 OCPU / 6 GB works.
- Image: Canonical Ubuntu 24.04 (ships Python 3.12).
- Add your SSH public key.
- Networking: a public subnet is fine. Do NOT open any inbound ports beyond the default SSH (22) for your own admin access. The engine needs outbound only (443 to Neon, R2, and the market-data provider). You may even close inbound 22 after setup and use the OCI serial console / Cloud Shell.
- Create, then SSH in:
ssh ubuntu@<public-ip>.
Always-Free ARM capacity can be scarce by region. If "out of capacity", retry or pick a different availability domain/region.
sudo apt-get update
sudo apt-get install -y python3.12 python3.12-venv python3-pip git tzdata curl
# Node 20 (only for scripts/sync-db.mjs, the Neon publish helper)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
# Pin the VM clock to UTC so the 04:00 UTC timer is unambiguous.
sudo timedatectl set-timezone UTCVerify: python3.12 --version (3.12.x), node --version (v20.x), git --version,
timedatectl (should show UTC).
We deploy to /opt/assetframe-scripts (the path baked into the systemd units; change all
three units if you use a different path). Run the engine as the ubuntu user.
sudo mkdir -p /opt/assetframe-scripts
sudo chown ubuntu:ubuntu /opt/assetframe-scripts
git clone https://github.com/JoeWat2005/assetframe-scripts.git /opt/assetframe-scripts
cd /opt/assetframe-scripts(If you cannot use HTTPS, add the deploy key from step 6 first, then clone over SSH:
git@github.com:JoeWat2005/assetframe-scripts.git.)
cd /opt/assetframe-scripts
python3.12 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt # fpdf2, pymupdf, anthropic, psycopg[binary]
npm install # @neondatabase/serverless for sync-db.mjsrequirements.txt now includes psycopg[binary] (psycopg3) — the poller's Postgres
client. (The web app's sync-db.mjs uses the Node Neon driver; the Python engine had no
PG client before this runner.)
The engine reads DATABASE_URL (and the rest) from the environment, falling back to
<repo>/.env. systemd loads this same file via EnvironmentFile=. Never commit it.
cd /opt/assetframe-scripts
umask 077
cat > .env <<'EOF'
# Cloudflare R2 (publish.py uploads Pro files here)
R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=assetframe-pro
# Neon Postgres — the PROD url (the engine writes, the web app reads).
DATABASE_URL=postgresql://...neon.tech/neondb?sslmode=require&channel_binding=require
# Market data — yahoo (keyless default) or eodhd (+ key). Futures =F always use Yahoo.
ADVISOR_DATA_PROVIDER=yahoo
# EODHD_API_KEY=...
# Anthropic — required only when ASSETFRAME_AUTHOR_BRIEFS is on (autonomous brief authoring).
ANTHROPIC_API_KEY=...
EOF
chmod 600 .env
EnvironmentFileparses simpleKEY=VALUElines (no shell expansion). Keep theDATABASE_URLon one line and unquoted, exactly as above.
Sanity check the DB wiring before installing services:
.venv/bin/python -c "import scripts.coordination.engine_ops as e; print('DATABASE_URL ok:', bool(e.database_url()))"
# Then a single real tick (heartbeats + claims one request if queued, else no-op):
.venv/bin/python -m scripts.scheduler.service.poller --onceIf DATABASE_URL is missing you get a clear ConfigError, not a stack trace.
The engine appends scored outcomes to ledger/outcome_ledger.csv and must push them back.
Give this VM its own deploy key with write access.
ssh-keygen -t ed25519 -C "assetframe-oci-runner" -f ~/.ssh/assetframe_deploy -N ""
cat ~/.ssh/assetframe_deploy.pub- GitHub → repo assetframe-scripts → Settings → Deploy keys → Add deploy key → paste the public key → Allow write access → Add.
- Point git at the key and use the SSH remote:
cat >> ~/.ssh/config <<'EOF'
Host github-assetframe
HostName github.com
User git
IdentityFile ~/.ssh/assetframe_deploy
IdentitiesOnly yes
EOF
chmod 600 ~/.ssh/config
cd /opt/assetframe-scripts
git remote set-url origin git@github-assetframe:JoeWat2005/assetframe-scripts.git
git config user.name "AssetFrame OCI runner"
git config user.email "engine@assetframe.local"
ssh -T git@github-assetframe # expect the GitHub "successfully authenticated" bannerThe runner scripts here do not auto-commit; wire the ledger push into your publish/commit step. This key is what makes that push possible from a VM with no interactive login.
cd /opt/assetframe-scripts
sudo cp deploy/assetframe-poller.service /etc/systemd/system/
sudo cp deploy/assetframe-daily.service /etc/systemd/system/
sudo cp deploy/assetframe-daily.timer /etc/systemd/system/If you deployed somewhere other than /opt/assetframe-scripts or run as a user other
than ubuntu, edit the three units first (the WorkingDirectory, EnvironmentFile, and
ExecStart paths; add User=/Group= if not running as root-installed ubuntu).
sudo systemctl daemon-reload
sudo systemctl enable --now assetframe-poller.service assetframe-daily.timer(The .service for the daily run is not enabled directly — the timer drives it.)
# The poller should be active and heartbeating every 30s.
systemctl status assetframe-poller.service
journalctl -u assetframe-poller -f # watch live ticks
# The timer should be scheduled with a next-run at the upcoming 04:00 UTC.
systemctl list-timers assetframe-daily.timer
# Optional: fire a scheduled run right now to prove the path end-to-end.
sudo systemctl start assetframe-daily.service
journalctl -u assetframe-daily -eIn the web app's admin Engine console, the VM should flip to "online" within ~30s
(that is the first heartbeat landing in engine_state.last_heartbeat_at). Enqueue a manual
scoped run from the console and watch the poller claim it (journalctl -u assetframe-poller -f) and a row appear in engine_runs.
- Pause/resume automation from the web app — it sets
engine_state.automation_paused. The 04:00 timer respects it (logs "automation paused, skipping" and records a skip row). Manual console requests still run while paused — that is intentional. - Cancel a run from the console — it sets
cancel_requested; an in-flight run polls it (~every 5s) and terminates → statuscancelled. - Logs:
journalctl -u assetframe-poller -f(live) and-u assetframe-daily -e(last scheduled run). Run artifacts:<repo>/runs/<date>/run_manifest.json. - No inbound ports are ever required. If the console shows "offline", check outbound
network and
journalctl -u assetframe-poller— the loop logs DB errors and keeps going, so a persistent offline state usually means a badDATABASE_URLor no network. - Concurrency: the timer and poller share
<repo>/.run.lock; if a run is already in progress, the other path recordsfailed: another run is already in progressrather than double-running the engine. - Update the engine:
cd /opt/assetframe-scripts && git pull && .venv/bin/pip install -r requirements.txt && sudo systemctl restart assetframe-poller.service(the timer picks up the new code on its next fire automatically).
.github/workflows/ci.yml has a deploy job that runs after the tests pass, only on push
to main, and only when DEPLOY_ENABLED=true. It runs ON this box via a self-hosted
runner, so the VM stays inbound-portless (the runner dials out to GitHub). It fast-forwards the
checkout(s) and restarts the poller(s). To enable:
- Install a self-hosted runner — GitHub → repo Settings → Actions → Runners → New self-hosted
runner → Linux / ARM64. Run the shown commands as
opc, adding the label, and install as a service so it survives reboots:./config.sh --url https://github.com/JoeWat2005/assetframe-scripts --token <TOKEN> --labels assetframe --unattended sudo ./svc.sh install && sudo ./svc.sh start
- Let the runner restart the poller(s) without a password:
echo 'opc ALL=(root) NOPASSWD: /usr/bin/systemctl restart assetframe-poller.service, /usr/bin/systemctl restart assetframe-poller-dev.service' | sudo tee /etc/sudoers.d/assetframe-deploy sudo chmod 440 /etc/sudoers.d/assetframe-deploy
- Set repo variable
DEPLOY_ENABLED=true(Settings → Secrets and variables → Actions → Variables). Now every push tomainruns the tests and, if green, deploys here.
Self-hosted runners are safe on a private repo only — keep
assetframe-scriptsprivate (a public repo would let fork PRs run code on the box).
Run a second, fully isolated worker so the preview site's admin console can generate into
the dev database without touching prod. Engine + web both read DATABASE_URL / R2_BUCKET from
the environment, so it's config-only — no code change. Three isolation requirements:
- Separate checkout
/opt/assetframe-scripts-dev— its own run-lock,runs/, and ledger (the lock is per-directory; a shared dir would make dev/prod runs fail each other or pollute the prod ledger). - Separate R2 bucket
assetframe-pro-dev— so a dev report never overwrites a prod file. - Vercel Preview env →
R2_BUCKET=assetframe-pro-dev(it already uses the devDATABASE_URL).
# 1. Create the R2 bucket `assetframe-pro-dev` in Cloudflare. Make sure your R2 token is
# account-scoped (not bucket-scoped) so it can write both buckets.
# 2. Second checkout + venv + deps:
sudo mkdir -p /opt/assetframe-scripts-dev && sudo chown -R opc:opc /opt/assetframe-scripts-dev
git clone https://github.com/JoeWat2005/assetframe-scripts.git /opt/assetframe-scripts-dev
cd /opt/assetframe-scripts-dev
git checkout development # the dev folder tracks the `development` branch (prod tracks `main`)
python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt && npm install
# 3. Dev .env: same R2 keys + ANTHROPIC_API_KEY as prod, but the DEV Neon URL and the dev bucket:
# DATABASE_URL=postgresql://...@ep-twilight-bonus-...neon.tech/neondb?sslmode=require&channel_binding=require
# R2_BUCKET=assetframe-pro-dev
chmod 600 .env
# 4. Install + start the dev poller (runs as opc; NO daily timer — dev is on-demand):
sudo cp deploy/assetframe-poller-dev.service /etc/systemd/system/
sudo sed -i '/^\[Service\]/a User=opc\nGroup=opc' /etc/systemd/system/assetframe-poller-dev.service
sudo systemctl daemon-reload
sudo systemctl enable --now assetframe-poller-dev.service
# 5. In Vercel: set the Preview environment R2_BUCKET=assetframe-pro-dev and redeploy preview.The dev poller heartbeats the dev DB's engine_state, so the preview admin console shows it
online and its "Generate now" queues into the dev DB. The auto-deploy job updates this checkout
automatically once it exists.