SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%

ops: mld manifest (M2U64), render/first-run scripts, backup, DEPLOY.md, stats/status commands

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 12 days ago (Sep 12, 2026) parent df0223c

7 changed files +436 −0

added deploy/company-atlas.mld.json +175 −0
@@ -0,0 +1,175 @@
1 +{
2 + "app": "company-atlas",
3 + "label": "Company Atlas — the live atlas of global companies",
4 + "domain": "www.company-atlas.co",
5 + "port": 8360,
6 + "health_path": "/api/v1/health",
7 + "dir": "~/apps/company-atlas",
8 + "extra_paths": [],
9 + "sync_excludes": [
10 + ".venv/",
11 + "__pycache__/",
12 + ".pytest_cache/",
13 + ".ruff_cache/",
14 + "*.egg-info/",
15 + ".git/",
16 + ".env",
17 + ".env.*",
18 + "!.env.example",
19 + "node_modules/",
20 + "apps/web/.next/",
21 + "apps/web/.next-*/",
22 + "apps/web/next-env.d.ts",
23 + "*.tsbuildinfo",
24 + "apps/web/qa/screens/",
25 + "/data/",
26 + "/tmp/",
27 + "logs/",
28 + ".DS_Store",
29 + ".claude/",
30 + "deploy/.admin-token",
31 + "deploy/.llm-key",
32 + "deploy/rendered/",
33 + "fixtures/live/"
34 + ],
35 + "requires": {
36 + "runtimes": [
37 + "pm2",
38 + "node",
39 + "pnpm",
40 + "uv",
41 + "uv-python@3.12"
42 + ],
43 + "ram_gb": 12,
44 + "ports": [
45 + 8360,
46 + 8361
47 + ]
48 + },
49 + "ram_mb_observed": 4000,
50 + "size_mb": 80,
51 + "placement": {
52 + "pin": "M2U64",
53 + "prefer": null,
54 + "avoid": [
55 + "M3U96b",
56 + "M1M32"
57 + ],
58 + "reason": "Mac Studio M2 Ultra 24 c / 64 Go, 53 Go libres, Postgres 17 Homebrew déjà présent (base companyatlas créée le 2026-09-12), wg1 10.67.0.12 ; crawler continu (25 000+ capteurs) + Postgres = nœud le plus généreux hors M3U96a/b"
59 + },
60 + "processes": [
61 + {
62 + "name": "company-atlas-api",
63 + "manager": "pm2",
64 + "script": "{{HOME}}/apps/company-atlas/.venv/bin/python",
65 + "args": [
66 + "-m",
67 + "uvicorn",
68 + "companyatlas.api.main:app",
69 + "--host",
70 + "127.0.0.1",
71 + "--port",
72 + "8361",
73 + "--no-access-log",
74 + "--proxy-headers",
75 + "--timeout-keep-alive",
76 + "75",
77 + "--workers",
78 + "2"
79 + ],
80 + "interpreter": null,
81 + "cwd": "{{HOME}}/apps/company-atlas",
82 + "env": {
83 + "APP_ENV": "production",
84 + "CA_SITE_URL": "https://www.company-atlas.co",
85 + "DATABASE_URL": "postgresql+asyncpg://companyatlas:companyatlas@127.0.0.1:5432/companyatlas",
86 + "CA_DATA_DIR": "{{HOME}}/company-atlas-data",
87 + "CA_API_HOST": "127.0.0.1",
88 + "CA_API_PORT": "8361",
89 + "CA_ADMIN_TOKEN": "{{ADMIN_TOKEN}}",
90 + "CA_LLM_BASE_URL": "https://www.llm-api.io/v1",
91 + "CA_LLM_API_KEY": "{{LLM_KEY}}",
92 + "CA_DB_POOL_SIZE": "6",
93 + "CA_LOG_JSON": "1",
94 + "PYTHONUNBUFFERED": "1"
95 + },
96 + "cron_restart": null,
97 + "autorestart": true,
98 + "max_memory_restart": "2G"
99 + },
100 + {
101 + "name": "company-atlas-scheduler",
102 + "manager": "pm2",
103 + "script": "{{HOME}}/apps/company-atlas/.venv/bin/catlas",
104 + "args": [
105 + "schedule"
106 + ],
107 + "interpreter": null,
108 + "cwd": "{{HOME}}/apps/company-atlas",
109 + "env": {
110 + "APP_ENV": "production",
111 + "CA_SITE_URL": "https://www.company-atlas.co",
112 + "DATABASE_URL": "postgresql+asyncpg://companyatlas:companyatlas@127.0.0.1:5432/companyatlas",
113 + "CA_DATA_DIR": "{{HOME}}/company-atlas-data",
114 + "CA_ADMIN_TOKEN": "{{ADMIN_TOKEN}}",
115 + "CA_LLM_BASE_URL": "https://www.llm-api.io/v1",
116 + "CA_LLM_API_KEY": "{{LLM_KEY}}",
117 + "CA_FETCH_CONCURRENCY": "32",
118 + "CA_ONBOARDING_CONCURRENCY": "12",
119 + "CA_DB_POOL_SIZE": "12",
120 + "CA_DB_MAX_OVERFLOW": "12",
121 + "CA_WORKER_CONCURRENCY": "1",
122 + "CA_LOG_JSON": "1",
123 + "PATH": "/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin",
124 + "PYTHONUNBUFFERED": "1"
125 + },
126 + "cron_restart": null,
127 + "autorestart": true,
128 + "max_memory_restart": "6G"
129 + },
130 + {
131 + "name": "company-atlas-web",
132 + "manager": "pm2",
133 + "script": "/opt/homebrew/bin/node",
134 + "args": [
135 + "node_modules/next/dist/bin/next",
136 + "start",
137 + "-p",
138 + "8360",
139 + "-H",
140 + "0.0.0.0"
141 + ],
142 + "interpreter": null,
143 + "cwd": "{{HOME}}/apps/company-atlas/apps/web",
144 + "env": {
145 + "NODE_ENV": "production",
146 + "API_URL": "http://127.0.0.1:8361",
147 + "NEXT_PUBLIC_SITE_URL": "https://www.company-atlas.co",
148 + "NEXT_TELEMETRY_DISABLED": "1"
149 + },
150 + "cron_restart": null,
151 + "autorestart": true,
152 + "max_memory_restart": "2G"
153 + }
154 + ],
155 + "ngrok": null,
156 + "launchd": [],
157 + "env_overrides": {},
158 + "hooks": {
159 + "post_sync": [
160 + "set -o pipefail; mkdir -p $HOME/company-atlas-data/{objects,logs,backups,cache,seed} && echo ' data dirs ok'",
161 + "set -o pipefail; export PATH=\"$HOME/.local/bin:/opt/homebrew/bin:$PATH\"; (test -x .venv/bin/python || uv venv --python 3.12 .venv) && uv pip install -q --python .venv/bin/python -e . && echo ' python deps ok'",
162 + "set -o pipefail; export PATH=\"/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:$PATH\"; export DATABASE_URL=postgresql+asyncpg://companyatlas:companyatlas@127.0.0.1:5432/companyatlas CA_DATA_DIR=$HOME/company-atlas-data CA_LOG_JSON=0; .venv/bin/catlas migrate 2>&1 | tail -1 && .venv/bin/catlas seed 2>&1 | tail -2 && echo ' migrate + seed ok'",
163 + "set -o pipefail; export PATH=\"/opt/homebrew/bin:$PATH\"; pnpm install --frozen-lockfile --silent && echo ' web deps ok'",
164 + "set -o pipefail; export PATH=\"/opt/homebrew/bin:$PATH\"; cd apps/web && API_URL=http://127.0.0.1:8361 NEXT_PUBLIC_SITE_URL=https://www.company-atlas.co NEXT_TELEMETRY_DISABLED=1 pnpm build 2>&1 | tail -3 && echo ' web build ok'"
165 + ],
166 + "post_start": []
167 + },
168 + "notes": "v0.1.0 (2026-09-12) : Next 16 :8360 (rewrites /api/v1/* → FastAPI 127.0.0.1:8361, 2 workers uvicorn), scheduler `catlas schedule` (capteurs dus + onboarding/discovery + tâches périodiques : events, enrichissement LLM via llm-api.io, métriques horaires, agrégats quotidiens, signaux, tendances, alertes, réparation, rétention, backup pg_dump). Postgres 17 Homebrew local (base companyatlas, rôle companyatlas) ; pas de Redis. Données hors repo ~/company-atlas-data (objets zstd content-addressed, logs, backups). Secrets : CA_ADMIN_TOKEN (laptop deploy/.admin-token) et CA_LLM_API_KEY (laptop deploy/.llm-key, clé llm-api.io « company-atlas »). Après le premier deploy : `bash deploy/first-run.sh` sur le nœud (onboarding de masse). DNS : www.company-atlas.co A → 51.161.112.61 (OK) ; apex company-atlas.co sans A ; company-atlas.com a ses NS chez Vercel — à repointer avant d'ajouter la route/redirection .com.",
169 + "tunnel": {
170 + "domain": "www.company-atlas.co",
171 + "gateway": "BHS64",
172 + "redirects": [],
173 + "note": "Ajouter company-atlas.co (apex) et www.company-atlas.com / company-atlas.com dans redirects dès que leurs A pointent vers 51.161.112.61."
174 + }
175 +}
added deploy/first-run.sh +25 −0
@@ -0,0 +1,25 @@
1 +#!/bin/bash
2 +# First run on the production node (run ON the node from ~/apps/company-atlas, ideally under nohup):
3 +# nohup bash deploy/first-run.sh > ~/company-atlas-data/logs/first-run.log 2>&1 &
4 +# 1. migrate + seed (idempotent) 2. mass onboarding (discovery → sensors) 3. first metrics/daily pass.
5 +# The PM2 scheduler (`catlas schedule`) keeps running in parallel: it also consumes the discovery queue and the due sensors,
6 +# so this script only accelerates the initial onboarding with a dedicated worker.
7 +set -euo pipefail
8 +cd "$(dirname "$0")/.."
9 +export PATH="$HOME/.local/bin:/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:$PATH"
10 +export DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://companyatlas:companyatlas@127.0.0.1:5432/companyatlas}
11 +export CA_DATA_DIR=${CA_DATA_DIR:-$HOME/company-atlas-data}
12 +export CA_LOG_JSON=0
13 +CATLAS=.venv/bin/catlas
14 +CONC=${CA_ONBOARDING_CONCURRENCY:-16}
15 +
16 +$CATLAS migrate | tail -1
17 +$CATLAS seed | tail -2
18 +echo "== onboarding (discovery + sensors) for every pending company, concurrency $CONC"
19 +$CATLAS onboard --concurrency "$CONC" || true
20 +echo "== first intelligence pass"
21 +$CATLAS process-changes --limit 5000 || true
22 +$CATLAS metrics --all || true
23 +$CATLAS daily --catch-up || true
24 +$CATLAS stats || true
25 +echo "== done $(date)"
added deploy/render-manifest.sh +19 −0
@@ -0,0 +1,19 @@
1 +#!/bin/bash
2 +# Render deploy/company-atlas.mld.json with the real secrets and push it to the mld gateway (M1M32:~/dispatch/apps/).
3 +# Usage: deploy/render-manifest.sh [--push]
4 +# Secrets (git-ignored): deploy/.admin-token (generated if missing), deploy/.llm-key (llm-api.io key "company-atlas"; optional).
5 +set -euo pipefail
6 +cd "$(dirname "$0")/.."
7 +TOKEN_FILE=deploy/.admin-token
8 +LLM_FILE=deploy/.llm-key
9 +[ -s "$TOKEN_FILE" ] || { openssl rand -hex 24 > "$TOKEN_FILE"; chmod 600 "$TOKEN_FILE"; echo "generated $TOKEN_FILE"; }
10 +TOKEN=$(tr -d '\n' < "$TOKEN_FILE")
11 +LLM_KEY=""
12 +[ -s "$LLM_FILE" ] && LLM_KEY=$(tr -d '\n' < "$LLM_FILE") || echo "warning: $LLM_FILE missing — LLM enrichment disabled in production"
13 +mkdir -p deploy/rendered
14 +sed -e "s/{{ADMIN_TOKEN}}/$TOKEN/g" -e "s#{{LLM_KEY}}#$LLM_KEY#g" deploy/company-atlas.mld.json > deploy/rendered/company-atlas.json
15 +python3 -c "import json; json.load(open('deploy/rendered/company-atlas.json')); print('manifest ok')"
16 +if [ "${1:-}" = "--push" ]; then
17 + scp -q deploy/rendered/company-atlas.json M1M32:~/dispatch/apps/company-atlas.json
18 + ssh M1M32 'chmod 600 ~/dispatch/apps/company-atlas.json && echo "pushed to M1M32:~/dispatch/apps/company-atlas.json"'
19 +fi
added docs/DEPLOY.md +68 −0
@@ -0,0 +1,68 @@
1 +# Deploying Company Atlas on MacLustr
2 +
3 +Production runs on **M2U64** (Mac Studio M2 Ultra, 24 c / 64 GB) behind the MacLustr Tunnel (BHS64 Caddy → WireGuard wg1 10.67.0.12).
4 +Everything goes through the gateway M1M32 and `mld` (`~/Desktop/cluster-skill/mld`).
5 +
6 +## Topology
7 +
8 +```
9 +Internet → DNS (A www.company-atlas.co → 51.161.112.61)
10 + → BHS64 Caddy (TLS Let's Encrypt) → WireGuard wg1 → M2U64:8360 (Next.js, PM2 company-atlas-web)
11 + └─ rewrite /api/v1/* → 127.0.0.1:8361 (FastAPI, 2 uvicorn workers, PM2 company-atlas-api)
12 + └─ PM2 company-atlas-scheduler (`catlas schedule`: due sensors + discovery queue + periodic tasks)
13 + └─ Postgres 17 Homebrew (db companyatlas) · ~/company-atlas-data (objects zstd, logs, backups)
14 + LLM enrichment: https://www.llm-api.io/v1 (MacLustr llm-api on M1M64, key "company-atlas") — optional, budgeted
15 +```
16 +
17 +Node prerequisites (done 2026-09-12): node 25 + pnpm + pm2 (Homebrew), `uv` + Python 3.12 (`~/.local/bin/uv`), `postgresql@17` running,
18 +role/db `companyatlas` (password `companyatlas`, localhost only), extensions `pg_trgm` + `uuid-ossp`, `~/company-atlas-data`.
19 +No Redis is required.
20 +
21 +## Domains
22 +
23 +| Host | State | Action |
24 +|---|---|---|
25 +| `www.company-atlas.co` | A → 51.161.112.61 (GoDaddy) — **canonical today** | route posed by `mld deploy` |
26 +| `company-atlas.co` | no A record | add `A @ → 51.161.112.61`, then put `company-atlas.co` in `tunnel.redirects` |
27 +| `www.company-atlas.com` / `company-atlas.com` | nameservers at **Vercel** (`ns1.vercel-dns.com`), A → Vercel IPs | point A records (or NS) at the gateway; then add both to `tunnel.redirects` (308 → www.company-atlas.co) or make `.com` canonical: `CA_SITE_URL`, `NEXT_PUBLIC_SITE_URL`, `tunnel.domain` |
28 +
29 +Caddy only issues certificates for hosts whose authoritative DNS points at the gateway — do not add a route before the DNS is in place.
30 +
31 +## Release procedure (from the laptop)
32 +
33 +```bash
34 +cd ~/Desktop/Projets/apps-web/company-atlas
35 +.venv/bin/pytest -q && .venv/bin/ruff check src tests && pnpm -r typecheck # 1. tests + typecheck
36 +deploy/render-manifest.sh --push # 2. manifest with CA_ADMIN_TOKEN + CA_LLM_API_KEY → M1M32:~/dispatch/apps/company-atlas.json
37 +~/Desktop/cluster-skill/mld stage ~/Desktop/Projets/apps-web/company-atlas company-atlas # 3. laptop → gateway staging (sync_excludes)
38 +~/Desktop/cluster-skill/mld deploy company-atlas --node M2U64 # 4. rsync → node, hooks (venv, migrate, seed, pnpm build), PM2, health, tunnel route, registry
39 +ssh M2U64 'cd ~/apps/company-atlas && nohup bash deploy/first-run.sh > ~/company-atlas-data/logs/first-run.log 2>&1 &' # 5. first time: mass onboarding
40 +curl -sI https://www.company-atlas.co | head -1 # 6. public check
41 +```
42 +
43 +`mld deploy` re-points `https://www.company-atlas.co → M2U64:8360` on BHS64 and runs the public health check. `mld heal` (every 5 min on
44 +M1M32) restarts anything missing — note it also *undoes* a manual `pm2 stop`: to pause a process, remove it from the manifest.
45 +
46 +## Operations
47 +
48 +```bash
49 +~/Desktop/cluster-skill/mld status | grep company-atlas
50 +~/Desktop/cluster-skill/mld logs company-atlas
51 +ssh M2U64 'cd ~/apps/company-atlas && export PATH=/opt/homebrew/opt/postgresql@17/bin:$PATH CA_LOG_JSON=0 DATABASE_URL=postgresql+asyncpg://companyatlas:companyatlas@127.0.0.1:5432/companyatlas CA_DATA_DIR=$HOME/company-atlas-data; .venv/bin/catlas status' # heartbeat, queue, failures
52 +ssh M2U64 '… .venv/bin/catlas stats' # dataset counters
53 +ssh M2U64 '… .venv/bin/catlas sensors --status failing --limit 50'
54 +ssh M2U64 '… .venv/bin/catlas discover https://example.com --dry-run'
55 +ssh M2U64 '… .venv/bin/catlas backup' # pg_dump → ~/company-atlas-data/backups (nightly 04:35 too)
56 +scripts/backup-offnode.sh # second copy (dumps + objects) → M1M32:~/backups/company-atlas
57 +curl -s https://www.company-atlas.co/api/v1/stats | python3 -m json.tool | head
58 +```
59 +
60 +Admin console: `https://www.company-atlas.co/admin` (token = `deploy/.admin-token`). Environment variables: `.env.example`.
61 +
62 +## Scaling
63 +
64 +- More crawl throughput: raise `CA_FETCH_CONCURRENCY` (scheduler env in the manifest) or run a second `catlas schedule --no-onboarding`
65 + on another node with `DATABASE_URL` pointing at M2U64 (Postgres must then listen on the LAN and the object store must be shared or
66 + per-node — observations reference object keys, so a per-node store needs a shared path; prefer scaling on the same node first).
67 +- LLM enrichment: any OpenAI-compatible server (`CA_LLM_BASE_URL`); budget `CA_LLM_DAILY_BUDGET`.
68 +- Storage: object store growth ≈ changed snapshots × ~15 kB (zstd). 373 GB free on M2U64 at launch.
added scripts/backup-offnode.sh +11 −0
@@ -0,0 +1,11 @@
1 +#!/bin/bash
2 +# Second independent copy of the historical archive (spec §154–155): pg_dump files + object store from the production node to the
3 +# gateway M1M32 (~/backups/company-atlas). Run from the laptop (or cron on M1M32). Incremental rsync; never deletes on the target.
4 +# Usage: scripts/backup-offnode.sh [NODE=M2U64] [TARGET=M1M32]
5 +set -euo pipefail
6 +NODE=${1:-M2U64}
7 +TARGET=${2:-M1M32}
8 +ssh "$NODE" 'cd ~/apps/company-atlas && export PATH=/opt/homebrew/opt/postgresql@17/bin:$PATH CA_DATA_DIR=$HOME/company-atlas-data DATABASE_URL=postgresql+asyncpg://companyatlas:companyatlas@127.0.0.1:5432/companyatlas && .venv/bin/catlas backup --keep 14 | tail -1'
9 +NODE_IP=$(ssh "$NODE" 'ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1')
10 +ssh "$TARGET" "mkdir -p ~/backups/company-atlas/{backups,objects} && rsync -a --ignore-existing simon-pierreboucher@${NODE_IP}:company-atlas-data/backups/ ~/backups/company-atlas/backups/ && rsync -a --ignore-existing simon-pierreboucher@${NODE_IP}:company-atlas-data/objects/ ~/backups/company-atlas/objects/ && du -sh ~/backups/company-atlas/*"
11 +echo "off-node copy on $TARGET:~/backups/company-atlas done $(date)"
added src/companyatlas/commands/ops.py +136 −0
@@ -0,0 +1,136 @@
1 +"""Operations commands: `catlas stats`, `catlas status`, `catlas backup`."""
2 +from __future__ import annotations
3 +
4 +import json
5 +import os
6 +import subprocess
7 +import time
8 +from datetime import UTC, datetime
9 +from pathlib import Path
10 +
11 +import typer
12 +from rich.table import Table
13 +
14 +from companyatlas.config import settings
15 +
16 +
17 +def register(app: typer.Typer) -> None:
18 + app.command()(stats)
19 + app.command()(status)
20 + app.command()(backup)
21 +
22 +
23 +def stats() -> None:
24 + """Dataset counters (companies, sensors, observations, changes, events, archive)."""
25 + from companyatlas.archive import store_stats
26 + from companyatlas.cli import out, run_async
27 + from companyatlas.db import connection, fetch_one
28 +
29 + async def go(): # type: ignore[no-untyped-def]
30 + async with connection() as conn:
31 + return await fetch_one(conn, """
32 + select (select count(*) from companies) as companies,
33 + (select count(*) from companies where onboarding_status = 'active') as companies_active,
34 + (select count(*) from sensors) as sensors,
35 + (select count(*) from sensors where status = 'active') as sensors_active,
36 + (select count(*) from observations) as observations,
37 + (select count(*) from snapshots) as snapshots,
38 + (select count(*) from changes) as changes,
39 + (select count(*) from changes where kind in ('meaningful','major','critical')) as meaningful_changes,
40 + (select count(*) from events where status = 'active') as events,
41 + (select count(*) from jobs where status = 'open') as jobs_open,
42 + (select count(*) from observations where fetched_at > now() - interval '24 hours') as observations_24h,
43 + (select count(*) from events where detected_at > now() - interval '24 hours' and status = 'active') as events_24h,
44 + (select value from settings_kv where key = 'dataset_started_at') as dataset_started_at
45 + """)
46 +
47 + row = run_async(go()) or {}
48 + row["archive"] = store_stats()
49 + started = row.get("dataset_started_at")
50 + if started:
51 + try:
52 + dt = datetime.fromisoformat(str(started).strip('"').replace("Z", "+00:00"))
53 + row["dataset_age_days"] = (datetime.now(UTC) - dt).days
54 + except ValueError:
55 + pass
56 + out.print(json.dumps(row, default=str, indent=1))
57 +
58 +
59 +def status() -> None:
60 + """Scheduler heartbeat, queue depth, failures by class (last 24 h), periodic task health."""
61 + from companyatlas.cli import out, run_async
62 + from companyatlas.db import connection, fetch_all, fetch_one
63 +
64 + async def go(): # type: ignore[no-untyped-def]
65 + async with connection() as conn:
66 + hb = await fetch_one(conn, "select value, updated_at from settings_kv where key = 'scheduler:heartbeat'")
67 + due = await fetch_one(conn, """select count(*) filter (where next_run_at <= now()) as due, count(*) as total,
68 + count(*) filter (where status = 'failing') as failing, count(*) filter (where status = 'stale') as stale,
69 + count(*) filter (where status = 'blocked') as blocked, count(*) filter (where status = 'retired') as retired
70 + from sensors where status <> 'retired' or status = 'retired'""")
71 + queue = await fetch_all(conn, "select kind, status, count(*) as n from queue_jobs group by 1, 2 order by 1, 2")
72 + fails = await fetch_all(conn, "select failure_class, count(*) as n from failures where at > now() - interval '24 hours' group by 1 order by 2 desc")
73 + onboarding = await fetch_all(conn, "select onboarding_status, count(*) as n from companies group by 1 order by 2 desc")
74 + return hb, due, queue, fails, onboarding
75 +
76 + hb, due, queue, fails, onboarding = run_async(go())
77 + if hb:
78 + age = (datetime.now(UTC) - hb["updated_at"]).total_seconds() if hb.get("updated_at") else None
79 + out.print(f"[bold]scheduler[/] heartbeat {int(age)}s ago" if age is not None else "[bold]scheduler[/] heartbeat unknown")
80 + val = hb["value"] if isinstance(hb["value"], dict) else json.loads(hb["value"])
81 + out.print(f" worker={val.get('worker')} inflight={val.get('inflight')} due={val.get('due')} tick_ms={val.get('tick_ms')}")
82 + for t in val.get("tasks") or []:
83 + out.print(f" task {t.get('name'):<22} runs={t.get('runs')} failures={t.get('failures')} err={t.get('last_error') or '-'}")
84 + else:
85 + out.print("[yellow]scheduler[/] no heartbeat yet")
86 + out.print(f"[bold]sensors[/] {due}")
87 + t = Table(title="onboarding")
88 + t.add_column("status")
89 + t.add_column("n", justify="right")
90 + for r in onboarding:
91 + t.add_row(str(r["onboarding_status"]), str(r["n"]))
92 + out.print(t)
93 + t = Table(title="queue")
94 + for c in ("kind", "status", "n"):
95 + t.add_column(c)
96 + for r in queue:
97 + t.add_row(r["kind"], r["status"], str(r["n"]))
98 + out.print(t)
99 + t = Table(title="failures 24h")
100 + t.add_column("class")
101 + t.add_column("n", justify="right")
102 + for r in fails:
103 + t.add_row(str(r["failure_class"]), str(r["n"]))
104 + out.print(t)
105 +
106 +
107 +def backup(keep: int = typer.Option(14, help="number of dumps to keep")) -> None:
108 + """pg_dump (custom format, compressed) into CA_DATA_DIR/backups; prunes old dumps. Copy off-node with scripts/backup-offnode.sh."""
109 + from companyatlas.cli import out
110 +
111 + settings.ensure_dirs()
112 + ts = time.strftime("%Y%m%d-%H%M%S")
113 + target = settings.backups_dir / f"companyatlas-{ts}.dump"
114 + env = dict(os.environ)
115 + cmd = ["pg_dump", "--format=custom", "--compress=zstd:6", "--no-owner", "--dbname", settings.sync_database_url, "--file", str(target)]
116 + try:
117 + subprocess.run(cmd, check=True, env=env, timeout=3600)
118 + except FileNotFoundError:
119 + cmd[0] = "/opt/homebrew/opt/postgresql@17/bin/pg_dump"
120 + subprocess.run(cmd, check=True, env=env, timeout=3600)
121 + dumps = sorted(Path(settings.backups_dir).glob("companyatlas-*.dump"))
122 + for old in dumps[:-keep]:
123 + old.unlink(missing_ok=True)
124 + out.print(f"[green]backup ok[/] {target} ({target.stat().st_size // 1024} kB), kept {min(len(dumps), keep)}")
125 +
126 +
127 +try: # register the nightly backup as a periodic task when the scheduler imports this module
128 + from companyatlas.services.periodic import periodic
129 +
130 + @periodic("backup-nightly", cron=settings.backup_cron)
131 + async def _backup_task() -> None:
132 + import asyncio
133 +
134 + await asyncio.to_thread(backup, 14)
135 +except Exception: # noqa: BLE001
136 + pass
modified src/companyatlas/services/periodic.py +2 −0
@@ -72,6 +72,8 @@ TASK_MODULES: list[str] = [
72 72 "companyatlas.services.llm.enrich",
73 73 "companyatlas.services.signals",
74 74 "companyatlas.services.trends",
75 + "companyatlas.services.repair",
76 + "companyatlas.commands.ops",
75 77 ]
76 78
77 79
78 80