RunFlow

Docker-based async job execution. Submit a container, get a track_id, poll for results.

REST API at https://runflow.rodmena.app — Bearer token = your API key (whitelisted in auth.rodmena.app)

Authentication #auth

RunFlow uses a two-tier auth model backed by auth.rodmena.app:

You cannot use a random UUID — only keys approved by the service admin (via auth.rodmena.app) will work. The API key also serves as the tenant isolation boundary: all runs, logs, and metadata are scoped to the key's identity.

Authorization: Bearer <your-api-key>

RBAC permissions are granted per API key in auth.rodmena.app:

runflow_run_submit   # POST /api/v1/runs
runflow_run_read     # GET /api/v1/runs, /{track_id}, /result, /logs, /utilization, /tenant/quotas
runflow_run_control # POST /pause, /resume, /stop, /kill
runflow_run_delete   # DELETE /api/v1/runs/{track_id}
runflow_tenant_admin # PUT /api/v1/tenant/quotas
Getting an API key: Contact the service admin to register your key in auth.rodmena.app. You'll receive a UUIDv4 API key with the permissions you need. Use it as the Bearer token in all requests.
curl https://runflow.rodmena.app/api/v1/tenant/quotas \
  -H "Authorization: Bearer $API_KEY"

Quick Start #quickstart

Three steps: check health, submit a run, fetch the result + logs.

1

Check health

No auth required. Returns service + dependency status.

curl https://runflow.rodmena.app/health
# {"status":"healthy","postgres":"up","redis":"up","docker":"up","workers":{"total":4,"busy":0}}
2

Submit a hello-world Docker run

API_KEY=ead69607-06c3-4321-916a-c41636349815   # your API key (registered in auth.rodmena.app)
curl -X POST https://runflow.rodmena.app/api/v1/runs \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "image": "python:3.12-slim",
    "command": ["python", "-c", "print(\"hello from runflow\")"],
    "timeout_seconds": 60
  }'
# {"track_id":"01KY0XX71BMX0B88C3A189S2K7","status":"scheduled","eligible_start":"2026-07-20T23:31:17Z","clamped":false}
3

Poll status, then fetch result + logs

TRACK=01KY0XX71BMX0B88C3A189S2K7

# status — poll until state is terminal
curl -s https://runflow.rodmena.app/api/v1/runs/$TRACK \
  -H "Authorization: Bearer $API_KEY" | python3 -m json.tool

# result — available when state is "exited" / "failed" / "timeout" / "killed"
curl -s https://runflow.rodmena.app/api/v1/runs/$TRACK/result \
  -H "Authorization: Bearer $API_KEY" | python3 -m json.tool

# logs — stdout only here; use stream=both for both
curl -s "https://runflow.rodmena.app/api/v1/runs/$TRACK/logs?stream=stdout" \
  -H "Authorization: Bearer $API_KEY" | python3 -m json.tool

Examples #examples

4a. Hello-world Docker run

Minimal run: pull python:3.12-slim, run a one-liner, exit.

curl -X POST https://runflow.rodmena.app/api/v1/runs \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "image": "python:3.12-slim",
    "command": ["python", "-c", "print(\"hello from runflow\")"],
    "timeout_seconds": 60
  }'

4b. Python script with env vars (useful)

A run that takes an env var and does something useful — fetches a URL and prints the HTTP status + content-type. The env dict is injected into the container as environment variables.

curl -X POST https://runflow.rodmena.app/api/v1/runs \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "image": "python:3.12-slim",
    "command": ["python", "-c", "import os,urllib.request; r=urllib.request.urlopen(os.environ[\"TARGET_URL\"]); print(r.status, r.headers[\"content-type\"])"],
    "env": {"TARGET_URL": "https://api.github.com/repos/python/cpython"},
    "timeout_seconds": 30,
    "tags": ["probe", "github"]
  }'

The tags array is free-form labels for filtering when listing runs. The env values are strings only.

4c. Useful work — data processing with resource limits

A bigger run with CPU/memory limits and a name for easy identification. Note: stdin is not supported — pipe data via env vars or bake it into the command. The container's command reads from env/args, not stdin.

