diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 069553ac..5e4834bd 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -11,6 +11,17 @@ a candidate do not establish public availability or authorize publication. ## [Unreleased] +### Hosted service + +- `job list` and `service triage` report the account's job limit from the + service's entitlement (`trigger_limit`) instead of the legacy free ceiling: + paid accounts show `Jobs: N (unlimited)` and `max_jobs` / `jobs.max` null, a + limited account shows its own `max`, and an unavailable entitlement says so. + `job_limit.limit` is replaced by `job_limit.max` (the field the service + sends), `job_limit.kind` is one of `limited`, `unlimited` or `unavailable`, + and triage gains `jobs.limit`. An older service without `trigger_limit` still reads as limited + at `max_triggers`. + ## [0.15.0-rc.2] — 2026-10-02 These are accumulated changes in the 0.15 candidate train; some capabilities diff --git a/cli/bun/src/core/service/jobs.ts b/cli/bun/src/core/service/jobs.ts index aa431d0c..3ccbcbee 100644 --- a/cli/bun/src/core/service/jobs.ts +++ b/cli/bun/src/core/service/jobs.ts @@ -558,21 +558,51 @@ async function getJson(context: Context, index: number, path: string): Promise= 0 ? { kind: "limited", max: legacy } : { kind: "unavailable" }; + } + const limit = object(source, "job_limit"); + if (typeof limit.kind !== "string") throw protocol("job_limit.kind"); + if (limit.kind === "limited") return { kind: "limited", max: scalar(limit.max ?? null, "epochMs", "job_limit.max") }; + return limit.kind === "unlimited" ? { kind: "unlimited" } : { kind: "unavailable" }; +} + +/** + * The projected jobs, job limit (`job_limit`) and its maximum (`max_jobs`: + * the limit's `max` when limited, otherwise null) of a job list body (shared + * with `cli service triage`). + */ +export function projectJobs(body: JsonObject): { jobs: JsonObject[]; limit: JsonObject; max: Json } { const jobs = array(body.triggers ?? null, 1000, "jobs").map(projectJob); - const max = body.max_triggers === undefined || body.max_triggers === null ? null : scalar(body.max_triggers, "epochMs", "max_jobs"); - return { jobs, max }; + const limit = projectJobLimit(body); + return { jobs, limit, max: limit.kind === "limited" ? limit.max! : null }; +} + +/** The human `Jobs:` line of `job list` and `cli service triage`. */ +export function jobsLine(total: number, limit: Json | undefined): string { + const shape = limit !== null && typeof limit === "object" && !Array.isArray(limit) ? limit : {}; + if (shape.kind === "limited" && typeof shape.max === "number") return `Jobs: ${total} of ${shape.max} allowed\n`; + if (shape.kind === "unlimited") return `Jobs: ${total} (unlimited)\n`; + return `Jobs: ${total} (limit unavailable; try again)\n`; } async function list(context: Context): Promise { const body = await getJson(context, 0, "/triggers"); - const { jobs, max } = projectJobs(body); - const jobLimit: JsonObject = {}; - copy(jobLimit, object(body.trigger_limit ?? null, "job_limit"), "job_limit", [["kind", { text: 64 }, false], ["limit", "epochMs", false]]); - if (!Object.hasOwn(jobLimit, "kind")) throw protocol("job_limit.kind"); + const { jobs, limit, max } = projectJobs(body); const types = array(body.types ?? null, 64, "types").map(projectType); - const result: JsonObject = { jobs, max_jobs: max, job_limit: jobLimit, types }; + const result: JsonObject = { jobs, max_jobs: max, job_limit: limit, types }; context.human = humanList(result); return result; } @@ -971,8 +1001,7 @@ function humanRotated(result: JsonObject, environment: Environment): string { function humanList(result: JsonObject): string { const jobs = result.jobs as JsonObject[]; - const limit = typeof result.max_jobs === "number" ? ` of ${result.max_jobs} allowed` : ""; - let text = `Jobs: ${jobs.length}${limit}\n`; + let text = jobsLine(jobs.length, result.job_limit); if (jobs.length === 0) text += "No jobs.\n"; for (const job of jobs) { text += `${textOrDash(job.id)} ${textOrDash(job.type)} name=${textOrDash(job.name)} program=${textOrDash(job.program_ref)} interval=${textOrDash(job.interval_seconds)} next=${isoOrDash(job.next_fire_at)}\n`; diff --git a/cli/bun/src/core/service/triage.ts b/cli/bun/src/core/service/triage.ts index aa11aa0e..f6cc575a 100644 --- a/cli/bun/src/core/service/triage.ts +++ b/cli/bun/src/core/service/triage.ts @@ -16,7 +16,7 @@ import { RunnerFailure } from "../types"; import { projectHealth, Shape } from "./discovery"; import { jsonObject, requestFor, type Response } from "./http"; import type { Context } from "./index"; -import { projectJobs } from "./jobs"; +import { jobsLine, projectJobs } from "./jobs"; import type { Json, JsonObject } from "./manifest"; import { organization } from "./organizations"; import { argvText, redactDetails } from "./render"; @@ -179,7 +179,7 @@ async function runsSection(context: Context): Promise { async function jobsSection(context: Context): Promise { const body = jsonObject(await fetch(context, JOBS, "/triggers")); - const { jobs: all, max } = projectJobs(body); + const { jobs: all, limit, max } = projectJobs(body); const shown = all.slice(0, JOBS_SHOWN).map((entry) => { const job: JsonObject = {}; for (const key of JOB_KEYS) { @@ -188,7 +188,7 @@ async function jobsSection(context: Context): Promise { } return job; }); - return { total: all.length, max, jobs: shown }; + return { total: all.length, max, limit, jobs: shown }; } function objectOf(value: Json | undefined): JsonObject { @@ -307,7 +307,7 @@ export function human(result: JsonObject): string { const jobs = objectOf(result.jobs); if (jobs.problem === null) { const total = typeof jobs.total === "number" ? jobs.total : 0; - out += typeof jobs.max === "number" ? `Jobs: ${total} of ${jobs.max} allowed\n` : `Jobs: ${total}\n`; + out += jobsLine(total, jobs.limit); for (const job of Array.isArray(jobs.jobs) ? jobs.jobs.map(objectOf) : []) { const name = Object.hasOwn(job, "name") ? text(job.name) : "-"; out += ` ${text(job.id)} ${text(job.type)} ${name}${Object.hasOwn(job, "last_error") ? " (error)" : ""}\n`; diff --git a/cli/bun/test/service-jobs.test.ts b/cli/bun/test/service-jobs.test.ts index 40d1632e..2e498e3f 100644 --- a/cli/bun/test/service-jobs.test.ts +++ b/cli/bun/test/service-jobs.test.ts @@ -5,7 +5,7 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { runCli } from "../src/cli"; -import { asInteger, INTERVAL_MAX, INTERVAL_MIN, projectJob, SPEC_MAX_BYTES } from "../src/core/service/jobs"; +import { asInteger, INTERVAL_MAX, INTERVAL_MIN, jobsLine, projectJob, projectJobs, SPEC_MAX_BYTES } from "../src/core/service/jobs"; import { RunnerFailure } from "../src/core/types"; const KEY = "rr_test_0123456789abcdef0123456789abcdef"; @@ -52,6 +52,34 @@ describe("Service job projections", () => { expect(code(() => projectJob({ id: "x", type: "webhook", createdAt: 1 }))).toBe("SERVICE_PROTOCOL_INVALID"); expect(code(() => projectJob({ id: TID, type: "webhook" }))).toBe("SERVICE_PROTOCOL_INVALID"); }); + + test("the job limit comes from trigger_limit, never the legacy max_triggers", () => { + const limit = (body: Record) => { + const { limit, max } = projectJobs({ triggers: [], ...body } as never); + return { limit, max }; + }; + expect(limit({ max_triggers: 5, trigger_limit: { kind: "limited", max: 3, extra: 1 } })).toEqual({ limit: { kind: "limited", max: 3 }, max: 3 }); + expect(limit({ max_triggers: 5, trigger_limit: { kind: "limited", max: 0 } })).toEqual({ limit: { kind: "limited", max: 0 }, max: 0 }); + expect(limit({ max_triggers: 5, trigger_limit: { kind: "unlimited", max: 9 } })).toEqual({ limit: { kind: "unlimited" }, max: null }); + expect(limit({ max_triggers: 5, trigger_limit: { kind: "unavailable" } })).toEqual({ limit: { kind: "unavailable" }, max: null }); + // An unknown kind is not guessed. + expect(limit({ max_triggers: 5, trigger_limit: { kind: "metered", max: 7 } })).toEqual({ limit: { kind: "unavailable" }, max: null }); + // An older service without trigger_limit: its max_triggers is the limit. + expect(limit({ max_triggers: 5 })).toEqual({ limit: { kind: "limited", max: 5 }, max: 5 }); + expect(limit({ max_triggers: 5, trigger_limit: null })).toEqual({ limit: { kind: "limited", max: 5 }, max: 5 }); + expect(limit({})).toEqual({ limit: { kind: "unavailable" }, max: null }); + expect(limit({ max_triggers: "5" })).toEqual({ limit: { kind: "unavailable" }, max: null }); + for (const bad of [ + { kind: "limited" }, { kind: "limited", max: null }, { kind: "limited", max: -1 }, { kind: "limited", max: 1.5 }, { kind: "limited", max: "3" }, + {}, { kind: 1 }, { kind: null }, "limited", [], 3, + ]) expect(code(() => projectJobs({ triggers: [], max_triggers: 5, trigger_limit: bad } as never))).toBe("SERVICE_PROTOCOL_INVALID"); + }); + + test("the Jobs line names the limit's kind", () => { + expect(jobsLine(2, { kind: "limited", max: 3 })).toBe("Jobs: 2 of 3 allowed\n"); + expect(jobsLine(2, { kind: "unlimited" })).toBe("Jobs: 2 (unlimited)\n"); + expect(jobsLine(2, { kind: "unavailable" })).toBe("Jobs: 2 (limit unavailable; try again)\n"); + }); }); describe("Service job local checks send nothing", () => { diff --git a/cli/conformance/cases/service/discovery/triage-authenticated-human.json b/cli/conformance/cases/service/discovery/triage-authenticated-human.json index 1dd3143a..1cfa091b 100644 --- a/cli/conformance/cases/service/discovery/triage-authenticated-human.json +++ b/cli/conformance/cases/service/discovery/triage-authenticated-human.json @@ -178,7 +178,7 @@ }, "exitCode": 0, "stdout": { - "text": "Service: ok, default model model-sol\nCredential: valid, from the credential store\nWallet: $3.12 available, $6.12 reserved (low: below $5.00)\nOrganization: exowner1 (admin)\nRuns: 2 recent, 1 live\n run_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa running model-sol 2026-09-24T08:35:02.570Z\n run_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb completed model-luna 2026-09-24T05:56:26.658Z\nJobs: 1 of 5 allowed\n 7d9e4c1a-2b3f-4a5e-8c6d-9f0a1b2c3d4e schedule Nightly digest (error)\nNext:\n prose cli run watch run_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # run run_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa is running\n prose cli wallet topup --amount-cents 500 --preview # the available balance $3.12 is below $5.00\n prose cli job show 7d9e4c1a-2b3f-4a5e-8c6d-9f0a1b2c3d4e # job 7d9e4c1a-2b3f-4a5e-8c6d-9f0a1b2c3d4e reported an error\n" + "text": "Service: ok, default model model-sol\nCredential: valid, from the credential store\nWallet: $3.12 available, $6.12 reserved (low: below $5.00)\nOrganization: exowner1 (admin)\nRuns: 2 recent, 1 live\n run_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa running model-sol 2026-09-24T08:35:02.570Z\n run_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb completed model-luna 2026-09-24T05:56:26.658Z\nJobs: 1 (unlimited)\n 7d9e4c1a-2b3f-4a5e-8c6d-9f0a1b2c3d4e schedule Nightly digest (error)\nNext:\n prose cli run watch run_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # run run_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa is running\n prose cli wallet topup --amount-cents 500 --preview # the available balance $3.12 is below $5.00\n prose cli job show 7d9e4c1a-2b3f-4a5e-8c6d-9f0a1b2c3d4e # job 7d9e4c1a-2b3f-4a5e-8c6d-9f0a1b2c3d4e reported an error\n" }, "stderr": { "text": "" diff --git a/cli/conformance/cases/service/discovery/triage-authenticated-unknown-fields.json b/cli/conformance/cases/service/discovery/triage-authenticated-unknown-fields.json index e91d2adc..daeeff24 100644 --- a/cli/conformance/cases/service/discovery/triage-authenticated-unknown-fields.json +++ b/cli/conformance/cases/service/discovery/triage-authenticated-unknown-fields.json @@ -389,7 +389,10 @@ "type": "schedule" } ], - "max": 5, + "limit": { + "kind": "unlimited" + }, + "max": null, "problem": null, "total": 1 }, diff --git a/cli/conformance/cases/service/discovery/triage-authenticated.json b/cli/conformance/cases/service/discovery/triage-authenticated.json index addb4a92..e61121af 100644 --- a/cli/conformance/cases/service/discovery/triage-authenticated.json +++ b/cli/conformance/cases/service/discovery/triage-authenticated.json @@ -212,7 +212,10 @@ "type": "schedule" } ], - "max": 5, + "limit": { + "kind": "unlimited" + }, + "max": null, "problem": null, "total": 1 }, diff --git a/cli/conformance/cases/service/discovery/triage-healthy-nothing-next.json b/cli/conformance/cases/service/discovery/triage-healthy-nothing-next.json index a2478da8..a0ad19c1 100644 --- a/cli/conformance/cases/service/discovery/triage-healthy-nothing-next.json +++ b/cli/conformance/cases/service/discovery/triage-healthy-nothing-next.json @@ -180,7 +180,10 @@ }, "jobs": { "jobs": [], - "max": 5, + "limit": { + "kind": "unlimited" + }, + "max": null, "problem": null, "total": 0 }, diff --git a/cli/conformance/cases/service/discovery/triage-jobs-limit-limited-human.json b/cli/conformance/cases/service/discovery/triage-jobs-limit-limited-human.json new file mode 100644 index 00000000..ab671343 --- /dev/null +++ b/cli/conformance/cases/service/discovery/triage-jobs-limit-limited-human.json @@ -0,0 +1,192 @@ +{ + "id": "triage-jobs-limit-limited-human", + "feature": "discovery", + "operation": "service.triage", + "description": "The human triage Jobs line uses the account's limit from trigger_limit.", + "argv": [ + "cli", + "service", + "triage" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/health", + "requestHeaders": { + "Authorization": { + "present": false + }, + "X-OpenProse-Client": { + "pattern": "^cli/[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?\\+(rust|bun)$" + } + }, + "status": 200, + "body": { + "status": "ok", + "environments": { + "default": "builtin", + "available": [ + "builtin", + "linux" + ] + }, + "models": [ + "model-sol", + "model-luna" + ], + "default_model": "model-sol", + "version": "9.9.9-extra", + "capabilities": { + "web:search": { + "available": true, + "tool": "web_search" + } + } + } + }, + { + "method": "GET", + "path": "/wallet/balance", + "requestHeaders": { + "Authorization": { + "present": true + }, + "X-OpenProse-Client": { + "pattern": "^cli/[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?\\+(rust|bun)$" + } + }, + "status": 200, + "body": { + "balance": { + "available_cents": 312, + "available_dollars": "3.12", + "available_nanos": 3120000000, + "posted_cents": 924, + "posted_dollars": "9.24", + "reserved_cents": 612, + "reserved_dollars": "6.12", + "reserved_nanos": 6120000000 + } + } + }, + { + "method": "GET", + "path": "/organizations/default", + "requestHeaders": { + "Authorization": { + "present": true + }, + "X-OpenProse-Client": { + "pattern": "^cli/[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?\\+(rust|bun)$" + } + }, + "status": 200, + "body": { + "organization": { + "id": "0f3c2a9e-5b1d-4c7e-9a8f-1e2d3c4b5a69", + "slug": "exowner1", + "name": "Example Owner", + "role": "admin", + "created_at": 1790012915257, + "created_by": "cus_ExampleOwner01" + } + } + }, + { + "method": "GET", + "path": "/runs", + "query": { + "limit": "5" + }, + "requestHeaders": { + "Authorization": { + "present": true + }, + "X-OpenProse-Client": { + "pattern": "^cli/[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?\\+(rust|bun)$" + } + }, + "status": 200, + "body": { + "runs": [ + { + "run_id": "run_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": "2026-09-24T08:35:02.570Z", + "status": "running", + "model": "model-sol", + "customer_id": "cus_ExampleOwner01", + "environment": { + "id": "builtin", + "runtime_contract": 1, + "version": "1.0.0" + } + }, + { + "run_id": "run_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "created_at": "2026-09-24T05:56:26.658Z", + "status": "completed", + "model": "model-luna", + "price_cents": 2, + "environment_price_cents": 1, + "billing_status": "settled", + "program_ref": "exowner1/daily-digest@3765c81b9bf06fb0" + } + ], + "next_before": "8209773159722_run_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + }, + { + "method": "GET", + "path": "/triggers", + "requestHeaders": { + "Authorization": { + "present": true + }, + "X-OpenProse-Client": { + "pattern": "^cli/[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?\\+(rust|bun)$" + } + }, + "status": 200, + "body": { + "triggers": [ + { + "id": "7d9e4c1a-2b3f-4a5e-8c6d-9f0a1b2c3d4e", + "type": "schedule", + "createdAt": 1790012915257, + "name": "Nightly digest", + "adopted": true, + "intervalSeconds": 86400, + "nextFireAt": 1790100000000, + "lastRunId": "run_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "lastError": "program not found" + } + ], + "max_triggers": 5, + "trigger_limit": { + "kind": "limited", + "max": 3 + }, + "types": [] + } + } + ] + }, + "exitCode": 0, + "stdout": { + "text": "Service: ok, default model model-sol\nCredential: valid, from the credential store\nWallet: $3.12 available, $6.12 reserved (low: below $5.00)\nOrganization: exowner1 (admin)\nRuns: 2 recent, 1 live\n run_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa running model-sol 2026-09-24T08:35:02.570Z\n run_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb completed model-luna 2026-09-24T05:56:26.658Z\nJobs: 1 of 3 allowed\n 7d9e4c1a-2b3f-4a5e-8c6d-9f0a1b2c3d4e schedule Nightly digest (error)\nNext:\n prose cli run watch run_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # run run_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa is running\n prose cli wallet topup --amount-cents 500 --preview # the available balance $3.12 is below $5.00\n prose cli job show 7d9e4c1a-2b3f-4a5e-8c6d-9f0a1b2c3d4e # job 7d9e4c1a-2b3f-4a5e-8c6d-9f0a1b2c3d4e reported an error\n" + }, + "stderr": { + "text": "" + }, + "forbid": [ + "rr_test_0123456789abcdef0123456789abcdef", + "9.9.9-extra", + "web_search" + ] +} diff --git a/cli/conformance/cases/service/discovery/triage-jobs-limit-limited.json b/cli/conformance/cases/service/discovery/triage-jobs-limit-limited.json new file mode 100644 index 00000000..0c51fb5e --- /dev/null +++ b/cli/conformance/cases/service/discovery/triage-jobs-limit-limited.json @@ -0,0 +1,324 @@ +{ + "id": "triage-jobs-limit-limited", + "feature": "discovery", + "operation": "service.triage", + "description": "Triage reports the account's job limit from trigger_limit: max and limit.", + "argv": [ + "--output", + "json", + "cli", + "service", + "triage" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/health", + "requestHeaders": { + "Authorization": { + "present": false + }, + "X-OpenProse-Client": { + "pattern": "^cli/[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?\\+(rust|bun)$" + } + }, + "status": 200, + "body": { + "status": "ok", + "environments": { + "default": "builtin", + "available": [ + "builtin", + "linux" + ] + }, + "models": [ + "model-sol", + "model-luna" + ], + "default_model": "model-sol", + "version": "9.9.9-extra", + "capabilities": { + "web:search": { + "available": true, + "tool": "web_search" + } + } + } + }, + { + "method": "GET", + "path": "/wallet/balance", + "requestHeaders": { + "Authorization": { + "present": true + }, + "X-OpenProse-Client": { + "pattern": "^cli/[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?\\+(rust|bun)$" + } + }, + "status": 200, + "body": { + "balance": { + "available_cents": 312, + "available_dollars": "3.12", + "available_nanos": 3120000000, + "posted_cents": 924, + "posted_dollars": "9.24", + "reserved_cents": 612, + "reserved_dollars": "6.12", + "reserved_nanos": 6120000000 + } + } + }, + { + "method": "GET", + "path": "/organizations/default", + "requestHeaders": { + "Authorization": { + "present": true + }, + "X-OpenProse-Client": { + "pattern": "^cli/[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?\\+(rust|bun)$" + } + }, + "status": 200, + "body": { + "organization": { + "id": "0f3c2a9e-5b1d-4c7e-9a8f-1e2d3c4b5a69", + "slug": "exowner1", + "name": "Example Owner", + "role": "admin", + "created_at": 1790012915257, + "created_by": "cus_ExampleOwner01" + } + } + }, + { + "method": "GET", + "path": "/runs", + "query": { + "limit": "5" + }, + "requestHeaders": { + "Authorization": { + "present": true + }, + "X-OpenProse-Client": { + "pattern": "^cli/[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?\\+(rust|bun)$" + } + }, + "status": 200, + "body": { + "runs": [ + { + "run_id": "run_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": "2026-09-24T08:35:02.570Z", + "status": "running", + "model": "model-sol", + "customer_id": "cus_ExampleOwner01", + "environment": { + "id": "builtin", + "runtime_contract": 1, + "version": "1.0.0" + } + }, + { + "run_id": "run_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "created_at": "2026-09-24T05:56:26.658Z", + "status": "completed", + "model": "model-luna", + "price_cents": 2, + "environment_price_cents": 1, + "billing_status": "settled", + "program_ref": "exowner1/daily-digest@3765c81b9bf06fb0" + } + ], + "next_before": "8209773159722_run_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + }, + { + "method": "GET", + "path": "/triggers", + "requestHeaders": { + "Authorization": { + "present": true + }, + "X-OpenProse-Client": { + "pattern": "^cli/[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?\\+(rust|bun)$" + } + }, + "status": 200, + "body": { + "triggers": [ + { + "id": "7d9e4c1a-2b3f-4a5e-8c6d-9f0a1b2c3d4e", + "type": "schedule", + "createdAt": 1790012915257, + "name": "Nightly digest", + "adopted": true, + "intervalSeconds": 86400, + "nextFireAt": 1790100000000, + "lastRunId": "run_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "lastError": "program not found" + } + ], + "max_triggers": 5, + "trigger_limit": { + "kind": "limited", + "max": 3 + }, + "types": [] + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "health.read", + "operation": "service.triage", + "problem": null, + "result": { + "credential": { + "problem": null, + "source": "store", + "state": "valid", + "variable": "OPENPROSE_API_KEY" + }, + "health": { + "default_model": "model-sol", + "models": [ + "model-sol", + "model-luna" + ], + "problem": null, + "status": "ok" + }, + "jobs": { + "jobs": [ + { + "id": "7d9e4c1a-2b3f-4a5e-8c6d-9f0a1b2c3d4e", + "last_error": "program not found", + "last_run_id": "run_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Nightly digest", + "next_fire_at": 1790100000000, + "next_fire_at_iso": "2026-09-22T18:00:00.000Z", + "type": "schedule" + } + ], + "limit": { + "kind": "limited", + "max": 3 + }, + "max": 3, + "problem": null, + "total": 1 + }, + "nextCommands": [ + { + "argv": [ + "--output", + "json", + "cli", + "run", + "watch", + "run_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "env": null, + "why": "run run_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa is running" + }, + { + "argv": [ + "--output", + "json", + "cli", + "wallet", + "topup", + "--amount-cents", + "500", + "--preview" + ], + "env": null, + "why": "the available balance $3.12 is below $5.00" + }, + { + "argv": [ + "--output", + "json", + "cli", + "job", + "show", + "7d9e4c1a-2b3f-4a5e-8c6d-9f0a1b2c3d4e" + ], + "env": null, + "why": "job 7d9e4c1a-2b3f-4a5e-8c6d-9f0a1b2c3d4e reported an error" + } + ], + "organization": { + "default": { + "created_at": 1790012915257, + "created_at_iso": "2026-09-21T17:48:35.257Z", + "id": "0f3c2a9e-5b1d-4c7e-9a8f-1e2d3c4b5a69", + "name": "Example Owner", + "role": "admin", + "slug": "exowner1" + }, + "problem": null + }, + "runs": { + "live": [ + "run_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "problem": null, + "recent": [ + { + "created_at": "2026-09-24T08:35:02.570Z", + "model": "model-sol", + "run_id": "run_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "status": "running" + }, + { + "created_at": "2026-09-24T05:56:26.658Z", + "model": "model-luna", + "price_cents": 2, + "program_ref": "exowner1/daily-digest@3765c81b9bf06fb0", + "run_id": "run_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "status": "completed" + } + ] + }, + "wallet": { + "balance": { + "available_cents": 312, + "available_dollars": "3.12", + "posted_cents": 924, + "posted_dollars": "9.24", + "reserved_cents": 612, + "reserved_dollars": "6.12" + }, + "low": true, + "lowBelowCents": 500, + "problem": null + } + }, + "schema": "openprose.service-operation/1" + } + }, + "forbid": [ + "rr_test_0123456789abcdef0123456789abcdef", + "cus_ExampleOwner01", + "nanos", + "next_before", + "9.9.9-extra", + "web_search", + "runtime_contract", + "environment_price_cents" + ] +} diff --git a/cli/conformance/cases/service/discovery/triage-no-cost-fields.json b/cli/conformance/cases/service/discovery/triage-no-cost-fields.json index e70f5fa9..929c143b 100644 --- a/cli/conformance/cases/service/discovery/triage-no-cost-fields.json +++ b/cli/conformance/cases/service/discovery/triage-no-cost-fields.json @@ -206,7 +206,10 @@ "type": "schedule" } ], - "max": 5, + "limit": { + "kind": "unlimited" + }, + "max": null, "problem": null, "total": 1 }, diff --git a/cli/conformance/cases/service/jobs/job-list-empty-human.json b/cli/conformance/cases/service/jobs/job-list-empty-human.json index 216dae98..134d597d 100644 --- a/cli/conformance/cases/service/jobs/job-list-empty-human.json +++ b/cli/conformance/cases/service/jobs/job-list-empty-human.json @@ -24,7 +24,7 @@ "max_triggers": 5, "trigger_limit": { "kind": "limited", - "limit": 5 + "max": 5 }, "types": [ { diff --git a/cli/conformance/cases/service/jobs/job-list-human-next-fire-iso.json b/cli/conformance/cases/service/jobs/job-list-human-next-fire-iso.json index b088146f..f52fc192 100644 --- a/cli/conformance/cases/service/jobs/job-list-human-next-fire-iso.json +++ b/cli/conformance/cases/service/jobs/job-list-human-next-fire-iso.json @@ -103,6 +103,6 @@ }, "exitCode": 0, "stdout": { - "text": "Jobs: 2 of 5 allowed\n3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 webhook name=test-slack program=exowner1/slack-hello@97ddf3a447f534a7 interval=- next=-\n7b2e4d19-6a3c-4e85-b1f0-9c4d8e2a6b73 schedule name=- program=- interval=86400 next=2026-09-24T12:31:09.281Z\nTypes: schedule\n" + "text": "Jobs: 2 (unlimited)\n3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 webhook name=test-slack program=exowner1/slack-hello@97ddf3a447f534a7 interval=- next=-\n7b2e4d19-6a3c-4e85-b1f0-9c4d8e2a6b73 schedule name=- program=- interval=86400 next=2026-09-24T12:31:09.281Z\nTypes: schedule\n" } } diff --git a/cli/conformance/cases/service/jobs/job-list-human.json b/cli/conformance/cases/service/jobs/job-list-human.json index 90c6a6c4..71859a83 100644 --- a/cli/conformance/cases/service/jobs/job-list-human.json +++ b/cli/conformance/cases/service/jobs/job-list-human.json @@ -102,7 +102,7 @@ }, "exitCode": 0, "stdout": { - "text": "Jobs: 2 of 5 allowed\n3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 webhook name=test-slack program=exowner1/slack-hello@97ddf3a447f534a7 interval=- next=-\n7b2e4d19-6a3c-4e85-b1f0-9c4d8e2a6b73 schedule name=- program=- interval=86400 next=-\nTypes: schedule\n" + "text": "Jobs: 2 (unlimited)\n3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 webhook name=test-slack program=exowner1/slack-hello@97ddf3a447f534a7 interval=- next=-\n7b2e4d19-6a3c-4e85-b1f0-9c4d8e2a6b73 schedule name=- program=- interval=86400 next=-\nTypes: schedule\n" }, "stderr": { "text": "" diff --git a/cli/conformance/cases/service/jobs/job-list-jsonl-records.json b/cli/conformance/cases/service/jobs/job-list-jsonl-records.json index 75706b97..d1f6a286 100644 --- a/cli/conformance/cases/service/jobs/job-list-jsonl-records.json +++ b/cli/conformance/cases/service/jobs/job-list-jsonl-records.json @@ -175,7 +175,7 @@ "job_limit": { "kind": "unlimited" }, - "max_jobs": 5, + "max_jobs": null, "types": [ { "config_fields": [ diff --git a/cli/conformance/cases/service/jobs/job-list-limit-legacy-human.json b/cli/conformance/cases/service/jobs/job-list-limit-legacy-human.json new file mode 100644 index 00000000..b33f1bb7 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-list-limit-legacy-human.json @@ -0,0 +1,107 @@ +{ + "id": "job-list-limit-legacy-human", + "feature": "jobs", + "operation": "job.list", + "description": "An older service without trigger_limit: the limit is its max_triggers.", + "argv": [ + "cli", + "job", + "list" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers", + "status": 200, + "body": { + "triggers": [ + { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": "test-slack", + "internalRef": "opaque-1", + "url": null, + "intervalSeconds": null, + "mode": null, + "repositoryId": null, + "repositoryFullName": null, + "repositoryBranch": null, + "contextRepositoryId": null, + "contextRepositoryFullName": null, + "contextRepositoryBranch": null, + "createdAt": 1790212421024, + "adopted": false, + "contextRepository": null, + "contracts": [ + { + "programRef": "exowner1/slack-hello@97ddf3a447f534a7", + "programSlug": "slack-hello", + "enabled": true, + "model": "model-luna", + "contextRepositoryFullName": null, + "contextRepositoryBranch": null + } + ], + "programRef": "exowner1/slack-hello@97ddf3a447f534a7", + "programSlug": "slack-hello", + "model": "model-luna", + "lastRunId": "run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c", + "lastError": null, + "nextFireAt": null, + "lastEventAt": 1789504569023 + }, + { + "id": "7b2e4d19-6a3c-4e85-b1f0-9c4d8e2a6b73", + "type": "schedule", + "name": null, + "internalRef": "opaque-1", + "url": null, + "intervalSeconds": 86400, + "mode": null, + "repositoryId": null, + "repositoryFullName": null, + "repositoryBranch": null, + "contextRepositoryId": 1000001, + "contextRepositoryFullName": "exowner1/example-repo", + "contextRepositoryBranch": "main", + "createdAt": 1788438667830, + "adopted": false + } + ], + "max_triggers": 5, + "types": [ + { + "id": "schedule", + "label": "On a schedule", + "description": "Runs a contract on a recurring timer.", + "config_fields": [ + "interval_seconds", + "program_ref" + ], + "emits": [ + "fired_at" + ], + "driver": "scheduled" + } + ] + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "text": "Jobs: 2 of 5 allowed\n3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 webhook name=test-slack program=exowner1/slack-hello@97ddf3a447f534a7 interval=- next=-\n7b2e4d19-6a3c-4e85-b1f0-9c4d8e2a6b73 schedule name=- program=- interval=86400 next=-\nTypes: schedule\n" + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-list-limit-limited-human.json b/cli/conformance/cases/service/jobs/job-list-limit-limited-human.json new file mode 100644 index 00000000..b85a7e11 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-list-limit-limited-human.json @@ -0,0 +1,111 @@ +{ + "id": "job-list-limit-limited-human", + "feature": "jobs", + "operation": "job.list", + "description": "A free account's limit comes from trigger_limit.max, not the legacy max_triggers.", + "argv": [ + "cli", + "job", + "list" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers", + "status": 200, + "body": { + "triggers": [ + { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": "test-slack", + "internalRef": "opaque-1", + "url": null, + "intervalSeconds": null, + "mode": null, + "repositoryId": null, + "repositoryFullName": null, + "repositoryBranch": null, + "contextRepositoryId": null, + "contextRepositoryFullName": null, + "contextRepositoryBranch": null, + "createdAt": 1790212421024, + "adopted": false, + "contextRepository": null, + "contracts": [ + { + "programRef": "exowner1/slack-hello@97ddf3a447f534a7", + "programSlug": "slack-hello", + "enabled": true, + "model": "model-luna", + "contextRepositoryFullName": null, + "contextRepositoryBranch": null + } + ], + "programRef": "exowner1/slack-hello@97ddf3a447f534a7", + "programSlug": "slack-hello", + "model": "model-luna", + "lastRunId": "run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c", + "lastError": null, + "nextFireAt": null, + "lastEventAt": 1789504569023 + }, + { + "id": "7b2e4d19-6a3c-4e85-b1f0-9c4d8e2a6b73", + "type": "schedule", + "name": null, + "internalRef": "opaque-1", + "url": null, + "intervalSeconds": 86400, + "mode": null, + "repositoryId": null, + "repositoryFullName": null, + "repositoryBranch": null, + "contextRepositoryId": 1000001, + "contextRepositoryFullName": "exowner1/example-repo", + "contextRepositoryBranch": "main", + "createdAt": 1788438667830, + "adopted": false + } + ], + "max_triggers": 5, + "trigger_limit": { + "kind": "limited", + "max": 3 + }, + "types": [ + { + "id": "schedule", + "label": "On a schedule", + "description": "Runs a contract on a recurring timer.", + "config_fields": [ + "interval_seconds", + "program_ref" + ], + "emits": [ + "fired_at" + ], + "driver": "scheduled" + } + ] + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "text": "Jobs: 2 of 3 allowed\n3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 webhook name=test-slack program=exowner1/slack-hello@97ddf3a447f534a7 interval=- next=-\n7b2e4d19-6a3c-4e85-b1f0-9c4d8e2a6b73 schedule name=- program=- interval=86400 next=-\nTypes: schedule\n" + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-list-limit-limited-without-max.json b/cli/conformance/cases/service/jobs/job-list-limit-limited-without-max.json new file mode 100644 index 00000000..589159c9 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-list-limit-limited-without-max.json @@ -0,0 +1,128 @@ +{ + "id": "job-list-limit-limited-without-max", + "feature": "jobs", + "operation": "job.list", + "description": "A limited trigger_limit without a max is SERVICE_PROTOCOL_INVALID naming job_limit.max.", + "argv": [ + "--output", + "json", + "cli", + "job", + "list" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers", + "status": 200, + "body": { + "triggers": [ + { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": "test-slack", + "internalRef": "opaque-1", + "url": null, + "intervalSeconds": null, + "mode": null, + "repositoryId": null, + "repositoryFullName": null, + "repositoryBranch": null, + "contextRepositoryId": null, + "contextRepositoryFullName": null, + "contextRepositoryBranch": null, + "createdAt": 1790212421024, + "adopted": false, + "contextRepository": null, + "contracts": [ + { + "programRef": "exowner1/slack-hello@97ddf3a447f534a7", + "programSlug": "slack-hello", + "enabled": true, + "model": "model-luna", + "contextRepositoryFullName": null, + "contextRepositoryBranch": null + } + ], + "programRef": "exowner1/slack-hello@97ddf3a447f534a7", + "programSlug": "slack-hello", + "model": "model-luna", + "lastRunId": "run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c", + "lastError": null, + "nextFireAt": null, + "lastEventAt": 1789504569023 + } + ], + "max_triggers": 5, + "trigger_limit": { + "kind": "limited" + }, + "types": [ + { + "id": "schedule", + "label": "On a schedule", + "description": "Runs a contract on a recurring timer.", + "config_fields": [ + "interval_seconds", + "program_ref" + ], + "emits": [ + "fired_at" + ], + "driver": "scheduled" + }, + { + "id": "webhook", + "label": "Webhook", + "description": "Receives events from your code.", + "config_fields": [ + "program_ref" + ], + "emits": [ + "webhook_payload_json" + ], + "driver": "webhook", + "webhook": { + "receivers": [ + { + "id": "slack" + } + ] + } + } + ] + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + } + ] + }, + "exitCode": 10, + "stdout": { + "json": { + "interaction": "job.list", + "operation": "job.list", + "problem": { + "action": "Retry later and report the sanitized error code if it persists.", + "boundary": "protocol", + "code": "SERVICE_PROTOCOL_INVALID", + "details": { + "reason": "unexpected job response: job_limit.max" + }, + "exitCode": 10, + "message": "The OpenProse service returned an invalid response.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + } +} diff --git a/cli/conformance/cases/service/jobs/job-list-limit-limited.json b/cli/conformance/cases/service/jobs/job-list-limit-limited.json new file mode 100644 index 00000000..6313d86f --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-list-limit-limited.json @@ -0,0 +1,179 @@ +{ + "id": "job-list-limit-limited", + "feature": "jobs", + "operation": "job.list", + "description": "job_limit mirrors trigger_limit; max_jobs is the account's max when limited.", + "argv": [ + "--output", + "json", + "cli", + "job", + "list" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers", + "status": 200, + "body": { + "triggers": [ + { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": "test-slack", + "internalRef": "opaque-1", + "url": null, + "intervalSeconds": null, + "mode": null, + "repositoryId": null, + "repositoryFullName": null, + "repositoryBranch": null, + "contextRepositoryId": null, + "contextRepositoryFullName": null, + "contextRepositoryBranch": null, + "createdAt": 1790212421024, + "adopted": false, + "contextRepository": null, + "contracts": [ + { + "programRef": "exowner1/slack-hello@97ddf3a447f534a7", + "programSlug": "slack-hello", + "enabled": true, + "model": "model-luna", + "contextRepositoryFullName": null, + "contextRepositoryBranch": null + } + ], + "programRef": "exowner1/slack-hello@97ddf3a447f534a7", + "programSlug": "slack-hello", + "model": "model-luna", + "lastRunId": "run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c", + "lastError": null, + "nextFireAt": null, + "lastEventAt": 1789504569023 + } + ], + "max_triggers": 5, + "trigger_limit": { + "kind": "limited", + "max": 3 + }, + "types": [ + { + "id": "schedule", + "label": "On a schedule", + "description": "Runs a contract on a recurring timer.", + "config_fields": [ + "interval_seconds", + "program_ref" + ], + "emits": [ + "fired_at" + ], + "driver": "scheduled" + }, + { + "id": "webhook", + "label": "Webhook", + "description": "Receives events from your code.", + "config_fields": [ + "program_ref" + ], + "emits": [ + "webhook_payload_json" + ], + "driver": "webhook", + "webhook": { + "receivers": [ + { + "id": "slack" + } + ] + } + } + ] + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.list", + "operation": "job.list", + "problem": null, + "result": { + "job_limit": { + "kind": "limited", + "max": 3 + }, + "jobs": [ + { + "context_repository_branch": null, + "context_repository_full_name": null, + "contracts": [ + { + "context_repository_branch": null, + "context_repository_full_name": null, + "enabled": true, + "model": "model-luna", + "program_ref": "exowner1/slack-hello@97ddf3a447f534a7", + "program_slug": "slack-hello" + } + ], + "created_at": 1790212421024, + "created_at_iso": "2026-09-24T01:13:41.024Z", + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "interval_seconds": null, + "last_error": null, + "last_event_at": 1789504569023, + "last_event_at_iso": "2026-09-15T20:36:09.023Z", + "last_run_id": "run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c", + "mode": null, + "model": "model-luna", + "name": "test-slack", + "next_fire_at": null, + "next_fire_at_iso": null, + "program_ref": "exowner1/slack-hello@97ddf3a447f534a7", + "program_slug": "slack-hello", + "repository_branch": null, + "repository_full_name": null, + "repository_id": null, + "type": "webhook", + "url": null + } + ], + "max_jobs": 3, + "types": [ + { + "config_fields": [ + "interval_seconds", + "program_ref" + ], + "description": "Runs a contract on a recurring timer.", + "id": "schedule", + "label": "On a schedule" + }, + { + "config_fields": [ + "program_ref" + ], + "description": "Receives events from your code.", + "id": "webhook", + "label": "Webhook" + } + ] + }, + "schema": "openprose.service-operation/1" + } + } +} diff --git a/cli/conformance/cases/service/jobs/job-list-limit-unavailable-human.json b/cli/conformance/cases/service/jobs/job-list-limit-unavailable-human.json new file mode 100644 index 00000000..efa10507 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-list-limit-unavailable-human.json @@ -0,0 +1,110 @@ +{ + "id": "job-list-limit-unavailable-human", + "feature": "jobs", + "operation": "job.list", + "description": "An unavailable limit (the service's entitlement lookup failed) is shown as unavailable, never as the legacy ceiling.", + "argv": [ + "cli", + "job", + "list" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers", + "status": 200, + "body": { + "triggers": [ + { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": "test-slack", + "internalRef": "opaque-1", + "url": null, + "intervalSeconds": null, + "mode": null, + "repositoryId": null, + "repositoryFullName": null, + "repositoryBranch": null, + "contextRepositoryId": null, + "contextRepositoryFullName": null, + "contextRepositoryBranch": null, + "createdAt": 1790212421024, + "adopted": false, + "contextRepository": null, + "contracts": [ + { + "programRef": "exowner1/slack-hello@97ddf3a447f534a7", + "programSlug": "slack-hello", + "enabled": true, + "model": "model-luna", + "contextRepositoryFullName": null, + "contextRepositoryBranch": null + } + ], + "programRef": "exowner1/slack-hello@97ddf3a447f534a7", + "programSlug": "slack-hello", + "model": "model-luna", + "lastRunId": "run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c", + "lastError": null, + "nextFireAt": null, + "lastEventAt": 1789504569023 + }, + { + "id": "7b2e4d19-6a3c-4e85-b1f0-9c4d8e2a6b73", + "type": "schedule", + "name": null, + "internalRef": "opaque-1", + "url": null, + "intervalSeconds": 86400, + "mode": null, + "repositoryId": null, + "repositoryFullName": null, + "repositoryBranch": null, + "contextRepositoryId": 1000001, + "contextRepositoryFullName": "exowner1/example-repo", + "contextRepositoryBranch": "main", + "createdAt": 1788438667830, + "adopted": false + } + ], + "max_triggers": 5, + "trigger_limit": { + "kind": "unavailable" + }, + "types": [ + { + "id": "schedule", + "label": "On a schedule", + "description": "Runs a contract on a recurring timer.", + "config_fields": [ + "interval_seconds", + "program_ref" + ], + "emits": [ + "fired_at" + ], + "driver": "scheduled" + } + ] + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "text": "Jobs: 2 (limit unavailable; try again)\n3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 webhook name=test-slack program=exowner1/slack-hello@97ddf3a447f534a7 interval=- next=-\n7b2e4d19-6a3c-4e85-b1f0-9c4d8e2a6b73 schedule name=- program=- interval=86400 next=-\nTypes: schedule\n" + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-list-limit-unknown-kind.json b/cli/conformance/cases/service/jobs/job-list-limit-unknown-kind.json new file mode 100644 index 00000000..2b9eb165 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-list-limit-unknown-kind.json @@ -0,0 +1,178 @@ +{ + "id": "job-list-limit-unknown-kind", + "feature": "jobs", + "operation": "job.list", + "description": "A trigger_limit kind this client does not know is reported as unavailable rather than guessed.", + "argv": [ + "--output", + "json", + "cli", + "job", + "list" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers", + "status": 200, + "body": { + "triggers": [ + { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": "test-slack", + "internalRef": "opaque-1", + "url": null, + "intervalSeconds": null, + "mode": null, + "repositoryId": null, + "repositoryFullName": null, + "repositoryBranch": null, + "contextRepositoryId": null, + "contextRepositoryFullName": null, + "contextRepositoryBranch": null, + "createdAt": 1790212421024, + "adopted": false, + "contextRepository": null, + "contracts": [ + { + "programRef": "exowner1/slack-hello@97ddf3a447f534a7", + "programSlug": "slack-hello", + "enabled": true, + "model": "model-luna", + "contextRepositoryFullName": null, + "contextRepositoryBranch": null + } + ], + "programRef": "exowner1/slack-hello@97ddf3a447f534a7", + "programSlug": "slack-hello", + "model": "model-luna", + "lastRunId": "run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c", + "lastError": null, + "nextFireAt": null, + "lastEventAt": 1789504569023 + } + ], + "max_triggers": 5, + "trigger_limit": { + "kind": "metered", + "max": 7 + }, + "types": [ + { + "id": "schedule", + "label": "On a schedule", + "description": "Runs a contract on a recurring timer.", + "config_fields": [ + "interval_seconds", + "program_ref" + ], + "emits": [ + "fired_at" + ], + "driver": "scheduled" + }, + { + "id": "webhook", + "label": "Webhook", + "description": "Receives events from your code.", + "config_fields": [ + "program_ref" + ], + "emits": [ + "webhook_payload_json" + ], + "driver": "webhook", + "webhook": { + "receivers": [ + { + "id": "slack" + } + ] + } + } + ] + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.list", + "operation": "job.list", + "problem": null, + "result": { + "job_limit": { + "kind": "unavailable" + }, + "jobs": [ + { + "context_repository_branch": null, + "context_repository_full_name": null, + "contracts": [ + { + "context_repository_branch": null, + "context_repository_full_name": null, + "enabled": true, + "model": "model-luna", + "program_ref": "exowner1/slack-hello@97ddf3a447f534a7", + "program_slug": "slack-hello" + } + ], + "created_at": 1790212421024, + "created_at_iso": "2026-09-24T01:13:41.024Z", + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "interval_seconds": null, + "last_error": null, + "last_event_at": 1789504569023, + "last_event_at_iso": "2026-09-15T20:36:09.023Z", + "last_run_id": "run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c", + "mode": null, + "model": "model-luna", + "name": "test-slack", + "next_fire_at": null, + "next_fire_at_iso": null, + "program_ref": "exowner1/slack-hello@97ddf3a447f534a7", + "program_slug": "slack-hello", + "repository_branch": null, + "repository_full_name": null, + "repository_id": null, + "type": "webhook", + "url": null + } + ], + "max_jobs": null, + "types": [ + { + "config_fields": [ + "interval_seconds", + "program_ref" + ], + "description": "Runs a contract on a recurring timer.", + "id": "schedule", + "label": "On a schedule" + }, + { + "config_fields": [ + "program_ref" + ], + "description": "Receives events from your code.", + "id": "webhook", + "label": "Webhook" + } + ] + }, + "schema": "openprose.service-operation/1" + } + } +} diff --git a/cli/conformance/cases/service/jobs/job-list-projection-unknown-fields.json b/cli/conformance/cases/service/jobs/job-list-projection-unknown-fields.json index 015c2318..6e98b1ee 100644 --- a/cli/conformance/cases/service/jobs/job-list-projection-unknown-fields.json +++ b/cli/conformance/cases/service/jobs/job-list-projection-unknown-fields.json @@ -243,7 +243,7 @@ "url": null } ], - "max_jobs": 5, + "max_jobs": null, "types": [ { "config_fields": [ diff --git a/cli/conformance/cases/service/jobs/job-list-projection.json b/cli/conformance/cases/service/jobs/job-list-projection.json index a7b9ce4a..dc5892de 100644 --- a/cli/conformance/cases/service/jobs/job-list-projection.json +++ b/cli/conformance/cases/service/jobs/job-list-projection.json @@ -150,7 +150,7 @@ "url": null } ], - "max_jobs": 5, + "max_jobs": null, "types": [ { "config_fields": [ diff --git a/cli/rust/crates/prose-runner-core/src/service/jobs.rs b/cli/rust/crates/prose-runner-core/src/service/jobs.rs index 0c1fb396..9c416c04 100644 --- a/cli/rust/crates/prose-runner-core/src/service/jobs.rs +++ b/cli/rust/crates/prose-runner-core/src/service/jobs.rs @@ -1038,47 +1038,77 @@ fn get_json( context.send(&request)?.json_object() } -/// The projected jobs and job limit (`max_jobs`) of a job list body (shared -/// with `cli service triage`). +/// The projected jobs and job limit of a job list body (shared with `cli +/// service triage`). pub(super) fn project_jobs(body: &Map) -> Result<(Vec, Value), RunnerError> { let jobs = array(body.get("triggers").unwrap_or(&Value::Null), 1000, "jobs")? .iter() .map(project_job) .collect::, _>>()?; - let max = match body.get("max_triggers") { - None | Some(Value::Null) => Value::Null, - Some(value) => scalar(value, Kind::EpochMs, "max_jobs")?, + Ok((jobs, project_job_limit(body)?)) +} + +/// The account's job limit `{kind, max?}` from the service's `trigger_limit` +/// entitlement. `max_triggers` is a legacy field holding the free ceiling, so +/// it is read only when `trigger_limit` is absent or null (an older service). +/// An unknown kind is `unavailable`, never guessed. +fn project_job_limit(body: &Map) -> Result { + let limit = match body.get("trigger_limit") { + None | Some(Value::Null) => { + return Ok(match body.get("max_triggers").and_then(as_integer) { + Some(max) if max >= 0 => json!({"kind": "limited", "max": max}), + _ => json!({"kind": "unavailable"}), + }); + } + Some(value) => object(value, "job_limit")?, }; - Ok((jobs, max)) + let kind = limit + .get("kind") + .and_then(Value::as_str) + .ok_or_else(|| protocol("job_limit.kind"))?; + Ok(match kind { + "limited" => { + let max = limit + .get("max") + .and_then(as_integer) + .filter(|max| *max >= 0) + .ok_or_else(|| protocol("job_limit.max"))?; + json!({"kind": "limited", "max": max}) + } + "unlimited" => json!({"kind": "unlimited"}), + _ => json!({"kind": "unavailable"}), + }) +} + +/// `max_jobs` / triage `jobs.max`: the job limit's max when limited, else +/// null. +pub(super) fn limit_max(job_limit: &Value) -> Value { + if job_limit["kind"] == "limited" { + job_limit["max"].clone() + } else { + Value::Null + } +} + +/// The human `Jobs:` line for `total` jobs under `job_limit`. +pub(super) fn jobs_line(total: usize, job_limit: &Value) -> String { + match (job_limit["kind"].as_str(), job_limit["max"].as_i64()) { + (Some("limited"), Some(max)) => format!("Jobs: {total} of {max} allowed\n"), + (Some("unlimited"), _) => format!("Jobs: {total} (unlimited)\n"), + _ => format!("Jobs: {total} (limit unavailable; try again)\n"), + } } fn list(context: &mut Context<'_>) -> Result { let body = get_json(context, 0, "/triggers")?; - let (jobs, max) = project_jobs(&body)?; - let limit = object( - body.get("trigger_limit").unwrap_or(&Value::Null), - "job_limit", - )?; - let mut job_limit = Map::new(); - copy( - &mut job_limit, - limit, - "job_limit", - &[ - ("kind", Kind::Text(64), false), - ("limit", Kind::EpochMs, false), - ], - )?; - if !job_limit.contains_key("kind") { - return Err(protocol("job_limit.kind")); - } + let (jobs, job_limit) = project_jobs(&body)?; let types = array(body.get("types").unwrap_or(&Value::Null), 64, "types")? .iter() .map(project_type) .collect::, _>>()?; let result = json!({ "jobs": jobs, - "max_jobs": max, + "max_jobs": limit_max(&job_limit), "job_limit": job_limit, "types": types, }); @@ -1785,11 +1815,7 @@ fn human_rotated(result: &Value) -> String { fn human_list(result: &Value) -> String { let jobs = result["jobs"].as_array().map_or(&[][..], Vec::as_slice); let mut text = String::new(); - let limit = match &result["max_jobs"] { - Value::Number(max) => format!(" of {max} allowed"), - _ => String::new(), - }; - let _ = writeln!(text, "Jobs: {}{limit}", jobs.len()); + text.push_str(&jobs_line(jobs.len(), &result["job_limit"])); if jobs.is_empty() { text.push_str("No jobs.\n"); } @@ -1921,6 +1947,69 @@ mod tests { ); } + #[test] + fn job_limit_follows_the_entitlement_not_the_legacy_ceiling() { + let limit = |body: Value| project_job_limit(body.as_object().unwrap()); + assert_eq!( + limit( + json!({"max_triggers": 5, "trigger_limit": {"kind": "limited", "max": 3, "x": 1}}) + ) + .unwrap(), + json!({"kind": "limited", "max": 3}) + ); + assert_eq!( + limit(json!({"max_triggers": 5, "trigger_limit": {"kind": "unlimited", "max": 9}})) + .unwrap(), + json!({"kind": "unlimited"}) + ); + for kind in ["unavailable", "metered", ""] { + assert_eq!( + limit(json!({"max_triggers": 5, "trigger_limit": {"kind": kind}})).unwrap(), + json!({"kind": "unavailable"}) + ); + } + assert_eq!( + limit(json!({"max_triggers": 5})).unwrap(), + json!({"kind": "limited", "max": 5}) + ); + assert_eq!( + limit(json!({"max_triggers": 5, "trigger_limit": null})).unwrap(), + json!({"kind": "limited", "max": 5}) + ); + assert_eq!(limit(json!({})).unwrap(), json!({"kind": "unavailable"})); + assert_eq!( + limit(json!({"max_triggers": "5"})).unwrap(), + json!({"kind": "unavailable"}) + ); + for bad in [ + json!({"trigger_limit": {"kind": "limited"}}), + json!({"trigger_limit": {"kind": "limited", "max": -1}}), + json!({"trigger_limit": {"kind": "limited", "max": 1.5}}), + json!({"trigger_limit": {"kind": "limited", "max": "3"}}), + json!({"trigger_limit": {"max": 3}}), + json!({"trigger_limit": {"kind": 1}}), + json!({"trigger_limit": "limited"}), + ] { + assert!(limit(bad).is_err()); + } + } + + #[test] + fn job_limit_lines_and_max() { + let limited = json!({"kind": "limited", "max": 3}); + let unlimited = json!({"kind": "unlimited"}); + let unavailable = json!({"kind": "unavailable"}); + assert_eq!(limit_max(&limited), json!(3)); + assert_eq!(limit_max(&unlimited), Value::Null); + assert_eq!(limit_max(&unavailable), Value::Null); + assert_eq!(jobs_line(2, &limited), "Jobs: 2 of 3 allowed\n"); + assert_eq!(jobs_line(2, &unlimited), "Jobs: 2 (unlimited)\n"); + assert_eq!( + jobs_line(2, &unavailable), + "Jobs: 2 (limit unavailable; try again)\n" + ); + } + #[test] fn run_counts_fold_into_public_states() { let status = project_status(&json!({ diff --git a/cli/rust/crates/prose-runner-core/src/service/triage.rs b/cli/rust/crates/prose-runner-core/src/service/triage.rs index 4740021e..d6323bff 100644 --- a/cli/rust/crates/prose-runner-core/src/service/triage.rs +++ b/cli/rust/crates/prose-runner-core/src/service/triage.rs @@ -268,7 +268,7 @@ fn runs_section(context: &mut Context<'_>) -> Result, RunnerE fn jobs_section(context: &mut Context<'_>) -> Result, RunnerError> { let body = fetch(context, JOBS, "/triggers")?.json_object()?; - let (all, max) = jobs::project_jobs(&body)?; + let (all, limit) = jobs::project_jobs(&body)?; let shown = all .iter() .take(JOBS_SHOWN) @@ -284,7 +284,8 @@ fn jobs_section(context: &mut Context<'_>) -> Result, RunnerE .collect::>(); let mut fields = Map::new(); fields.insert("total".into(), json!(all.len())); - fields.insert("max".into(), max); + fields.insert("max".into(), jobs::limit_max(&limit)); + fields.insert("limit".into(), limit); fields.insert("jobs".into(), Value::Array(shown)); Ok(fields) } @@ -477,14 +478,8 @@ fn human(result: &Value) -> String { out.push_str("Jobs: skipped\n"); } else if jobs["problem"].is_null() { let total = jobs["total"].as_u64().unwrap_or_default(); - match jobs["max"].as_i64() { - Some(max) => { - let _ = writeln!(out, "Jobs: {total} of {max} allowed"); - } - None => { - let _ = writeln!(out, "Jobs: {total}"); - } - } + let total = usize::try_from(total).unwrap_or(usize::MAX); + out.push_str(&jobs::jobs_line(total, &jobs["limit"])); for job in jobs["jobs"].as_array().cloned().unwrap_or_default() { let name = job.get("name").map_or_else(|| "-".to_owned(), text); let _ = write!( @@ -554,7 +549,8 @@ mod tests { "low": true, "lowBelowCents": 500}, "organization": {"problem": null, "default": {"slug": "o", "role": "admin"}}, "runs": {"problem": null, "recent": vec![run; RECENT_RUNS], "live": ["run_1"]}, - "jobs": {"problem": null, "total": 9, "max": 10, "jobs": vec![job; JOBS_SHOWN]}, + "jobs": {"problem": null, "total": 9, "max": 10, + "limit": {"kind": "limited", "max": 10}, "jobs": vec![job; JOBS_SHOWN]}, "nextCommands": vec![command; LIVE_WATCH_MAX + 1 + FAILING_JOBS_MAX], }); let text = human(&result); diff --git a/cli/shared/schemas/service/discovery.schema.json b/cli/shared/schemas/service/discovery.schema.json index dbe33098..6dc1a6f4 100644 --- a/cli/shared/schemas/service/discovery.schema.json +++ b/cli/shared/schemas/service/discovery.schema.json @@ -491,6 +491,7 @@ "problem", "total", "max", + "limit", "jobs" ], "properties": { @@ -510,7 +511,11 @@ "type": "integer", "minimum": 0 } - ] + ], + "description": "The account's maximum number of jobs when limited; null when unlimited or unavailable." + }, + "limit": { + "$ref": "jobs.schema.json#/$defs/jobLimit" }, "jobs": { "type": "array", diff --git a/cli/shared/schemas/service/jobs.schema.json b/cli/shared/schemas/service/jobs.schema.json index 1bb23946..e5970227 100644 --- a/cli/shared/schemas/service/jobs.schema.json +++ b/cli/shared/schemas/service/jobs.schema.json @@ -574,25 +574,11 @@ "type": "integer", "minimum": 0 } - ] + ], + "description": "The account's maximum number of jobs when limited; null when unlimited or unavailable." }, "job_limit": { - "type": "object", - "additionalProperties": false, - "required": [ - "kind" - ], - "properties": { - "kind": { - "type": "string", - "maxLength": 64, - "pattern": "^[^\\u0000-\\u001f\\u007f]*$" - }, - "limit": { - "type": "integer", - "minimum": 0 - } - } + "$ref": "#/$defs/jobLimit" }, "types": { "type": "array", @@ -1000,6 +986,27 @@ } } } + }, + "jobLimit": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "enum": [ + "limited", + "unlimited", + "unavailable" + ] + }, + "max": { + "type": "integer", + "minimum": 0 + } + }, + "description": "The account's job limit from the service: limited (with max), unlimited, or unavailable (the entitlement lookup failed; retry). An older service without one is read as limited at its max_triggers." } } } diff --git a/cli/shared/service/responses/triggers-list.schema.json b/cli/shared/service/responses/triggers-list.schema.json index 29a138b6..b3d31d94 100644 --- a/cli/shared/service/responses/triggers-list.schema.json +++ b/cli/shared/service/responses/triggers-list.schema.json @@ -44,6 +44,9 @@ "properties": { "kind": { "type": "string" + }, + "max": { + "type": "integer" } } }, diff --git a/cli/shared/tests/test_service_contract.py b/cli/shared/tests/test_service_contract.py index 10577e6e..48cf731b 100644 --- a/cli/shared/tests/test_service_contract.py +++ b/cli/shared/tests/test_service_contract.py @@ -399,11 +399,11 @@ def test_secret_bearing_results_are_confined_to_their_creating_operation(self): self.assert_valid(self.result_ref("job.create"), {**show, "endpoint": "/webhooks/triggers/x", "endpoint_url": url, "signing_secret": "s"}) self.assert_valid(self.result_ref("job.rotate-secret"), {"id": show["job"]["id"], "endpoint": "/webhooks/triggers/x", "endpoint_url": url, "signing_secret": "s"}) self.assert_invalid(self.result_ref("job.show"), {**show, "endpoint_url": "http://example.invalid/webhooks/triggers/x"}) - self.assert_valid(self.result_ref("job.list"), {"jobs": [show["job"]], "max_jobs": 5, "job_limit": {"kind": "unlimited"}, "types": []}) + self.assert_valid(self.result_ref("job.list"), {"jobs": [show["job"]], "max_jobs": None, "job_limit": {"kind": "unlimited"}, "types": []}) # Job output uses the user noun and snake_case; service internals never appear. for internal in ({"internalRef": "opaque-1"}, {"adopted": True}, {"context_repository_id": 7}, {"createdAt": 1}): self.assert_invalid(self.result_ref("job.list"), {"jobs": [{**show["job"], **internal}], - "max_jobs": 5, "job_limit": {"kind": "unlimited"}, "types": []}) + "max_jobs": None, "job_limit": {"kind": "unlimited"}, "types": []}) self.assert_invalid(self.result_ref("job.list"), {"triggers": [show["job"]], "max_triggers": 5, "trigger_limit": {"kind": "unlimited"}, "types": []}) diff --git a/docs/service/discovery.md b/docs/service/discovery.md index 8e7ebf8c..ceae4b47 100644 --- a/docs/service/discovery.md +++ b/docs/service/discovery.md @@ -94,7 +94,9 @@ section and `nextCommands`: - **Prices only.** The balance keeps `*_cents` and `*_dollars` (never `*_nanos` or the customer id). Runs are `cli run list` summaries (price fields only). Jobs keep `id`, `type`, `name`, `nextFireAt`, `lastEventAt`, - `lastRunId` and `lastError`, with `total` and `max`. `health` keeps + `lastRunId` and `lastError`, with `total`, `max` (the account's maximum + when limited, else null) and `limit` (`{kind, max?}`, as in `job list`'s + `job_limit`). `health` keeps `status`, `default_model` and `models`. - **`nextCommands` from state.** Each entry is `{why, argv, env}`, with `argv` the words after `prose`, keeping the output mode: diff --git a/docs/service/jobs.md b/docs/service/jobs.md index 5d82db50..ed577b07 100644 --- a/docs/service/jobs.md +++ b/docs/service/jobs.md @@ -97,8 +97,16 @@ printf '%s' '{"type":"webhook","delivery_mode":"test","name":"my-hook"}' \ `job list` returns `{jobs, max_jobs, job_limit, types}` (each type `{id, label, description, config_fields}`); an empty account is exit 0 with -`jobs: []`. `job show` returns `{job, status}`. Every job field is built from -its public field list, in snake_case (`interval_seconds`, `created_at`, +`jobs: []`. `job_limit` is the account's entitlement from the service: +`{kind: "limited", max}`, `{kind: "unlimited"}` (paid accounts) or +`{kind: "unavailable"}` (the service could not check the entitlement; retry). +A kind this client does not know is reported as `unavailable`, and an older +service without one is read as limited at its `max_triggers`. `max_jobs` is +`job_limit.max` when limited and `null` otherwise. The human first line is +`Jobs: N of M allowed`, `Jobs: N (unlimited)` or +`Jobs: N (limit unavailable; try again)`; `cli service triage` reports the +same as `jobs.max` and `jobs.limit`. `job show` returns `{job, status}`. +Every job field is built from its public field list, in snake_case (`interval_seconds`, `created_at`, `next_fire_at`, `last_run_id`, `program_ref`, …); any other service field (internal references, adoption and driver detail, repository ids and detail objects, receiver and reply details) is dropped, as is the webhook's relative