Artifacts and retention

Every flow the farm runs is captured: what Maestro wrote, what it saw, and what the screen was doing. This is the point of running on a real device rather than reading a pass/fail bit.

What gets captured

Maestro runs with --debug-output pointed at a scratch directory, and the farm collects that directory afterwards, plus the flow's own output streams.

Kind Where it comes from Typical files
screenshot Maestro's debug output .png, .jpg, .jpeg
recording adb shell screenrecord, run by the farm .mp4, .webm
hierarchy Maestro's debug output .json, .xml — view hierarchy dumps and commands-*.json
log Maestro's debug output maestro.log
stdout / stderr The flow process itself written by the farm

Classification is by file extension alone, never by sniffing contents. A file whose extension the farm does not recognise is dropped, not stored unclassified — the farm will not risk rewriting bytes it does not understand.

maestro.log is usually the first thing to read on a failure: it is the narrative of what the flow actually did, which is what you want once a screenshot alone does not explain the result.

Screen recordings

Every flow is screen-recorded automatically, capped at 180 seconds. A longer flow produces a truncated recording, not a missing one.

Recordings are served with HTTP Range support, which is not optional decoration — Safari refuses to play <video> content at all without a 206 Partial Content response, and probes with a Range: bytes=0-1 request before it will start.

Collection is best-effort throughout. Losing a screenshot never turns a passing flow red, and never fails a run: the artifacts matter most precisely when the run already failed, so failing the run because they could not be saved would be backwards. Problems surface as warnings on the step.

Getting artifacts back

Artifacts are not in the CI response. GET /run/:id gives you verdicts and captured stdout/stderr; anything richer goes through the dashboard routes.

# every step of a job, each with its artifact rows
curl -H "Authorization: Bearer $TOKEN" "$FARM_URL/api/jobs/$JOB" \
  | jq '.steps[] | {name, status, artifacts: [.artifacts[] | {id, kind, bytes}]}'

# one artifact's raw bytes
curl -H "Authorization: Bearer $TOKEN" "$FARM_URL/api/artifacts/1234" -o screenshot.png

# one step's log as plain text
curl -H "Authorization: Bearer $TOKEN" \
  "$FARM_URL/api/flow-runs/42/log?stream=stderr"

Content-Type is inferred from the file extension. Ownership is checked through the job that owns the step that owns the artifact, so another org's artifact id returns 404, never someone else's bytes.

A coding agent can read the same artifacts through the MCP tools, which handle the size limits for you.

Retention

Off by default. A fresh deployment keeps every job and every artifact forever until an admin opts in.

An admin can set three thresholds in the dashboard's Admin → Retention tab:

Setting Meaning
maxAgeDays Delete jobs older than this
maxTotalBytes Keep total artifact storage under this
maxJobs Keep at most this many jobs

Each is independently a non-negative integer, or null to disable that one. Thresholds are server-wide, not per-org — a sweep triggered by one org's volume evicts the globally oldest jobs, whoever they belong to.

Once a threshold is exceeded, a background sweep deletes the oldest jobs first, removing both the job row and its artifact files.

⚠️ Eviction deletes job history, not just files. If your workflow treats the farm as a durable record of past runs, say so before an admin enables a threshold, and export what you need first.

GET /jobs returning at most 200 jobs is a separate thing — a response cap, not a retention limit. Those jobs still exist.

Disk usage

GET /api/devices reports artifactBytes at the top level of the response — farm-wide, not per device: the live running total of collected artifact storage across every device in the pool, independent of whatever a retention sweep has already evicted. It is the number to watch if retention is off.

View this page on GitHub ↗ · nmehlei/DeviceYard