From 751fda7e83040d49ec89f435620724f5c9dddee0 Mon Sep 17 00:00:00 2001 From: Wilson Silva Date: Tue, 8 Sep 2026 11:54:57 +0100 Subject: [PATCH 1/8] build: move to Go 1.27 Bumps the go directive so the CLI builds with the current toolchain and applies the go fix modernizers that come with it: errors.AsType, maps.Copy, slices.Contains, strings.SplitSeq and the any spelling. The one hand edit replaces a LastIndex plus slice with strings.CutLast when deriving an output file extension. The behavioral changes in 1.27 were checked against this codebase. The WebSocket idle timer drains its channel with a non-blocking select, so the unbuffered timer channel is safe. Body.Close sites either read the body to the end already or close on an error path, so the automatic drain changes nothing. No golden compressed fixtures, closure-name assertions or removed GODEBUG settings exist here. The generated client keeps github.com/google/uuid because oapi-codegen types every uuid field with it; moving hand-written code to the stdlib uuid package would split one identifier across two types. golangci-lint moves to v2.13 in CI, the first release built with Go 1.27. Darwin binaries now require macOS 13 or later. --- .github/workflows/ci.yml | 2 +- go.mod | 2 +- internal/api/client_test.go | 5 ++--- internal/api/serverless/client.go | 2 +- internal/api/transport/errors.go | 3 +-- internal/api/types.go | 4 ++-- internal/api/upload_test.go | 3 +-- internal/cmd/run/output.go | 7 ++----- internal/cmd/run/run.go | 4 +--- internal/cmd/serverless/display_test.go | 9 ++------- internal/cmd/serverless/pack.go | 2 +- internal/cmd/serverless/volume.go | 2 +- internal/cmdutil/errors.go | 6 ++---- 13 files changed, 18 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1a3cf3..f3258de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: - name: Lint uses: golangci/golangci-lint-action@v9 with: - version: v2.12 + version: v2.13 docs: runs-on: ubuntu-latest diff --git a/go.mod b/go.mod index ddfc50c..20de8f2 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/runware/runware-cli -go 1.26.4 +go 1.27.1 require ( github.com/briandowns/spinner v1.23.2 diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 15f035c..003589d 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "log/slog" + "maps" "net/http" "net/http/httptest" "strings" @@ -165,9 +166,7 @@ func TestSend_NoAPIKey(t *testing.T) { func successItem(t *testing.T, extra map[string]any) json.RawMessage { t.Helper() m := map[string]any{fieldStatus: "success"} - for k, v := range extra { - m[k] = v - } + maps.Copy(m, extra) return rawJSON(t, m) } diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index db71337..d936f4c 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -114,7 +114,7 @@ type TaskStatus = gen.TaskStatus // TaskPayload is the JSON object forwarded to an endpoint handler. // It is the TaskInvocation.payload member, not the request body itself. -type TaskPayload = map[string]interface{} +type TaskPayload = map[string]any // ListTasksParams are optional filters for ListTasks. type ListTasksParams = gen.ListTasksParams diff --git a/internal/api/transport/errors.go b/internal/api/transport/errors.go index 0f64a1e..de70d3f 100644 --- a/internal/api/transport/errors.go +++ b/internal/api/transport/errors.go @@ -474,8 +474,7 @@ func IsAuthError(err error) bool { if errors.Is(err, ErrNoAPIKey) { return true } - var re *RunwareError - if errors.As(err, &re) { + if re, ok := errors.AsType[*RunwareError](err); ok { return re.Code == CodeAuth } return false diff --git a/internal/api/types.go b/internal/api/types.go index 7fe59ea..304d79d 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -137,7 +137,7 @@ type TeamMember struct { Name string `json:"name"` Email string `json:"email"` Roles []string `json:"roles"` - JoinedAt time.Time `json:"joinedAt,omitempty"` + JoinedAt time.Time `json:"joinedAt"` } // APIKeyInfo describes a single API key on the account. @@ -147,7 +147,7 @@ type APIKeyInfo struct { Description string `json:"description,omitempty"` Enabled bool `json:"enabled"` CreatedAt time.Time `json:"createdAt"` - LastUsedAt time.Time `json:"lastUsedAt,omitempty"` + LastUsedAt time.Time `json:"lastUsedAt"` Requests int `json:"requests,omitempty"` } diff --git a/internal/api/upload_test.go b/internal/api/upload_test.go index 9ded1c3..7ba3575 100644 --- a/internal/api/upload_test.go +++ b/internal/api/upload_test.go @@ -275,8 +275,7 @@ func TestModelUpload_StreamErrorPropagated(t *testing.T) { } _, err := NewClient(mock, slog.Default()).ModelUpload(context.Background(), minimalUploadRequest(), ModelUploadOptions{}) - var re *transport.RunwareError - if !errors.As(err, &re) { + if _, ok := errors.AsType[*transport.RunwareError](err); !ok { t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err) } } diff --git a/internal/cmd/run/output.go b/internal/cmd/run/output.go index c101313..9725f2e 100644 --- a/internal/cmd/run/output.go +++ b/internal/cmd/run/output.go @@ -282,11 +282,8 @@ func buildDestPath(outputDir, field, urlStr string, idx int, multi bool) string // Fallback: derive extension from URL path + generic stem. ext := "" - if dot := strings.LastIndex(u.Path, "."); dot != -1 { - candidate := u.Path[dot:] // e.g. ".png" - if len(candidate) <= 6 { // sanity: extensions are short - ext = candidate - } + if _, candidate, ok := strings.CutLast(u.Path, "."); ok && len(candidate) <= 5 { // extensions are short + ext = "." + candidate } base := fieldBaseName(field) if multi { diff --git a/internal/cmd/run/run.go b/internal/cmd/run/run.go index 09d8e1a..21303a5 100644 --- a/internal/cmd/run/run.go +++ b/internal/cmd/run/run.go @@ -218,9 +218,7 @@ The model positional argument may be omitted when --preset supplies one.`, // so that --preset runs fail consistently with non-preset runs. func mergePresetParams(presetParams map[string]string, kvArgs []string) (map[string]string, error) { merged := make(map[string]string, len(presetParams)+len(kvArgs)) - for k, v := range presetParams { - merged[k] = v - } + maps.Copy(merged, presetParams) for _, kv := range kvArgs { k, v, ok := strings.Cut(kv, "=") if !ok { diff --git a/internal/cmd/serverless/display_test.go b/internal/cmd/serverless/display_test.go index b322832..62056c4 100644 --- a/internal/cmd/serverless/display_test.go +++ b/internal/cmd/serverless/display_test.go @@ -2,6 +2,7 @@ package serverless import ( "bytes" + "slices" "strings" "testing" "time" @@ -400,13 +401,7 @@ func TestEnvVarsResult_ShowsValue(t *testing.T) { envVarResult(ev), } for _, table := range tables { - hasValue := false - for _, h := range table.Headers() { - if h == colValue { - hasValue = true - break - } - } + hasValue := slices.Contains(table.Headers(), colValue) if !hasValue { t.Fatalf("%T table must include a Value column: %v", table, table.Headers()) } diff --git a/internal/cmd/serverless/pack.go b/internal/cmd/serverless/pack.go index 7ef32be..35b2656 100644 --- a/internal/cmd/serverless/pack.go +++ b/internal/cmd/serverless/pack.go @@ -297,7 +297,7 @@ func readIgnoreFile(path string) ([]string, error) { // business; ParsePattern handles both, and a pattern that begins with an // escaped `#` must not be mistaken for one here. lines := []string{} - for _, line := range strings.Split(string(raw), "\n") { + for line := range strings.SplitSeq(string(raw), "\n") { lines = append(lines, strings.TrimSuffix(line, "\r")) } return lines, nil diff --git a/internal/cmd/serverless/volume.go b/internal/cmd/serverless/volume.go index c6c9034..c986ed3 100644 --- a/internal/cmd/serverless/volume.go +++ b/internal/cmd/serverless/volume.go @@ -73,7 +73,7 @@ func validateMountPath(raw string, seen []string) (string, error) { return "", fmt.Errorf("volume %q: contains unsupported character %q", raw, r) } - for _, component := range strings.Split(strings.TrimPrefix(mount, "/"), "/") { + for component := range strings.SplitSeq(strings.TrimPrefix(mount, "/"), "/") { if len(component) > maxVolumePathComponent { return "", fmt.Errorf("volume %q: path component exceeds %d bytes", raw, maxVolumePathComponent) } diff --git a/internal/cmdutil/errors.go b/internal/cmdutil/errors.go index f43b96b..85c84c2 100644 --- a/internal/cmdutil/errors.go +++ b/internal/cmdutil/errors.go @@ -44,8 +44,7 @@ func PrintErrorTo(logger *log.Logger, w io.Writer, format output.Format, err err return } - var re *transport.RunwareError - if errors.As(err, &re) { + if re, ok := errors.AsType[*transport.RunwareError](err); ok { if isStructuredFormat(format) { writeStructuredError(w, format, structuredErrors(re.APIFields())) return @@ -78,8 +77,7 @@ func PrintErrorMsg(logger *log.Logger, format output.Format, message string, err // PrintErrorMsgTo logs a custom message and writes structured output to w. func PrintErrorMsgTo(logger *log.Logger, w io.Writer, format output.Format, message string, err error) { - var re *transport.RunwareError - if errors.As(err, &re) { + if re, ok := errors.AsType[*transport.RunwareError](err); ok { if isStructuredFormat(format) { writeStructuredError(w, format, map[string]any{ fieldMessage: message, From 9b0b885e771f6d169f8ce554dcf7bfba61825485 Mon Sep 17 00:00:00 2001 From: Wilson Silva Date: Tue, 8 Sep 2026 11:55:26 +0100 Subject: [PATCH 2/8] chore(serverless): sync the OpenAPI spec and regenerate the client Brings the vendored spec level with Runware/serverless main (43a861c7). The entries route no longer says it answers 404, and the tail route, the app errors route, the GPU type restore route and the InternalServerError response are now generated. --- api/serverless/openapi.yaml | 421 +++++-- internal/api/serverless/gen/client.gen.go | 1317 +++++++++++++++++++-- 2 files changed, 1533 insertions(+), 205 deletions(-) diff --git a/api/serverless/openapi.yaml b/api/serverless/openapi.yaml index fb0caad..53a87cf 100644 --- a/api/serverless/openapi.yaml +++ b/api/serverless/openapi.yaml @@ -52,7 +52,7 @@ tags: - name: EnvironmentVariables description: Plain-text environment variables scoped to an app - name: Observability - description: Usage, events and errors + description: Usage, metrics, events, and logs - name: Tenancy description: > Platform-only organisation tenancy (ADR-019). Restricted to the Runware @@ -96,7 +96,9 @@ paths: summary: Add a GPU type to the catalogue description: > Creates a new entry in the global GPU type catalogue. Restricted to the Runware platform - organization. The `id` (catalogue code) is immutable and remains reserved after retirement. + organization. The `id` (catalogue code) is immutable and remains reserved after retirement: + a retired code answers `409` here and is brought back with + `POST /v1/gpu-types/{gpuTypeId}/restore` rather than recreated. operationId: createGpuType requestBody: required: true @@ -194,7 +196,8 @@ paths: description: > Soft-deletes a GPU type while preserving its code and price history. Restricted to the Runware platform organization. Returns `409` if a worker configuration or GPU pool still - references the code. Retiring an already retired code returns `404`. + references the code. Retiring an already retired code returns `404`. Reversible with + `POST /v1/gpu-types/{gpuTypeId}/restore`. operationId: deleteGpuType parameters: - $ref: '#/components/parameters/GpuTypeId' @@ -214,6 +217,40 @@ paths: '422': $ref: '#/components/responses/ValidationError' + /v1/gpu-types/{gpuTypeId}/restore: + post: + tags: + - Compute + summary: Restore a retired GPU type + description: > + Returns a retired GPU type to the catalogue under the same code, with its price history + intact, so the reserved code is usable again for the hardware it already described. + Restricted to the Runware platform organization. A code that is not retired returns + `409`; a code no entry has ever held returns `404`. Whether customers can then select + the type still depends on pool admission, exactly as for any active type. + operationId: restoreGpuType + parameters: + - $ref: '#/components/parameters/GpuTypeId' + responses: + '200': + description: Restored GPU type + content: + application/json: + schema: + $ref: '#/components/schemas/GpuType' + '401': + $ref: '#/components/responses/Unauthorized' + '503': + $ref: '#/components/responses/ServiceUnavailable' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/ValidationError' + /v1/gpu-types/{gpuTypeId}/prices: get: tags: @@ -387,11 +424,14 @@ paths: answered with the task it already names rather than starting a second one, so the `202` can carry a task that has already finished: read its `status` instead of assuming `pending`, and note it may name a different `appId`. Apps in - `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and - `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An - organisation whose serverless tenancy is revoked, or has no tenancy receipt - at all, returns `403 Forbidden`, distinguishing that from an app that does - not exist. An organisation whose tenancy is still being provisioned + `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an + `initializing` app whose first rollout has not produced a version returns + `409 Conflict`, and an `active` app the platform has observed to have no workload + able to serve returns `503 Service Unavailable` with no task minted. `stopped`, + `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return + `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no + tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app + that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before @@ -440,11 +480,14 @@ paths: `appId` on a resubmission just as the `200` does. Poll `GET /v1/apps/{appId}/tasks/{taskId}` for its result. A request the platform cannot attribute to an accepted task fails instead, with no task to poll. Apps in - `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and - `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An - organisation whose serverless tenancy is revoked, or has no tenancy receipt - at all, returns `403 Forbidden`, distinguishing that from an app that does - not exist. An organisation whose tenancy is still being provisioned + `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an + `initializing` app whose first rollout has not produced a version returns + `409 Conflict`, and an `active` app the platform has observed to have no workload + able to serve returns `503 Service Unavailable` with no task minted. `stopped`, + `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return + `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no + tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app + that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task @@ -765,10 +808,12 @@ paths: a container document's parser refused it, 422 where it parsed and broke a rule) leaves the app on its current version and writes no version row and no build row. After the builder accepts, version N+1 is recorded - even if a concurrent secret deactivation or env/secret collision - prevents this request's env/secrets overlay; in that case the previous - environmentVariables and attachment set stay in place and are what the - new version snapshots. + even if a concurrent secret deactivation, an env/secret collision, or + the 25-deployment attachment ceiling below prevents this request's + env/secrets overlay; in that case the previous environmentVariables + and attachment set stay in place and are what the new version + snapshots, and the `422` the standalone case below returns does not + apply here — the request still succeeds. `environmentVariables` replaces the whole set: a key absent from the map is deleted, and a null value omits that key from the new set. The @@ -777,7 +822,9 @@ paths: `secrets` replaces the whole attachment set. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app; the combined set of plain variables and - attachments is capped at 100. This is a control-plane record only — + attachments is capped at 100, and each individual secret can be + attached to at most 25 deployments total — an entry that would push a + secret past that returns `422`. This is a control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so a secrets-only change does not roll the workload. @@ -939,7 +986,10 @@ paths: - $ref: '#/components/parameters/SourceUploadId' responses: '204': - description: Source upload aborted + description: | + Source upload aborted. The upload can no longer supply a source, whether or + not its staging object went with it: a storage failure there leaves the object + to server-side cleanup rather than to the client. '400': $ref: '#/components/responses/BadRequest' '401': @@ -952,8 +1002,6 @@ paths: $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' - '502': - $ref: '#/components/responses/BadGateway' '503': $ref: '#/components/responses/ServiceUnavailable' @@ -1556,13 +1604,28 @@ paths: summary: List workers description: > Returns a newest-first page of workers observed for the app (including - terminal `stopped` rows until purged). Optional `state` and `status` narrow the - page; a cursor must be replayed under the same filters it was issued with. + terminal `stopped` rows until purged). Omitted `versionId` scopes the page + to the app's `activeVersionId`. An app with no active version therefore + answers an empty default page — not that it has no workers, only that + none are pinned. Optional `state` and `status` narrow the page further; + a cursor must be replayed under the same filters it was issued with. operationId: listWorkers parameters: - $ref: '#/components/parameters/AppId' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' + - name: versionId + in: query + required: false + description: > + Scope the page to one version. The default is the app's `activeVersionId`. + When that field is unset the default page is empty: the app has no + active version, not that it has no workers. Send `all` (any case) to + include every version. An empty value is refused. A cursor must be + replayed under the same version scope it was issued with. + schema: + type: string + minLength: 1 - name: state in: query required: false @@ -1723,6 +1786,13 @@ paths: tags: - Secrets summary: Update a secret + description: > + Re-encrypts the value under the same name (no rename). Rolls every + live deployment that attaches this secret in place so a running + worker picks up the new value. If a rollout is already in progress + when this commits, this change is not guaranteed to land on it — + it reaches the worker on a later redeploy instead. A deployment + that is not live picks it up on its next deploy for another reason. operationId: updateSecret parameters: - $ref: '#/components/parameters/SecretName' @@ -1757,19 +1827,19 @@ paths: summary: Delete a secret description: > Soft-deletes a secret: marks the row `pending_destroy` and bumps - revision. This API does not hard-delete the row. A background sweep - removes the row and releases the name once no running worker can still - hold the value — the value travels inside the worker's own environment, - which is fixed when the container starts, so a worker keeps it until it - stops. There is no deadline on that wait. Returns `409` while any - app still attaches it — cascade-detach is not performed here; - detach each holder with - `DELETE .../apps/{id}/secrets/{name}` first. Attach and detach change - the secret set for the next rollout. Neither operation rolls workers. - While the row remains `pending_destroy` the name stays reserved, so - create may return `409` even though list no longer shows the secret. - Retries on an already-pending name are safe when no attachments remain - (`204`); they still return `409` while attached. + revision. This API does not hard-delete the row. Returns `409` while + any app still attaches it — cascade-detach is not performed here; + detach each holder with `DELETE .../apps/{id}/secrets/{name}` first, + which rolls the deployments it names in place. A background sweep + removes the row and releases the name once no running worker can + still hold the value, rather than assuming every roll a detach + started actually landed — a deployment that was not live when + detached has nothing to roll until it resumes, so the sweep is the + actual backstop, not the detach. While the row remains + `pending_destroy` the name stays reserved, so create may return `409` + even though list no longer shows the secret. Retries on an + already-pending name are safe when no attachments remain (`204`); + they still return `409` while attached. operationId: deleteSecret parameters: - $ref: '#/components/parameters/SecretName' @@ -1902,10 +1972,15 @@ paths: summary: Attach a secret to an app description: > Records that an organisation secret is attached to an app under a - resolved env-var name. The next rollout injects the value into the - worker. This operation does not roll workers. Returns `409` if the - secret is already attached, or if another attach would use the same - env-var name. + resolved env-var name, and rolls the app's live deployment in place + so a running worker picks up the value without waiting for an + unrelated deploy. If a rollout is already in progress when this + commits, this attach is not guaranteed to land on it — it reaches + the worker on a later redeploy instead. A deployment that is not + live records the attachment only — the next resume reads the + attach set fresh. + Returns `409` if the secret is already attached, or if another + attach would use the same env-var name. The resolved name (`envVarName`, or `secretName` when omitted) must not already exist as a plain environment variable on this app @@ -1918,6 +1993,11 @@ paths: environment variables plus attached secrets — the same combined ceiling as create and the single-key env-var route. Attaching when the app is already at that limit returns `422`. + + A secret can be attached to at most 25 deployments — rolling every + attached deployment is what an update or detach costs, so the ceiling + bounds that cost rather than the app side of the binding. Attaching + past it returns `422`. operationId: attachAppSecret parameters: - $ref: '#/components/parameters/AppId' @@ -1951,8 +2031,12 @@ paths: - Secrets summary: Detach a secret from an app description: > - Removes the attachment from the next rollout. This operation does not - roll workers. Existing workers keep the value until they stop. + Removes the attachment and rolls the app's live deployment in place + so a running worker stops receiving the value. If a rollout is + already in progress when this commits, this detach is not + guaranteed to land on it — the worker stops receiving the value on + a later redeploy instead. A deployment that is not live has the + removal recorded only — there is nothing to roll until it resumes. operationId: detachAppSecret parameters: - $ref: '#/components/parameters/AppId' @@ -2131,6 +2215,57 @@ paths: '400': $ref: '#/components/responses/BadRequest' + /v1/apps/{appId}/errors: + get: + tags: + - Observability + summary: List request errors for an app + description: > + One page of failed inference requests for this app, newest first. + Omit `statusClass` for both 4xx and 5xx. The cursor is opaque and is + only valid with the same `window` and `statusClass` it was issued under. + operationId: listAppErrors + parameters: + - $ref: '#/components/parameters/AppId' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/AppErrorWindow' + - $ref: '#/components/parameters/AppErrorStatusClass' + responses: + '200': + description: A page of errors + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Page' + - type: object + required: + - data + properties: + data: + type: array + items: + $ref: '#/components/schemas/LogEntry' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/TooManyRequests' + '502': + $ref: '#/components/responses/BadGateway' + '503': + $ref: '#/components/responses/ServiceUnavailable' + '504': + $ref: '#/components/responses/GatewayTimeout' + /v1/usage: get: tags: @@ -2247,24 +2382,37 @@ paths: series rather than an error. - `apps_request_volume` returns one series per app: 24 hourly request counts over - `window=24h` (`step_s` 3600, unit `requests`). Repeat `appId` once per id on the - current list page to pad idle apps with all-null series, in request order. Hours - that started before that live app's `createdAt` are null, so a reused app id does - not inherit the previous generation's traffic still in the 24h store. The same - `appId` pad applies to the list-scoped `apps_error_volume` and - `apps_request_duration` queries. Other queries reject `appId`. Other windows - are not available for these queries. + `apps_request_volume` returns one series per app: 96 quarter-hour request counts + over `window=24h` (`step_s` 900, unit `requests`). Repeat `appId` once per id on + the current list page to pad idle apps with all-null series, in request order. + A series is named for the live app behind it, so a reused app id reports its own + generation's traffic and not the one before it. The same `appId` pad applies to + the list-scoped `apps_error_volume` and `apps_request_duration` queries. Other + queries reject `appId`. Other windows are not available for these queries. - `endpoints_request_volume` is the endpoints-list counterpart: 24 hourly request - counts per endpoint over `window=24h` (`step_s` 3600, unit `requests`). It - requires `deployment` (the public app id, rewritten to the live deployment + `endpoints_request_volume` is the endpoints-list counterpart: 96 quarter-hour + request counts per endpoint over `window=24h` (`step_s` 900, unit `requests`). + It requires `deployment` (the public app id, rewritten to the live deployment UUID). Repeat `endpointId` once per id on the current `listEndpoints` page to - pad idle endpoints with all-null series, in request order. Hours that started + pad idle endpoints with all-null series, in request order. Buckets that started before that endpoint row's `createdAt` are null, so a removed-then-readded path does not inherit the previous row's traffic. Other queries reject `endpointId`. Other windows are not available for this query. + + + Three queries serve one app's overview, all over `window=24h` at `step_s` 900 and + all requiring `deployment` (the public app id, rewritten to the live deployment + UUID): `app_traffic_24h` returns `requests`, `client_errors` (4xx) and + `server_errors` (5xx) as request counts; `app_worker_seconds_24h` returns + `startup`, `execution` and `idle` as worker-seconds, whose three values in a + bucket sum to that bucket's worker time; and `app_cold_starts_24h` returns + `cold_starts` as a count. They report counts and totals rather than rates or + ratios, so a per-minute figure is a bucket value divided by `step_s / 60` and a + 24h ratio is one summed axis over another — summing first and dividing once, + because averaging a per-bucket ratio across the axis does not give the 24h + ratio. These queries are absent from `listInsightsQueries`: they back the + overview rather than the Metrics tab. Other windows are not available for them. operationId: getMetricSeries parameters: - $ref: '#/components/parameters/QueryId' @@ -2309,12 +2457,8 @@ paths: summary: Read one page of a named log query description: > Returns one page of log entries, newest first, with an opaque cursor for the next - page when one exists. - - - No query is registered yet: live tail, retention tiers and log quotas are decided - in a follow-up ADR, so every request currently answers `404`. The route exists so - the contract is fixed before the templates land. + page when one exists. Query ids and their supported selectors are listed by the + insights catalogue. operationId: getLogEntries parameters: - $ref: '#/components/parameters/QueryId' @@ -2349,6 +2493,51 @@ paths: '504': $ref: '#/components/responses/GatewayTimeout' + /v1/logs/queries/{queryId}/tail: + get: + tags: + - Observability + summary: Follow a named application log query + description: > + Streams new application log entries as Server-Sent Events. The stream sends + keepalive comments while quiet and ends with an `end` event when its connection + lifetime expires or the service shuts down. Clients should reconnect after an + `end` event. Use the `runtime_tail` query id; queries that sort or aggregate are + rejected because they cannot be followed live. + operationId: tailLogEntries + parameters: + - $ref: '#/components/parameters/QueryId' + - $ref: '#/components/parameters/RequiredLogDeployment' + responses: + '200': + description: > + Live SSE log stream. Default message events carry one `LogEntry` JSON object. + Named `end` and `error` events terminate the stream. + content: + text/event-stream: + schema: + type: string + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalServerError' + '502': + $ref: '#/components/responses/BadGateway' + '503': + $ref: '#/components/responses/ServiceUnavailable' + '504': + $ref: '#/components/responses/GatewayTimeout' + components: securitySchemes: ApiKeyAuth: @@ -2481,6 +2670,13 @@ components: description: Narrow to one app. schema: $ref: '#/components/schemas/AppId' + RequiredLogDeployment: + name: deployment + in: query + required: true + description: App whose new log entries are streamed. + schema: + $ref: '#/components/schemas/AppId' SelectorEndpoint: name: endpoint in: query @@ -2500,6 +2696,26 @@ components: schema: type: string enum: ['2xx', '4xx', '5xx'] + AppErrorWindow: + name: window + in: query + required: false + description: > + The range to search. Same closed ladder as the metrics queries. Defaults + to the last 24 hours. + schema: + type: string + enum: [1h, 6h, 24h, 7d, 30d] + default: 24h + AppErrorStatusClass: + name: statusClass + in: query + required: false + description: > + Narrow to one error class. Omit for both 4xx and 5xx. + schema: + type: string + enum: [4xx, 5xx] SelectorRegion: name: region in: query @@ -2517,15 +2733,16 @@ components: style: form explode: true description: > - Restrict expand-by-`app_id` queries (`apps_request_volume`, + Restrict the list-scoped queries (`apps_request_volume`, `apps_error_volume`, `apps_request_duration`) to these app ids: one series per id, in request order, with all-null series for apps that had no samples. - Values are hourly over `window=24h`. Hours that started before that live - app's `createdAt` are null. Repeat the parameter once per id on the current - list page (at most 100, matching `listApps`). Omit it to receive every app - in the organisation that had data, unless that set is larger than this - query will expand: then the call is a `422` on `appId` and the list page - should name the apps it is showing. Other queries reject this parameter. + Values are quarter-hourly over `window=24h`. An id with no live app is + dropped rather than refused: a deleted app has no traffic to pad. Repeat the + parameter once per id on the current list page (at most 100, matching + `listApps`). Omit it to receive every app in the organisation that had data, + unless that set is larger than this query will expand: then the call is a + `422` on `appId` and the list page should name the apps it is showing. Other + queries reject this parameter. schema: type: array maxItems: 100 @@ -2540,8 +2757,8 @@ components: description: > Restrict `endpoints_request_volume` to these endpoint ids: one series per id, in request order, with all-null series for endpoints that had no traffic. - Values are hourly request counts over `window=24h`. Hours that started - before that endpoint row's `createdAt` are null. Repeat the parameter once + Values are quarter-hourly request counts over `window=24h`. Buckets that + started before that endpoint row's `createdAt` are null. Repeat the parameter once per id on the current list page (at most 100, matching `listEndpoints`). Omit it to receive every endpoint on the selected app that had data, unless that set is larger than this query will expand: then the call is a `422` @@ -2642,6 +2859,12 @@ components: application/problem+json: schema: $ref: '#/components/schemas/ProblemDetails' + InternalServerError: + description: Unexpected server error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' BadGateway: description: An upstream service was unreachable or refused the request content: @@ -2864,13 +3087,14 @@ components: AppName: type: string description: > - Mutable display name. Must contain at least one non-whitespace character: - it is what the console renders and what `sort=name` orders on, and it is - not required to be unique. Unlike `appId` the pattern is - unanchored, so interior spaces are allowed — only an entirely blank name - is rejected. + Mutable display name. Must start and end with a non-whitespace + character: it is what the console renders and what `sort=name` orders + on, and it is not required to be unique. Interior spaces are allowed + ("Sentiment Analysis"); leading or trailing whitespace is rejected, + because a padded name is indistinguishable from its trimmed form in + the console and breaks a name-confirm delete. minLength: 1 - pattern: '\S' + pattern: '^\S(.*\S)?$' EnvironmentVariableName: type: string @@ -3419,8 +3643,8 @@ components: additionalProperties: false description: > Observed state for one app at `calculatedAt`. Desired worker scale remains - in `configuration`. Worker and GPU counts are always present; traffic and - duration fields are omitted when their backing data is unavailable. + in `configuration`. Worker and GPU counts are always present; traffic, duration, + and queue fields are omitted when their backing data is unavailable. required: - activeWorkers - provisionedGpuCount @@ -3467,6 +3691,14 @@ components: (all requests; the duration histogram has no status class). Omitted when metrics cannot be read, when the app had no requests, or when the duration series has no samples for the app in the window. + queueDepth: + type: integer + format: int64 + minimum: 0 + description: > + Ready plus unacknowledged messages on this app's live inference + queue. Omitted when the app is not live or the gauge cannot be + read. Zero when the queue is live and empty. App: type: object @@ -3592,7 +3824,10 @@ components: does not create one, and an unknown or inactive name returns `404`. Shape matches `POST /apps/{appId}/secrets` so create and attach share one contract. Each injected name must not collide with a key - in `environmentVariables` — see `SecretAttach`. + in `environmentVariables` — see `SecretAttach`. Each secret can + also be attached to at most 25 deployments total, shared with every + other route that attaches it; an entry that would push a secret + past that returns `422`. items: $ref: '#/components/schemas/SecretAttach' environmentVariables: @@ -3657,8 +3892,8 @@ components: - $ref: '#/components/schemas/AppName' description: > Mutable display name; does not affect app identity or routing. - Omit to leave unchanged — an explicit blank value is rejected, not - treated as a clear. + Omit to leave unchanged — an explicit blank or padded value is + rejected, not treated as a clear. configuration: $ref: '#/components/schemas/WorkerConfigPatch' appSource: @@ -3688,7 +3923,9 @@ components: only — secret values do not reach a pod, and the version snapshot carries no secrets — so this field does not roll the workload. An app holds at most 100 environment bindings in total; this - array cannot exceed that ceiling on its own. + array cannot exceed that ceiling on its own. Each individual + secret can also be attached to at most 25 deployments total, + shared with every other route that attaches it. maxItems: 100 items: $ref: '#/components/schemas/SecretAttach' @@ -4448,23 +4685,33 @@ components: type: integer format: int32 minimum: 0 - description: GPUs attached to this worker at observation time. + description: > + GPUs attached to this worker at observation time. Zero means the + snapshot is unknown: the worker is still unscheduled, or a terminal + observation could not read a GPU pair. A known snapshot is `>= 1` + and travels with `gpuType`. An empty pair is incomplete, not a CPU + worker. CPU workloads are not supported. gpuType: type: string allOf: - $ref: '#/components/schemas/GpuTypeId' nullable: true - description: GPU catalogue code snapshotted at observation time; omitted for CPU workers. + description: > + GPU catalogue code snapshotted at observation time. Omitted until a + GPU snapshot exists (the worker is still unscheduled, or the + observation could not read a type). Not a CPU-worker marker; CPU + workloads are not supported. gpuAvailability: type: string allOf: - $ref: '#/components/schemas/GpuAvailability' nullable: true description: > - How the catalogue currently provisions this worker's `gpuType`. Omitted for a - CPU worker and for a code the catalogue no longer holds. Unlike `gpuType` this - is read now rather than snapshotted, so it tells you how that GPU is supplied - today, not how it was supplied when the worker started. + How the catalogue currently provisions this worker's `gpuType`. + Omitted when there is no `gpuType`, or when the catalogue no longer + holds the code. Unlike `gpuType` this is read now rather than + snapshotted, so it tells you how that GPU is supplied today, not how + it was supplied when the worker started. lastSeenAt: type: string format: date-time diff --git a/internal/api/serverless/gen/client.gen.go b/internal/api/serverless/gen/client.gen.go index 9bfaa27..e7d37a8 100644 --- a/internal/api/serverless/gen/client.gen.go +++ b/internal/api/serverless/gen/client.gen.go @@ -424,6 +424,51 @@ func (e WorkerStatus) Valid() bool { } } +// Defines values for AppErrorStatusClass. +const ( + AppErrorStatusClassN4xx AppErrorStatusClass = "4xx" + AppErrorStatusClassN5xx AppErrorStatusClass = "5xx" +) + +// Valid indicates whether the value is a known member of the AppErrorStatusClass enum. +func (e AppErrorStatusClass) Valid() bool { + switch e { + case AppErrorStatusClassN4xx: + return true + case AppErrorStatusClassN5xx: + return true + default: + return false + } +} + +// Defines values for AppErrorWindow. +const ( + AppErrorWindowN1h AppErrorWindow = "1h" + AppErrorWindowN24h AppErrorWindow = "24h" + AppErrorWindowN30d AppErrorWindow = "30d" + AppErrorWindowN6h AppErrorWindow = "6h" + AppErrorWindowN7d AppErrorWindow = "7d" +) + +// Valid indicates whether the value is a known member of the AppErrorWindow enum. +func (e AppErrorWindow) Valid() bool { + switch e { + case AppErrorWindowN1h: + return true + case AppErrorWindowN24h: + return true + case AppErrorWindowN30d: + return true + case AppErrorWindowN6h: + return true + case AppErrorWindowN7d: + return true + default: + return false + } +} + // Defines values for MetricWindow. const ( MetricWindowN1h MetricWindow = "1h" @@ -472,6 +517,51 @@ func (e SelectorStatusClass) Valid() bool { } } +// Defines values for ListAppErrorsParamsWindow. +const ( + ListAppErrorsParamsWindowN1h ListAppErrorsParamsWindow = "1h" + ListAppErrorsParamsWindowN24h ListAppErrorsParamsWindow = "24h" + ListAppErrorsParamsWindowN30d ListAppErrorsParamsWindow = "30d" + ListAppErrorsParamsWindowN6h ListAppErrorsParamsWindow = "6h" + ListAppErrorsParamsWindowN7d ListAppErrorsParamsWindow = "7d" +) + +// Valid indicates whether the value is a known member of the ListAppErrorsParamsWindow enum. +func (e ListAppErrorsParamsWindow) Valid() bool { + switch e { + case ListAppErrorsParamsWindowN1h: + return true + case ListAppErrorsParamsWindowN24h: + return true + case ListAppErrorsParamsWindowN30d: + return true + case ListAppErrorsParamsWindowN6h: + return true + case ListAppErrorsParamsWindowN7d: + return true + default: + return false + } +} + +// Defines values for ListAppErrorsParamsStatusClass. +const ( + ListAppErrorsParamsStatusClassN4xx ListAppErrorsParamsStatusClass = "4xx" + ListAppErrorsParamsStatusClassN5xx ListAppErrorsParamsStatusClass = "5xx" +) + +// Valid indicates whether the value is a known member of the ListAppErrorsParamsStatusClass enum. +func (e ListAppErrorsParamsStatusClass) Valid() bool { + switch e { + case ListAppErrorsParamsStatusClassN4xx: + return true + case ListAppErrorsParamsStatusClassN5xx: + return true + default: + return false + } +} + // Defines values for GetLogEntriesParamsWindow. const ( GetLogEntriesParamsWindowN1h GetLogEntriesParamsWindow = "1h" @@ -555,7 +645,7 @@ type App struct { // AppId Immutable app identifier. Unique among the authenticated organisation's live apps: it cannot be changed after creation, and it becomes available again once the app it named reaches `deleted`. AppId AppId `json:"appId"` - // AppName Mutable display name. Must contain at least one non-whitespace character: it is what the console renders and what `sort=name` orders on, and it is not required to be unique. Unlike `appId` the pattern is unanchored, so interior spaces are allowed — only an entirely blank name is rejected. + // AppName Mutable display name. Must start and end with a non-whitespace character: it is what the console renders and what `sort=name` orders on, and it is not required to be unique. Interior spaces are allowed ("Sentiment Analysis"); leading or trailing whitespace is rejected, because a padded name is indistinguishable from its trimmed form in the console and breaks a name-confirm delete. AppName AppName `json:"appName"` // Configuration Live worker configuration. Updated via `PATCH /apps/{appId}`. @@ -568,7 +658,7 @@ type App struct { // IsFavourite Whether the authenticated organisation has favourited this app. Favourited apps sort ahead of non-favourited apps; toggled via `PUT`/`DELETE` `/v1/apps/{appId}/favourite`. IsFavourite bool `json:"isFavourite"` - // Runtime Observed state for one app at `calculatedAt`. Desired worker scale remains in `configuration`. Worker and GPU counts are always present; traffic and duration fields are omitted when their backing data is unavailable. + // Runtime Observed state for one app at `calculatedAt`. Desired worker scale remains in `configuration`. Worker and GPU counts are always present; traffic, duration, and queue fields are omitted when their backing data is unavailable. Runtime AppRuntime `json:"runtime"` // Secrets Secrets attached to this app, including any env-var name override. Populated on single-app responses; list of apps returns an empty array to avoid an N+1 — use `/apps/{appId}/secrets` to page the set. @@ -582,7 +672,7 @@ type AppCreate struct { // AppId Immutable app identifier. Unique among the authenticated organisation's live apps: it cannot be changed after creation, and it becomes available again once the app it named reaches `deleted`. AppId AppId `json:"appId"` - // AppName Mutable display name. Must contain at least one non-whitespace character: it is what the console renders and what `sort=name` orders on, and it is not required to be unique. Unlike `appId` the pattern is unanchored, so interior spaces are allowed — only an entirely blank name is rejected. + // AppName Mutable display name. Must start and end with a non-whitespace character: it is what the console renders and what `sort=name` orders on, and it is not required to be unique. Interior spaces are allowed ("Sentiment Analysis"); leading or trailing whitespace is rejected, because a padded name is indistinguishable from its trimmed form in the console and breaks a name-confirm delete. AppName AppName `json:"appName"` // AppSource Write-only. Source for the app's first version; not returned in the App response. Use the `/builds` endpoints to inspect build status. @@ -601,7 +691,7 @@ type AppCreate struct { // Each key must satisfy `EnvironmentVariableName` — POSIX-style, at most 128 characters. OpenAPI 3.0 cannot constrain map keys, so a bad one is rejected by the server rather than by the schema. Keys must also not collide with a secret's injected env var name on the same app (see `attachAppSecret`). EnvironmentVariables *map[string]string `json:"environmentVariables,omitempty"` - // Secrets Existing organisation secrets to attach to this app, with an optional env-var name override per entry. This is the app's initial attachment set, so the first rollout carries the values into the worker. The secret must already exist and be `active`; this route does not create one, and an unknown or inactive name returns `404`. Shape matches `POST /apps/{appId}/secrets` so create and attach share one contract. Each injected name must not collide with a key in `environmentVariables` — see `SecretAttach`. + // Secrets Existing organisation secrets to attach to this app, with an optional env-var name override per entry. This is the app's initial attachment set, so the first rollout carries the values into the worker. The secret must already exist and be `active`; this route does not create one, and an unknown or inactive name returns `404`. Shape matches `POST /apps/{appId}/secrets` so create and attach share one contract. Each injected name must not collide with a key in `environmentVariables` — see `SecretAttach`. Each secret can also be attached to at most 25 deployments total, shared with every other route that attaches it; an entry that would push a secret past that returns `422`. Secrets *[]SecretAttach `json:"secrets,omitempty"` // Volumes Persistent node-local directories bind-mounted through the checkpointer into the sandboxed application. Use these for downloaded weights and caches that must stay outside the checkpointed root filesystem. Paths must be unique and non-overlapping. The set is frozen into each immutable app version. @@ -628,10 +718,10 @@ type AppEventType string // AppId Immutable app identifier. Unique among the authenticated organisation's live apps: it cannot be changed after creation, and it becomes available again once the app it named reaches `deleted`. type AppId = string -// AppName Mutable display name. Must contain at least one non-whitespace character: it is what the console renders and what `sort=name` orders on, and it is not required to be unique. Unlike `appId` the pattern is unanchored, so interior spaces are allowed — only an entirely blank name is rejected. +// AppName Mutable display name. Must start and end with a non-whitespace character: it is what the console renders and what `sort=name` orders on, and it is not required to be unique. Interior spaces are allowed ("Sentiment Analysis"); leading or trailing whitespace is rejected, because a padded name is indistinguishable from its trimmed form in the console and breaks a name-confirm delete. type AppName = string -// AppRuntime Observed state for one app at `calculatedAt`. Desired worker scale remains in `configuration`. Worker and GPU counts are always present; traffic and duration fields are omitted when their backing data is unavailable. +// AppRuntime Observed state for one app at `calculatedAt`. Desired worker scale remains in `configuration`. Worker and GPU counts are always present; traffic, duration, and queue fields are omitted when their backing data is unavailable. type AppRuntime struct { // ActiveWorkers Non-terminal workers (`status` other than `stopped`) on this app. Pending workers count because they still hold capacity. ActiveWorkers int64 `json:"activeWorkers"` @@ -648,6 +738,9 @@ type AppRuntime struct { // ProvisionedGpuCount Sum of `gpuCount` across those workers. A pending worker contributes zero until Kubernetes schedules it onto a node. ProvisionedGpuCount int64 `json:"provisionedGpuCount"` + // QueueDepth Ready plus unacknowledged messages on this app's live inference queue. Omitted when the app is not live or the gauge cannot be read. Zero when the queue is live and empty. + QueueDepth *int64 `json:"queueDepth,omitempty"` + // Requests24h Requests served by this app in the last 24 hours. Omitted until available. Requests24h *int64 `json:"requests24h,omitempty"` } @@ -713,7 +806,7 @@ type AppSummary struct { // `appSource` starts a build and records version N+1 with a new image. The deploy queue rolls that version once the build is ready; `activeVersionId` moves only then. A builder rejection leaves the app on its current version. After accept, a late overlay race leaves the new version recorded and the previous attachments in place. // `secrets` replaces the attachment set and does not roll the workload. Endpoints are not a field of this contract at all: the set belongs to the app source, so it changes only when a new version with a new source builds and deploys, and an unknown `endpoints` field is rejected like any other. type AppUpdate struct { - // AppName Mutable display name; does not affect app identity or routing. Omit to leave unchanged — an explicit blank value is rejected, not treated as a clear. + // AppName Mutable display name; does not affect app identity or routing. Omit to leave unchanged — an explicit blank or padded value is rejected, not treated as a clear. AppName *AppName `json:"appName,omitempty"` // AppSource Write-only. New source to build and deploy; not returned in the App response. Use the `/builds` endpoints to inspect build status. Triggers a build (for `code` sources) or validates `container.yaml` then builds (for `container` sources). On accept the resulting version is recorded with a new image tag and rolled through the deploy queue. A builder rejection leaves the app on the previous version and writes no version or build row. After accept, a concurrent secret deactivation or env/secret collision leaves version N+1 recorded and the previous attachment set in place. `activeVersionId` moves only when that rollout completes. Not valid on a `stopped` or `stopping` app — there is nothing to roll the new version onto, and `resume` rolls the pinned one — so supplying it in those statuses returns `409 Conflict`. @@ -725,7 +818,7 @@ type AppUpdate struct { // EnvironmentVariables Replaces the app's environment variables. Keys are the variable names, values are the values. A key absent from the map is deleted. A null value omits that key from the new set. The resolved map is snapshotted onto the version this update records, so a deploy applies it. When the copied image is deployable the update pins and rolls, the same as a configuration change. EnvironmentVariables *map[string]*string `json:"environmentVariables,omitempty"` - // Secrets Replaces the app's secret attachments. Same `SecretAttach` shape as create and `POST /apps/{appId}/secrets`. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app. Control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so this field does not roll the workload. An app holds at most 100 environment bindings in total; this array cannot exceed that ceiling on its own. + // Secrets Replaces the app's secret attachments. Same `SecretAttach` shape as create and `POST /apps/{appId}/secrets`. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app. Control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so this field does not roll the workload. An app holds at most 100 environment bindings in total; this array cannot exceed that ceiling on its own. Each individual secret can also be attached to at most 25 deployments total, shared with every other route that attaches it. Secrets *[]SecretAttach `json:"secrets,omitempty"` } @@ -1520,13 +1613,13 @@ type Worker struct { // CreatedAt Pod creation time (`metadata.creationTimestamp`), not insert time. It is also the start of the worker's uptime. CreatedAt time.Time `json:"createdAt"` - // GpuAvailability How the catalogue currently provisions this worker's `gpuType`. Omitted for a CPU worker and for a code the catalogue no longer holds. Unlike `gpuType` this is read now rather than snapshotted, so it tells you how that GPU is supplied today, not how it was supplied when the worker started. + // GpuAvailability How the catalogue currently provisions this worker's `gpuType`. Omitted when there is no `gpuType`, or when the catalogue no longer holds the code. Unlike `gpuType` this is read now rather than snapshotted, so it tells you how that GPU is supplied today, not how it was supplied when the worker started. GpuAvailability *GpuAvailability `json:"gpuAvailability,omitempty"` - // GpuCount GPUs attached to this worker at observation time. + // GpuCount GPUs attached to this worker at observation time. Zero means the snapshot is unknown: the worker is still unscheduled, or a terminal observation could not read a GPU pair. A known snapshot is `>= 1` and travels with `gpuType`. An empty pair is incomplete, not a CPU worker. CPU workloads are not supported. GpuCount int32 `json:"gpuCount"` - // GpuType GPU catalogue code snapshotted at observation time; omitted for CPU workers. + // GpuType GPU catalogue code snapshotted at observation time. Omitted until a GPU snapshot exists (the worker is still unscheduled, or the observation could not read a type). Not a CPU-worker marker; CPU workloads are not supported. GpuType *GpuTypeId `json:"gpuType,omitempty"` Id openapi_types.UUID `json:"id"` @@ -1644,6 +1737,12 @@ type WorkerStateFilter string // WorkerStatus Worker lifecycle status. Also the type of `UsageEvent.eventType`, which records a ledger subset of these states (see that field — `busy` never appears there). `unhealthy` means the pod exists but failed to become or stay ready, not an intentional drain or stop. type WorkerStatus string +// AppErrorStatusClass defines model for AppErrorStatusClass. +type AppErrorStatusClass string + +// AppErrorWindow defines model for AppErrorWindow. +type AppErrorWindow string + // Cursor defines model for Cursor. type Cursor = string @@ -1659,6 +1758,9 @@ type PinnedTo = int64 // QueryId defines model for QueryId. type QueryId = string +// RequiredLogDeployment Immutable app identifier. Unique among the authenticated organisation's live apps: it cannot be changed after creation, and it becomes available again once the app it named reaches `deleted`. +type RequiredLogDeployment = AppId + // SelectorDeployment Immutable app identifier. Unique among the authenticated organisation's live apps: it cannot be changed after creation, and it becomes available again once the app it named reaches `deleted`. type SelectorDeployment = AppId @@ -1695,6 +1797,9 @@ type Forbidden = ProblemDetails // GatewayTimeout RFC 9457 problem details. Every error response from this API uses this schema with media type `application/problem+json`. `type` is a URI that identifies the problem class and dereferences to its documentation; clients should switch on `type` (not `status` or `detail`, which are not stable identifiers). Additional members beyond those below may appear. type GatewayTimeout = ProblemDetails +// InternalServerError RFC 9457 problem details. Every error response from this API uses this schema with media type `application/problem+json`. `type` is a URI that identifies the problem class and dereferences to its documentation; clients should switch on `type` (not `status` or `detail`, which are not stable identifiers). Additional members beyond those below may appear. +type InternalServerError = ProblemDetails + // NotFound RFC 9457 problem details. Every error response from this API uses this schema with media type `application/problem+json`. `type` is a URI that identifies the problem class and dereferences to its documentation; clients should switch on `type` (not `status` or `detail`, which are not stable identifiers). Additional members beyond those below may appear. type NotFound = ProblemDetails @@ -1759,6 +1864,27 @@ type ListAppEnvironmentVariablesParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` } +// ListAppErrorsParams defines parameters for ListAppErrors. +type ListAppErrorsParams struct { + // Limit Maximum number of items to return. + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + + // Cursor Opaque pagination cursor returned as `nextCursor` by a previous call. + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + + // Window The range to search. Same closed ladder as the metrics queries. Defaults to the last 24 hours. + Window *ListAppErrorsParamsWindow `form:"window,omitempty" json:"window,omitempty"` + + // StatusClass Narrow to one error class. Omit for both 4xx and 5xx. + StatusClass *ListAppErrorsParamsStatusClass `form:"statusClass,omitempty" json:"statusClass,omitempty"` +} + +// ListAppErrorsParamsWindow defines parameters for ListAppErrors. +type ListAppErrorsParamsWindow string + +// ListAppErrorsParamsStatusClass defines parameters for ListAppErrors. +type ListAppErrorsParamsStatusClass string + // ListAppEventsParams defines parameters for ListAppEvents. type ListAppEventsParams struct { // Limit Maximum number of items to return. @@ -1805,6 +1931,9 @@ type ListWorkersParams struct { // Cursor Opaque pagination cursor returned as `nextCursor` by a previous call. Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + // VersionId Scope the page to one version. The default is the app's `activeVersionId`. When that field is unset the default page is empty: the app has no active version, not that it has no workers. Send `all` (any case) to include every version. An empty value is refused. A cursor must be replayed under the same version scope it was issued with. + VersionId *string `form:"versionId,omitempty" json:"versionId,omitempty"` + // State Narrow the page by worker state. The default, `all`, keeps the terminal `stopped` rows in the page; `live` drops them. // // A `state` of `live` with a `status` of `stopped` is a contradiction and is refused, because an empty page would read as "this app has never run". @@ -1842,6 +1971,12 @@ type GetLogEntriesParams struct { // GetLogEntriesParamsWindow defines parameters for GetLogEntries. type GetLogEntriesParamsWindow string +// TailLogEntriesParams defines parameters for TailLogEntries. +type TailLogEntriesParams struct { + // Deployment App whose new log entries are streamed. + Deployment RequiredLogDeployment `form:"deployment" json:"deployment"` +} + // GetMetricSeriesParams defines parameters for GetMetricSeries. type GetMetricSeriesParams struct { // Window The time window. A closed set rather than a free-form range, because every distinct range defeats the server-side cache alignment that makes a sliding window cheap. Only the windows a query lists in the catalogue can be asked of it. @@ -1862,10 +1997,10 @@ type GetMetricSeriesParams struct { // Region Narrow to one region. No series carries a region label yet, so no query currently accepts this and supplying it is rejected rather than ignored. Region *SelectorRegion `form:"region,omitempty" json:"region,omitempty"` - // AppId Restrict expand-by-`app_id` queries (`apps_request_volume`, `apps_error_volume`, `apps_request_duration`) to these app ids: one series per id, in request order, with all-null series for apps that had no samples. Values are hourly over `window=24h`. Hours that started before that live app's `createdAt` are null. Repeat the parameter once per id on the current list page (at most 100, matching `listApps`). Omit it to receive every app in the organisation that had data, unless that set is larger than this query will expand: then the call is a `422` on `appId` and the list page should name the apps it is showing. Other queries reject this parameter. + // AppId Restrict the list-scoped queries (`apps_request_volume`, `apps_error_volume`, `apps_request_duration`) to these app ids: one series per id, in request order, with all-null series for apps that had no samples. Values are quarter-hourly over `window=24h`. An id with no live app is dropped rather than refused: a deleted app has no traffic to pad. Repeat the parameter once per id on the current list page (at most 100, matching `listApps`). Omit it to receive every app in the organisation that had data, unless that set is larger than this query will expand: then the call is a `422` on `appId` and the list page should name the apps it is showing. Other queries reject this parameter. AppId *SeriesAppId `form:"appId,omitempty" json:"appId,omitempty"` - // EndpointId Restrict `endpoints_request_volume` to these endpoint ids: one series per id, in request order, with all-null series for endpoints that had no traffic. Values are hourly request counts over `window=24h`. Hours that started before that endpoint row's `createdAt` are null. Repeat the parameter once per id on the current list page (at most 100, matching `listEndpoints`). Omit it to receive every endpoint on the selected app that had data, unless that set is larger than this query will expand: then the call is a `422` on `endpointId` and the list page should name the endpoints it is showing. Other queries reject this parameter. Requires `deployment`. + // EndpointId Restrict `endpoints_request_volume` to these endpoint ids: one series per id, in request order, with all-null series for endpoints that had no traffic. Values are quarter-hourly request counts over `window=24h`. Buckets that started before that endpoint row's `createdAt` are null. Repeat the parameter once per id on the current list page (at most 100, matching `listEndpoints`). Omit it to receive every endpoint on the selected app that had data, unless that set is larger than this query will expand: then the call is a `422` on `endpointId` and the list page should name the endpoints it is showing. Other queries reject this parameter. Requires `deployment`. EndpointId *SeriesEndpointId `form:"endpointId,omitempty" json:"endpointId,omitempty"` } @@ -2234,9 +2369,9 @@ type ClientInterface interface { // // Patches one or more aspects of an app in place. All fields are optional; omitted fields are left unchanged. Valid in any non-`deleted` status, including `stopped` (changes apply on `resume`). Lifecycle transitions use the dedicated `deploy`, `stop`, `resume`, and `delete` operations. // A configuration or `environmentVariables` change records a new version with the same image. If that image is deployable, the update pins it as `activeVersionId` and rolls the workload when the app is `active` or `initializing`. A `failed` app is moved to `initializing` and rolled, the same as `POST /deploy`. If the image is not deployable, the version is recorded and `activeVersionId` is left unchanged. If the roll fails, `activeVersionId` is restored and the previous configuration keeps serving. A name-only change records a version and does not pin. A `stopped` or `stopping` app pins the version and rolls it on `resume`. A configuration, `environmentVariables`, or `appSource` change while a create or resume rollout is already in progress returns `409 Conflict`. A name-only or `secrets`-only change does not. - // `appSource` starts a build and records version N+1 with a new image tag. The deploy queue carries the build-then-deploy tail; `activeVersionId` moves only when that rollout completes. A builder rejection (400 where a container document's parser refused it, 422 where it parsed and broke a rule) leaves the app on its current version and writes no version row and no build row. After the builder accepts, version N+1 is recorded even if a concurrent secret deactivation or env/secret collision prevents this request's env/secrets overlay; in that case the previous environmentVariables and attachment set stay in place and are what the new version snapshots. + // `appSource` starts a build and records version N+1 with a new image tag. The deploy queue carries the build-then-deploy tail; `activeVersionId` moves only when that rollout completes. A builder rejection (400 where a container document's parser refused it, 422 where it parsed and broke a rule) leaves the app on its current version and writes no version row and no build row. After the builder accepts, version N+1 is recorded even if a concurrent secret deactivation, an env/secret collision, or the 25-deployment attachment ceiling below prevents this request's env/secrets overlay; in that case the previous environmentVariables and attachment set stay in place and are what the new version snapshots, and the `422` the standalone case below returns does not apply here — the request still succeeds. // `environmentVariables` replaces the whole set: a key absent from the map is deleted, and a null value omits that key from the new set. The resolved map is snapshotted onto the new version. - // `secrets` replaces the whole attachment set. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app; the combined set of plain variables and attachments is capped at 100. This is a control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so a secrets-only change does not roll the workload. + // `secrets` replaces the whole attachment set. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app; the combined set of plain variables and attachments is capped at 100, and each individual secret can be attached to at most 25 deployments total — an entry that would push a secret past that returns `422`. This is a control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so a secrets-only change does not roll the workload. // Endpoints are not a field of this contract: the set belongs to the app source, so it changes only when a new version with a new source builds and deploys. // // Takes any type of body and a specified content type. @@ -2248,9 +2383,9 @@ type ClientInterface interface { // // Patches one or more aspects of an app in place. All fields are optional; omitted fields are left unchanged. Valid in any non-`deleted` status, including `stopped` (changes apply on `resume`). Lifecycle transitions use the dedicated `deploy`, `stop`, `resume`, and `delete` operations. // A configuration or `environmentVariables` change records a new version with the same image. If that image is deployable, the update pins it as `activeVersionId` and rolls the workload when the app is `active` or `initializing`. A `failed` app is moved to `initializing` and rolled, the same as `POST /deploy`. If the image is not deployable, the version is recorded and `activeVersionId` is left unchanged. If the roll fails, `activeVersionId` is restored and the previous configuration keeps serving. A name-only change records a version and does not pin. A `stopped` or `stopping` app pins the version and rolls it on `resume`. A configuration, `environmentVariables`, or `appSource` change while a create or resume rollout is already in progress returns `409 Conflict`. A name-only or `secrets`-only change does not. - // `appSource` starts a build and records version N+1 with a new image tag. The deploy queue carries the build-then-deploy tail; `activeVersionId` moves only when that rollout completes. A builder rejection (400 where a container document's parser refused it, 422 where it parsed and broke a rule) leaves the app on its current version and writes no version row and no build row. After the builder accepts, version N+1 is recorded even if a concurrent secret deactivation or env/secret collision prevents this request's env/secrets overlay; in that case the previous environmentVariables and attachment set stay in place and are what the new version snapshots. + // `appSource` starts a build and records version N+1 with a new image tag. The deploy queue carries the build-then-deploy tail; `activeVersionId` moves only when that rollout completes. A builder rejection (400 where a container document's parser refused it, 422 where it parsed and broke a rule) leaves the app on its current version and writes no version row and no build row. After the builder accepts, version N+1 is recorded even if a concurrent secret deactivation, an env/secret collision, or the 25-deployment attachment ceiling below prevents this request's env/secrets overlay; in that case the previous environmentVariables and attachment set stay in place and are what the new version snapshots, and the `422` the standalone case below returns does not apply here — the request still succeeds. // `environmentVariables` replaces the whole set: a key absent from the map is deleted, and a null value omits that key from the new set. The resolved map is snapshotted onto the new version. - // `secrets` replaces the whole attachment set. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app; the combined set of plain variables and attachments is capped at 100. This is a control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so a secrets-only change does not roll the workload. + // `secrets` replaces the whole attachment set. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app; the combined set of plain variables and attachments is capped at 100, and each individual secret can be attached to at most 25 deployments total — an entry that would push a secret past that returns `422`. This is a control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so a secrets-only change does not roll the workload. // Endpoints are not a field of this contract: the set belongs to the app source, so it changes only when a new version with a new source builds and deploys. // // Takes a body of the `application/json` content type. @@ -2351,6 +2486,13 @@ type ClientInterface interface { // Corresponds with PUT /v1/apps/{appId}/environment-variables/{variableName} (the `UpdateAppEnvironmentVariable` operationId). UpdateAppEnvironmentVariable(ctx context.Context, appId AppId, variableName EnvironmentVariableName, body UpdateAppEnvironmentVariableJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListAppErrors List request errors for an app + // + // One page of failed inference requests for this app, newest first. Omit `statusClass` for both 4xx and 5xx. The cursor is opaque and is only valid with the same `window` and `statusClass` it was issued under. + // + // Corresponds with GET /v1/apps/{appId}/errors (the `ListAppErrors` operationId). + ListAppErrors(ctx context.Context, appId AppId, params *ListAppErrorsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListAppEvents List app events // // Corresponds with GET /v1/apps/{appId}/events (the `ListAppEvents` operationId). @@ -2372,7 +2514,7 @@ type ClientInterface interface { // StartAsyncTaskWithBody Start a new async task // - // Starts a new async task on `appId`, routing the request body payload to an available worker. The task runs asynchronously and the response is `202`; poll `GET /v1/apps/{appId}/tasks/{taskId}` for completion. Resubmitting a task id is answered with the task it already names rather than starting a second one, so the `202` can carry a task that has already finished: read its `status` instead of assuming `pending`, and note it may name a different `appId`. Apps in `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. + // Starts a new async task on `appId`, routing the request body payload to an available worker. The task runs asynchronously and the response is `202`; poll `GET /v1/apps/{appId}/tasks/{taskId}` for completion. Resubmitting a task id is answered with the task it already names rather than starting a second one, so the `202` can carry a task that has already finished: read its `status` instead of assuming `pending`, and note it may name a different `appId`. Apps in `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an `initializing` app whose first rollout has not produced a version returns `409 Conflict`, and an `active` app the platform has observed to have no workload able to serve returns `503 Service Unavailable` with no task minted. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. // // Takes any type of body and a specified content type. // @@ -2381,7 +2523,7 @@ type ClientInterface interface { // StartAsyncTask Start a new async task // - // Starts a new async task on `appId`, routing the request body payload to an available worker. The task runs asynchronously and the response is `202`; poll `GET /v1/apps/{appId}/tasks/{taskId}` for completion. Resubmitting a task id is answered with the task it already names rather than starting a second one, so the `202` can carry a task that has already finished: read its `status` instead of assuming `pending`, and note it may name a different `appId`. Apps in `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. + // Starts a new async task on `appId`, routing the request body payload to an available worker. The task runs asynchronously and the response is `202`; poll `GET /v1/apps/{appId}/tasks/{taskId}` for completion. Resubmitting a task id is answered with the task it already names rather than starting a second one, so the `202` can carry a task that has already finished: read its `status` instead of assuming `pending`, and note it may name a different `appId`. Apps in `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an `initializing` app whose first rollout has not produced a version returns `409 Conflict`, and an `active` app the platform has observed to have no workload able to serve returns `503 Service Unavailable` with no task minted. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. // // Takes a body of the `application/json` content type. // @@ -2390,7 +2532,7 @@ type ClientInterface interface { // StartSyncTaskWithBody Start a new sync task // - // Starts a new sync task on `appId`, routing the request body payload to an available worker. The request blocks until the task is terminal and returns the result inline (`200`). Resubmitting a task id waits on the task it already names rather than starting a second one, so the `200` carries that task's result and may name a different `appId` — poll it under the one returned. A task that outlives the wait window is **not** a failure: the task is still queued or running, and the response is `202` carrying that task with `status: pending` — the same shape `invoke-async` returns, and it names the owning `appId` on a resubmission just as the `200` does. Poll `GET /v1/apps/{appId}/tasks/{taskId}` for its result. A request the platform cannot attribute to an accepted task fails instead, with no task to poll. Apps in `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. + // Starts a new sync task on `appId`, routing the request body payload to an available worker. The request blocks until the task is terminal and returns the result inline (`200`). Resubmitting a task id waits on the task it already names rather than starting a second one, so the `200` carries that task's result and may name a different `appId` — poll it under the one returned. A task that outlives the wait window is **not** a failure: the task is still queued or running, and the response is `202` carrying that task with `status: pending` — the same shape `invoke-async` returns, and it names the owning `appId` on a resubmission just as the `200` does. Poll `GET /v1/apps/{appId}/tasks/{taskId}` for its result. A request the platform cannot attribute to an accepted task fails instead, with no task to poll. Apps in `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an `initializing` app whose first rollout has not produced a version returns `409 Conflict`, and an `active` app the platform has observed to have no workload able to serve returns `503 Service Unavailable` with no task minted. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. // // Takes any type of body and a specified content type. // @@ -2399,7 +2541,7 @@ type ClientInterface interface { // StartSyncTask Start a new sync task // - // Starts a new sync task on `appId`, routing the request body payload to an available worker. The request blocks until the task is terminal and returns the result inline (`200`). Resubmitting a task id waits on the task it already names rather than starting a second one, so the `200` carries that task's result and may name a different `appId` — poll it under the one returned. A task that outlives the wait window is **not** a failure: the task is still queued or running, and the response is `202` carrying that task with `status: pending` — the same shape `invoke-async` returns, and it names the owning `appId` on a resubmission just as the `200` does. Poll `GET /v1/apps/{appId}/tasks/{taskId}` for its result. A request the platform cannot attribute to an accepted task fails instead, with no task to poll. Apps in `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. + // Starts a new sync task on `appId`, routing the request body payload to an available worker. The request blocks until the task is terminal and returns the result inline (`200`). Resubmitting a task id waits on the task it already names rather than starting a second one, so the `200` carries that task's result and may name a different `appId` — poll it under the one returned. A task that outlives the wait window is **not** a failure: the task is still queued or running, and the response is `202` carrying that task with `status: pending` — the same shape `invoke-async` returns, and it names the owning `appId` on a resubmission just as the `200` does. Poll `GET /v1/apps/{appId}/tasks/{taskId}` for its result. A request the platform cannot attribute to an accepted task fails instead, with no task to poll. Apps in `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an `initializing` app whose first rollout has not produced a version returns `409 Conflict`, and an `active` app the platform has observed to have no workload able to serve returns `503 Service Unavailable` with no task minted. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. // // Takes a body of the `application/json` content type. // @@ -2420,9 +2562,10 @@ type ClientInterface interface { // AttachAppSecretWithBody Attach a secret to an app // - // Records that an organisation secret is attached to an app under a resolved env-var name. The next rollout injects the value into the worker. This operation does not roll workers. Returns `409` if the secret is already attached, or if another attach would use the same env-var name. + // Records that an organisation secret is attached to an app under a resolved env-var name, and rolls the app's live deployment in place so a running worker picks up the value without waiting for an unrelated deploy. If a rollout is already in progress when this commits, this attach is not guaranteed to land on it — it reaches the worker on a later redeploy instead. A deployment that is not live records the attachment only — the next resume reads the attach set fresh. Returns `409` if the secret is already attached, or if another attach would use the same env-var name. // The resolved name (`envVarName`, or `secretName` when omitted) must not already exist as a plain environment variable on this app (`deployment_configs.key`). Both sources use the same pod env namespace, so the server rejects the collision with `422` instead of allowing a last-wins override later. The reverse check applies when setting a plain environment variable. // An app holds at most 100 environment bindings in total — plain environment variables plus attached secrets — the same combined ceiling as create and the single-key env-var route. Attaching when the app is already at that limit returns `422`. + // A secret can be attached to at most 25 deployments — rolling every attached deployment is what an update or detach costs, so the ceiling bounds that cost rather than the app side of the binding. Attaching past it returns `422`. // // Takes any type of body and a specified content type. // @@ -2431,9 +2574,10 @@ type ClientInterface interface { // AttachAppSecret Attach a secret to an app // - // Records that an organisation secret is attached to an app under a resolved env-var name. The next rollout injects the value into the worker. This operation does not roll workers. Returns `409` if the secret is already attached, or if another attach would use the same env-var name. + // Records that an organisation secret is attached to an app under a resolved env-var name, and rolls the app's live deployment in place so a running worker picks up the value without waiting for an unrelated deploy. If a rollout is already in progress when this commits, this attach is not guaranteed to land on it — it reaches the worker on a later redeploy instead. A deployment that is not live records the attachment only — the next resume reads the attach set fresh. Returns `409` if the secret is already attached, or if another attach would use the same env-var name. // The resolved name (`envVarName`, or `secretName` when omitted) must not already exist as a plain environment variable on this app (`deployment_configs.key`). Both sources use the same pod env namespace, so the server rejects the collision with `422` instead of allowing a last-wins override later. The reverse check applies when setting a plain environment variable. // An app holds at most 100 environment bindings in total — plain environment variables plus attached secrets — the same combined ceiling as create and the single-key env-var route. Attaching when the app is already at that limit returns `422`. + // A secret can be attached to at most 25 deployments — rolling every attached deployment is what an update or detach costs, so the ceiling bounds that cost rather than the app side of the binding. Attaching past it returns `422`. // // Takes a body of the `application/json` content type. // @@ -2442,7 +2586,7 @@ type ClientInterface interface { // DetachAppSecret Detach a secret from an app // - // Removes the attachment from the next rollout. This operation does not roll workers. Existing workers keep the value until they stop. + // Removes the attachment and rolls the app's live deployment in place so a running worker stops receiving the value. If a rollout is already in progress when this commits, this detach is not guaranteed to land on it — the worker stops receiving the value on a later redeploy instead. A deployment that is not live has the removal recorded only — there is nothing to roll until it resumes. // // Corresponds with DELETE /v1/apps/{appId}/secrets/{secretName} (the `DetachAppSecret` operationId). DetachAppSecret(ctx context.Context, appId AppId, secretName SecretName, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2487,7 +2631,7 @@ type ClientInterface interface { // ListWorkers List workers // - // Returns a newest-first page of workers observed for the app (including terminal `stopped` rows until purged). Optional `state` and `status` narrow the page; a cursor must be replayed under the same filters it was issued with. + // Returns a newest-first page of workers observed for the app (including terminal `stopped` rows until purged). Omitted `versionId` scopes the page to the app's `activeVersionId`. An app with no active version therefore answers an empty default page — not that it has no workers, only that none are pinned. Optional `state` and `status` narrow the page further; a cursor must be replayed under the same filters it was issued with. // // Corresponds with GET /v1/apps/{appId}/workers (the `ListWorkers` operationId). ListWorkers(ctx context.Context, appId AppId, params *ListWorkersParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2508,7 +2652,7 @@ type ClientInterface interface { // CreateGpuTypeWithBody Add a GPU type to the catalogue // - // Creates a new entry in the global GPU type catalogue. Restricted to the Runware platform organization. The `id` (catalogue code) is immutable and remains reserved after retirement. + // Creates a new entry in the global GPU type catalogue. Restricted to the Runware platform organization. The `id` (catalogue code) is immutable and remains reserved after retirement: a retired code answers `409` here and is brought back with `POST /v1/gpu-types/{gpuTypeId}/restore` rather than recreated. // // Takes any type of body and a specified content type. // @@ -2517,7 +2661,7 @@ type ClientInterface interface { // CreateGpuType Add a GPU type to the catalogue // - // Creates a new entry in the global GPU type catalogue. Restricted to the Runware platform organization. The `id` (catalogue code) is immutable and remains reserved after retirement. + // Creates a new entry in the global GPU type catalogue. Restricted to the Runware platform organization. The `id` (catalogue code) is immutable and remains reserved after retirement: a retired code answers `409` here and is brought back with `POST /v1/gpu-types/{gpuTypeId}/restore` rather than recreated. // // Takes a body of the `application/json` content type. // @@ -2526,7 +2670,7 @@ type ClientInterface interface { // DeleteGpuType Retire a GPU type from the catalogue // - // Soft-deletes a GPU type while preserving its code and price history. Restricted to the Runware platform organization. Returns `409` if a worker configuration or GPU pool still references the code. Retiring an already retired code returns `404`. + // Soft-deletes a GPU type while preserving its code and price history. Restricted to the Runware platform organization. Returns `409` if a worker configuration or GPU pool still references the code. Retiring an already retired code returns `404`. Reversible with `POST /v1/gpu-types/{gpuTypeId}/restore`. // // Corresponds with DELETE /v1/gpu-types/{gpuTypeId} (the `DeleteGpuType` operationId). DeleteGpuType(ctx context.Context, gpuTypeId GpuTypeId, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2606,15 +2750,27 @@ type ClientInterface interface { // Corresponds with PATCH /v1/gpu-types/{gpuTypeId}/prices/{priceId} (the `UpdateGpuTypePrice` operationId). UpdateGpuTypePrice(ctx context.Context, gpuTypeId GpuTypeId, priceId openapi_types.UUID, body UpdateGpuTypePriceJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - // GetLogEntries Read one page of a named log query + // RestoreGpuType Restore a retired GPU type + // + // Returns a retired GPU type to the catalogue under the same code, with its price history intact, so the reserved code is usable again for the hardware it already described. Restricted to the Runware platform organization. A code that is not retired returns `409`; a code no entry has ever held returns `404`. Whether customers can then select the type still depends on pool admission, exactly as for any active type. // - // Returns one page of log entries, newest first, with an opaque cursor for the next page when one exists. + // Corresponds with POST /v1/gpu-types/{gpuTypeId}/restore (the `RestoreGpuType` operationId). + RestoreGpuType(ctx context.Context, gpuTypeId GpuTypeId, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetLogEntries Read one page of a named log query // - // No query is registered yet: live tail, retention tiers and log quotas are decided in a follow-up ADR, so every request currently answers `404`. The route exists so the contract is fixed before the templates land. + // Returns one page of log entries, newest first, with an opaque cursor for the next page when one exists. Query ids and their supported selectors are listed by the insights catalogue. // // Corresponds with GET /v1/logs/queries/{queryId}/entries (the `GetLogEntries` operationId). GetLogEntries(ctx context.Context, queryId QueryId, params *GetLogEntriesParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // TailLogEntries Follow a named application log query + // + // Streams new application log entries as Server-Sent Events. The stream sends keepalive comments while quiet and ends with an `end` event when its connection lifetime expires or the service shuts down. Clients should reconnect after an `end` event. Use the `runtime_tail` query id; queries that sort or aggregate are rejected because they cannot be followed live. + // + // Corresponds with GET /v1/logs/queries/{queryId}/tail (the `TailLogEntries` operationId). + TailLogEntries(ctx context.Context, queryId QueryId, params *TailLogEntriesParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListInsightsQueries List the metric and log queries this build can answer // // The catalogue: every named query, its unit and aggregation, the selectors it accepts, the series it returns, and the windows actually backed by stored series. @@ -2632,9 +2788,11 @@ type ClientInterface interface { // // An organization with no metrics yet is answered with the full axis and all-null series rather than an error. // - // `apps_request_volume` returns one series per app: 24 hourly request counts over `window=24h` (`step_s` 3600, unit `requests`). Repeat `appId` once per id on the current list page to pad idle apps with all-null series, in request order. Hours that started before that live app's `createdAt` are null, so a reused app id does not inherit the previous generation's traffic still in the 24h store. The same `appId` pad applies to the list-scoped `apps_error_volume` and `apps_request_duration` queries. Other queries reject `appId`. Other windows are not available for these queries. + // `apps_request_volume` returns one series per app: 96 quarter-hour request counts over `window=24h` (`step_s` 900, unit `requests`). Repeat `appId` once per id on the current list page to pad idle apps with all-null series, in request order. A series is named for the live app behind it, so a reused app id reports its own generation's traffic and not the one before it. The same `appId` pad applies to the list-scoped `apps_error_volume` and `apps_request_duration` queries. Other queries reject `appId`. Other windows are not available for these queries. + // + // `endpoints_request_volume` is the endpoints-list counterpart: 96 quarter-hour request counts per endpoint over `window=24h` (`step_s` 900, unit `requests`). It requires `deployment` (the public app id, rewritten to the live deployment UUID). Repeat `endpointId` once per id on the current `listEndpoints` page to pad idle endpoints with all-null series, in request order. Buckets that started before that endpoint row's `createdAt` are null, so a removed-then-readded path does not inherit the previous row's traffic. Other queries reject `endpointId`. Other windows are not available for this query. // - // `endpoints_request_volume` is the endpoints-list counterpart: 24 hourly request counts per endpoint over `window=24h` (`step_s` 3600, unit `requests`). It requires `deployment` (the public app id, rewritten to the live deployment UUID). Repeat `endpointId` once per id on the current `listEndpoints` page to pad idle endpoints with all-null series, in request order. Hours that started before that endpoint row's `createdAt` are null, so a removed-then-readded path does not inherit the previous row's traffic. Other queries reject `endpointId`. Other windows are not available for this query. + // Three queries serve one app's overview, all over `window=24h` at `step_s` 900 and all requiring `deployment` (the public app id, rewritten to the live deployment UUID): `app_traffic_24h` returns `requests`, `client_errors` (4xx) and `server_errors` (5xx) as request counts; `app_worker_seconds_24h` returns `startup`, `execution` and `idle` as worker-seconds, whose three values in a bucket sum to that bucket's worker time; and `app_cold_starts_24h` returns `cold_starts` as a count. They report counts and totals rather than rates or ratios, so a per-minute figure is a bucket value divided by `step_s / 60` and a 24h ratio is one summed axis over another — summing first and dividing once, because averaging a per-bucket ratio across the axis does not give the 24h ratio. These queries are absent from `listInsightsQueries`: they back the overview rather than the Metrics tab. Other windows are not available for them. // // Corresponds with GET /v1/metrics/queries/{queryId}/series (the `GetMetricSeries` operationId). GetMetricSeries(ctx context.Context, queryId QueryId, params *GetMetricSeriesParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2692,13 +2850,15 @@ type ClientInterface interface { // DeleteSecret Delete a secret // - // Soft-deletes a secret: marks the row `pending_destroy` and bumps revision. This API does not hard-delete the row. A background sweep removes the row and releases the name once no running worker can still hold the value — the value travels inside the worker's own environment, which is fixed when the container starts, so a worker keeps it until it stops. There is no deadline on that wait. Returns `409` while any app still attaches it — cascade-detach is not performed here; detach each holder with `DELETE .../apps/{id}/secrets/{name}` first. Attach and detach change the secret set for the next rollout. Neither operation rolls workers. While the row remains `pending_destroy` the name stays reserved, so create may return `409` even though list no longer shows the secret. Retries on an already-pending name are safe when no attachments remain (`204`); they still return `409` while attached. + // Soft-deletes a secret: marks the row `pending_destroy` and bumps revision. This API does not hard-delete the row. Returns `409` while any app still attaches it — cascade-detach is not performed here; detach each holder with `DELETE .../apps/{id}/secrets/{name}` first, which rolls the deployments it names in place. A background sweep removes the row and releases the name once no running worker can still hold the value, rather than assuming every roll a detach started actually landed — a deployment that was not live when detached has nothing to roll until it resumes, so the sweep is the actual backstop, not the detach. While the row remains `pending_destroy` the name stays reserved, so create may return `409` even though list no longer shows the secret. Retries on an already-pending name are safe when no attachments remain (`204`); they still return `409` while attached. // // Corresponds with DELETE /v1/secrets/{secretName} (the `DeleteSecret` operationId). DeleteSecret(ctx context.Context, secretName SecretName, reqEditors ...RequestEditorFn) (*http.Response, error) // UpdateSecretWithBody Update a secret // + // Re-encrypts the value under the same name (no rename). Rolls every live deployment that attaches this secret in place so a running worker picks up the new value. If a rollout is already in progress when this commits, this change is not guaranteed to land on it — it reaches the worker on a later redeploy instead. A deployment that is not live picks it up on its next deploy for another reason. + // // Takes any type of body and a specified content type. // // Corresponds with PUT /v1/secrets/{secretName} (the `UpdateSecret` operationId). @@ -2706,6 +2866,8 @@ type ClientInterface interface { // UpdateSecret Update a secret // + // Re-encrypts the value under the same name (no rename). Rolls every live deployment that attaches this secret in place so a running worker picks up the new value. If a rollout is already in progress when this commits, this change is not guaranteed to land on it — it reaches the worker on a later redeploy instead. A deployment that is not live picks it up on its next deploy for another reason. + // // Takes a body of the `application/json` content type. // // Corresponds with PUT /v1/secrets/{secretName} (the `UpdateSecret` operationId). @@ -2911,9 +3073,9 @@ func (c *Client) GetApp(ctx context.Context, appId AppId, reqEditors ...RequestE // // Patches one or more aspects of an app in place. All fields are optional; omitted fields are left unchanged. Valid in any non-`deleted` status, including `stopped` (changes apply on `resume`). Lifecycle transitions use the dedicated `deploy`, `stop`, `resume`, and `delete` operations. // A configuration or `environmentVariables` change records a new version with the same image. If that image is deployable, the update pins it as `activeVersionId` and rolls the workload when the app is `active` or `initializing`. A `failed` app is moved to `initializing` and rolled, the same as `POST /deploy`. If the image is not deployable, the version is recorded and `activeVersionId` is left unchanged. If the roll fails, `activeVersionId` is restored and the previous configuration keeps serving. A name-only change records a version and does not pin. A `stopped` or `stopping` app pins the version and rolls it on `resume`. A configuration, `environmentVariables`, or `appSource` change while a create or resume rollout is already in progress returns `409 Conflict`. A name-only or `secrets`-only change does not. -// `appSource` starts a build and records version N+1 with a new image tag. The deploy queue carries the build-then-deploy tail; `activeVersionId` moves only when that rollout completes. A builder rejection (400 where a container document's parser refused it, 422 where it parsed and broke a rule) leaves the app on its current version and writes no version row and no build row. After the builder accepts, version N+1 is recorded even if a concurrent secret deactivation or env/secret collision prevents this request's env/secrets overlay; in that case the previous environmentVariables and attachment set stay in place and are what the new version snapshots. +// `appSource` starts a build and records version N+1 with a new image tag. The deploy queue carries the build-then-deploy tail; `activeVersionId` moves only when that rollout completes. A builder rejection (400 where a container document's parser refused it, 422 where it parsed and broke a rule) leaves the app on its current version and writes no version row and no build row. After the builder accepts, version N+1 is recorded even if a concurrent secret deactivation, an env/secret collision, or the 25-deployment attachment ceiling below prevents this request's env/secrets overlay; in that case the previous environmentVariables and attachment set stay in place and are what the new version snapshots, and the `422` the standalone case below returns does not apply here — the request still succeeds. // `environmentVariables` replaces the whole set: a key absent from the map is deleted, and a null value omits that key from the new set. The resolved map is snapshotted onto the new version. -// `secrets` replaces the whole attachment set. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app; the combined set of plain variables and attachments is capped at 100. This is a control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so a secrets-only change does not roll the workload. +// `secrets` replaces the whole attachment set. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app; the combined set of plain variables and attachments is capped at 100, and each individual secret can be attached to at most 25 deployments total — an entry that would push a secret past that returns `422`. This is a control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so a secrets-only change does not roll the workload. // Endpoints are not a field of this contract: the set belongs to the app source, so it changes only when a new version with a new source builds and deploys. // // Takes any type of body and a specified content type. @@ -2935,9 +3097,9 @@ func (c *Client) UpdateAppWithBody(ctx context.Context, appId AppId, contentType // // Patches one or more aspects of an app in place. All fields are optional; omitted fields are left unchanged. Valid in any non-`deleted` status, including `stopped` (changes apply on `resume`). Lifecycle transitions use the dedicated `deploy`, `stop`, `resume`, and `delete` operations. // A configuration or `environmentVariables` change records a new version with the same image. If that image is deployable, the update pins it as `activeVersionId` and rolls the workload when the app is `active` or `initializing`. A `failed` app is moved to `initializing` and rolled, the same as `POST /deploy`. If the image is not deployable, the version is recorded and `activeVersionId` is left unchanged. If the roll fails, `activeVersionId` is restored and the previous configuration keeps serving. A name-only change records a version and does not pin. A `stopped` or `stopping` app pins the version and rolls it on `resume`. A configuration, `environmentVariables`, or `appSource` change while a create or resume rollout is already in progress returns `409 Conflict`. A name-only or `secrets`-only change does not. -// `appSource` starts a build and records version N+1 with a new image tag. The deploy queue carries the build-then-deploy tail; `activeVersionId` moves only when that rollout completes. A builder rejection (400 where a container document's parser refused it, 422 where it parsed and broke a rule) leaves the app on its current version and writes no version row and no build row. After the builder accepts, version N+1 is recorded even if a concurrent secret deactivation or env/secret collision prevents this request's env/secrets overlay; in that case the previous environmentVariables and attachment set stay in place and are what the new version snapshots. +// `appSource` starts a build and records version N+1 with a new image tag. The deploy queue carries the build-then-deploy tail; `activeVersionId` moves only when that rollout completes. A builder rejection (400 where a container document's parser refused it, 422 where it parsed and broke a rule) leaves the app on its current version and writes no version row and no build row. After the builder accepts, version N+1 is recorded even if a concurrent secret deactivation, an env/secret collision, or the 25-deployment attachment ceiling below prevents this request's env/secrets overlay; in that case the previous environmentVariables and attachment set stay in place and are what the new version snapshots, and the `422` the standalone case below returns does not apply here — the request still succeeds. // `environmentVariables` replaces the whole set: a key absent from the map is deleted, and a null value omits that key from the new set. The resolved map is snapshotted onto the new version. -// `secrets` replaces the whole attachment set. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app; the combined set of plain variables and attachments is capped at 100. This is a control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so a secrets-only change does not roll the workload. +// `secrets` replaces the whole attachment set. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app; the combined set of plain variables and attachments is capped at 100, and each individual secret can be attached to at most 25 deployments total — an entry that would push a secret past that returns `422`. This is a control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so a secrets-only change does not roll the workload. // Endpoints are not a field of this contract: the set belongs to the app source, so it changes only when a new version with a new source builds and deploys. // // Takes a body of the `application/json` content type. @@ -3158,6 +3320,23 @@ func (c *Client) UpdateAppEnvironmentVariable(ctx context.Context, appId AppId, return c.Client.Do(req) } +// ListAppErrors List request errors for an app +// +// One page of failed inference requests for this app, newest first. Omit `statusClass` for both 4xx and 5xx. The cursor is opaque and is only valid with the same `window` and `statusClass` it was issued under. +// +// Corresponds with GET /v1/apps/{appId}/errors (the `ListAppErrors` operationId). +func (c *Client) ListAppErrors(ctx context.Context, appId AppId, params *ListAppErrorsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListAppErrorsRequest(c.Server, appId, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // ListAppEvents List app events // // Corresponds with GET /v1/apps/{appId}/events (the `ListAppEvents` operationId). @@ -3209,7 +3388,7 @@ func (c *Client) FavouriteApp(ctx context.Context, appId AppId, reqEditors ...Re // StartAsyncTaskWithBody Start a new async task // -// Starts a new async task on `appId`, routing the request body payload to an available worker. The task runs asynchronously and the response is `202`; poll `GET /v1/apps/{appId}/tasks/{taskId}` for completion. Resubmitting a task id is answered with the task it already names rather than starting a second one, so the `202` can carry a task that has already finished: read its `status` instead of assuming `pending`, and note it may name a different `appId`. Apps in `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. +// Starts a new async task on `appId`, routing the request body payload to an available worker. The task runs asynchronously and the response is `202`; poll `GET /v1/apps/{appId}/tasks/{taskId}` for completion. Resubmitting a task id is answered with the task it already names rather than starting a second one, so the `202` can carry a task that has already finished: read its `status` instead of assuming `pending`, and note it may name a different `appId`. Apps in `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an `initializing` app whose first rollout has not produced a version returns `409 Conflict`, and an `active` app the platform has observed to have no workload able to serve returns `503 Service Unavailable` with no task minted. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. // // Takes any type of body and a specified content type. // @@ -3228,7 +3407,7 @@ func (c *Client) StartAsyncTaskWithBody(ctx context.Context, appId AppId, endpoi // StartAsyncTask Start a new async task // -// Starts a new async task on `appId`, routing the request body payload to an available worker. The task runs asynchronously and the response is `202`; poll `GET /v1/apps/{appId}/tasks/{taskId}` for completion. Resubmitting a task id is answered with the task it already names rather than starting a second one, so the `202` can carry a task that has already finished: read its `status` instead of assuming `pending`, and note it may name a different `appId`. Apps in `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. +// Starts a new async task on `appId`, routing the request body payload to an available worker. The task runs asynchronously and the response is `202`; poll `GET /v1/apps/{appId}/tasks/{taskId}` for completion. Resubmitting a task id is answered with the task it already names rather than starting a second one, so the `202` can carry a task that has already finished: read its `status` instead of assuming `pending`, and note it may name a different `appId`. Apps in `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an `initializing` app whose first rollout has not produced a version returns `409 Conflict`, and an `active` app the platform has observed to have no workload able to serve returns `503 Service Unavailable` with no task minted. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. // // Takes a body of the `application/json` content type. // @@ -3247,7 +3426,7 @@ func (c *Client) StartAsyncTask(ctx context.Context, appId AppId, endpointPath E // StartSyncTaskWithBody Start a new sync task // -// Starts a new sync task on `appId`, routing the request body payload to an available worker. The request blocks until the task is terminal and returns the result inline (`200`). Resubmitting a task id waits on the task it already names rather than starting a second one, so the `200` carries that task's result and may name a different `appId` — poll it under the one returned. A task that outlives the wait window is **not** a failure: the task is still queued or running, and the response is `202` carrying that task with `status: pending` — the same shape `invoke-async` returns, and it names the owning `appId` on a resubmission just as the `200` does. Poll `GET /v1/apps/{appId}/tasks/{taskId}` for its result. A request the platform cannot attribute to an accepted task fails instead, with no task to poll. Apps in `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. +// Starts a new sync task on `appId`, routing the request body payload to an available worker. The request blocks until the task is terminal and returns the result inline (`200`). Resubmitting a task id waits on the task it already names rather than starting a second one, so the `200` carries that task's result and may name a different `appId` — poll it under the one returned. A task that outlives the wait window is **not** a failure: the task is still queued or running, and the response is `202` carrying that task with `status: pending` — the same shape `invoke-async` returns, and it names the owning `appId` on a resubmission just as the `200` does. Poll `GET /v1/apps/{appId}/tasks/{taskId}` for its result. A request the platform cannot attribute to an accepted task fails instead, with no task to poll. Apps in `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an `initializing` app whose first rollout has not produced a version returns `409 Conflict`, and an `active` app the platform has observed to have no workload able to serve returns `503 Service Unavailable` with no task minted. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. // // Takes any type of body and a specified content type. // @@ -3266,7 +3445,7 @@ func (c *Client) StartSyncTaskWithBody(ctx context.Context, appId AppId, endpoin // StartSyncTask Start a new sync task // -// Starts a new sync task on `appId`, routing the request body payload to an available worker. The request blocks until the task is terminal and returns the result inline (`200`). Resubmitting a task id waits on the task it already names rather than starting a second one, so the `200` carries that task's result and may name a different `appId` — poll it under the one returned. A task that outlives the wait window is **not** a failure: the task is still queued or running, and the response is `202` carrying that task with `status: pending` — the same shape `invoke-async` returns, and it names the owning `appId` on a resubmission just as the `200` does. Poll `GET /v1/apps/{appId}/tasks/{taskId}` for its result. A request the platform cannot attribute to an accepted task fails instead, with no task to poll. Apps in `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. +// Starts a new sync task on `appId`, routing the request body payload to an available worker. The request blocks until the task is terminal and returns the result inline (`200`). Resubmitting a task id waits on the task it already names rather than starting a second one, so the `200` carries that task's result and may name a different `appId` — poll it under the one returned. A task that outlives the wait window is **not** a failure: the task is still queued or running, and the response is `202` carrying that task with `status: pending` — the same shape `invoke-async` returns, and it names the owning `appId` on a resubmission just as the `200` does. Poll `GET /v1/apps/{appId}/tasks/{taskId}` for its result. A request the platform cannot attribute to an accepted task fails instead, with no task to poll. Apps in `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an `initializing` app whose first rollout has not produced a version returns `409 Conflict`, and an `active` app the platform has observed to have no workload able to serve returns `503 Service Unavailable` with no task minted. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. // // Takes a body of the `application/json` content type. // @@ -3317,9 +3496,10 @@ func (c *Client) ListAppSecrets(ctx context.Context, appId AppId, params *ListAp // AttachAppSecretWithBody Attach a secret to an app // -// Records that an organisation secret is attached to an app under a resolved env-var name. The next rollout injects the value into the worker. This operation does not roll workers. Returns `409` if the secret is already attached, or if another attach would use the same env-var name. +// Records that an organisation secret is attached to an app under a resolved env-var name, and rolls the app's live deployment in place so a running worker picks up the value without waiting for an unrelated deploy. If a rollout is already in progress when this commits, this attach is not guaranteed to land on it — it reaches the worker on a later redeploy instead. A deployment that is not live records the attachment only — the next resume reads the attach set fresh. Returns `409` if the secret is already attached, or if another attach would use the same env-var name. // The resolved name (`envVarName`, or `secretName` when omitted) must not already exist as a plain environment variable on this app (`deployment_configs.key`). Both sources use the same pod env namespace, so the server rejects the collision with `422` instead of allowing a last-wins override later. The reverse check applies when setting a plain environment variable. // An app holds at most 100 environment bindings in total — plain environment variables plus attached secrets — the same combined ceiling as create and the single-key env-var route. Attaching when the app is already at that limit returns `422`. +// A secret can be attached to at most 25 deployments — rolling every attached deployment is what an update or detach costs, so the ceiling bounds that cost rather than the app side of the binding. Attaching past it returns `422`. // // Takes any type of body and a specified content type. // @@ -3338,9 +3518,10 @@ func (c *Client) AttachAppSecretWithBody(ctx context.Context, appId AppId, conte // AttachAppSecret Attach a secret to an app // -// Records that an organisation secret is attached to an app under a resolved env-var name. The next rollout injects the value into the worker. This operation does not roll workers. Returns `409` if the secret is already attached, or if another attach would use the same env-var name. +// Records that an organisation secret is attached to an app under a resolved env-var name, and rolls the app's live deployment in place so a running worker picks up the value without waiting for an unrelated deploy. If a rollout is already in progress when this commits, this attach is not guaranteed to land on it — it reaches the worker on a later redeploy instead. A deployment that is not live records the attachment only — the next resume reads the attach set fresh. Returns `409` if the secret is already attached, or if another attach would use the same env-var name. // The resolved name (`envVarName`, or `secretName` when omitted) must not already exist as a plain environment variable on this app (`deployment_configs.key`). Both sources use the same pod env namespace, so the server rejects the collision with `422` instead of allowing a last-wins override later. The reverse check applies when setting a plain environment variable. // An app holds at most 100 environment bindings in total — plain environment variables plus attached secrets — the same combined ceiling as create and the single-key env-var route. Attaching when the app is already at that limit returns `422`. +// A secret can be attached to at most 25 deployments — rolling every attached deployment is what an update or detach costs, so the ceiling bounds that cost rather than the app side of the binding. Attaching past it returns `422`. // // Takes a body of the `application/json` content type. // @@ -3359,7 +3540,7 @@ func (c *Client) AttachAppSecret(ctx context.Context, appId AppId, body AttachAp // DetachAppSecret Detach a secret from an app // -// Removes the attachment from the next rollout. This operation does not roll workers. Existing workers keep the value until they stop. +// Removes the attachment and rolls the app's live deployment in place so a running worker stops receiving the value. If a rollout is already in progress when this commits, this detach is not guaranteed to land on it — the worker stops receiving the value on a later redeploy instead. A deployment that is not live has the removal recorded only — there is nothing to roll until it resumes. // // Corresponds with DELETE /v1/apps/{appId}/secrets/{secretName} (the `DetachAppSecret` operationId). func (c *Client) DetachAppSecret(ctx context.Context, appId AppId, secretName SecretName, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -3474,7 +3655,7 @@ func (c *Client) GetVersion(ctx context.Context, appId AppId, versionNumber int3 // ListWorkers List workers // -// Returns a newest-first page of workers observed for the app (including terminal `stopped` rows until purged). Optional `state` and `status` narrow the page; a cursor must be replayed under the same filters it was issued with. +// Returns a newest-first page of workers observed for the app (including terminal `stopped` rows until purged). Omitted `versionId` scopes the page to the app's `activeVersionId`. An app with no active version therefore answers an empty default page — not that it has no workers, only that none are pinned. Optional `state` and `status` narrow the page further; a cursor must be replayed under the same filters it was issued with. // // Corresponds with GET /v1/apps/{appId}/workers (the `ListWorkers` operationId). func (c *Client) ListWorkers(ctx context.Context, appId AppId, params *ListWorkersParams, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -3525,7 +3706,7 @@ func (c *Client) ListGpuTypes(ctx context.Context, reqEditors ...RequestEditorFn // CreateGpuTypeWithBody Add a GPU type to the catalogue // -// Creates a new entry in the global GPU type catalogue. Restricted to the Runware platform organization. The `id` (catalogue code) is immutable and remains reserved after retirement. +// Creates a new entry in the global GPU type catalogue. Restricted to the Runware platform organization. The `id` (catalogue code) is immutable and remains reserved after retirement: a retired code answers `409` here and is brought back with `POST /v1/gpu-types/{gpuTypeId}/restore` rather than recreated. // // Takes any type of body and a specified content type. // @@ -3544,7 +3725,7 @@ func (c *Client) CreateGpuTypeWithBody(ctx context.Context, contentType string, // CreateGpuType Add a GPU type to the catalogue // -// Creates a new entry in the global GPU type catalogue. Restricted to the Runware platform organization. The `id` (catalogue code) is immutable and remains reserved after retirement. +// Creates a new entry in the global GPU type catalogue. Restricted to the Runware platform organization. The `id` (catalogue code) is immutable and remains reserved after retirement: a retired code answers `409` here and is brought back with `POST /v1/gpu-types/{gpuTypeId}/restore` rather than recreated. // // Takes a body of the `application/json` content type. // @@ -3563,7 +3744,7 @@ func (c *Client) CreateGpuType(ctx context.Context, body CreateGpuTypeJSONReques // DeleteGpuType Retire a GPU type from the catalogue // -// Soft-deletes a GPU type while preserving its code and price history. Restricted to the Runware platform organization. Returns `409` if a worker configuration or GPU pool still references the code. Retiring an already retired code returns `404`. +// Soft-deletes a GPU type while preserving its code and price history. Restricted to the Runware platform organization. Returns `409` if a worker configuration or GPU pool still references the code. Retiring an already retired code returns `404`. Reversible with `POST /v1/gpu-types/{gpuTypeId}/restore`. // // Corresponds with DELETE /v1/gpu-types/{gpuTypeId} (the `DeleteGpuType` operationId). func (c *Client) DeleteGpuType(ctx context.Context, gpuTypeId GpuTypeId, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -3743,11 +3924,26 @@ func (c *Client) UpdateGpuTypePrice(ctx context.Context, gpuTypeId GpuTypeId, pr return c.Client.Do(req) } -// GetLogEntries Read one page of a named log query +// RestoreGpuType Restore a retired GPU type +// +// Returns a retired GPU type to the catalogue under the same code, with its price history intact, so the reserved code is usable again for the hardware it already described. Restricted to the Runware platform organization. A code that is not retired returns `409`; a code no entry has ever held returns `404`. Whether customers can then select the type still depends on pool admission, exactly as for any active type. // -// Returns one page of log entries, newest first, with an opaque cursor for the next page when one exists. +// Corresponds with POST /v1/gpu-types/{gpuTypeId}/restore (the `RestoreGpuType` operationId). +func (c *Client) RestoreGpuType(ctx context.Context, gpuTypeId GpuTypeId, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRestoreGpuTypeRequest(c.Server, gpuTypeId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// GetLogEntries Read one page of a named log query // -// No query is registered yet: live tail, retention tiers and log quotas are decided in a follow-up ADR, so every request currently answers `404`. The route exists so the contract is fixed before the templates land. +// Returns one page of log entries, newest first, with an opaque cursor for the next page when one exists. Query ids and their supported selectors are listed by the insights catalogue. // // Corresponds with GET /v1/logs/queries/{queryId}/entries (the `GetLogEntries` operationId). func (c *Client) GetLogEntries(ctx context.Context, queryId QueryId, params *GetLogEntriesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -3762,6 +3958,23 @@ func (c *Client) GetLogEntries(ctx context.Context, queryId QueryId, params *Get return c.Client.Do(req) } +// TailLogEntries Follow a named application log query +// +// Streams new application log entries as Server-Sent Events. The stream sends keepalive comments while quiet and ends with an `end` event when its connection lifetime expires or the service shuts down. Clients should reconnect after an `end` event. Use the `runtime_tail` query id; queries that sort or aggregate are rejected because they cannot be followed live. +// +// Corresponds with GET /v1/logs/queries/{queryId}/tail (the `TailLogEntries` operationId). +func (c *Client) TailLogEntries(ctx context.Context, queryId QueryId, params *TailLogEntriesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTailLogEntriesRequest(c.Server, queryId, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // ListInsightsQueries List the metric and log queries this build can answer // // The catalogue: every named query, its unit and aggregation, the selectors it accepts, the series it returns, and the windows actually backed by stored series. @@ -3789,9 +4002,11 @@ func (c *Client) ListInsightsQueries(ctx context.Context, reqEditors ...RequestE // // An organization with no metrics yet is answered with the full axis and all-null series rather than an error. // -// `apps_request_volume` returns one series per app: 24 hourly request counts over `window=24h` (`step_s` 3600, unit `requests`). Repeat `appId` once per id on the current list page to pad idle apps with all-null series, in request order. Hours that started before that live app's `createdAt` are null, so a reused app id does not inherit the previous generation's traffic still in the 24h store. The same `appId` pad applies to the list-scoped `apps_error_volume` and `apps_request_duration` queries. Other queries reject `appId`. Other windows are not available for these queries. +// `apps_request_volume` returns one series per app: 96 quarter-hour request counts over `window=24h` (`step_s` 900, unit `requests`). Repeat `appId` once per id on the current list page to pad idle apps with all-null series, in request order. A series is named for the live app behind it, so a reused app id reports its own generation's traffic and not the one before it. The same `appId` pad applies to the list-scoped `apps_error_volume` and `apps_request_duration` queries. Other queries reject `appId`. Other windows are not available for these queries. +// +// `endpoints_request_volume` is the endpoints-list counterpart: 96 quarter-hour request counts per endpoint over `window=24h` (`step_s` 900, unit `requests`). It requires `deployment` (the public app id, rewritten to the live deployment UUID). Repeat `endpointId` once per id on the current `listEndpoints` page to pad idle endpoints with all-null series, in request order. Buckets that started before that endpoint row's `createdAt` are null, so a removed-then-readded path does not inherit the previous row's traffic. Other queries reject `endpointId`. Other windows are not available for this query. // -// `endpoints_request_volume` is the endpoints-list counterpart: 24 hourly request counts per endpoint over `window=24h` (`step_s` 3600, unit `requests`). It requires `deployment` (the public app id, rewritten to the live deployment UUID). Repeat `endpointId` once per id on the current `listEndpoints` page to pad idle endpoints with all-null series, in request order. Hours that started before that endpoint row's `createdAt` are null, so a removed-then-readded path does not inherit the previous row's traffic. Other queries reject `endpointId`. Other windows are not available for this query. +// Three queries serve one app's overview, all over `window=24h` at `step_s` 900 and all requiring `deployment` (the public app id, rewritten to the live deployment UUID): `app_traffic_24h` returns `requests`, `client_errors` (4xx) and `server_errors` (5xx) as request counts; `app_worker_seconds_24h` returns `startup`, `execution` and `idle` as worker-seconds, whose three values in a bucket sum to that bucket's worker time; and `app_cold_starts_24h` returns `cold_starts` as a count. They report counts and totals rather than rates or ratios, so a per-minute figure is a bucket value divided by `step_s / 60` and a 24h ratio is one summed axis over another — summing first and dividing once, because averaging a per-bucket ratio across the axis does not give the 24h ratio. These queries are absent from `listInsightsQueries`: they back the overview rather than the Metrics tab. Other windows are not available for them. // // Corresponds with GET /v1/metrics/queries/{queryId}/series (the `GetMetricSeries` operationId). func (c *Client) GetMetricSeries(ctx context.Context, queryId QueryId, params *GetMetricSeriesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -3909,7 +4124,7 @@ func (c *Client) CreateSecret(ctx context.Context, body CreateSecretJSONRequestB // DeleteSecret Delete a secret // -// Soft-deletes a secret: marks the row `pending_destroy` and bumps revision. This API does not hard-delete the row. A background sweep removes the row and releases the name once no running worker can still hold the value — the value travels inside the worker's own environment, which is fixed when the container starts, so a worker keeps it until it stops. There is no deadline on that wait. Returns `409` while any app still attaches it — cascade-detach is not performed here; detach each holder with `DELETE .../apps/{id}/secrets/{name}` first. Attach and detach change the secret set for the next rollout. Neither operation rolls workers. While the row remains `pending_destroy` the name stays reserved, so create may return `409` even though list no longer shows the secret. Retries on an already-pending name are safe when no attachments remain (`204`); they still return `409` while attached. +// Soft-deletes a secret: marks the row `pending_destroy` and bumps revision. This API does not hard-delete the row. Returns `409` while any app still attaches it — cascade-detach is not performed here; detach each holder with `DELETE .../apps/{id}/secrets/{name}` first, which rolls the deployments it names in place. A background sweep removes the row and releases the name once no running worker can still hold the value, rather than assuming every roll a detach started actually landed — a deployment that was not live when detached has nothing to roll until it resumes, so the sweep is the actual backstop, not the detach. While the row remains `pending_destroy` the name stays reserved, so create may return `409` even though list no longer shows the secret. Retries on an already-pending name are safe when no attachments remain (`204`); they still return `409` while attached. // // Corresponds with DELETE /v1/secrets/{secretName} (the `DeleteSecret` operationId). func (c *Client) DeleteSecret(ctx context.Context, secretName SecretName, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -3926,6 +4141,8 @@ func (c *Client) DeleteSecret(ctx context.Context, secretName SecretName, reqEdi // UpdateSecretWithBody Update a secret // +// Re-encrypts the value under the same name (no rename). Rolls every live deployment that attaches this secret in place so a running worker picks up the new value. If a rollout is already in progress when this commits, this change is not guaranteed to land on it — it reaches the worker on a later redeploy instead. A deployment that is not live picks it up on its next deploy for another reason. +// // Takes any type of body and a specified content type. // // Corresponds with PUT /v1/secrets/{secretName} (the `UpdateSecret` operationId). @@ -3943,6 +4160,8 @@ func (c *Client) UpdateSecretWithBody(ctx context.Context, secretName SecretName // UpdateSecret Update a secret // +// Re-encrypts the value under the same name (no rename). Rolls every live deployment that attaches this secret in place so a running worker picks up the new value. If a rollout is already in progress when this commits, this change is not guaranteed to land on it — it reaches the worker on a later redeploy instead. A deployment that is not live picks it up on its next deploy for another reason. +// // Takes a body of the `application/json` content type. // // Corresponds with PUT /v1/secrets/{secretName} (the `UpdateSecret` operationId). @@ -4861,6 +5080,103 @@ func NewUpdateAppEnvironmentVariableRequestWithBody(server string, appId AppId, return req, nil } +// NewListAppErrorsRequest constructs an http.Request for the ListAppErrors method +func NewListAppErrorsRequest(server string, appId AppId, params *ListAppErrorsParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "appId", appId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/v1/apps/%s/errors", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Limit != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "limit", *params.Limit, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: "int32"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Cursor != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "cursor", *params.Cursor, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Window != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "window", *params.Window, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.StatusClass != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "statusClass", *params.StatusClass, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewListAppEventsRequest constructs an http.Request for the ListAppEvents method func NewListAppEventsRequest(server string, appId AppId, params *ListAppEventsParams) (*http.Request, error) { var err error @@ -5691,6 +6007,18 @@ func NewListWorkersRequest(server string, appId AppId, params *ListWorkersParams } + if params.VersionId != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "versionId", *params.VersionId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + if params.State != nil { if queryFrag, err := runtime.StyleParamWithOptions("form", true, "state", *params.State, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { @@ -6167,6 +6495,40 @@ func NewUpdateGpuTypePriceRequestWithBody(server string, gpuTypeId GpuTypeId, pr return req, nil } +// NewRestoreGpuTypeRequest constructs an http.Request for the RestoreGpuType method +func NewRestoreGpuTypeRequest(server string, gpuTypeId GpuTypeId) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "gpuTypeId", gpuTypeId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/v1/gpu-types/%s/restore", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewGetLogEntriesRequest constructs an http.Request for the GetLogEntries method func NewGetLogEntriesRequest(server string, queryId QueryId, params *GetLogEntriesParams) (*http.Request, error) { var err error @@ -6272,7 +6634,64 @@ func NewGetLogEntriesRequest(server string, queryId QueryId, params *GetLogEntri return req, nil } -// NewListInsightsQueriesRequest constructs an http.Request for the ListInsightsQueries method +// NewTailLogEntriesRequest constructs an http.Request for the TailLogEntries method +func NewTailLogEntriesRequest(server string, queryId QueryId, params *TailLogEntriesParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "queryId", queryId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/v1/logs/queries/%s/tail", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "deployment", params.Deployment, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewListInsightsQueriesRequest constructs an http.Request for the ListInsightsQueries method func NewListInsightsQueriesRequest(server string) (*http.Request, error) { var err error @@ -7085,9 +7504,9 @@ type ClientWithResponsesInterface interface { // // Patches one or more aspects of an app in place. All fields are optional; omitted fields are left unchanged. Valid in any non-`deleted` status, including `stopped` (changes apply on `resume`). Lifecycle transitions use the dedicated `deploy`, `stop`, `resume`, and `delete` operations. // A configuration or `environmentVariables` change records a new version with the same image. If that image is deployable, the update pins it as `activeVersionId` and rolls the workload when the app is `active` or `initializing`. A `failed` app is moved to `initializing` and rolled, the same as `POST /deploy`. If the image is not deployable, the version is recorded and `activeVersionId` is left unchanged. If the roll fails, `activeVersionId` is restored and the previous configuration keeps serving. A name-only change records a version and does not pin. A `stopped` or `stopping` app pins the version and rolls it on `resume`. A configuration, `environmentVariables`, or `appSource` change while a create or resume rollout is already in progress returns `409 Conflict`. A name-only or `secrets`-only change does not. - // `appSource` starts a build and records version N+1 with a new image tag. The deploy queue carries the build-then-deploy tail; `activeVersionId` moves only when that rollout completes. A builder rejection (400 where a container document's parser refused it, 422 where it parsed and broke a rule) leaves the app on its current version and writes no version row and no build row. After the builder accepts, version N+1 is recorded even if a concurrent secret deactivation or env/secret collision prevents this request's env/secrets overlay; in that case the previous environmentVariables and attachment set stay in place and are what the new version snapshots. + // `appSource` starts a build and records version N+1 with a new image tag. The deploy queue carries the build-then-deploy tail; `activeVersionId` moves only when that rollout completes. A builder rejection (400 where a container document's parser refused it, 422 where it parsed and broke a rule) leaves the app on its current version and writes no version row and no build row. After the builder accepts, version N+1 is recorded even if a concurrent secret deactivation, an env/secret collision, or the 25-deployment attachment ceiling below prevents this request's env/secrets overlay; in that case the previous environmentVariables and attachment set stay in place and are what the new version snapshots, and the `422` the standalone case below returns does not apply here — the request still succeeds. // `environmentVariables` replaces the whole set: a key absent from the map is deleted, and a null value omits that key from the new set. The resolved map is snapshotted onto the new version. - // `secrets` replaces the whole attachment set. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app; the combined set of plain variables and attachments is capped at 100. This is a control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so a secrets-only change does not roll the workload. + // `secrets` replaces the whole attachment set. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app; the combined set of plain variables and attachments is capped at 100, and each individual secret can be attached to at most 25 deployments total — an entry that would push a secret past that returns `422`. This is a control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so a secrets-only change does not roll the workload. // Endpoints are not a field of this contract: the set belongs to the app source, so it changes only when a new version with a new source builds and deploys. // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). @@ -7099,9 +7518,9 @@ type ClientWithResponsesInterface interface { // // Patches one or more aspects of an app in place. All fields are optional; omitted fields are left unchanged. Valid in any non-`deleted` status, including `stopped` (changes apply on `resume`). Lifecycle transitions use the dedicated `deploy`, `stop`, `resume`, and `delete` operations. // A configuration or `environmentVariables` change records a new version with the same image. If that image is deployable, the update pins it as `activeVersionId` and rolls the workload when the app is `active` or `initializing`. A `failed` app is moved to `initializing` and rolled, the same as `POST /deploy`. If the image is not deployable, the version is recorded and `activeVersionId` is left unchanged. If the roll fails, `activeVersionId` is restored and the previous configuration keeps serving. A name-only change records a version and does not pin. A `stopped` or `stopping` app pins the version and rolls it on `resume`. A configuration, `environmentVariables`, or `appSource` change while a create or resume rollout is already in progress returns `409 Conflict`. A name-only or `secrets`-only change does not. - // `appSource` starts a build and records version N+1 with a new image tag. The deploy queue carries the build-then-deploy tail; `activeVersionId` moves only when that rollout completes. A builder rejection (400 where a container document's parser refused it, 422 where it parsed and broke a rule) leaves the app on its current version and writes no version row and no build row. After the builder accepts, version N+1 is recorded even if a concurrent secret deactivation or env/secret collision prevents this request's env/secrets overlay; in that case the previous environmentVariables and attachment set stay in place and are what the new version snapshots. + // `appSource` starts a build and records version N+1 with a new image tag. The deploy queue carries the build-then-deploy tail; `activeVersionId` moves only when that rollout completes. A builder rejection (400 where a container document's parser refused it, 422 where it parsed and broke a rule) leaves the app on its current version and writes no version row and no build row. After the builder accepts, version N+1 is recorded even if a concurrent secret deactivation, an env/secret collision, or the 25-deployment attachment ceiling below prevents this request's env/secrets overlay; in that case the previous environmentVariables and attachment set stay in place and are what the new version snapshots, and the `422` the standalone case below returns does not apply here — the request still succeeds. // `environmentVariables` replaces the whole set: a key absent from the map is deleted, and a null value omits that key from the new set. The resolved map is snapshotted onto the new version. - // `secrets` replaces the whole attachment set. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app; the combined set of plain variables and attachments is capped at 100. This is a control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so a secrets-only change does not roll the workload. + // `secrets` replaces the whole attachment set. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app; the combined set of plain variables and attachments is capped at 100, and each individual secret can be attached to at most 25 deployments total — an entry that would push a secret past that returns `422`. This is a control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so a secrets-only change does not roll the workload. // Endpoints are not a field of this contract: the set belongs to the app source, so it changes only when a new version with a new source builds and deploys. // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). @@ -7216,6 +7635,15 @@ type ClientWithResponsesInterface interface { // Corresponds with PUT /v1/apps/{appId}/environment-variables/{variableName} (the `UpdateAppEnvironmentVariable` operationId). UpdateAppEnvironmentVariableWithResponse(ctx context.Context, appId AppId, variableName EnvironmentVariableName, body UpdateAppEnvironmentVariableJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateAppEnvironmentVariableResponse, error) + // ListAppErrorsWithResponse List request errors for an app + // + // One page of failed inference requests for this app, newest first. Omit `statusClass` for both 4xx and 5xx. The cursor is opaque and is only valid with the same `window` and `statusClass` it was issued under. + // + // Returns a wrapper object for the known response body format(s). + // + // Corresponds with GET /v1/apps/{appId}/errors (the `ListAppErrors` operationId). + ListAppErrorsWithResponse(ctx context.Context, appId AppId, params *ListAppErrorsParams, reqEditors ...RequestEditorFn) (*ListAppErrorsResponse, error) + // ListAppEventsWithResponse List app events // // Returns a wrapper object for the known response body format(s). @@ -7243,7 +7671,7 @@ type ClientWithResponsesInterface interface { // StartAsyncTaskWithBodyWithResponse Start a new async task // - // Starts a new async task on `appId`, routing the request body payload to an available worker. The task runs asynchronously and the response is `202`; poll `GET /v1/apps/{appId}/tasks/{taskId}` for completion. Resubmitting a task id is answered with the task it already names rather than starting a second one, so the `202` can carry a task that has already finished: read its `status` instead of assuming `pending`, and note it may name a different `appId`. Apps in `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. + // Starts a new async task on `appId`, routing the request body payload to an available worker. The task runs asynchronously and the response is `202`; poll `GET /v1/apps/{appId}/tasks/{taskId}` for completion. Resubmitting a task id is answered with the task it already names rather than starting a second one, so the `202` can carry a task that has already finished: read its `status` instead of assuming `pending`, and note it may name a different `appId`. Apps in `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an `initializing` app whose first rollout has not produced a version returns `409 Conflict`, and an `active` app the platform has observed to have no workload able to serve returns `503 Service Unavailable` with no task minted. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // @@ -7252,7 +7680,7 @@ type ClientWithResponsesInterface interface { // StartAsyncTaskWithResponse Start a new async task // - // Starts a new async task on `appId`, routing the request body payload to an available worker. The task runs asynchronously and the response is `202`; poll `GET /v1/apps/{appId}/tasks/{taskId}` for completion. Resubmitting a task id is answered with the task it already names rather than starting a second one, so the `202` can carry a task that has already finished: read its `status` instead of assuming `pending`, and note it may name a different `appId`. Apps in `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. + // Starts a new async task on `appId`, routing the request body payload to an available worker. The task runs asynchronously and the response is `202`; poll `GET /v1/apps/{appId}/tasks/{taskId}` for completion. Resubmitting a task id is answered with the task it already names rather than starting a second one, so the `202` can carry a task that has already finished: read its `status` instead of assuming `pending`, and note it may name a different `appId`. Apps in `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an `initializing` app whose first rollout has not produced a version returns `409 Conflict`, and an `active` app the platform has observed to have no workload able to serve returns `503 Service Unavailable` with no task minted. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // @@ -7261,7 +7689,7 @@ type ClientWithResponsesInterface interface { // StartSyncTaskWithBodyWithResponse Start a new sync task // - // Starts a new sync task on `appId`, routing the request body payload to an available worker. The request blocks until the task is terminal and returns the result inline (`200`). Resubmitting a task id waits on the task it already names rather than starting a second one, so the `200` carries that task's result and may name a different `appId` — poll it under the one returned. A task that outlives the wait window is **not** a failure: the task is still queued or running, and the response is `202` carrying that task with `status: pending` — the same shape `invoke-async` returns, and it names the owning `appId` on a resubmission just as the `200` does. Poll `GET /v1/apps/{appId}/tasks/{taskId}` for its result. A request the platform cannot attribute to an accepted task fails instead, with no task to poll. Apps in `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. + // Starts a new sync task on `appId`, routing the request body payload to an available worker. The request blocks until the task is terminal and returns the result inline (`200`). Resubmitting a task id waits on the task it already names rather than starting a second one, so the `200` carries that task's result and may name a different `appId` — poll it under the one returned. A task that outlives the wait window is **not** a failure: the task is still queued or running, and the response is `202` carrying that task with `status: pending` — the same shape `invoke-async` returns, and it names the owning `appId` on a resubmission just as the `200` does. Poll `GET /v1/apps/{appId}/tasks/{taskId}` for its result. A request the platform cannot attribute to an accepted task fails instead, with no task to poll. Apps in `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an `initializing` app whose first rollout has not produced a version returns `409 Conflict`, and an `active` app the platform has observed to have no workload able to serve returns `503 Service Unavailable` with no task minted. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // @@ -7270,7 +7698,7 @@ type ClientWithResponsesInterface interface { // StartSyncTaskWithResponse Start a new sync task // - // Starts a new sync task on `appId`, routing the request body payload to an available worker. The request blocks until the task is terminal and returns the result inline (`200`). Resubmitting a task id waits on the task it already names rather than starting a second one, so the `200` carries that task's result and may name a different `appId` — poll it under the one returned. A task that outlives the wait window is **not** a failure: the task is still queued or running, and the response is `202` carrying that task with `status: pending` — the same shape `invoke-async` returns, and it names the owning `appId` on a resubmission just as the `200` does. Poll `GET /v1/apps/{appId}/tasks/{taskId}` for its result. A request the platform cannot attribute to an accepted task fails instead, with no task to poll. Apps in `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. + // Starts a new sync task on `appId`, routing the request body payload to an available worker. The request blocks until the task is terminal and returns the result inline (`200`). Resubmitting a task id waits on the task it already names rather than starting a second one, so the `200` carries that task's result and may name a different `appId` — poll it under the one returned. A task that outlives the wait window is **not** a failure: the task is still queued or running, and the response is `202` carrying that task with `status: pending` — the same shape `invoke-async` returns, and it names the owning `appId` on a resubmission just as the `200` does. Poll `GET /v1/apps/{appId}/tasks/{taskId}` for its result. A request the platform cannot attribute to an accepted task fails instead, with no task to poll. Apps in `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an `initializing` app whose first rollout has not produced a version returns `409 Conflict`, and an `active` app the platform has observed to have no workload able to serve returns `503 Service Unavailable` with no task minted. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // @@ -7295,9 +7723,10 @@ type ClientWithResponsesInterface interface { // AttachAppSecretWithBodyWithResponse Attach a secret to an app // - // Records that an organisation secret is attached to an app under a resolved env-var name. The next rollout injects the value into the worker. This operation does not roll workers. Returns `409` if the secret is already attached, or if another attach would use the same env-var name. + // Records that an organisation secret is attached to an app under a resolved env-var name, and rolls the app's live deployment in place so a running worker picks up the value without waiting for an unrelated deploy. If a rollout is already in progress when this commits, this attach is not guaranteed to land on it — it reaches the worker on a later redeploy instead. A deployment that is not live records the attachment only — the next resume reads the attach set fresh. Returns `409` if the secret is already attached, or if another attach would use the same env-var name. // The resolved name (`envVarName`, or `secretName` when omitted) must not already exist as a plain environment variable on this app (`deployment_configs.key`). Both sources use the same pod env namespace, so the server rejects the collision with `422` instead of allowing a last-wins override later. The reverse check applies when setting a plain environment variable. // An app holds at most 100 environment bindings in total — plain environment variables plus attached secrets — the same combined ceiling as create and the single-key env-var route. Attaching when the app is already at that limit returns `422`. + // A secret can be attached to at most 25 deployments — rolling every attached deployment is what an update or detach costs, so the ceiling bounds that cost rather than the app side of the binding. Attaching past it returns `422`. // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // @@ -7306,9 +7735,10 @@ type ClientWithResponsesInterface interface { // AttachAppSecretWithResponse Attach a secret to an app // - // Records that an organisation secret is attached to an app under a resolved env-var name. The next rollout injects the value into the worker. This operation does not roll workers. Returns `409` if the secret is already attached, or if another attach would use the same env-var name. + // Records that an organisation secret is attached to an app under a resolved env-var name, and rolls the app's live deployment in place so a running worker picks up the value without waiting for an unrelated deploy. If a rollout is already in progress when this commits, this attach is not guaranteed to land on it — it reaches the worker on a later redeploy instead. A deployment that is not live records the attachment only — the next resume reads the attach set fresh. Returns `409` if the secret is already attached, or if another attach would use the same env-var name. // The resolved name (`envVarName`, or `secretName` when omitted) must not already exist as a plain environment variable on this app (`deployment_configs.key`). Both sources use the same pod env namespace, so the server rejects the collision with `422` instead of allowing a last-wins override later. The reverse check applies when setting a plain environment variable. // An app holds at most 100 environment bindings in total — plain environment variables plus attached secrets — the same combined ceiling as create and the single-key env-var route. Attaching when the app is already at that limit returns `422`. + // A secret can be attached to at most 25 deployments — rolling every attached deployment is what an update or detach costs, so the ceiling bounds that cost rather than the app side of the binding. Attaching past it returns `422`. // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // @@ -7317,7 +7747,7 @@ type ClientWithResponsesInterface interface { // DetachAppSecretWithResponse Detach a secret from an app // - // Removes the attachment from the next rollout. This operation does not roll workers. Existing workers keep the value until they stop. + // Removes the attachment and rolls the app's live deployment in place so a running worker stops receiving the value. If a rollout is already in progress when this commits, this detach is not guaranteed to land on it — the worker stops receiving the value on a later redeploy instead. A deployment that is not live has the removal recorded only — there is nothing to roll until it resumes. // // Returns a wrapper object for the known response body format(s). // @@ -7376,7 +7806,7 @@ type ClientWithResponsesInterface interface { // ListWorkersWithResponse List workers // - // Returns a newest-first page of workers observed for the app (including terminal `stopped` rows until purged). Optional `state` and `status` narrow the page; a cursor must be replayed under the same filters it was issued with. + // Returns a newest-first page of workers observed for the app (including terminal `stopped` rows until purged). Omitted `versionId` scopes the page to the app's `activeVersionId`. An app with no active version therefore answers an empty default page — not that it has no workers, only that none are pinned. Optional `state` and `status` narrow the page further; a cursor must be replayed under the same filters it was issued with. // // Returns a wrapper object for the known response body format(s). // @@ -7403,7 +7833,7 @@ type ClientWithResponsesInterface interface { // CreateGpuTypeWithBodyWithResponse Add a GPU type to the catalogue // - // Creates a new entry in the global GPU type catalogue. Restricted to the Runware platform organization. The `id` (catalogue code) is immutable and remains reserved after retirement. + // Creates a new entry in the global GPU type catalogue. Restricted to the Runware platform organization. The `id` (catalogue code) is immutable and remains reserved after retirement: a retired code answers `409` here and is brought back with `POST /v1/gpu-types/{gpuTypeId}/restore` rather than recreated. // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // @@ -7412,7 +7842,7 @@ type ClientWithResponsesInterface interface { // CreateGpuTypeWithResponse Add a GPU type to the catalogue // - // Creates a new entry in the global GPU type catalogue. Restricted to the Runware platform organization. The `id` (catalogue code) is immutable and remains reserved after retirement. + // Creates a new entry in the global GPU type catalogue. Restricted to the Runware platform organization. The `id` (catalogue code) is immutable and remains reserved after retirement: a retired code answers `409` here and is brought back with `POST /v1/gpu-types/{gpuTypeId}/restore` rather than recreated. // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // @@ -7421,7 +7851,7 @@ type ClientWithResponsesInterface interface { // DeleteGpuTypeWithResponse Retire a GPU type from the catalogue // - // Soft-deletes a GPU type while preserving its code and price history. Restricted to the Runware platform organization. Returns `409` if a worker configuration or GPU pool still references the code. Retiring an already retired code returns `404`. + // Soft-deletes a GPU type while preserving its code and price history. Restricted to the Runware platform organization. Returns `409` if a worker configuration or GPU pool still references the code. Retiring an already retired code returns `404`. Reversible with `POST /v1/gpu-types/{gpuTypeId}/restore`. // // Returns a wrapper object for the known response body format(s). // @@ -7509,17 +7939,33 @@ type ClientWithResponsesInterface interface { // Corresponds with PATCH /v1/gpu-types/{gpuTypeId}/prices/{priceId} (the `UpdateGpuTypePrice` operationId). UpdateGpuTypePriceWithResponse(ctx context.Context, gpuTypeId GpuTypeId, priceId openapi_types.UUID, body UpdateGpuTypePriceJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateGpuTypePriceResponse, error) - // GetLogEntriesWithResponse Read one page of a named log query + // RestoreGpuTypeWithResponse Restore a retired GPU type // - // Returns one page of log entries, newest first, with an opaque cursor for the next page when one exists. + // Returns a retired GPU type to the catalogue under the same code, with its price history intact, so the reserved code is usable again for the hardware it already described. Restricted to the Runware platform organization. A code that is not retired returns `409`; a code no entry has ever held returns `404`. Whether customers can then select the type still depends on pool admission, exactly as for any active type. + // + // Returns a wrapper object for the known response body format(s). + // + // Corresponds with POST /v1/gpu-types/{gpuTypeId}/restore (the `RestoreGpuType` operationId). + RestoreGpuTypeWithResponse(ctx context.Context, gpuTypeId GpuTypeId, reqEditors ...RequestEditorFn) (*RestoreGpuTypeResponse, error) + + // GetLogEntriesWithResponse Read one page of a named log query // - // No query is registered yet: live tail, retention tiers and log quotas are decided in a follow-up ADR, so every request currently answers `404`. The route exists so the contract is fixed before the templates land. + // Returns one page of log entries, newest first, with an opaque cursor for the next page when one exists. Query ids and their supported selectors are listed by the insights catalogue. // // Returns a wrapper object for the known response body format(s). // // Corresponds with GET /v1/logs/queries/{queryId}/entries (the `GetLogEntries` operationId). GetLogEntriesWithResponse(ctx context.Context, queryId QueryId, params *GetLogEntriesParams, reqEditors ...RequestEditorFn) (*GetLogEntriesResponse, error) + // TailLogEntriesWithResponse Follow a named application log query + // + // Streams new application log entries as Server-Sent Events. The stream sends keepalive comments while quiet and ends with an `end` event when its connection lifetime expires or the service shuts down. Clients should reconnect after an `end` event. Use the `runtime_tail` query id; queries that sort or aggregate are rejected because they cannot be followed live. + // + // Returns a wrapper object for the known response body format(s). + // + // Corresponds with GET /v1/logs/queries/{queryId}/tail (the `TailLogEntries` operationId). + TailLogEntriesWithResponse(ctx context.Context, queryId QueryId, params *TailLogEntriesParams, reqEditors ...RequestEditorFn) (*TailLogEntriesResponse, error) + // ListInsightsQueriesWithResponse List the metric and log queries this build can answer // // The catalogue: every named query, its unit and aggregation, the selectors it accepts, the series it returns, and the windows actually backed by stored series. @@ -7539,9 +7985,11 @@ type ClientWithResponsesInterface interface { // // An organization with no metrics yet is answered with the full axis and all-null series rather than an error. // - // `apps_request_volume` returns one series per app: 24 hourly request counts over `window=24h` (`step_s` 3600, unit `requests`). Repeat `appId` once per id on the current list page to pad idle apps with all-null series, in request order. Hours that started before that live app's `createdAt` are null, so a reused app id does not inherit the previous generation's traffic still in the 24h store. The same `appId` pad applies to the list-scoped `apps_error_volume` and `apps_request_duration` queries. Other queries reject `appId`. Other windows are not available for these queries. + // `apps_request_volume` returns one series per app: 96 quarter-hour request counts over `window=24h` (`step_s` 900, unit `requests`). Repeat `appId` once per id on the current list page to pad idle apps with all-null series, in request order. A series is named for the live app behind it, so a reused app id reports its own generation's traffic and not the one before it. The same `appId` pad applies to the list-scoped `apps_error_volume` and `apps_request_duration` queries. Other queries reject `appId`. Other windows are not available for these queries. // - // `endpoints_request_volume` is the endpoints-list counterpart: 24 hourly request counts per endpoint over `window=24h` (`step_s` 3600, unit `requests`). It requires `deployment` (the public app id, rewritten to the live deployment UUID). Repeat `endpointId` once per id on the current `listEndpoints` page to pad idle endpoints with all-null series, in request order. Hours that started before that endpoint row's `createdAt` are null, so a removed-then-readded path does not inherit the previous row's traffic. Other queries reject `endpointId`. Other windows are not available for this query. + // `endpoints_request_volume` is the endpoints-list counterpart: 96 quarter-hour request counts per endpoint over `window=24h` (`step_s` 900, unit `requests`). It requires `deployment` (the public app id, rewritten to the live deployment UUID). Repeat `endpointId` once per id on the current `listEndpoints` page to pad idle endpoints with all-null series, in request order. Buckets that started before that endpoint row's `createdAt` are null, so a removed-then-readded path does not inherit the previous row's traffic. Other queries reject `endpointId`. Other windows are not available for this query. + // + // Three queries serve one app's overview, all over `window=24h` at `step_s` 900 and all requiring `deployment` (the public app id, rewritten to the live deployment UUID): `app_traffic_24h` returns `requests`, `client_errors` (4xx) and `server_errors` (5xx) as request counts; `app_worker_seconds_24h` returns `startup`, `execution` and `idle` as worker-seconds, whose three values in a bucket sum to that bucket's worker time; and `app_cold_starts_24h` returns `cold_starts` as a count. They report counts and totals rather than rates or ratios, so a per-minute figure is a bucket value divided by `step_s / 60` and a 24h ratio is one summed axis over another — summing first and dividing once, because averaging a per-bucket ratio across the axis does not give the 24h ratio. These queries are absent from `listInsightsQueries`: they back the overview rather than the Metrics tab. Other windows are not available for them. // // Returns a wrapper object for the known response body format(s). // @@ -7603,7 +8051,7 @@ type ClientWithResponsesInterface interface { // DeleteSecretWithResponse Delete a secret // - // Soft-deletes a secret: marks the row `pending_destroy` and bumps revision. This API does not hard-delete the row. A background sweep removes the row and releases the name once no running worker can still hold the value — the value travels inside the worker's own environment, which is fixed when the container starts, so a worker keeps it until it stops. There is no deadline on that wait. Returns `409` while any app still attaches it — cascade-detach is not performed here; detach each holder with `DELETE .../apps/{id}/secrets/{name}` first. Attach and detach change the secret set for the next rollout. Neither operation rolls workers. While the row remains `pending_destroy` the name stays reserved, so create may return `409` even though list no longer shows the secret. Retries on an already-pending name are safe when no attachments remain (`204`); they still return `409` while attached. + // Soft-deletes a secret: marks the row `pending_destroy` and bumps revision. This API does not hard-delete the row. Returns `409` while any app still attaches it — cascade-detach is not performed here; detach each holder with `DELETE .../apps/{id}/secrets/{name}` first, which rolls the deployments it names in place. A background sweep removes the row and releases the name once no running worker can still hold the value, rather than assuming every roll a detach started actually landed — a deployment that was not live when detached has nothing to roll until it resumes, so the sweep is the actual backstop, not the detach. While the row remains `pending_destroy` the name stays reserved, so create may return `409` even though list no longer shows the secret. Retries on an already-pending name are safe when no attachments remain (`204`); they still return `409` while attached. // // Returns a wrapper object for the known response body format(s). // @@ -7612,6 +8060,8 @@ type ClientWithResponsesInterface interface { // UpdateSecretWithBodyWithResponse Update a secret // + // Re-encrypts the value under the same name (no rename). Rolls every live deployment that attaches this secret in place so a running worker picks up the new value. If a rollout is already in progress when this commits, this change is not guaranteed to land on it — it reaches the worker on a later redeploy instead. A deployment that is not live picks it up on its next deploy for another reason. + // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // // Corresponds with PUT /v1/secrets/{secretName} (the `UpdateSecret` operationId). @@ -7619,6 +8069,8 @@ type ClientWithResponsesInterface interface { // UpdateSecretWithResponse Update a secret // + // Re-encrypts the value under the same name (no rename). Rolls every live deployment that attaches this secret in place so a running worker picks up the new value. If a rollout is already in progress when this commits, this change is not guaranteed to land on it — it reaches the worker on a later redeploy instead. A deployment that is not live picks it up on its next deploy for another reason. + // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // // Corresponds with PUT /v1/secrets/{secretName} (the `UpdateSecret` operationId). @@ -8909,6 +9361,127 @@ func (r UpdateAppEnvironmentVariableResponse) ContentType() string { return "" } +// ListAppErrorsResponse429Headers the declared response headers of an HTTP 429 response for ListAppErrors +type ListAppErrorsResponse429Headers struct { + RetryAfter int32 +} + +type ListAppErrorsResponse struct { + Body []byte + HTTPResponse *http.Response + // JSON200 the response for an HTTP 200 `application/json` response + JSON200 *struct { + Data []LogEntry `json:"data"` + + // NextCursor Cursor for the next page; null when there are no more items. + NextCursor *string `json:"nextCursor,omitempty"` + } + // ApplicationproblemJSON400 the response for an HTTP 400 `application/problem+json` response + ApplicationproblemJSON400 *BadRequest + // ApplicationproblemJSON401 the response for an HTTP 401 `application/problem+json` response + ApplicationproblemJSON401 *Unauthorized + // ApplicationproblemJSON403 the response for an HTTP 403 `application/problem+json` response + ApplicationproblemJSON403 *Forbidden + // ApplicationproblemJSON404 the response for an HTTP 404 `application/problem+json` response + ApplicationproblemJSON404 *NotFound + // ApplicationproblemJSON422 the response for an HTTP 422 `application/problem+json` response + ApplicationproblemJSON422 *ValidationError + // ApplicationproblemJSON429 the response for an HTTP 429 `application/problem+json` response + ApplicationproblemJSON429 *TooManyRequests + // ApplicationproblemJSON502 the response for an HTTP 502 `application/problem+json` response + ApplicationproblemJSON502 *BadGateway + // ApplicationproblemJSON503 the response for an HTTP 503 `application/problem+json` response + ApplicationproblemJSON503 *ServiceUnavailable + // ApplicationproblemJSON504 the response for an HTTP 504 `application/problem+json` response + ApplicationproblemJSON504 *GatewayTimeout + // Headers429 the parsed response headers for an HTTP 429 response + Headers429 *ListAppErrorsResponse429Headers +} + +// GetJSON200 returns the response for an HTTP 200 `application/json` response +func (r ListAppErrorsResponse) GetJSON200() *struct { + Data []LogEntry `json:"data"` + + // NextCursor Cursor for the next page; null when there are no more items. + NextCursor *string `json:"nextCursor,omitempty"` +} { + return r.JSON200 +} + +// GetApplicationproblemJSON400 returns the response for an HTTP 400 `application/problem+json` response +func (r ListAppErrorsResponse) GetApplicationproblemJSON400() *BadRequest { + return r.ApplicationproblemJSON400 +} + +// GetApplicationproblemJSON401 returns the response for an HTTP 401 `application/problem+json` response +func (r ListAppErrorsResponse) GetApplicationproblemJSON401() *Unauthorized { + return r.ApplicationproblemJSON401 +} + +// GetApplicationproblemJSON403 returns the response for an HTTP 403 `application/problem+json` response +func (r ListAppErrorsResponse) GetApplicationproblemJSON403() *Forbidden { + return r.ApplicationproblemJSON403 +} + +// GetApplicationproblemJSON404 returns the response for an HTTP 404 `application/problem+json` response +func (r ListAppErrorsResponse) GetApplicationproblemJSON404() *NotFound { + return r.ApplicationproblemJSON404 +} + +// GetApplicationproblemJSON422 returns the response for an HTTP 422 `application/problem+json` response +func (r ListAppErrorsResponse) GetApplicationproblemJSON422() *ValidationError { + return r.ApplicationproblemJSON422 +} + +// GetApplicationproblemJSON429 returns the response for an HTTP 429 `application/problem+json` response +func (r ListAppErrorsResponse) GetApplicationproblemJSON429() *TooManyRequests { + return r.ApplicationproblemJSON429 +} + +// GetApplicationproblemJSON502 returns the response for an HTTP 502 `application/problem+json` response +func (r ListAppErrorsResponse) GetApplicationproblemJSON502() *BadGateway { + return r.ApplicationproblemJSON502 +} + +// GetApplicationproblemJSON503 returns the response for an HTTP 503 `application/problem+json` response +func (r ListAppErrorsResponse) GetApplicationproblemJSON503() *ServiceUnavailable { + return r.ApplicationproblemJSON503 +} + +// GetApplicationproblemJSON504 returns the response for an HTTP 504 `application/problem+json` response +func (r ListAppErrorsResponse) GetApplicationproblemJSON504() *GatewayTimeout { + return r.ApplicationproblemJSON504 +} + +// GetBody returns the raw response body bytes +func (r ListAppErrorsResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r ListAppErrorsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListAppErrorsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListAppErrorsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type ListAppEventsResponse struct { Body []byte HTTPResponse *http.Response @@ -11007,6 +11580,89 @@ func (r UpdateGpuTypePriceResponse) ContentType() string { return "" } +type RestoreGpuTypeResponse struct { + Body []byte + HTTPResponse *http.Response + // JSON200 the response for an HTTP 200 `application/json` response + JSON200 *GpuType + // ApplicationproblemJSON401 the response for an HTTP 401 `application/problem+json` response + ApplicationproblemJSON401 *Unauthorized + // ApplicationproblemJSON403 the response for an HTTP 403 `application/problem+json` response + ApplicationproblemJSON403 *Forbidden + // ApplicationproblemJSON404 the response for an HTTP 404 `application/problem+json` response + ApplicationproblemJSON404 *NotFound + // ApplicationproblemJSON409 the response for an HTTP 409 `application/problem+json` response + ApplicationproblemJSON409 *Conflict + // ApplicationproblemJSON422 the response for an HTTP 422 `application/problem+json` response + ApplicationproblemJSON422 *ValidationError + // ApplicationproblemJSON503 the response for an HTTP 503 `application/problem+json` response + ApplicationproblemJSON503 *ServiceUnavailable +} + +// GetJSON200 returns the response for an HTTP 200 `application/json` response +func (r RestoreGpuTypeResponse) GetJSON200() *GpuType { + return r.JSON200 +} + +// GetApplicationproblemJSON401 returns the response for an HTTP 401 `application/problem+json` response +func (r RestoreGpuTypeResponse) GetApplicationproblemJSON401() *Unauthorized { + return r.ApplicationproblemJSON401 +} + +// GetApplicationproblemJSON403 returns the response for an HTTP 403 `application/problem+json` response +func (r RestoreGpuTypeResponse) GetApplicationproblemJSON403() *Forbidden { + return r.ApplicationproblemJSON403 +} + +// GetApplicationproblemJSON404 returns the response for an HTTP 404 `application/problem+json` response +func (r RestoreGpuTypeResponse) GetApplicationproblemJSON404() *NotFound { + return r.ApplicationproblemJSON404 +} + +// GetApplicationproblemJSON409 returns the response for an HTTP 409 `application/problem+json` response +func (r RestoreGpuTypeResponse) GetApplicationproblemJSON409() *Conflict { + return r.ApplicationproblemJSON409 +} + +// GetApplicationproblemJSON422 returns the response for an HTTP 422 `application/problem+json` response +func (r RestoreGpuTypeResponse) GetApplicationproblemJSON422() *ValidationError { + return r.ApplicationproblemJSON422 +} + +// GetApplicationproblemJSON503 returns the response for an HTTP 503 `application/problem+json` response +func (r RestoreGpuTypeResponse) GetApplicationproblemJSON503() *ServiceUnavailable { + return r.ApplicationproblemJSON503 +} + +// GetBody returns the raw response body bytes +func (r RestoreGpuTypeResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r RestoreGpuTypeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RestoreGpuTypeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r RestoreGpuTypeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + // GetLogEntriesResponse429Headers the declared response headers of an HTTP 429 response for GetLogEntries type GetLogEntriesResponse429Headers struct { RetryAfter int32 @@ -11118,18 +11774,129 @@ func (r GetLogEntriesResponse) ContentType() string { return "" } -type ListInsightsQueriesResponse struct { +// TailLogEntriesResponse429Headers the declared response headers of an HTTP 429 response for TailLogEntries +type TailLogEntriesResponse429Headers struct { + RetryAfter int32 +} + +type TailLogEntriesResponse struct { Body []byte HTTPResponse *http.Response - // JSON200 the response for an HTTP 200 `application/json` response - JSON200 *QueryCatalogue + // ApplicationproblemJSON400 the response for an HTTP 400 `application/problem+json` response + ApplicationproblemJSON400 *BadRequest // ApplicationproblemJSON401 the response for an HTTP 401 `application/problem+json` response ApplicationproblemJSON401 *Unauthorized // ApplicationproblemJSON403 the response for an HTTP 403 `application/problem+json` response ApplicationproblemJSON403 *Forbidden - // ApplicationproblemJSON502 the response for an HTTP 502 `application/problem+json` response - ApplicationproblemJSON502 *BadGateway - // ApplicationproblemJSON503 the response for an HTTP 503 `application/problem+json` response + // ApplicationproblemJSON404 the response for an HTTP 404 `application/problem+json` response + ApplicationproblemJSON404 *NotFound + // ApplicationproblemJSON422 the response for an HTTP 422 `application/problem+json` response + ApplicationproblemJSON422 *ValidationError + // ApplicationproblemJSON429 the response for an HTTP 429 `application/problem+json` response + ApplicationproblemJSON429 *TooManyRequests + // ApplicationproblemJSON500 the response for an HTTP 500 `application/problem+json` response + ApplicationproblemJSON500 *InternalServerError + // ApplicationproblemJSON502 the response for an HTTP 502 `application/problem+json` response + ApplicationproblemJSON502 *BadGateway + // ApplicationproblemJSON503 the response for an HTTP 503 `application/problem+json` response + ApplicationproblemJSON503 *ServiceUnavailable + // ApplicationproblemJSON504 the response for an HTTP 504 `application/problem+json` response + ApplicationproblemJSON504 *GatewayTimeout + // Headers429 the parsed response headers for an HTTP 429 response + Headers429 *TailLogEntriesResponse429Headers +} + +// GetApplicationproblemJSON400 returns the response for an HTTP 400 `application/problem+json` response +func (r TailLogEntriesResponse) GetApplicationproblemJSON400() *BadRequest { + return r.ApplicationproblemJSON400 +} + +// GetApplicationproblemJSON401 returns the response for an HTTP 401 `application/problem+json` response +func (r TailLogEntriesResponse) GetApplicationproblemJSON401() *Unauthorized { + return r.ApplicationproblemJSON401 +} + +// GetApplicationproblemJSON403 returns the response for an HTTP 403 `application/problem+json` response +func (r TailLogEntriesResponse) GetApplicationproblemJSON403() *Forbidden { + return r.ApplicationproblemJSON403 +} + +// GetApplicationproblemJSON404 returns the response for an HTTP 404 `application/problem+json` response +func (r TailLogEntriesResponse) GetApplicationproblemJSON404() *NotFound { + return r.ApplicationproblemJSON404 +} + +// GetApplicationproblemJSON422 returns the response for an HTTP 422 `application/problem+json` response +func (r TailLogEntriesResponse) GetApplicationproblemJSON422() *ValidationError { + return r.ApplicationproblemJSON422 +} + +// GetApplicationproblemJSON429 returns the response for an HTTP 429 `application/problem+json` response +func (r TailLogEntriesResponse) GetApplicationproblemJSON429() *TooManyRequests { + return r.ApplicationproblemJSON429 +} + +// GetApplicationproblemJSON500 returns the response for an HTTP 500 `application/problem+json` response +func (r TailLogEntriesResponse) GetApplicationproblemJSON500() *InternalServerError { + return r.ApplicationproblemJSON500 +} + +// GetApplicationproblemJSON502 returns the response for an HTTP 502 `application/problem+json` response +func (r TailLogEntriesResponse) GetApplicationproblemJSON502() *BadGateway { + return r.ApplicationproblemJSON502 +} + +// GetApplicationproblemJSON503 returns the response for an HTTP 503 `application/problem+json` response +func (r TailLogEntriesResponse) GetApplicationproblemJSON503() *ServiceUnavailable { + return r.ApplicationproblemJSON503 +} + +// GetApplicationproblemJSON504 returns the response for an HTTP 504 `application/problem+json` response +func (r TailLogEntriesResponse) GetApplicationproblemJSON504() *GatewayTimeout { + return r.ApplicationproblemJSON504 +} + +// GetBody returns the raw response body bytes +func (r TailLogEntriesResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r TailLogEntriesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r TailLogEntriesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r TailLogEntriesResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ListInsightsQueriesResponse struct { + Body []byte + HTTPResponse *http.Response + // JSON200 the response for an HTTP 200 `application/json` response + JSON200 *QueryCatalogue + // ApplicationproblemJSON401 the response for an HTTP 401 `application/problem+json` response + ApplicationproblemJSON401 *Unauthorized + // ApplicationproblemJSON403 the response for an HTTP 403 `application/problem+json` response + ApplicationproblemJSON403 *Forbidden + // ApplicationproblemJSON502 the response for an HTTP 502 `application/problem+json` response + ApplicationproblemJSON502 *BadGateway + // ApplicationproblemJSON503 the response for an HTTP 503 `application/problem+json` response ApplicationproblemJSON503 *ServiceUnavailable // ApplicationproblemJSON504 the response for an HTTP 504 `application/problem+json` response ApplicationproblemJSON504 *GatewayTimeout @@ -11814,8 +12581,6 @@ type DeleteSourceUploadResponse struct { ApplicationproblemJSON409 *Conflict // ApplicationproblemJSON422 the response for an HTTP 422 `application/problem+json` response ApplicationproblemJSON422 *ValidationError - // ApplicationproblemJSON502 the response for an HTTP 502 `application/problem+json` response - ApplicationproblemJSON502 *BadGateway // ApplicationproblemJSON503 the response for an HTTP 503 `application/problem+json` response ApplicationproblemJSON503 *ServiceUnavailable } @@ -11850,11 +12615,6 @@ func (r DeleteSourceUploadResponse) GetApplicationproblemJSON422() *ValidationEr return r.ApplicationproblemJSON422 } -// GetApplicationproblemJSON502 returns the response for an HTTP 502 `application/problem+json` response -func (r DeleteSourceUploadResponse) GetApplicationproblemJSON502() *BadGateway { - return r.ApplicationproblemJSON502 -} - // GetApplicationproblemJSON503 returns the response for an HTTP 503 `application/problem+json` response func (r DeleteSourceUploadResponse) GetApplicationproblemJSON503() *ServiceUnavailable { return r.ApplicationproblemJSON503 @@ -12361,9 +13121,9 @@ func (c *ClientWithResponses) GetAppWithResponse(ctx context.Context, appId AppI // // Patches one or more aspects of an app in place. All fields are optional; omitted fields are left unchanged. Valid in any non-`deleted` status, including `stopped` (changes apply on `resume`). Lifecycle transitions use the dedicated `deploy`, `stop`, `resume`, and `delete` operations. // A configuration or `environmentVariables` change records a new version with the same image. If that image is deployable, the update pins it as `activeVersionId` and rolls the workload when the app is `active` or `initializing`. A `failed` app is moved to `initializing` and rolled, the same as `POST /deploy`. If the image is not deployable, the version is recorded and `activeVersionId` is left unchanged. If the roll fails, `activeVersionId` is restored and the previous configuration keeps serving. A name-only change records a version and does not pin. A `stopped` or `stopping` app pins the version and rolls it on `resume`. A configuration, `environmentVariables`, or `appSource` change while a create or resume rollout is already in progress returns `409 Conflict`. A name-only or `secrets`-only change does not. -// `appSource` starts a build and records version N+1 with a new image tag. The deploy queue carries the build-then-deploy tail; `activeVersionId` moves only when that rollout completes. A builder rejection (400 where a container document's parser refused it, 422 where it parsed and broke a rule) leaves the app on its current version and writes no version row and no build row. After the builder accepts, version N+1 is recorded even if a concurrent secret deactivation or env/secret collision prevents this request's env/secrets overlay; in that case the previous environmentVariables and attachment set stay in place and are what the new version snapshots. +// `appSource` starts a build and records version N+1 with a new image tag. The deploy queue carries the build-then-deploy tail; `activeVersionId` moves only when that rollout completes. A builder rejection (400 where a container document's parser refused it, 422 where it parsed and broke a rule) leaves the app on its current version and writes no version row and no build row. After the builder accepts, version N+1 is recorded even if a concurrent secret deactivation, an env/secret collision, or the 25-deployment attachment ceiling below prevents this request's env/secrets overlay; in that case the previous environmentVariables and attachment set stay in place and are what the new version snapshots, and the `422` the standalone case below returns does not apply here — the request still succeeds. // `environmentVariables` replaces the whole set: a key absent from the map is deleted, and a null value omits that key from the new set. The resolved map is snapshotted onto the new version. -// `secrets` replaces the whole attachment set. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app; the combined set of plain variables and attachments is capped at 100. This is a control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so a secrets-only change does not roll the workload. +// `secrets` replaces the whole attachment set. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app; the combined set of plain variables and attachments is capped at 100, and each individual secret can be attached to at most 25 deployments total — an entry that would push a secret past that returns `422`. This is a control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so a secrets-only change does not roll the workload. // Endpoints are not a field of this contract: the set belongs to the app source, so it changes only when a new version with a new source builds and deploys. // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). @@ -12381,9 +13141,9 @@ func (c *ClientWithResponses) UpdateAppWithBodyWithResponse(ctx context.Context, // // Patches one or more aspects of an app in place. All fields are optional; omitted fields are left unchanged. Valid in any non-`deleted` status, including `stopped` (changes apply on `resume`). Lifecycle transitions use the dedicated `deploy`, `stop`, `resume`, and `delete` operations. // A configuration or `environmentVariables` change records a new version with the same image. If that image is deployable, the update pins it as `activeVersionId` and rolls the workload when the app is `active` or `initializing`. A `failed` app is moved to `initializing` and rolled, the same as `POST /deploy`. If the image is not deployable, the version is recorded and `activeVersionId` is left unchanged. If the roll fails, `activeVersionId` is restored and the previous configuration keeps serving. A name-only change records a version and does not pin. A `stopped` or `stopping` app pins the version and rolls it on `resume`. A configuration, `environmentVariables`, or `appSource` change while a create or resume rollout is already in progress returns `409 Conflict`. A name-only or `secrets`-only change does not. -// `appSource` starts a build and records version N+1 with a new image tag. The deploy queue carries the build-then-deploy tail; `activeVersionId` moves only when that rollout completes. A builder rejection (400 where a container document's parser refused it, 422 where it parsed and broke a rule) leaves the app on its current version and writes no version row and no build row. After the builder accepts, version N+1 is recorded even if a concurrent secret deactivation or env/secret collision prevents this request's env/secrets overlay; in that case the previous environmentVariables and attachment set stay in place and are what the new version snapshots. +// `appSource` starts a build and records version N+1 with a new image tag. The deploy queue carries the build-then-deploy tail; `activeVersionId` moves only when that rollout completes. A builder rejection (400 where a container document's parser refused it, 422 where it parsed and broke a rule) leaves the app on its current version and writes no version row and no build row. After the builder accepts, version N+1 is recorded even if a concurrent secret deactivation, an env/secret collision, or the 25-deployment attachment ceiling below prevents this request's env/secrets overlay; in that case the previous environmentVariables and attachment set stay in place and are what the new version snapshots, and the `422` the standalone case below returns does not apply here — the request still succeeds. // `environmentVariables` replaces the whole set: a key absent from the map is deleted, and a null value omits that key from the new set. The resolved map is snapshotted onto the new version. -// `secrets` replaces the whole attachment set. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app; the combined set of plain variables and attachments is capped at 100. This is a control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so a secrets-only change does not roll the workload. +// `secrets` replaces the whole attachment set. An attachment absent from the array is detached. Injected names must not collide with a plain environment variable on the app; the combined set of plain variables and attachments is capped at 100, and each individual secret can be attached to at most 25 deployments total — an entry that would push a secret past that returns `422`. This is a control-plane record only — secret values do not reach a pod, and the version snapshot carries no secrets — so a secrets-only change does not roll the workload. // Endpoints are not a field of this contract: the set belongs to the app source, so it changes only when a new version with a new source builds and deploys. // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). @@ -12570,6 +13330,21 @@ func (c *ClientWithResponses) UpdateAppEnvironmentVariableWithResponse(ctx conte return ParseUpdateAppEnvironmentVariableResponse(rsp) } +// ListAppErrorsWithResponse List request errors for an app +// +// One page of failed inference requests for this app, newest first. Omit `statusClass` for both 4xx and 5xx. The cursor is opaque and is only valid with the same `window` and `statusClass` it was issued under. +// +// Returns a wrapper object for the known response body format(s). +// +// Corresponds with GET /v1/apps/{appId}/errors (the `ListAppErrors` operationId). +func (c *ClientWithResponses) ListAppErrorsWithResponse(ctx context.Context, appId AppId, params *ListAppErrorsParams, reqEditors ...RequestEditorFn) (*ListAppErrorsResponse, error) { + rsp, err := c.ListAppErrors(ctx, appId, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListAppErrorsResponse(rsp) +} + // ListAppEventsWithResponse List app events // // Returns a wrapper object for the known response body format(s). @@ -12615,7 +13390,7 @@ func (c *ClientWithResponses) FavouriteAppWithResponse(ctx context.Context, appI // StartAsyncTaskWithBodyWithResponse Start a new async task // -// Starts a new async task on `appId`, routing the request body payload to an available worker. The task runs asynchronously and the response is `202`; poll `GET /v1/apps/{appId}/tasks/{taskId}` for completion. Resubmitting a task id is answered with the task it already names rather than starting a second one, so the `202` can carry a task that has already finished: read its `status` instead of assuming `pending`, and note it may name a different `appId`. Apps in `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. +// Starts a new async task on `appId`, routing the request body payload to an available worker. The task runs asynchronously and the response is `202`; poll `GET /v1/apps/{appId}/tasks/{taskId}` for completion. Resubmitting a task id is answered with the task it already names rather than starting a second one, so the `202` can carry a task that has already finished: read its `status` instead of assuming `pending`, and note it may name a different `appId`. Apps in `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an `initializing` app whose first rollout has not produced a version returns `409 Conflict`, and an `active` app the platform has observed to have no workload able to serve returns `503 Service Unavailable` with no task minted. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // @@ -12630,7 +13405,7 @@ func (c *ClientWithResponses) StartAsyncTaskWithBodyWithResponse(ctx context.Con // StartAsyncTaskWithResponse Start a new async task // -// Starts a new async task on `appId`, routing the request body payload to an available worker. The task runs asynchronously and the response is `202`; poll `GET /v1/apps/{appId}/tasks/{taskId}` for completion. Resubmitting a task id is answered with the task it already names rather than starting a second one, so the `202` can carry a task that has already finished: read its `status` instead of assuming `pending`, and note it may name a different `appId`. Apps in `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. +// Starts a new async task on `appId`, routing the request body payload to an available worker. The task runs asynchronously and the response is `202`; poll `GET /v1/apps/{appId}/tasks/{taskId}` for completion. Resubmitting a task id is answered with the task it already names rather than starting a second one, so the `202` can carry a task that has already finished: read its `status` instead of assuming `pending`, and note it may name a different `appId`. Apps in `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an `initializing` app whose first rollout has not produced a version returns `409 Conflict`, and an `active` app the platform has observed to have no workload able to serve returns `503 Service Unavailable` with no task minted. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // @@ -12645,7 +13420,7 @@ func (c *ClientWithResponses) StartAsyncTaskWithResponse(ctx context.Context, ap // StartSyncTaskWithBodyWithResponse Start a new sync task // -// Starts a new sync task on `appId`, routing the request body payload to an available worker. The request blocks until the task is terminal and returns the result inline (`200`). Resubmitting a task id waits on the task it already names rather than starting a second one, so the `200` carries that task's result and may name a different `appId` — poll it under the one returned. A task that outlives the wait window is **not** a failure: the task is still queued or running, and the response is `202` carrying that task with `status: pending` — the same shape `invoke-async` returns, and it names the owning `appId` on a resubmission just as the `200` does. Poll `GET /v1/apps/{appId}/tasks/{taskId}` for its result. A request the platform cannot attribute to an accepted task fails instead, with no task to poll. Apps in `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. +// Starts a new sync task on `appId`, routing the request body payload to an available worker. The request blocks until the task is terminal and returns the result inline (`200`). Resubmitting a task id waits on the task it already names rather than starting a second one, so the `200` carries that task's result and may name a different `appId` — poll it under the one returned. A task that outlives the wait window is **not** a failure: the task is still queued or running, and the response is `202` carrying that task with `status: pending` — the same shape `invoke-async` returns, and it names the owning `appId` on a resubmission just as the `200` does. Poll `GET /v1/apps/{appId}/tasks/{taskId}` for its result. A request the platform cannot attribute to an accepted task fails instead, with no task to poll. Apps in `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an `initializing` app whose first rollout has not produced a version returns `409 Conflict`, and an `active` app the platform has observed to have no workload able to serve returns `503 Service Unavailable` with no task minted. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // @@ -12660,7 +13435,7 @@ func (c *ClientWithResponses) StartSyncTaskWithBodyWithResponse(ctx context.Cont // StartSyncTaskWithResponse Start a new sync task // -// Starts a new sync task on `appId`, routing the request body payload to an available worker. The request blocks until the task is terminal and returns the result inline (`200`). Resubmitting a task id waits on the task it already names rather than starting a second one, so the `200` carries that task's result and may name a different `appId` — poll it under the one returned. A task that outlives the wait window is **not** a failure: the task is still queued or running, and the response is `202` carrying that task with `status: pending` — the same shape `invoke-async` returns, and it names the owning `appId` on a resubmission just as the `200` does. Poll `GET /v1/apps/{appId}/tasks/{taskId}` for its result. A request the platform cannot attribute to an accepted task fails instead, with no task to poll. Apps in `initializing`, `active`, or `stopping` accept invocation. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. +// Starts a new sync task on `appId`, routing the request body payload to an available worker. The request blocks until the task is terminal and returns the result inline (`200`). Resubmitting a task id waits on the task it already names rather than starting a second one, so the `200` carries that task's result and may name a different `appId` — poll it under the one returned. A task that outlives the wait window is **not** a failure: the task is still queued or running, and the response is `202` carrying that task with `status: pending` — the same shape `invoke-async` returns, and it names the owning `appId` on a resubmission just as the `200` does. Poll `GET /v1/apps/{appId}/tasks/{taskId}` for its result. A request the platform cannot attribute to an accepted task fails instead, with no task to poll. Apps in `initializing`, `active`, or `stopping` accept invocation, with two exceptions: an `initializing` app whose first rollout has not produced a version returns `409 Conflict`, and an `active` app the platform has observed to have no workload able to serve returns `503 Service Unavailable` with no task minted. `stopped`, `deleting`, and `failed` return `409 Conflict`; unknown or deleted apps return `404 Not Found`. An organisation whose serverless tenancy is revoked, or has no tenancy receipt at all, returns `403 Forbidden`, distinguishing that from an app that does not exist. An organisation whose tenancy is still being provisioned returns `503 Service Unavailable` instead — that state is retryable, not a revocation, and clears once provisioning finishes. Endpoint membership is checked against the active version's endpoint set before the task is accepted: an endpoint the app does not declare returns `404` whose `endpointPath` extension member carries the rejected path, distinguishing it from an unknown app, and the task never enters the queue. // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // @@ -12703,9 +13478,10 @@ func (c *ClientWithResponses) ListAppSecretsWithResponse(ctx context.Context, ap // AttachAppSecretWithBodyWithResponse Attach a secret to an app // -// Records that an organisation secret is attached to an app under a resolved env-var name. The next rollout injects the value into the worker. This operation does not roll workers. Returns `409` if the secret is already attached, or if another attach would use the same env-var name. +// Records that an organisation secret is attached to an app under a resolved env-var name, and rolls the app's live deployment in place so a running worker picks up the value without waiting for an unrelated deploy. If a rollout is already in progress when this commits, this attach is not guaranteed to land on it — it reaches the worker on a later redeploy instead. A deployment that is not live records the attachment only — the next resume reads the attach set fresh. Returns `409` if the secret is already attached, or if another attach would use the same env-var name. // The resolved name (`envVarName`, or `secretName` when omitted) must not already exist as a plain environment variable on this app (`deployment_configs.key`). Both sources use the same pod env namespace, so the server rejects the collision with `422` instead of allowing a last-wins override later. The reverse check applies when setting a plain environment variable. // An app holds at most 100 environment bindings in total — plain environment variables plus attached secrets — the same combined ceiling as create and the single-key env-var route. Attaching when the app is already at that limit returns `422`. +// A secret can be attached to at most 25 deployments — rolling every attached deployment is what an update or detach costs, so the ceiling bounds that cost rather than the app side of the binding. Attaching past it returns `422`. // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // @@ -12720,9 +13496,10 @@ func (c *ClientWithResponses) AttachAppSecretWithBodyWithResponse(ctx context.Co // AttachAppSecretWithResponse Attach a secret to an app // -// Records that an organisation secret is attached to an app under a resolved env-var name. The next rollout injects the value into the worker. This operation does not roll workers. Returns `409` if the secret is already attached, or if another attach would use the same env-var name. +// Records that an organisation secret is attached to an app under a resolved env-var name, and rolls the app's live deployment in place so a running worker picks up the value without waiting for an unrelated deploy. If a rollout is already in progress when this commits, this attach is not guaranteed to land on it — it reaches the worker on a later redeploy instead. A deployment that is not live records the attachment only — the next resume reads the attach set fresh. Returns `409` if the secret is already attached, or if another attach would use the same env-var name. // The resolved name (`envVarName`, or `secretName` when omitted) must not already exist as a plain environment variable on this app (`deployment_configs.key`). Both sources use the same pod env namespace, so the server rejects the collision with `422` instead of allowing a last-wins override later. The reverse check applies when setting a plain environment variable. // An app holds at most 100 environment bindings in total — plain environment variables plus attached secrets — the same combined ceiling as create and the single-key env-var route. Attaching when the app is already at that limit returns `422`. +// A secret can be attached to at most 25 deployments — rolling every attached deployment is what an update or detach costs, so the ceiling bounds that cost rather than the app side of the binding. Attaching past it returns `422`. // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // @@ -12737,7 +13514,7 @@ func (c *ClientWithResponses) AttachAppSecretWithResponse(ctx context.Context, a // DetachAppSecretWithResponse Detach a secret from an app // -// Removes the attachment from the next rollout. This operation does not roll workers. Existing workers keep the value until they stop. +// Removes the attachment and rolls the app's live deployment in place so a running worker stops receiving the value. If a rollout is already in progress when this commits, this detach is not guaranteed to land on it — the worker stops receiving the value on a later redeploy instead. A deployment that is not live has the removal recorded only — there is nothing to roll until it resumes. // // Returns a wrapper object for the known response body format(s). // @@ -12838,7 +13615,7 @@ func (c *ClientWithResponses) GetVersionWithResponse(ctx context.Context, appId // ListWorkersWithResponse List workers // -// Returns a newest-first page of workers observed for the app (including terminal `stopped` rows until purged). Optional `state` and `status` narrow the page; a cursor must be replayed under the same filters it was issued with. +// Returns a newest-first page of workers observed for the app (including terminal `stopped` rows until purged). Omitted `versionId` scopes the page to the app's `activeVersionId`. An app with no active version therefore answers an empty default page — not that it has no workers, only that none are pinned. Optional `state` and `status` narrow the page further; a cursor must be replayed under the same filters it was issued with. // // Returns a wrapper object for the known response body format(s). // @@ -12883,7 +13660,7 @@ func (c *ClientWithResponses) ListGpuTypesWithResponse(ctx context.Context, reqE // CreateGpuTypeWithBodyWithResponse Add a GPU type to the catalogue // -// Creates a new entry in the global GPU type catalogue. Restricted to the Runware platform organization. The `id` (catalogue code) is immutable and remains reserved after retirement. +// Creates a new entry in the global GPU type catalogue. Restricted to the Runware platform organization. The `id` (catalogue code) is immutable and remains reserved after retirement: a retired code answers `409` here and is brought back with `POST /v1/gpu-types/{gpuTypeId}/restore` rather than recreated. // // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // @@ -12898,7 +13675,7 @@ func (c *ClientWithResponses) CreateGpuTypeWithBodyWithResponse(ctx context.Cont // CreateGpuTypeWithResponse Add a GPU type to the catalogue // -// Creates a new entry in the global GPU type catalogue. Restricted to the Runware platform organization. The `id` (catalogue code) is immutable and remains reserved after retirement. +// Creates a new entry in the global GPU type catalogue. Restricted to the Runware platform organization. The `id` (catalogue code) is immutable and remains reserved after retirement: a retired code answers `409` here and is brought back with `POST /v1/gpu-types/{gpuTypeId}/restore` rather than recreated. // // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // @@ -12913,7 +13690,7 @@ func (c *ClientWithResponses) CreateGpuTypeWithResponse(ctx context.Context, bod // DeleteGpuTypeWithResponse Retire a GPU type from the catalogue // -// Soft-deletes a GPU type while preserving its code and price history. Restricted to the Runware platform organization. Returns `409` if a worker configuration or GPU pool still references the code. Retiring an already retired code returns `404`. +// Soft-deletes a GPU type while preserving its code and price history. Restricted to the Runware platform organization. Returns `409` if a worker configuration or GPU pool still references the code. Retiring an already retired code returns `404`. Reversible with `POST /v1/gpu-types/{gpuTypeId}/restore`. // // Returns a wrapper object for the known response body format(s). // @@ -13061,11 +13838,24 @@ func (c *ClientWithResponses) UpdateGpuTypePriceWithResponse(ctx context.Context return ParseUpdateGpuTypePriceResponse(rsp) } -// GetLogEntriesWithResponse Read one page of a named log query +// RestoreGpuTypeWithResponse Restore a retired GPU type // -// Returns one page of log entries, newest first, with an opaque cursor for the next page when one exists. +// Returns a retired GPU type to the catalogue under the same code, with its price history intact, so the reserved code is usable again for the hardware it already described. Restricted to the Runware platform organization. A code that is not retired returns `409`; a code no entry has ever held returns `404`. Whether customers can then select the type still depends on pool admission, exactly as for any active type. // -// No query is registered yet: live tail, retention tiers and log quotas are decided in a follow-up ADR, so every request currently answers `404`. The route exists so the contract is fixed before the templates land. +// Returns a wrapper object for the known response body format(s). +// +// Corresponds with POST /v1/gpu-types/{gpuTypeId}/restore (the `RestoreGpuType` operationId). +func (c *ClientWithResponses) RestoreGpuTypeWithResponse(ctx context.Context, gpuTypeId GpuTypeId, reqEditors ...RequestEditorFn) (*RestoreGpuTypeResponse, error) { + rsp, err := c.RestoreGpuType(ctx, gpuTypeId, reqEditors...) + if err != nil { + return nil, err + } + return ParseRestoreGpuTypeResponse(rsp) +} + +// GetLogEntriesWithResponse Read one page of a named log query +// +// Returns one page of log entries, newest first, with an opaque cursor for the next page when one exists. Query ids and their supported selectors are listed by the insights catalogue. // // Returns a wrapper object for the known response body format(s). // @@ -13078,6 +13868,21 @@ func (c *ClientWithResponses) GetLogEntriesWithResponse(ctx context.Context, que return ParseGetLogEntriesResponse(rsp) } +// TailLogEntriesWithResponse Follow a named application log query +// +// Streams new application log entries as Server-Sent Events. The stream sends keepalive comments while quiet and ends with an `end` event when its connection lifetime expires or the service shuts down. Clients should reconnect after an `end` event. Use the `runtime_tail` query id; queries that sort or aggregate are rejected because they cannot be followed live. +// +// Returns a wrapper object for the known response body format(s). +// +// Corresponds with GET /v1/logs/queries/{queryId}/tail (the `TailLogEntries` operationId). +func (c *ClientWithResponses) TailLogEntriesWithResponse(ctx context.Context, queryId QueryId, params *TailLogEntriesParams, reqEditors ...RequestEditorFn) (*TailLogEntriesResponse, error) { + rsp, err := c.TailLogEntries(ctx, queryId, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseTailLogEntriesResponse(rsp) +} + // ListInsightsQueriesWithResponse List the metric and log queries this build can answer // // The catalogue: every named query, its unit and aggregation, the selectors it accepts, the series it returns, and the windows actually backed by stored series. @@ -13103,9 +13908,11 @@ func (c *ClientWithResponses) ListInsightsQueriesWithResponse(ctx context.Contex // // An organization with no metrics yet is answered with the full axis and all-null series rather than an error. // -// `apps_request_volume` returns one series per app: 24 hourly request counts over `window=24h` (`step_s` 3600, unit `requests`). Repeat `appId` once per id on the current list page to pad idle apps with all-null series, in request order. Hours that started before that live app's `createdAt` are null, so a reused app id does not inherit the previous generation's traffic still in the 24h store. The same `appId` pad applies to the list-scoped `apps_error_volume` and `apps_request_duration` queries. Other queries reject `appId`. Other windows are not available for these queries. +// `apps_request_volume` returns one series per app: 96 quarter-hour request counts over `window=24h` (`step_s` 900, unit `requests`). Repeat `appId` once per id on the current list page to pad idle apps with all-null series, in request order. A series is named for the live app behind it, so a reused app id reports its own generation's traffic and not the one before it. The same `appId` pad applies to the list-scoped `apps_error_volume` and `apps_request_duration` queries. Other queries reject `appId`. Other windows are not available for these queries. // -// `endpoints_request_volume` is the endpoints-list counterpart: 24 hourly request counts per endpoint over `window=24h` (`step_s` 3600, unit `requests`). It requires `deployment` (the public app id, rewritten to the live deployment UUID). Repeat `endpointId` once per id on the current `listEndpoints` page to pad idle endpoints with all-null series, in request order. Hours that started before that endpoint row's `createdAt` are null, so a removed-then-readded path does not inherit the previous row's traffic. Other queries reject `endpointId`. Other windows are not available for this query. +// `endpoints_request_volume` is the endpoints-list counterpart: 96 quarter-hour request counts per endpoint over `window=24h` (`step_s` 900, unit `requests`). It requires `deployment` (the public app id, rewritten to the live deployment UUID). Repeat `endpointId` once per id on the current `listEndpoints` page to pad idle endpoints with all-null series, in request order. Buckets that started before that endpoint row's `createdAt` are null, so a removed-then-readded path does not inherit the previous row's traffic. Other queries reject `endpointId`. Other windows are not available for this query. +// +// Three queries serve one app's overview, all over `window=24h` at `step_s` 900 and all requiring `deployment` (the public app id, rewritten to the live deployment UUID): `app_traffic_24h` returns `requests`, `client_errors` (4xx) and `server_errors` (5xx) as request counts; `app_worker_seconds_24h` returns `startup`, `execution` and `idle` as worker-seconds, whose three values in a bucket sum to that bucket's worker time; and `app_cold_starts_24h` returns `cold_starts` as a count. They report counts and totals rather than rates or ratios, so a per-minute figure is a bucket value divided by `step_s / 60` and a 24h ratio is one summed axis over another — summing first and dividing once, because averaging a per-bucket ratio across the axis does not give the 24h ratio. These queries are absent from `listInsightsQueries`: they back the overview rather than the Metrics tab. Other windows are not available for them. // // Returns a wrapper object for the known response body format(s). // @@ -13203,7 +14010,7 @@ func (c *ClientWithResponses) CreateSecretWithResponse(ctx context.Context, body // DeleteSecretWithResponse Delete a secret // -// Soft-deletes a secret: marks the row `pending_destroy` and bumps revision. This API does not hard-delete the row. A background sweep removes the row and releases the name once no running worker can still hold the value — the value travels inside the worker's own environment, which is fixed when the container starts, so a worker keeps it until it stops. There is no deadline on that wait. Returns `409` while any app still attaches it — cascade-detach is not performed here; detach each holder with `DELETE .../apps/{id}/secrets/{name}` first. Attach and detach change the secret set for the next rollout. Neither operation rolls workers. While the row remains `pending_destroy` the name stays reserved, so create may return `409` even though list no longer shows the secret. Retries on an already-pending name are safe when no attachments remain (`204`); they still return `409` while attached. +// Soft-deletes a secret: marks the row `pending_destroy` and bumps revision. This API does not hard-delete the row. Returns `409` while any app still attaches it — cascade-detach is not performed here; detach each holder with `DELETE .../apps/{id}/secrets/{name}` first, which rolls the deployments it names in place. A background sweep removes the row and releases the name once no running worker can still hold the value, rather than assuming every roll a detach started actually landed — a deployment that was not live when detached has nothing to roll until it resumes, so the sweep is the actual backstop, not the detach. While the row remains `pending_destroy` the name stays reserved, so create may return `409` even though list no longer shows the secret. Retries on an already-pending name are safe when no attachments remain (`204`); they still return `409` while attached. // // Returns a wrapper object for the known response body format(s). // @@ -13218,6 +14025,8 @@ func (c *ClientWithResponses) DeleteSecretWithResponse(ctx context.Context, secr // UpdateSecretWithBodyWithResponse Update a secret // +// Re-encrypts the value under the same name (no rename). Rolls every live deployment that attaches this secret in place so a running worker picks up the new value. If a rollout is already in progress when this commits, this change is not guaranteed to land on it — it reaches the worker on a later redeploy instead. A deployment that is not live picks it up on its next deploy for another reason. +// // Takes any type of body and a specified content type, and returns a wrapper object for the known response body format(s). // // Corresponds with PUT /v1/secrets/{secretName} (the `UpdateSecret` operationId). @@ -13231,6 +14040,8 @@ func (c *ClientWithResponses) UpdateSecretWithBodyWithResponse(ctx context.Conte // UpdateSecretWithResponse Update a secret // +// Re-encrypts the value under the same name (no rename). Rolls every live deployment that attaches this secret in place so a running worker picks up the new value. If a rollout is already in progress when this commits, this change is not guaranteed to land on it — it reaches the worker on a later redeploy instead. A deployment that is not live picks it up on its next deploy for another reason. +// // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). // // Corresponds with PUT /v1/secrets/{secretName} (the `UpdateSecret` operationId). @@ -14326,6 +15137,113 @@ func ParseUpdateAppEnvironmentVariableResponse(rsp *http.Response) (*UpdateAppEn return response, nil } +// ParseListAppErrorsResponse parses an HTTP response from a ListAppErrorsWithResponse call +func ParseListAppErrorsResponse(rsp *http.Response) (*ListAppErrorsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListAppErrorsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest struct { + Data []LogEntry `json:"data"` + + // NextCursor Cursor for the next page; null when there are no more items. + NextCursor *string `json:"nextCursor,omitempty"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ValidationError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: + var dest BadGateway + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON502 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ServiceUnavailable + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 504: + var dest GatewayTimeout + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON504 = &dest + + } + + switch { + case rsp.StatusCode == 429: + var headers ListAppErrorsResponse429Headers + if values := rsp.Header.Values("Retry-After"); len(values) > 0 { + var value int32 + if err := runtime.BindStyledParameterWithOptions("simple", "Retry-After", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true, Type: "integer", Format: "int32"}); err != nil { + return nil, err + } + headers.RetryAfter = value + } + response.Headers429 = &headers + } + + return response, nil +} + // ParseListAppEventsResponse parses an HTTP response from a ListAppEventsWithResponse call func ParseListAppEventsResponse(rsp *http.Response) (*ListAppEventsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -16016,6 +16934,74 @@ func ParseUpdateGpuTypePriceResponse(rsp *http.Response) (*UpdateGpuTypePriceRes return response, nil } +// ParseRestoreGpuTypeResponse parses an HTTP response from a RestoreGpuTypeWithResponse call +func ParseRestoreGpuTypeResponse(rsp *http.Response) (*RestoreGpuTypeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RestoreGpuTypeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest GpuType + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Conflict + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ValidationError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ServiceUnavailable + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest + + } + + return response, nil +} + // ParseGetLogEntriesResponse parses an HTTP response from a GetLogEntriesWithResponse call func ParseGetLogEntriesResponse(rsp *http.Response) (*GetLogEntriesResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -16118,6 +17104,108 @@ func ParseGetLogEntriesResponse(rsp *http.Response) (*GetLogEntriesResponse, err return response, nil } +// ParseTailLogEntriesResponse parses an HTTP response from a TailLogEntriesWithResponse call +func ParseTailLogEntriesResponse(rsp *http.Response) (*TailLogEntriesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &TailLogEntriesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ValidationError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalServerError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: + var dest BadGateway + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON502 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ServiceUnavailable + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 504: + var dest GatewayTimeout + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON504 = &dest + + } + + switch { + case rsp.StatusCode == 429: + var headers TailLogEntriesResponse429Headers + if values := rsp.Header.Values("Retry-After"); len(values) > 0 { + var value int32 + if err := runtime.BindStyledParameterWithOptions("simple", "Retry-After", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true, Type: "integer", Format: "int32"}); err != nil { + return nil, err + } + headers.RetryAfter = value + } + response.Headers429 = &headers + } + + return response, nil +} + // ParseListInsightsQueriesResponse parses an HTTP response from a ListInsightsQueriesWithResponse call func ParseListInsightsQueriesResponse(rsp *http.Response) (*ListInsightsQueriesResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -16742,13 +17830,6 @@ func ParseDeleteSourceUploadResponse(rsp *http.Response) (*DeleteSourceUploadRes } response.ApplicationproblemJSON422 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: - var dest BadGateway - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.ApplicationproblemJSON502 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: var dest ServiceUnavailable if err := json.Unmarshal(bodyBytes, &dest); err != nil { From 64bd27a01c11f23114014968c100c7b0a44da27e Mon Sep 17 00:00:00 2001 From: Wilson Silva Date: Tue, 8 Sep 2026 12:03:42 +0100 Subject: [PATCH 3/8] feat(serverless): show and follow application logs from the CLI Replaces the apps logs stub with the real command so a developer can debug an app without the dashboard. The recent page comes from the runtime log query over a closed window, and --follow keeps a Server-Sent Events stream open on the runtime_tail query until Ctrl-C, reconnecting when the server ends the stream at the end of its connection lifetime and after a short pause when it reports a failure. The ticket asked for --since, --revision and --worker. The logs API has no such selectors: it narrows by window, app and endpoint, and the tail route by app only. The flags therefore follow the API (--window, --limit, --cursor, --follow), as agreed with Ryan on 2026-09-08, and the three ticket flags are left out rather than shipped as no-ops. There is no --endpoint flag either: the route declares the selector, but no log query accepts it and the API answers 422 for every value. The generated client cannot stream, so the tail uses the raw generated request with the whole-request timeout removed and a small SSE reader on top. An omitted limit is sent as 20 explicitly, because the API does not apply its documented default and the store then answers 100. When the API leaves the level empty the line falls back to the log.severity_text field, which is where the store keeps it today. Ctrl-C now cancels the root command context instead of killing the process, so a follow exits cleanly with status 0 and any other interrupted command exits with 130. --- cmd/runware/main.go | 33 ++- docs/runware_serverless_apps.md | 2 +- docs/runware_serverless_apps_logs.md | 34 ++- internal/api/serverless/client.go | 13 +- internal/api/serverless/logs.go | 170 ++++++++++++++ internal/api/serverless/logs_test.go | 262 ++++++++++++++++++++++ internal/api/serverless/sse.go | 74 ++++++ internal/api/serverless/sse_test.go | 66 ++++++ internal/cmd/serverless/apps.go | 17 +- internal/cmd/serverless/apps_logs.go | 244 ++++++++++++++++++++ internal/cmd/serverless/apps_logs_test.go | 209 +++++++++++++++++ 11 files changed, 1095 insertions(+), 29 deletions(-) create mode 100644 internal/api/serverless/logs.go create mode 100644 internal/api/serverless/logs_test.go create mode 100644 internal/api/serverless/sse.go create mode 100644 internal/api/serverless/sse_test.go create mode 100644 internal/cmd/serverless/apps_logs.go create mode 100644 internal/cmd/serverless/apps_logs_test.go diff --git a/cmd/runware/main.go b/cmd/runware/main.go index ee5a34c..b4a5881 100644 --- a/cmd/runware/main.go +++ b/cmd/runware/main.go @@ -1,7 +1,11 @@ package main import ( + "context" + "errors" "os" + "os/signal" + "syscall" "github.com/runware/runware-cli/internal/buildinfo" "github.com/runware/runware-cli/internal/cmd" @@ -14,11 +18,34 @@ var ( date = "unknown" ) +// exitInterrupted is the conventional status for a run stopped by SIGINT. +const exitInterrupted = 130 + func main() { buildinfo.Set(version, commit, date) + os.Exit(run()) +} + +// run executes the root command under a context that SIGINT and SIGTERM +// cancel, so long-running commands stop cleanly, and returns the exit status. +func run() int { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + // Once the first signal has cancelled the context, hand the signals back so + // a second Ctrl-C kills a command that does not stop on its own. + go func() { + <-ctx.Done() + stop() + }() + rootCmd, logger := cmd.NewRoot() - if err := rootCmd.Execute(); err != nil { - cmdutil.PrintError(logger, cmdutil.FormatFor(rootCmd), err) - os.Exit(1) + err := rootCmd.ExecuteContext(ctx) + if err == nil { + return 0 + } + if errors.Is(err, context.Canceled) && ctx.Err() != nil { + return exitInterrupted } + cmdutil.PrintError(logger, cmdutil.FormatFor(rootCmd), err) + return 1 } diff --git a/docs/runware_serverless_apps.md b/docs/runware_serverless_apps.md index 50a47e2..2a2c60b 100644 --- a/docs/runware_serverless_apps.md +++ b/docs/runware_serverless_apps.md @@ -35,7 +35,7 @@ runware serverless apps [flags] * [runware serverless apps events](runware_serverless_apps_events.md) - List events for a serverless application * [runware serverless apps invoke](runware_serverless_apps_invoke.md) - Invoke an application endpoint * [runware serverless apps list](runware_serverless_apps_list.md) - List serverless applications -* [runware serverless apps logs](runware_serverless_apps_logs.md) - Show logs for a serverless application +* [runware serverless apps logs](runware_serverless_apps_logs.md) - Show or follow logs for a serverless application * [runware serverless apps resume](runware_serverless_apps_resume.md) - Resume a stopped serverless application * [runware serverless apps scale](runware_serverless_apps_scale.md) - Scale a serverless application * [runware serverless apps show](runware_serverless_apps_show.md) - Show details for a serverless application diff --git a/docs/runware_serverless_apps_logs.md b/docs/runware_serverless_apps_logs.md index f989b29..73b42f0 100644 --- a/docs/runware_serverless_apps_logs.md +++ b/docs/runware_serverless_apps_logs.md @@ -1,13 +1,22 @@ ## runware serverless apps logs -Show logs for a serverless application +Show or follow logs for a serverless application ### Synopsis -Show application logs. +Show recent application logs, oldest first, and optionally follow new ones. -This command is not implemented yet. The log-query route exists but currently -answers 404 until a follow-up ADR; live tail is not supported. +The recent page is read from the runtime log query over --window (default 1h), +and --limit and --cursor page through it. With --follow the command prints the +recent page, then streams new entries until interrupted; the stream reconnects +when the server ends it. The live stream has no window, so --window, --limit +and --cursor apply to the recent page only, and --cursor cannot be combined +with --follow. Entries written between the recent page and the start of the +stream, or while the stream reconnects, can be missed or repeated. + +In table format each entry is one line: time, level and message. In json or +yaml format the recent page is printed as one document; with --follow every +entry is printed as one JSON object per line. ``` runware serverless apps logs [flags] @@ -16,14 +25,27 @@ runware serverless apps logs [flags] ### Examples ``` - # show application logs (not available yet) + # show the last hour of logs runware serverless apps logs my-app + + # show the last six hours + runware serverless apps logs my-app --window 6h + + # follow new log entries until Ctrl-C + runware serverless apps logs my-app --follow + + # page through older entries + runware serverless apps logs my-app --limit 50 --cursor ``` ### Options ``` - -h, --help help for logs + --cursor string Pagination cursor from a previous nextCursor + -f, --follow Stream new log entries until interrupted + -h, --help help for logs + --limit int Maximum number of entries on the recent page (1-100) + --window string Time window for the recent page (1h, 6h, 24h, 7d, or 30d) (default "1h") ``` ### Options inherited from parent commands diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index d936f4c..4316152 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -252,14 +252,21 @@ func (c *Client) createInner() *gen.ClientWithResponses { // unchanged. func (c *Client) innerWithMinTimeout(minTimeout time.Duration) *gen.ClientWithResponses { hc, ok := c.doer.(*http.Client) - if !ok { + if !ok || hc.Timeout == 0 || hc.Timeout >= minTimeout { return c.inner } - if hc.Timeout == 0 || hc.Timeout >= minTimeout { + return c.innerWithTimeout(minTimeout) +} + +// innerWithTimeout returns a generated client over a clone of the HTTP client +// with the given whole-request timeout; zero removes the deadline. +func (c *Client) innerWithTimeout(timeout time.Duration) *gen.ClientWithResponses { + hc, ok := c.doer.(*http.Client) + if !ok { return c.inner } cloned := *hc - cloned.Timeout = minTimeout + cloned.Timeout = timeout return newGeneratedClient(c.apiKey, c.baseURL, &cloned) } diff --git a/internal/api/serverless/logs.go b/internal/api/serverless/logs.go new file mode 100644 index 0000000..6f8b23b --- /dev/null +++ b/internal/api/serverless/logs.go @@ -0,0 +1,170 @@ +package serverless + +import ( + "context" + "encoding/json/v2" + "errors" + "fmt" + "io" + "mime" + "net/http" + + "github.com/runware/runware-cli/internal/api/serverless/gen" + "github.com/runware/runware-cli/internal/api/transport" +) + +// LogEntry is one application log line. +type LogEntry = gen.LogEntry + +// LogEntryPage is one page of log entries, newest first. +type LogEntryPage = gen.LogEntryPage + +// GetLogEntriesParams narrows a log page: window, page size, cursor, app and endpoint. +type GetLogEntriesParams = gen.GetLogEntriesParams + +// LogWindow is the closed set of time windows a log query accepts. +type LogWindow = gen.GetLogEntriesParamsWindow + +const ( + // LogQueryRuntime is the pageable application log query. + LogQueryRuntime = "runtime" + // LogQueryRuntimeTail is the only log query the tail route accepts. + LogQueryRuntimeTail = "runtime_tail" + // DefaultLogLimit is sent when the caller sets no page size. The API + // documents 20 but does not apply it, so an omitted limit falls through + // to the downstream default of 100. + DefaultLogLimit = 20 +) + +// ErrTailEnded reports that the server closed a log stream on purpose, at the +// end of its connection lifetime or on shutdown. The caller may reconnect. +var ErrTailEnded = errors.New("log stream ended") + +// TailStreamError is the server's `error` event: the stream is over and the +// detail says why. +type TailStreamError struct { + Detail string +} + +func (e *TailStreamError) Error() string { + if e.Detail == "" { + return "log stream failed" + } + return "log stream failed: " + e.Detail +} + +// GetLogEntries returns one page of a named log query. +func (c *Client) GetLogEntries(ctx context.Context, queryID string, params GetLogEntriesParams) (LogEntryPage, error) { + if c.apiKey == "" { + return LogEntryPage{}, transport.ErrNoAPIKey + } + if params.Limit == nil { + limit := Limit(DefaultLogLimit) + params.Limit = &limit + } + + resp, err := c.inner.GetLogEntriesWithResponse(ctx, queryID, ¶ms) + if err != nil { + return LogEntryPage{}, fmt.Errorf("get log entries: %w", err) + } + + c.logResponse(ctx, resp.HTTPResponse, resp.Body) + + switch resp.StatusCode() { + case http.StatusOK: + if resp.JSON200 == nil { + return LogEntryPage{}, fmt.Errorf("get log entries: empty 200 response") + } + page := *resp.JSON200 + if page.Entries == nil { + page.Entries = []LogEntry{} + } + return page, nil + case http.StatusBadRequest: + return LogEntryPage{}, problemToError(resp.ApplicationproblemJSON400, http.StatusBadRequest) + case http.StatusUnauthorized: + return LogEntryPage{}, problemToError(resp.ApplicationproblemJSON401, http.StatusUnauthorized) + case http.StatusForbidden: + return LogEntryPage{}, problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden) + case http.StatusNotFound: + return LogEntryPage{}, problemToError(resp.ApplicationproblemJSON404, http.StatusNotFound) + case http.StatusUnprocessableEntity: + return LogEntryPage{}, problemToError(resp.ApplicationproblemJSON422, http.StatusUnprocessableEntity) + case http.StatusTooManyRequests: + return LogEntryPage{}, problemToError(resp.ApplicationproblemJSON429, http.StatusTooManyRequests) + case http.StatusBadGateway: + return LogEntryPage{}, problemToError(resp.ApplicationproblemJSON502, http.StatusBadGateway) + case http.StatusServiceUnavailable: + return LogEntryPage{}, problemToError(resp.ApplicationproblemJSON503, http.StatusServiceUnavailable) + case http.StatusGatewayTimeout: + return LogEntryPage{}, problemToError(resp.ApplicationproblemJSON504, http.StatusGatewayTimeout) + default: + return LogEntryPage{}, problemFromBody(resp.Body, resp.StatusCode()) + } +} + +// TailLogs follows a named log query for one app and hands every new entry to +// emit. It returns ErrTailEnded when the server closes the stream cleanly, a +// *TailStreamError when the server reports a failure, ctx.Err() when the +// caller stops, and any error emit returns. +func (c *Client) TailLogs(ctx context.Context, queryID, appID string, emit func(LogEntry) error) error { + if c.apiKey == "" { + return transport.ErrNoAPIKey + } + + params := gen.TailLogEntriesParams{ + Deployment: appID, + } + resp, err := c.streamInner().TailLogEntries(ctx, queryID, ¶ms) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("tail logs: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, maxSSEFrameBytes)) + c.logResponse(ctx, resp, body) + return problemFromBody(body, resp.StatusCode) + } + c.logResponse(ctx, resp, nil) + if mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")); mediaType != "text/event-stream" { + return fmt.Errorf("tail logs: unexpected content type %q", resp.Header.Get("Content-Type")) + } + + err = readSSEEvents(resp.Body, func(ev sseEvent) error { + switch ev.Event { + case "", "message": + var entry LogEntry + if err := json.Unmarshal([]byte(ev.Data), &entry); err != nil { + return fmt.Errorf("tail logs: decode entry: %w", err) + } + return emit(entry) + case sseEventEnd: + return ErrTailEnded + case sseEventError: + return &TailStreamError{Detail: ev.Data} + default: + return nil + } + }) + if ctx.Err() != nil { + return ctx.Err() + } + if err == nil { + // The connection closed without an end or error event. + return ErrTailEnded + } + return err +} + +// streamInner returns the generated client without a whole-request timeout, +// which a long-lived event stream would otherwise hit. +func (c *Client) streamInner() *gen.ClientWithResponses { + if hc, ok := c.doer.(*http.Client); !ok || hc.Timeout == 0 { + return c.inner + } + return c.innerWithTimeout(0) +} diff --git a/internal/api/serverless/logs_test.go b/internal/api/serverless/logs_test.go new file mode 100644 index 0000000..3d01813 --- /dev/null +++ b/internal/api/serverless/logs_test.go @@ -0,0 +1,262 @@ +package serverless + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/runware/runware-cli/internal/api/transport" +) + +const ( + testLogsEntriesPath = "/v1/logs/queries/runtime/entries" + testLogsTailPath = "/v1/logs/queries/runtime_tail/tail" + testLogBodyReady = "ready" + testLogsProblemJSON = "application/problem+json" + testLogsEventStream = "text/event-stream" + testLogsWindow = "1h" + testLogsUnknownAppID = "no-such-app" +) + +func TestGetLogEntries_SendsSelectorsAndDefaultLimit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != testLogsEntriesPath { + t.Errorf("path = %q", r.URL.Path) + } + q := r.URL.Query() + if q.Get("window") != testLogsWindow || q.Get("limit") != "20" || q.Get("deployment") != testAppID { + t.Errorf("query = %q", r.URL.RawQuery) + } + if q.Has("cursor") || q.Has("endpoint") { + t.Errorf("unset selectors must be absent, query = %q", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"entries":[{"time":1750000000,"level":"info","body":%q,"fields":{"k":"v"}}]}`, testLogBodyReady) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + deployment := testAppID + page, err := c.GetLogEntries(context.Background(), LogQueryRuntime, GetLogEntriesParams{ + Window: LogWindow(testLogsWindow), + Deployment: &deployment, + }) + if err != nil { + t.Fatalf("GetLogEntries: %v", err) + } + if len(page.Entries) != 1 || page.Entries[0].Body != testLogBodyReady || page.Entries[0].Time != 1750000000 { + t.Fatalf("entries = %#v", page.Entries) + } + if page.Entries[0].Level == nil || *page.Entries[0].Level != "info" { + t.Errorf("level = %v", page.Entries[0].Level) + } + if page.NextCursor != nil { + t.Errorf("nextCursor must be absent on the last page, got %q", *page.NextCursor) + } +} + +func TestGetLogEntries_PassesLimitAndCursor(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("limit") != "5" || q.Get("cursor") != testCursorPage2 { + t.Errorf("query = %q", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"entries":[],"nextCursor":%q}`, testCursorPage3) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + limit := Limit(5) + cursor := testCursorPage2 + page, err := c.GetLogEntries(context.Background(), LogQueryRuntime, GetLogEntriesParams{ + Window: LogWindow(testLogsWindow), + Limit: &limit, + Cursor: &cursor, + }) + if err != nil { + t.Fatalf("GetLogEntries: %v", err) + } + if page.Entries == nil || len(page.Entries) != 0 { + t.Errorf("entries = %#v, want an empty non-nil slice", page.Entries) + } + if page.NextCursor == nil || *page.NextCursor != testCursorPage3 { + t.Errorf("nextCursor = %v", page.NextCursor) + } +} + +func TestGetLogEntries_NoAPIKey(t *testing.T) { + c := newClient("", "http://unused", slog.Default(), http.DefaultClient) + _, err := c.GetLogEntries(context.Background(), LogQueryRuntime, GetLogEntriesParams{Window: LogWindow(testLogsWindow)}) + if !errors.Is(err, transport.ErrNoAPIKey) { + t.Fatalf("err = %v", err) + } +} + +func TestGetLogEntries_UnknownAppIsNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", testLogsProblemJSON) + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"type":"https://docs.runware.ai/serverless/errors#not-found","title":"Not Found","status":404,"detail":"app no-such-app not found"}`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + deployment := testLogsUnknownAppID + _, err := c.GetLogEntries(context.Background(), LogQueryRuntime, GetLogEntriesParams{ + Window: LogWindow(testLogsWindow), + Deployment: &deployment, + }) + re, ok := errors.AsType[*transport.RunwareError](err) + if !ok { + t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err) + } + if re.Code != transport.CodeNotFound || re.Message != "app no-such-app not found" { + t.Errorf("code=%q message=%q", re.Code, re.Message) + } +} + +func TestGetLogEntries_ValidationErrorNamesTheParameter(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", testLogsProblemJSON) + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"type":"https://docs.runware.ai/serverless/errors#validation-error","title":"Unprocessable Entity","status":422,"detail":"request failed validation","errors":[{"pointer":"/window","detail":"this query does not answer window 30d"}]}`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + _, err := c.GetLogEntries(context.Background(), LogQueryRuntime, GetLogEntriesParams{Window: LogWindow("30d")}) + if err == nil || !strings.Contains(err.Error(), "/window: this query does not answer window 30d") { + t.Fatalf("err = %v", err) + } +} + +func serveSSE(t *testing.T, frames ...string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != testLogsTailPath { + t.Errorf("path = %q", r.URL.Path) + } + if r.URL.Query().Get("deployment") != testAppID { + t.Errorf("query = %q", r.URL.RawQuery) + } + w.Header().Set("Content-Type", testLogsEventStream+"; charset=utf-8") + w.WriteHeader(http.StatusOK) + flusher := w.(http.Flusher) + flusher.Flush() + for _, frame := range frames { + _, _ = w.Write([]byte(frame)) + flusher.Flush() + } + })) +} + +func TestTailLogs_EmitsEntriesUntilEnd(t *testing.T) { + srv := serveSSE(t, + ": keepalive\n\n", + "data: {\"time\":1750000000,\"body\":\"ready\"}\n\n", + "data: {\"time\":1750000001,\"level\":\"warn\",\"body\":\"slow\"}\n\n", + "event: end\ndata: \n\n", + ) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + var got []LogEntry + err := c.TailLogs(context.Background(), LogQueryRuntimeTail, testAppID, func(e LogEntry) error { + got = append(got, e) + return nil + }) + if !errors.Is(err, ErrTailEnded) { + t.Fatalf("err = %v, want ErrTailEnded", err) + } + if len(got) != 2 || got[0].Body != testLogBodyReady || got[1].Body != "slow" || got[1].Level == nil || *got[1].Level != "warn" { + t.Fatalf("entries = %#v", got) + } +} + +func TestTailLogs_ErrorEventCarriesDetail(t *testing.T) { + srv := serveSSE(t, "event: error\ndata: the log stream became unavailable\n\n") + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + err := c.TailLogs(context.Background(), LogQueryRuntimeTail, testAppID, func(LogEntry) error { return nil }) + se, ok := errors.AsType[*TailStreamError](err) + if !ok || se.Detail != "the log stream became unavailable" { + t.Fatalf("err = %#v", err) + } +} + +func TestTailLogs_ConnectionCloseWithoutEndCountsAsEnded(t *testing.T) { + srv := serveSSE(t, "data: {\"time\":1,\"body\":\"x\"}\n\n") + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + err := c.TailLogs(context.Background(), LogQueryRuntimeTail, testAppID, func(LogEntry) error { return nil }) + if !errors.Is(err, ErrTailEnded) { + t.Fatalf("err = %v", err) + } +} + +func TestTailLogs_CancelReturnsContextError(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", testLogsEventStream) + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + select { + case <-r.Context().Done(): + case <-release: + } + })) + defer srv.Close() + defer close(release) + + // The cancel lands after the client's whole-request timeout would have + // fired, so a tail that kept the timeout fails this test with a deadline error. + c := newClient("test-key", srv.URL, slog.Default(), &http.Client{Timeout: 100 * time.Millisecond}) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(400 * time.Millisecond) + cancel() + }() + err := c.TailLogs(ctx, LogQueryRuntimeTail, testAppID, func(LogEntry) error { return nil }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } +} + +func TestTailLogs_NonStreamStatusIsAProblem(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", testLogsProblemJSON) + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"type":"https://docs.runware.ai/serverless/errors#validation-error","title":"Unprocessable Entity","status":422,"detail":"request failed validation","errors":[{"pointer":"/queryId","detail":"this query does not support live tail"}]}`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + err := c.TailLogs(context.Background(), LogQueryRuntime, testAppID, func(LogEntry) error { return nil }) + re, ok := errors.AsType[*transport.RunwareError](err) + if !ok || re.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(re.Message, "/queryId: this query does not support live tail") { + t.Fatalf("err = %#v", err) + } +} + +func TestTailLogs_RejectsNonEventStreamBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + err := c.TailLogs(context.Background(), LogQueryRuntimeTail, testAppID, func(LogEntry) error { return nil }) + if err == nil || !strings.Contains(err.Error(), "unexpected content type") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/api/serverless/sse.go b/internal/api/serverless/sse.go new file mode 100644 index 0000000..54b2fe0 --- /dev/null +++ b/internal/api/serverless/sse.go @@ -0,0 +1,74 @@ +package serverless + +import ( + "bufio" + "fmt" + "io" + "strings" +) + +// maxSSEFrameBytes bounds one event; the server caps its frames at the same size. +const maxSSEFrameBytes = 8 << 20 + +// The named events that end a log stream. Every other event carries an entry. +const ( + sseEventEnd = "end" + sseEventError = "error" +) + +// sseEvent is one Server-Sent Event: the event name (empty for the default +// message event) and the data lines joined with newlines. +type sseEvent struct { + Event string + Data string +} + +// readSSEEvents parses a text/event-stream body and hands every event to +// handle, in order, until the body ends, a read fails or handle returns an +// error. Comment lines (the server's keepalives) are dropped. +func readSSEEvents(r io.Reader, handle func(sseEvent) error) error { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), maxSSEFrameBytes) + + var ( + ev sseEvent + data []string + pending bool + ) + dispatch := func() error { + if !pending { + return nil + } + ev.Data = strings.Join(data, "\n") + err := handle(ev) + ev, data, pending = sseEvent{}, nil, false + return err + } + + for scanner.Scan() { + line := strings.TrimSuffix(scanner.Text(), "\r") + switch { + case line == "": + if err := dispatch(); err != nil { + return err + } + case strings.HasPrefix(line, ":"): + // Comment: keepalive. + default: + field, value, _ := strings.Cut(line, ":") + value = strings.TrimPrefix(value, " ") + switch field { + case "event": + ev.Event = value + pending = true + case "data": + data = append(data, value) + pending = true + } + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("read log stream: %w", err) + } + return dispatch() +} diff --git a/internal/api/serverless/sse_test.go b/internal/api/serverless/sse_test.go new file mode 100644 index 0000000..0a21f58 --- /dev/null +++ b/internal/api/serverless/sse_test.go @@ -0,0 +1,66 @@ +package serverless + +import ( + "errors" + "strings" + "testing" +) + +func collectSSE(t *testing.T, stream string) []sseEvent { + t.Helper() + var got []sseEvent + err := readSSEEvents(strings.NewReader(stream), func(ev sseEvent) error { + got = append(got, ev) + return nil + }) + if err != nil { + t.Fatalf("readSSEEvents: %v", err) + } + return got +} + +func TestReadSSEEvents_DataAndNamedEvents(t *testing.T) { + got := collectSSE(t, "data: {\"time\":1750000000,\"body\":\"ready\"}\n\nevent: end\ndata: \n\n") + if len(got) != 2 { + t.Fatalf("events = %#v", got) + } + if got[0].Event != "" || got[0].Data != `{"time":1750000000,"body":"ready"}` { + t.Errorf("first = %#v", got[0]) + } + if got[1].Event != sseEventEnd || got[1].Data != "" { + t.Errorf("second = %#v", got[1]) + } +} + +func TestReadSSEEvents_SkipsKeepaliveComments(t *testing.T) { + got := collectSSE(t, ": keepalive\n\n: keepalive\n\ndata: a\n\n") + if len(got) != 1 || got[0].Data != "a" { + t.Fatalf("events = %#v", got) + } +} + +func TestReadSSEEvents_JoinsMultiLineDataAndTrimsCR(t *testing.T) { + got := collectSSE(t, "event: error\r\ndata: first\r\ndata: second\r\n\r\n") + if len(got) != 1 || got[0].Event != sseEventError || got[0].Data != "first\nsecond" { + t.Fatalf("events = %#v", got) + } +} + +func TestReadSSEEvents_DispatchesTrailingEventAtEOF(t *testing.T) { + got := collectSSE(t, "data: tail") + if len(got) != 1 || got[0].Data != "tail" { + t.Fatalf("events = %#v", got) + } +} + +func TestReadSSEEvents_StopsOnHandlerError(t *testing.T) { + sentinel := errors.New("stop") + calls := 0 + err := readSSEEvents(strings.NewReader("data: a\n\ndata: b\n\n"), func(sseEvent) error { + calls++ + return sentinel + }) + if !errors.Is(err, sentinel) || calls != 1 { + t.Fatalf("err=%v calls=%d", err, calls) + } +} diff --git a/internal/cmd/serverless/apps.go b/internal/cmd/serverless/apps.go index 149f0ef..4f0af29 100644 --- a/internal/cmd/serverless/apps.go +++ b/internal/cmd/serverless/apps.go @@ -29,7 +29,7 @@ func newAppsCmd(logger *log.Logger) *cobra.Command { newAppsEnvCmd(logger), newAppsVersionsCmd(logger), newAppsBuildsCmd(logger), - newAppsLogsCmd(), + newAppsLogsCmd(logger), newAppsEventsCmd(logger), newAppsWorkersCmd(logger), newAppsScaleCmd(logger), @@ -225,21 +225,6 @@ func newAppsEndpointsShowCmd(logger *log.Logger) *cobra.Command { } } -func newAppsLogsCmd() *cobra.Command { - cmd := stubLeaf( - "logs ", - "Show logs for a serverless application", - ` # show application logs (not available yet) - runware serverless apps logs my-app`, - cobra.ExactArgs(1), - ) - cmd.Long = `Show application logs. - -This command is not implemented yet. The log-query route exists but currently -answers 404 until a follow-up ADR; live tail is not supported.` - return cmd -} - func newAppsEventsCmd(logger *log.Logger) *cobra.Command { var ( limit int diff --git a/internal/cmd/serverless/apps_logs.go b/internal/cmd/serverless/apps_logs.go new file mode 100644 index 0000000..363d82c --- /dev/null +++ b/internal/cmd/serverless/apps_logs.go @@ -0,0 +1,244 @@ +package serverless + +import ( + "context" + "encoding/json/v2" + "errors" + "fmt" + "io" + "log/slog" + "slices" + "strings" + "time" + + "github.com/charmbracelet/log" + serverlessapi "github.com/runware/runware-cli/internal/api/serverless" + "github.com/runware/runware-cli/internal/cmdutil" + "github.com/runware/runware-cli/internal/config" + "github.com/runware/runware-cli/internal/output" + "github.com/spf13/cobra" +) + +// tailReconnectDelay separates two connection attempts after the server +// reports a stream failure, or after a clean end of a stream that lived +// shorter than this, so a server that closes at once is not hammered. +const tailReconnectDelay = 2 * time.Second + +// logWindows lists the accepted --window values, in the order they are documented. +const logWindows = "1h, 6h, 24h, 7d, or 30d" + +// logTailer opens one live log stream and hands each entry to emit until the +// stream ends or ctx is cancelled. +type logTailer func(ctx context.Context, emit func(serverlessapi.LogEntry) error) error + +// logsFlags is the flag set of apps logs. +type logsFlags struct { + window string + limit int + cursor string + follow bool +} + +func newAppsLogsCmd(logger *log.Logger) *cobra.Command { + var flags logsFlags + + cmd := &cobra.Command{ + Use: "logs ", + Short: "Show or follow logs for a serverless application", + Long: `Show recent application logs, oldest first, and optionally follow new ones. + +The recent page is read from the runtime log query over --window (default 1h), +and --limit and --cursor page through it. With --follow the command prints the +recent page, then streams new entries until interrupted; the stream reconnects +when the server ends it. The live stream has no window, so --window, --limit +and --cursor apply to the recent page only, and --cursor cannot be combined +with --follow. Entries written between the recent page and the start of the +stream, or while the stream reconnects, can be missed or repeated. + +In table format each entry is one line: time, level and message. In json or +yaml format the recent page is printed as one document; with --follow every +entry is printed as one JSON object per line.`, + Example: ` # show the last hour of logs + runware serverless apps logs my-app + + # show the last six hours + runware serverless apps logs my-app --window 6h + + # follow new log entries until Ctrl-C + runware serverless apps logs my-app --follow + + # page through older entries + runware serverless apps logs my-app --limit 50 --cursor `, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + appID := args[0] + params, err := logEntriesParams(appID, flags) + if err != nil { + return err + } + if flags.follow && flags.cursor != "" { + return fmt.Errorf("--cursor cannot be combined with --follow") + } + format := cmdutil.FormatFor(cmd) + out := cmd.OutOrStdout() + errOut := cmd.ErrOrStderr() + + spin := cmdutil.NewSpinner(fmt.Sprintf("Fetching logs for %s...", appID)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + page, err := client.GetLogEntries(cmd.Context(), serverlessapi.LogQueryRuntime, params) + spin.Stop() + if err != nil { + return err + } + + if !flags.follow { + return printLogPage(format, page, out, errOut, extraLogsCursorFlags(flags)) + } + + emit := logEmitter(format, out) + for _, entry := range slices.Backward(page.Entries) { + if err := emit(entry); err != nil { + return err + } + } + tail := func(ctx context.Context, emit func(serverlessapi.LogEntry) error) error { + return client.TailLogs(ctx, serverlessapi.LogQueryRuntimeTail, appID, emit) + } + return followLogs(cmd.Context(), tail, emit, errOut) + }, + } + + cmd.Flags().StringVar(&flags.window, "window", "1h", "Time window for the recent page ("+logWindows+")") + cmd.Flags().IntVar(&flags.limit, "limit", 0, "Maximum number of entries on the recent page (1-100)") + cmd.Flags().StringVar(&flags.cursor, "cursor", "", "Pagination cursor from a previous nextCursor") + cmd.Flags().BoolVarP(&flags.follow, "follow", "f", false, "Stream new log entries until interrupted") + return cmd +} + +// logEntriesParams validates the page flags locally and maps them onto the API. +func logEntriesParams(appID string, flags logsFlags) (serverlessapi.GetLogEntriesParams, error) { + if err := validateListLimit(flags.limit); err != nil { + return serverlessapi.GetLogEntriesParams{}, err + } + window, err := parseValidFlag[serverlessapi.LogWindow]("--window", flags.window, logWindows) + if err != nil { + return serverlessapi.GetLogEntriesParams{}, err + } + if window == nil { + return serverlessapi.GetLogEntriesParams{}, fmt.Errorf("--window is required (want %s)", logWindows) + } + params := serverlessapi.GetLogEntriesParams{ + Window: *window, + Deployment: &appID, + } + params.Limit, params.Cursor = listPageParams(flags.limit, flags.cursor) + return params, nil +} + +// extraLogsCursorFlags repeats the filters a next-page --cursor is bound to. +func extraLogsCursorFlags(flags logsFlags) string { + parts := appendFlag(nil, "--window", flags.window) + if flags.limit > 0 { + parts = appendFlag(parts, "--limit", fmt.Sprint(flags.limit)) + } + return strings.Join(parts, " ") +} + +// printLogPage prints one page: as a document in json or yaml, as one line +// per entry, oldest first, in table format. +func printLogPage(format output.Format, page serverlessapi.LogEntryPage, out, errOut io.Writer, extraCursorFlags string) error { + switch format { + case output.FormatJSON, output.FormatYAML: + return output.Print(format, page) + default: + for _, entry := range slices.Backward(page.Entries) { + if err := writeLogLine(out, entry); err != nil { + return err + } + } + return printNextCursor(errOut, page.NextCursor, extraCursorFlags) + } +} + +// logEmitter returns the per-entry writer a live stream uses for format. +func logEmitter(format output.Format, out io.Writer) func(serverlessapi.LogEntry) error { + switch format { + case output.FormatJSON, output.FormatYAML: + return func(entry serverlessapi.LogEntry) error { + line, err := json.Marshal(entry) + if err != nil { + return err + } + _, err = fmt.Fprintf(out, "%s\n", line) + return err + } + default: + return func(entry serverlessapi.LogEntry) error { + return writeLogLine(out, entry) + } + } +} + +// followLogs keeps a live stream open until ctx is cancelled. A clean end of a +// long-lived stream reconnects at once; a reported stream failure, or a clean +// end of a short-lived stream, reconnects after tailReconnectDelay. Any other +// error is returned. Cancellation is a normal exit. +func followLogs(ctx context.Context, tail logTailer, emit func(serverlessapi.LogEntry) error, errOut io.Writer) error { + for { + started := time.Now() + err := tail(ctx, emit) + if ctx.Err() != nil { + return nil //nolint:nilerr // Cancellation is the normal way a follow ends. + } + _, streamFailed := errors.AsType[*serverlessapi.TailStreamError](err) + switch { + case streamFailed: + _, _ = fmt.Fprintf(errOut, "%v; reconnecting\n", err) + case errors.Is(err, serverlessapi.ErrTailEnded): + if time.Since(started) >= tailReconnectDelay { + continue + } + default: + return err + } + select { + case <-ctx.Done(): + return nil + case <-time.After(tailReconnectDelay): + } + } +} + +// writeLogLine renders one entry as "time level message". +func writeLogLine(out io.Writer, entry serverlessapi.LogEntry) error { + _, err := fmt.Fprintln(out, formatLogLine(entry)) + return err +} + +func formatLogLine(entry serverlessapi.LogEntry) string { + ts := "-" + if entry.Time != 0 { + ts = time.Unix(entry.Time, 0).UTC().Format(time.RFC3339) + } + level := "-" + if l := entryLevel(entry); l != "" { + level = strings.ToUpper(l) + } + return fmt.Sprintf("%-20s %-5s %s", ts, level, entry.Body) +} + +// severityField is the structured-log field the store keeps the level under +// when the API leaves the level member empty. +const severityField = "log.severity_text" + +func entryLevel(entry serverlessapi.LogEntry) string { + if entry.Level != nil && *entry.Level != "" { + return *entry.Level + } + if entry.Fields != nil { + return (*entry.Fields)[severityField] + } + return "" +} diff --git a/internal/cmd/serverless/apps_logs_test.go b/internal/cmd/serverless/apps_logs_test.go new file mode 100644 index 0000000..ed37d5d --- /dev/null +++ b/internal/cmd/serverless/apps_logs_test.go @@ -0,0 +1,209 @@ +package serverless + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "testing/synctest" + "time" + + serverlessapi "github.com/runware/runware-cli/internal/api/serverless" + "github.com/runware/runware-cli/internal/output" +) + +const ( + testLogWindow6h = "6h" + testLogCursor = "page-2" + testLogBodyReady = "ready" + testLogBodySlow = "slow" +) + +func TestLogEntriesParams_MapsFlags(t *testing.T) { + params, err := logEntriesParams(testAppID, logsFlags{window: testLogWindow6h, limit: 50, cursor: testLogCursor}) + if err != nil { + t.Fatalf("logEntriesParams: %v", err) + } + if string(params.Window) != testLogWindow6h || params.Deployment == nil || *params.Deployment != testAppID { + t.Errorf("window/deployment = %#v", params) + } + if params.Limit == nil || *params.Limit != 50 || params.Cursor == nil || *params.Cursor != testLogCursor { + t.Errorf("limit/cursor = %#v", params) + } +} + +func TestLogEntriesParams_OmitsUnsetOptionalFlags(t *testing.T) { + params, err := logEntriesParams(testAppID, logsFlags{window: "1h"}) + if err != nil { + t.Fatalf("logEntriesParams: %v", err) + } + if params.Limit != nil || params.Cursor != nil { + t.Errorf("optional params must be nil: %#v", params) + } +} + +func TestLogEntriesParams_RejectsBadFlags(t *testing.T) { + cases := map[string]struct { + window string + limit int + want string + }{ + "window": {window: "2h", want: "invalid --window"}, + "limit": {window: "1h", limit: 101, want: "--limit must be between 1 and 100"}, + "nowindow": {window: "", want: "--window is required"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + _, err := logEntriesParams(testAppID, logsFlags{window: tc.window, limit: tc.limit}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want %q", err, tc.want) + } + }) + } +} + +func TestFormatLogLine(t *testing.T) { + level := "info" + got := formatLogLine(serverlessapi.LogEntry{Time: 1750000000, Level: &level, Body: testLogBodyReady}) + if got != "2025-06-15T15:06:40Z INFO ready" { + t.Errorf("line = %q", got) + } + fields := map[string]string{severityField: "ERROR"} + got = formatLogLine(serverlessapi.LogEntry{Time: 1750000000, Fields: &fields, Body: "from fields"}) + if got != "2025-06-15T15:06:40Z ERROR from fields" { + t.Errorf("line = %q", got) + } + got = formatLogLine(serverlessapi.LogEntry{Body: "no time, no level"}) + if got != "- - no time, no level" { + t.Errorf("line = %q", got) + } +} + +func TestPrintLogPage_TablePrintsOldestFirstAndCursorHint(t *testing.T) { + next := testLogCursor + page := serverlessapi.LogEntryPage{ + Entries: []serverlessapi.LogEntry{ + {Time: 1750000001, Body: testLogBodySlow}, + {Time: 1750000000, Body: testLogBodyReady}, + }, + NextCursor: &next, + } + var out, errOut bytes.Buffer + if err := printLogPage(output.FormatTable, page, &out, &errOut, extraLogsCursorFlags(logsFlags{window: testLogWindow6h, limit: 50})); err != nil { + t.Fatalf("printLogPage: %v", err) + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 2 || !strings.HasSuffix(lines[0], testLogBodyReady) || !strings.HasSuffix(lines[1], testLogBodySlow) { + t.Fatalf("stdout = %q", out.String()) + } + want := "Next page: --window 6h --limit 50 --cursor " + testLogCursor + if !strings.Contains(errOut.String(), want) { + t.Fatalf("stderr = %q, want %q", errOut.String(), want) + } +} + +func TestLogEmitter_JSONWritesOneObjectPerLine(t *testing.T) { + var out bytes.Buffer + emit := logEmitter(output.FormatJSON, &out) + level := "warn" + if err := emit(serverlessapi.LogEntry{Time: 1, Level: &level, Body: testLogBodySlow}); err != nil { + t.Fatalf("emit: %v", err) + } + if err := emit(serverlessapi.LogEntry{Time: 2, Body: testLogBodyReady}); err != nil { + t.Fatalf("emit: %v", err) + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 2 || lines[0] != `{"body":"slow","level":"warn","time":1}` || lines[1] != `{"body":"ready","time":2}` { + t.Fatalf("ndjson = %q", out.String()) + } +} + +func TestFollowLogs_ReconnectsAtOnceAfterALongStreamEnds(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + var attempts []time.Time + tail := func(context.Context, func(serverlessapi.LogEntry) error) error { + attempts = append(attempts, time.Now()) + if len(attempts) == 3 { + cancel() + return context.Canceled + } + time.Sleep(15 * time.Minute) + return serverlessapi.ErrTailEnded + } + var errOut bytes.Buffer + if err := followLogs(ctx, tail, func(serverlessapi.LogEntry) error { return nil }, &errOut); err != nil { + t.Fatalf("followLogs: %v", err) + } + if len(attempts) != 3 || attempts[2].Sub(attempts[1]) != 15*time.Minute || errOut.Len() != 0 { + t.Fatalf("attempts=%v stderr=%q", attempts, errOut.String()) + } + }) +} + +func TestFollowLogs_WaitsAfterAShortStreamEnds(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + var attempts []time.Time + tail := func(context.Context, func(serverlessapi.LogEntry) error) error { + attempts = append(attempts, time.Now()) + if len(attempts) == 2 { + cancel() + return context.Canceled + } + return serverlessapi.ErrTailEnded + } + if err := followLogs(ctx, tail, func(serverlessapi.LogEntry) error { return nil }, &bytes.Buffer{}); err != nil { + t.Fatalf("followLogs: %v", err) + } + if len(attempts) != 2 || attempts[1].Sub(attempts[0]) != tailReconnectDelay { + t.Fatalf("attempts = %v", attempts) + } + }) +} + +func TestFollowLogs_WaitsBeforeReconnectingAfterStreamFailure(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + var attempts []time.Time + tail := func(context.Context, func(serverlessapi.LogEntry) error) error { + attempts = append(attempts, time.Now()) + if len(attempts) == 2 { + cancel() + return context.Canceled + } + return &serverlessapi.TailStreamError{Detail: "the log stream became unavailable"} + } + var errOut bytes.Buffer + if err := followLogs(ctx, tail, func(serverlessapi.LogEntry) error { return nil }, &errOut); err != nil { + t.Fatalf("followLogs: %v", err) + } + if len(attempts) != 2 || attempts[1].Sub(attempts[0]) != tailReconnectDelay { + t.Fatalf("attempts = %v", attempts) + } + if !strings.Contains(errOut.String(), "the log stream became unavailable; reconnecting") { + t.Fatalf("stderr = %q", errOut.String()) + } + }) +} + +func TestFollowLogs_ReturnsOtherErrors(t *testing.T) { + boom := errors.New("boom") + tail := func(context.Context, func(serverlessapi.LogEntry) error) error { return boom } + err := followLogs(context.Background(), tail, func(serverlessapi.LogEntry) error { return nil }, &bytes.Buffer{}) + if !errors.Is(err, boom) { + t.Fatalf("err = %v", err) + } +} + +func TestLogsCmd_RejectsCursorWithFollow(t *testing.T) { + cmd := newAppsLogsCmd(nil) + cmd.SetArgs([]string{testAppID, "--follow", "--cursor", testLogCursor}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "--cursor cannot be combined with --follow") { + t.Fatalf("err = %v", err) + } +} From 4656c0238e903a86cbab7ba8680f4787795a041a Mon Sep 17 00:00:00 2001 From: Wilson Silva Date: Tue, 8 Sep 2026 15:31:32 +0100 Subject: [PATCH 4/8] docs(serverless): state the default page size in the logs --limit help --- docs/runware_serverless_apps_logs.md | 2 +- internal/cmd/serverless/apps_logs.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/runware_serverless_apps_logs.md b/docs/runware_serverless_apps_logs.md index 73b42f0..c60f219 100644 --- a/docs/runware_serverless_apps_logs.md +++ b/docs/runware_serverless_apps_logs.md @@ -44,7 +44,7 @@ runware serverless apps logs [flags] --cursor string Pagination cursor from a previous nextCursor -f, --follow Stream new log entries until interrupted -h, --help help for logs - --limit int Maximum number of entries on the recent page (1-100) + --limit int Maximum number of entries on the recent page (1-100, default 20) --window string Time window for the recent page (1h, 6h, 24h, 7d, or 30d) (default "1h") ``` diff --git a/internal/cmd/serverless/apps_logs.go b/internal/cmd/serverless/apps_logs.go index 363d82c..3101ca6 100644 --- a/internal/cmd/serverless/apps_logs.go +++ b/internal/cmd/serverless/apps_logs.go @@ -111,7 +111,7 @@ entry is printed as one JSON object per line.`, } cmd.Flags().StringVar(&flags.window, "window", "1h", "Time window for the recent page ("+logWindows+")") - cmd.Flags().IntVar(&flags.limit, "limit", 0, "Maximum number of entries on the recent page (1-100)") + cmd.Flags().IntVar(&flags.limit, "limit", 0, "Maximum number of entries on the recent page (1-100, default 20)") cmd.Flags().StringVar(&flags.cursor, "cursor", "", "Pagination cursor from a previous nextCursor") cmd.Flags().BoolVarP(&flags.follow, "follow", "f", false, "Stream new log entries until interrupted") return cmd From 71104b811d360a9d2f01f767d2e70c33d1f40a25 Mon Sep 17 00:00:00 2001 From: Wilson Silva Date: Tue, 8 Sep 2026 15:41:45 +0100 Subject: [PATCH 5/8] fix(serverless): bound a log stream event across all of its lines The scanner bounded one line at 8 MiB, but the data lines of one event accumulated without a bound, so a stream of many lines under the cap could grow one event without limit before dispatch. --- internal/api/serverless/sse.go | 15 +++++++++++++-- internal/api/serverless/sse_test.go | 12 ++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/internal/api/serverless/sse.go b/internal/api/serverless/sse.go index 54b2fe0..a5f336b 100644 --- a/internal/api/serverless/sse.go +++ b/internal/api/serverless/sse.go @@ -2,14 +2,20 @@ package serverless import ( "bufio" + "errors" "fmt" "io" + "strconv" "strings" ) -// maxSSEFrameBytes bounds one event; the server caps its frames at the same size. +// maxSSEFrameBytes bounds one event, across all of its lines; the server caps +// its frames at the same size. const maxSSEFrameBytes = 8 << 20 +// ErrSSEEventTooLarge reports an event whose lines together exceed maxSSEFrameBytes. +var ErrSSEEventTooLarge = errors.New("log stream event exceeds " + strconv.Itoa(maxSSEFrameBytes) + " bytes") + // The named events that end a log stream. Every other event carries an entry. const ( sseEventEnd = "end" @@ -33,6 +39,7 @@ func readSSEEvents(r io.Reader, handle func(sseEvent) error) error { var ( ev sseEvent data []string + size int pending bool ) dispatch := func() error { @@ -41,7 +48,7 @@ func readSSEEvents(r io.Reader, handle func(sseEvent) error) error { } ev.Data = strings.Join(data, "\n") err := handle(ev) - ev, data, pending = sseEvent{}, nil, false + ev, data, size, pending = sseEvent{}, nil, 0, false return err } @@ -55,6 +62,10 @@ func readSSEEvents(r io.Reader, handle func(sseEvent) error) error { case strings.HasPrefix(line, ":"): // Comment: keepalive. default: + size += len(line) + if size > maxSSEFrameBytes { + return ErrSSEEventTooLarge + } field, value, _ := strings.Cut(line, ":") value = strings.TrimPrefix(value, " ") switch field { diff --git a/internal/api/serverless/sse_test.go b/internal/api/serverless/sse_test.go index 0a21f58..db3e519 100644 --- a/internal/api/serverless/sse_test.go +++ b/internal/api/serverless/sse_test.go @@ -64,3 +64,15 @@ func TestReadSSEEvents_StopsOnHandlerError(t *testing.T) { t.Fatalf("err=%v calls=%d", err, calls) } } + +func TestReadSSEEvents_RejectsAnEventLargerThanTheCapAcrossLines(t *testing.T) { + line := "data: " + strings.Repeat("x", 1<<20) + "\n" + stream := strings.Repeat(line, maxSSEFrameBytes>>20+1) + "\n" + err := readSSEEvents(strings.NewReader(stream), func(sseEvent) error { + t.Fatal("an oversized event must not reach the handler") + return nil + }) + if !errors.Is(err, ErrSSEEventTooLarge) { + t.Fatalf("err = %v", err) + } +} From 4a727660bb606878c91b45bc6e6a217c9ba46c78 Mon Sep 17 00:00:00 2001 From: Wilson Silva Date: Tue, 8 Sep 2026 15:41:45 +0100 Subject: [PATCH 6/8] fix(serverless): keep logs oldest first in every format and exit 0 on an early Ctrl-C The json and yaml page printed newest first while the table and the follow stream printed oldest first. A Ctrl-C during the initial fetch of a follow exited 130 while one during the stream exited 0. --- internal/cmd/serverless/apps_logs.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/cmd/serverless/apps_logs.go b/internal/cmd/serverless/apps_logs.go index 3101ca6..e75f996 100644 --- a/internal/cmd/serverless/apps_logs.go +++ b/internal/cmd/serverless/apps_logs.go @@ -90,6 +90,9 @@ entry is printed as one JSON object per line.`, page, err := client.GetLogEntries(cmd.Context(), serverlessapi.LogQueryRuntime, params) spin.Stop() if err != nil { + if flags.follow && cmd.Context().Err() != nil { + return nil //nolint:nilerr // Ctrl-C ends a follow normally, before the stream as well as during it. + } return err } @@ -146,14 +149,16 @@ func extraLogsCursorFlags(flags logsFlags) string { return strings.Join(parts, " ") } -// printLogPage prints one page: as a document in json or yaml, as one line -// per entry, oldest first, in table format. +// printLogPage prints one page, oldest first: as a document in json or yaml, +// as one line per entry in table format. func printLogPage(format output.Format, page serverlessapi.LogEntryPage, out, errOut io.Writer, extraCursorFlags string) error { + page.Entries = slices.Clone(page.Entries) + slices.Reverse(page.Entries) switch format { case output.FormatJSON, output.FormatYAML: return output.Print(format, page) default: - for _, entry := range slices.Backward(page.Entries) { + for _, entry := range page.Entries { if err := writeLogLine(out, entry); err != nil { return err } From eae81a2b751455ca8e209955b4fa6ecdb2a7166b Mon Sep 17 00:00:00 2001 From: Wilson Silva Date: Tue, 8 Sep 2026 15:41:46 +0100 Subject: [PATCH 7/8] chore(serverless): follow the literal style in the logs tests and drop the stale events help text --- docs/runware_serverless_apps_events.md | 4 +- internal/cmd/serverless/apps.go | 4 +- internal/cmd/serverless/apps_logs_test.go | 98 +++++++++++++++++++---- 3 files changed, 85 insertions(+), 21 deletions(-) diff --git a/docs/runware_serverless_apps_events.md b/docs/runware_serverless_apps_events.md index 7a541db..9d75827 100644 --- a/docs/runware_serverless_apps_events.md +++ b/docs/runware_serverless_apps_events.md @@ -6,8 +6,8 @@ List events for a serverless application List deploy, scaling, audit, and error events for an application. -Events are the control-plane audit trail, not worker stdout. Live log -streaming is not available (apps logs is not implemented). +Events are the control-plane audit trail, not worker stdout; use apps logs +for worker output. ``` runware serverless apps events [flags] diff --git a/internal/cmd/serverless/apps.go b/internal/cmd/serverless/apps.go index 4f0af29..4ea7e8a 100644 --- a/internal/cmd/serverless/apps.go +++ b/internal/cmd/serverless/apps.go @@ -237,8 +237,8 @@ func newAppsEventsCmd(logger *log.Logger) *cobra.Command { Short: "List events for a serverless application", Long: `List deploy, scaling, audit, and error events for an application. -Events are the control-plane audit trail, not worker stdout. Live log -streaming is not available (apps logs is not implemented).`, +Events are the control-plane audit trail, not worker stdout; use apps logs +for worker output.`, Example: ` # list events for an application runware serverless apps events my-app diff --git a/internal/cmd/serverless/apps_logs_test.go b/internal/cmd/serverless/apps_logs_test.go index ed37d5d..dd8b2ed 100644 --- a/internal/cmd/serverless/apps_logs_test.go +++ b/internal/cmd/serverless/apps_logs_test.go @@ -21,7 +21,11 @@ const ( ) func TestLogEntriesParams_MapsFlags(t *testing.T) { - params, err := logEntriesParams(testAppID, logsFlags{window: testLogWindow6h, limit: 50, cursor: testLogCursor}) + params, err := logEntriesParams(testAppID, logsFlags{ + window: testLogWindow6h, + limit: 50, + cursor: testLogCursor, + }) if err != nil { t.Fatalf("logEntriesParams: %v", err) } @@ -43,19 +47,33 @@ func TestLogEntriesParams_OmitsUnsetOptionalFlags(t *testing.T) { } } +// badFlagsCase pairs a rejected flag set with the error text it must produce. +type badFlagsCase struct { + flags logsFlags + want string +} + func TestLogEntriesParams_RejectsBadFlags(t *testing.T) { - cases := map[string]struct { - window string - limit int - want string - }{ - "window": {window: "2h", want: "invalid --window"}, - "limit": {window: "1h", limit: 101, want: "--limit must be between 1 and 100"}, - "nowindow": {window: "", want: "--window is required"}, + cases := map[string]badFlagsCase{ + "window": { + flags: logsFlags{window: "2h"}, + want: "invalid --window", + }, + "limit": { + flags: logsFlags{ + window: "1h", + limit: 101, + }, + want: "--limit must be between 1 and 100", + }, + "nowindow": { + flags: logsFlags{window: ""}, + want: "--window is required", + }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { - _, err := logEntriesParams(testAppID, logsFlags{window: tc.window, limit: tc.limit}) + _, err := logEntriesParams(testAppID, tc.flags) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("err = %v, want %q", err, tc.want) } @@ -65,12 +83,20 @@ func TestLogEntriesParams_RejectsBadFlags(t *testing.T) { func TestFormatLogLine(t *testing.T) { level := "info" - got := formatLogLine(serverlessapi.LogEntry{Time: 1750000000, Level: &level, Body: testLogBodyReady}) + got := formatLogLine(serverlessapi.LogEntry{ + Time: 1750000000, + Level: &level, + Body: testLogBodyReady, + }) if got != "2025-06-15T15:06:40Z INFO ready" { t.Errorf("line = %q", got) } fields := map[string]string{severityField: "ERROR"} - got = formatLogLine(serverlessapi.LogEntry{Time: 1750000000, Fields: &fields, Body: "from fields"}) + got = formatLogLine(serverlessapi.LogEntry{ + Time: 1750000000, + Fields: &fields, + Body: "from fields", + }) if got != "2025-06-15T15:06:40Z ERROR from fields" { t.Errorf("line = %q", got) } @@ -84,13 +110,23 @@ func TestPrintLogPage_TablePrintsOldestFirstAndCursorHint(t *testing.T) { next := testLogCursor page := serverlessapi.LogEntryPage{ Entries: []serverlessapi.LogEntry{ - {Time: 1750000001, Body: testLogBodySlow}, - {Time: 1750000000, Body: testLogBodyReady}, + { + Time: 1750000001, + Body: testLogBodySlow, + }, + { + Time: 1750000000, + Body: testLogBodyReady, + }, }, NextCursor: &next, } var out, errOut bytes.Buffer - if err := printLogPage(output.FormatTable, page, &out, &errOut, extraLogsCursorFlags(logsFlags{window: testLogWindow6h, limit: 50})); err != nil { + flags := logsFlags{ + window: testLogWindow6h, + limit: 50, + } + if err := printLogPage(output.FormatTable, page, &out, &errOut, extraLogsCursorFlags(flags)); err != nil { t.Fatalf("printLogPage: %v", err) } lines := strings.Split(strings.TrimSpace(out.String()), "\n") @@ -107,10 +143,17 @@ func TestLogEmitter_JSONWritesOneObjectPerLine(t *testing.T) { var out bytes.Buffer emit := logEmitter(output.FormatJSON, &out) level := "warn" - if err := emit(serverlessapi.LogEntry{Time: 1, Level: &level, Body: testLogBodySlow}); err != nil { + if err := emit(serverlessapi.LogEntry{ + Time: 1, + Level: &level, + Body: testLogBodySlow, + }); err != nil { t.Fatalf("emit: %v", err) } - if err := emit(serverlessapi.LogEntry{Time: 2, Body: testLogBodyReady}); err != nil { + if err := emit(serverlessapi.LogEntry{ + Time: 2, + Body: testLogBodyReady, + }); err != nil { t.Fatalf("emit: %v", err) } lines := strings.Split(strings.TrimSpace(out.String()), "\n") @@ -207,3 +250,24 @@ func TestLogsCmd_RejectsCursorWithFollow(t *testing.T) { t.Fatalf("err = %v", err) } } + +func TestPrintLogPage_LeavesTheCallerPageUntouched(t *testing.T) { + page := serverlessapi.LogEntryPage{ + Entries: []serverlessapi.LogEntry{ + { + Time: 2, + Body: testLogBodySlow, + }, + { + Time: 1, + Body: testLogBodyReady, + }, + }, + } + if err := printLogPage(output.FormatTable, page, &bytes.Buffer{}, &bytes.Buffer{}, ""); err != nil { + t.Fatalf("printLogPage: %v", err) + } + if page.Entries[0].Body != testLogBodySlow { + t.Fatalf("caller page was reordered: %#v", page.Entries) + } +} From 0f4f5605e8d4223ee1d86a82c17a730553ae67a3 Mon Sep 17 00:00:00 2001 From: Wilson Silva Date: Tue, 8 Sep 2026 15:42:08 +0100 Subject: [PATCH 8/8] chore(serverless): gofmt the logs tests --- internal/cmd/serverless/apps_logs_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/serverless/apps_logs_test.go b/internal/cmd/serverless/apps_logs_test.go index dd8b2ed..37a4f9e 100644 --- a/internal/cmd/serverless/apps_logs_test.go +++ b/internal/cmd/serverless/apps_logs_test.go @@ -64,7 +64,7 @@ func TestLogEntriesParams_RejectsBadFlags(t *testing.T) { window: "1h", limit: 101, }, - want: "--limit must be between 1 and 100", + want: "--limit must be between 1 and 100", }, "nowindow": { flags: logsFlags{window: ""},