curl -X POST https://runflow.rodmena.app/api/v1/runs \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "image": "python:3.12-slim",
    "command": ["python", "-c", "import json,os; data={\"a\":1,\"b\":2,\"c\":3}; print(json.dumps({\"count\":len(data),\"keys\":list(data.keys()),\"sum\":sum(data.values())}))"],
    "env": {"PYTHONDONTWRITEBYTECODE": "1"},
    "timeout_seconds": 120,
    "cpu_limit": 1.0,
    "memory_limit_mb": 512,
    "name": "summarize-input",
    "tags": ["etl", "summary"]
  }'

Requested cpu_limit / memory_limit_mb / timeout_seconds are clamped to your tenant quotas. If clamped, the response has "clamped": true.

4d. Scheduled start (delay a run)

Omit scheduled_start for ASAP. Provide an ISO-8601 timestamp to run later.

curl -X POST https://runflow.rodmena.app/api/v1/runs \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "image": "alpine:3.20",
    "command": ["echo", "scheduled run fired"],
    "scheduled_start": "2026-07-22T09:00:00Z",
    "timeout_seconds": 30
  }'

4e. Control: pause, resume, stop, kill

# pause a running container
curl -X POST https://runflow.rodmena.app/api/v1/runs/$TRACK/pause \
  -H "Authorization: Bearer $API_KEY"

# resume
curl -X POST https://runflow.rodmena.app/api/v1/runs/$TRACK/resume \
  -H "Authorization: Bearer $API_KEY"

# graceful stop (SIGTERM + grace period)
curl -X POST https://runflow.rodmena.app/api/v1/runs/$TRACK/stop \
  -H "Authorization: Bearer $API_KEY"

# force kill (SIGKILL)
curl -X POST https://runflow.rodmena.app/api/v1/runs/$TRACK/kill \
  -H "Authorization: Bearer $API_KEY"

Scheduling #scheduling

RunFlow uses a reactive scheduling model. When you submit a run, it gets an eligible_start (the earliest time it's allowed to start, based on scheduled_start and the tenant's working-hours calendar). The scheduler then dispatches eligible runs to free workers every 1 second. If no worker is free, the run waits and retries — it is never rejected for capacity reasons.

Contract change (v2): Concurrent quota no longer rejects submissions with 429. Instead, runs are accepted and queued. Only max_daily_runs still triggers an immediate 429. If you depended on 429-at-quota as backpressure, poll scheduling_info.queue_position instead.

How eligible_start is computed

eligible_start = working_hours_clamp(max(scheduled_start, now))

Capacity (worker pool, concurrent quota) is not considered at submit time. A run at the concurrent quota limit is accepted and waits in scheduled state until a worker frees up. If it waits longer than max_queue_seconds past eligible_start, it transitions to failed:queue_timeout.

Working hours calendar

Set working_hours_tjp on the tenant's quotas to restrict when runs may start. The value is a TJP (TaskJuggler) shift definition. Runs are only dispatched if their eligible_start falls within the shift; if you submit at 18:30 with a Mon-Fri 09:00-18:00 shift, eligible_start advances to Monday 09:00.

# Set working hours via PUT /api/v1/tenant/quotas
curl -X PUT https://runflow.rodmena.app/api/v1/tenant/quotas \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "working_hours_tjp": "shift shift_def \"Business Hours\" { workinghours mon - fri 09:00 - 18:00 }"
  }'

# Now submit on Friday 17:55 (on-shift) -\u003e eligible_start = Friday 17:55
curl -X POST https://runflow.rodmena.app/api/v1/runs \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "image": "python:3.12-slim",
    "command": ["python", "-c", "print(\"within hours\")"],
    "scheduled_start": "2025-07-18T17:55:00Z"
  }'

# Submit on Friday 18:30 (off-shift) -\u003e eligible_start = Monday 09:00
curl -X POST https://runflow.rodmena.app/api/v1/runs \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "image": "python:3.12-slim",
    "command": ["python", "-c", "print(\"after hours\")"],
    "scheduled_start": "2025-07-18T18:30:00Z"
  }'

scheduling_info in run status

Every GET /api/v1/runs/{track_id} response includes a scheduling_info object:

{
  "scheduling_info": {
    "eligible_start": "2026-07-21T10:00:00Z",
    "working_hours_applied": false,
    "queue_position": 3,
    "estimated_start": "2026-07-21T10:00:30Z"
  }
}

What happens when the pool is full

# 4 workers, 4 runs already running, submit a 5th
curl -X POST https://runflow.rodmena.app/api/v1/runs \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"image": "python:3.12-slim", "command": ["python", "-c", "print(5)"]}'

