# vm-migration serve API Documentation

## Overview

`vm-migration serve` exposes an HTTP API for driving VictoriaMetrics storage
migrations as a long-running service. Clients use it to prepare migrations,
dispatch jobs, follow live output, poll progress, and inspect completed job
artifacts. This document defines the contract consumed by a separate front-end
project. Operator procedures, recovery, and filesystem maintenance live in
[runbook.md](runbook.md).

Migrations are created by submitting a compiled plan via
`POST /migrations/{name}`. The created resource is available as soon as the
request returns `201 Created`.

### Resource Model

The top-level resource is a named **Migration**. A Migration holds an immutable
runtime plan, current progress, job history, and any artifacts produced by its
jobs.

Migration names contain 1–63 lowercase alphanumeric or hyphen characters,
start and end with an alphanumeric character, and match
`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`. Every endpoint that operates on one
Migration is scoped under `/migrations/{name}/...`.

Jobs are single-flight per Migration: at most one job runs for a Migration at
a time, and a conflicting dispatch returns `409 Conflict`. Different
Migrations can run jobs concurrently.

### Job Model

A **Job** is one unit of asynchronous work dispatched against a Migration.
Each job has:

- a **kind** literal: `migrate-run` | `export-probe` | `vmselect-deploy` | `vmselect-undeploy`
- an opaque **Job ID**, unique within its Migration
- a job record containing `id`, `kind`, `startedAt`, and, when applicable,
  `finishedAt`, `result`, `cancelled`, and `warnings`
