Files
UmbraDatastore/docs/INTEGRATION_SPEC.md
umbra audio cce2ce55dc Initial commit: UmbraDatastore configuration and deployment scripts
- Database deployment script (scripts/deploy-database.sh)
- Kubernetes manifests for CNPG PostgreSQL cluster (k8s/base/)
- Documentation (docs/DEPLOYMENT_GUIDE.md, DATASTORE_SPEC.md, INTEGRATION_SPEC.md)
- README with architecture overview and operations guide
2026-10-31 19:47:27 -07:00

739 lines
23 KiB
Markdown

# UmbraTrans Integration Specification for External Services
> **Version:** 1.0
> **Audience:** UmbraAudio, UmbraFormat teams
> **Status:** Active
> **Last Updated:** 2026-10-31
This document defines the contract for external services (UmbraAudio, UmbraFormat) to integrate with the UmbraTrans datastore. It covers PostgreSQL access, NFS shared storage, and API boundaries.
---
## 1. Architecture Overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ UMBRA ECOSYSTEM │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ UmbraTrans │ │ UmbraAudio │ │ UmbraFormat │ │
│ │ (this cluster) │◄───►│ (remote node) │◄───►│ (remote node) │ │
│ │ │ │ │ │ │ │
│ │ • Web API/UI │ │ • TTS/ASR │ │ • EPUB/PDF │ │
│ │ • Job Queue │ │ • Audio Master │ │ • Cover Gen │ │
│ │ • PostgreSQL │ │ • Speaker DB │ │ • Print Layout │ │
│ │ • NFS Server │ │ │ │ │ │
│ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │ │
│ └────────────────────────┼────────────────────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ SHARED STORE │ │
│ │ │ │
│ │ PostgreSQL │ │
│ │ (192.168.123.14)│ │
│ │ │ │
│ │ NFS │ │
│ │ (192.168.123.14)│ │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ WordPress │ (External - API only) │
│ │ (sales site) │ │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ Git Server │ (192.168.123.18 - Mini-Eq) │
│ │ (source code) │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## 2. Network Connectivity
### 2.1 Required Access from Remote Nodes
| Service | Host | Port | Protocol | Purpose |
|---------|------|------|----------|---------|
| PostgreSQL (PgBouncer) | `192.168.123.14` | `6432` | TCP | Pooled DB connections |
| PostgreSQL (Direct) | `192.168.123.14` | `5432` | TCP | Admin/backup (optional) |
| NFS | `192.168.123.14` | `2049` | TCP/UDP | Shared storage mount |
| Web API | `umbra.bdmurphy.me` | `443` | HTTPS | REST API, webhooks |
### 2.2 Firewall Rules (on 192.168.123.14)
```bash
# PostgreSQL (PgBouncer) - allow from cluster nodes
iptables -A INPUT -s 192.168.123.0/24 -p tcp --dport 6432 -j ACCEPT
# PostgreSQL (Direct) - allow from cluster nodes
iptables -A INPUT -s 192.168.123.0/24 -p tcp --dport 5432 -j ACCEPT
# NFS - allow from cluster nodes
iptables -A INPUT -s 192.168.123.0/24 -p tcp --dport 2049 -j ACCEPT
iptables -A INPUT -s 192.168.123.0/24 -p udp --dport 2049 -j ACCEPT
# Also need: 111 (rpcbind), 20048 (mountd) - check rpcinfo -p
```
### 2.3 DNS / Hosts
Add to `/etc/hosts` on remote machines (or configure DNS):
```
192.168.123.14 umbra-db umbra-nfs umbra-api
```
---
## 3. PostgreSQL Access
### 3.1 Connection Parameters
```bash
# PgBouncer (RECOMMENDED - pooled, 6432)
host=192.168.123.14
port=6432
dbname=umbratrans
user=umbraudio # or umbraformat
password=<from secret>
sslmode=require
connect_timeout=10
application_name=umbraudio-worker # or umbraformat-worker
# Direct (for admin/backup only - 5432)
host=192.168.123.14
port=5432
dbname=umbratrans
user=umbraudio
password=<from secret>
sslmode=require
```
### 3.2 Database Roles
| Role | Permissions | Used By |
|------|-------------|---------|
| `umbraudio` | `SELECT, INSERT, UPDATE` on `jobs`, `artifacts`, `compute_nodes`, `node_heartbeats`, `model_registry`, `node_models`; `SELECT` on `projects`, `organizations` | UmbraAudio workers |
| `umbraformat` | `SELECT, INSERT, UPDATE` on `jobs`, `artifacts`, `format_metadata`; `SELECT` on `projects`, `organizations` | UmbraFormat workers |
| `umbra_web` | Full access to all tables (web API) | UmbraTrans web |
### 3.3 Required Extensions
```sql
-- Already installed by migrations
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "citext";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
```
---
## 4. NFS Shared Storage
### 4.1 Mount Points (on Remote Nodes)
```bash
# /etc/fstab
192.168.123.14:/mnt/umbra-data/productions /mnt/umbra/productions nfs4 rw,hard,intr,rsize=1048576,wsize=1048576,timeo=600,retrans=2 0 0
192.168.123.14:/mnt/umbra-data/artifacts /mnt/umbra/artifacts nfs4 rw,hard,intr,rsize=1048576,wsize=1048576,timeo=600,retrans=2 0 0
192.168.123.14:/mnt/umbra-data/covers /mnt/umbra/covers nfs4 rw,hard,intr,rsize=1048576,wsize=1048576,timeo=600,retrans=2 0 0
192.168.123.14:/mnt/umbra-data/audio /mnt/umbra/audio nfs4 rw,hard,intr,rsize=1048576,wsize=1048576,timeo=600,retrans=2 0 0
```
### 4.2 Directory Permissions
```bash
# On NFS server (192.168.123.14)
mkdir -p /mnt/umbra-data/{productions,artifacts,covers,audio,backups}
chown -R 1000:1000 /mnt/umbra-data
chmod -R 700 /mnt/umbra-data
# On remote nodes - same UID/GID
groupadd -g 1000 umbra
useradd -u 1000 -g umbra -s /bin/bash umbra
```
### 4.3 Access Patterns by Service
| Path | UmbraAudio | UmbraFormat | UmbraTrans Web |
|------|------------|-------------|----------------|
| `/productions/<project>-<lang>/source/` | Read (source text) | Read (metadata) | Read/Write |
| `/productions/<project>-<lang>/runs/` | Read/Write (translation artifacts) | Read | Read |
| `/artifacts/<org>/<project>/<hash>/` | Read/Write (audio renders) | Read/Write (EPUB/PDF) | Read |
| `/covers/<asset_id>/` | Read | Read/Write (cover gen) | Read/Write |
| `/audio/<project>/<lang>/` | Read/Write (master audio) | Read | Read |
---
## 5. Job Queue Integration
### 5.1 How Workers Claim Jobs
```sql
-- Atomic job claim (run in transaction)
BEGIN;
SET LOCAL statement_timeout = 5000;
SET LOCAL app.user_id = '<node-user-id>';
SET LOCAL search_path = 'public';
UPDATE public.jobs
SET status = 'running',
lease_owner = '<compute_node_id>',
lease_expires_at = now() + interval '30 minutes',
started_at = now(),
attempt = attempt + 1
WHERE id = (
SELECT id FROM public.jobs
WHERE status = 'queued'
AND not_before <= now()
AND (lease_expires_at IS NULL OR lease_expires_at <= now())
AND kind IN ('<allowed-kinds>')
AND organization_id = '<org-id>'
ORDER BY priority, not_before, created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *;
COMMIT;
```
### 5.2 Job Kinds by Service
| Service | Job Kinds (claims) | Job Kinds (enqueues) |
|---------|-------------------|---------------------|
| **UmbraAudio** | `tts`, `asr`, `assemble`, `encode`, `master`, `speaker_profile` | `tts` (after translation), `assemble` (after tts) |
| **UmbraFormat** | `build_epub`, `build_pdf`, `cover_generate`, `metadata_validate` | `build_epub` (after translation+audio), `cover_generate` |
### 5.3 Job Payload Structure
```json
// TTS Job
{
"utterance_ids": ["utt-001", "utt-002"],
"speaker_id": "spk-narrator",
"voice_config": {
"model": "xtts-v2",
"language": "es",
"speed": 1.0
},
"output_format": "wav"
}
// EPUB Build Job
{
"project_id": "uuid",
"language": "es",
"include_audio": true,
"cover_asset_id": "asset-uuid",
"format_options": {
"epub_version": "3.2",
"drm": false,
"embedded_fonts": ["Source Serif Pro"]
}
}
```
### 5.4 Job Result Structure
```json
// TTS Result
{
"artifacts": [
{
"artifact_id": "uuid",
"kind": "audio",
"storage_uri": "file://192.168.123.14/mnt/umbra-data/audio/proj123/es/chapters/ch-001/track-001.wav",
"content_hash": "sha256...",
"byte_size": 1048576,
"media_type": "audio/wav",
"duration_ms": 5432
}
],
"metrics": {
"total_duration_ms": 5432,
"rtf": 0.15
}
}
// EPUB Build Result
{
"artifacts": [
{
"artifact_id": "uuid",
"kind": "epub",
"storage_uri": "file://192.168.123.14/mnt/umbra-data/artifacts/org1/proj123/hash123.epub",
"content_hash": "sha256...",
"byte_size": 5242880,
"media_type": "application/epub+zip"
}
],
"validation": {
"epubcheck": "passed",
"page_count": 312
}
}
```
---
## 6. Artifact Protocol (Content-Addressed Storage)
### 6.1 Writing Artifacts
```python
# 1. Compute content hash
import hashlib
content_hash = hashlib.sha256(audio_bytes).hexdigest()
# 2. Write to NFS (atomic)
artifact_path = f"/mnt/umbra/artifacts/{org_id}/{project_id}/{content_hash[:2]}/{content_hash}"
os.makedirs(os.path.dirname(artifact_path), exist_ok=True)
tmp_path = artifact_path + ".tmp"
with open(tmp_path, "wb") as f:
f.write(audio_bytes)
os.rename(tmp_path, artifact_path)
# 3. Insert artifact row (PostgreSQL)
INSERT INTO public.artifacts (
id, project_id, organization_id, lang, kind, status,
content_hash, byte_size, media_type, storage_key,
storage_uri, integrity, derivation, created_at
) VALUES (
gen_random_uuid(), $project_id, $org_id, $lang, 'audio', 'final',
$content_hash, $byte_size, 'audio/wav', $content_hash,
$storage_uri, 'unverified', $derivation_json, now()
)
ON CONFLICT (project_id, content_hash, kind, lang) DO NOTHING;
# 4. Link to job
INSERT INTO public.job_artifacts (job_id, artifact_id, role, created_at)
VALUES ($job_id, $artifact_id, 'produced', now());
```
### 6.2 Storage URI Format
```
file://<node-id>/<relative-path-from-nfs-root>
Examples:
file://umbra-audio-01/mnt/umbra-data/audio/proj123/es/chapters/ch-001/track-001.wav
file://umbra-format-01/mnt/umbra-data/artifacts/org1/proj123/ab12cd34.epub
```
### 6.3 Reading Artifacts
```python
# Via storage_uri from artifact row
storage_uri = artifact_row["storage_uri"] # "file://umbra-audio-01/mnt/umbra-data/..."
# Parse and read
if storage_uri.startswith("file://"):
# Extract path after hostname
path = storage_uri.split("/", 3)[-1] # "/mnt/umbra-data/..."
with open(path, "rb") as f:
data = f.read()
```
---
## 7. Compute Node Registration
### 7.1 Node Registration (at startup)
```sql
-- Register compute node
INSERT INTO public.compute_nodes (
id, organization_id, name, is_controller, trust_domain,
locality, transport, status, agent_version, agent_started_at, created_at
) VALUES (
$node_id, $org_id, $hostname, false, 'remote',
$datacenter, 'https', 'provisioning', $version, now(), now()
)
ON CONFLICT (organization_id, name) DO UPDATE SET
status = 'provisioning',
agent_version = EXCLUDED.agent_version,
agent_started_at = EXCLUDED.agent_started_at,
updated_at = now()
RETURNING id;
```
### 7.2 Device Registration
```sql
-- Register GPUs/CPUs
INSERT INTO public.compute_devices (
id, compute_node_id, kind, label, vram_total_mb, vram_free_mb,
ram_total_mb, ram_free_mb, compute_units, throughput_factor, supports
) VALUES (
gen_random_uuid(), $node_id, 'gpu_discrete', 'gpu:0',
24576, 24576, 0, 0, 10496, 1.0, '{"cuda": "12.4"}'
)
ON CONFLICT (compute_node_id, label) DO UPDATE SET
vram_total_mb = EXCLUDED.vram_total_mb,
vram_free_mb = EXCLUDED.vram_free_mb,
updated_at = now();
```
### 7.3 Heartbeat (every 30 seconds)
```sql
-- Update heartbeat (overwrites previous)
INSERT INTO public.node_heartbeats (
compute_node_id, reported_at, cpu_busy_pct, ram_free_mb,
vram_free_mb, vram_total_mb, disk_free_mb, load_avg_1m,
resident_models, active_jobs, agent_version, raw
) VALUES (
$node_id, now(), $cpu_pct, $ram_free, $vram_free, $vram_total,
$disk_free, $load_avg, $resident_models_json, $active_jobs, $version, $raw_json
)
ON CONFLICT (compute_node_id) DO UPDATE SET
reported_at = EXCLUDED.reported_at,
cpu_busy_pct = EXCLUDED.cpu_busy_pct,
ram_free_mb = EXCLUDED.ram_free_mb,
vram_free_mb = EXCLUDED.vram_free_mb,
vram_total_mb = EXCLUDED.vram_total_mb,
disk_free_mb = EXCLUDED.disk_free_mb,
load_avg_1m = EXCLUDED.load_avg_1m,
resident_models = EXCLUDED.resident_models,
active_jobs = EXCLUDED.active_jobs,
agent_version = EXCLUDED.agent_version,
raw = EXCLUDED.raw;
```
### 7.4 Status Transitions
```
provisioning → online → draining → offline → retired
↘️ (heartbeat stops, >2min) ↗️
```
- **online**: Heartbeat received, accepting jobs
- **draining**: Finishing current jobs, not accepting new
- **offline**: No heartbeat > 2 minutes
- **retired**: Decommissioned permanently
---
## 8. Model Registry
### 8.1 Model Registration (by controller)
```sql
INSERT INTO public.model_registry (
id, slug, kind, vram_min_mb, vram_preferred_mb, ram_min_mb,
quantisation, context_tokens, requires_accelerators,
reference_throughput, load_seconds, capabilities, source_uri, license
) VALUES (
gen_random_uuid(), 'xtts-v2', 'tts', 6144, 8192, 2048,
'fp16', 4096, ARRAY['cuda'], 1.0, 30,
'{"languages": ["en","es","fr","de","it","pt","pl","tr","ru","nl","cs","ar","zh","ja","hu","ko"]}',
'https://github.com/coqui-ai/TTS', 'CPML'
)
ON CONFLICT (slug) DO NOTHING;
```
### 8.2 Model Residency (worker reports)
```sql
-- When model loaded
INSERT INTO public.node_models (
compute_node_id, model_id, device_id, state, loaded_at, vram_held_mb
) VALUES ($node_id, $model_id, $device_id, 'loaded', now(), 7168)
ON CONFLICT (compute_node_id, model_id) DO UPDATE SET
state = EXCLUDED.state,
loaded_at = EXCLUDED.loaded_at,
vram_held_mb = EXCLUDED.vram_held_mb,
updated_at = now();
-- When model unloaded
UPDATE public.node_models SET state = 'available', vram_held_mb = 0
WHERE compute_node_id = $node_id AND model_id = $model_id;
```
---
## 9. Audio Import (UmbraAudio → UmbraTrans)
### 9.1 Bundle Export Format
UmbraAudio exports a bundle directory:
```
/mnt/umbra/productions/<project>-<lang>/bundle/
├── bundle.json # Envelope with metadata
└── manifest.jsonl # Line-delimited JSON records
```
**bundle.json:**
```json
{
"bundle_format_version": 1,
"content_hash": "sha256-of-manifest",
"source": {
"system": "umbraaudio",
"node_id": "umbra-audio-01",
"product_version": "1.2.0"
},
"scope": {
"books_full_detail": ["book-dir-name"]
}
}
```
**manifest.jsonl records:** `book`, `chapter`, `speaker`, `utterance`, `render`
### 9.2 Import Trigger
UmbraTrans imports via CLI (runs as CronJob or manual):
```bash
python -m umbratrans_web.cli import-productions \
--organization umbra \
--allow-production \
--confirm
```
This reads bundles from `/mnt/umbra/productions/*/bundle/` and populates:
- `project_source_refs`
- `translation_chapters`
- `translation_speakers`
- `translation_utterances`
- `artifacts` (audio renders)
- `translation_imports` (ledger)
---
## 10. Format Metadata API (UmbraFormat ↔ UmbraTrans)
### 10.1 Get Format Metadata
```bash
GET /api/projects/{project_id}/format-metadata?language_tag=es
Authorization: Bearer <token>
```
Response:
```json
{
"project_id": "uuid",
"language_tag": "es",
"title": "El Cazador de Ofertas",
"isbn13": "9788412345678",
"cover_front_asset_id": "asset-uuid",
"binding": "paperback",
"trim_w_in": 6.0,
"trim_h_in": 9.0,
"page_count_total": 312,
"font_primary": "Source Serif Pro",
"font_primary_size_pt": 11.0,
"line_height_pt": 14.5,
"margin_inside_in": 0.75,
"margin_outside_in": 0.625,
"margin_top_in": 0.875,
"margin_bottom_in": 0.875
}
```
### 10.2 Save Format Metadata
```bash
PUT /api/projects/{project_id}/format-metadata
Authorization: Bearer <token>
Content-Type: application/json
{
"language_tag": "es",
"isbn13": "9788412345678",
"cover_front_asset_id": "asset-uuid",
"binding": "paperback",
"trim_w_in": 6.0,
"trim_h_in": 9.0,
"page_count_total": 312,
"font_primary": "Source Serif Pro",
"font_primary_size_pt": 11.0
}
```
### 10.3 Cover Upload
```bash
POST /api/projects/{project_id}/cover?language_tag=es
Authorization: Bearer <token>
Content-Type: multipart/form-data
file: <cover-image.png>
```
---
## 11. Webhooks / Event Notifications
### 11.1 Job Completion Webhook
UmbraTrans can POST to external services on job completion:
```json
POST https://umbra-audio.internal/webhooks/job-complete
Content-Type: application/json
X-Umbra-Signature: sha256=...
{
"event": "job.completed",
"job_id": "uuid",
"kind": "tts",
"project_id": "uuid",
"organization_id": "uuid",
"status": "complete",
"result": { ... },
"artifacts": [
{ "artifact_id": "uuid", "kind": "audio", "storage_uri": "..." }
],
"completed_at": "2026-10-31T12:00:00Z"
}
```
### 11.2 Configure Webhook URLs
```sql
-- In organization or project settings
UPDATE public.organizations
SET webhook_urls = '{"job_complete": "https://umbra-audio.internal/webhooks/job-complete"}'
WHERE id = $org_id;
```
---
## 12. Credentials & Secrets
### 12.1 Service Accounts (Kubernetes)
```yaml
# umbra-audio namespace on remote cluster
apiVersion: v1
kind: Secret
metadata:
name: umbratrans-credentials
namespace: umbra-audio
type: Opaque
stringData:
DATABASE_DSN: "postgresql://umbraudio:PASS@192.168.123.14:6432/umbratrans?sslmode=require"
NFS_SERVER: "192.168.123.14"
NFS_PRODUCTIONS_PATH: "/mnt/umbra-data/productions"
NFS_ARTIFACTS_PATH: "/mnt/umbra-data/artifacts"
NODE_CREDENTIAL_PREFIX: "umbra-audio-"
```
### 12.2 Node Credentials (for job queue auth)
```sql
-- Created by UmbraTrans admin
INSERT INTO public.node_credentials (
id, compute_node_id, key_prefix, key_hash, agent_name, scopes, issued_at
) VALUES (
gen_random_uuid(), $node_id, 'umbra-audio-01-',
'$argon2id$v=19$m=65536,t=3,p=4$salt$hash',
'umbra-audio-worker', ARRAY['queue:claim', 'queue:complete', 'artifacts:write'],
now()
);
```
Worker uses this credential to authenticate job queue operations.
---
## 13. Health Checks & Monitoring
### 13.1 Required Health Endpoints
Each service exposes:
```
GET /health → { "status": "ok", "service": "umbra-audio", "version": "1.2.0" }
GET /metrics → Prometheus metrics
```
### 13.2 Key Metrics to Export
| Metric | Type | Labels |
|--------|------|--------|
| `umbra_jobs_claimed_total` | Counter | service, kind, status |
| `umbra_job_duration_seconds` | Histogram | service, kind |
| `umbra_artifact_bytes_written` | Counter | service, kind |
| `umbra_node_heartbeat_age_seconds` | Gauge | node_id |
| `umbra_gpu_utilization` | Gauge | node_id, device_id |
| `umbra_queue_depth` | Gauge | kind, status |
---
## 14. Deployment Checklist for Remote Services
### UmbraAudio
- [ ] PostgreSQL role `umbraudio` created with correct grants
- [ ] Node credentials inserted in `node_credentials`
- [ ] NFS mounts configured in `/etc/fstab`
- [ ] Compute node registered in `compute_nodes`
- [ ] Devices registered in `compute_devices`
- [ ] Models registered in `model_registry`
- [ ] Heartbeat cron running (every 30s)
- [ ] Job poller running for `tts`, `asr`, `assemble`, `encode`, `master`
- [ ] Artifact writer writing to `/mnt/umbra/artifacts/` + `artifacts` table
- [ ] Webhook endpoint for job completion (optional)
### UmbraFormat
- [ ] PostgreSQL role `umbraformat` created with correct grants
- [ ] NFS mounts configured in `/etc/fstab`
- [ ] Job poller running for `build_epub`, `build_pdf`, `cover_generate`, `metadata_validate`
- [ ] Format metadata API integrated (GET/PUT `/format-metadata`)
- [ ] Cover upload integrated (POST `/cover`)
- [ ] Artifact writer for EPUB/PDF to `/mnt/umbra/artifacts/`
- [ ] Webhook endpoint for job completion (optional)
---
## 15. Troubleshooting
| Issue | Diagnosis | Resolution |
|-------|-----------|------------|
| Job claim returns 0 rows | No queued jobs, or wrong `organization_id` | Check `SELECT * FROM jobs WHERE status='queued' AND organization_id=...` |
| Lease expires mid-job | Job takes >30min | Increase `lease_expires_at` on claim, or heartbeat extends it |
| NFS mount hangs | Network partition, server down | Use `hard,intr` options; check `rpcinfo -p 192.168.123.14` |
| SSL connection failed | Cert mismatch | Use `sslmode=require` with valid CA, or `sslmode=verify-ca` |
| Artifact conflict | Duplicate content hash | `ON CONFLICT DO NOTHING` - safe to ignore |
| Heartbeat not updating | Clock skew | Ensure NTP sync on all nodes |
---
## 16. Contact & Escalation
| Role | Contact | Channel |
|------|---------|---------|
| UmbraTrans Platform | platform@umbra.example.com | Slack #umbra-platform |
| Database Admin | dba@umbra.example.com | PagerDuty |
| Network/Infra | infra@umbra.example.com | Slack #umbra-infra |
---
## Appendix: Quick Reference Commands
```bash
# Test DB connectivity
psql "postgresql://umbraudio:PASS@192.168.123.14:6432/umbratrans?sslmode=require" -c "SELECT now();"
# Test NFS mount
mount -t nfs4 192.168.123.14:/mnt/umbra-data/productions /mnt/test && ls /mnt/test
# Check job queue
psql $DSN -c "SELECT kind, status, count(*) FROM jobs GROUP BY kind, status;"
# Check node status
psql $DSN -c "SELECT id, name, status, last_heartbeat_at FROM compute_nodes;"
# View recent artifacts
psql $DSN -c "SELECT kind, count(*), sum(byte_size) FROM artifacts GROUP BY kind;"
```