# Response: 202 Accepted (NOT 429)
{"track_id": "01KY...", "status": "scheduled", "eligible_start": "2026-07-21T..."}

# Check scheduling_info to see queue position
curl -s https://runflow.rodmena.app/api/v1/runs/01KY... \
  -H "Authorization: Bearer $API_KEY" | python3 -m json.tool
# "scheduling_info": {"queue_position": 1, "estimated_start": "..."}

# When a running run exits, the 5th run is dispatched within ~1s

Quotas and limits

Per-tenant quotas control resource usage. max_concurrent_runs limits how many runs can be in {pulling, running, paused} simultaneously — enforced at dispatch time, not submit time. max_daily_runs limits submissions per UTC day — enforced at submit time with 429. Resource limits (cpu_limit, memory_limit_mb, timeout_seconds) are clamped to the tenant max and the response includes "clamped": true if any were reduced.

# Check your quotas
curl https://runflow.rodmena.app/api/v1/tenant/quotas \
  -H "Authorization: Bearer $API_KEY"

# Update quotas (requires runflow_tenant_admin permission)
curl -X PUT https://runflow.rodmena.app/api/v1/tenant/quotas \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "max_concurrent_runs": 5,
    "max_daily_runs": 200,
    "max_timeout_seconds": 3600,
    "max_queue_seconds": 600
  }'

Run Lifecycle #lifecycle

Every run moves through these states. Poll GET /api/v1/runs/{track_id} until state is terminal.

scheduled -> queued -> pulling -> running -> exited | failed | timeout | killed | v paused -> (resume) -> running
StateCategoryMeaning
scheduledactiveSubmitted, waiting for eligible_start or a free worker slot.
queuedactiveSlot acquired, waiting in the worker queue.
pullingactiveDocker is pulling the image.
runningactiveContainer is executing.
pausedactiveContainer paused via POST /pause. Resume with /resume.
exitedterminalContainer exited 0 or was stopped gracefully.
failedterminalContainer exited non-zero, or the worker failed to start it.
timeoutterminalRun exceeded timeout_seconds and was killed.
killedterminalRun force-killed via POST /kill.

Control endpoints: POST /pause, POST /resume, POST /stop (graceful SIGTERM + grace), POST /kill (SIGKILL). All return 204 No Content on success.

Endpoint Reference #endpoints

MethodPathDescriptionPermissionNotes
GET/pingLiveness probeNo auth. Returns {"message":"PONG"}
GET/healthReadiness probeNo auth. Returns service + dependency status JSON
GET/metricsPrometheus expositionNo auth, IP-restricted to private networks. text/plain
POST/api/v1/runsSubmit a runrunflow_run_submitBody: RunSubmitRequest. Returns RunSubmitResponse (202)
GET/api/v1/runsList runsrunflow_run_readQuery: state, tag, limit (default 100, max 1000), offset
GET/api/v1/runs/{track_id}Run statusrunflow_run_readReturns RunStatus
GET/api/v1/runs/{track_id}/resultRun resultrunflow_run_readReturns RunResult. Only available when state is terminal
GET/api/v1/runs/{track_id}/logsLogsrunflow_run_readQuery: stream=stdout|stderr|both (default both), since=N (offset; null=tail last N), limit (default 1000, max 1000). Returns LogResponse
GET/api/v1/runs/{track_id}/utilizationCPU/mem/net samplesrunflow_run_readQuery: from, to (ISO-8601). Returns UtilizationResponse
POST/api/v1/runs/{track_id}/pausePauserunflow_run_controlReturns 204
POST/api/v1/runs/{track_id}/resumeResumerunflow_run_controlReturns 204
POST/api/v1/runs/{track_id}/stopGraceful stop (SIGTERM + grace)runflow_run_controlReturns 204
POST/api/v1/runs/{track_id}/killForce kill (SIGKILL)runflow_run_controlReturns 204
GET/api/v1/tenant/quotasTenant quotasrunflow_run_readReturns TenantQuotas

Request / Response Models #models

RunSubmitRequest — POST /api/v1/runs body

{
  "image": "python:3.12-slim",          // required, Docker image ref (host/path[:tag|@digest])
  "command": ["python", "-c", "..."],   // default [], command args
  "env": {"KEY": "value"},              // default {}, injected as container env vars (string values only)
  "scheduled_start": null,              // ISO-8601 or null (ASAP)
  "timeout_seconds": null,             // null=use default; clamped to quota max_timeout_seconds
  "cpu_limit": null,                     // cores, clamped to quota max_cpu_cores
  "memory_limit_mb": null,               // MB, clamped to quota max_memory_mb
  "name": "optional-label",             // max 128 chars
  "tags": ["tag1", "tag2"]              // default [], free-form labels
}