- an optional **artifact** (successful export-probe and vmselect jobs provide
  one; migrate-run, cancellation, and failed jobs don't)

Job-dispatch endpoints return `202 Accepted` as soon as the record is
available. A record without `finishedAt` is in flight. Use the returned
`jobId` for cancellation and job polling.

### API Endpoints Summary

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/migrations/{name}` | Prepare + register a migration from a compiled plan |
| GET | `/migrations` | List all migrations, registration order |
| GET | `/migrations/{name}` | Migration status snapshot |
| POST | `/migrations/{name}/run` | Start or resume a migrate-run job |
| GET | `/migrations/{name}/steps` | Pending steps the next run would execute (read-only) |
| GET | `/migrations/{name}/plan` | The migration's immutable runtime plan (verbatim) |
| POST | `/migrations/{name}/deploy` | Deploy temporary vmselect targets and wait for readiness |
| POST | `/migrations/{name}/undeploy` | Delete the temporary vmselects and wait until gone |
| POST | `/migrations/{name}/probe` | Measure source export size and throughput |
| POST | `/migrations/{name}/cancel` | Cancel the in-flight job, optionally guarded by `jobId` |
| GET | `/migrations/{name}/summary` | Loop-summary snapshot |
| GET | `/migrations/{name}/jobs` | Job history, plus the in-flight job |
| GET | `/migrations/{name}/jobs/{id}` | Single-job record (in-flight included) |
| GET | `/migrations/{name}/jobs/{id}/artifact` | Job artifact JSON (`?format=text` renders the probe table) |
| GET | `/migrations/{name}/logs` | Tail the current or most recent job output (chunked `text/plain`) |
| GET | `/migrations/{name}/tune` | Effective runtime knobs per section (vmctl, vmselect) |
| POST | `/migrations/{name}/tune` | Replace a knob section (vmctl applies at the next shard; vmselect at the next deploy) |
| GET | `/version` | Build metadata of the running binary (git hash, build date) |

## Conventions

- **HTTP methods**: GET reads, POST acts — the API uses only GET and POST;
  PUT/DELETE/PATCH never appear on the surface.
- **Base URL**: all paths in this document are relative to the service base
  URL.
- **Request bodies**: send JSON request bodies as `application/json`.
- **Response formats**: responses are JSON unless an endpoint documents a
  text format. The log endpoint always returns `text/plain; charset=utf-8`.
- **Authentication**: **none**. Intended for cluster-internal deployment behind
  a reverse proxy that handles auth (mTLS, token, OIDC). Do not expose directly
  to untrusted networks.
- **Versioning**: no version prefix. Schema is tied to the binary version
  (`vm-migration version`; a running serve reports it at `GET /version`).
- **Field handling**: unknown request and JSON document fields are ignored,
  except where a typo'd field must surface: the tune endpoints (strict
  decode → 422) and the deploy/undeploy endpoints (strict decode → 400).
- **Error body**: non-2xx responses carry a plain-text body (not JSON) so curl
  users see the message without parsing.

## Reference Data

### Job Kinds

Returned as `kind` in migration status, job records, and job artifacts.

| Kind | Description | Artifact on success |
|------|-------------|---------------------|
| `migrate-run` | Run or resume the migration loop | no |
| `export-probe` | Measure source export size and throughput | yes |
| `vmselect-deploy` | Deploy temporary vmselect targets | yes |
| `vmselect-undeploy` | Remove temporary vmselect targets | yes |

### Step Status Literals

Stored under `loopStatus.tenants[].steps[].status` for loop jobs.

| Status | Description |
|--------|-------------|
| `pending` | Step has not been attempted yet |
| `running` | Step action is in flight |
| `failed` | Step action failed; re-running the loop retries the step |
| `completed` | Step action completed successfully |

### Error Categories

Stored under `loopStatus.tenants[].steps[].lastError.category` when a step failed.

| Category | Description |
|----------|-------------|
| `permanent` | Action ran and the failure looks like a data/usage error |
| `transient` | Action ran and the failure looks like a network hiccup or HTTP 5xx; re-running the step may succeed |
| `infrastructure` | Action could not start or was interrupted before producing output |

## Migration Collection

### GET /migrations

List all migrations in registration order. Returns one migration status per
migration (see
[GET /migrations/{name}](#get-migrationsname) for shape).

```bash
curl localhost:8080/migrations
```

```json
{
  "total": 2,
  "migrations": [
    {
      "name": "prod-2026-08",
      "dir": "/srv/migrations/prod-2026-08",
      "running": false
    },
    {
      "name": "staging-2026-09",
      "dir": "/srv/migrations/staging-2026-09",
      "running": true,
      "kind": "migrate-run",
      "jobId": "migrate-run-1739283799888777666"
    }
  ]
}
```

### POST /migrations/{name}

Prepare and register a Migration from a compiled plan. The operation is
atomic from the API's perspective: a successful response makes the complete
Migration immediately available, and a failed response does not register a
partial resource.

**Path Parameters**:

| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | string | Migration name matching the rule in [Resource Model](#resource-model) |

**Request Body**: the compiled plan with the embedded `tenants` inventory.

```bash
curl -X POST localhost:8080/migrations/prod-2026-08 \
  -H 'Content-Type: application/json' \
  --data-binary @plan-compile.json
```

**Response** (`201 Created`): the new migration's status snapshot (same
shape as [GET /migrations/{name}](#get-migrationsname)).

**Error Responses**:

| Status | Condition |
|--------|-----------|
| 400 | Invalid `name` form |
| 409 | The name is already registered or unavailable after an incomplete earlier preparation |
| 413 | Compiled-plan body exceeds 16 MiB |
| 422 | Compiled plan rejected: parse/validation failure, no tenants embedded, or tenant-name collision |
| 500 | Preparation failed because of an internal I/O error |

### GET /migrations/{name}

Migration status snapshot: runtime state plus the loop's per-tenant progress.
Read-only; does not block an in-flight job.

**Path Parameters**:

| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | string | Migration name (DNS-subdomain) |

**Response**:

```json
{
  "name": "prod-2026-08",
  "dir": "/srv/migrations/prod-2026-08",
  "running": true,
  "kind": "migrate-run",
  "jobId": "migrate-run-1739283799888777666",
  "lastJobId": "migrate-run-1739283700111222333",
  "lastKind": "migrate-run",
  "loopStatus": {
    "updatedAt": "2026-08-11T10:15:00Z",
    "tenants": [
      {
        "name": "1001:0",
        "completed": false,
        "failed": false,
        "currentStep": "migrate",
        "startedAt": "2026-08-11T10:00:00Z",
        "steps": [
          {
            "step": "manifest",
            "status": "completed",
            "attempts": 1,
            "startedAt": "2026-08-11T10:00:00Z",
            "finishedAt": "2026-08-11T10:00:02Z"
          },
          {
            "step": "migrate",
            "status": "running",
            "attempts": 1,
            "startedAt": "2026-08-11T10:00:02Z"
          }
        ]
      }
    ]
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Migration identifier |
| `dir` | string | Server diagnostic path; clients must not parse or construct it |
| `running` | bool | True iff a job is currently in flight |
| `kind` | string enum | Kind of the in-flight job; omitted when idle |
| `jobId` | string | Opaque ID of the in-flight job; omitted when idle |
| `lastJobId` | string | Opaque ID of the most recently completed job; omitted if no job has ever run |
| `lastKind` | string enum | Kind of the most recent job; omitted if never ran |
| `lastResult` | string | Error from the most recent job; omitted when the job succeeded or no job has run |
| `resumeError` | string | Error reported when the service could not resume an interrupted migration; omitted otherwise |
| `resumeErrorAt` | string | RFC3339 UTC time associated with `resumeError`; omitted with it |
| `loopStatus` | object | Current per-tenant loop progress; omitted until the first migrate-run initializes it |

`loopStatus.updatedAt` is an RFC3339 UTC timestamp. Each
`loopStatus.tenants[]` entry contains the original tenant ID in `name`, the
tenant-level `completed`, `failed`, and `currentStep` state, optional
`startedAt` / `finishedAt` timestamps, and `steps[]`. Each step contains
`step`, a [step status](#step-status-literals), `attempts`, optional timestamps,
an optional `lastExitCode`, and an optional
`lastError: {"category":"...","message":"..."}`.

**Error Responses**:

| Status | Condition |
|--------|-----------|
| 400 | Invalid migration name form |
| 404 | Unknown migration |

## Job Control Endpoints

All job-control endpoints are single-flight per Migration: a second POST while
one is in flight returns 409.

### POST /migrations/{name}/run

Start or resume a migrate-run job. Returns `202` immediately; the loop runs
asynchronously. Repeating this request resumes failed or pending steps.

**Request Body** (all fields optional; body may be empty):

```json
{
  "retries": 3
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `retries` | int | `5` | After a partial failure, run up to N additional attempts with a fixed 60-second delay. All attempts share one job record and `/logs` announces each retry. Hard errors and cancellation do not retry. An explicit `0` disables retries |

**Retry semantics**: an attempt covers the whole migration pass. Completed
steps and completed shard work are retained, so a retry executes only work
that remains incomplete. Hard errors and cancellation end the job without
using the remaining retry budget.

**Response** (`202`):

```json
{"started": true, "jobId": "migrate-run-1739283799888777666"}
```

**Error Responses**:

| Status | Condition |
|--------|-----------|
| 400 | Invalid migration name, malformed JSON body, or negative `retries` |
| 404 | Unknown migration |
| 409 | A job is already in flight |
| 500 | The migration loop could not be initialized |

### GET /migrations/{name}/steps

Preview what the next run would do: per-tenant pending steps in plan tenant
order. The request does not dispatch a job or change migration progress. While
a job runs, the preview reflects its current progress.

Each step carries its full action spec; migrate steps additionally carry the
complete vmctl command lines when they can be rendered. Tenants with nothing
pending are omitted:

```json
{
  "tenants": [
    {
      "name": "1001:0",
      "steps": [
        {"step": "manifest", "action": {"kind": "manifest", "tenantsFile": "tenants-1001-0.json", "manifest": "tenants-1001-0-manifest.json"}},
        {
          "step": "migrate",
          "action": {"kind": "migrate", "manifest": "tenants-1001-0-manifest.json", "rateLimitBytes": 100000000, "concurrency": 24, "stepInterval": "day"},
          "cmds": [
            "vmctl vm-native --vm-native-src-addr http://src --vm-native-dst-addr http://dst --vm-native-filter-match '{__name__!=\"\"}' --vm-native-filter-time-start 2026-01-01T00:00:00Z --vm-native-filter-time-end 2026-01-02T00:00:00Z --vm-native-step-interval day --disable-progress-bar -s"
          ]
        },
        {"step": "verify", "action": {"kind": "verify", "manifest": "tenants-1001-0-manifest.json"}}
      ]
    }
  ]
}
```

| Field | Type | Description |
|-------|------|-------------|
| `tenants[].name` | string | Original tenant ID, for example `1001:0` |
| `tenants[].steps[]` | array | Incomplete steps in execution order |
| `tenants[].steps[].step` | string | Step name |
| `tenants[].steps[].action` | object | Action parameters for the step |
| `tenants[].steps[].cmds` | []string | Rendered external commands; omitted when unavailable or not applicable |

Filename-valued action fields such as `tenantsFile`, `manifest`, and
`statusFile` are opaque server-generated references. Clients must not derive
them from the tenant ID.

**Error Responses**:

| Status | Condition |
|--------|-----------|
| 400 | Invalid migration name form |
| 404 | Unknown migration |
| 500 | The next-step view could not be computed |

### GET /migrations/{name}/plan

Return the Migration's immutable runtime plan verbatim. The plan is created by
`POST /migrations/{name}` and does not change for the lifetime of the
Migration.

| Field | Description |
|-------|-------------|
| `runtime` | Region, vmcluster and vminsert URL patterns, migration window, and verification policy |
| `vmselect` | Origin, migration-source, and candidate URL patterns |
| `tenants[]` | Tenant ID, opaque artifact references, and ordered step actions |
| `tenants[].name` | Original tenant ID, for example `1001:0` |
| `tenants[].cmds[]` | Step name and its `action` object |

The plan's filename-valued fields are opaque references. Clients may display
them but must not construct or modify them based on tenant IDs.

**Error Responses**:

| Status | Condition |
|--------|-----------|
| 400 | Invalid migration name form |
| 404 | Unknown migration |
| 500 | The runtime plan could not be read |

### POST /migrations/{name}/probe

Dispatch the export probe. It measures source-export bytes and duration for
each tenant shard using the plan's `vmselect.migration` endpoint. The probe
does not import data or mark shards migrated or verified, but it may initialize
the metadata needed to enumerate shards.

The request has no body. The selector, per-shard timeout, and rate behavior
are fixed by the service.

**Response**:

```json
{"started": true, "jobId": "export-probe-1739283799888777666"}
```

**Error Responses**:

| Status | Condition |
|--------|-----------|
| 400 | Invalid migration name form |
| 404 | Unknown migration |
| 409 | A job is already in flight |
| 500 | The job could not be dispatched because of an internal error |
| 503 | The plan lacks the configuration required to run the probe |

### POST /migrations/{name}/cancel

Cancel the in-flight job. Returns `202` immediately — in-flight work stops,
and any external process an action spawned (for example vmctl) is
terminated. `running` flips to `false` once the job exits.

**Request Body** (all fields optional):

```json
{
  "jobId": "migrate-run-1739283799888777666"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `jobId` | string | Optional. When set, must match the in-flight job's id; protects against racing a stale cancel against a freshly-started job |

**Response**:

```json
{"cancelled": true}
```

**Error Responses**:

| Status | Condition |
|--------|-----------|
| 400 | Invalid migration name form or malformed JSON body |
| 404 | Unknown migration |
| 409 | No job in flight, or the supplied `jobId` doesn't match the in-flight job's id |

**Behavior notes**:

- The completed job record contains `cancelled: true` and remains in job
  history.
- Cancellation does not produce a job artifact. Work completed before
  cancellation remains available to a later resume.
- `POST /cancel` does not wait. Poll `GET /migrations/{name}` to observe
  `running:false`.

## Temporary Vmselect Lifecycle Endpoints

These endpoints manage the temporary `migration` and `candidate` vmselect
targets described by the Migration's prepared plan. Effective specs include
any overrides returned by the tune endpoints. Both operations are idempotent;
repeat the corresponding POST to retry a failed job.

Changing a vmselect tune override does not change a running target. Dispatch
`POST /deploy` to apply the effective spec.

### POST /migrations/{name}/deploy

Deploy both temporary vmselect targets and wait for readiness; each rollout
is waited out within a fixed 3600-second budget. Progress is available from
`/logs`; the first target failure ends the job.

**Response**: `202 {"started": true, "jobId": "vmselect-deploy-..."}`.

**Error Responses**:

| Status | Condition |
|--------|-----------|
| 400 | Invalid migration name |
| 404 | Unknown migration |
| 409 | A job is already running |
| 500 | The prepared vmselect configuration could not be loaded |
| 503 | Cluster provisioning is unavailable |

### POST /migrations/{name}/undeploy

Remove both temporary vmselect targets and wait until they are gone.
Targets that are already absent count as success. The operation uses a fixed
120-second wait per target. Error responses match `/deploy`.

## Tune Endpoints

Tune endpoints manage runtime overrides without replacing the prepared plan.
The `vmctl` section is read at each shard boundary; an in-flight shard keeps
the settings it started with. The `vmselect` section is read by the next
`POST /deploy`.

### GET /migrations/{name}/tune

Return the effective knobs per section: prepared plan values with current
overrides applied.

**Response**:

```json
{
  "vmctl": {"rateLimitBytes": 104857600, "concurrency": 8, "updatedAt": "2026-08-22T00:00:00Z"},
  "vmselect": {
    "migration": {
      "name": "vmselect-migration",
      "url": "http://vmselect-migration.observability.svc.cluster.local:8481/select/{tenant}/prometheus",
      "image": "victoriametrics/vmselect:v1.126.0",
      "replicas": 1,
      "resources": {"requests": {"cpu": "500m", "memory": "2Gi"}, "limits": {"cpu": "1", "memory": "4Gi"}}
    },
    "candidate": {
      "name": "vmselect-candidate",
      "url": "http://vmselect-candidate.observability.svc.cluster.local:8481/select/{tenant}/prometheus",
      "image": "victoriametrics/vmselect:v1.126.0",
      "replicas": 1,
      "resources": {"requests": {"cpu": "500m", "memory": "2Gi"}, "limits": {"cpu": "1", "memory": "4Gi"}}
    },
    "updatedAt": "2026-08-22T00:00:00Z"
  }
}
```

`vmctl` knobs unset in both places are omitted; `updatedAt` appears only
while a section carries an override.

**Error Responses**:

| Status | Condition |
|--------|-----------|
| 400 | Invalid migration name form |
| 404 | Unknown migration |
| 500 | The effective configuration could not be loaded |

### POST /migrations/{name}/tune

Apply a sectioned replacement. The body is strict-decoded (unknown fields →
422): a **present** section replaces its override wholesale, an **empty**
section removes its override, and an **absent** section is untouched.

```json
{"vmctl": {"concurrency": 16}, "vmselect": {"migration": {"replicas": 2}}}
```

| Section | Fields | Constraints |
|---------|--------|-------------|
| `vmctl` | `rateLimitBytes` (int\|null), `concurrency` (int\|null) | `concurrency` >= 1, `rateLimitBytes` >= 0 |
| `vmselect` | per-target `image`, `replicas`, `resources{requests,limits}` | `replicas` 1–10; non-empty strings |

`updatedAt` is server-stamped and rejected in request bodies (strict decode →
422); it appears only in responses.

**Response**: the full effective view (same shape as GET).

**Error Responses**:

| Status | Condition |
|--------|-----------|
| 400 | Invalid migration name form |
| 404 | Unknown migration |
| 422 | Malformed / unknown-field body, or a value out of range |
| 500 | I/O fault |

## Job History Endpoints

Read-only endpoints over the recorded jobs and their artifacts.

### GET /migrations/{name}/jobs

List every job record in submission order (`startedAt` ascending), plus
the in-flight record while a job runs. An in-flight record has no `finishedAt`.
The response is wrapped with the total count and is
`{"total":0,"jobs":[]}` when no job is available.

```bash
curl localhost:8080/migrations/prod-2026-08/jobs
```

```json
{
  "total": 3,
  "jobs": [
    {
      "id": "migrate-run-1739283700111222333",
      "kind": "migrate-run",
      "startedAt": "2026-08-11T10:00:00Z",
      "finishedAt": "2026-08-11T10:05:30Z"
    },
    {
      "id": "export-probe-1739283800111222333",
      "kind": "export-probe",
      "startedAt": "2026-08-11T10:15:00Z",
      "finishedAt": "2026-08-11T10:16:30Z",
      "warnings": ["tenant=1001:0 action log unavailable"]
    },
    {
      "id": "export-probe-1739283900111222333",
      "kind": "export-probe",
      "startedAt": "2026-08-11T10:20:00Z"
    }
  ]
}
```

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Opaque job ID |
| `kind` | string enum | Work performed by the job |
| `startedAt` | string | RFC3339 UTC start time |
| `finishedAt` | string | RFC3339 UTC finish time; omitted while in flight |
| `result` | string | Terminal error; omitted on success and while in flight |
| `cancelled` | bool | Present and `true` when cancellation ended the job |
| `warnings` | []string | Non-fatal, actionable warnings; omitted when empty |

**Error Responses**:

| Status | Condition |
|--------|-----------|
| 400 | Invalid migration name form |
| 404 | Unknown migration |

### GET /migrations/{name}/jobs/{id}

Return one job record with the same shape as a list entry. In-flight jobs are
available without `finishedAt`.

**Error Responses**:

| Status | Condition |
|--------|-----------|
| 400 | Invalid migration name form |
| 404 | Unknown migration or job ID |

### GET /migrations/{name}/jobs/{id}/artifact

Return the artifact for a completed job. Migrate-run jobs have no artifact.
Successful export-probe and vmselect deploy/undeploy jobs produce one;
cancellation and jobs that end with an error do not.

**Export-probe artifact shape**:

```json
{
  "id": "export-probe-1739283800111222333",
  "kind": "export-probe",
  "startedAt": "2026-08-11T10:17:00Z",
  "finishedAt": "2026-08-11T10:18:30Z",
  "tenants": [
    {
      "tenant": "1001:0",
      "shards": [
        { "shardID": "abc", "window": "2026-06-16T00:00:00Z - 2026-06-17T00:00:00Z",
          "bytes": 12345, "durMs": 1234, "throughputBps": 10004 }
      ],
      "totals": { "bytes": 12345, "durMs": 1234, "avgBps": 10004 }
    }
  ],
  "totals": { "bytes": 12345, "durMs": 1234, "avgBps": 10004 }
}
```

**Vmselect artifact shape**:

```json
{
  "id": "vmselect-deploy-1739283800111222333",
  "kind": "vmselect-deploy",
  "startedAt": "2026-08-11T10:17:00Z",
  "finishedAt": "2026-08-11T10:18:30Z",
  "vmselects": [
    {
      "name": "vmselect-migration",
      "image": "victoriametrics/vmselect:v1.126.0",
      "action": "created",
      "storageIds": [3],
      "readyAfterSeconds": 12.4
    },
    {
      "name": "vmselect-candidate",
      "image": "victoriametrics/vmselect:v1.126.0",
      "action": "created",
      "storageIds": [0, 1, 2],
      "readyAfterSeconds": 9.8
    }
  ]
}
```

For `vmselect-deploy`, `action` is `created` or `updated`. For
`vmselect-undeploy`, it is `deleted` or `absent`. `readyAfterSeconds` is the
elapsed time for that target's deploy or undeploy operation.

An export-probe shard may include an `error` string. Per-shard export errors
are reported in the artifact and do not prevent the probe job from completing.

`?format=text` renders an export-probe artifact as a fixed-width table instead
(one row per shard, an error column when any shard failed, and an overall
totals row). Text format is not supported for vmselect artifacts.

```
$ curl localhost:8080/migrations/prod-2026-08/jobs/export-probe-1739283800111222333/artifact?format=text
job=export-probe-1739283800111222333 finished=2026-08-11T10:18:30Z
tenant  shardID   window                                        bytes   dur      avg
------  --------  -------------------------------------------  ------  ----  -------
1001:0  abc       2026-06-16T00:00:00Z - 2026-06-17T00:00:00Z  12.06K  1.2s  9.77K/s
------  --------  -------------------------------------------  ------  ----  -------
total   1 shards  -                                            12.06K  1.2s  9.77K/s
```

**Error Responses**:

| Status | Condition |
|--------|-----------|
| 400 | Invalid migration name, or `format=text` requested for a non-probe artifact |
| 404 | Unknown migration or job ID, or the job has no artifact |
| 500 | The artifact could not be read or decoded |

## Log Endpoint

### GET /migrations/{name}/logs

Chunked `text/plain` stream of the live progress and observed action output for
the migration's current (or most recent) job. Every tenant's output flows
through this one stream in execution order, so a single connection follows
the whole job across tenants. Designed for `curl -N` / `tail -F` style live
monitoring; body is plain text without SSE framing.

**Path Parameters**:

| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | string | Migration name |

**Response**:

```
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Cache-Control: no-cache
X-Accel-Buffering: no

[2026-08-11T10:15:00.123Z] ----...
[2026-08-11T10:15:00.124Z] tenant=1001:0 step=manifest attempt=1 status=running
[2026-08-11T10:15:00.125Z] output:
[2026-08-11T10:15:01.456Z] generated 50 shards
...
[2026-08-11T10:20:00.000Z] ----...
[2026-08-11T10:20:00.001Z] tenant=1002:0 step=manifest attempt=1 status=running
...
=== job finished ===
```

**Stream behavior**:

- A new connection first receives recent output, then live output as it is
  produced.
- When the job finishes, the server writes `=== job finished ===` and closes
  the connection. If no job has produced output, it writes
  `=== no job running ===` and closes immediately.
- Slow clients may lose messages. Reconnecting backfills recent output, but
  clients must not treat this endpoint as a lossless log archive.
- A job stream supports at most 32 concurrent subscribers; excess connections
  are closed with a capacity error.

**Error Responses**:

| Status | Condition |
|--------|-----------|
| 400 | Invalid migration name form |
| 404 | Unknown migration |

## Summary Endpoint

### GET /migrations/{name}/summary

Loop-summary snapshot: per-tenant progress aggregated over the loop's status.

**Query Parameters**:

| Parameter | Default | Description |
|-----------|---------|-------------|
| `format`  | `json`  | `json` returns the loop-summary as JSON; `text` returns a human-readable progress table |

**Response (JSON)**:

```json
{
  "statusPath": "/srv/migrations/prod-2026-08/migration-loop-status.json",
  "updatedAt": "2026-08-11T10:15:00Z",
  "total": 2,
  "done": 1,
  "running": 1,
  "failed": 0,
  "pending": 0,
  "percent": 50,
  "complete": false,
  "totalDuration": 330.5,
  "totalMigrated": 104857600,
  "rows": [
    {
      "tenant": "1001:0",
      "step": "done",
      "status": "completed",
      "attempt": 1,
      "duration": 120.5,
      "imported": 4,
      "verified": 4,
      "shardsTotal": 4,
      "bytesMigrated": 104857600,
      "migrateRate": 0
    },
    {
      "tenant": "1002:0",
      "step": "migrate",
      "status": "running",
      "attempt": 1,
      "duration": 210,
      "imported": 1,
      "verified": 0,
      "shardsTotal": 4,
      "bytesMigrated": 0,
      "migrateRate": 5242880
    }
  ]
}
```

`statusPath` is a server diagnostic path and must not be parsed by clients.
`updatedAt` is an RFC3339 UTC timestamp. `totalDuration` and each row's
`duration` are seconds. `totalMigrated` and `bytesMigrated` are bytes;
`migrateRate` is bytes per second. `percent` is the integer percentage of
tenants whose status is `completed`. `complete` is the derived finish flag:
true exactly when `done == total` and `failed == 0`. Each row's `status` is one of
`completed`, `running`, `failed`, or `pending`; `step` identifies the current
step.

**Response (text)**: `200 OK`, `Content-Type: text/plain; charset=utf-8`, with a
human-readable progress table.

**Error Responses**:

| Status | Condition |
|--------|-----------|
| 400 | Invalid migration name form |
| 404 | Unknown migration |
| 500 | Summary state could not be read or aggregated |
| 503 | Loop has not been run yet |

## Version Endpoint

### GET /version

Build metadata of the running binary, as stamped at link time.

**Response (JSON)**:

```json
{
  "version": "657dce5e3f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c",
  "date": "2026-08-31T02:11:57Z"
}
```

`version` is the git hash of the source revision; `date` is the UTC build
timestamp in RFC3339 form. Both carry placeholder values (`dev`,
`unknown`) when the binary was built without the `make build` stamp.

**Error Responses**: none — the endpoint has no failure modes.
