Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 40 additions & 11 deletions cli/bun/src/core/service/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,21 +558,51 @@ async function getJson(context: Context, index: number, path: string): Promise<J
return jsonObject(await context.send(requestFor(context.operation, index, path)));
}

/** The projected jobs and job limit (`max_jobs`) of a job list body (shared with `cli service triage`). */
export function projectJobs(body: JsonObject): { jobs: JsonObject[]; max: Json } {
/**
* The account's job limit (`job_limit`) from a job list body. The service's
* `trigger_limit` is the account's entitlement: `limited` needs an integer
* `max` of at least 0, `unlimited` stands, and `unavailable` or any other kind
* is unavailable (never guessed). Without `trigger_limit` (an older service),
* an integer `max_triggers` is the limit, otherwise it is unavailable. When
* `trigger_limit` is present, the legacy `max_triggers` (the free ceiling, not
* this account's limit) is ignored.
*/
function projectJobLimit(body: JsonObject): JsonObject {
const source = body.trigger_limit;
if (source === undefined || source === null) {
const legacy = asInteger(body.max_triggers);
return legacy !== undefined && legacy >= 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<Json> {
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;
}
Expand Down Expand Up @@ -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`;
Expand Down
8 changes: 4 additions & 4 deletions cli/bun/src/core/service/triage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -179,7 +179,7 @@ async function runsSection(context: Context): Promise<JsonObject> {

async function jobsSection(context: Context): Promise<JsonObject> {
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) {
Expand All @@ -188,7 +188,7 @@ async function jobsSection(context: Context): Promise<JsonObject> {
}
return job;
});
return { total: all.length, max, jobs: shown };
return { total: all.length, max, limit, jobs: shown };
}

function objectOf(value: Json | undefined): JsonObject {
Expand Down Expand Up @@ -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`;
Expand Down
30 changes: 29 additions & 1 deletion cli/bun/test/service-jobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown>) => {
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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,10 @@
"type": "schedule"
}
],
"max": 5,
"limit": {
"kind": "unlimited"
},
"max": null,
"problem": null,
"total": 1
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,10 @@
"type": "schedule"
}
],
"max": 5,
"limit": {
"kind": "unlimited"
},
"max": null,
"problem": null,
"total": 1
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,10 @@
},
"jobs": {
"jobs": [],
"max": 5,
"limit": {
"kind": "unlimited"
},
"max": null,
"problem": null,
"total": 0
},
Expand Down
Loading
Loading