RunSubmitResponse — 202

{
  "track_id": "01KY0XX71BMX0B88C3A189S2K7",   // ULID
  "status": "scheduled",
  "eligible_start": "2026-07-20T23:31:17Z",
  "clamped": false                       // true if requested limits were reduced to quota
}

RunStatus — GET /api/v1/runs/{track_id}

{
  "track_id": "...",
  "tenant_id": "...",
  "state": "running",                   // scheduled|queued|pulling|running|paused|exited|failed|timeout|killed
  "image": "...",
  "command": [...],
  "env": {...},
  "submitted_at": "...",
  "eligible_start": "...",
  "started_at": "...",                  // null until running
  "finished_at": "...",                 // null until terminal
  "exit_code": 0,                       // null until terminal
  "container_id": "...",
  "worker_id": "...",
  "cpu_limit": 1.0,
  "memory_limit_mb": 512,
  "timeout_seconds": 60,
  "name": "...",
  "tags": [...],
  "stdout_file": "/var/log/runflow/...stdout",
  "stderr_file": "/var/log/runflow/...stderr",
  "events": [{"ts":"...","from_state":"...","to_state":"...","reason":"...","payload":{}}],
  "scheduling_info": {                        // see Scheduling section
    "eligible_start": "2026-07-21T10:00:00Z",
    "working_hours_applied": false,
    "queue_position": 3,                   // null if not scheduled
    "estimated_start": "2026-07-21T10:00:30Z"  // soft estimate, null if no history
  }
}

RunResult — GET /api/v1/runs/{track_id}/result (terminal only)

{
  "track_id": "...",
  "exit_code": 0,
  "status": "exited",                   // exited|failed|timeout|killed
  "started_at": "...",
  "finished_at": "...",
  "duration_seconds": 12.34,
  "stdout_bytes": 1024,
  "stderr_bytes": 0,
  "stdout_file": "...",
  "stderr_file": "...",
  "result_summary": "..."
}

LogResponse — GET /api/v1/runs/{track_id}/logs

{
  "lines": ["line1", "line2"],
  "next_since": 2,                       // pass as ?since=2 for the next page
  "eof": true                            // false = more lines may arrive (run still active)
}

UtilizationResponse — GET /api/v1/runs/{track_id}/utilization

{
  "samples": [
    {
      "timestamp": "2026-07-20T23:31:18Z",
      "cpu_percent": 12.5,
      "memory_mb": 128.4,
      "memory_limit_mb": 512,
      "net_rx_bytes": 1024,
      "net_tx_bytes": 2048
    }
  ]
}

TenantQuotas — GET /api/v1/tenant/quotas, PUT /api/v1/tenant/quotas

{
  "max_concurrent_runs": 10,          // max runs in {pulling,running,paused} simultaneously
  "max_daily_runs": 100,            // max submissions per UTC day (still rejected at submit)
  "max_timeout_seconds": 86400,
  "max_cpu_cores": 4.0,
  "max_memory_mb": 4096,
  "max_queue_seconds": 3600,        // max wait after eligible_start before queue_timeout
  "working_hours_tjp": null,         // TJP shift definition, see Scheduling section
  "retention_days": 30
}

PUT requires runflow_tenant_admin permission. Only the quotas fields you include are updated.

curl -X PUT https://runflow.rodmena.app/api/v1/tenant/quotas \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "max_concurrent_runs": 5,
    "max_queue_seconds": 1800,
    "working_hours_tjp": "shift shift_def \"Office\" { workinghours mon - fri 09:00 - 18:00 }"
  }'

Errors #errors

Standard error envelope (FastAPI default):

{
  "detail": "human-readable message"
}
StatusMeaning
400Bad request — invalid image reference, malformed JSON
401No/invalid bearer token
403Permission denied (missing RBAC permission)
404Run not found (wrong track_id or not owned by tenant)
409Conflict — result requested on non-terminal run, pause/resume on wrong state
422Validation error (Pydantic schema mismatch, invalid TJP shift)
429Daily quota exceeded (daily_quota_exceeded) or rate limited (per-tenant sliding window). Note: concurrent quota no longer rejects — see Scheduling.
500Internal server error