From c03b4580464d37baadddd4d7309e946357c70815 Mon Sep 17 00:00:00 2001 From: Raymond Weitekamp <19483938+rawwerks@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:08:33 -0400 Subject: [PATCH 1/3] Set webhook binding settings from job contract attach job contract attach gains --model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --clear-repo, --clear-commit-output, --clear-input, --clear-files and --replace for webhook jobs; other job types refuse them after the job is read. Re-attaching a bound program changes only the options given: on a service that reports each binding's environment it re-binds the same reference with just the changed fields (null clears); on an older service it merges the saved settings and warns that stored files and environment may be dropped. --replace onto another revision is a full replace that carries the saved settings and environment (stored files must be given again with --file); --replace onto a program that is already bound is refused. The attach plan quotes the hold for the binding as it will run, with the job's type. job contract list reports each binding's saved settings, effective model, revision and bind time. Interrupts during a plan's advisory quote now stop the command. run quote's help is reworded briefly to keep the published manifest within its size budget, and the contracts response schema names program_ref. Co-Authored-By: Claude Opus 5.5 --- cli/CHANGELOG.md | 16 + cli/bun/src/core/service/index.ts | 12 +- cli/bun/src/core/service/jobs.ts | 567 +++++++- cli/bun/src/core/service/runs.ts | 56 +- cli/bun/test/service-jobs.test.ts | 155 +- .../framework/help-json-group-records.json | 10 +- .../framework/service-capabilities-json.json | 9 +- .../framework/service-capabilities-jsonl.json | 9 +- ...-attach-clear-all-inputs-server-merge.json | 166 +++ ...b-contract-attach-clear-input-invalid.json | 60 + ...ntract-attach-clear-repo-client-merge.json | 159 +++ ...ontract-attach-commit-output-mismatch.json | 62 + ...ct-attach-commit-output-uses-repo-url.json | 143 ++ ...job-contract-attach-conflicting-clear.json | 61 + ...ract-attach-conflicting-commit-output.json | 61 + ...job-contract-attach-conflicting-files.json | 67 + .../jobs/job-contract-attach-human.json | 12 + ...act-attach-input-clear-input-conflict.json | 62 + .../job-contract-attach-model-refused.json | 70 +- .../jobs/job-contract-attach-numeric-rev.json | 5 +- ...-attach-object-input-protocol-invalid.json | 152 ++ ...tract-attach-plain-bound-server-merge.json | 107 ++ ...-contract-attach-preview-quote-github.json | 148 ++ ...ontract-attach-preview-quote-job-type.json | 147 ++ ...ract-attach-preview-quote-unavailable.json | 135 ++ ...act-attach-replace-onto-bound-refused.json | 194 +++ ...job-contract-attach-schedule-rejected.json | 15 + ...t-attach-settings-non-webhook-refused.json | 117 ++ .../jobs/job-contract-attach-unpinned.json | 3 + ...tach-webhook-clear-files-server-merge.json | 163 +++ ...ttach-webhook-clear-repo-server-merge.json | 168 +++ ...act-attach-webhook-files-server-merge.json | 179 +++ .../job-contract-attach-webhook-human.json | 125 ++ ...ct-attach-webhook-inputs-server-merge.json | 169 +++ ...ct-attach-webhook-preview-quote-human.json | 158 +++ ...contract-attach-webhook-preview-quote.json | 187 +++ ...ach-webhook-rebind-client-merge-human.json | 148 ++ ...ct-attach-webhook-rebind-client-merge.json | 162 +++ ...ct-attach-webhook-rebind-server-merge.json | 164 +++ ...contract-attach-webhook-replace-human.json | 158 +++ .../job-contract-attach-webhook-replace.json | 172 +++ ...-contract-attach-webhook-settings-new.json | 143 ++ .../service/jobs/job-contract-attach.json | 15 + ...contract-detach-confirmation-required.json | 3 + ...ract-detach-mismatch-protocol-invalid.json | 3 + .../service/jobs/job-contract-detach.json | 3 + .../jobs/job-contract-list-not-found.json | 5 +- ...-contract-list-webhook-settings-human.json | 81 ++ .../job-contract-list-webhook-settings.json | 135 ++ .../cases/service/jobs/job-contract-list.json | 18 +- .../crates/prose-runner-core/src/registry.rs | 2 +- .../prose-runner-core/src/service/jobs.rs | 1250 ++++++++++++++++- .../prose-runner-core/src/service/mod.rs | 35 +- .../prose-runner-core/src/service/programs.rs | 2 +- .../prose-runner-core/src/service/runs.rs | 53 +- cli/shared/schemas/service/jobs.schema.json | 59 + cli/shared/service/help.v1.json | 15 +- cli/shared/service/operations.v1.json | 162 ++- .../responses/trigger-contracts.schema.json | 55 +- docs/service/jobs.md | 40 +- 60 files changed, 6594 insertions(+), 218 deletions(-) create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-clear-all-inputs-server-merge.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-clear-input-invalid.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-clear-repo-client-merge.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-commit-output-mismatch.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-commit-output-uses-repo-url.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-conflicting-clear.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-conflicting-commit-output.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-conflicting-files.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-input-clear-input-conflict.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-object-input-protocol-invalid.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-plain-bound-server-merge.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-preview-quote-github.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-preview-quote-job-type.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-preview-quote-unavailable.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-replace-onto-bound-refused.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-settings-non-webhook-refused.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-webhook-clear-files-server-merge.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-webhook-clear-repo-server-merge.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-webhook-files-server-merge.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-webhook-human.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-webhook-inputs-server-merge.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-webhook-preview-quote-human.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-webhook-preview-quote.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-client-merge-human.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-client-merge.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-server-merge.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-webhook-replace-human.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-webhook-replace.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-webhook-settings-new.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-list-webhook-settings-human.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-list-webhook-settings.json diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index d0b355f9..9dc8dadf 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -35,6 +35,22 @@ a candidate do not establish public availability or authorize publication. - `job create` webhook specs accept `model`, `reasoning_effort`, `repository_url`, `repository_branch` and `output` for the connected contract (the service requires `program_ref` with them). +- `job contract attach` sets a webhook job's run settings: `--model`, + `--reasoning-effort`, `--repo`, `--commit-output`, `--input` / + `--inputs-file`, `--environment`, `--file`, `--clear-repo`, + `--clear-commit-output`, `--clear-input`, `--clear-files` and `--replace`. + Its plan quotes the hold for the binding as it will run. Re-attaching a + bound program changes only the options given instead of resetting its + settings. `--model` is no longer refused. The options are refused for other + job types. +- An interrupt during a plan's advisory quote (`run submit`, `program draft`, + `program save`, `job contract attach`) now stops the command instead of being + swallowed. +- `run quote --help` is reworded more briefly to keep `cli service operations` + within its size budget. +- `job contract list` reports each binding's saved settings + (`run_configuration`), `effective_model`, `rev_id`, `bound_at` and + `is_platform_default`. ## [0.15.0-rc.2] — 2026-10-02 diff --git a/cli/bun/src/core/service/index.ts b/cli/bun/src/core/service/index.ts index 950ab81d..1eb43879 100644 --- a/cli/bun/src/core/service/index.ts +++ b/cli/bun/src/core/service/index.ts @@ -254,17 +254,19 @@ export class Context { /** * The advisory GET /run/quote hold for a plan (`index` is the operation's - * quote request): {hold}, or undefined when the quote fails, so a failed - * quote never hides the plan. The service's price policy reference stays - * internal. + * quote request, sent with `environment` or else the given `query`): + * {hold}, or undefined when the quote fails, so a failed quote never hides + * the plan. The service's price policy reference stays internal. */ - async advisoryQuote(index: number, environment?: string): Promise { + async advisoryQuote(index: number, environment?: string, query: Array<[string, string]> = []): Promise { const request: Request = { ...requestFor(this.operation, index, "/run/quote"), class: "control" }; if (environment !== undefined) request.query.push(["environment", environment]); + request.query.push(...query); let body: JsonObject; try { body = jsonObject(await this.send(request)); } catch (caught) { - if (caught instanceof RunnerFailure) return undefined; + // Advisory: only an interrupt stops here. + if (caught instanceof RunnerFailure && caught.code !== "CANCELLED") return undefined; throw caught; } const hold = body.hold; diff --git a/cli/bun/src/core/service/jobs.ts b/cli/bun/src/core/service/jobs.ts index 47a8c2ab..4101901b 100644 --- a/cli/bun/src/core/service/jobs.ts +++ b/cli/bun/src/core/service/jobs.ts @@ -24,7 +24,16 @@ // stderr warning without the secret. Both results, and `job show` when the // service's endpoint is the secret-free job-id webhook path, carry the // absolute `endpoint_url` built from the environment origin. -// - `job contract attach|detach` take a pinned `OWNER/SLUG@REV`. +// - `job contract attach|detach` take a pinned `OWNER/SLUG@REV`. Attach +// options set a webhook binding's run settings (other job types are +// refused after reading the job). The job's listed contracts are always +// read first, so a re-attach keeps a bound program's saved settings: a +// service that lists `environment` merges a same-ref re-bind itself and is +// sent only the changes; otherwise the saved settings are merged here. A +// plan (--preview, or no --yes) also reads the job and carries an advisory +// quote for the binding as it will run. +// - `job contract list` adds each binding's saved settings as +// `run_configuration`, never the program text or file content. // // Mirrors cli/rust/crates/prose-runner-core/src/service/jobs.rs. import { failure, invocationFailure } from "../errors"; @@ -35,7 +44,8 @@ import { encodeSegment, holdQuery, jsonObject, parseJson, requestFor, type Reque import type { Context } from "./index"; import { didYouMean, type Environment, type Json, type JsonObject } from "./manifest"; import { parseOwnAllowed, parseProgramRef, pinned, resolveToRun, validSlug } from "./program-ref"; -import { absoluteUrl, addIso, canonicalJson, isoMs, nextLine, usdCents, validText } from "./render"; +import { absoluteUrl, addIso, argvText, canonicalJson, isoMs, nextLine, usdCents, validText } from "./render"; +import { commitNotRead, modelOption, parseInputs, parseRepository, repositoryUrl, sameRepository, tokenOption, validInputKey, type Repository } from "./runs"; /** Largest job spec or configuration file. */ export const SPEC_MAX_BYTES = 65_536; @@ -55,8 +65,8 @@ export async function execute(context: Context): Promise { case "job.deliveries": return await deliveries(context); case "job.rotate-secret": return await rotateSecret(context); case "job.contract.list": return await contractList(context); - case "job.contract.attach": return await contractChange(context, true); - case "job.contract.detach": return await contractChange(context, false); + case "job.contract.attach": return await contractAttach(context); + case "job.contract.detach": return await contractDetach(context); default: return await context.notImplemented(); } } @@ -322,6 +332,7 @@ const isUuid = (text: string): boolean => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}- const isRunId = (text: string): boolean => /^run_[A-Za-z0-9_-]{1,128}$/u.test(text); const isModelId = (text: string): boolean => /^[a-z0-9][a-z0-9.-]{0,63}$/u.test(text); const isHex64 = (text: string): boolean => /^[0-9a-f]{64}$/u.test(text); +const isRevId = (text: string): boolean => /^[0-9a-f]{16}$/u.test(text); function isProgramRef(text: string): boolean { if (text.length > 200) return false; try { parseProgramRef(text, true); return true; } catch { return false; } @@ -329,7 +340,7 @@ function isProgramRef(text: string): boolean { type Kind = | { text: number } | { prose: number } - | "integer" | "epochMs" | "bool" | "uuid" | "runId" | "modelId" | "programRef" | "slug" | "hex64"; + | "integer" | "epochMs" | "bool" | "uuid" | "runId" | "modelId" | "programRef" | "slug" | "hex64" | "revId"; function scalar(value: Json | undefined, kind: Kind, field: string): Json { const text = (): string => { @@ -365,6 +376,7 @@ function scalar(value: Json | undefined, kind: Kind, field: string): Json { case "programRef": return check(isProgramRef(text())); case "slug": return check(validSlug(text())); case "hex64": return check(isHex64(text())); + case "revId": return check(isRevId(text())); } } @@ -395,6 +407,23 @@ function array(value: Json | undefined, max: number, field: string): Json[] { // ------------------------------------------------------------- projections +const byUtf8 = (left: string, right: string): number => Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); +const costNamed = (name: string): boolean => /[Cc][Oo][Ss][Tt]/u.test(name); + +/** + * Money rule: no output property name may match /cost/i. An input so named is + * a user key, not a money field, and `job configure` replaces every input, so + * it is kept losslessly as a {name, value} entry of `input_entries` instead of + * being dropped. Entries are ordered by UTF-8 bytes, as the Rust product's + * sorted map. `input_entries` is omitted when there are none. + */ +function splitInputs(inputs: Record): JsonObject { + const target: JsonObject = { inputs: Object.fromEntries(Object.entries(inputs).filter(([name]) => !costNamed(name))) }; + const entries = Object.entries(inputs).filter(([name]) => costNamed(name)).sort(([a], [b]) => byUtf8(a, b)); + if (entries.length > 0) target.input_entries = entries.map(([name, value]) => ({ name, value })); + return target; +} + function projectRunConfiguration(value: Json, field: string): JsonObject { const source = object(value, field); const target: JsonObject = {}; @@ -402,16 +431,7 @@ function projectRunConfiguration(value: Json, field: string): JsonObject { if (Object.hasOwn(source, "inputs")) { const inputs = object(source.inputs, `${field}.inputs`); if (Object.keys(inputs).length > 100 || Object.values(inputs).some((item) => typeof item !== "string")) throw protocol(`${field}.inputs`); - // Money rule: no output property name may match /cost/i. An input - // so named is a user key, not a money field, and `job configure` replaces - // every input, so it is kept losslessly as a {name, value} entry of - // `input_entries` instead of being dropped. Entries are ordered - // by UTF-8 bytes, as the Rust product's sorted map. - const costNamed = (name: string): boolean => /[Cc][Oo][Ss][Tt]/u.test(name); - target.inputs = Object.fromEntries(Object.entries(inputs).filter(([name]) => !costNamed(name))); - const entries = Object.entries(inputs).filter(([name]) => costNamed(name)) - .sort(([a], [b]) => Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"))); - if (entries.length > 0) target.input_entries = entries.map(([name, value]) => ({ name, value })); + Object.assign(target, splitInputs(inputs as Record)); } if (Object.hasOwn(source, "files")) { const name = `${field}.files`; @@ -862,22 +882,85 @@ async function rotateSecret(context: Context): Promise { async function contractList(context: Context): Promise { const id = jobId(context); const body = await getJson(context, 0, `/triggers/${encodeSegment(id)}/contracts`); - // The contract route names differ from the job record's; both project to - // the same public contract fields. - const contracts = array(body.contracts ?? null, 16, "contracts").map((contract) => { - const source = object(contract, "contracts"); - const renamed: JsonObject = {}; - for (const [from, to] of [["program_ref", "programRef"], ["slug", "programSlug"], ["enabled", "enabled"], ["model", "model"]] as const) { - if (Object.hasOwn(source, from)) renamed[to] = source[from]!; - } - return projectContract(renamed, "contracts"); - }); + const contracts = array(body.contracts ?? null, 16, "contracts").map(projectListedContract); const result: JsonObject = { contracts }; if (Object.hasOwn(body, "max_contracts")) result.max_contracts = scalar(body.max_contracts, "epochMs", "max_contracts"); context.human = humanContracts(result); return result; } +/** + * One contract of the contract route, whose names differ from the job + * record's: the public contract fields plus its saved run settings as + * `run_configuration`. The program text and file content are never projected. + */ +function projectListedContract(value: Json, index: number): JsonObject { + const field = "contracts"; + const source = object(value, field); + const target: JsonObject = {}; + copy(target, source, field, [ + ["program_ref", "programRef", false], ["enabled", "bool", false], ["model", "modelId", true], + ["effective_model", "modelId", true], ["rev_id", "revId", false], ["bound_at", "epochMs", false], + ["is_platform_default", "bool", false], + ]); + if (!Object.hasOwn(target, "program_ref")) throw protocol(`${field}.program_ref`); + if (Object.hasOwn(source, "slug")) target.program_slug = scalar(source.slug, "slug", `${field}.slug`); + addIso(target, ["bound_at"]); + const run = listedRunConfiguration(source, field, index); + if (Object.keys(run).length > 0) target.run_configuration = run; + return target; +} + +/** A listed binding's saved settings in the public `run_configuration` names; absent and null settings are left out. */ +function listedRunConfiguration(source: JsonObject, field: string, index: number): JsonObject { + const present = (name: string): boolean => Object.hasOwn(source, name) && source[name] !== null; + const target: JsonObject = {}; + if (present("reasoning_effort")) target.reasoning_effort = scalar(source.reasoning_effort, { text: 32 }, `${field}.reasoning_effort`); + if (present("inputs")) { + const name = `${field}.inputs`; + const inputs = object(source.inputs, name); + if (Object.keys(inputs).length > 100) throw protocol(name); + // A string is kept as it is; another JSON scalar is carried as its JSON text. + const texts: Record = {}; + for (const [key, item] of Object.entries(inputs)) { + if (item !== null && typeof item === "object") throw protocol(`${field}[${index}].inputs.${key}`); + texts[key] = typeof item === "string" ? item : canonicalJson(item); + } + const split = splitInputs(texts); + if (Object.keys(split.inputs as JsonObject).length === 0) delete split.inputs; + Object.assign(target, split); + } + if (present("repositories")) { + const name = `${field}.repositories`; + const repositories = array(source.repositories, 16, name).map((repository) => { + const item: JsonObject = {}; + copy(item, object(repository, name), name, [["url", { text: 2048 }, false], ["branch", { text: 256 }, true]]); + if (!Object.hasOwn(item, "url")) throw protocol(name); + return item; + }); + if (repositories.length > 0) target.context_repositories = repositories; + } + if (present("environment")) target.environment = scalar(source.environment, { text: 64 }, `${field}.environment`); + if (present("files")) { + const name = `${field}.files`; + const files = array(source.files, 64, name).map((file) => { + const item: JsonObject = {}; + copy(item, object(file, name), name, [["name", { text: 256 }, false], ["size", "epochMs", false], ["sha256", "hex64", false]]); + if (!Object.hasOwn(item, "name") || !Object.hasOwn(item, "size")) throw protocol(name); + return item; + }); + if (files.length > 0) target.stored_files = files; + } + if (present("output")) { + const name = `${field}.output`; + const item: JsonObject = {}; + copy(item, object(source.output, name), name, [["type", { text: 32 }, false], ["repository", { text: 2048 }, false], ["branch", { text: 256 }, true]]); + if (!Object.hasOwn(item, "type") || !Object.hasOwn(item, "repository")) throw protocol(name); + target.output = item; + } + return target; +} + /** * Why a contract reference is not pinned, with the command that prints the * rev_id for the program the caller named: `program show` @@ -895,26 +978,380 @@ function unpinnedReason(context: Context, value: string): string { return `program reference ${quoted(value)} must be pinned as OWNER/SLUG@REV (the 16-hex-digit rev_id); \`${show}\` prints the latest as \`ref\``; } -async function contractChange(context: Context, attach: boolean): Promise { +/** The pinned OWNER/SLUG@REV of `option` (the positional argument when undefined). */ +function pinnedValue(context: Context, value: string, option?: string): string { + try { return pinned(parseProgramRef(value, true)) ?? ""; } + catch { throw invocationFailure(`${option === undefined ? "" : `${option}: `}${unpinnedReason(context, value)}`); } +} + +async function contractDetach(context: Context): Promise { const id = jobId(context); - const value = context.argument("OWNER/SLUG@REV") ?? ""; - let reference: string; - try { reference = pinned(parseProgramRef(value, true)) ?? ""; } - catch { throw invocationFailure(unpinnedReason(context, value)); } - if (attach && context.option("--model") !== undefined) { - throw invocationFailure("the service does not accept --model when attaching a contract; attached contracts run on the service's default job model (see `cli job contract list`)"); + const reference = pinnedValue(context, context.argument("OWNER/SLUG@REV") ?? ""); + const path = `/triggers/${encodeSegment(id)}/contracts/${encodeSegment(reference)}`; + const gate = context.gate(context.planned(0, path)); + if (gate.kind === "preview") return gate.result; + const response = jsonObject(await context.send(requestFor(context.operation, 0, path))); + if (response.unbound !== reference) throw protocol("unbound"); + context.human = `Detached ${humanSafeScalar(reference)} from job ${humanSafeScalar(id)}.\n` + + `List the job's contracts with \`cli job contract list ${humanSafeScalar(id)}\`.\n`; + return { contracts: [{ program_ref: reference }] }; +} + +/** The `job contract attach` options that change a webhook binding's settings (with --replace, they need a webhook job). */ +const BINDING_OPTIONS = [ + "--model", "--reasoning-effort", "--repo", "--commit-output", "--clear-repo", "--clear-commit-output", + "--input", "--inputs-file", "--clear-input", "--environment", "--file", "--clear-files", "--replace", +] as const; +const BINDING_FLAGS = new Set(["--clear-repo", "--clear-commit-output", "--clear-files"]); +/** Stored binding files: at most 20, 5 MiB each and 10 MiB in total. */ +const MAX_BINDING_FILES = 20; +const MAX_BINDING_FILE_BYTES = 5 << 20; +const MAX_BINDING_FILES_BYTES = 10 << 20; + +/** The settings options of one `job contract attach`, checked locally. */ +interface Binding { + model?: string; + effort?: string; + repo?: Repository; + commit?: Repository; + clearRepo: boolean; + clearCommit: boolean; + /** --file NAME -> base64 content (replaces the stored set), or undefined. */ + files?: JsonObject; + clearFiles: boolean; + inputs: Map; + clearInputs: string[]; + environment?: string; + replace?: string; + /** Any option above was given. */ + any: boolean; +} + +/** + * Reads and checks the settings options before any request: conflicting + * options, and a --commit-output that is not the --repo given, are + * INVOCATION_INVALID. + */ +async function bindingOptions(context: Context): Promise { + const given = (name: string): boolean => (BINDING_FLAGS.has(name) ? context.flag(name) : context.optionValues(name).length > 0); + const clearRepo = context.flag("--clear-repo"); + const clearCommit = context.flag("--clear-commit-output"); + if (given("--repo") && clearRepo) throw invocationFailure("--repo and --clear-repo cannot be combined: --repo replaces the saved repository, --clear-repo removes it"); + if (given("--commit-output") && clearRepo) throw invocationFailure("--commit-output and --clear-repo cannot be combined: --commit-output sets the commit output, --clear-repo removes it"); + if (given("--commit-output") && clearCommit) throw invocationFailure("--commit-output and --clear-commit-output cannot be combined: --commit-output sets the commit output, --clear-commit-output removes it"); + const clearFiles = context.flag("--clear-files"); + if (given("--file") && clearFiles) throw invocationFailure("--file and --clear-files cannot be combined: --file replaces the stored files, --clear-files removes them"); + const clearInputs: string[] = []; + for (const key of context.optionValues("--clear-input")) { + if (!validInputKey(key)) throw invocationFailure(`--clear-input ${quoteText(key)} must be an input name of 1 to 128 characters without control characters`); + if (!clearInputs.includes(key)) clearInputs.push(key); } - const body = attach ? new TextEncoder().encode(canonicalJson({ program_ref: reference })) : undefined; - const path = attach - ? `/triggers/${encodeSegment(id)}/contracts` - : `/triggers/${encodeSegment(id)}/contracts/${encodeSegment(reference)}`; - const gate = context.gate(context.planned(0, path, [], body)); + for (const raw of context.optionValues("--input")) { + const key = raw.includes("=") ? raw.slice(0, raw.indexOf("=")) : raw; + if (clearInputs.includes(key)) throw invocationFailure(`--input ${quoteText(key)} and --clear-input ${quoteText(key)} cannot be combined: --input sets the input, --clear-input removes it`); + } + const replaceValue = context.option("--replace"); + const repoValue = context.option("--repo"); + const commitValue = context.option("--commit-output"); + const binding: Binding = { + clearRepo, clearCommit, clearInputs, clearFiles, + inputs: await parseInputs(context), + any: BINDING_OPTIONS.some(given), + }; + const model = modelOption(context); + if (model !== undefined) binding.model = model; + const effort = context.option("--reasoning-effort"); + if (effort !== undefined) binding.effort = effort; + if (repoValue !== undefined) binding.repo = parseRepository("--repo", repoValue); + if (commitValue !== undefined) binding.commit = parseRepository("--commit-output", commitValue); + const environment = tokenOption(context, "--environment", "an environment"); + if (environment !== undefined) binding.environment = environment; + if (replaceValue !== undefined) binding.replace = pinnedValue(context, replaceValue, "--replace"); + const files = await bindingFiles(context); + if (files !== undefined) binding.files = files; + if (binding.commit !== undefined && binding.repo !== undefined && !sameRepository(binding.commit, binding.repo)) { + throw commitNotRead(binding.commit); + } + return binding; +} + +/** + * The last component of a `/`-separated path, as a file name: trailing `/` + * and `.` components are skipped, and a path ending in `..` (or only `.`) + * has none (empty). + */ +function baseName(path: string): string { + const parts = path.split("/").filter((part, index) => part !== "" && (part !== "." || index === 0)); + const last = parts[parts.length - 1] ?? ""; + return last === "." || last === ".." ? "" : last; +} + +/** + * The `--file [NAME=]PATH` files as NAME -> base64 of their UTF-8 content + * (NAME defaults to PATH's last segment), or undefined when none is given. + */ +async function bindingFiles(context: Context): Promise { + const values = context.optionValues("--file"); + if (values.length === 0) return undefined; + if (values.length > MAX_BINDING_FILES) throw invocationFailure(`--file was given ${values.length} times; a binding stores at most ${MAX_BINDING_FILES} files`); + const files: JsonObject = {}; + let total = 0; + for (const raw of values) { + const equals = raw.indexOf("="); + const path = equals >= 0 ? raw.slice(equals + 1) : raw; + const name = equals >= 0 ? raw.slice(0, equals) : baseName(path); + if (!validText(name, 256) || name.includes("/") || name.includes("\\")) { + throw invocationFailure(`--file ${quoteText(raw)}: the file name must be 1 to 256 characters without /, \\ or control characters; give it as NAME=PATH`); + } + const bytes = await readSource(context.cwd, path, MAX_BINDING_FILE_BYTES, "--file"); + try { new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); } + catch { throw invocationFailure(`--file ${quoteText(path)} is not UTF-8 text`); } + total += bytes.length; + if (total > MAX_BINDING_FILES_BYTES) throw invocationFailure(`the --file files hold more than ${MAX_BINDING_FILES_BYTES} bytes together; a binding stores at most that much`); + if (Object.hasOwn(files, name)) throw invocationFailure(`--file name ${quoteText(name)} was given more than once; name each file uniquely with NAME=PATH`); + files[name] = Buffer.from(bytes).toString("base64"); + } + return files; +} + +/** Whether two repository URLs name the same repository (GitHub names ignore case). */ +const sameUrl = (left: string, right: string): boolean => left.toLowerCase() === right.toLowerCase(); + +/** The commit output of `--commit-output`, committing to `url` (the repository the runs read). */ +function commitOutput(commit: Repository, url: string): JsonObject { + return commit.branch === undefined ? { type: "commit", repository: url } : { type: "commit", repository: url, branch: commit.branch }; +} + +/** A bound contract's saved settings as the service listed them; a wrong shape is SERVICE_PROTOCOL_INVALID. */ +interface Saved { + /** The binding has stored files (their content is never listed). */ + files?: boolean; + model?: string; + effort?: string; + environment?: string; + repository?: { url: string; branch?: string }; + output?: JsonObject; + inputs: JsonObject; +} + +function savedSettings(contract: JsonObject): Saved { + const field = "contracts"; + const text = (name: string, max: number): string | undefined => + contract[name] === null || contract[name] === undefined ? undefined : scalar(contract[name], { text: max }, `${field}.${name}`) as string; + const saved: Saved = { inputs: {} }; + const model = contract.model === null || contract.model === undefined ? undefined : scalar(contract.model, "modelId", `${field}.model`) as string; + if (model !== undefined) saved.model = model; + const effort = text("reasoning_effort", 32); + if (effort !== undefined) saved.effort = effort; + const environment = text("environment", 64); + if (environment !== undefined) saved.environment = environment; + saved.files = Array.isArray(contract.files) && contract.files.length > 0; + if (contract.repositories !== null && contract.repositories !== undefined) { + const first = array(contract.repositories, 16, `${field}.repositories`)[0]; + if (first !== undefined) { + const item: JsonObject = {}; + copy(item, object(first, `${field}.repositories`), `${field}.repositories`, [["url", { text: 2048 }, false], ["branch", { text: 256 }, true]]); + if (typeof item.url !== "string") throw protocol(`${field}.repositories`); + saved.repository = typeof item.branch === "string" ? { url: item.url, branch: item.branch } : { url: item.url }; + } + } + if (contract.output !== null && contract.output !== undefined) { + const output = object(contract.output, `${field}.output`); + if (typeof output.repository !== "string") throw protocol(`${field}.output`); + saved.output = output; + } + // Inputs are kept as returned: a non-string value is sent back unchanged. + if (contract.inputs !== null && contract.inputs !== undefined) saved.inputs = { ...object(contract.inputs, `${field}.inputs`) }; + return saved; +} + +/** `argv` without the settings options and their values (the suggestion for a job that is not a webhook). */ +function withoutBindingOptions(argv: readonly string[]): string[] { + return withoutOptions(argv, BINDING_OPTIONS, BINDING_FLAGS); +} + +/** `argv` without `options` (with their values) and `flags`, up to any `--`. */ +function withoutOptions(argv: readonly string[], options: readonly string[], flags: ReadonlySet): string[] { + const kept: string[] = []; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]!; + if (token === "--") { kept.push(...argv.slice(index)); break; } + const name = token.includes("=") ? token.slice(0, token.indexOf("=")) : token; + if (!options.includes(name)) { kept.push(token); continue; } + if (name === token && !flags.has(name)) index += 1; + } + return kept; +} + +/** + * The POST body of `job contract attach` from the job's listed contracts. + * A re-bind of the same ref on a merging service sends only what the options + * change (clears as JSON nulls); otherwise the saved settings of the bound + * ref (or of the --replace target) are merged here and sent in full. An + * unbound ref sends only the options given. + */ +/** The settings the runs of a binding use after an attach, as the plan quote prices them. */ +interface Effective { model?: string; effort?: string; environment?: string; repository: boolean } + +function attachBody(reference: string, binding: Binding, contracts: JsonObject[]): { body: JsonObject; note: string | undefined; effective: Effective } { + const base = contracts.find((contract) => contract.program_ref === (binding.replace ?? reference)); + const saved = base === undefined ? undefined : savedSettings(base); + // A service that lists environment (and file metadata) merges a re-bind itself. + const merging = contracts.some((contract) => Object.hasOwn(contract, "environment")); + const repoUrl = binding.repo === undefined ? undefined : repositoryUrl(binding.repo); + // The repository the runs read after this change. + const effectiveUrl = repoUrl ?? (binding.clearRepo ? undefined : saved?.repository?.url); + if (binding.commit !== undefined && (effectiveUrl === undefined || !sameUrl(effectiveUrl, repositoryUrl(binding.commit)))) { + throw commitNotRead(binding.commit); + } + // A new repository drops a saved output that commits elsewhere. + const outputElsewhere = repoUrl !== undefined && typeof saved?.output?.repository === "string" && !sameUrl(saved.output.repository, repoUrl); + const mergedInputs = (): JsonObject => { + const inputs: JsonObject = { ...(saved?.inputs ?? {}) }; + for (const [key, value] of binding.inputs) inputs[key] = value; + for (const key of binding.clearInputs) delete inputs[key]; + return inputs; + }; + const body: JsonObject = { program_ref: reference }; + // A --replace of the attached ref itself is an ordinary re-attach. + const moved = binding.replace !== undefined && binding.replace !== reference; + if (saved !== undefined && merging && !moved) { + body.replace_program_ref = reference; + if (binding.model !== undefined) body.model = binding.model; + if (binding.effort !== undefined) body.reasoning_effort = binding.effort; + if (binding.repo !== undefined) { + body.repository_url = repoUrl!; + body.repository_branch = binding.repo.branch ?? null; + if (outputElsewhere) body.output = null; + } + if (binding.clearRepo) Object.assign(body, { repository_url: null, repository_branch: null, output: null }); + if (binding.clearCommit) body.output = null; + if (binding.commit !== undefined) body.output = commitOutput(binding.commit, effectiveUrl!); + if (binding.inputs.size > 0 || binding.clearInputs.length > 0) body.inputs = mergedInputs(); + if (binding.environment !== undefined) body.environment = binding.environment; + if (binding.files !== undefined) body.files = binding.files; + if (binding.clearFiles) body.files = null; + // The service keeps what is not sent: the saved settings, with the options over them. + const effective: Effective = { repository: effectiveUrl !== undefined }; + const model = binding.model ?? saved.model; + if (model !== undefined) effective.model = model; + const effort = binding.effort ?? saved.effort; + if (effort !== undefined) effective.effort = effort; + const environment = binding.environment ?? saved.environment; + if (environment !== undefined) effective.environment = environment; + return { body, note: undefined, effective }; + } + const model = binding.model ?? saved?.model; + if (model !== undefined) body.model = model; + const effort = binding.effort ?? saved?.effort; + if (effort !== undefined) body.reasoning_effort = effort; + const repository = binding.repo !== undefined + ? (binding.repo.branch === undefined ? { url: repoUrl! } : { url: repoUrl!, branch: binding.repo.branch }) + : (binding.clearRepo ? undefined : saved?.repository); + if (repository !== undefined) { + body.repository_url = repository.url; + if (repository.branch !== undefined) body.repository_branch = repository.branch; + } + const output = binding.commit !== undefined ? commitOutput(binding.commit, effectiveUrl!) + : (binding.clearRepo || binding.clearCommit || outputElsewhere ? undefined : saved?.output); + if (output !== undefined) body.output = output; + const inputs = mergedInputs(); + if (Object.keys(inputs).length > 0) body.inputs = inputs; + // A move to another revision is a full replace: the saved environment is + // carried too (an older service lists none). + const environment = binding.environment ?? (moved ? saved?.environment : undefined); + if (environment !== undefined) body.environment = environment; + if (binding.files !== undefined) body.files = binding.files; + // Clearing files of an unbound program changes nothing. + else if (binding.clearFiles && saved !== undefined) body.files = null; + if (binding.replace !== undefined) body.replace_program_ref = binding.replace; + const effective: Effective = { repository: repository !== undefined }; + if (model !== undefined) effective.model = model; + if (effort !== undefined) effective.effort = effort; + if (environment !== undefined) effective.environment = environment; + // An older service lists neither stored files nor environment, so a + // client-merged re-bind cannot carry them; stored file content is never + // listed, so a move cannot carry the files either. + let note: string | undefined; + if (saved !== undefined && !merging) note = DROPPED_NOTE; + else if (moved && saved?.files === true && binding.files === undefined && !binding.clearFiles) note = FILES_NOTE; + return { body, note, effective }; +} + +/** Job types that always bind a repository, so their runs are quoted with one. */ +const REPOSITORY_JOB_TYPES = ["github-issue-opened", "github-pull-request-opened", "github-release-published"]; + +const DROPPED_NOTE = "note: this service does not report stored files or environment; re-attaching may drop them\n"; +const FILES_NOTE = "note: stored files are not carried to the new revision; pass --file to attach them\n"; + +async function contractAttach(context: Context): Promise { + const id = jobId(context); + const reference = pinnedValue(context, context.argument("OWNER/SLUG@REV") ?? ""); + const binding = await bindingOptions(context); + const jobPath = `/triggers/${encodeSegment(id)}`; + const path = `${jobPath}/contracts`; + // Settings apply to webhook jobs only, and a plan's quote prices the + // job's type: read the job first. + const plan = context.invocation.preview || !context.invocation.yes; + let live = false; + let jobType: string | undefined; + if (binding.any || plan) { + const detail = projectDetail(await getJson(context, 0, jobPath), false, context.environment); + const kind = (detail.job as JsonObject).type; + if (typeof kind === "string") jobType = kind; + if (binding.any && kind !== "webhook") { + const error = invocationFailure(`job ${id} is a ${humanSafeScalar(typeof kind === "string" ? kind : "")} job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace and the --clear options) apply to webhook jobs only`); + throw context.corrected(error, "Attach without those options: `{command}`", withoutBindingOptions(context.invocation.argv)); + } + live = (detail.status as JsonObject | undefined)?.delivery_mode === "live"; + } + // The listed contracts keep a bound program's saved settings. + const listed = array((await getJson(context, 1, path)).contracts ?? null, 16, "contracts").map((contract) => object(contract, "contracts")); + // The listing must be valid as `job contract list` reads it. + listed.forEach(projectListedContract); + // --replace onto another program that is already bound would reset that + // binding's settings. + const replaced = binding.replace; + if (replaced !== undefined && replaced !== reference && listed.some((contract) => contract.program_ref === reference)) { + const error = invocationFailure(`${reference} is already bound to job ${humanSafeScalar(id)}; --replace would reset its settings. Change it in place without --replace, or detach ${replaced} first`); + throw context.corrected(error, "Change it in place without --replace: `{command}`", withoutOptions(context.invocation.argv, ["--replace"], new Set())); + } + const { body, note, effective } = attachBody(reference, binding, listed); + const bytes = new TextEncoder().encode(canonicalJson(body)); + const planned = context.planned(2, path, [], bytes); + if (plan) { + // Advisory: a failed quote leaves the plan without one. + const query: Array<[string, string]> = [["program_ref", reference], ...holdQuery({ + model: effective.model, reasoningEffort: effective.effort, environment: effective.environment, + repositoriesBound: effective.repository || (jobType !== undefined && REPOSITORY_JOB_TYPES.includes(jobType)), + })]; + if (jobType !== undefined) query.push(["job_type", jobType]); + const quoted = await context.advisoryQuote(3, undefined, query); + if (quoted !== undefined) planned.quote = quoted; + } + const gate = context.gate(planned); if (gate.kind === "preview") return gate.result; - const request: Request = { ...requestFor(context.operation, 0, path), ...(body === undefined ? {} : { body }) }; - const response = jsonObject(await context.send(request)); - const key = attach ? "bound" : "unbound"; - if (response[key] !== reference) throw protocol(key); - context.human = `${attach ? "Attached" : "Detached"} ${humanSafeScalar(reference)} ${attach ? "to" : "from"} job ${humanSafeScalar(id)}.\n` + let response: JsonObject; + try { response = jsonObject(await context.send({ ...requestFor(context.operation, 2, path), body: bytes })); } + catch (caught) { + // A live webhook may refuse binding changes: switching it to test delivery first allows them. + const message = caught instanceof RunnerFailure ? caught.details?.serviceMessage : undefined; + if (live && caught instanceof RunnerFailure && caught.code === "SERVICE_REQUEST_REJECTED" && typeof message === "string" && /immutable/iu.test(message)) { + const argv = context.followUpArgv(["job", "update", id, "--spec-file", "-", "--yes"]); + const stdin = canonicalJson({ delivery_mode: "test" }); + throw new RunnerFailure({ + code: caught.code, boundary: caught.boundary, message: caught.message, exitCode: caught.exitCode, retryable: caught.retryable, + action: `${caught.action} The job delivers live; to change its binding, switch it to test delivery first: \`${argvText(argv)}\` with details.suggestedStdin on standard input.`, + details: { ...(caught.details ?? {}), suggestedArgv: argv, suggestedStdin: `${stdin}\n` }, + }); + } + throw caught; + } + if (response.bound !== reference) throw protocol("bound"); + if (note !== undefined && context.mode === "human") context.err(note); + const settings = settingsLine(body); + context.human = `Attached ${humanSafeScalar(reference)} to job ${humanSafeScalar(id)}.\n` + + (settings === undefined ? "" : ` settings: ${settings}\n`) + `List the job's contracts with \`cli job contract list ${humanSafeScalar(id)}\`.\n`; return { contracts: [{ program_ref: reference }] }; } @@ -1018,9 +1455,49 @@ function humanDeliveries(result: JsonObject): string { function humanContracts(result: JsonObject): string { const contracts = result.contracts as JsonObject[]; if (contracts.length === 0) return "No contracts.\n"; - return contracts.map((contract) => - `${textOrDash(contract.program_ref)} enabled=${typeof contract.enabled === "boolean" ? String(contract.enabled) : "-"} model=${textOrDash(contract.model)}\n`, - ).join(""); + return contracts.map((contract) => { + const line = `${textOrDash(contract.program_ref)} enabled=${typeof contract.enabled === "boolean" ? String(contract.enabled) : "-"} model=${textOrDash(contract.model)}\n`; + const run = isObject(contract.run_configuration) ? contract.run_configuration : undefined; + if (run === undefined) return line; + const repository = Array.isArray(run.context_repositories) && isObject(run.context_repositories[0]) ? run.context_repositories[0] : undefined; + const names = [ + ...(isObject(run.inputs) ? Object.keys(run.inputs) : []), + ...(Array.isArray(run.input_entries) ? run.input_entries.map((entry) => (isObject(entry) && typeof entry.name === "string" ? entry.name : "")) : []), + ]; + const settings = settingsText({ + effort: run.reasoning_effort, repository: repository?.url, branch: repository?.branch, + output: isObject(run.output) ? run.output : undefined, inputs: names, + files: Array.isArray(run.stored_files) ? run.stored_files.map((file) => (isObject(file) && typeof file.name === "string" ? file.name : "")) : [], + }); + return settings === undefined ? line : `${line} settings: ${settings}\n`; + }).join(""); +} + +/** The human `settings:` text of an attach request body (clears, sent as null, are not shown). */ +function settingsLine(body: JsonObject): string | undefined { + return settingsText({ + effort: body.reasoning_effort, repository: body.repository_url, branch: body.repository_branch, + output: isObject(body.output) ? body.output : undefined, inputs: isObject(body.inputs) ? Object.keys(body.inputs) : [], + files: isObject(body.files) ? Object.keys(body.files) : [], + }); +} + +/** + * `reasoning effort E; repository URL@BRANCH; commit output URL; inputs k1, + * k2; files n1, n2` with only the parts present; input values and file + * content are never shown. + */ +function settingsText(parts: { effort: Json | undefined; repository: Json | undefined; branch: Json | undefined; output: JsonObject | undefined; inputs: string[]; files: string[] }): string | undefined { + const at = (url: Json | undefined, branch: Json | undefined): string => `${humanSafeScalar(String(url))}${typeof branch === "string" ? `@${humanSafeScalar(branch)}` : ""}`; + const shown: string[] = []; + if (typeof parts.effort === "string") shown.push(`reasoning effort ${humanSafeScalar(parts.effort)}`); + if (typeof parts.repository === "string") shown.push(`repository ${at(parts.repository, parts.branch)}`); + if (typeof parts.output?.repository === "string") shown.push(`commit output ${humanSafeScalar(parts.output.repository)}`); + const names = parts.inputs.filter((name) => name !== "").sort(byUtf8); + if (names.length > 0) shown.push(`inputs ${names.map(humanSafeScalar).join(", ")}`); + const files = parts.files.filter((name) => name !== "").sort(byUtf8); + if (files.length > 0) shown.push(`files ${files.map(humanSafeScalar).join(", ")}`); + return shown.length === 0 ? undefined : shown.join("; "); } /** diff --git a/cli/bun/src/core/service/runs.ts b/cli/bun/src/core/service/runs.ts index 0b1a0841..96c42ea3 100644 --- a/cli/bun/src/core/service/runs.ts +++ b/cli/bun/src/core/service/runs.ts @@ -861,13 +861,18 @@ function errorEnd(follow: Follow): RunnerFailure { // --------------------------------------------------------------------------- // run submit -interface Repository { owner: string; name: string; branch?: string } +export interface Repository { owner: string; name: string; branch?: string } -const repositoryUrl = (repository: Repository): string => `https://github.com/${repository.owner}/${repository.name}`; -const sameRepository = (left: Repository, right: Repository): boolean => +export const repositoryUrl = (repository: Repository): string => `https://github.com/${repository.owner}/${repository.name}`; +export const sameRepository = (left: Repository, right: Repository): boolean => left.owner.toLowerCase() === right.owner.toLowerCase() && left.name.toLowerCase() === right.name.toLowerCase(); -function parseRepository(option: string, value: string): Repository { +/** A --commit-output that is not a repository the runs read (shared with `job contract attach`). */ +export function commitNotRead(commit: Repository): RunnerFailure { + return invocationFailure(`--commit-output ${commit.owner}/${commit.name} must also be given as --repo ${commit.owner}/${commit.name}[@BRANCH]; the service commits only to a context repository`); +} + +export function parseRepository(option: string, value: string): Repository { const error = () => invocationFailure(`${option} ${quoteText(value)} must be OWNER/NAME or OWNER/NAME@BRANCH (a GitHub repository)`); const at = value.indexOf("@"); const name = at >= 0 ? value.slice(0, at) : value; @@ -880,12 +885,30 @@ function parseRepository(option: string, value: string): Repository { return branch === undefined ? { owner, name: repo } : { owner, name: repo, branch }; } -function validInputKey(key: string): boolean { +export function validInputKey(key: string): boolean { const length = Array.from(key).length; return length >= 1 && length <= 128 && !/[\u0000-\u001f\u007f]/u.test(key); } -async function parseInputs(context: Context): Promise> { +/** The --model value, checked as a model id (shared with `job contract attach`). */ +export function modelOption(context: Context): string | undefined { + const model = context.option("--model"); + if (model !== undefined && !validModel(model)) throw invocationFailure(`--model ${quoteText(model)} is not a model id; list them with \`${command(context, "model list")}\``); + return model; +} + +/** An --environment or --runtime value, checked as a lowercase id (shared with `job contract attach`). */ +export function tokenOption(context: Context, option: string, kind: string): string | undefined { + const value = context.option(option); + if (value !== undefined && !validToken(value)) throw invocationFailure(`${option} ${quoteText(value)} is not ${kind} id (lowercase letters, digits, _ and -)`); + return value; +} + +/** + * `--inputs-file` then each `--input KEY=VALUE|KEY=@FILE` (an --input wins + * over the file's key); shared with `job contract attach`. + */ +export async function parseInputs(context: Context): Promise> { const inputs = new Map(); const file = context.option("--inputs-file"); if (file !== undefined) { @@ -996,17 +1019,11 @@ interface RunOptions { } function runOptions(context: Context): RunOptions { - const model = context.option("--model"); - if (model !== undefined && !validModel(model)) throw invocationFailure(`--model ${quoteText(model)} is not a model id; list them with \`${command(context, "model list")}\``); + const model = modelOption(context); const effort = context.option("--reasoning-effort"); if (effort !== undefined && !validEffort(effort)) throw invocationFailure(`--reasoning-effort ${quoteText(effort)} must be lowercase letters (for example low, medium or high)`); - const environment = context.option("--environment"); - const runtime = context.option("--runtime"); - for (const [option, kind, value] of [["--environment", "an environment", environment], ["--runtime", "a runtime", runtime]] as const) { - if (value !== undefined && !validToken(value)) { - throw invocationFailure(`${option} ${quoteText(value)} is not ${kind} id (lowercase letters, digits, _ and -)`); - } - } + const environment = tokenOption(context, "--environment", "an environment"); + const runtime = tokenOption(context, "--runtime", "a runtime"); const repositories: Repository[] = []; for (const value of context.optionValues("--repo")) { const repository = parseRepository("--repo", value); @@ -1015,9 +1032,7 @@ function runOptions(context: Context): RunOptions { } const commitValue = context.option("--commit-output"); const commit = commitValue === undefined ? undefined : parseRepository("--commit-output", commitValue); - if (commit !== undefined && !repositories.some((repository) => sameRepository(repository, commit))) { - throw invocationFailure(`--commit-output ${commit.owner}/${commit.name} must also be given as --repo ${commit.owner}/${commit.name}[@BRANCH]; the service commits only to a context repository`); - } + if (commit !== undefined && !repositories.some((repository) => sameRepository(repository, commit))) throw commitNotRead(commit); return { model, effort, environment, runtime, repositories, commit }; } @@ -1190,12 +1205,13 @@ async function submit(context: Context): Promise { // The plan's quote prices exactly this submission: the same body bytes // and the `POST /run` query without the live session. const quoteRequest: Request = { ...requestFor(context.operation, 1, "/run/quote"), class: "control", query: [...submission.extraQuery], body: submission.body }; - // The quote is advisory: a failed quote never hides the plan. + // The quote is advisory: a failed quote never hides the plan; only an + // interrupt stops here. try { const { hold } = quoteFields(jsonObject(await context.send(quoteRequest))); planned.quote = { hold }; } catch (caught) { - if (!(caught instanceof RunnerFailure)) throw caught; + if (!(caught instanceof RunnerFailure) || caught.code === "CANCELLED") throw caught; } const gate = context.gate(planned); if (gate.kind === "preview") return gate.result; diff --git a/cli/bun/test/service-jobs.test.ts b/cli/bun/test/service-jobs.test.ts index 40d1632e..0d8361db 100644 --- a/cli/bun/test/service-jobs.test.ts +++ b/cli/bun/test/service-jobs.test.ts @@ -73,14 +73,30 @@ describe("Service job local checks send nothing", () => { expect(configure.report!.problem.details.reason).toStartWith("the configuration needs interval_seconds"); }); - test("detach without --yes plans the encoded DELETE; attach refuses --model and unpinned refs", async () => { + test("detach without --yes plans the encoded DELETE; attach refuses conflicting options and unpinned refs", async () => { const detach = await job(["contract", "detach", TID, "exowner1/probe@0123456789abcdef"]); expect(detach.exit).toBe(2); expect(detach.report!.problem.code).toBe("CONFIRMATION_REQUIRED"); expect(detach.report!.problem.details.plannedRequest).toMatchObject({ method: "DELETE", description: "Detach a program from a job." }); expect(detach.report!.problem.details.plannedRequest).not.toHaveProperty("path"); - const model = await job(["contract", "attach", TID, "exowner1/probe@0123456789abcdef", "--model", "model-luna", "--yes"]); - expect(model.report!.problem.code).toBe("INVOCATION_INVALID"); + for (const [extra, reason] of [ + [["--repo", "exowner1/app", "--clear-repo"], "--repo and --clear-repo cannot be combined"], + [["--commit-output", "exowner1/app", "--clear-commit-output"], "--commit-output and --clear-commit-output cannot be combined"], + [["--input", "topic=cats", "--clear-input", "topic"], '--input "topic" and --clear-input "topic" cannot be combined: --input sets the input, --clear-input removes it'], + [["--clear-input", "bad\tname"], '--clear-input "bad\\tname" must be an input name of 1 to 128 characters without control characters'], + [["--repo", "exowner1/app", "--commit-output", "exowner1/other"], "--commit-output exowner1/other must also be given as --repo exowner1/other[@BRANCH]"], + [["--replace", "exowner1/probe"], "--replace: program reference"], + [["--file", "a.md", "--clear-files"], "--file and --clear-files cannot be combined"], + [["--file", "dir/x=a.md"], '--file "dir/x=a.md": the file name must be 1 to 256 characters without /, \\ or control characters; give it as NAME=PATH'], + [["--file", "a.md", "--file", "a.md=a.md"], '--file name "a.md" was given more than once; name each file uniquely with NAME=PATH'], + [["--file", "missing.md"], '--file "missing.md"'], + [["--file", "dir/"], '--file "dir/" is not a readable file'], + [["--file", "../.."], '--file "../..": the file name must be'], + ] as const) { + const refused = await job(["contract", "attach", TID, "exowner1/probe@0123456789abcdef", ...extra, "--yes"], { files: { "a.md": "a\n" } }); + expect(refused.exit).toBe(2); + expect(refused.report!.problem.details.reason).toStartWith(reason); + } const unpinned = await job(["contract", "attach", TID, "exowner1/probe", "--yes"]); expect(unpinned.report!.problem.details.reason).toContain("must be pinned as OWNER/SLUG@REV"); }); @@ -102,3 +118,136 @@ describe("Service job secrets", () => { expect(result.stderr).not.toContain(SECRET); }); }); + +describe("Service job contract settings", () => { + const REF = "exowner1/probe@0123456789abcdef"; + const contractsPath = `/triggers/${TID}/contracts`; + const webhook = (deliveryMode: string) => ({ + method: "GET", path: `/triggers/${TID}`, status: 200, + body: { trigger: { id: TID, type: "webhook", createdAt: 1 }, status: { deliveryMode } }, + }); + const listing = (contracts: unknown[]) => ({ method: "GET", path: contractsPath, status: 200, body: { contracts, max_contracts: 5 } }); + const bound = (extra: Record) => ({ + program_ref: REF, owner: "exowner1", slug: "probe", rev_id: "0123456789abcdef", content: "# private", is_platform_default: false, + enabled: true, bound_at: 1, inputs: { keep: "1", count: 3 }, model: null, effective_model: "model-luna", reasoning_effort: null, + repositories: [{ url: "https://github.com/exowner1/app", branch: "main" }], + output: { type: "commit", repository: "https://github.com/exowner1/app" }, ...extra, + }); + const fixture = (exchanges: unknown[]) => ({ environment: "production", credentials: { production: KEY }, storeAvailable: true, exchanges }); + const post = (expectedBody: unknown, status = 201, body: unknown = { bound: REF }) => ({ method: "POST", path: contractsPath, expectedBody, status, body }); + + test("a merging service gets a new repository with a null branch and the output that committed elsewhere cleared", async () => { + const result = await job(["contract", "attach", TID, REF, "--repo", "exowner1/other", "--yes"], { + fixture: fixture([webhook("test"), listing([bound({ environment: null, files: [] })]), + post({ program_ref: REF, replace_program_ref: REF, repository_url: "https://github.com/exowner1/other", repository_branch: null, output: null })]), + }); + expect(result.exit).toBe(0); + }); + + test("an older service gets the saved settings merged in full, non-string inputs unchanged", async () => { + const result = await job(["contract", "attach", TID, REF, "--commit-output", "exowner1/app@out", "--clear-input", "keep", "--yes"], { + fixture: fixture([webhook("test"), listing([bound({})]), + post({ program_ref: REF, repository_url: "https://github.com/exowner1/app", repository_branch: "main", inputs: { count: 3 }, output: { type: "commit", repository: "https://github.com/exowner1/app", branch: "out" } })]), + }); + expect(result.exit).toBe(0); + }); + + test("a commit output needs a repository the runs read", async () => { + const result = await job(["contract", "attach", TID, REF, "--commit-output", "exowner1/app", "--yes"], { + fixture: fixture([webhook("test"), listing([])]), + }); + expect(result.exit).toBe(2); + expect(result.report!.problem.details.reason).toStartWith("--commit-output exowner1/app must also be given as --repo"); + }); + + test("a live job's refusal names switching it to test delivery", async () => { + const result = await job(["contract", "attach", TID, REF, "--environment", "linux", "--yes"], { + fixture: fixture([webhook("live"), listing([]), post({ program_ref: REF, environment: "linux" }, 400, { error: "execution configuration is immutable" })]), + }); + expect(result.exit).toBe(10); + const problem = result.report!.problem; + expect(problem.code).toBe("SERVICE_REQUEST_REJECTED"); + expect(problem.details.suggestedArgv).toEqual(["--output", "json", "cli", "job", "update", TID, "--spec-file", "-", "--yes"]); + expect(problem.details.suggestedStdin).toBe('{"delivery_mode":"test"}\n'); + expect(problem.action).toEndWith(`The job delivers live; to change its binding, switch it to test delivery first: \`prose --output json cli job update ${TID} --spec-file - --yes\` with details.suggestedStdin on standard input.`); + }); + + test("the preview plan digests the merged body, summarizes it and quotes the binding as it will run", async () => { + const quote = { + method: "GET", path: "/run/quote", status: 200, body: { hold: { hold_usd: "0.06", ttl_seconds: 900 } }, + query: { program_ref: REF, reasoning_effort: "low", job_type: "webhook" }, + }; + const result = await job(["contract", "attach", TID, REF, "--input", "topic=cats", "--reasoning-effort", "low", "--preview"], { + fixture: fixture([webhook("test"), listing([]), quote]), + }); + expect(result.exit).toBe(0); + expect(result.report!.result.plannedRequest.summary).toEqual({ programRef: REF, reasoning_effort: "low", inputKeys: ["topic"] }); + expect(result.report!.result.plannedRequest.quote).toEqual({ hold: { hold_usd: "0.06", hold_cents: 6, ttl_seconds: 900 } }); + }); + + test("a server-merge plan quotes the saved settings with a cleared repository removed", async () => { + const quote = { + method: "GET", path: "/run/quote", status: 200, body: { hold: { hold_usd: "0.06", ttl_seconds: 900 } }, + query: { program_ref: REF, model: "model-sol", environment: "linux", job_type: "webhook" }, + }; + const result = await job(["contract", "attach", TID, REF, "--clear-repo", "--model", "model-sol", "--preview"], { + fixture: fixture([webhook("test"), listing([bound({ environment: "linux" })]), quote]), + }); + expect(result.exit).toBe(0); + expect(result.report!.result.plannedRequest.quote.hold.hold_usd).toBe("0.06"); + }); + + test("files replace the stored set; clearing an unbound program's files sends nothing", async () => { + const sent = await job(["contract", "attach", TID, REF, "--file", "a.md", "--clear-files", "--yes"], { files: { "a.md": "a" } }); + expect(sent.exit).toBe(2); + const unbound = await job(["contract", "attach", TID, REF, "--clear-files", "--yes"], { + fixture: fixture([webhook("test"), listing([]), post({ program_ref: REF })]), + }); + expect(unbound.exit).toBe(0); + const older = await job(["contract", "attach", TID, REF, "--file", "a.md", "--yes"], { + files: { "a.md": "hi\n" }, human: true, + fixture: fixture([webhook("test"), listing([bound({})]), + post({ program_ref: REF, repository_url: "https://github.com/exowner1/app", repository_branch: "main", inputs: { keep: "1", count: 3 }, output: { type: "commit", repository: "https://github.com/exowner1/app" }, files: { "a.md": "aGkK" } })]), + }); + expect(older.exit).toBe(0); + expect(older.stdout).toContain(" settings: repository https://github.com/exowner1/app@main; commit output https://github.com/exowner1/app; inputs count, keep; files a.md\n"); + }); + + test("--replace onto another program that is already bound is refused before the binding is sent", async () => { + const other = "exowner1/probe@fedcba9876543210"; + const result = await job(["contract", "attach", TID, other, "--replace=" + REF, "--yes"], { + fixture: fixture([webhook("test"), listing([bound({}), bound({ program_ref: other })])]), + }); + expect(result.exit).toBe(2); + expect(result.report!.problem.details.reason).toBe(`${other} is already bound to job ${TID}; --replace would reset its settings. Change it in place without --replace, or detach ${REF} first`); + expect(result.report!.problem.details.suggestedArgv).toEqual(["--output", "json", "cli", "job", "contract", "attach", TID, other, "--yes"]); + }); + + test("a move to another revision is a full replace: saved settings and environment are sent, files are noted", async () => { + const next = "exowner1/probe@fedcba9876543210"; + const result = await job(["contract", "attach", TID, next, "--replace", REF, "--yes"], { + human: true, + fixture: fixture([webhook("test"), listing([bound({ environment: "linux", files: [{ name: "a.md", size: 1 }] })]), + { method: "POST", path: contractsPath, status: 201, body: { bound: next }, expectedBody: { + program_ref: next, replace_program_ref: REF, environment: "linux", repository_url: "https://github.com/exowner1/app", repository_branch: "main", + inputs: { keep: "1", count: 3 }, output: { type: "commit", repository: "https://github.com/exowner1/app" }, + } }]), + }); + expect(result.exit).toBe(0); + expect(result.stderr).toBe("note: stored files are not carried to the new revision; pass --file to attach them\n"); + }); + + test("an object-valued saved input is SERVICE_PROTOCOL_INVALID and nothing is sent", async () => { + const result = await job(["contract", "attach", TID, REF, "--yes"], { fixture: fixture([listing([bound({ environment: null, inputs: { a: [1] } })])]) }); + expect(result.exit).toBe(10); + expect(result.report!.problem.details.reason).toBe("unexpected job response: contracts[0].inputs.a"); + }); + + test("the live hint is added only for an immutable-configuration refusal", async () => { + const result = await job(["contract", "attach", TID, REF, "--environment", "linux", "--yes"], { + fixture: fixture([webhook("live"), listing([]), post({ program_ref: REF, environment: "linux" }, 400, { error: "environment is not offered" })]), + }); + expect(result.exit).toBe(10); + expect(result.report!.problem.details).not.toHaveProperty("suggestedStdin"); + }); +}); diff --git a/cli/conformance/cases/service/framework/help-json-group-records.json b/cli/conformance/cases/service/framework/help-json-group-records.json index a20cacd3..3091cd94 100644 --- a/cli/conformance/cases/service/framework/help-json-group-records.json +++ b/cli/conformance/cases/service/framework/help-json-group-records.json @@ -74,21 +74,21 @@ "mutation": false, "options": [ { - "description": "Run a saved program; a bare SLUG is your own program. @REV is a rev_id, or @N (@revN) for revision N of your own program; without @REV the service prices the latest revision. The service prices this program, including its own run settings and declared tools.", + "description": "Run a saved program; a bare SLUG is your own program. @REV is a rev_id, or @N (@revN) for revision N of your own program; without @REV the service prices the latest revision.", "name": "--from", "repeatable": false, "required": false, "value": "[OWNER/]SLUG[@REV]" }, { - "description": "Program input; @FILE reads UTF-8 text of at most 1 MiB. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it.", + "description": "Program input; @FILE reads UTF-8 text of at most 1 MiB. Accepted as `cli run submit` takes it; not sent.", "name": "--input", "repeatable": true, "required": false, "value": "KEY=VALUE|KEY=@FILE" }, { - "description": "JSON object of string inputs. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it.", + "description": "JSON object of string inputs. Accepted as `cli run submit` takes it; not sent.", "name": "--inputs-file", "repeatable": false, "required": false, @@ -126,7 +126,7 @@ }, { "default": "the service's default runtime", - "description": "Runtime offered by the service. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it.", + "description": "Runtime offered by the service. Accepted as `cli run submit` takes it; not sent.", "name": "--runtime", "repeatable": false, "required": false, @@ -153,7 +153,7 @@ "stream": false }, "preview": false, - "summary": "Report the hold a run reserves: money set aside from the wallet while the run is live. It is not the price. The environment is checked first.\nThe hold depends on the model, reasoning effort, environment, declared tools and bound repositories, and what the run does not use is released when it settles. With FILE or --from the service prices that program, including its own run settings and declared tools; --model, --reasoning-effort, --environment, --repo and --commit-output override them. `basis` names where each value came from (request, program or default). A run's price is known only after it settles (`cli run show RUN_ID`, price_cents). The program, inputs and options `cli run submit` takes are accepted, so you can quote the exact command you will submit." + "summary": "Report the hold a run reserves: money set aside from the wallet while the run is live. It is not the price. The environment is checked first.\nThe hold depends on the model, reasoning effort, environment, declared tools and bound repositories; what the run does not use is released when it settles. With FILE or --from the service prices that program, including its own run settings and declared tools; the run-setting options override them. `basis` names where each value came from (request, program or a default). A run's price is known only after it settles (`cli run show RUN_ID`, price_cents). It takes what `cli run submit` takes, so you can quote the exact command you will submit." }, { "arguments": [ diff --git a/cli/conformance/cases/service/framework/service-capabilities-json.json b/cli/conformance/cases/service/framework/service-capabilities-json.json index fb1f9219..6b19122f 100644 --- a/cli/conformance/cases/service/framework/service-capabilities-json.json +++ b/cli/conformance/cases/service/framework/service-capabilities-json.json @@ -203,7 +203,7 @@ "--json" ], "schema": "openprose.service-operations/1", - "sha256": "87e68b5f8f2e0125f7f6afc548a2d888bdd9c645261c59935fd4e3e7dc388bbb" + "sha256": "ff313c6f91b301039bb3688ad59464da1c10c5a6a13abff1d20b73550501da0b" }, "nouns": { "auth": [ @@ -442,7 +442,7 @@ "prose cli run quote hello.prose.md --input topic=cats --json" ], "id": "run.quote", - "summary": "Report the hold a run reserves: money set aside from the wallet while the run is live. It is not the price. The environment is checked first.\nThe hold depends on the model, reasoning effort, environment, declared tools and bound repositories, and what the run does not use is released when it settles. With FILE or --from the service prices that program, including its own run settings and declared tools; --model, --reasoning-effort, --environment, --repo and --commit-output override them. `basis` names where each value came from (request, program or default). A run's price is known only after it settles (`cli run show RUN_ID`, price_cents). The program, inputs and options `cli run submit` takes are accepted, so you can quote the exact command you will submit." + "summary": "Report the hold a run reserves: money set aside from the wallet while the run is live. It is not the price. The environment is checked first.\nThe hold depends on the model, reasoning effort, environment, declared tools and bound repositories; what the run does not use is released when it settles. With FILE or --from the service prices that program, including its own run settings and declared tools; the run-setting options override them. `basis` names where each value came from (request, program or a default). A run's price is known only after it settles (`cli run show RUN_ID`, price_cents). It takes what `cli run submit` takes, so you can quote the exact command you will submit." }, { "command": [ @@ -860,10 +860,11 @@ "confirm": true, "effect": "write", "examples": [ - "prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes" + "prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes", + "prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --repo exowner1/app@main --reasoning-effort medium --yes" ], "id": "job.contract.attach", - "summary": "Attach a pinned program to a job." + "summary": "Attach a pinned program to a job. The options set a webhook job's run settings; re-attaching a bound program changes only those given." }, { "command": [ diff --git a/cli/conformance/cases/service/framework/service-capabilities-jsonl.json b/cli/conformance/cases/service/framework/service-capabilities-jsonl.json index 7906de27..31fd9a3f 100644 --- a/cli/conformance/cases/service/framework/service-capabilities-jsonl.json +++ b/cli/conformance/cases/service/framework/service-capabilities-jsonl.json @@ -209,7 +209,7 @@ "--json" ], "schema": "openprose.service-operations/1", - "sha256": "87e68b5f8f2e0125f7f6afc548a2d888bdd9c645261c59935fd4e3e7dc388bbb" + "sha256": "ff313c6f91b301039bb3688ad59464da1c10c5a6a13abff1d20b73550501da0b" }, "nouns": { "auth": [ @@ -448,7 +448,7 @@ "prose cli run quote hello.prose.md --input topic=cats --json" ], "id": "run.quote", - "summary": "Report the hold a run reserves: money set aside from the wallet while the run is live. It is not the price. The environment is checked first.\nThe hold depends on the model, reasoning effort, environment, declared tools and bound repositories, and what the run does not use is released when it settles. With FILE or --from the service prices that program, including its own run settings and declared tools; --model, --reasoning-effort, --environment, --repo and --commit-output override them. `basis` names where each value came from (request, program or default). A run's price is known only after it settles (`cli run show RUN_ID`, price_cents). The program, inputs and options `cli run submit` takes are accepted, so you can quote the exact command you will submit." + "summary": "Report the hold a run reserves: money set aside from the wallet while the run is live. It is not the price. The environment is checked first.\nThe hold depends on the model, reasoning effort, environment, declared tools and bound repositories; what the run does not use is released when it settles. With FILE or --from the service prices that program, including its own run settings and declared tools; the run-setting options override them. `basis` names where each value came from (request, program or a default). A run's price is known only after it settles (`cli run show RUN_ID`, price_cents). It takes what `cli run submit` takes, so you can quote the exact command you will submit." }, { "command": [ @@ -866,10 +866,11 @@ "confirm": true, "effect": "write", "examples": [ - "prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes" + "prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes", + "prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --repo exowner1/app@main --reasoning-effort medium --yes" ], "id": "job.contract.attach", - "summary": "Attach a pinned program to a job." + "summary": "Attach a pinned program to a job. The options set a webhook job's run settings; re-attaching a bound program changes only those given." }, { "command": [ diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-clear-all-inputs-server-merge.json b/cli/conformance/cases/service/jobs/job-contract-attach-clear-all-inputs-server-merge.json new file mode 100644 index 00000000..b41fb438 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-clear-all-inputs-server-merge.json @@ -0,0 +1,166 @@ +{ + "id": "job-contract-attach-clear-all-inputs-server-merge", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "Clearing every saved input on a merging service sends an empty inputs object; leaving it out would keep them.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--clear-input", + "cost_center", + "--clear-input", + "channel", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "replace_program_ref": "exowner1/jobs-example@0123456789abcdef", + "inputs": {} + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + } + ] + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-clear-input-invalid.json b/cli/conformance/cases/service/jobs/job-contract-attach-clear-input-invalid.json new file mode 100644 index 00000000..64954879 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-clear-input-invalid.json @@ -0,0 +1,60 @@ +{ + "id": "job-contract-attach-clear-input-invalid", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "An invalid --clear-input name is refused before any request.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--clear-input", + "bad\tname", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Correct the value named in Detail; `prose --output json cli job contract attach --help` shows the accepted syntax.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "--clear-input \"bad\\tname\" must be an input name of 1 to 128 characters without control characters", + "suggestedArgv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "--help" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-clear-repo-client-merge.json b/cli/conformance/cases/service/jobs/job-contract-attach-clear-repo-client-merge.json new file mode 100644 index 00000000..71b4fcb8 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-clear-repo-client-merge.json @@ -0,0 +1,159 @@ +{ + "id": "job-contract-attach-clear-repo-client-merge", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "On a service that does not report environment, --clear-repo leaves the repository and commit output out of the full binding.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--clear-repo", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "model": "model-luna", + "reasoning_effort": "low", + "inputs": { + "channel": "C0123", + "cost_center": "ops" + } + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + } + ] + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-commit-output-mismatch.json b/cli/conformance/cases/service/jobs/job-contract-attach-commit-output-mismatch.json new file mode 100644 index 00000000..d24d3329 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-commit-output-mismatch.json @@ -0,0 +1,62 @@ +{ + "id": "job-contract-attach-commit-output-mismatch", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "--commit-output must be the repository the runs read; another repository is refused before the binding is sent.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--repo", + "exowner1/app", + "--commit-output", + "exowner1/other", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Correct the value named in Detail; `prose --output json cli job contract attach --help` shows the accepted syntax.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "--commit-output exowner1/other must also be given as --repo exowner1/other[@BRANCH]; the service commits only to a context repository", + "suggestedArgv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "--help" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-commit-output-uses-repo-url.json b/cli/conformance/cases/service/jobs/job-contract-attach-commit-output-uses-repo-url.json new file mode 100644 index 00000000..40f0024d --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-commit-output-uses-repo-url.json @@ -0,0 +1,143 @@ +{ + "id": "job-contract-attach-commit-output-uses-repo-url", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "The commit output names the context repository's URL, whatever case --commit-output was typed in.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--model", + "model-sol", + "--reasoning-effort", + "medium", + "--repo", + "exowner1/App@main", + "--commit-output", + "EXOWNER1/app", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "model": "model-sol", + "reasoning_effort": "medium", + "repository_url": "https://github.com/exowner1/App", + "repository_branch": "main", + "output": { + "type": "commit", + "repository": "https://github.com/exowner1/App" + } + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + } + ] + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-conflicting-clear.json b/cli/conformance/cases/service/jobs/job-contract-attach-conflicting-clear.json new file mode 100644 index 00000000..f4facf01 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-conflicting-clear.json @@ -0,0 +1,61 @@ +{ + "id": "job-contract-attach-conflicting-clear", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "--repo and --clear-repo together are refused before any request.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--repo", + "exowner1/app", + "--clear-repo", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Correct the value named in Detail; `prose --output json cli job contract attach --help` shows the accepted syntax.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "--repo and --clear-repo cannot be combined: --repo replaces the saved repository, --clear-repo removes it", + "suggestedArgv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "--help" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-conflicting-commit-output.json b/cli/conformance/cases/service/jobs/job-contract-attach-conflicting-commit-output.json new file mode 100644 index 00000000..85b254bb --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-conflicting-commit-output.json @@ -0,0 +1,61 @@ +{ + "id": "job-contract-attach-conflicting-commit-output", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "--commit-output and --clear-repo together are refused before any request.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--commit-output", + "exowner1/app", + "--clear-repo", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Correct the value named in Detail; `prose --output json cli job contract attach --help` shows the accepted syntax.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "--commit-output and --clear-repo cannot be combined: --commit-output sets the commit output, --clear-repo removes it", + "suggestedArgv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "--help" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-conflicting-files.json b/cli/conformance/cases/service/jobs/job-contract-attach-conflicting-files.json new file mode 100644 index 00000000..1f6dd78e --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-conflicting-files.json @@ -0,0 +1,67 @@ +{ + "id": "job-contract-attach-conflicting-files", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "--file and --clear-files together are refused before any request.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--file", + "notes.md", + "--clear-files", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Correct the value named in Detail; `prose --output json cli job contract attach --help` shows the accepted syntax.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "--file and --clear-files cannot be combined: --file replaces the stored files, --clear-files removes them", + "suggestedArgv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "--help" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + }, + "files": [ + { + "path": "notes.md", + "content": "Reply in the house style.\n" + } + ] +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-human.json b/cli/conformance/cases/service/jobs/job-contract-attach-human.json index efe6278e..909de8b7 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach-human.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach-human.json @@ -19,6 +19,18 @@ }, "storeAvailable": true, "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, { "method": "POST", "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-input-clear-input-conflict.json b/cli/conformance/cases/service/jobs/job-contract-attach-input-clear-input-conflict.json new file mode 100644 index 00000000..b6146d1d --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-input-clear-input-conflict.json @@ -0,0 +1,62 @@ +{ + "id": "job-contract-attach-input-clear-input-conflict", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "--input and --clear-input on the same key are refused before any request.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--input", + "topic=cats", + "--clear-input", + "topic", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Correct the value named in Detail; `prose --output json cli job contract attach --help` shows the accepted syntax.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "--input \"topic\" and --clear-input \"topic\" cannot be combined: --input sets the input, --clear-input removes it", + "suggestedArgv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "--help" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-model-refused.json b/cli/conformance/cases/service/jobs/job-contract-attach-model-refused.json index 496e40b0..96b5a376 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach-model-refused.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach-model-refused.json @@ -2,7 +2,7 @@ "id": "job-contract-attach-model-refused", "feature": "jobs", "operation": "job.contract.attach", - "description": "--model is refused because the service ignores a model on attach.", + "description": "--model applies to webhook jobs only; for another job type it is refused before the binding is sent.", "argv": [ "--output", "json", @@ -22,7 +22,62 @@ "production": "rr_test_0123456789abcdef0123456789abcdef" }, "storeAvailable": true, - "exchanges": [] + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "email", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + } + ] }, "exitCode": 2, "stdout": { @@ -30,11 +85,11 @@ "interaction": "job.contracts", "operation": "job.contract.attach", "problem": { - "action": "Correct the value named in Detail; `prose --output json cli job contract attach --help` shows the accepted syntax.", + "action": "Attach without those options: `prose --output json cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/jobs-example@0123456789abcdef --yes`.", "boundary": "invocation", "code": "INVOCATION_INVALID", "details": { - "reason": "the service does not accept --model when attaching a contract; attached contracts run on the service's default job model (see `cli job contract list`)", + "reason": "job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 is a email job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace and the --clear options) apply to webhook jobs only", "suggestedArgv": [ "--output", "json", @@ -42,7 +97,9 @@ "job", "contract", "attach", - "--help" + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--yes" ] }, "exitCode": 2, @@ -53,5 +110,8 @@ "result": null, "schema": "openprose.service-operation/1" } + }, + "stderr": { + "text": "" } } diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-numeric-rev.json b/cli/conformance/cases/service/jobs/job-contract-attach-numeric-rev.json index 2134b0c0..1eacef37 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach-numeric-rev.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach-numeric-rev.json @@ -54,5 +54,8 @@ }, "forbid": [ "rr_test_0123456789abcdef0123456789abcdef" - ] + ], + "stderr": { + "text": "" + } } diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-object-input-protocol-invalid.json b/cli/conformance/cases/service/jobs/job-contract-attach-object-input-protocol-invalid.json new file mode 100644 index 00000000..065c2a37 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-object-input-protocol-invalid.json @@ -0,0 +1,152 @@ +{ + "id": "job-contract-attach-object-input-protocol-invalid", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "A saved input that is an object is not a valid listing: SERVICE_PROTOCOL_INVALID, and nothing is sent.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--reasoning-effort", + "high", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": { + "id": "C0123" + } + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + } + ] + }, + "exitCode": 10, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Retry later and report the sanitized error code if it persists.", + "boundary": "protocol", + "code": "SERVICE_PROTOCOL_INVALID", + "details": { + "reason": "unexpected job response: contracts[0].inputs.channel" + }, + "exitCode": 10, + "message": "The OpenProse service returned an invalid response.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-plain-bound-server-merge.json b/cli/conformance/cases/service/jobs/job-contract-attach-plain-bound-server-merge.json new file mode 100644 index 00000000..81c2dbe2 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-plain-bound-server-merge.json @@ -0,0 +1,107 @@ +{ + "id": "job-contract-attach-plain-bound-server-merge", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "A plain attach of a bound program on a merging service re-binds the same ref and changes nothing.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "replace_program_ref": "exowner1/jobs-example@0123456789abcdef" + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + } + ] + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-preview-quote-github.json b/cli/conformance/cases/service/jobs/job-contract-attach-preview-quote-github.json new file mode 100644 index 00000000..8a76d64f --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-preview-quote-github.json @@ -0,0 +1,148 @@ +{ + "id": "job-contract-attach-preview-quote-github", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "A GitHub event job always binds its repository, so the attach plan's quote sends repositories=1.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--preview" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "github-pull-request-opened", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/run/quote", + "query": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "repositories": "1", + "job_type": "github-pull-request-opened" + }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "status": 200, + "body": { + "hold": { + "hold_usd": "0.06", + "ttl_seconds": 900 + }, + "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", + "note": "Unused hold is released when the run settles." + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "plannedRequest": { + "bodyBytes": 56, + "bodySha256": "14df8de6bc74931c03dd47b4e7ffe9ae69caa0a9b478a7dd0c20be6db0470695", + "description": "Attach a pinned program to a job.", + "effect": "write", + "method": "POST", + "operation": "job.contract.attach", + "quote": { + "hold": { + "hold_cents": 6, + "hold_usd": "0.06", + "ttl_seconds": 900 + } + }, + "summary": { + "programRef": "exowner1/jobs-example@0123456789abcdef" + } + }, + "preview": true + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-preview-quote-job-type.json b/cli/conformance/cases/service/jobs/job-contract-attach-preview-quote-job-type.json new file mode 100644 index 00000000..37377093 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-preview-quote-job-type.json @@ -0,0 +1,147 @@ +{ + "id": "job-contract-attach-preview-quote-job-type", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "A plain attach plan reads the job's type so the quote prices the model a job of that type runs on.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--preview" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "email", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/run/quote", + "query": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "job_type": "email" + }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "status": 200, + "body": { + "hold": { + "hold_usd": "0.06", + "ttl_seconds": 900 + }, + "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", + "note": "Unused hold is released when the run settles." + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "plannedRequest": { + "bodyBytes": 56, + "bodySha256": "14df8de6bc74931c03dd47b4e7ffe9ae69caa0a9b478a7dd0c20be6db0470695", + "description": "Attach a pinned program to a job.", + "effect": "write", + "method": "POST", + "operation": "job.contract.attach", + "quote": { + "hold": { + "hold_cents": 6, + "hold_usd": "0.06", + "ttl_seconds": 900 + } + }, + "summary": { + "programRef": "exowner1/jobs-example@0123456789abcdef" + } + }, + "preview": true + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-preview-quote-unavailable.json b/cli/conformance/cases/service/jobs/job-contract-attach-preview-quote-unavailable.json new file mode 100644 index 00000000..248d264c --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-preview-quote-unavailable.json @@ -0,0 +1,135 @@ +{ + "id": "job-contract-attach-preview-quote-unavailable", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "The attach plan's quote is advisory: a failed quote leaves the plan without one.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--preview" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "email", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/run/quote", + "query": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "job_type": "email" + }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "status": 500, + "body": { + "error_code": 1101 + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "plannedRequest": { + "bodyBytes": 56, + "bodySha256": "14df8de6bc74931c03dd47b4e7ffe9ae69caa0a9b478a7dd0c20be6db0470695", + "description": "Attach a pinned program to a job.", + "effect": "write", + "method": "POST", + "operation": "job.contract.attach", + "summary": { + "programRef": "exowner1/jobs-example@0123456789abcdef" + } + }, + "preview": true + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-replace-onto-bound-refused.json b/cli/conformance/cases/service/jobs/job-contract-attach-replace-onto-bound-refused.json new file mode 100644 index 00000000..0104b716 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-replace-onto-bound-refused.json @@ -0,0 +1,194 @@ +{ + "id": "job-contract-attach-replace-onto-bound-refused", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "--replace onto a program that is already bound is refused before the binding is sent: it would reset that binding's settings. Change the bound program in place instead.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@fedcba9876543210", + "--replace", + "exowner1/jobs-example@0123456789abcdef", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + }, + { + "program_ref": "exowner1/jobs-example@fedcba9876543210", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "fedcba9876543210", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + } + ] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Change it in place without --replace: `prose --output json cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/jobs-example@fedcba9876543210 --yes`.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "exowner1/jobs-example@fedcba9876543210 is already bound to job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10; --replace would reset its settings. Change it in place without --replace, or detach exowner1/jobs-example@0123456789abcdef first", + "suggestedArgv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@fedcba9876543210", + "--yes" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-schedule-rejected.json b/cli/conformance/cases/service/jobs/job-contract-attach-schedule-rejected.json index 8db7d6f9..dda83811 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach-schedule-rejected.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach-schedule-rejected.json @@ -21,6 +21,18 @@ }, "storeAvailable": true, "exchanges": [ + { + "method": "GET", + "path": "/triggers/7b2e4d19-6a3c-4e85-b1f0-9c4d8e2a6b73/contracts", + "status": 200, + "body": { + "contracts": [], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, { "method": "POST", "path": "/triggers/7b2e4d19-6a3c-4e85-b1f0-9c4d8e2a6b73/contracts", @@ -55,5 +67,8 @@ "result": null, "schema": "openprose.service-operation/1" } + }, + "stderr": { + "text": "" } } diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-settings-non-webhook-refused.json b/cli/conformance/cases/service/jobs/job-contract-attach-settings-non-webhook-refused.json new file mode 100644 index 00000000..50fd8ff1 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-settings-non-webhook-refused.json @@ -0,0 +1,117 @@ +{ + "id": "job-contract-attach-settings-non-webhook-refused", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "Run settings apply to webhook jobs only; for another job type they are refused before the binding is sent.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--reasoning-effort", + "high", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "email", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + } + ] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Attach without those options: `prose --output json cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/jobs-example@0123456789abcdef --yes`.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 is a email job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace and the --clear options) apply to webhook jobs only", + "suggestedArgv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--yes" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-unpinned.json b/cli/conformance/cases/service/jobs/job-contract-attach-unpinned.json index fe27d026..73cc8a54 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach-unpinned.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach-unpinned.json @@ -51,5 +51,8 @@ "result": null, "schema": "openprose.service-operation/1" } + }, + "stderr": { + "text": "" } } diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-clear-files-server-merge.json b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-clear-files-server-merge.json new file mode 100644 index 00000000..ba77241b --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-clear-files-server-merge.json @@ -0,0 +1,163 @@ +{ + "id": "job-contract-attach-webhook-clear-files-server-merge", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "--clear-files on a merging service sends files: null.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--clear-files", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "replace_program_ref": "exowner1/jobs-example@0123456789abcdef", + "files": null + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + } + ] + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-clear-repo-server-merge.json b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-clear-repo-server-merge.json new file mode 100644 index 00000000..a01010a6 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-clear-repo-server-merge.json @@ -0,0 +1,168 @@ +{ + "id": "job-contract-attach-webhook-clear-repo-server-merge", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "--clear-repo on a merging service sends explicit nulls for the repository and the commit output.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--clear-repo", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": { + "type": "commit", + "repository": "https://github.com/exowner1/app" + }, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "replace_program_ref": "exowner1/jobs-example@0123456789abcdef", + "repository_url": null, + "repository_branch": null, + "output": null + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + } + ] + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-files-server-merge.json b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-files-server-merge.json new file mode 100644 index 00000000..e2288f49 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-files-server-merge.json @@ -0,0 +1,179 @@ +{ + "id": "job-contract-attach-webhook-files-server-merge", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "--file replaces the binding's stored files: the files are sent as a name to base64 map, and the service keeps the other settings.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--file", + "notes.md", + "--file", + "style=guide.txt", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "replace_program_ref": "exowner1/jobs-example@0123456789abcdef", + "files": { + "notes.md": "UmVwbHkgaW4gdGhlIGhvdXNlIHN0eWxlLgo=", + "style": "QmUgYnJpZWYuCg==" + } + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + } + ] + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + }, + "files": [ + { + "path": "notes.md", + "content": "Reply in the house style.\n" + }, + { + "path": "guide.txt", + "content": "Be brief.\n" + } + ] +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-human.json b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-human.json new file mode 100644 index 00000000..7b4f7e9f --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-human.json @@ -0,0 +1,125 @@ +{ + "id": "job-contract-attach-webhook-human", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "Human output names the settings that were sent.", + "argv": [ + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--model", + "model-sol", + "--repo", + "exowner1/app@main", + "--input", + "topic=cats", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "model": "model-sol", + "repository_url": "https://github.com/exowner1/app", + "repository_branch": "main", + "inputs": { + "topic": "cats" + } + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "text": "Attached exowner1/jobs-example@0123456789abcdef to job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10.\n settings: repository https://github.com/exowner1/app@main; inputs topic\nList the job's contracts with `cli job contract list 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10`.\n" + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-inputs-server-merge.json b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-inputs-server-merge.json new file mode 100644 index 00000000..a6dff3fb --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-inputs-server-merge.json @@ -0,0 +1,169 @@ +{ + "id": "job-contract-attach-webhook-inputs-server-merge", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "Input changes send the full resulting inputs: saved, plus given keys, minus cleared keys.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--input", + "topic=cats", + "--clear-input", + "channel", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "replace_program_ref": "exowner1/jobs-example@0123456789abcdef", + "inputs": { + "cost_center": "ops", + "topic": "cats" + } + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + } + ] + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-preview-quote-human.json b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-preview-quote-human.json new file mode 100644 index 00000000..88146b4f --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-preview-quote-human.json @@ -0,0 +1,158 @@ +{ + "id": "job-contract-attach-webhook-preview-quote-human", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "The human attach preview shows the hold for the binding as it will run.", + "argv": [ + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--reasoning-effort", + "high", + "--preview" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/run/quote", + "query": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "model": "model-luna", + "reasoning_effort": "high", + "environment": "builtin", + "repositories": "1", + "job_type": "webhook" + }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "status": 200, + "body": { + "hold": { + "hold_usd": "0.06", + "ttl_seconds": 900 + }, + "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", + "note": "Unused hold is released when the run settles." + } + } + ] + }, + "exitCode": 0, + "stdout": { + "text": "Preview: Attach a pinned program to a job.\nSummary: program exowner1/jobs-example@0123456789abcdef; reasoning effort high\nEffect: attaches a program to the job\nHold: $0.06 (set aside from the wallet while the run is live; not its price)\nNothing was changed.\n" + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-preview-quote.json b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-preview-quote.json new file mode 100644 index 00000000..8b7383ac --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-preview-quote.json @@ -0,0 +1,187 @@ +{ + "id": "job-contract-attach-webhook-preview-quote", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "The attach plan quotes the binding as it will run: the program, the job's type, and the merged model, reasoning effort, environment and repository.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--reasoning-effort", + "high", + "--preview" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/run/quote", + "query": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "model": "model-luna", + "reasoning_effort": "high", + "environment": "builtin", + "repositories": "1", + "job_type": "webhook" + }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "status": 200, + "body": { + "hold": { + "hold_usd": "0.06", + "ttl_seconds": 900 + }, + "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", + "note": "Unused hold is released when the run settles." + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "plannedRequest": { + "bodyBytes": 145, + "bodySha256": "97307806f7b891a027187d7b273f988fa92dfa22189a6e8e93ff19465a914457", + "description": "Attach a pinned program to a job.", + "effect": "write", + "method": "POST", + "operation": "job.contract.attach", + "quote": { + "hold": { + "hold_cents": 6, + "hold_usd": "0.06", + "ttl_seconds": 900 + } + }, + "summary": { + "programRef": "exowner1/jobs-example@0123456789abcdef", + "reasoning_effort": "high" + } + }, + "preview": true + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-client-merge-human.json b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-client-merge-human.json new file mode 100644 index 00000000..c123a9ff --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-client-merge-human.json @@ -0,0 +1,148 @@ +{ + "id": "job-contract-attach-webhook-rebind-client-merge-human", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "The client-merge path warns that stored files and environment cannot be kept.", + "argv": [ + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--reasoning-effort", + "high", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "model": "model-luna", + "reasoning_effort": "high", + "repository_url": "https://github.com/exowner1/app", + "repository_branch": "main", + "inputs": { + "channel": "C0123", + "cost_center": "ops" + } + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "text": "Attached exowner1/jobs-example@0123456789abcdef to job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10.\n settings: reasoning effort high; repository https://github.com/exowner1/app@main; inputs channel, cost_center\nList the job's contracts with `cli job contract list 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10`.\n" + }, + "stderr": { + "text": "note: this service does not report stored files or environment; re-attaching may drop them\n" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-client-merge.json b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-client-merge.json new file mode 100644 index 00000000..97125623 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-client-merge.json @@ -0,0 +1,162 @@ +{ + "id": "job-contract-attach-webhook-rebind-client-merge", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "On a service that does not report environment, re-attaching merges the saved settings in the client and sends them in full.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--reasoning-effort", + "high", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "model": "model-luna", + "reasoning_effort": "high", + "repository_url": "https://github.com/exowner1/app", + "repository_branch": "main", + "inputs": { + "channel": "C0123", + "cost_center": "ops" + } + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + } + ] + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-server-merge.json b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-server-merge.json new file mode 100644 index 00000000..beb47598 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-server-merge.json @@ -0,0 +1,164 @@ +{ + "id": "job-contract-attach-webhook-rebind-server-merge", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "Re-attaching a bound program on a service that merges sends the same ref as replace_program_ref and only the changed setting.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--reasoning-effort", + "high", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "replace_program_ref": "exowner1/jobs-example@0123456789abcdef", + "reasoning_effort": "high" + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + } + ] + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-replace-human.json b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-replace-human.json new file mode 100644 index 00000000..77c2e93b --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-replace-human.json @@ -0,0 +1,158 @@ +{ + "id": "job-contract-attach-webhook-replace-human", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "Human --replace output notes that stored files are not carried to the new revision.", + "argv": [ + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@fedcba9876543210", + "--replace", + "exowner1/jobs-example@0123456789abcdef", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@fedcba9876543210", + "replace_program_ref": "exowner1/jobs-example@0123456789abcdef", + "model": "model-luna", + "reasoning_effort": "low", + "repository_url": "https://github.com/exowner1/app", + "repository_branch": "main", + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "environment": "builtin" + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@fedcba9876543210" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "text": "Attached exowner1/jobs-example@fedcba9876543210 to job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10.\n settings: reasoning effort low; repository https://github.com/exowner1/app@main; inputs channel, cost_center\nList the job's contracts with `cli job contract list 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10`.\n" + }, + "stderr": { + "text": "note: stored files are not carried to the new revision; pass --file to attach them\n" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-replace.json b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-replace.json new file mode 100644 index 00000000..e50397e0 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-replace.json @@ -0,0 +1,172 @@ +{ + "id": "job-contract-attach-webhook-replace", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "--replace moves a webhook binding to another revision: a full replace that carries the replaced binding's model, reasoning effort, repository, inputs and environment; stored files cannot be carried.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@fedcba9876543210", + "--replace", + "exowner1/jobs-example@0123456789abcdef", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@fedcba9876543210", + "replace_program_ref": "exowner1/jobs-example@0123456789abcdef", + "model": "model-luna", + "reasoning_effort": "low", + "repository_url": "https://github.com/exowner1/app", + "repository_branch": "main", + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "environment": "builtin" + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@fedcba9876543210" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@fedcba9876543210" + } + ] + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-settings-new.json b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-settings-new.json new file mode 100644 index 00000000..6ec163d8 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-settings-new.json @@ -0,0 +1,143 @@ +{ + "id": "job-contract-attach-webhook-settings-new", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "Attaching an unbound program to a webhook sends only the settings given: model, reasoning effort, a repository as context and commit output.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--model", + "model-sol", + "--reasoning-effort", + "medium", + "--repo", + "exowner1/app@main", + "--commit-output", + "exowner1/app", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "model": "model-sol", + "reasoning_effort": "medium", + "repository_url": "https://github.com/exowner1/app", + "repository_branch": "main", + "output": { + "type": "commit", + "repository": "https://github.com/exowner1/app" + } + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + } + ] + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach.json b/cli/conformance/cases/service/jobs/job-contract-attach.json index ea629c4e..43116b37 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach.json @@ -21,6 +21,18 @@ }, "storeAvailable": true, "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, { "method": "POST", "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", @@ -52,5 +64,8 @@ }, "schema": "openprose.service-operation/1" } + }, + "stderr": { + "text": "" } } diff --git a/cli/conformance/cases/service/jobs/job-contract-detach-confirmation-required.json b/cli/conformance/cases/service/jobs/job-contract-detach-confirmation-required.json index dd3bc4e3..379dc423 100644 --- a/cli/conformance/cases/service/jobs/job-contract-detach-confirmation-required.json +++ b/cli/conformance/cases/service/jobs/job-contract-detach-confirmation-required.json @@ -71,5 +71,8 @@ "result": null, "schema": "openprose.service-operation/1" } + }, + "stderr": { + "text": "" } } diff --git a/cli/conformance/cases/service/jobs/job-contract-detach-mismatch-protocol-invalid.json b/cli/conformance/cases/service/jobs/job-contract-detach-mismatch-protocol-invalid.json index d7be3545..1ca5b37c 100644 --- a/cli/conformance/cases/service/jobs/job-contract-detach-mismatch-protocol-invalid.json +++ b/cli/conformance/cases/service/jobs/job-contract-detach-mismatch-protocol-invalid.json @@ -54,5 +54,8 @@ "result": null, "schema": "openprose.service-operation/1" } + }, + "stderr": { + "text": "" } } diff --git a/cli/conformance/cases/service/jobs/job-contract-detach.json b/cli/conformance/cases/service/jobs/job-contract-detach.json index 06dd6f2e..4d3d9d26 100644 --- a/cli/conformance/cases/service/jobs/job-contract-detach.json +++ b/cli/conformance/cases/service/jobs/job-contract-detach.json @@ -49,5 +49,8 @@ }, "schema": "openprose.service-operation/1" } + }, + "stderr": { + "text": "" } } diff --git a/cli/conformance/cases/service/jobs/job-contract-list-not-found.json b/cli/conformance/cases/service/jobs/job-contract-list-not-found.json index 51682af1..ad9e3457 100644 --- a/cli/conformance/cases/service/jobs/job-contract-list-not-found.json +++ b/cli/conformance/cases/service/jobs/job-contract-list-not-found.json @@ -42,12 +42,12 @@ "boundary": "hosted-service", "code": "SERVICE_RESOURCE_NOT_FOUND", "details": { - "serviceStatus": 404, "reason": "job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 was not found", "resource": { "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", "kind": "job" }, + "serviceStatus": 404, "suggestedArgv": [ "--output", "json", @@ -64,5 +64,8 @@ "result": null, "schema": "openprose.service-operation/1" } + }, + "stderr": { + "text": "" } } diff --git a/cli/conformance/cases/service/jobs/job-contract-list-webhook-settings-human.json b/cli/conformance/cases/service/jobs/job-contract-list-webhook-settings-human.json new file mode 100644 index 00000000..21bd1746 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-list-webhook-settings-human.json @@ -0,0 +1,81 @@ +{ + "id": "job-contract-list-webhook-settings-human", + "feature": "jobs", + "operation": "job.contract.list", + "description": "Human contract list adds a settings line per binding; input values are never printed.", + "argv": [ + "cli", + "job", + "contract", + "list", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/slack-hello@97ddf3a447f534a7", + "owner": "exowner1", + "slug": "slack-hello", + "rev_id": "97ddf3a447f534a7", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops", + "limit": 3 + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "high", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": { + "type": "commit", + "repository": "https://github.com/exowner1/app" + }, + "environment": "linux", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "text": "exowner1/slack-hello@97ddf3a447f534a7 enabled=true model=model-luna\n settings: reasoning effort high; repository https://github.com/exowner1/app@main; commit output https://github.com/exowner1/app; inputs channel, cost_center, limit; files notes.md\n" + }, + "forbid": [ + "private program text" + ], + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-list-webhook-settings.json b/cli/conformance/cases/service/jobs/job-contract-list-webhook-settings.json new file mode 100644 index 00000000..3fc6d761 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-list-webhook-settings.json @@ -0,0 +1,135 @@ +{ + "id": "job-contract-list-webhook-settings", + "feature": "jobs", + "operation": "job.contract.list", + "description": "`job contract list` reports each binding's saved settings: effective model, revision, bind time, reasoning effort, inputs (money-rule names in input_entries), the context repository, commit output, environment and stored file metadata; never the program text or file content.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "list", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/slack-hello@97ddf3a447f534a7", + "owner": "exowner1", + "slug": "slack-hello", + "rev_id": "97ddf3a447f534a7", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops", + "limit": 3 + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "high", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": { + "type": "commit", + "repository": "https://github.com/exowner1/app" + }, + "environment": "linux", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.list", + "problem": null, + "result": { + "contracts": [ + { + "bound_at": 1789501241012, + "bound_at_iso": "2026-09-15T19:40:41.012Z", + "effective_model": "model-luna", + "enabled": true, + "is_platform_default": false, + "model": "model-luna", + "program_ref": "exowner1/slack-hello@97ddf3a447f534a7", + "program_slug": "slack-hello", + "rev_id": "97ddf3a447f534a7", + "run_configuration": { + "context_repositories": [ + { + "branch": "main", + "url": "https://github.com/exowner1/app" + } + ], + "environment": "linux", + "input_entries": [ + { + "name": "cost_center", + "value": "ops" + } + ], + "inputs": { + "channel": "C0123", + "limit": "3" + }, + "output": { + "repository": "https://github.com/exowner1/app", + "type": "commit" + }, + "reasoning_effort": "high", + "stored_files": [ + { + "name": "notes.md", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 12 + } + ] + } + } + ], + "max_contracts": 5 + }, + "schema": "openprose.service-operation/1" + } + }, + "forbid": [ + "private program text" + ], + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-list.json b/cli/conformance/cases/service/jobs/job-contract-list.json index 01a2fccf..7b566284 100644 --- a/cli/conformance/cases/service/jobs/job-contract-list.json +++ b/cli/conformance/cases/service/jobs/job-contract-list.json @@ -2,7 +2,7 @@ "id": "job-contract-list", "feature": "jobs", "operation": "job.contract.list", - "description": "Contracts project to the public snake_case contract fields (program_ref, program_slug, enabled, model); content and inputs are dropped.", + "description": "Contracts project to the public snake_case contract fields (program_ref, program_slug, enabled, model); content is dropped.", "argv": [ "--output", "json", @@ -57,10 +57,19 @@ "result": { "contracts": [ { + "bound_at": 1789501241012, + "bound_at_iso": "2026-09-15T19:40:41.012Z", "enabled": true, + "is_platform_default": false, "model": "model-luna", "program_ref": "exowner1/slack-hello@97ddf3a447f534a7", - "program_slug": "slack-hello" + "program_slug": "slack-hello", + "rev_id": "97ddf3a447f534a7", + "run_configuration": { + "inputs": { + "channel": "C0123" + } + } } ], "max_contracts": 5 @@ -70,5 +79,8 @@ }, "forbid": [ "private program text" - ] + ], + "stderr": { + "text": "" + } } diff --git a/cli/rust/crates/prose-runner-core/src/registry.rs b/cli/rust/crates/prose-runner-core/src/registry.rs index bf8a6029..336115fa 100644 --- a/cli/rust/crates/prose-runner-core/src/registry.rs +++ b/cli/rust/crates/prose-runner-core/src/registry.rs @@ -174,7 +174,7 @@ fn reference_matches(receipt: &Value, organization: &str, package: &str, version && receipt["reference"]["package"] == package && receipt["reference"]["version"] == version } -fn base64(bytes: &[u8]) -> String { +pub(crate) fn base64(bytes: &[u8]) -> String { const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4); for part in bytes.chunks(3) { 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 f14ae05a..19f90917 100644 --- a/cli/rust/crates/prose-runner-core/src/service/jobs.rs +++ b/cli/rust/crates/prose-runner-core/src/service/jobs.rs @@ -27,15 +27,22 @@ //! service's endpoint is the secret-free job-id webhook path, carry the //! absolute `endpoint_url` built from the environment origin. //! - `job contract attach|detach` take a pinned `OWNER/SLUG@REV`. +//! - `job contract attach` also sets a webhook binding's execution settings. +//! It reads the job (webhook jobs only) when a setting is given, and always +//! reads the bound contracts first: a re-attached binding keeps its saved +//! settings, by sending only the changes to a service that merges them +//! (one that lists `environment`) or the merged settings in full otherwise. //! //! Mirrors `cli/bun/src/core/service/jobs.ts`. use super::http::{Request, encode_segment}; use super::program_ref; use super::render::{absolute_url, add_iso, canonical, iso_ms, next_line, valid_text}; +use super::runs::{self, Repository}; use super::{Context, Environment, Gate}; use crate::RunnerError; use crate::error::{ErrorCode, human_safe_scalar}; use serde_json::{Map, Value, json}; +use std::collections::BTreeMap; use std::fmt::Write as _; /// Largest job spec or configuration file. @@ -603,6 +610,8 @@ enum Kind { ProgramRef, Slug, Hex64, + /// A program revision id: 16 lowercase hex digits. + RevId, } fn scalar(value: &Value, kind: Kind, field: &str) -> Result { @@ -644,6 +653,7 @@ fn scalar(value: &Value, kind: Kind, field: &str) -> Result Kind::ProgramRef => check(is_program_ref(text()?)), Kind::Slug => check(program_ref::valid_slug(text()?)), Kind::Hex64 => check(is_hex64(text()?)), + Kind::RevId => check(program_ref::valid_rev(text()?)), } } @@ -1149,23 +1159,55 @@ fn quote(context: &mut Context<'_>, spec: &Map) -> Result { + program_ref: Option<&'a str>, + model: Option<&'a str>, + reasoning_effort: Option<&'a str>, + environment: Option<&'a str>, + repositories_bound: bool, + job_type: Option<&'a str>, +} + +/// The `GET /run/quote` request (manifest request `index`) for `inputs`. +fn quote_request(context: &Context<'_>, index: usize, inputs: &HoldInputs<'_>) -> Request { + let mut request = Request::from_manifest(context.operation, index, "/run/quote"); + if let Some(program) = inputs.program_ref { request = request.query("program_ref", program.to_owned()); } let mut request = super::runs::hold_query( request, - text("model"), - text("reasoning_effort"), - text("environment"), - repositories_bound, + inputs.model, + inputs.reasoning_effort, + inputs.environment, + inputs.repositories_bound, ); // The job's type lets the service price the model a job of that type - // runs on when the spec names none. - if let Some(job_type) = text("type") { + // runs on when the plan names none. + if let Some(job_type) = inputs.job_type { request = request.query("job_type", job_type.to_owned()); } - let body = context.send(&request)?.json_object()?; + request +} + +/// Sends a plan quote and projects its `{hold}`. +fn send_quote(context: &mut Context<'_>, request: &Request) -> Result { + let body = context.send(request)?.json_object()?; let hold = object(body.get("hold").unwrap_or(&Value::Null), "quote.hold")?; let hold_usd = hold .get("hold_usd") @@ -1555,28 +1597,11 @@ fn contract_list(context: &mut Context<'_>) -> Result { 0, &format!("/triggers/{}/contracts", encode_segment(&id)), )?; - // The contract route names differ from the job record's; both project - // to the same public contract fields. - let mut contracts = Vec::new(); - for contract in array( - body.get("contracts").unwrap_or(&Value::Null), - 16, - "contracts", - )? { - let source = object(contract, "contracts")?; - let mut renamed = Map::new(); - for (from, to) in [ - ("program_ref", "programRef"), - ("slug", "programSlug"), - ("enabled", "enabled"), - ("model", "model"), - ] { - if let Some(value) = source.get(from) { - renamed.insert(to.to_owned(), value.clone()); - } - } - contracts.push(project_contract(&Value::Object(renamed), "contracts")?); - } + let contracts = listed_contracts(&body)? + .iter() + .enumerate() + .map(|(index, contract)| project_listed_contract(contract, index)) + .collect::, _>>()?; let mut result = Map::new(); result.insert("contracts".into(), Value::Array(contracts)); if let Some(max) = body.get("max_contracts") { @@ -1590,6 +1615,134 @@ fn contract_list(context: &mut Context<'_>) -> Result { Ok(result) } +/// The contract objects of a `GET /triggers/{id}/contracts` body. +fn listed_contracts(body: &Map) -> Result<&Vec, RunnerError> { + let contracts = array( + body.get("contracts").unwrap_or(&Value::Null), + 16, + "contracts", + )?; + if contracts.iter().any(|contract| !contract.is_object()) { + return Err(protocol("contracts")); + } + Ok(contracts) +} + +/// A stored input value as text: a string as it is, another JSON scalar +/// as its JSON text; an object or array is not an input. +fn input_text(value: &Value, field: &str) -> Result { + match value { + Value::String(_) => Ok(value.clone()), + Value::Object(_) | Value::Array(_) => Err(protocol(field)), + other => Ok(Value::String(other.to_string())), + } +} + +/// One contract of `GET /triggers/{id}/contracts`: the public contract +/// fields, and its saved execution settings as `run_configuration` (inputs +/// under the money rule, stored file metadata only). Program text and file +/// content are never projected. +fn project_listed_contract(contract: &Value, index: usize) -> Result { + let field = "contracts"; + let source = object(contract, field)?; + // The contract route names differ from the job record's; both project + // to the same public contract fields. + let mut renamed = Map::new(); + for (from, to) in [ + ("program_ref", "programRef"), + ("slug", "programSlug"), + ("enabled", "enabled"), + ("model", "model"), + ] { + if let Some(value) = source.get(from) { + renamed.insert(to.to_owned(), value.clone()); + } + } + let Value::Object(mut target) = project_contract(&Value::Object(renamed), field)? else { + return Err(protocol(field)); + }; + copy( + &mut target, + source, + field, + &[ + ("effective_model", Kind::ModelId, true), + ("rev_id", Kind::RevId, false), + ("bound_at", Kind::EpochMs, false), + ("is_platform_default", Kind::Bool, false), + ], + )?; + add_iso(&mut target, &["bound_at"]); + let present = |name: &str| source.get(name).filter(|value| !value.is_null()); + let mut configuration = Map::new(); + if let Some(effort) = present("reasoning_effort") { + configuration.insert("reasoning_effort".into(), effort.clone()); + } + if let Some(inputs) = present("inputs") { + let name = format!("{field}.inputs"); + let inputs = object(inputs, &name)?; + if !inputs.is_empty() { + let inputs = inputs + .iter() + .map(|(key, value)| { + let field = format!("contracts[{index}].inputs.{key}"); + Ok((key.clone(), input_text(value, &field)?)) + }) + .collect::, RunnerError>>()?; + configuration.insert("inputs".into(), Value::Object(inputs)); + } + } + if let Some(repositories) = present("repositories") { + if !array(repositories, 16, &format!("{field}.repositories"))?.is_empty() { + configuration.insert("contextRepositories".into(), repositories.clone()); + } + } + for name in ["environment", "output"] { + if let Some(value) = present(name) { + configuration.insert(name.into(), value.clone()); + } + } + let mut projected = if configuration.is_empty() { + Map::new() + } else { + match project_run_configuration( + &Value::Object(configuration), + &format!("{field}.run_configuration"), + )? { + Value::Object(projected) => projected, + _ => return Err(protocol(field)), + } + }; + if let Some(files) = present("files") { + let name = format!("{field}.files"); + let mut stored = Vec::new(); + for file in array(files, 64, &name)? { + let mut item = Map::new(); + copy( + &mut item, + object(file, &name)?, + &name, + &[ + ("name", Kind::Text(256), false), + ("size", Kind::EpochMs, false), + ("sha256", Kind::Hex64, false), + ], + )?; + if !item.contains_key("name") || !item.contains_key("size") { + return Err(protocol(&name)); + } + stored.push(Value::Object(item)); + } + if !stored.is_empty() { + projected.insert("stored_files".into(), Value::Array(stored)); + } + } + if !projected.is_empty() { + target.insert("run_configuration".into(), Value::Object(projected)); + } + Ok(Value::Object(target)) +} + /// Why a contract reference is not pinned, with the command that prints the /// `rev_id` for the program the caller named: `program show` /// resolves `@N` and prints the pinned reference as `ref`. @@ -1615,55 +1768,844 @@ fn unpinned_reason(context: &Context<'_>, value: &str) -> String { ) } -fn contract_change(context: &mut Context<'_>, attach: bool) -> Result { - let id = job_id(context)?; +/// The pinned `OWNER/SLUG@REV` argument of `job contract attach|detach`. +fn contract_reference(context: &Context<'_>) -> Result { let value = context.argument("OWNER/SLUG@REV").unwrap_or_default(); - let reference = program_ref::parse(value, true) + Ok(program_ref::parse(value, true) .map_err(|error| error.with_detail("reason", unpinned_reason(context, value)))? .pinned() - .unwrap_or_default(); - if attach && context.option("--model").is_some() { - return Err(invalid( - "the service does not accept --model when attaching a contract; attached contracts run on the service's default job model (see `cli job contract list`)", - )); - } - let (path, body) = if attach { - let body = serde_json::to_vec(&json!({"program_ref": reference})).expect("JSON serializes"); - ( - format!("/triggers/{}/contracts", encode_segment(&id)), - Some(body), - ) - } else { - ( - format!( - "/triggers/{}/contracts/{}", - encode_segment(&id), - encode_segment(&reference) - ), - None, - ) - }; - if let Gate::Preview(result) = context.gate(context.planned(0, &path, &[], body.as_deref()))? { + .unwrap_or_default()) +} + +fn contract_change(context: &mut Context<'_>, attach: bool) -> Result { + if attach { + return contract_attach(context); + } + let id = job_id(context)?; + let reference = contract_reference(context)?; + let path = format!( + "/triggers/{}/contracts/{}", + encode_segment(&id), + encode_segment(&reference) + ); + if let Gate::Preview(result) = context.gate(context.planned(0, &path, &[], None))? { return Ok(result); } - let mut request = Request::from_manifest(context.operation, 0, path); - request.body = body; + let request = Request::from_manifest(context.operation, 0, path); let response = context.send(&request)?.json_object()?; - let key = if attach { "bound" } else { "unbound" }; - if response.get(key).and_then(Value::as_str) != Some(reference.as_str()) { - return Err(protocol(key)); + if response.get("unbound").and_then(Value::as_str) != Some(reference.as_str()) { + return Err(protocol("unbound")); } context.human = Some(format!( - "{} {} {} job {}.\nList the job's contracts with `cli job contract list {}`.\n", - if attach { "Attached" } else { "Detached" }, + "Detached {} from job {}.\nList the job's contracts with `cli job contract list {}`.\n", human_safe_scalar(&reference), - if attach { "to" } else { "from" }, human_safe_scalar(&id), human_safe_scalar(&id), )); Ok(json!({"contracts": [{"program_ref": reference}]})) } +/// The value options of `job contract attach` that only a webhook binding +/// takes (any of them, or a flag below, reads the job first). +const BINDING_OPTIONS: [&str; 10] = [ + "--model", + "--reasoning-effort", + "--repo", + "--commit-output", + "--input", + "--inputs-file", + "--clear-input", + "--environment", + "--replace", + "--file", +]; +const BINDING_FLAGS: [&str; 3] = ["--clear-repo", "--clear-commit-output", "--clear-files"]; + +/// `--file` limits: files per binding, bytes per file and bytes in all. +const MAX_BINDING_FILES: usize = 20; +const MAX_BINDING_FILE_BYTES: u64 = 5 << 20; +const MAX_BINDING_FILES_BYTES: u64 = 10 << 20; + +/// The binding settings given to `job contract attach`, checked before any +/// request. +struct BindingOptions { + model: Option, + effort: Option, + repo: Option, + commit: Option, + clear_repo: bool, + clear_commit: bool, + /// `--inputs-file` and `--input`, when either is given. + inputs: Option>, + clear_inputs: Vec, + environment: Option, + replace: Option, + /// `--file`: each NAME with its content in base64, when given. + files: Option>, + clear_files: bool, +} + +/// `--file [NAME=]PATH` values: UTF-8 files by NAME (default the path's +/// basename), checked against the binding limits. +fn parse_binding_files(context: &Context<'_>) -> Result, RunnerError> { + let values = context.invocation.option_values("--file"); + if values.len() > MAX_BINDING_FILES { + return Err(invalid(format!( + "--file was given {} times; a binding stores at most {MAX_BINDING_FILES} files", + values.len() + ))); + } + let cwd = context.system.current_dir.clone(); + let mut files = BTreeMap::new(); + let mut total = 0_u64; + for value in values { + let (name, path) = if let Some((name, path)) = value.split_once('=') { + (name.to_owned(), path) + } else { + let base = std::path::Path::new(value) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + (base.to_owned(), value.as_str()) + }; + if !valid_text(&name, 256) || name.contains(['/', '\\']) { + return Err(invalid(format!( + "--file {value_quoted}: the file name must be 1 to 256 characters without /, \\ or control characters; give it as NAME=PATH", + value_quoted = crate::error::quote(value) + ))); + } + let bytes = super::fs::read_source(&cwd, path, MAX_BINDING_FILE_BYTES, "--file")?; + if std::str::from_utf8(&bytes).is_err() { + return Err(invalid(format!( + "--file {path_quoted} is not UTF-8 text", + path_quoted = crate::error::quote(path) + ))); + } + total += bytes.len() as u64; + if total > MAX_BINDING_FILES_BYTES { + return Err(invalid(format!( + "the --file files hold more than {MAX_BINDING_FILES_BYTES} bytes together; a binding stores at most that much" + ))); + } + if files + .insert(name.clone(), crate::registry::base64(&bytes)) + .is_some() + { + return Err(invalid(format!( + "--file name {name_quoted} was given more than once; name each file uniquely with NAME=PATH", + name_quoted = crate::error::quote(&name) + ))); + } + } + Ok(files) +} + +impl BindingOptions { + fn any(context: &Context<'_>) -> bool { + BINDING_OPTIONS + .iter() + .any(|option| !context.invocation.option_values(option).is_empty()) + || BINDING_FLAGS.iter().any(|flag| context.flag(flag)) + } + + /// Checked in a fixed order shared by both ports: conflicts, then + /// `--clear-input` names, inputs, values and finally `--file` reads. + fn parse(context: &Context<'_>) -> Result { + let clear_repo = context.flag("--clear-repo"); + let clear_commit = context.flag("--clear-commit-output"); + let clear_files = context.flag("--clear-files"); + let given = |option: &str| context.option(option).is_some(); + if given("--repo") && clear_repo { + return Err(invalid( + "--repo and --clear-repo cannot be combined: --repo replaces the saved repository, --clear-repo removes it", + )); + } + for (flag, set) in [ + ("--clear-repo", clear_repo), + ("--clear-commit-output", clear_commit), + ] { + if given("--commit-output") && set { + return Err(invalid(format!( + "--commit-output and {flag} cannot be combined: --commit-output sets the commit output, {flag} removes it" + ))); + } + } + if given("--file") && clear_files { + return Err(invalid( + "--file and --clear-files cannot be combined: --file replaces the stored files, --clear-files removes them", + )); + } + let clear_inputs = context.invocation.option_values("--clear-input").to_vec(); + for key in &clear_inputs { + if !runs::valid_input_key(key) { + return Err(invalid(format!( + "--clear-input {key_quoted} must be an input name of 1 to 128 characters without control characters", + key_quoted = crate::error::quote(key) + ))); + } + } + for key in &clear_inputs { + let set = context + .invocation + .option_values("--input") + .iter() + .any(|raw| raw.split_once('=').is_some_and(|(name, _)| name == key)); + if set { + return Err(invalid(format!( + "--input {key_quoted} and --clear-input {key_quoted} cannot be combined: --input sets the input, --clear-input removes it", + key_quoted = crate::error::quote(key) + ))); + } + } + let inputs = (given("--inputs-file") + || !context.invocation.option_values("--input").is_empty()) + .then(|| runs::parse_inputs(context)) + .transpose()?; + let model = context.option("--model").map(str::to_owned); + if let Some(model) = model.as_deref().filter(|model| !runs::valid_model(model)) { + return Err(invalid(format!( + "--model {model_quoted} is not a model id; list them with `{}`", + context.command("model list"), + model_quoted = crate::error::quote(model) + ))); + } + let repo = context + .option("--repo") + .map(|value| runs::parse_repository("--repo", value)) + .transpose()?; + let commit = context + .option("--commit-output") + .map(|value| runs::parse_repository("--commit-output", value)) + .transpose()?; + let environment = context.option("--environment").map(str::to_owned); + if let Some(environment) = environment + .as_deref() + .filter(|value| !runs::valid_token(value)) + { + return Err(invalid(format!( + "--environment {value_quoted} is not an environment id (lowercase letters, digits, _ and -)", + value_quoted = crate::error::quote(environment) + ))); + } + let replace = match context.option("--replace") { + Some(value) => Some( + program_ref::parse(value, true) + .map_err(|error| { + error.with_detail( + "reason", + format!("--replace: {}", unpinned_reason(context, value)), + ) + })? + .pinned() + .unwrap_or_default(), + ), + None => None, + }; + // Known locally when --repo is given; otherwise checked against the + // saved repository once the contracts are read. + if let (Some(commit), Some(repo)) = (&commit, &repo) { + if !same_url(&commit.url(), &repo.url()) { + return Err(runs::commit_output_mismatch(commit)); + } + } + let files = given("--file") + .then(|| parse_binding_files(context)) + .transpose()?; + Ok(Self { + model, + effort: context.option("--reasoning-effort").map(str::to_owned), + repo, + commit, + clear_repo, + clear_commit, + inputs, + clear_inputs, + environment, + replace, + files, + clear_files, + }) + } + + /// `files`: the given set (it replaces the stored one), or a JSON null + /// for `--clear-files` when `nulls` are allowed. + fn put_files(&self, body: &mut Map, nulls: bool) { + if let Some(files) = &self.files { + body.insert("files".into(), json!(files)); + } else if self.clear_files && nulls { + body.insert("files".into(), Value::Null); + } + } + + /// The inputs after these options: `saved`, then the given keys, minus + /// the cleared keys; `None` when the input options change nothing (none + /// given, or only an empty `--inputs-file`). + fn merged_inputs(&self, saved: Option<&Map>) -> Option> { + if self.inputs.as_ref().is_none_or(BTreeMap::is_empty) && self.clear_inputs.is_empty() { + return None; + } + let mut inputs = saved.cloned().unwrap_or_default(); + for (key, value) in self.inputs.iter().flatten() { + inputs.insert(key.clone(), json!(value)); + } + for key in &self.clear_inputs { + inputs.remove(key); + } + Some(inputs) + } +} + +/// Repository URLs name the same repository (GitHub names ignore case). +fn same_url(left: &str, right: &str) -> bool { + left.eq_ignore_ascii_case(right) +} + +/// `{type: "commit", repository, branch?}`: the repository is the effective +/// repository's URL as the binding stores it, the branch `--commit-output`'s. +fn commit_output(url: &str, commit: &Repository) -> Value { + let mut output = Map::new(); + output.insert("type".into(), json!("commit")); + output.insert("repository".into(), json!(url)); + if let Some(branch) = &commit.branch { + output.insert("branch".into(), json!(branch)); + } + Value::Object(output) +} + +/// This invocation's argv without the webhook-only binding options. +fn argv_without_binding_options(argv: &[String]) -> Vec { + argv_without(argv, &BINDING_OPTIONS, &BINDING_FLAGS) +} + +/// `argv` without the value `options` (and their values) and `flags`. +fn argv_without(argv: &[String], options: &[&str], flags: &[&str]) -> Vec { + let mut kept = Vec::new(); + let mut tokens = argv.iter(); + while let Some(token) = tokens.next() { + if token == "--" { + kept.push(token.clone()); + kept.extend(tokens.by_ref().cloned()); + break; + } + if flags.contains(&token.as_str()) { + continue; + } + if options.contains(&token.as_str()) { + tokens.next(); + continue; + } + let inline = token + .split_once('=') + .is_some_and(|(name, _)| options.contains(&name)); + if !inline { + kept.push(token.clone()); + } + } + kept +} + +/// The saved settings of one listed binding, as the service returned them. +#[derive(Default)] +struct SavedBinding { + model: Option, + effort: Option, + repository: Option<(Value, Option)>, + output: Option, + inputs: Option>, + environment: Option, + /// Whether the binding stores files (their content is never listed). + has_files: bool, +} + +impl SavedBinding { + /// A listed contract: `raw` as the service returned it (its inputs are + /// kept unchanged), `projected` its checked [`project_listed_contract`] + /// projection, from which the other settings are rebuilt. + fn of(raw: &Value, projected: &Value) -> Self { + let configuration = &projected["run_configuration"]; + let text = |value: &Value| value.as_str().map(|text| json!(text)); + let repository = configuration["context_repositories"] + .as_array() + .and_then(|repositories| repositories.first()) + .and_then(|repository| Some((text(&repository["url"])?, text(&repository["branch"])))); + let output = configuration["output"].as_object().map(|output| { + let mut kept = Map::new(); + for key in ["type", "repository", "branch"] { + if let Some(value) = output.get(key).and_then(text) { + kept.insert(key.into(), value); + } + } + Value::Object(kept) + }); + Self { + model: text(&projected["model"]), + effort: text(&configuration["reasoning_effort"]), + repository, + output, + inputs: raw["inputs"].as_object().cloned(), + environment: text(&configuration["environment"]), + has_files: configuration["stored_files"] + .as_array() + .is_some_and(|files| !files.is_empty()), + } + } + + fn output_repository(&self) -> Option<&str> { + self.output.as_ref()?["repository"].as_str() + } +} + +fn contract_attach(context: &mut Context<'_>) -> Result { + let id = job_id(context)?; + let reference = contract_reference(context)?; + let settings_given = BindingOptions::any(context); + let options = BindingOptions::parse(context)?; + let plan = context.invocation.preview || !context.invocation.yes; + // Request 0: binding settings apply to webhook jobs only, and a plan's + // quote prices the job's type. + let mut live = false; + let mut job_type = None; + if settings_given || plan { + let body = get_json(context, 0, &format!("/triggers/{}", encode_segment(&id)))?; + let detail = project_detail(&body, false, &context.environment)?; + job_type = detail["job"]["type"] + .as_str() + .filter(|kind| !kind.is_empty()) + .map(str::to_owned); + let kind = job_type.as_deref().unwrap_or_default(); + if settings_given && kind != "webhook" { + let error = invalid(format!( + "job {} is a {} job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace and the --clear options) apply to webhook jobs only", + human_safe_scalar(&id), + human_safe_scalar(kind) + )); + let argv = argv_without_binding_options(&context.invocation.argv); + return Err(context.corrected( + error, + "Attach without those options: `{command}`", + argv, + )); + } + live = detail["status"]["delivery_mode"] == "live"; + } + // Request 1: the bound programs and their saved settings. + let path = format!("/triggers/{}/contracts", encode_segment(&id)); + let listing = get_json(context, 1, &path)?; + let contracts = listed_contracts(&listing)?; + let projected = contracts + .iter() + .enumerate() + .map(|(index, contract)| project_listed_contract(contract, index)) + .collect::, _>>()?; + let merging = contracts + .iter() + .any(|contract| contract.get("environment").is_some()); + // --replace onto another program that is already bound would reset that + // binding's settings. + if let Some(replaced) = options + .replace + .as_deref() + .filter(|replaced| *replaced != reference) + { + if contracts + .iter() + .any(|contract| contract["program_ref"].as_str() == Some(reference.as_str())) + { + let error = invalid(format!( + "{reference} is already bound to job {}; --replace would reset its settings. Change it in place without --replace, or detach {replaced} first", + human_safe_scalar(&id) + )); + let argv = argv_without(&context.invocation.argv, &["--replace"], &[]); + return Err(context.corrected( + error, + "Change it in place without --replace: `{command}`", + argv, + )); + } + } + let base = options.replace.as_deref().unwrap_or(&reference); + let saved = contracts + .iter() + .zip(&projected) + .find(|(_, contract)| contract["program_ref"].as_str() == Some(base)) + .map(|(raw, contract)| SavedBinding::of(raw, contract)); + let mut body = Map::new(); + body.insert("program_ref".into(), json!(reference)); + // A --replace of the attached ref itself is an ordinary re-attach. + let moved = options + .replace + .as_deref() + .is_some_and(|replaced| replaced != reference); + let client_merge = saved.is_some() && (moved || !merging); + match &saved { + // A merging service keeps what it saved: send the same ref as + // replace_program_ref and only the changes. + Some(saved) if !client_merge => { + body.insert("replace_program_ref".into(), json!(reference)); + server_merge(&options, saved, &mut body)?; + } + Some(saved) => client_merge_body(&options, saved, moved, &mut body)?, + None => { + if let Some(commit) = &options.commit { + if options.repo.is_none() { + return Err(runs::commit_output_mismatch(commit)); + } + } + fresh_body(&options, &mut body); + } + } + if let Some(replace) = &options.replace { + body.insert("replace_program_ref".into(), json!(replace)); + } + let body = Value::Object(body); + let bytes = canonical(&body).into_bytes(); + let mut planned = context.planned(2, &path, &[], Some(&bytes)); + if plan { + // Advisory: a failed quote leaves the plan without one. + let server_saved = saved.as_ref().filter(|_| !client_merge); + let inputs = effective_hold(&body, server_saved, job_type.as_deref()); + let request = quote_request(context, 3, &inputs); + match send_quote(context, &request) { + Ok(quote) => planned["quote"] = quote, + Err(error) if error.code == ErrorCode::Cancelled => return Err(error), + Err(_) => {} + } + } + if let Gate::Preview(result) = context.gate(planned)? { + return Ok(result); + } + let mut request = Request::from_manifest(context.operation, 2, path); + request.body = Some(bytes); + let response = match context.send(&request) { + Err(error) if live && immutable_refusal(&error) => { + return Err(live_refusal(context, &id, error)); + } + other => other?.json_object()?, + }; + if response.get("bound").and_then(Value::as_str) != Some(reference.as_str()) { + return Err(protocol("bound")); + } + if context.mode == crate::OutputMode::Human { + if client_merge && !merging { + let _ = context.err.write_all(OLDER_SERVICE_NOTE.as_bytes()); + } else if moved + && options.files.is_none() + && !options.clear_files + && saved.as_ref().is_some_and(|saved| saved.has_files) + { + let _ = context.err.write_all(FILES_NOT_CARRIED_NOTE.as_bytes()); + } + } + let mut text = format!( + "Attached {} to job {}.\n", + human_safe_scalar(&reference), + human_safe_scalar(&id), + ); + if let Some(line) = settings_line(&body) { + let _ = writeln!(text, " settings: {line}"); + } + let _ = writeln!( + text, + "List the job's contracts with `cli job contract list {}`.", + human_safe_scalar(&id) + ); + context.human = Some(text); + Ok(json!({"contracts": [{"program_ref": reference}]})) +} + +const OLDER_SERVICE_NOTE: &str = + "note: this service does not report stored files or environment; re-attaching may drop them\n"; +const FILES_NOT_CARRIED_NOTE: &str = + "note: stored files are not carried to the new revision; pass --file to attach them\n"; + +/// Job types whose runs always read a repository. +const REPOSITORY_JOB_TYPES: [&str; 3] = [ + "github-issue-opened", + "github-pull-request-opened", + "github-release-published", +]; + +/// The hold inputs of an attach plan: the binding as it will run. `body` is +/// the request body; on the server-merge path (`saved`) a setting the body +/// leaves out keeps its saved value and a JSON null clears it. +fn effective_hold<'a>( + body: &'a Value, + saved: Option<&'a SavedBinding>, + job_type: Option<&'a str>, +) -> HoldInputs<'a> { + let effective = |key: &str, saved_value: Option<&'a Value>| match body.get(key) { + Some(value) => value.as_str(), + None => saved_value.and_then(Value::as_str), + }; + let saved_repository = saved + .and_then(|saved| saved.repository.as_ref()) + .map(|(url, _)| url); + HoldInputs { + program_ref: body["program_ref"].as_str(), + model: effective("model", saved.and_then(|saved| saved.model.as_ref())), + reasoning_effort: effective( + "reasoning_effort", + saved.and_then(|saved| saved.effort.as_ref()), + ), + environment: effective( + "environment", + saved.and_then(|saved| saved.environment.as_ref()), + ), + repositories_bound: effective("repository_url", saved_repository).is_some() + || job_type.is_some_and(|kind| REPOSITORY_JOB_TYPES.contains(&kind)), + job_type, + } +} + +/// `--commit-output` must name the repository the runs read: the `--repo` +/// given, or the saved one when it is kept. Returns that repository's URL. +fn check_commit_output( + options: &BindingOptions, + saved: &SavedBinding, +) -> Result, RunnerError> { + let Some(commit) = &options.commit else { + return Ok(None); + }; + let effective = match &options.repo { + Some(repo) => Some(repo.url()), + None if options.clear_repo => None, + None => saved + .repository + .as_ref() + .and_then(|(url, _)| url.as_str().map(str::to_owned)), + }; + match effective { + Some(url) if same_url(&url, &commit.url()) => Ok(Some(url)), + _ => Err(runs::commit_output_mismatch(commit)), + } +} + +/// Whether a `--repo` leaves the saved commit output pointing elsewhere. +fn output_elsewhere(options: &BindingOptions, saved: &SavedBinding) -> bool { + match (&options.repo, saved.output_repository()) { + (Some(repo), Some(output)) => !same_url(&repo.url(), output), + _ => false, + } +} + +/// The server-merge body: only the fields the options change, with JSON +/// nulls for clears. +fn server_merge( + options: &BindingOptions, + saved: &SavedBinding, + body: &mut Map, +) -> Result<(), RunnerError> { + let commit_url = check_commit_output(options, saved)?; + if let Some(model) = &options.model { + body.insert("model".into(), json!(model)); + } + if let Some(effort) = &options.effort { + body.insert("reasoning_effort".into(), json!(effort)); + } + if let Some(repo) = &options.repo { + body.insert("repository_url".into(), json!(repo.url())); + body.insert("repository_branch".into(), json!(repo.branch)); + if output_elsewhere(options, saved) { + body.insert("output".into(), Value::Null); + } + } + if options.clear_repo { + body.insert("repository_url".into(), Value::Null); + body.insert("repository_branch".into(), Value::Null); + body.insert("output".into(), Value::Null); + } + if options.clear_commit { + body.insert("output".into(), Value::Null); + } + if let (Some(commit), Some(url)) = (&options.commit, &commit_url) { + body.insert("output".into(), commit_output(url, commit)); + } + if let Some(inputs) = options.merged_inputs(saved.inputs.as_ref()) { + body.insert("inputs".into(), Value::Object(inputs)); + } + if let Some(environment) = &options.environment { + body.insert("environment".into(), json!(environment)); + } + options.put_files(body, true); + Ok(()) +} + +/// The client-merge body: the saved settings with the options applied, in +/// full (a service that replaces the whole binding on a re-post, or a move +/// to another revision, which also carries the saved `environment`). +fn client_merge_body( + options: &BindingOptions, + saved: &SavedBinding, + moved: bool, + body: &mut Map, +) -> Result<(), RunnerError> { + let commit_url = check_commit_output(options, saved)?; + let mut model = saved.model.clone(); + let mut effort = saved.effort.clone(); + let mut repository = saved.repository.clone(); + let mut output = saved.output.clone(); + if let Some(value) = &options.model { + model = Some(json!(value)); + } + if let Some(value) = &options.effort { + effort = Some(json!(value)); + } + if let Some(repo) = &options.repo { + repository = Some((json!(repo.url()), repo.branch.as_ref().map(|b| json!(b)))); + if output_elsewhere(options, saved) { + output = None; + } + } + if options.clear_repo { + repository = None; + output = None; + } + if options.clear_commit { + output = None; + } + if let (Some(commit), Some(url)) = (&options.commit, &commit_url) { + output = Some(commit_output(url, commit)); + } + let inputs = options + .merged_inputs(saved.inputs.as_ref()) + .or_else(|| saved.inputs.clone()); + if let Some(model) = model { + body.insert("model".into(), model); + } + if let Some(effort) = effort { + body.insert("reasoning_effort".into(), effort); + } + if let Some((url, branch)) = repository { + body.insert("repository_url".into(), url); + if let Some(branch) = branch { + body.insert("repository_branch".into(), branch); + } + } + if let Some(output) = output { + body.insert("output".into(), output); + } + if let Some(inputs) = inputs.filter(|inputs| !inputs.is_empty()) { + body.insert("inputs".into(), Value::Object(inputs)); + } + if let Some(environment) = &options.environment { + body.insert("environment".into(), json!(environment)); + } else if let Some(environment) = saved.environment.as_ref().filter(|_| moved) { + body.insert("environment".into(), environment.clone()); + } + options.put_files(body, true); + Ok(()) +} + +/// The body for a program that is not bound yet: only the options given, +/// without nulls. +fn fresh_body(options: &BindingOptions, body: &mut Map) { + if let Some(model) = &options.model { + body.insert("model".into(), json!(model)); + } + if let Some(effort) = &options.effort { + body.insert("reasoning_effort".into(), json!(effort)); + } + if let Some(repo) = &options.repo { + body.insert("repository_url".into(), json!(repo.url())); + if let Some(branch) = &repo.branch { + body.insert("repository_branch".into(), json!(branch)); + } + } + if let (Some(commit), Some(repo)) = (&options.commit, &options.repo) { + body.insert("output".into(), commit_output(&repo.url(), commit)); + } + if let Some(inputs) = options + .merged_inputs(None) + .filter(|inputs| !inputs.is_empty()) + { + body.insert("inputs".into(), Value::Object(inputs)); + } + if let Some(environment) = &options.environment { + body.insert("environment".into(), json!(environment)); + } + options.put_files(body, false); +} + +/// The service's refusal to change a live webhook's binding. +fn immutable_refusal(error: &RunnerError) -> bool { + error.code == ErrorCode::ServiceRequestRejected + && error + .details + .as_ref() + .and_then(|details| details.get("serviceMessage")) + .and_then(Value::as_str) + .is_some_and(|message| message.to_ascii_lowercase().contains("immutable")) +} + +/// A live webhook's refusal to change its binding: also name the switch to +/// test delivery, with the spec on standard input. +fn live_refusal(context: &Context<'_>, id: &str, error: RunnerError) -> RunnerError { + let words = ["job", "update", id, "--spec-file", "-", "--yes"]; + let mut error = error; + error.action = format!( + "{} The job delivers live; to change its binding, switch it to test delivery first: `{}` with details.suggestedStdin on standard input.", + error.action, + context.command(&words.join(" ")) + ); + error + .with_detail("suggestedArgv", json!(context.follow_up_argv(&words))) + .with_detail( + "suggestedStdin", + Value::String("{\"delivery_mode\":\"test\"}\n".into()), + ) +} + +/// `reasoning effort E; repository URL@BRANCH; commit output URL; inputs +/// k1, k2`: the parts present in a binding body (service names) or a +/// projected `run_configuration`; input values are never shown. +fn settings_line(settings: &Value) -> Option { + let mut parts = Vec::new(); + if let Some(effort) = settings["reasoning_effort"].as_str() { + parts.push(format!("reasoning effort {effort}")); + } + let repository = settings["repository_url"] + .as_str() + .map(|url| (url, settings["repository_branch"].as_str())) + .or_else(|| { + let first = &settings["context_repositories"][0]; + first["url"] + .as_str() + .map(|url| (url, first["branch"].as_str())) + }); + if let Some((url, branch)) = repository { + parts.push(match branch { + Some(branch) => format!("repository {url}@{branch}"), + None => format!("repository {url}"), + }); + } + if let Some(url) = settings["output"]["repository"].as_str() { + parts.push(format!("commit output {url}")); + } + let mut keys = settings["inputs"] + .as_object() + .map(|inputs| inputs.keys().map(String::as_str).collect::>()) + .unwrap_or_default(); + if let Some(entries) = settings["input_entries"].as_array() { + keys.extend(entries.iter().filter_map(|entry| entry["name"].as_str())); + } + if !keys.is_empty() { + keys.sort_unstable(); + parts.push(format!("inputs {}", keys.join(", "))); + } + let mut files = settings["files"] + .as_object() + .map(|files| files.keys().map(String::as_str).collect::>()) + .unwrap_or_default(); + if let Some(stored) = settings["stored_files"].as_array() { + files.extend(stored.iter().filter_map(|file| file["name"].as_str())); + } + if !files.is_empty() { + files.sort_unstable(); + parts.push(format!("files {}", files.join(", "))); + } + (!parts.is_empty()).then(|| human_safe_scalar(&parts.join("; "))) +} + // ------------------------------------------------------------------- human fn text_or_dash(value: &Value) -> String { @@ -1888,6 +2830,9 @@ fn human_contracts(result: &Value) -> String { .map_or("-", |flag| if flag { "true" } else { "false" }), text_or_dash(&contract["model"]), ); + if let Some(line) = settings_line(&contract["run_configuration"]) { + let _ = writeln!(text, " settings: {line}"); + } text }) } @@ -1896,6 +2841,10 @@ fn human_contracts(result: &Value) -> String { mod tests { use super::*; + fn listed(contract: &Value) -> Result { + project_listed_contract(contract, 0) + } + #[test] fn integers_accept_zero_fraction_floats_only() { assert_eq!(as_integer(&json!(86_400)), Some(86_400)); @@ -1948,6 +2897,177 @@ mod tests { ); } + #[test] + fn listed_contracts_project_saved_settings_without_content() { + let projected = listed(&json!({ + "program_ref": "exowner1/hello@0123456789abcdef", + "owner": "exowner1", + "slug": "hello", + "rev_id": "0123456789abcdef", + "content": "program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1, + "inputs": {"topic": "cats", "limit": 3, "flag": true, "cost_center": "ops"}, + "model": null, + "effective_model": "model-a", + "reasoning_effort": null, + "repositories": [], + "output": null, + "environment": null, + "files": [{"name": "a.md", "size": 2, "content": "hi"}], + })) + .unwrap(); + assert_eq!( + projected["run_configuration"], + json!({ + "inputs": {"topic": "cats", "limit": "3", "flag": "true"}, + "input_entries": [{"name": "cost_center", "value": "ops"}], + "stored_files": [{"name": "a.md", "size": 2}], + }) + ); + assert_eq!(projected["bound_at_iso"], json!("1970-01-01T00:00:00.001Z")); + assert!(projected.get("content").is_none() && projected.get("owner").is_none()); + let bare = listed(&json!({ + "program_ref": "exowner1/hello@0123456789abcdef", "inputs": {}, "repositories": [], + })) + .unwrap(); + assert!(bare.get("run_configuration").is_none()); + let error = listed(&json!({ + "program_ref": "exowner1/hello@0123456789abcdef", "inputs": {"a": {"b": 1}}, + })) + .unwrap_err(); + assert_eq!( + error.details.unwrap()["reason"], + json!("unexpected job response: contracts[0].inputs.a") + ); + } + + #[test] + fn settings_line_names_parts_but_never_input_values() { + assert_eq!( + settings_line(&json!({ + "program_ref": "x", "reasoning_effort": "low", + "repository_url": "https://github.com/o/n", "repository_branch": null, + "output": null, "inputs": {"b": "secret", "a": "secret"}, + })) + .as_deref(), + Some("reasoning effort low; repository https://github.com/o/n; inputs a, b") + ); + assert_eq!(settings_line(&json!({"program_ref": "x"})), None); + assert_eq!( + settings_line(&json!({"files": {"b.md": "YQ==", "a.md": "YQ=="}})).as_deref(), + Some("files a.md, b.md") + ); + assert_eq!( + settings_line(&json!({"stored_files": [{"name": "n.md", "size": 1}]})).as_deref(), + Some("files n.md") + ); + } + + #[test] + fn binding_options_are_dropped_from_the_suggested_argv() { + let argv = [ + "cli", + "job", + "contract", + "attach", + "J", + "R", + "--model", + "m", + "--clear-repo", + "--input=a=b", + "--yes", + "--", + "--model", + ] + .map(String::from); + assert_eq!( + argv_without_binding_options(&argv), + [ + "cli", "job", "contract", "attach", "J", "R", "--yes", "--", "--model" + ] + ); + } + + #[test] + fn server_merge_sends_changes_and_client_merge_sends_everything() { + let options = BindingOptions { + model: None, + effort: None, + repo: Some(runs::parse_repository("--repo", "o/other").unwrap()), + commit: None, + clear_repo: false, + clear_commit: false, + inputs: Some(BTreeMap::from([("t".to_owned(), "v".to_owned())])), + clear_inputs: vec!["n".to_owned()], + environment: None, + replace: None, + files: None, + clear_files: false, + }; + let saved = SavedBinding { + model: Some(json!("model-a")), + effort: None, + repository: Some((json!("https://github.com/o/app"), Some(json!("main")))), + output: Some(json!({"type": "commit", "repository": "https://github.com/o/app"})), + inputs: Some(json!({"n": 1, "k": 2}).as_object().unwrap().clone()), + environment: Some(json!("builtin")), + has_files: true, + }; + let mut body = Map::new(); + server_merge(&options, &saved, &mut body).unwrap(); + assert_eq!( + Value::Object(body), + json!({"repository_url": "https://github.com/o/other", "repository_branch": null, + "output": null, "inputs": {"k": 2, "t": "v"}}) + ); + let mut body = Map::new(); + client_merge_body(&options, &saved, false, &mut body).unwrap(); + assert_eq!( + Value::Object(body), + json!({"model": "model-a", "repository_url": "https://github.com/o/other", + "inputs": {"k": 2, "t": "v"}}) + ); + // A move to another revision also carries the saved environment, and + // the commit output names the effective repository's URL. + let moved = BindingOptions { + repo: None, + commit: Some(runs::parse_repository("--commit-output", "O/APP@out").unwrap()), + ..options + }; + let mut body = Map::new(); + client_merge_body(&moved, &saved, true, &mut body).unwrap(); + assert_eq!(body["environment"], json!("builtin")); + assert_eq!( + body["output"], + json!({"type": "commit", "repository": "https://github.com/o/app", "branch": "out"}) + ); + } + + #[test] + fn plan_quote_prices_the_binding_as_it_will_run() { + let saved = SavedBinding { + model: Some(json!("model-a")), + effort: Some(json!("low")), + repository: Some((json!("https://github.com/o/app"), None)), + environment: Some(json!("builtin")), + ..SavedBinding::default() + }; + let body = json!({"program_ref": "o/p@0123456789abcdef", "reasoning_effort": "high", + "repository_url": null, "repository_branch": null}); + let merged = effective_hold(&body, Some(&saved), Some("webhook")); + assert_eq!( + (merged.model, merged.reasoning_effort, merged.environment), + (Some("model-a"), Some("high"), Some("builtin")) + ); + assert!(!merged.repositories_bound); + let fresh = effective_hold(&body, None, Some("github-issue-opened")); + assert_eq!((fresh.model, fresh.environment), (None, None)); + assert!(fresh.repositories_bound); + } + #[test] fn run_counts_fold_into_public_states() { let status = project_status(&json!({ diff --git a/cli/rust/crates/prose-runner-core/src/service/mod.rs b/cli/rust/crates/prose-runner-core/src/service/mod.rs index 5ffad5c8..64f2c2ba 100644 --- a/cli/rust/crates/prose-runner-core/src/service/mod.rs +++ b/cli/rust/crates/prose-runner-core/src/service/mod.rs @@ -4402,22 +4402,35 @@ impl Context<'_> { /// The advisory `GET /run/quote` hold for a plan (`index` is the /// operation's quote request): `{hold}`, or `None` when the quote fails, - /// so a failed quote never hides the plan. The service's price policy - /// reference stays internal. - pub fn advisory_quote(&mut self, index: usize, environment: Option<&str>) -> Option { + /// so a failed quote never hides the plan; an interrupt (`CANCELLED`) + /// still stops the command. The service's price policy reference stays + /// internal. + pub fn advisory_quote( + &mut self, + index: usize, + environment: Option<&str>, + ) -> Result, RunnerError> { let mut request = http::Request::from_manifest(self.operation, index, "/run/quote") .class(http::TransportClass::Control); if let Some(environment) = environment { request = request.query("environment", environment.to_owned()); } - let body = self.send(&request).ok()?.json_object().ok()?; - let hold = body.get("hold")?; - let hold_usd = hold["hold_usd"].as_str()?; - let hold_cents = render::usd_cents(hold_usd)?; - let ttl = hold["ttl_seconds"].as_u64()?; - Some(json!({ - "hold": {"hold_usd": hold_usd, "hold_cents": hold_cents, "ttl_seconds": ttl}, - })) + let response = match self.send(&request) { + Ok(response) => response, + Err(error) if error.code == ErrorCode::Cancelled => return Err(error), + Err(_) => return Ok(None), + }; + let hold = || { + let body = response.json_object().ok()?; + let hold = body.get("hold")?; + let hold_usd = hold["hold_usd"].as_str()?; + let hold_cents = render::usd_cents(hold_usd)?; + let ttl = hold["ttl_seconds"].as_u64()?; + Some(json!({ + "hold": {"hold_usd": hold_usd, "hold_cents": hold_cents, "ttl_seconds": ttl}, + })) + }; + Ok(hold()) } /// The confirmation gate: `--preview` returns the plan as the result, diff --git a/cli/rust/crates/prose-runner-core/src/service/programs.rs b/cli/rust/crates/prose-runner-core/src/service/programs.rs index 2fbf2732..de8f15a2 100644 --- a/cli/rust/crates/prose-runner-core/src/service/programs.rs +++ b/cli/rust/crates/prose-runner-core/src/service/programs.rs @@ -773,7 +773,7 @@ fn draft(context: &mut Context<'_>) -> Result { if context.invocation.preview || !context.invocation.yes { // A draft reserves a run's hold, quoted without options (request 2, // advisory). - if let Some(quote) = context.advisory_quote(2, None) { + if let Some(quote) = context.advisory_quote(2, None)? { planned["quote"] = quote; } } diff --git a/cli/rust/crates/prose-runner-core/src/service/runs.rs b/cli/rust/crates/prose-runner-core/src/service/runs.rs index 2ff2358f..b364722b 100644 --- a/cli/rust/crates/prose-runner-core/src/service/runs.rs +++ b/cli/rust/crates/prose-runner-core/src/service/runs.rs @@ -150,7 +150,7 @@ fn ascii_all(value: &str, allowed: impl Fn(u8) -> bool) -> bool { } /// `^[a-z0-9][a-z0-9_-]{0,63}$` (environment and runtime ids). -fn valid_token(value: &str) -> bool { +pub(super) fn valid_token(value: &str) -> bool { let bytes = value.as_bytes(); (1..=64).contains(&bytes.len()) && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) @@ -160,7 +160,7 @@ fn valid_token(value: &str) -> bool { } /// `^[a-z0-9][a-z0-9.-]{0,63}$` (hosted model ids). -fn valid_model(value: &str) -> bool { +pub(super) fn valid_model(value: &str) -> bool { let bytes = value.as_bytes(); (1..=64).contains(&bytes.len()) && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) @@ -1570,14 +1570,16 @@ fn detached_result(context: &mut Context<'_>, follow: &Follow, fallback: &str) - // --------------------------------------------------------------------------- // run submit -struct Repository { - owner: String, - name: String, - branch: Option, +/// A GitHub repository given as `OWNER/NAME[@BRANCH]` (`--repo`, +/// `--commit-output`); shared with `job contract attach`. +pub(super) struct Repository { + pub(super) owner: String, + pub(super) name: String, + pub(super) branch: Option, } impl Repository { - fn url(&self) -> String { + pub(super) fn url(&self) -> String { format!("https://github.com/{}/{}", self.owner, self.name) } @@ -1586,7 +1588,7 @@ impl Repository { } } -fn parse_repository(option: &str, value: &str) -> Result { +pub(super) fn parse_repository(option: &str, value: &str) -> Result { let error = || { invalid(format!( "{option} {value_quoted} must be OWNER/NAME or OWNER/NAME@BRANCH (a GitHub repository)", @@ -1611,11 +1613,13 @@ fn parse_repository(option: &str, value: &str) -> Result bool { +pub(super) fn valid_input_key(key: &str) -> bool { (1..=128).contains(&key.chars().count()) && !key.chars().any(is_control) } -fn parse_inputs(context: &Context<'_>) -> Result, RunnerError> { +/// `--inputs-file` then `--input KEY=VALUE|KEY=@FILE` (an `--input` wins); +/// shared with `job contract attach`. +pub(super) fn parse_inputs(context: &Context<'_>) -> Result, RunnerError> { let cwd = context.system.current_dir.clone(); let mut inputs = BTreeMap::new(); if let Some(file) = context.option("--inputs-file") { @@ -1686,6 +1690,14 @@ fn parse_inputs(context: &Context<'_>) -> Result, Runne Ok(inputs) } +/// `--commit-output` names a repository the runs do not read as context. +pub(super) fn commit_output_mismatch(commit: &Repository) -> RunnerError { + invalid(format!( + "--commit-output {}/{} must also be given as --repo {}/{}[@BRANCH]; the service commits only to a context repository", + commit.owner, commit.name, commit.owner, commit.name + )) +} + fn looks_like_url(value: &str) -> bool { value.split_once("://").is_some_and(|(scheme, _)| { !scheme.is_empty() @@ -1782,10 +1794,7 @@ fn run_options(context: &Context<'_>) -> Result { .iter() .any(|repository| repository.same(commit)) { - return Err(invalid(format!( - "--commit-output {}/{} must also be given as --repo {}/{}[@BRANCH]; the service commits only to a context repository", - commit.owner, commit.name, commit.owner, commit.name - ))); + return Err(commit_output_mismatch(commit)); } } Ok(RunOptions { @@ -2236,14 +2245,16 @@ fn submit(context: &mut Context<'_>) -> Result { .class(TransportClass::Control); quote_request.query.clone_from(&submission.extra_query); quote_request.body = Some(submission.body.clone()); - // The quote is advisory: a failed quote never hides the plan. - if let Ok(Ok(body)) = context - .send("e_request) - .map(|response| response.json_object()) - { - if let Ok(hold) = quote_fields(&body) { - planned["quote"] = json!({"hold": hold}); + // The quote is advisory: a failed quote never hides the plan; only + // an interrupt stops here. + match context.send("e_request) { + Ok(response) => { + if let Ok(hold) = response.json_object().and_then(|body| quote_fields(&body)) { + planned["quote"] = json!({"hold": hold}); + } } + Err(error) if error.code == ErrorCode::Cancelled => return Err(error), + Err(_) => {} } if let Gate::Preview(result) = context.gate(planned)? { return Ok(result); diff --git a/cli/shared/schemas/service/jobs.schema.json b/cli/shared/schemas/service/jobs.schema.json index 1bb23946..1119ce5b 100644 --- a/cli/shared/schemas/service/jobs.schema.json +++ b/cli/shared/schemas/service/jobs.schema.json @@ -56,6 +56,38 @@ }, "run_configuration": { "$ref": "#/$defs/runConfiguration" + }, + "effective_model": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "common.schema.json#/$defs/modelId" + } + ] + }, + "rev_id": { + "type": "string", + "pattern": "^[0-9a-f]{16}$" + }, + "bound_at": { + "type": "integer", + "minimum": 0 + }, + "bound_at_iso": { + "description": "`bound_at` as an RFC 3339 UTC time.", + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "common.schema.json#/$defs/timestamp" + } + ] + }, + "is_platform_default": { + "type": "boolean" } } }, @@ -998,6 +1030,33 @@ ] } } + }, + "stored_files": { + "description": "Files stored on a webhook binding, by metadata only; their content is never shown.", + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "size" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 256, + "pattern": "^[^\\u0000-\\u001f\\u007f]*$" + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "sha256": { + "$ref": "common.schema.json#/$defs/sha256" + } + } + } } } } diff --git a/cli/shared/service/help.v1.json b/cli/shared/service/help.v1.json index 80058015..7454de6d 100644 --- a/cli/shared/service/help.v1.json +++ b/cli/shared/service/help.v1.json @@ -1,6 +1,6 @@ { "schema": "openprose.service-help/1", - "manifestSha256": "a77171cfda52072dffc7a37a9341b3f9b3a578dbdf8cf35040cdfae78711f819", + "manifestSha256": "72ed72d21829edc004f53b5ba77c6e4d8cd77d439b5425d7ce6f16c274356021", "topics": { "cli": "Usage: prose [GLOBAL OPTIONS] cli [ARGUMENTS] [OPTIONS]\n\nOpenProse service and account commands. They reach the hosted OpenProse service\nand never prompt. New here? `prose cli service guide` walks through a first\nprogram, a daily schedule, scripting and costs. Commands for this machine\n(doctor, config) are listed by `prose --help`.\n\nCommands:\n run Run a program on the hosted service, then follow, read or cancel it: submit, watch, show, list, cancel, download, input, quote, share.\n program Save your programs and manage their revisions: save, list, show, delete, draft, revisions, visibility.\n example Working example programs to read and copy: list, show.\n job Run a saved program on a schedule or from a webhook: create, list, show, delete, configure, contract, deliveries, rotate-secret, update.\n wallet Balance, usage and credit: balance, usage, events, topup, redeem.\n auth Sign in, check or remove the stored key: login, status, logout.\n model Hosted models: list.\n result Published results of public programs (OWNER/SLUG); a run's own output is `cli run show` or `cli run download`: show, list, publish, unpublish.\n org Organizations, members and invitations: list, show, create, default, invitation, invite, member, rename.\n repo Repositories the service can read: list.\n package Registry packages (no source execution): fetch, list, publish, withdraw.\n service Service status, triage, the guide and the command list: triage, status, guide, capabilities, operations.\n\nExamples:\n prose cli run submit hello.prose.md --preview\n prose cli program save hello hello.prose.md --preview\n prose cli example list --json\n prose cli job create --spec-file job.json --preview\n prose cli wallet balance --json\n prose cli auth login\n prose cli model list --json\n prose cli result show exowner1/hello --latest\n prose cli org list --json\n prose cli repo list --json\n prose cli package publish my-package --organization acme --name tool --version 1.0.0 --json\n prose cli service triage --json\n\nGlobal options:\n --output human|json|jsonl Output mode; the same as the PROSE_OUTPUT setting.\n\nRun `prose cli --help` for details. `prose cli service operations --json` prints every command as JSON.\n", "cli auth": "Usage: prose [GLOBAL OPTIONS] cli auth [ARGUMENTS] [OPTIONS]\n\nAccount credentials for the OpenProse service. Every other service command\nreads the key these commands manage, or OPENPROSE_API_KEY.\n\nCommands:\n login Sign in with the GitHub device flow and store the key in the OS credential store.\n status Report whether a credential is available and valid.\n logout Remove the stored credential.\n\nExamples:\n prose cli auth login\n prose cli auth status --json\n prose cli auth logout --json\n\nGlobal options:\n --output human|json|jsonl Output mode; the same as the PROSE_OUTPUT setting.\n\nCredentials:\n OPENPROSE_API_KEY API key; a non-empty value wins over the stored key.\n\nDevice flow: `cli auth login` prints a one-time code and https://github.com/login/device\non stderr, never the key, and never opens a browser. A person approves the code\nthere within 15 minutes. An agent without a person sets the variable instead.\n\nRun `prose cli auth --help` for details. `prose cli service operations --json` prints every command as JSON.\n", @@ -13,7 +13,7 @@ "cli job": "Usage: prose [GLOBAL OPTIONS] cli job [ARGUMENTS] [OPTIONS]\n\nCommands:\n create Create a job from a JSON spec.\n list List jobs, the account's job limit and the available job types.\n show Show one job and its delivery status.\n delete Delete a job.\n configure Replace a schedule job's cadence and run configuration.\n contract Commands: attach, detach, list.\n deliveries List a webhook job's recent deliveries.\n rotate-secret Rotate a webhook job's signing secret.\n update Change a webhook job's settings.\n\nExamples:\n prose cli job create --spec-file job.json --preview\n prose cli job list --json\n prose cli job show 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --json\n prose cli job delete 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --preview\n prose cli job configure 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --interval-seconds 3600 --yes\n prose cli job contract list 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --json\n prose cli job deliveries 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --json\n prose cli job rotate-secret 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --yes\n prose cli job update 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --spec-file job.json --yes\n\nGlobal options:\n --output human|json|jsonl Output mode; the same as the PROSE_OUTPUT setting.\n\nRun `prose cli job --help` for details. `prose cli service operations --json` prints every command as JSON.\n", "cli job configure": "Usage: prose [GLOBAL OPTIONS] cli job configure [OPTIONS]\n\nReplace a schedule job's cadence and run configuration. Schedule jobs only.\nQuick form: `cli job configure JOB_ID --interval-seconds N` reads the job and resends its current configuration_revision, revision_token and bindings with the new interval.\nFull form: --config-file with the body {\"interval_seconds\":N,\"configuration_revision\":R,\"revision_token\":\"T\",\"bindings\":[{\"program_ref\":\"OWNER/SLUG@REV\",\"run_configuration\":{...}}]}.\nRead the current values with `cli job show JOB_ID --json`: status.configuration_revision, status.revision_token (an opaque token), and each status.contracts[] program_ref and run_configuration, under the same names.\nrun_configuration takes model, environment, reasoning_effort, inputs, files, context_repositories and output as show prints them, except input_entries: move each {name, value} entry into inputs, because every input left out is deleted.\nA stale revision_token is SERVICE_WRITE_CONFLICT; while a run of the schedule is in progress the service answers SERVICE_UNAVAILABLE (retry later).\n\nArguments:\n JOB_ID Job id.\n\nOptions:\n --config-file FILE Schedule configuration: a JSON object of at most 64 KiB (see above), or -. Give exactly one of --config-file and --interval-seconds.\n --interval-seconds N New interval (60 to 2678400); the current configuration is read and resent unchanged otherwise.\n --yes Confirm this operation; required because it replaces the schedule's cadence and run configuration; the next scheduled run uses them.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job configure 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --interval-seconds 3600 --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", "cli job contract": "Usage: prose [GLOBAL OPTIONS] cli job contract [ARGUMENTS] [OPTIONS]\n\nCommands:\n attach Attach a pinned program to a job.\n detach Detach a program from a job.\n list List the programs attached to a job.\n\nExamples:\n prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes\n prose cli job contract detach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes\n prose cli job contract list 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --json\n\nGlobal options:\n --output human|json|jsonl Output mode; the same as the PROSE_OUTPUT setting.\n\nRun `prose cli job contract --help` for details. `prose cli service operations --json` prints every command as JSON.\n", - "cli job contract attach": "Usage: prose [GLOBAL OPTIONS] cli job contract attach [OPTIONS]\n\nAttach a pinned program to a job.\n\nArguments:\n JOB_ID Job id.\n OWNER/SLUG@REV Pinned program reference.\n\nOptions:\n --model MODEL Hosted model for runs of this program.\n --yes Confirm this operation; required because the job starts running this program, a paid run, each time it fires.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", + "cli job contract attach": "Usage: prose [GLOBAL OPTIONS] cli job contract attach [OPTIONS]\n\nAttach a pinned program to a job. The options set a webhook job's run settings; re-attaching a bound program changes only those given.\n\nArguments:\n JOB_ID Job id.\n OWNER/SLUG@REV Pinned program reference.\n\nOptions:\n --model MODEL Hosted model for its runs.\n --reasoning-effort EFFORT Reasoning effort for its runs. One of: minimal, low, medium, high, xhigh.\n --repo OWNER/NAME[@BRANCH] Repository its runs read as context.\n --commit-output OWNER/NAME[@BRANCH]\n Repository receiving each run's commit; the context repository.\n --clear-repo Remove the saved repository and commit output.\n --clear-commit-output Remove the saved commit output.\n --input KEY=VALUE|KEY=@FILE\n Program input; @FILE reads a file. Overrides saved inputs. Repeatable.\n --inputs-file FILE JSON object of string inputs.\n --clear-input KEY Remove a saved input. Repeatable.\n --file [NAME=]PATH UTF-8 file for its runs; replaces all stored files. Repeatable.\n --clear-files Remove the stored files.\n --environment ENV Where its runs execute.\n --replace OWNER/SLUG@REV Bound revision this replaces, keeping its settings.\n --yes Confirm this operation; required because the job starts running this program, a paid run, each time it fires.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes\n prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --repo exowner1/app@main --reasoning-effort medium --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", "cli job contract detach": "Usage: prose [GLOBAL OPTIONS] cli job contract detach [OPTIONS]\n\nDetach a program from a job.\n\nArguments:\n JOB_ID Job id.\n OWNER/SLUG@REV Pinned program reference.\n\nOptions:\n --yes Confirm this operation; required because the job stops running this program from now on.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job contract detach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", "cli job contract list": "Usage: prose [GLOBAL OPTIONS] cli job contract list [OPTIONS]\n\nList the programs attached to a job.\n\nArguments:\n JOB_ID Job id.\n\nOptions:\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job contract list 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --json\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation or configuration; 10 service or credential error; 24 interrupted.\n", "cli job create": "Usage: prose [GLOBAL OPTIONS] cli job create [OPTIONS]\n\nCreate a job from a JSON spec. A job starts paid runs on its own, on a schedule or from webhook events, until it is deleted.\nSpec keys are snake_case. `type` is required; `cli job list` prints every type with its config_fields. Each type accepts the keys listed under this command's `spec` in `cli service operations --json`; every problem is reported at once.\n Schedule: {\"type\":\"schedule\",\"program_ref\":\"OWNER/SLUG@REV\",\"interval_seconds\":86400}\n interval_seconds 60 to 2678400 (86400 is once a day). The first run starts about one second after the job is created, then one every interval.\n Cron expressions and a time of day are not supported.\n Optional: model, reasoning_effort, environment, inputs (name -> string), files, repository_url, repository_branch, output.\n Webhook: {\"type\":\"webhook\",\"name\":\"NAME\",\"delivery_mode\":\"test\"}\n optional name, program_ref, receiver, receiver_secret, reply, reply_secret; delivery_mode test or live; with program_ref also model, reasoning_effort, repository_url, repository_branch, output. Without program_ref it starts no runs: no hold, effect write.\nREV is the program's program.rev_id (printed by `cli program save` and `cli program show OWNER/SLUG`), not its commit_id.\n\nOptions:\n --spec-file FILE Job spec: a JSON object of at most 64 KiB (see above), or - for standard input. Required.\n --yes Confirm this operation; required because the job starts paid runs on its own schedule or events until it is deleted (a webhook with no program starts none until one is attached).\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job create --spec-file job.json --preview\n prose cli job create --spec-file job.json --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", @@ -64,7 +64,7 @@ "cli run download": "Usage: prose [GLOBAL OPTIONS] cli run download [OPTIONS]\n\nDownload every file listed in a run manifest into a new directory (./RUN_ID unless --output-dir).\n\nArguments:\n RUN_ID Run id (run_ and 64 hex digits), or latest for your newest run.\n\nOptions:\n --output-dir DIR Destination directory; it must not exist. Default: ./RUN_ID.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli run download run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c\n prose cli run download run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c --output-dir run-output\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation or configuration; 10 service or credential error; 24 interrupted.\n", "cli run input": "Usage: prose [GLOBAL OPTIONS] cli run input [OPTIONS]\n\nSend an instruction to a live run. The instruction id makes retries idempotent. Without a live session on this machine the run record is read first: an ended run is refused as SERVICE_WRITE_CONFLICT (not retryable).\n\nArguments:\n RUN_ID Run id (run_...).\n TEXT Instruction text, 1 to 4000 characters.\n\nOptions:\n --id UUID Instruction id to reuse when retrying; a new UUID is minted otherwise.\n --session UUID Live session to use instead of the one recorded in the local run journal.\n --yes Confirm this operation; required because the live run acts on the instruction as soon as it arrives, and it cannot be taken back.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli run input run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c 'Also write a haiku.' --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", "cli run list": "Usage: prose [GLOBAL OPTIONS] cli run list [OPTIONS]\n\nList runs, newest first, one page at a time.\n\nOptions:\n --limit N Page size, 1 to 200. Default: 20.\n --before CURSOR Opaque cursor from a previous result's nextBefore.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli run list --limit 10 --json\n\nOutput: openprose.service-operation/1 (--json).\nPaging: pass the result's nextBefore to --before for the next page.\nExit codes: 0 success; 2 invalid invocation or configuration; 10 service or credential error; 24 interrupted.\n", - "cli run quote": "Usage: prose [GLOBAL OPTIONS] cli run quote [FILE] [OPTIONS]\n\nReport the hold a run reserves: money set aside from the wallet while the run is live. It is not the price. The environment is checked first.\nThe hold depends on the model, reasoning effort, environment, declared tools and bound repositories, and what the run does not use is released when it settles. With FILE or --from the service prices that program, including its own run settings and declared tools; --model, --reasoning-effort, --environment, --repo and --commit-output override them. `basis` names where each value came from (request, program or default). A run's price is known only after it settles (`cli run show RUN_ID`, price_cents). The program, inputs and options `cli run submit` takes are accepted, so you can quote the exact command you will submit.\n\nArguments:\n FILE Program file, or - for standard input, as `cli run submit` takes it. The service prices this program, including its own run settings and declared tools.\n\nOptions:\n --from [OWNER/]SLUG[@REV] Run a saved program; a bare SLUG is your own program. @REV is a rev_id, or @N (@revN) for revision N of your own program; without @REV the service prices the latest revision. The service prices this program, including its own run settings and declared tools.\n --input KEY=VALUE|KEY=@FILE\n Program input; @FILE reads UTF-8 text of at most 1 MiB. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it. Repeatable.\n --inputs-file FILE JSON object of string inputs. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it.\n --model MODEL Hosted model id (see `cli model list`). Sent with the quote; overrides the program's own setting. Default: the service's default_model (`cli model list`).\n --reasoning-effort EFFORT Reasoning effort supported by the model. Sent with the quote; overrides the program's own setting. One of: minimal, low, medium, high, xhigh.\n --environment ENV Execution environment offered by the service (for example builtin or linux). Sent with the quote; overrides the program's own setting. Default: the service's default environment.\n --runtime RUNTIME Runtime offered by the service. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it. Default: the service's default runtime.\n --repo OWNER/NAME[@BRANCH] Read-only repository context. Sent with the quote: a bound repository adds to the hold. Repeatable.\n --commit-output OWNER/NAME[@BRANCH]\n Writable repository that receives the run's commit. Sent with the quote: a bound repository adds to the hold.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli run quote --json\n prose cli run quote hello.prose.md --input topic=cats --json\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation or configuration; 10 service or credential error; 24 interrupted.\n", + "cli run quote": "Usage: prose [GLOBAL OPTIONS] cli run quote [FILE] [OPTIONS]\n\nReport the hold a run reserves: money set aside from the wallet while the run is live. It is not the price. The environment is checked first.\nThe hold depends on the model, reasoning effort, environment, declared tools and bound repositories; what the run does not use is released when it settles. With FILE or --from the service prices that program, including its own run settings and declared tools; the run-setting options override them. `basis` names where each value came from (request, program or a default). A run's price is known only after it settles (`cli run show RUN_ID`, price_cents). It takes what `cli run submit` takes, so you can quote the exact command you will submit.\n\nArguments:\n FILE Program file, or - for standard input, as `cli run submit` takes it. The service prices this program, including its own run settings and declared tools.\n\nOptions:\n --from [OWNER/]SLUG[@REV] Run a saved program; a bare SLUG is your own program. @REV is a rev_id, or @N (@revN) for revision N of your own program; without @REV the service prices the latest revision.\n --input KEY=VALUE|KEY=@FILE\n Program input; @FILE reads UTF-8 text of at most 1 MiB. Accepted as `cli run submit` takes it; not sent. Repeatable.\n --inputs-file FILE JSON object of string inputs. Accepted as `cli run submit` takes it; not sent.\n --model MODEL Hosted model id (see `cli model list`). Sent with the quote; overrides the program's own setting. Default: the service's default_model (`cli model list`).\n --reasoning-effort EFFORT Reasoning effort supported by the model. Sent with the quote; overrides the program's own setting. One of: minimal, low, medium, high, xhigh.\n --environment ENV Execution environment offered by the service (for example builtin or linux). Sent with the quote; overrides the program's own setting. Default: the service's default environment.\n --runtime RUNTIME Runtime offered by the service. Accepted as `cli run submit` takes it; not sent. Default: the service's default runtime.\n --repo OWNER/NAME[@BRANCH] Read-only repository context. Sent with the quote: a bound repository adds to the hold. Repeatable.\n --commit-output OWNER/NAME[@BRANCH]\n Writable repository that receives the run's commit. Sent with the quote: a bound repository adds to the hold.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli run quote --json\n prose cli run quote hello.prose.md --input topic=cats --json\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation or configuration; 10 service or credential error; 24 interrupted.\n", "cli run share": "Usage: prose [GLOBAL OPTIONS] cli run share [OPTIONS]\n\nCreate a public link to a run's outputs. Anyone with the link can open it for 24 hours, and it cannot be revoked.\nTo give specific people access instead, invite them to your organization (`cli org invite`).\n\nArguments:\n RUN_ID Run id (run_ and 64 hex digits), or latest for your newest run.\n\nOptions:\n --yes Confirm this operation; required because it creates a public link to the run's outputs that anyone who has it can open for 24 hours, and it cannot be revoked.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli run share run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", "cli run show": "Usage: prose [GLOBAL OPTIONS] cli run show [OPTIONS]\n\nShow a run manifest (status, files, price), or one output file with --file. Signed file URLs are never printed.\nThe manifest has no final response text. Read the run's outputs with `cli run show RUN_ID --file outputs/result.json` or `cli run download RUN_ID --output-dir DIR`; replay the stream, whose terminal event carries the response, with `cli run watch RUN_ID --after 0` (runs submitted from this machine, or pass --session).\n\nArguments:\n RUN_ID Run id (run_ and 64 hex digits), or latest for your newest run.\n\nOptions:\n --file PATH Output file path exactly as listed in the manifest.\n --output-file FILE Write the bytes to FILE, which must not exist, instead of including them in the result.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli run show run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c --json\n prose cli run show run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c --file outputs/result.json\n prose cli run show latest --json\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation or configuration; 10 service or credential error, or no such run or --file (SERVICE_RESOURCE_NOT_FOUND); 24 interrupted.\n", "cli run submit": "Usage: prose [GLOBAL OPTIONS] cli run submit [FILE] [OPTIONS]\n\nRun a program on the hosted service and stream it until it finishes, detaches or the deadline passes.\nInputs: --input KEY=VALUE overrides the same key from --inputs-file.\nA --wait shorter than the run exits 21 with details.resumeArgv: the run continues, and that command follows it again. Never submit again to resume; that starts a second paid run.\n\nArguments:\n FILE Program file, or - for standard input. Omit when --from is given.\n\nOptions:\n --from [OWNER/]SLUG[@REV] Run a saved program; a bare SLUG is your own program. @REV is a rev_id, or @N (@revN) for revision N of your own program; without @REV the latest revision is pinned.\n --input KEY=VALUE|KEY=@FILE\n Program input; @FILE reads UTF-8 text of at most 1 MiB. It overrides the same key from --inputs-file. Repeatable.\n --inputs-file FILE JSON object of string inputs; an --input of the same key wins.\n --model MODEL Hosted model id (see `cli model list`). Default: the service's default_model (`cli model list`).\n --reasoning-effort EFFORT Reasoning effort supported by the model. One of: minimal, low, medium, high, xhigh.\n --environment ENV Where the run executes (for example builtin: file tools only; linux: a shell without network access). Web access is through the browser:interact tool. Default: the service's default environment.\n --runtime RUNTIME Runtime offered by the service. Default: the service's default runtime.\n --repo OWNER/NAME[@BRANCH] Read-only repository context. Repeatable.\n --commit-output OWNER/NAME[@BRANCH]\n Writable repository that receives the run's commit.\n --detach Return as soon as the run id is known.\n --wait DURATION Stop following after this long and exit 21 (for example 90s, 10m, 2h; maximum 6h). The run continues. Default: 30m.\n --session UUID Use this session UUID instead of a new one (recovery only).\n --yes Confirm this operation; required because it starts a paid run: a hold (money set aside from the wallet, not the price) is reserved now, and the run's price is charged when it settles.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli run submit hello.prose.md --preview\n prose cli run submit hello.prose.md --yes\n prose cli run submit --from exowner1/hello --input topic=rain --yes --detach --json\n\nOutput: openprose.service-operation/1 (--json) or openprose.service-event/1 lines (--output jsonl). The run id is result.runId (and result.run.run_id once the run ends); the answer is result.run.response.\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 21 deadline or detached: the run id is known and the run continues (resume with details.resumeArgv); 22 run failed, or submission ambiguous (recover with details.resumeArgv: the same session with --detach, never a new run); 24 cancelled on the service.\n", @@ -87,7 +87,7 @@ "contract": "service/1", "manifest": { "schema": "openprose.service-operations/1", - "sha256": "87e68b5f8f2e0125f7f6afc548a2d888bdd9c645261c59935fd4e3e7dc388bbb", + "sha256": "ff313c6f91b301039bb3688ad59464da1c10c5a6a13abff1d20b73550501da0b", "argv": [ "cli", "service", @@ -501,7 +501,7 @@ "run", "quote" ], - "summary": "Report the hold a run reserves: money set aside from the wallet while the run is live. It is not the price. The environment is checked first.\nThe hold depends on the model, reasoning effort, environment, declared tools and bound repositories, and what the run does not use is released when it settles. With FILE or --from the service prices that program, including its own run settings and declared tools; --model, --reasoning-effort, --environment, --repo and --commit-output override them. `basis` names where each value came from (request, program or default). A run's price is known only after it settles (`cli run show RUN_ID`, price_cents). The program, inputs and options `cli run submit` takes are accepted, so you can quote the exact command you will submit.", + "summary": "Report the hold a run reserves: money set aside from the wallet while the run is live. It is not the price. The environment is checked first.\nThe hold depends on the model, reasoning effort, environment, declared tools and bound repositories; what the run does not use is released when it settles. With FILE or --from the service prices that program, including its own run settings and declared tools; the run-setting options override them. `basis` names where each value came from (request, program or a default). A run's price is known only after it settles (`cli run show RUN_ID`, price_cents). It takes what `cli run submit` takes, so you can quote the exact command you will submit.", "effect": "read", "confirm": false, "examples": [ @@ -923,11 +923,12 @@ "contract", "attach" ], - "summary": "Attach a pinned program to a job.", + "summary": "Attach a pinned program to a job. The options set a webhook job's run settings; re-attaching a bound program changes only those given.", "effect": "write", "confirm": true, "examples": [ - "prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes" + "prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes", + "prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --repo exowner1/app@main --reasoning-effort medium --yes" ] }, { diff --git a/cli/shared/service/operations.v1.json b/cli/shared/service/operations.v1.json index d1c91ee2..32b6117e 100644 --- a/cli/shared/service/operations.v1.json +++ b/cli/shared/service/operations.v1.json @@ -1955,7 +1955,7 @@ ], "contract": "service/1", "feature": "runs", - "summary": "Report the hold a run reserves: money set aside from the wallet while the run is live. It is not the price. The environment is checked first.\nThe hold depends on the model, reasoning effort, environment, declared tools and bound repositories, and what the run does not use is released when it settles. With FILE or --from the service prices that program, including its own run settings and declared tools; --model, --reasoning-effort, --environment, --repo and --commit-output override them. `basis` names where each value came from (request, program or default). A run's price is known only after it settles (`cli run show RUN_ID`, price_cents). The program, inputs and options `cli run submit` takes are accepted, so you can quote the exact command you will submit.", + "summary": "Report the hold a run reserves: money set aside from the wallet while the run is live. It is not the price. The environment is checked first.\nThe hold depends on the model, reasoning effort, environment, declared tools and bound repositories; what the run does not use is released when it settles. With FILE or --from the service prices that program, including its own run settings and declared tools; the run-setting options override them. `basis` names where each value came from (request, program or a default). A run's price is known only after it settles (`cli run show RUN_ID`, price_cents). It takes what `cli run submit` takes, so you can quote the exact command you will submit.", "interaction": "run.quote", "interactions": [ "health.read", @@ -1974,21 +1974,21 @@ { "name": "--from", "value": "[OWNER/]SLUG[@REV]", - "description": "Run a saved program; a bare SLUG is your own program. @REV is a rev_id, or @N (@revN) for revision N of your own program; without @REV the service prices the latest revision. The service prices this program, including its own run settings and declared tools.", + "description": "Run a saved program; a bare SLUG is your own program. @REV is a rev_id, or @N (@revN) for revision N of your own program; without @REV the service prices the latest revision.", "repeatable": false, "required": false }, { "name": "--input", "value": "KEY=VALUE|KEY=@FILE", - "description": "Program input; @FILE reads UTF-8 text of at most 1 MiB. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it.", + "description": "Program input; @FILE reads UTF-8 text of at most 1 MiB. Accepted as `cli run submit` takes it; not sent.", "repeatable": true, "required": false }, { "name": "--inputs-file", "value": "FILE", - "description": "JSON object of string inputs. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it.", + "description": "JSON object of string inputs. Accepted as `cli run submit` takes it; not sent.", "repeatable": false, "required": false }, @@ -2025,7 +2025,7 @@ { "name": "--runtime", "value": "RUNTIME", - "description": "Runtime offered by the service. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it.", + "description": "Runtime offered by the service. Accepted as `cli run submit` takes it; not sent.", "default": "the service's default runtime", "repeatable": false, "required": false @@ -5565,10 +5565,12 @@ ], "contract": "service/1", "feature": "jobs", - "summary": "Attach a pinned program to a job.", + "summary": "Attach a pinned program to a job. The options set a webhook job's run settings; re-attaching a bound program changes only those given.", "interaction": "job.contracts", "interactions": [ - "job.contracts" + "job.contracts", + "job.read", + "run.quote" ], "arguments": [ { @@ -5588,12 +5590,131 @@ { "name": "--model", "value": "MODEL", - "description": "Hosted model for runs of this program.", + "description": "Hosted model for its runs.", + "repeatable": false, + "required": false + }, + { + "name": "--reasoning-effort", + "value": "EFFORT", + "description": "Reasoning effort for its runs.", + "repeatable": false, + "required": false, + "choices": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + { + "name": "--repo", + "value": "OWNER/NAME[@BRANCH]", + "description": "Repository its runs read as context.", + "repeatable": false, + "required": false + }, + { + "name": "--commit-output", + "value": "OWNER/NAME[@BRANCH]", + "description": "Repository receiving each run's commit; the context repository.", + "repeatable": false, + "required": false + }, + { + "name": "--clear-repo", + "value": null, + "description": "Remove the saved repository and commit output.", + "repeatable": false, + "required": false + }, + { + "name": "--clear-commit-output", + "value": null, + "description": "Remove the saved commit output.", + "repeatable": false, + "required": false + }, + { + "name": "--input", + "value": "KEY=VALUE|KEY=@FILE", + "description": "Program input; @FILE reads a file. Overrides saved inputs.", + "repeatable": true, + "required": false + }, + { + "name": "--inputs-file", + "value": "FILE", + "description": "JSON object of string inputs.", + "repeatable": false, + "required": false + }, + { + "name": "--clear-input", + "value": "KEY", + "description": "Remove a saved input.", + "repeatable": true, + "required": false + }, + { + "name": "--file", + "value": "[NAME=]PATH", + "description": "UTF-8 file for its runs; replaces all stored files.", + "repeatable": true, + "required": false + }, + { + "name": "--clear-files", + "value": null, + "description": "Remove the stored files.", + "repeatable": false, + "required": false + }, + { + "name": "--environment", + "value": "ENV", + "description": "Where its runs execute.", + "repeatable": false, + "required": false + }, + { + "name": "--replace", + "value": "OWNER/SLUG@REV", + "description": "Bound revision this replaces, keeping its settings.", "repeatable": false, "required": false } ], "requests": [ + { + "interaction": "job.read", + "method": "GET", + "path": "/triggers/{id}", + "auth": "bearer", + "body": null, + "when": "a webhook option, --replace, or a plan (no --yes)", + "catalogRoute": { + "method": "GET", + "path": "/triggers/{id}", + "auth": "api_key" + }, + "response": "trigger-detail" + }, + { + "interaction": "job.contracts", + "method": "GET", + "path": "/triggers/{id}/contracts", + "auth": "bearer", + "body": null, + "when": "always", + "catalogRoute": { + "method": "GET", + "path": "/triggers/{id}/contracts", + "auth": "api_key" + }, + "response": "trigger-contracts" + }, { "interaction": "job.contracts", "method": "POST", @@ -5607,6 +5728,28 @@ "auth": "api_key" }, "response": null + }, + { + "interaction": "run.quote", + "method": "GET", + "path": "/run/quote", + "query": { + "program_ref": "{program_ref}", + "model": "{model}", + "reasoning_effort": "{reasoning_effort}", + "environment": "{environment}", + "repositories": "1", + "job_type": "{job_type}" + }, + "auth": "bearer", + "body": null, + "when": "confirmation", + "catalogRoute": { + "method": "GET", + "path": "/run/quote", + "auth": "api_key_or_session_token" + }, + "response": "run-quote" } ], "effect": "write", @@ -5650,7 +5793,8 @@ } ], "examples": [ - "prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes" + "prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes", + "prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --repo exowner1/app@main --reasoning-effort medium --yes" ] }, { diff --git a/cli/shared/service/responses/trigger-contracts.schema.json b/cli/shared/service/responses/trigger-contracts.schema.json index 4ffe3728..35c38ac4 100644 --- a/cli/shared/service/responses/trigger-contracts.schema.json +++ b/cli/shared/service/responses/trigger-contracts.schema.json @@ -13,11 +13,62 @@ "items": { "type": "object", "required": [ - "programRef" + "program_ref" ], "properties": { - "programRef": { + "program_ref": { "type": "string" + }, + "rev_id": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "bound_at": { + "type": "integer" + }, + "is_platform_default": { + "type": "boolean" + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "effective_model": { + "type": [ + "string", + "null" + ] + }, + "reasoning_effort": { + "type": [ + "string", + "null" + ] + }, + "inputs": { + "type": "object" + }, + "repositories": { + "type": "array" + }, + "output": { + "type": [ + "object", + "null" + ] + }, + "environment": { + "type": [ + "string", + "null" + ] + }, + "files": { + "type": "array" } } } diff --git a/docs/service/jobs.md b/docs/service/jobs.md index a33d86e5..64651919 100644 --- a/docs/service/jobs.md +++ b/docs/service/jobs.md @@ -178,13 +178,43 @@ prose cli job show "$JOB" --json | jq '.result.status | { revision number such as `@1` exits 2 naming `program show OWNER/SLUG@1 --json`, which resolves it for your own program). Their result is `{contracts: [{program_ref}]}`: the one contract the service attached or - detached. Run `job contract list` for the full list. `--model` is refused - because the service ignores a model on attach; attached contracts run on the - service's default job model. Schedules change contracts only through - `job configure` (the contract routes answer 405 → + detached. Run `job contract list` for the full list. Schedules change + contracts only through `job configure` (the contract routes answer 405 → `SERVICE_REQUEST_REJECTED`). +- For a **webhook** job, `job contract attach` also sets how its runs execute: + `--model`, `--reasoning-effort`, `--repo OWNER/NAME[@BRANCH]` (a repository + the runs read as context), `--commit-output OWNER/NAME[@BRANCH]` (it must be + that repository), `--input KEY=VALUE` / `--inputs-file FILE`, + `--environment ENV`, `--file [NAME=]PATH` (UTF-8; it replaces the stored + file set, as re-deploying from the editor does; at most 20 files, 5 MiB each + and 10 MiB in total), and `--clear-repo`, `--clear-commit-output`, + `--clear-input KEY` and `--clear-files`. A single stored file cannot be + removed on its own: the service returns file metadata, not content, so give + the full set with `--file`. These options on another job type are refused before + anything is sent (the job is read first to check its type). +- Attaching a program that is already bound changes only the options given. + When the service reports each binding's `environment`, the CLI re-binds the + same reference (`replace_program_ref`) with just the changed fields and + `null` for a clear, and the service keeps the rest, including stored files. + Against a service that does not report it, the CLI merges the saved + settings itself and warns that stored files and environment may be + dropped. `--replace OWNER/SLUG@REV` moves a binding to another revision of + the same program: a full replace that carries the old binding's model, + reasoning effort, repository, inputs and environment, but not its stored + files (give them again with `--file`). `--replace` onto a program that is + already bound is refused; change that binding in place. Settings apply to + deliveries + admitted after the change. The plan (`--preview` or `CONFIRMATION_REQUIRED`) + carries the hold for the binding as it will run (`GET /run/quote` with the + program, the job's type and the effective model, reasoning effort, + environment and repository); a failed quote leaves the plan without one. - `job contract list` reports each contract's `program_ref`, `program_slug`, - `enabled` and `model` and drops the program text and configured inputs. + `enabled`, `model`, `effective_model`, `rev_id`, `bound_at`, + `is_platform_default` and, for a binding with saved settings, + `run_configuration`: `reasoning_effort`, `inputs` (with `input_entries` for + names matching /cost/i), `context_repositories`, `output`, `environment` and + `stored_files` (`{name, size, sha256}`; content is never shown). The program + text is never shown, and human output lists input names, not values. ## Secrets From 46622aa2da5c22bbf645aae539fe19c1f52fcca2 Mon Sep 17 00:00:00 2001 From: Raymond Weitekamp <19483938+rawwerks@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:24:04 -0400 Subject: [PATCH 2/3] Never reset a binding's files or environment silently Address a second review of job contract attach. The job is always read first: another job type, and a plain re-attach of a bound webhook program, send only program_ref. On a service that does not report stored files and environment, re-binding a bound webhook program is refused unless the files and environment are given or --allow-reset accepts the reset; --replace needs --file, --clear-files or --allow-reset when the old binding has stored files. The stderr notes and the live hint are gone in favour of these refusals. --commit-output takes OWNER/NAME (the service picks the branch), a new --repo no longer drops a saved commit output silently, --repo naming the saved repository keeps its branch, and --file names follow the names the service stores. The published manifest size budget is raised to 106,496 bytes for the new options. Co-Authored-By: Claude Opus 5.5 --- cli/CHANGELOG.md | 8 +- cli/bun/src/core/service/jobs.ts | 211 +++++----- cli/bun/test/service-jobs.test.ts | 185 ++++---- .../framework/service-capabilities-json.json | 2 +- .../framework/service-capabilities-jsonl.json | 2 +- ...tract-attach-binding-changed-conflict.json | 171 ++++++++ ...ntract-attach-clear-repo-client-merge.json | 3 +- ...t-attach-commit-output-branch-refused.json | 62 +++ ...tract-attach-environment-server-merge.json | 164 ++++++++ ...job-contract-attach-file-name-invalid.json | 66 +++ .../jobs/job-contract-attach-human.json | 54 +++ ...b-contract-attach-input-without-value.json | 62 +++ .../job-contract-attach-model-refused.json | 2 +- ...ontract-attach-older-settings-refused.json | 143 +++++++ ...job-contract-attach-plain-bound-email.json | 160 +++++++ ...ract-attach-plain-bound-older-refused.json | 141 +++++++ ...tract-attach-plain-bound-server-merge.json | 59 ++- ...ct-attach-replace-drops-files-refused.json | 160 +++++++ ...ract-attach-repo-drops-output-refused.json | 163 ++++++++ ...ontract-attach-same-repo-keeps-branch.json | 165 ++++++++ ...job-contract-attach-schedule-rejected.json | 54 +++ ...t-attach-settings-non-webhook-refused.json | 2 +- ...ach-webhook-rebind-client-merge-human.json | 5 +- ...ct-attach-webhook-rebind-client-merge.json | 3 +- ...contract-attach-webhook-replace-human.json | 5 +- .../job-contract-attach-webhook-replace.json | 3 +- .../service/jobs/job-contract-attach.json | 54 +++ .../prose-runner-core/src/service/jobs.rs | 394 +++++++++++------- cli/shared/service/help.v1.json | 6 +- cli/shared/service/operations-public.v1.json | 2 +- cli/shared/service/operations.v1.json | 13 +- docs/service/jobs.md | 49 ++- 32 files changed, 2188 insertions(+), 385 deletions(-) create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-binding-changed-conflict.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-commit-output-branch-refused.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-environment-server-merge.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-file-name-invalid.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-input-without-value.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-older-settings-refused.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-plain-bound-email.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-plain-bound-older-refused.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-replace-drops-files-refused.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-repo-drops-output-refused.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-same-repo-keeps-branch.json diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 9dc8dadf..73722c04 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -42,7 +42,13 @@ a candidate do not establish public availability or authorize publication. Its plan quotes the hold for the binding as it will run. Re-attaching a bound program changes only the options given instead of resetting its settings. `--model` is no longer refused. The options are refused for other - job types. + job types, and a plain re-attach sends only `program_ref`. On a service that + does not report a binding's stored files and environment, re-binding a bound + webhook program is refused unless the files and environment are given or + `--allow-reset` accepts resetting them; `--replace` likewise needs `--file`, + `--clear-files` or `--allow-reset` when the old binding has stored files. +- The published `cli service operations` size budget is raised from 98,304 to + 106,496 bytes for the new job options. - An interrupt during a plan's advisory quote (`run submit`, `program draft`, `program save`, `job contract attach`) now stops the command instead of being swallowed. diff --git a/cli/bun/src/core/service/jobs.ts b/cli/bun/src/core/service/jobs.ts index 4101901b..7fa2035c 100644 --- a/cli/bun/src/core/service/jobs.ts +++ b/cli/bun/src/core/service/jobs.ts @@ -25,13 +25,15 @@ // service's endpoint is the secret-free job-id webhook path, carry the // absolute `endpoint_url` built from the environment origin. // - `job contract attach|detach` take a pinned `OWNER/SLUG@REV`. Attach -// options set a webhook binding's run settings (other job types are -// refused after reading the job). The job's listed contracts are always -// read first, so a re-attach keeps a bound program's saved settings: a -// service that lists `environment` merges a same-ref re-bind itself and is -// sent only the changes; otherwise the saved settings are merged here. A -// plan (--preview, or no --yes) also reads the job and carries an advisory -// quote for the binding as it will run. +// always reads the job and its listed contracts first. Attach options set +// a webhook binding's run settings (other job types are refused and take +// the program alone), and a re-attach keeps a bound program's saved +// settings: a service that lists `environment` merges a same-ref re-bind +// itself and is sent only the changes; otherwise the saved settings are +// merged here, which needs --allow-reset (or a file option and +// --environment) because such a service cannot report stored files or +// environment. A plan (--preview, or no --yes) carries an advisory quote +// for the binding as it will run. // - `job contract list` adds each binding's saved settings as // `run_configuration`, never the program text or file content. // @@ -44,7 +46,7 @@ import { encodeSegment, holdQuery, jsonObject, parseJson, requestFor, type Reque import type { Context } from "./index"; import { didYouMean, type Environment, type Json, type JsonObject } from "./manifest"; import { parseOwnAllowed, parseProgramRef, pinned, resolveToRun, validSlug } from "./program-ref"; -import { absoluteUrl, addIso, argvText, canonicalJson, isoMs, nextLine, usdCents, validText } from "./render"; +import { absoluteUrl, addIso, canonicalJson, isoMs, nextLine, usdCents, validText } from "./render"; import { commitNotRead, modelOption, parseInputs, parseRepository, repositoryUrl, sameRepository, tokenOption, validInputKey, type Repository } from "./runs"; /** Largest job spec or configuration file. */ @@ -1000,13 +1002,15 @@ async function contractDetach(context: Context): Promise { /** The `job contract attach` options that change a webhook binding's settings (with --replace, they need a webhook job). */ const BINDING_OPTIONS = [ "--model", "--reasoning-effort", "--repo", "--commit-output", "--clear-repo", "--clear-commit-output", - "--input", "--inputs-file", "--clear-input", "--environment", "--file", "--clear-files", "--replace", + "--input", "--inputs-file", "--clear-input", "--environment", "--file", "--clear-files", "--replace", "--allow-reset", ] as const; -const BINDING_FLAGS = new Set(["--clear-repo", "--clear-commit-output", "--clear-files"]); +const BINDING_FLAGS = new Set(["--clear-repo", "--clear-commit-output", "--clear-files", "--allow-reset"]); /** Stored binding files: at most 20, 5 MiB each and 10 MiB in total. */ const MAX_BINDING_FILES = 20; const MAX_BINDING_FILE_BYTES = 5 << 20; const MAX_BINDING_FILES_BYTES = 10 << 20; +/** A stored file name the service keeps as given. */ +const FILE_NAME = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,199}$/u; /** The settings options of one `job contract attach`, checked locally. */ interface Binding { @@ -1023,6 +1027,8 @@ interface Binding { clearInputs: string[]; environment?: string; replace?: string; + /** --allow-reset: resetting what an older service does not report is accepted. */ + allowReset: boolean; /** Any option above was given. */ any: boolean; } @@ -1046,8 +1052,9 @@ async function bindingOptions(context: Context): Promise { if (!validInputKey(key)) throw invocationFailure(`--clear-input ${quoteText(key)} must be an input name of 1 to 128 characters without control characters`); if (!clearInputs.includes(key)) clearInputs.push(key); } - for (const raw of context.optionValues("--input")) { - const key = raw.includes("=") ? raw.slice(0, raw.indexOf("=")) : raw; + // An --input without `=` fails as KEY=VALUE when the inputs are parsed. + for (const raw of context.optionValues("--input").filter((value) => value.includes("="))) { + const key = raw.slice(0, raw.indexOf("=")); if (clearInputs.includes(key)) throw invocationFailure(`--input ${quoteText(key)} and --clear-input ${quoteText(key)} cannot be combined: --input sets the input, --clear-input removes it`); } const replaceValue = context.option("--replace"); @@ -1055,6 +1062,7 @@ async function bindingOptions(context: Context): Promise { const commitValue = context.option("--commit-output"); const binding: Binding = { clearRepo, clearCommit, clearInputs, clearFiles, + allowReset: context.flag("--allow-reset"), inputs: await parseInputs(context), any: BINDING_OPTIONS.some(given), }; @@ -1063,7 +1071,10 @@ async function bindingOptions(context: Context): Promise { const effort = context.option("--reasoning-effort"); if (effort !== undefined) binding.effort = effort; if (repoValue !== undefined) binding.repo = parseRepository("--repo", repoValue); - if (commitValue !== undefined) binding.commit = parseRepository("--commit-output", commitValue); + if (commitValue !== undefined) { + binding.commit = parseRepository("--commit-output", commitValue); + if (binding.commit.branch !== undefined) throw invocationFailure(`--commit-output ${quoteText(commitValue)} takes OWNER/NAME: the service chooses the commit's branch`); + } const environment = tokenOption(context, "--environment", "an environment"); if (environment !== undefined) binding.environment = environment; if (replaceValue !== undefined) binding.replace = pinnedValue(context, replaceValue, "--replace"); @@ -1100,8 +1111,8 @@ async function bindingFiles(context: Context): Promise { const equals = raw.indexOf("="); const path = equals >= 0 ? raw.slice(equals + 1) : raw; const name = equals >= 0 ? raw.slice(0, equals) : baseName(path); - if (!validText(name, 256) || name.includes("/") || name.includes("\\")) { - throw invocationFailure(`--file ${quoteText(raw)}: the file name must be 1 to 256 characters without /, \\ or control characters; give it as NAME=PATH`); + if (!FILE_NAME.test(name) || name.includes("..")) { + throw invocationFailure(`--file ${quoteText(raw)}: the file name must be 1 to 200 letters, digits, ., _ or -, not starting with a dot and without ..; give it as NAME=PATH`); } const bytes = await readSource(context.cwd, path, MAX_BINDING_FILE_BYTES, "--file"); try { new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); } @@ -1117,15 +1128,15 @@ async function bindingFiles(context: Context): Promise { /** Whether two repository URLs name the same repository (GitHub names ignore case). */ const sameUrl = (left: string, right: string): boolean => left.toLowerCase() === right.toLowerCase(); -/** The commit output of `--commit-output`, committing to `url` (the repository the runs read). */ -function commitOutput(commit: Repository, url: string): JsonObject { - return commit.branch === undefined ? { type: "commit", repository: url } : { type: "commit", repository: url, branch: commit.branch }; +/** The commit output of `--commit-output`, committing to `url` (the repository the runs read); the service chooses the branch. */ +function commitOutput(url: string): JsonObject { + return { type: "commit", repository: url }; } /** A bound contract's saved settings as the service listed them; a wrong shape is SERVICE_PROTOCOL_INVALID. */ interface Saved { - /** The binding has stored files (their content is never listed). */ - files?: boolean; + /** How many files the binding stores (their content is never listed). */ + files: number; model?: string; effort?: string; environment?: string; @@ -1138,14 +1149,13 @@ function savedSettings(contract: JsonObject): Saved { const field = "contracts"; const text = (name: string, max: number): string | undefined => contract[name] === null || contract[name] === undefined ? undefined : scalar(contract[name], { text: max }, `${field}.${name}`) as string; - const saved: Saved = { inputs: {} }; + const saved: Saved = { inputs: {}, files: Array.isArray(contract.files) ? contract.files.length : 0 }; const model = contract.model === null || contract.model === undefined ? undefined : scalar(contract.model, "modelId", `${field}.model`) as string; if (model !== undefined) saved.model = model; const effort = text("reasoning_effort", 32); if (effort !== undefined) saved.effort = effort; const environment = text("environment", 64); if (environment !== undefined) saved.environment = environment; - saved.files = Array.isArray(contract.files) && contract.files.length > 0; if (contract.repositories !== null && contract.repositories !== undefined) { const first = array(contract.repositories, 16, `${field}.repositories`)[0]; if (first !== undefined) { @@ -1183,29 +1193,63 @@ function withoutOptions(argv: readonly string[], options: readonly string[], fla return kept; } -/** - * The POST body of `job contract attach` from the job's listed contracts. - * A re-bind of the same ref on a merging service sends only what the options - * change (clears as JSON nulls); otherwise the saved settings of the bound - * ref (or of the --replace target) are merged here and sent in full. An - * unbound ref sends only the options given. - */ /** The settings the runs of a binding use after an attach, as the plan quote prices them. */ interface Effective { model?: string; effort?: string; environment?: string; repository: boolean } -function attachBody(reference: string, binding: Binding, contracts: JsonObject[]): { body: JsonObject; note: string | undefined; effective: Effective } { +/** An INVOCATION_INVALID refusal with its own Action and no suggested command. */ +function refusal(reason: string, action: string): RunnerFailure { + const base = invocationFailure(reason); + return new RunnerFailure({ code: base.code, boundary: base.boundary, message: base.message, exitCode: base.exitCode, retryable: base.retryable, action, details: base.details ?? {} }); +} + +/** + * The POST body of `job contract attach` to a webhook job, from its listed + * contracts: + * - an unbound ref sends only the options given; + * - a bound ref with no options sends a bare program_ref on a merging + * service (a no-op there) and is refused on an older one; + * - a re-bind of the same ref with options sends, on a merging service, only + * what they change (clears as JSON nulls); on an older service the saved + * settings are merged here and sent in full; + * - a --replace onto another revision is a full replace from the replaced + * binding's saved settings, with its environment. + * An older service lists neither stored files nor environment, so a full + * re-post that would reset them needs --allow-reset (or both a file option + * and --environment). + */ +function attachBody(context: Context, id: string, reference: string, binding: Binding, contracts: JsonObject[]): { body: JsonObject; effective: Effective } { const base = contracts.find((contract) => contract.program_ref === (binding.replace ?? reference)); const saved = base === undefined ? undefined : savedSettings(base); // A service that lists environment (and file metadata) merges a re-bind itself. const merging = contracts.some((contract) => Object.hasOwn(contract, "environment")); + // A --replace of the attached ref itself is an ordinary re-attach. + const moved = binding.replace !== undefined && binding.replace !== reference; const repoUrl = binding.repo === undefined ? undefined : repositoryUrl(binding.repo); // The repository the runs read after this change. const effectiveUrl = repoUrl ?? (binding.clearRepo ? undefined : saved?.repository?.url); - if (binding.commit !== undefined && (effectiveUrl === undefined || !sameUrl(effectiveUrl, repositoryUrl(binding.commit)))) { - throw commitNotRead(binding.commit); - } - // A new repository drops a saved output that commits elsewhere. - const outputElsewhere = repoUrl !== undefined && typeof saved?.output?.repository === "string" && !sameUrl(saved.output.repository, repoUrl); + // --repo naming the saved repository without @BRANCH keeps its branch. + const sameRepo = repoUrl !== undefined && saved?.repository !== undefined && sameUrl(repoUrl, saved.repository.url); + const repoBranch = binding.repo?.branch ?? (sameRepo ? saved?.repository?.branch : undefined); + /** The commit output must go to the repository the runs read; a new repository must not leave a saved one pointing elsewhere. */ + const checkRepositories = (): void => { + if (binding.commit !== undefined && (effectiveUrl === undefined || !sameUrl(effectiveUrl, repositoryUrl(binding.commit)))) { + throw commitNotRead(binding.commit); + } + const savedOutput = typeof saved?.output?.repository === "string" ? saved.output.repository : undefined; + if (repoUrl !== undefined && !sameRepo && savedOutput !== undefined && !sameUrl(savedOutput, repoUrl) && binding.commit === undefined && !binding.clearCommit) { + throw invocationFailure(`the saved commit output goes to ${savedOutput}; with --repo give --commit-output OWNER/NAME or --clear-commit-output`); + } + }; + const savedEffective = (): Effective => { + const effective: Effective = { repository: effectiveUrl !== undefined }; + const model = binding.model ?? saved?.model; + if (model !== undefined) effective.model = model; + const effort = binding.effort ?? saved?.effort; + if (effort !== undefined) effective.effort = effort; + const environment = binding.environment ?? saved?.environment; + if (environment !== undefined) effective.environment = environment; + return effective; + }; const mergedInputs = (): JsonObject => { const inputs: JsonObject = { ...(saved?.inputs ?? {}) }; for (const [key, value] of binding.inputs) inputs[key] = value; @@ -1213,47 +1257,53 @@ function attachBody(reference: string, binding: Binding, contracts: JsonObject[] return inputs; }; const body: JsonObject = { program_ref: reference }; - // A --replace of the attached ref itself is an ordinary re-attach. - const moved = binding.replace !== undefined && binding.replace !== reference; + if (saved !== undefined && !moved && !binding.any) { + if (merging) return { body, effective: savedEffective() }; + throw refusal( + `${reference} is already bound to job ${humanSafeScalar(id)}; this service does not report its stored files or environment, so re-attaching it could reset them`, + `List its settings with \`${context.command(`job contract list ${id}`)}\`.`, + ); + } + if (saved !== undefined && !merging && !binding.allowReset + && !((binding.files !== undefined || binding.clearFiles) && binding.environment !== undefined)) { + throw refusal( + `this service does not report stored files or environment, so re-attaching ${reference} would reset them; give --file or --clear-files and --environment, or pass --allow-reset`, + "Give --file or --clear-files and --environment, or pass --allow-reset.", + ); + } + if (saved !== undefined && merging && moved && saved.files > 0 && binding.files === undefined && !binding.clearFiles && !binding.allowReset) { + throw invocationFailure(`replacing ${binding.replace!} with ${reference} drops ${binding.replace!}'s ${saved.files} stored file(s); give them with --file, or pass --clear-files or --allow-reset`); + } + checkRepositories(); if (saved !== undefined && merging && !moved) { body.replace_program_ref = reference; if (binding.model !== undefined) body.model = binding.model; if (binding.effort !== undefined) body.reasoning_effort = binding.effort; if (binding.repo !== undefined) { body.repository_url = repoUrl!; - body.repository_branch = binding.repo.branch ?? null; - if (outputElsewhere) body.output = null; + body.repository_branch = repoBranch ?? null; } if (binding.clearRepo) Object.assign(body, { repository_url: null, repository_branch: null, output: null }); if (binding.clearCommit) body.output = null; - if (binding.commit !== undefined) body.output = commitOutput(binding.commit, effectiveUrl!); + if (binding.commit !== undefined) body.output = commitOutput(effectiveUrl!); if (binding.inputs.size > 0 || binding.clearInputs.length > 0) body.inputs = mergedInputs(); if (binding.environment !== undefined) body.environment = binding.environment; if (binding.files !== undefined) body.files = binding.files; if (binding.clearFiles) body.files = null; // The service keeps what is not sent: the saved settings, with the options over them. - const effective: Effective = { repository: effectiveUrl !== undefined }; - const model = binding.model ?? saved.model; - if (model !== undefined) effective.model = model; - const effort = binding.effort ?? saved.effort; - if (effort !== undefined) effective.effort = effort; - const environment = binding.environment ?? saved.environment; - if (environment !== undefined) effective.environment = environment; - return { body, note: undefined, effective }; + return { body, effective: savedEffective() }; } const model = binding.model ?? saved?.model; if (model !== undefined) body.model = model; const effort = binding.effort ?? saved?.effort; if (effort !== undefined) body.reasoning_effort = effort; - const repository = binding.repo !== undefined - ? (binding.repo.branch === undefined ? { url: repoUrl! } : { url: repoUrl!, branch: binding.repo.branch }) - : (binding.clearRepo ? undefined : saved?.repository); + const repository = repoUrl !== undefined ? { url: repoUrl, branch: repoBranch } : (binding.clearRepo ? undefined : saved?.repository); if (repository !== undefined) { body.repository_url = repository.url; if (repository.branch !== undefined) body.repository_branch = repository.branch; } - const output = binding.commit !== undefined ? commitOutput(binding.commit, effectiveUrl!) - : (binding.clearRepo || binding.clearCommit || outputElsewhere ? undefined : saved?.output); + const output = binding.commit !== undefined ? commitOutput(effectiveUrl!) + : (binding.clearRepo || binding.clearCommit ? undefined : saved?.output); if (output !== undefined) body.output = output; const inputs = mergedInputs(); if (Object.keys(inputs).length > 0) body.inputs = inputs; @@ -1261,28 +1311,19 @@ function attachBody(reference: string, binding: Binding, contracts: JsonObject[] // carried too (an older service lists none). const environment = binding.environment ?? (moved ? saved?.environment : undefined); if (environment !== undefined) body.environment = environment; + // A full body carries no stored files: --clear-files is expressed by omission. if (binding.files !== undefined) body.files = binding.files; - // Clearing files of an unbound program changes nothing. - else if (binding.clearFiles && saved !== undefined) body.files = null; if (binding.replace !== undefined) body.replace_program_ref = binding.replace; const effective: Effective = { repository: repository !== undefined }; if (model !== undefined) effective.model = model; if (effort !== undefined) effective.effort = effort; if (environment !== undefined) effective.environment = environment; - // An older service lists neither stored files nor environment, so a - // client-merged re-bind cannot carry them; stored file content is never - // listed, so a move cannot carry the files either. - let note: string | undefined; - if (saved !== undefined && !merging) note = DROPPED_NOTE; - else if (moved && saved?.files === true && binding.files === undefined && !binding.clearFiles) note = FILES_NOTE; - return { body, note, effective }; + return { body, effective }; } /** Job types that always bind a repository, so their runs are quoted with one. */ const REPOSITORY_JOB_TYPES = ["github-issue-opened", "github-pull-request-opened", "github-release-published"]; -const DROPPED_NOTE = "note: this service does not report stored files or environment; re-attaching may drop them\n"; -const FILES_NOTE = "note: stored files are not carried to the new revision; pass --file to attach them\n"; async function contractAttach(context: Context): Promise { const id = jobId(context); @@ -1293,17 +1334,14 @@ async function contractAttach(context: Context): Promise { // Settings apply to webhook jobs only, and a plan's quote prices the // job's type: read the job first. const plan = context.invocation.preview || !context.invocation.yes; - let live = false; - let jobType: string | undefined; - if (binding.any || plan) { - const detail = projectDetail(await getJson(context, 0, jobPath), false, context.environment); - const kind = (detail.job as JsonObject).type; - if (typeof kind === "string") jobType = kind; - if (binding.any && kind !== "webhook") { - const error = invocationFailure(`job ${id} is a ${humanSafeScalar(typeof kind === "string" ? kind : "")} job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace and the --clear options) apply to webhook jobs only`); - throw context.corrected(error, "Attach without those options: `{command}`", withoutBindingOptions(context.invocation.argv)); - } - live = (detail.status as JsonObject | undefined)?.delivery_mode === "live"; + const detail = projectDetail(await getJson(context, 0, jobPath), false, context.environment); + const kind = (detail.job as JsonObject).type; + const jobType = typeof kind === "string" ? kind : undefined; + if (binding.any && jobType !== "webhook") { + const named = humanSafeScalar(jobType ?? ""); + const article = /^[aeiou]/u.test(named) ? "an" : "a"; + const error = invocationFailure(`job ${id} is ${article} ${named} job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace and the --clear options) apply to webhook jobs only`); + throw context.corrected(error, "Attach without those options: `{command}`", withoutBindingOptions(context.invocation.argv)); } // The listed contracts keep a bound program's saved settings. const listed = array((await getJson(context, 1, path)).contracts ?? null, 16, "contracts").map((contract) => object(contract, "contracts")); @@ -1316,7 +1354,10 @@ async function contractAttach(context: Context): Promise { const error = invocationFailure(`${reference} is already bound to job ${humanSafeScalar(id)}; --replace would reset its settings. Change it in place without --replace, or detach ${replaced} first`); throw context.corrected(error, "Change it in place without --replace: `{command}`", withoutOptions(context.invocation.argv, ["--replace"], new Set())); } - const { body, note, effective } = attachBody(reference, binding, listed); + // Any other job type takes the program alone. + const { body, effective } = jobType === "webhook" + ? attachBody(context, id, reference, binding, listed) + : { body: { program_ref: reference } as JsonObject, effective: { repository: false } as Effective }; const bytes = new TextEncoder().encode(canonicalJson(body)); const planned = context.planned(2, path, [], bytes); if (plan) { @@ -1331,24 +1372,8 @@ async function contractAttach(context: Context): Promise { } const gate = context.gate(planned); if (gate.kind === "preview") return gate.result; - let response: JsonObject; - try { response = jsonObject(await context.send({ ...requestFor(context.operation, 2, path), body: bytes })); } - catch (caught) { - // A live webhook may refuse binding changes: switching it to test delivery first allows them. - const message = caught instanceof RunnerFailure ? caught.details?.serviceMessage : undefined; - if (live && caught instanceof RunnerFailure && caught.code === "SERVICE_REQUEST_REJECTED" && typeof message === "string" && /immutable/iu.test(message)) { - const argv = context.followUpArgv(["job", "update", id, "--spec-file", "-", "--yes"]); - const stdin = canonicalJson({ delivery_mode: "test" }); - throw new RunnerFailure({ - code: caught.code, boundary: caught.boundary, message: caught.message, exitCode: caught.exitCode, retryable: caught.retryable, - action: `${caught.action} The job delivers live; to change its binding, switch it to test delivery first: \`${argvText(argv)}\` with details.suggestedStdin on standard input.`, - details: { ...(caught.details ?? {}), suggestedArgv: argv, suggestedStdin: `${stdin}\n` }, - }); - } - throw caught; - } + const response = jsonObject(await context.send({ ...requestFor(context.operation, 2, path), body: bytes })); if (response.bound !== reference) throw protocol("bound"); - if (note !== undefined && context.mode === "human") context.err(note); const settings = settingsLine(body); context.human = `Attached ${humanSafeScalar(reference)} to job ${humanSafeScalar(id)}.\n` + (settings === undefined ? "" : ` settings: ${settings}\n`) diff --git a/cli/bun/test/service-jobs.test.ts b/cli/bun/test/service-jobs.test.ts index 0d8361db..b27d274f 100644 --- a/cli/bun/test/service-jobs.test.ts +++ b/cli/bun/test/service-jobs.test.ts @@ -87,7 +87,11 @@ describe("Service job local checks send nothing", () => { [["--repo", "exowner1/app", "--commit-output", "exowner1/other"], "--commit-output exowner1/other must also be given as --repo exowner1/other[@BRANCH]"], [["--replace", "exowner1/probe"], "--replace: program reference"], [["--file", "a.md", "--clear-files"], "--file and --clear-files cannot be combined"], - [["--file", "dir/x=a.md"], '--file "dir/x=a.md": the file name must be 1 to 256 characters without /, \\ or control characters; give it as NAME=PATH'], + [["--file", "dir/x=a.md"], '--file "dir/x=a.md": the file name must be 1 to 200 letters, digits, ., _ or -, not starting with a dot and without ..; give it as NAME=PATH'], + [["--file", ".env=a.md"], '--file ".env=a.md": the file name must be'], + [["--file", "a..b=a.md"], '--file "a..b=a.md": the file name must be'], + [["--repo", "exowner1/app", "--commit-output", "exowner1/app@main"], '--commit-output "exowner1/app@main" takes OWNER/NAME: the service chooses the commit\'s branch'], + [["--input", "topic", "--clear-input", "topic"], '--input "topic" must be KEY=VALUE or KEY=@FILE'], [["--file", "a.md", "--file", "a.md=a.md"], '--file name "a.md" was given more than once; name each file uniquely with NAME=PATH'], [["--file", "missing.md"], '--file "missing.md"'], [["--file", "dir/"], '--file "dir/" is not a readable file'], @@ -121,133 +125,108 @@ describe("Service job secrets", () => { describe("Service job contract settings", () => { const REF = "exowner1/probe@0123456789abcdef"; + const NEXT = "exowner1/probe@fedcba9876543210"; const contractsPath = `/triggers/${TID}/contracts`; - const webhook = (deliveryMode: string) => ({ + const jobRead = (type = "webhook") => ({ method: "GET", path: `/triggers/${TID}`, status: 200, - body: { trigger: { id: TID, type: "webhook", createdAt: 1 }, status: { deliveryMode } }, + body: { trigger: { id: TID, type, createdAt: 1 }, status: { deliveryMode: "test" } }, }); const listing = (contracts: unknown[]) => ({ method: "GET", path: contractsPath, status: 200, body: { contracts, max_contracts: 5 } }); - const bound = (extra: Record) => ({ + // A bound binding as an older service lists it (no environment or files); pass them for a merging service. + const bound = (extra: Record = {}) => ({ program_ref: REF, owner: "exowner1", slug: "probe", rev_id: "0123456789abcdef", content: "# private", is_platform_default: false, enabled: true, bound_at: 1, inputs: { keep: "1", count: 3 }, model: null, effective_model: "model-luna", reasoning_effort: null, repositories: [{ url: "https://github.com/exowner1/app", branch: "main" }], output: { type: "commit", repository: "https://github.com/exowner1/app" }, ...extra, }); + const merging = (extra: Record = {}) => bound({ environment: "linux", files: [{ name: "a.md", size: 1 }], ...extra }); const fixture = (exchanges: unknown[]) => ({ environment: "production", credentials: { production: KEY }, storeAvailable: true, exchanges }); const post = (expectedBody: unknown, status = 201, body: unknown = { bound: REF }) => ({ method: "POST", path: contractsPath, expectedBody, status, body }); + const quote = (query: Record) => ({ method: "GET", path: "/run/quote", status: 200, body: { hold: { hold_usd: "0.06", ttl_seconds: 900 } }, query }); + const attach = (args: string[], exchanges: unknown[], options: { human?: boolean; files?: Record } = {}) => + job(["contract", "attach", TID, ...args], { ...options, fixture: fixture(exchanges) }); - test("a merging service gets a new repository with a null branch and the output that committed elsewhere cleared", async () => { - const result = await job(["contract", "attach", TID, REF, "--repo", "exowner1/other", "--yes"], { - fixture: fixture([webhook("test"), listing([bound({ environment: null, files: [] })]), - post({ program_ref: REF, replace_program_ref: REF, repository_url: "https://github.com/exowner1/other", repository_branch: null, output: null })]), - }); - expect(result.exit).toBe(0); - }); - - test("an older service gets the saved settings merged in full, non-string inputs unchanged", async () => { - const result = await job(["contract", "attach", TID, REF, "--commit-output", "exowner1/app@out", "--clear-input", "keep", "--yes"], { - fixture: fixture([webhook("test"), listing([bound({})]), - post({ program_ref: REF, repository_url: "https://github.com/exowner1/app", repository_branch: "main", inputs: { count: 3 }, output: { type: "commit", repository: "https://github.com/exowner1/app", branch: "out" } })]), - }); - expect(result.exit).toBe(0); + test("a merging service: a new repository without a branch sends a null branch; the saved one keeps its branch", async () => { + const other = await attach([REF, "--repo", "exowner1/other", "--clear-commit-output", "--yes"], [jobRead(), listing([merging()]), + post({ program_ref: REF, replace_program_ref: REF, repository_url: "https://github.com/exowner1/other", repository_branch: null, output: null })]); + expect(other.exit).toBe(0); + const same = await attach([REF, "--repo", "exowner1/App", "--yes"], [jobRead(), listing([merging()]), + post({ program_ref: REF, replace_program_ref: REF, repository_url: "https://github.com/exowner1/App", repository_branch: "main" })]); + expect(same.exit).toBe(0); }); - test("a commit output needs a repository the runs read", async () => { - const result = await job(["contract", "attach", TID, REF, "--commit-output", "exowner1/app", "--yes"], { - fixture: fixture([webhook("test"), listing([])]), - }); + test("a new repository with the saved commit output elsewhere is refused", async () => { + const result = await attach([REF, "--repo", "exowner1/other", "--yes"], [jobRead(), listing([merging()])]); expect(result.exit).toBe(2); - expect(result.report!.problem.details.reason).toStartWith("--commit-output exowner1/app must also be given as --repo"); + expect(result.report!.problem.details.reason).toBe("the saved commit output goes to https://github.com/exowner1/app; with --repo give --commit-output OWNER/NAME or --clear-commit-output"); + }); + + test("an older service: a plain re-attach is refused; settings need --allow-reset or a file option with --environment", async () => { + const plain = await attach([REF, "--yes"], [jobRead(), listing([bound()])]); + expect(plain.exit).toBe(2); + expect(plain.report!.problem.details.reason).toBe(`${REF} is already bound to job ${TID}; this service does not report its stored files or environment, so re-attaching it could reset them`); + expect(plain.report!.problem.action).toBe(`List its settings with \`prose --output json cli job contract list ${TID}\`.`); + expect(plain.report!.problem.details).not.toHaveProperty("suggestedArgv"); + const settings = await attach([REF, "--reasoning-effort", "high", "--yes"], [jobRead(), listing([bound()])]); + expect(settings.report!.problem.details.reason).toBe(`this service does not report stored files or environment, so re-attaching ${REF} would reset them; give --file or --clear-files and --environment, or pass --allow-reset`); + expect(settings.report!.problem.action).toBe("Give --file or --clear-files and --environment, or pass --allow-reset."); + expect(settings.report!.problem.details).not.toHaveProperty("suggestedArgv"); + const full = await attach([REF, "--commit-output", "exowner1/APP", "--clear-input", "keep", "--clear-files", "--environment", "linux", "--yes"], [jobRead(), listing([bound()]), + post({ program_ref: REF, repository_url: "https://github.com/exowner1/app", repository_branch: "main", inputs: { count: 3 }, output: { type: "commit", repository: "https://github.com/exowner1/app" }, environment: "linux" })]); + expect(full.exit).toBe(0); + }); + + test("a non-webhook job takes the program alone, bound or not, and refuses settings with the right article", async () => { + const plain = await attach([REF, "--yes"], [jobRead("email"), listing([merging()]), post({ program_ref: REF })]); + expect(plain.exit).toBe(0); + const refused = await attach([REF, "--allow-reset", "--yes"], [jobRead("schedule")]); + expect(refused.report!.problem.details.reason).toStartWith(`job ${TID} is a schedule job; run settings`); + const email = await attach([REF, "--model", "model-luna", "--yes"], [jobRead("email")]); + expect(email.report!.problem.details.reason).toStartWith(`job ${TID} is an email job; run settings`); + expect(email.report!.problem.details.suggestedArgv).toEqual(["--output", "json", "cli", "job", "contract", "attach", TID, REF, "--yes"]); + }); + + test("a move to another revision is a full replace and must account for the stored files", async () => { + const refused = await attach([NEXT, "--replace", REF, "--yes"], [jobRead(), listing([merging()])]); + expect(refused.exit).toBe(2); + expect(refused.report!.problem.details.reason).toBe(`replacing ${REF} with ${NEXT} drops ${REF}'s 1 stored file(s); give them with --file, or pass --clear-files or --allow-reset`); + const moved = await attach([NEXT, "--replace", REF, "--file", "a.md", "--yes"], [jobRead(), listing([merging()]), + { ...post({ + program_ref: NEXT, replace_program_ref: REF, environment: "linux", repository_url: "https://github.com/exowner1/app", repository_branch: "main", + inputs: { keep: "1", count: 3 }, output: { type: "commit", repository: "https://github.com/exowner1/app" }, files: { "a.md": "aGkK" }, + }), body: { bound: NEXT } }], { files: { "a.md": "hi\n" }, human: true }); + expect(moved.exit).toBe(0); + expect(moved.stderr).toBe(""); + expect(moved.stdout).toContain(" settings: repository https://github.com/exowner1/app@main; commit output https://github.com/exowner1/app; inputs count, keep; files a.md\n"); }); - test("a live job's refusal names switching it to test delivery", async () => { - const result = await job(["contract", "attach", TID, REF, "--environment", "linux", "--yes"], { - fixture: fixture([webhook("live"), listing([]), post({ program_ref: REF, environment: "linux" }, 400, { error: "execution configuration is immutable" })]), - }); + test("an object-valued saved input is SERVICE_PROTOCOL_INVALID and nothing is sent", async () => { + const result = await attach([REF, "--yes"], [jobRead(), listing([merging({ inputs: { a: [1] } })])]); expect(result.exit).toBe(10); - const problem = result.report!.problem; - expect(problem.code).toBe("SERVICE_REQUEST_REJECTED"); - expect(problem.details.suggestedArgv).toEqual(["--output", "json", "cli", "job", "update", TID, "--spec-file", "-", "--yes"]); - expect(problem.details.suggestedStdin).toBe('{"delivery_mode":"test"}\n'); - expect(problem.action).toEndWith(`The job delivers live; to change its binding, switch it to test delivery first: \`prose --output json cli job update ${TID} --spec-file - --yes\` with details.suggestedStdin on standard input.`); - }); - - test("the preview plan digests the merged body, summarizes it and quotes the binding as it will run", async () => { - const quote = { - method: "GET", path: "/run/quote", status: 200, body: { hold: { hold_usd: "0.06", ttl_seconds: 900 } }, - query: { program_ref: REF, reasoning_effort: "low", job_type: "webhook" }, - }; - const result = await job(["contract", "attach", TID, REF, "--input", "topic=cats", "--reasoning-effort", "low", "--preview"], { - fixture: fixture([webhook("test"), listing([]), quote]), - }); - expect(result.exit).toBe(0); - expect(result.report!.result.plannedRequest.summary).toEqual({ programRef: REF, reasoning_effort: "low", inputKeys: ["topic"] }); - expect(result.report!.result.plannedRequest.quote).toEqual({ hold: { hold_usd: "0.06", hold_cents: 6, ttl_seconds: 900 } }); + expect(result.report!.problem.details.reason).toBe("unexpected job response: contracts[0].inputs.a"); }); - test("a server-merge plan quotes the saved settings with a cleared repository removed", async () => { - const quote = { - method: "GET", path: "/run/quote", status: 200, body: { hold: { hold_usd: "0.06", ttl_seconds: 900 } }, - query: { program_ref: REF, model: "model-sol", environment: "linux", job_type: "webhook" }, - }; - const result = await job(["contract", "attach", TID, REF, "--clear-repo", "--model", "model-sol", "--preview"], { - fixture: fixture([webhook("test"), listing([bound({ environment: "linux" })]), quote]), - }); - expect(result.exit).toBe(0); - expect(result.report!.result.plannedRequest.quote.hold.hold_usd).toBe("0.06"); + test("a refusal passes through without a delivery-mode hint", async () => { + const result = await attach([REF, "--environment", "linux", "--yes"], [jobRead(), listing([]), post({ program_ref: REF, environment: "linux" }, 400, { error: "execution configuration is immutable" })]); + expect(result.exit).toBe(10); + expect(result.report!.problem.details).not.toHaveProperty("suggestedStdin"); }); - test("files replace the stored set; clearing an unbound program's files sends nothing", async () => { - const sent = await job(["contract", "attach", TID, REF, "--file", "a.md", "--clear-files", "--yes"], { files: { "a.md": "a" } }); - expect(sent.exit).toBe(2); - const unbound = await job(["contract", "attach", TID, REF, "--clear-files", "--yes"], { - fixture: fixture([webhook("test"), listing([]), post({ program_ref: REF })]), - }); - expect(unbound.exit).toBe(0); - const older = await job(["contract", "attach", TID, REF, "--file", "a.md", "--yes"], { - files: { "a.md": "hi\n" }, human: true, - fixture: fixture([webhook("test"), listing([bound({})]), - post({ program_ref: REF, repository_url: "https://github.com/exowner1/app", repository_branch: "main", inputs: { keep: "1", count: 3 }, output: { type: "commit", repository: "https://github.com/exowner1/app" }, files: { "a.md": "aGkK" } })]), - }); - expect(older.exit).toBe(0); - expect(older.stdout).toContain(" settings: repository https://github.com/exowner1/app@main; commit output https://github.com/exowner1/app; inputs count, keep; files a.md\n"); + test("the preview plan digests the body, summarizes it and quotes the binding as it will run", async () => { + const fresh = await attach([REF, "--input", "topic=cats", "--reasoning-effort", "low", "--preview"], + [jobRead(), listing([]), quote({ program_ref: REF, reasoning_effort: "low", job_type: "webhook" })]); + expect(fresh.exit).toBe(0); + expect(fresh.report!.result.plannedRequest.summary).toEqual({ programRef: REF, reasoning_effort: "low", inputKeys: ["topic"] }); + expect(fresh.report!.result.plannedRequest.quote).toEqual({ hold: { hold_usd: "0.06", hold_cents: 6, ttl_seconds: 900 } }); + const merged = await attach([REF, "--clear-repo", "--model", "model-sol", "--preview"], + [jobRead(), listing([merging()]), quote({ program_ref: REF, model: "model-sol", environment: "linux", job_type: "webhook" })]); + expect(merged.report!.result.plannedRequest.quote.hold.hold_usd).toBe("0.06"); }); test("--replace onto another program that is already bound is refused before the binding is sent", async () => { - const other = "exowner1/probe@fedcba9876543210"; - const result = await job(["contract", "attach", TID, other, "--replace=" + REF, "--yes"], { - fixture: fixture([webhook("test"), listing([bound({}), bound({ program_ref: other })])]), - }); + const result = await attach([NEXT, "--replace=" + REF, "--yes"], [jobRead(), listing([merging(), merging({ program_ref: NEXT })])]); expect(result.exit).toBe(2); - expect(result.report!.problem.details.reason).toBe(`${other} is already bound to job ${TID}; --replace would reset its settings. Change it in place without --replace, or detach ${REF} first`); - expect(result.report!.problem.details.suggestedArgv).toEqual(["--output", "json", "cli", "job", "contract", "attach", TID, other, "--yes"]); - }); - - test("a move to another revision is a full replace: saved settings and environment are sent, files are noted", async () => { - const next = "exowner1/probe@fedcba9876543210"; - const result = await job(["contract", "attach", TID, next, "--replace", REF, "--yes"], { - human: true, - fixture: fixture([webhook("test"), listing([bound({ environment: "linux", files: [{ name: "a.md", size: 1 }] })]), - { method: "POST", path: contractsPath, status: 201, body: { bound: next }, expectedBody: { - program_ref: next, replace_program_ref: REF, environment: "linux", repository_url: "https://github.com/exowner1/app", repository_branch: "main", - inputs: { keep: "1", count: 3 }, output: { type: "commit", repository: "https://github.com/exowner1/app" }, - } }]), - }); - expect(result.exit).toBe(0); - expect(result.stderr).toBe("note: stored files are not carried to the new revision; pass --file to attach them\n"); - }); - - test("an object-valued saved input is SERVICE_PROTOCOL_INVALID and nothing is sent", async () => { - const result = await job(["contract", "attach", TID, REF, "--yes"], { fixture: fixture([listing([bound({ environment: null, inputs: { a: [1] } })])]) }); - expect(result.exit).toBe(10); - expect(result.report!.problem.details.reason).toBe("unexpected job response: contracts[0].inputs.a"); - }); - - test("the live hint is added only for an immutable-configuration refusal", async () => { - const result = await job(["contract", "attach", TID, REF, "--environment", "linux", "--yes"], { - fixture: fixture([webhook("live"), listing([]), post({ program_ref: REF, environment: "linux" }, 400, { error: "environment is not offered" })]), - }); - expect(result.exit).toBe(10); - expect(result.report!.problem.details).not.toHaveProperty("suggestedStdin"); + expect(result.report!.problem.details.reason).toBe(`${NEXT} is already bound to job ${TID}; --replace would reset its settings. Change it in place without --replace, or detach ${REF} first`); + expect(result.report!.problem.details.suggestedArgv).toEqual(["--output", "json", "cli", "job", "contract", "attach", TID, NEXT, "--yes"]); }); }); diff --git a/cli/conformance/cases/service/framework/service-capabilities-json.json b/cli/conformance/cases/service/framework/service-capabilities-json.json index 6b19122f..d86e405f 100644 --- a/cli/conformance/cases/service/framework/service-capabilities-json.json +++ b/cli/conformance/cases/service/framework/service-capabilities-json.json @@ -203,7 +203,7 @@ "--json" ], "schema": "openprose.service-operations/1", - "sha256": "ff313c6f91b301039bb3688ad59464da1c10c5a6a13abff1d20b73550501da0b" + "sha256": "66a3a4190335950c43afde9c54cbf21a505f5189353d6d367a50352a11b31ab3" }, "nouns": { "auth": [ diff --git a/cli/conformance/cases/service/framework/service-capabilities-jsonl.json b/cli/conformance/cases/service/framework/service-capabilities-jsonl.json index 31fd9a3f..5f322a8a 100644 --- a/cli/conformance/cases/service/framework/service-capabilities-jsonl.json +++ b/cli/conformance/cases/service/framework/service-capabilities-jsonl.json @@ -209,7 +209,7 @@ "--json" ], "schema": "openprose.service-operations/1", - "sha256": "ff313c6f91b301039bb3688ad59464da1c10c5a6a13abff1d20b73550501da0b" + "sha256": "66a3a4190335950c43afde9c54cbf21a505f5189353d6d367a50352a11b31ab3" }, "nouns": { "auth": [ diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-binding-changed-conflict.json b/cli/conformance/cases/service/jobs/job-contract-attach-binding-changed-conflict.json new file mode 100644 index 00000000..aeba7b6e --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-binding-changed-conflict.json @@ -0,0 +1,171 @@ +{ + "id": "job-contract-attach-binding-changed-conflict", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "A binding changed concurrently: the service's 409 passes through.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--reasoning-effort", + "high", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "replace_program_ref": "exowner1/jobs-example@0123456789abcdef", + "reasoning_effort": "high" + }, + "status": 409, + "body": { + "error": "The binding changed; reload and retry.", + "code": "binding_changed" + } + } + ] + }, + "exitCode": 10, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Read the current state of the resource, reconcile the change, then retry.", + "boundary": "hosted-service", + "code": "SERVICE_WRITE_CONFLICT", + "details": { + "serviceMessage": "The binding changed; reload and retry.", + "serviceStatus": 409 + }, + "exitCode": 10, + "message": "The OpenProse service reported a conflicting write.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-clear-repo-client-merge.json b/cli/conformance/cases/service/jobs/job-contract-attach-clear-repo-client-merge.json index 71b4fcb8..a520e892 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach-clear-repo-client-merge.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach-clear-repo-client-merge.json @@ -2,7 +2,7 @@ "id": "job-contract-attach-clear-repo-client-merge", "feature": "jobs", "operation": "job.contract.attach", - "description": "On a service that does not report environment, --clear-repo leaves the repository and commit output out of the full binding.", + "description": "On a service that does not report environment, --clear-repo leaves the repository and commit output out of the full binding, with --allow-reset.", "argv": [ "--output", "json", @@ -13,6 +13,7 @@ "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", "exowner1/jobs-example@0123456789abcdef", "--clear-repo", + "--allow-reset", "--yes" ], "fixture": { diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-commit-output-branch-refused.json b/cli/conformance/cases/service/jobs/job-contract-attach-commit-output-branch-refused.json new file mode 100644 index 00000000..43345ba6 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-commit-output-branch-refused.json @@ -0,0 +1,62 @@ +{ + "id": "job-contract-attach-commit-output-branch-refused", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "--commit-output takes OWNER/NAME: the service chooses the commit's branch.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--repo", + "exowner1/app", + "--commit-output", + "exowner1/app@main", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Correct the value named in Detail; `prose --output json cli job contract attach --help` shows the accepted syntax.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "--commit-output \"exowner1/app@main\" takes OWNER/NAME: the service chooses the commit's branch", + "suggestedArgv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "--help" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-environment-server-merge.json b/cli/conformance/cases/service/jobs/job-contract-attach-environment-server-merge.json new file mode 100644 index 00000000..3acf0239 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-environment-server-merge.json @@ -0,0 +1,164 @@ +{ + "id": "job-contract-attach-environment-server-merge", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "--environment on a bound webhook binding sends only the environment.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--environment", + "linux", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "replace_program_ref": "exowner1/jobs-example@0123456789abcdef", + "environment": "linux" + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + } + ] + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-file-name-invalid.json b/cli/conformance/cases/service/jobs/job-contract-attach-file-name-invalid.json new file mode 100644 index 00000000..d46bc3ee --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-file-name-invalid.json @@ -0,0 +1,66 @@ +{ + "id": "job-contract-attach-file-name-invalid", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "A --file name the service would rename (a leading dot) is refused before any request.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--file", + ".env=notes.md", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Correct the value named in Detail; `prose --output json cli job contract attach --help` shows the accepted syntax.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "--file \".env=notes.md\": the file name must be 1 to 200 letters, digits, ., _ or -, not starting with a dot and without ..; give it as NAME=PATH", + "suggestedArgv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "--help" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + }, + "files": [ + { + "path": "notes.md", + "content": "Reply in the house style.\n" + } + ] +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-human.json b/cli/conformance/cases/service/jobs/job-contract-attach-human.json index 909de8b7..b9af0231 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach-human.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach-human.json @@ -19,6 +19,60 @@ }, "storeAvailable": true, "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, { "method": "GET", "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-input-without-value.json b/cli/conformance/cases/service/jobs/job-contract-attach-input-without-value.json new file mode 100644 index 00000000..6835938c --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-input-without-value.json @@ -0,0 +1,62 @@ +{ + "id": "job-contract-attach-input-without-value", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "An --input without = fails as KEY=VALUE even when the same word is cleared.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--input", + "topic", + "--clear-input", + "topic", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Correct the value named in Detail; `prose --output json cli job contract attach --help` shows the accepted syntax.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "--input \"topic\" must be KEY=VALUE or KEY=@FILE", + "suggestedArgv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "--help" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-model-refused.json b/cli/conformance/cases/service/jobs/job-contract-attach-model-refused.json index 96b5a376..8b84fb33 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach-model-refused.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach-model-refused.json @@ -89,7 +89,7 @@ "boundary": "invocation", "code": "INVOCATION_INVALID", "details": { - "reason": "job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 is a email job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace and the --clear options) apply to webhook jobs only", + "reason": "job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 is an email job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace and the --clear options) apply to webhook jobs only", "suggestedArgv": [ "--output", "json", diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-older-settings-refused.json b/cli/conformance/cases/service/jobs/job-contract-attach-older-settings-refused.json new file mode 100644 index 00000000..1b8971ae --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-older-settings-refused.json @@ -0,0 +1,143 @@ +{ + "id": "job-contract-attach-older-settings-refused", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "On an older service, changing a bound program's settings needs --file/--clear-files with --environment, or --allow-reset.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--reasoning-effort", + "high", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + } + ] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Give --file or --clear-files and --environment, or pass --allow-reset.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "this service does not report stored files or environment, so re-attaching exowner1/jobs-example@0123456789abcdef would reset them; give --file or --clear-files and --environment, or pass --allow-reset" + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-plain-bound-email.json b/cli/conformance/cases/service/jobs/job-contract-attach-plain-bound-email.json new file mode 100644 index 00000000..93121bfa --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-plain-bound-email.json @@ -0,0 +1,160 @@ +{ + "id": "job-contract-attach-plain-bound-email", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "Re-attaching a bound program to a non-webhook job sends a bare program_ref, never replace_program_ref.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "email", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + } + ] + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-plain-bound-older-refused.json b/cli/conformance/cases/service/jobs/job-contract-attach-plain-bound-older-refused.json new file mode 100644 index 00000000..388f2172 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-plain-bound-older-refused.json @@ -0,0 +1,141 @@ +{ + "id": "job-contract-attach-plain-bound-older-refused", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "On a service that does not report stored files or environment, a plain re-attach of a bound program is refused: it could reset them.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + } + ] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "List its settings with `prose --output json cli job contract list 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10`.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "exowner1/jobs-example@0123456789abcdef is already bound to job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10; this service does not report its stored files or environment, so re-attaching it could reset them" + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-plain-bound-server-merge.json b/cli/conformance/cases/service/jobs/job-contract-attach-plain-bound-server-merge.json index 81c2dbe2..d6ab4048 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach-plain-bound-server-merge.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach-plain-bound-server-merge.json @@ -2,7 +2,7 @@ "id": "job-contract-attach-plain-bound-server-merge", "feature": "jobs", "operation": "job.contract.attach", - "description": "A plain attach of a bound program on a merging service re-binds the same ref and changes nothing.", + "description": "A plain attach of a bound program on a merging service sends a bare program_ref, which the service treats as a no-op.", "argv": [ "--output", "json", @@ -21,6 +21,60 @@ }, "storeAvailable": true, "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, { "method": "GET", "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", @@ -75,8 +129,7 @@ } }, "expectedBody": { - "program_ref": "exowner1/jobs-example@0123456789abcdef", - "replace_program_ref": "exowner1/jobs-example@0123456789abcdef" + "program_ref": "exowner1/jobs-example@0123456789abcdef" }, "status": 201, "body": { diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-replace-drops-files-refused.json b/cli/conformance/cases/service/jobs/job-contract-attach-replace-drops-files-refused.json new file mode 100644 index 00000000..7c1cf143 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-replace-drops-files-refused.json @@ -0,0 +1,160 @@ +{ + "id": "job-contract-attach-replace-drops-files-refused", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "--replace onto another revision drops the stored files; without --file, --clear-files or --allow-reset it is refused.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@fedcba9876543210", + "--replace", + "exowner1/jobs-example@0123456789abcdef", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + } + ] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Correct the value named in Detail; `prose --output json cli job contract attach --help` shows the accepted syntax.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "replacing exowner1/jobs-example@0123456789abcdef with exowner1/jobs-example@fedcba9876543210 drops exowner1/jobs-example@0123456789abcdef's 1 stored file(s); give them with --file, or pass --clear-files or --allow-reset", + "suggestedArgv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "--help" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-repo-drops-output-refused.json b/cli/conformance/cases/service/jobs/job-contract-attach-repo-drops-output-refused.json new file mode 100644 index 00000000..5a29cac8 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-repo-drops-output-refused.json @@ -0,0 +1,163 @@ +{ + "id": "job-contract-attach-repo-drops-output-refused", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "A new --repo while the saved commit output goes to another repository is refused: give --commit-output or --clear-commit-output.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--repo", + "exowner1/other", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": { + "type": "commit", + "repository": "https://github.com/exowner1/app" + }, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + } + ] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Correct the value named in Detail; `prose --output json cli job contract attach --help` shows the accepted syntax.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "the saved commit output goes to https://github.com/exowner1/app; with --repo give --commit-output OWNER/NAME or --clear-commit-output", + "suggestedArgv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "--help" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-same-repo-keeps-branch.json b/cli/conformance/cases/service/jobs/job-contract-attach-same-repo-keeps-branch.json new file mode 100644 index 00000000..e27067d4 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-same-repo-keeps-branch.json @@ -0,0 +1,165 @@ +{ + "id": "job-contract-attach-same-repo-keeps-branch", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "--repo naming the saved repository without @BRANCH keeps its saved branch.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--repo", + "EXOWNER1/app", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "replace_program_ref": "exowner1/jobs-example@0123456789abcdef", + "repository_url": "https://github.com/EXOWNER1/app", + "repository_branch": "main" + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + } + ] + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-schedule-rejected.json b/cli/conformance/cases/service/jobs/job-contract-attach-schedule-rejected.json index dda83811..3427816a 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach-schedule-rejected.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach-schedule-rejected.json @@ -21,6 +21,60 @@ }, "storeAvailable": true, "exchanges": [ + { + "method": "GET", + "path": "/triggers/7b2e4d19-6a3c-4e85-b1f0-9c4d8e2a6b73", + "status": 200, + "body": { + "trigger": { + "id": "7b2e4d19-6a3c-4e85-b1f0-9c4d8e2a6b73", + "type": "schedule", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, { "method": "GET", "path": "/triggers/7b2e4d19-6a3c-4e85-b1f0-9c4d8e2a6b73/contracts", diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-settings-non-webhook-refused.json b/cli/conformance/cases/service/jobs/job-contract-attach-settings-non-webhook-refused.json index 50fd8ff1..e8e0c21d 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach-settings-non-webhook-refused.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach-settings-non-webhook-refused.json @@ -89,7 +89,7 @@ "boundary": "invocation", "code": "INVOCATION_INVALID", "details": { - "reason": "job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 is a email job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace and the --clear options) apply to webhook jobs only", + "reason": "job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 is an email job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace and the --clear options) apply to webhook jobs only", "suggestedArgv": [ "--output", "json", diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-client-merge-human.json b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-client-merge-human.json index c123a9ff..2121a3f1 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-client-merge-human.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-client-merge-human.json @@ -2,7 +2,7 @@ "id": "job-contract-attach-webhook-rebind-client-merge-human", "feature": "jobs", "operation": "job.contract.attach", - "description": "The client-merge path warns that stored files and environment cannot be kept.", + "description": "The client-merge path warns that stored files and environment cannot be kept, with --allow-reset.", "argv": [ "cli", "job", @@ -12,6 +12,7 @@ "exowner1/jobs-example@0123456789abcdef", "--reasoning-effort", "high", + "--allow-reset", "--yes" ], "fixture": { @@ -143,6 +144,6 @@ "text": "Attached exowner1/jobs-example@0123456789abcdef to job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10.\n settings: reasoning effort high; repository https://github.com/exowner1/app@main; inputs channel, cost_center\nList the job's contracts with `cli job contract list 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10`.\n" }, "stderr": { - "text": "note: this service does not report stored files or environment; re-attaching may drop them\n" + "text": "" } } diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-client-merge.json b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-client-merge.json index 97125623..de6c7e6c 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-client-merge.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-rebind-client-merge.json @@ -2,7 +2,7 @@ "id": "job-contract-attach-webhook-rebind-client-merge", "feature": "jobs", "operation": "job.contract.attach", - "description": "On a service that does not report environment, re-attaching merges the saved settings in the client and sends them in full.", + "description": "On a service that does not report environment, re-attaching merges the saved settings in the client and sends them in full, with --allow-reset.", "argv": [ "--output", "json", @@ -14,6 +14,7 @@ "exowner1/jobs-example@0123456789abcdef", "--reasoning-effort", "high", + "--allow-reset", "--yes" ], "fixture": { diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-replace-human.json b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-replace-human.json index 77c2e93b..cd0e7692 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-replace-human.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-replace-human.json @@ -2,7 +2,7 @@ "id": "job-contract-attach-webhook-replace-human", "feature": "jobs", "operation": "job.contract.attach", - "description": "Human --replace output notes that stored files are not carried to the new revision.", + "description": "--replace moves a webhook binding to another revision: a full replace carrying the old binding's model, reasoning effort, repository, inputs and environment; --clear-files accepts that stored files are not carried.", "argv": [ "cli", "job", @@ -12,6 +12,7 @@ "exowner1/jobs-example@fedcba9876543210", "--replace", "exowner1/jobs-example@0123456789abcdef", + "--clear-files", "--yes" ], "fixture": { @@ -153,6 +154,6 @@ "text": "Attached exowner1/jobs-example@fedcba9876543210 to job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10.\n settings: reasoning effort low; repository https://github.com/exowner1/app@main; inputs channel, cost_center\nList the job's contracts with `cli job contract list 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10`.\n" }, "stderr": { - "text": "note: stored files are not carried to the new revision; pass --file to attach them\n" + "text": "" } } diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-replace.json b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-replace.json index e50397e0..b83fcda9 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach-webhook-replace.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach-webhook-replace.json @@ -2,7 +2,7 @@ "id": "job-contract-attach-webhook-replace", "feature": "jobs", "operation": "job.contract.attach", - "description": "--replace moves a webhook binding to another revision: a full replace that carries the replaced binding's model, reasoning effort, repository, inputs and environment; stored files cannot be carried.", + "description": "--replace moves a webhook binding to another revision: a full replace carrying the old binding's model, reasoning effort, repository, inputs and environment; --clear-files accepts that stored files are not carried.", "argv": [ "--output", "json", @@ -14,6 +14,7 @@ "exowner1/jobs-example@fedcba9876543210", "--replace", "exowner1/jobs-example@0123456789abcdef", + "--clear-files", "--yes" ], "fixture": { diff --git a/cli/conformance/cases/service/jobs/job-contract-attach.json b/cli/conformance/cases/service/jobs/job-contract-attach.json index 43116b37..7614d1d2 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach.json @@ -21,6 +21,60 @@ }, "storeAvailable": true, "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, { "method": "GET", "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", 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 19f90917..46a942eb 100644 --- a/cli/rust/crates/prose-runner-core/src/service/jobs.rs +++ b/cli/rust/crates/prose-runner-core/src/service/jobs.rs @@ -1819,7 +1819,12 @@ const BINDING_OPTIONS: [&str; 10] = [ "--replace", "--file", ]; -const BINDING_FLAGS: [&str; 3] = ["--clear-repo", "--clear-commit-output", "--clear-files"]; +const BINDING_FLAGS: [&str; 4] = [ + "--clear-repo", + "--clear-commit-output", + "--clear-files", + "--allow-reset", +]; /// `--file` limits: files per binding, bytes per file and bytes in all. const MAX_BINDING_FILES: usize = 20; @@ -1827,7 +1832,8 @@ const MAX_BINDING_FILE_BYTES: u64 = 5 << 20; const MAX_BINDING_FILES_BYTES: u64 = 10 << 20; /// The binding settings given to `job contract attach`, checked before any -/// request. +/// request. Each flag is an independent command-line switch. +#[allow(clippy::struct_excessive_bools)] struct BindingOptions { model: Option, effort: Option, @@ -1843,6 +1849,19 @@ struct BindingOptions { /// `--file`: each NAME with its content in base64, when given. files: Option>, clear_files: bool, + /// `--allow-reset`: reset what the service cannot report. + allow_reset: bool, +} + +/// A stored file name: 1 to 200 of `A-Z a-z 0-9 . _ -`, not starting with +/// a dot and without `..`. +fn valid_file_name(name: &str) -> bool { + (1..=200).contains(&name.len()) + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + && !name.starts_with('.') + && !name.contains("..") } /// `--file [NAME=]PATH` values: UTF-8 files by NAME (default the path's @@ -1868,9 +1887,9 @@ fn parse_binding_files(context: &Context<'_>) -> Result .unwrap_or_default(); (base.to_owned(), value.as_str()) }; - if !valid_text(&name, 256) || name.contains(['/', '\\']) { + if !valid_file_name(&name) { return Err(invalid(format!( - "--file {value_quoted}: the file name must be 1 to 256 characters without /, \\ or control characters; give it as NAME=PATH", + "--file {value_quoted}: the file name must be 1 to 200 letters, digits, ., _ or -, not starting with a dot and without ..; give it as NAME=PATH", value_quoted = crate::error::quote(value) ))); } @@ -1977,6 +1996,14 @@ impl BindingOptions { .option("--commit-output") .map(|value| runs::parse_repository("--commit-output", value)) .transpose()?; + if let (Some(commit), Some(value)) = (&commit, context.option("--commit-output")) { + if commit.branch.is_some() { + return Err(invalid(format!( + "--commit-output {value_quoted} takes OWNER/NAME: the service chooses the commit's branch", + value_quoted = crate::error::quote(value) + ))); + } + } let environment = context.option("--environment").map(str::to_owned); if let Some(environment) = environment .as_deref() @@ -2024,9 +2051,17 @@ impl BindingOptions { replace, files, clear_files, + allow_reset: context.flag("--allow-reset"), }) } + /// Whether an older service may reset what it cannot report: with + /// `--allow-reset`, or when the files and environment are both given. + fn may_reset(&self) -> bool { + self.allow_reset + || ((self.files.is_some() || self.clear_files) && self.environment.is_some()) + } + /// `files`: the given set (it replaces the stored one), or a JSON null /// for `--clear-files` when `nulls` are allowed. fn put_files(&self, body: &mut Map, nulls: bool) { @@ -2060,16 +2095,10 @@ fn same_url(left: &str, right: &str) -> bool { left.eq_ignore_ascii_case(right) } -/// `{type: "commit", repository, branch?}`: the repository is the effective -/// repository's URL as the binding stores it, the branch `--commit-output`'s. -fn commit_output(url: &str, commit: &Repository) -> Value { - let mut output = Map::new(); - output.insert("type".into(), json!("commit")); - output.insert("repository".into(), json!(url)); - if let Some(branch) = &commit.branch { - output.insert("branch".into(), json!(branch)); - } - Value::Object(output) +/// `{type: "commit", repository}`: the effective repository's URL as the +/// binding stores it; the service chooses the commit's branch. +fn commit_output(url: &str) -> Value { + json!({"type": "commit", "repository": url}) } /// This invocation's argv without the webhook-only binding options. @@ -2113,8 +2142,8 @@ struct SavedBinding { output: Option, inputs: Option>, environment: Option, - /// Whether the binding stores files (their content is never listed). - has_files: bool, + /// How many files the binding stores (their content is never listed). + file_count: usize, } impl SavedBinding { @@ -2144,9 +2173,7 @@ impl SavedBinding { output, inputs: raw["inputs"].as_object().cloned(), environment: text(&configuration["environment"]), - has_files: configuration["stored_files"] - .as_array() - .is_some_and(|files| !files.is_empty()), + file_count: configuration["stored_files"].as_array().map_or(0, Vec::len), } } @@ -2155,38 +2182,65 @@ impl SavedBinding { } } +/// How `job contract attach` builds its body from the listing. +#[derive(Clone, Copy, PartialEq, Eq)] +enum AttachPath { + /// A bare `{program_ref}` (a job of another type, or a plain re-attach + /// on a service that keeps what it saved). + Bare, + /// A program that is not bound: only the options given. + Fresh, + /// A service that merges: only the changes. + ServerMerge, + /// The saved settings with the options applied, in full. + ClientMerge, +} + +/// An older service's re-post of a bound binding resets the stored files and +/// environment it does not report. +fn older_reset_refused(reference: &str) -> RunnerError { + let mut error = invalid(format!( + "this service does not report stored files or environment, so re-attaching {reference} would reset them; give --file or --clear-files and --environment, or pass --allow-reset" + )); + "Give --file or --clear-files and --environment, or pass --allow-reset." + .clone_into(&mut error.action); + error +} + +/// "a" or "an" before a job type. +fn article(word: &str) -> &'static str { + if word.starts_with(['a', 'e', 'i', 'o', 'u']) { + "an" + } else { + "a" + } +} + fn contract_attach(context: &mut Context<'_>) -> Result { let id = job_id(context)?; let reference = contract_reference(context)?; let settings_given = BindingOptions::any(context); let options = BindingOptions::parse(context)?; let plan = context.invocation.preview || !context.invocation.yes; - // Request 0: binding settings apply to webhook jobs only, and a plan's - // quote prices the job's type. - let mut live = false; - let mut job_type = None; - if settings_given || plan { - let body = get_json(context, 0, &format!("/triggers/{}", encode_segment(&id)))?; - let detail = project_detail(&body, false, &context.environment)?; - job_type = detail["job"]["type"] - .as_str() - .filter(|kind| !kind.is_empty()) - .map(str::to_owned); - let kind = job_type.as_deref().unwrap_or_default(); - if settings_given && kind != "webhook" { - let error = invalid(format!( - "job {} is a {} job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace and the --clear options) apply to webhook jobs only", - human_safe_scalar(&id), - human_safe_scalar(kind) - )); - let argv = argv_without_binding_options(&context.invocation.argv); - return Err(context.corrected( - error, - "Attach without those options: `{command}`", - argv, - )); - } - live = detail["status"]["delivery_mode"] == "live"; + // Request 0: the job's type. Settings apply to webhook jobs only, and a + // plan's quote prices the job's type. + let job_body = get_json(context, 0, &format!("/triggers/{}", encode_segment(&id)))?; + let detail = project_detail(&job_body, false, &context.environment)?; + let job_type = detail["job"]["type"] + .as_str() + .filter(|kind| !kind.is_empty()) + .map(str::to_owned); + let kind = job_type.as_deref().unwrap_or_default(); + let webhook = kind == "webhook"; + if settings_given && !webhook { + let error = invalid(format!( + "job {} is {} {} job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace and the --clear options) apply to webhook jobs only", + human_safe_scalar(&id), + article(kind), + human_safe_scalar(kind) + )); + let argv = argv_without_binding_options(&context.invocation.argv); + return Err(context.corrected(error, "Attach without those options: `{command}`", argv)); } // Request 1: the bound programs and their saved settings. let path = format!("/triggers/{}/contracts", encode_segment(&id)); @@ -2200,17 +2254,23 @@ fn contract_attach(context: &mut Context<'_>) -> Result { let merging = contracts .iter() .any(|contract| contract.get("environment").is_some()); - // --replace onto another program that is already bound would reset that - // binding's settings. - if let Some(replaced) = options + let bound = |target: &str| { + contracts + .iter() + .zip(&projected) + .find(|(_, contract)| contract["program_ref"].as_str() == Some(target)) + .map(|(raw, contract)| SavedBinding::of(raw, contract)) + }; + // A --replace of another revision is a full replace; one of the + // attached ref itself is an ordinary re-attach. + let moved = options .replace .as_deref() - .filter(|replaced| *replaced != reference) - { - if contracts - .iter() - .any(|contract| contract["program_ref"].as_str() == Some(reference.as_str())) - { + .filter(|replaced| *replaced != reference); + if let Some(replaced) = moved { + // ...and onto another program that is already bound it would reset + // that binding's settings. + if bound(&reference).is_some() { let error = invalid(format!( "{reference} is already bound to job {}; --replace would reset its settings. Change it in place without --replace, or detach {replaced} first", human_safe_scalar(&id) @@ -2223,29 +2283,64 @@ fn contract_attach(context: &mut Context<'_>) -> Result { )); } } - let base = options.replace.as_deref().unwrap_or(&reference); - let saved = contracts - .iter() - .zip(&projected) - .find(|(_, contract)| contract["program_ref"].as_str() == Some(base)) - .map(|(raw, contract)| SavedBinding::of(raw, contract)); + let saved = if webhook { + bound(moved.unwrap_or(&reference)) + } else { + None + }; + let route = match (&saved, moved) { + _ if !webhook => AttachPath::Bare, + (None, _) => AttachPath::Fresh, + (Some(_), None) if !settings_given => { + if !merging { + let mut error = invalid(format!( + "{reference} is already bound to job {}; this service does not report its stored files or environment, so re-attaching it could reset them", + human_safe_scalar(&id) + )); + error.action = format!( + "List its settings with `{}`.", + context.command(&format!("job contract list {id}")) + ); + return Err(error); + } + AttachPath::Bare + } + (Some(_), None) if merging => AttachPath::ServerMerge, + (Some(saved), Some(replaced)) => { + if !merging && !options.may_reset() { + return Err(older_reset_refused(&reference)); + } + if merging + && saved.file_count > 0 + && options.files.is_none() + && !options.clear_files + && !options.allow_reset + { + return Err(invalid(format!( + "replacing {replaced} with {reference} drops {replaced}'s {} stored file(s); give them with --file, or pass --clear-files or --allow-reset", + saved.file_count + ))); + } + AttachPath::ClientMerge + } + (Some(_), None) => { + if !options.may_reset() { + return Err(older_reset_refused(&reference)); + } + AttachPath::ClientMerge + } + }; let mut body = Map::new(); body.insert("program_ref".into(), json!(reference)); - // A --replace of the attached ref itself is an ordinary re-attach. - let moved = options - .replace - .as_deref() - .is_some_and(|replaced| replaced != reference); - let client_merge = saved.is_some() && (moved || !merging); - match &saved { - // A merging service keeps what it saved: send the same ref as - // replace_program_ref and only the changes. - Some(saved) if !client_merge => { + match (route, &saved) { + (AttachPath::ServerMerge, Some(saved)) => { body.insert("replace_program_ref".into(), json!(reference)); server_merge(&options, saved, &mut body)?; } - Some(saved) => client_merge_body(&options, saved, moved, &mut body)?, - None => { + (AttachPath::ClientMerge, Some(saved)) => { + client_merge_body(&options, saved, moved.is_some(), &mut body)?; + } + (AttachPath::Fresh, _) => { if let Some(commit) = &options.commit { if options.repo.is_none() { return Err(runs::commit_output_mismatch(commit)); @@ -2253,16 +2348,19 @@ fn contract_attach(context: &mut Context<'_>) -> Result { } fresh_body(&options, &mut body); } + _ => {} } - if let Some(replace) = &options.replace { - body.insert("replace_program_ref".into(), json!(replace)); + if route != AttachPath::Bare { + if let Some(replace) = &options.replace { + body.insert("replace_program_ref".into(), json!(replace)); + } } let body = Value::Object(body); let bytes = canonical(&body).into_bytes(); let mut planned = context.planned(2, &path, &[], Some(&bytes)); if plan { // Advisory: a failed quote leaves the plan without one. - let server_saved = saved.as_ref().filter(|_| !client_merge); + let server_saved = saved.as_ref().filter(|_| route == AttachPath::ServerMerge); let inputs = effective_hold(&body, server_saved, job_type.as_deref()); let request = quote_request(context, 3, &inputs); match send_quote(context, &request) { @@ -2276,26 +2374,10 @@ fn contract_attach(context: &mut Context<'_>) -> Result { } let mut request = Request::from_manifest(context.operation, 2, path); request.body = Some(bytes); - let response = match context.send(&request) { - Err(error) if live && immutable_refusal(&error) => { - return Err(live_refusal(context, &id, error)); - } - other => other?.json_object()?, - }; + let response = context.send(&request)?.json_object()?; if response.get("bound").and_then(Value::as_str) != Some(reference.as_str()) { return Err(protocol("bound")); } - if context.mode == crate::OutputMode::Human { - if client_merge && !merging { - let _ = context.err.write_all(OLDER_SERVICE_NOTE.as_bytes()); - } else if moved - && options.files.is_none() - && !options.clear_files - && saved.as_ref().is_some_and(|saved| saved.has_files) - { - let _ = context.err.write_all(FILES_NOT_CARRIED_NOTE.as_bytes()); - } - } let mut text = format!( "Attached {} to job {}.\n", human_safe_scalar(&reference), @@ -2313,11 +2395,6 @@ fn contract_attach(context: &mut Context<'_>) -> Result { Ok(json!({"contracts": [{"program_ref": reference}]})) } -const OLDER_SERVICE_NOTE: &str = - "note: this service does not report stored files or environment; re-attaching may drop them\n"; -const FILES_NOT_CARRIED_NOTE: &str = - "note: stored files are not carried to the new revision; pass --file to attach them\n"; - /// Job types whose runs always read a repository. const REPOSITORY_JOB_TYPES: [&str; 3] = [ "github-issue-opened", @@ -2380,12 +2457,32 @@ fn check_commit_output( } } -/// Whether a `--repo` leaves the saved commit output pointing elsewhere. -fn output_elsewhere(options: &BindingOptions, saved: &SavedBinding) -> bool { - match (&options.repo, saved.output_repository()) { - (Some(repo), Some(output)) => !same_url(&repo.url(), output), - _ => false, +/// A `--repo` must not leave the saved commit output pointing at another +/// repository unless the output is also given or cleared. +fn check_repo_output(options: &BindingOptions, saved: &SavedBinding) -> Result<(), RunnerError> { + let (Some(repo), Some(output)) = (&options.repo, saved.output_repository()) else { + return Ok(()); + }; + if same_url(&repo.url(), output) || options.commit.is_some() || options.clear_commit { + return Ok(()); + } + Err(invalid(format!( + "the saved commit output goes to {}; with --repo give --commit-output OWNER/NAME or --clear-commit-output", + human_safe_scalar(output) + ))) +} + +/// The branch a `--repo` binds: its own `@BRANCH`, else the saved branch +/// when it names the saved repository. +fn repo_branch(repo: &Repository, saved: &SavedBinding) -> Option { + if let Some(branch) = &repo.branch { + return Some(json!(branch)); } + saved + .repository + .as_ref() + .filter(|(url, _)| url.as_str().is_some_and(|url| same_url(url, &repo.url()))) + .and_then(|(_, branch)| branch.clone()) } /// The server-merge body: only the fields the options change, with JSON @@ -2396,6 +2493,7 @@ fn server_merge( body: &mut Map, ) -> Result<(), RunnerError> { let commit_url = check_commit_output(options, saved)?; + check_repo_output(options, saved)?; if let Some(model) = &options.model { body.insert("model".into(), json!(model)); } @@ -2404,10 +2502,10 @@ fn server_merge( } if let Some(repo) = &options.repo { body.insert("repository_url".into(), json!(repo.url())); - body.insert("repository_branch".into(), json!(repo.branch)); - if output_elsewhere(options, saved) { - body.insert("output".into(), Value::Null); - } + body.insert( + "repository_branch".into(), + repo_branch(repo, saved).unwrap_or(Value::Null), + ); } if options.clear_repo { body.insert("repository_url".into(), Value::Null); @@ -2417,8 +2515,8 @@ fn server_merge( if options.clear_commit { body.insert("output".into(), Value::Null); } - if let (Some(commit), Some(url)) = (&options.commit, &commit_url) { - body.insert("output".into(), commit_output(url, commit)); + if let Some(url) = &commit_url { + body.insert("output".into(), commit_output(url)); } if let Some(inputs) = options.merged_inputs(saved.inputs.as_ref()) { body.insert("inputs".into(), Value::Object(inputs)); @@ -2440,6 +2538,7 @@ fn client_merge_body( body: &mut Map, ) -> Result<(), RunnerError> { let commit_url = check_commit_output(options, saved)?; + check_repo_output(options, saved)?; let mut model = saved.model.clone(); let mut effort = saved.effort.clone(); let mut repository = saved.repository.clone(); @@ -2451,10 +2550,7 @@ fn client_merge_body( effort = Some(json!(value)); } if let Some(repo) = &options.repo { - repository = Some((json!(repo.url()), repo.branch.as_ref().map(|b| json!(b)))); - if output_elsewhere(options, saved) { - output = None; - } + repository = Some((json!(repo.url()), repo_branch(repo, saved))); } if options.clear_repo { repository = None; @@ -2463,8 +2559,8 @@ fn client_merge_body( if options.clear_commit { output = None; } - if let (Some(commit), Some(url)) = (&options.commit, &commit_url) { - output = Some(commit_output(url, commit)); + if let Some(url) = &commit_url { + output = Some(commit_output(url)); } let inputs = options .merged_inputs(saved.inputs.as_ref()) @@ -2492,7 +2588,8 @@ fn client_merge_body( } else if let Some(environment) = saved.environment.as_ref().filter(|_| moved) { body.insert("environment".into(), environment.clone()); } - options.put_files(body, true); + // A full body clears by omission. + options.put_files(body, false); Ok(()) } @@ -2511,8 +2608,8 @@ fn fresh_body(options: &BindingOptions, body: &mut Map) { body.insert("repository_branch".into(), json!(branch)); } } - if let (Some(commit), Some(repo)) = (&options.commit, &options.repo) { - body.insert("output".into(), commit_output(&repo.url(), commit)); + if let (Some(_), Some(repo)) = (&options.commit, &options.repo) { + body.insert("output".into(), commit_output(&repo.url())); } if let Some(inputs) = options .merged_inputs(None) @@ -2526,35 +2623,6 @@ fn fresh_body(options: &BindingOptions, body: &mut Map) { options.put_files(body, false); } -/// The service's refusal to change a live webhook's binding. -fn immutable_refusal(error: &RunnerError) -> bool { - error.code == ErrorCode::ServiceRequestRejected - && error - .details - .as_ref() - .and_then(|details| details.get("serviceMessage")) - .and_then(Value::as_str) - .is_some_and(|message| message.to_ascii_lowercase().contains("immutable")) -} - -/// A live webhook's refusal to change its binding: also name the switch to -/// test delivery, with the spec on standard input. -fn live_refusal(context: &Context<'_>, id: &str, error: RunnerError) -> RunnerError { - let words = ["job", "update", id, "--spec-file", "-", "--yes"]; - let mut error = error; - error.action = format!( - "{} The job delivers live; to change its binding, switch it to test delivery first: `{}` with details.suggestedStdin on standard input.", - error.action, - context.command(&words.join(" ")) - ); - error - .with_detail("suggestedArgv", json!(context.follow_up_argv(&words))) - .with_detail( - "suggestedStdin", - Value::String("{\"delivery_mode\":\"test\"}\n".into()), - ) -} - /// `reasoning effort E; repository URL@BRANCH; commit output URL; inputs /// k1, k2`: the parts present in a binding body (service names) or a /// projected `run_configuration`; input values are never shown. @@ -3006,6 +3074,7 @@ mod tests { replace: None, files: None, clear_files: false, + allow_reset: false, }; let saved = SavedBinding { model: Some(json!("model-a")), @@ -3014,7 +3083,13 @@ mod tests { output: Some(json!({"type": "commit", "repository": "https://github.com/o/app"})), inputs: Some(json!({"n": 1, "k": 2}).as_object().unwrap().clone()), environment: Some(json!("builtin")), - has_files: true, + file_count: 1, + }; + // Another repository would orphan the saved commit output. + assert!(server_merge(&options, &saved, &mut Map::new()).is_err()); + let options = BindingOptions { + clear_commit: true, + ..options }; let mut body = Map::new(); server_merge(&options, &saved, &mut body).unwrap(); @@ -3030,22 +3105,41 @@ mod tests { json!({"model": "model-a", "repository_url": "https://github.com/o/other", "inputs": {"k": 2, "t": "v"}}) ); + // The saved repository named again keeps its branch. + let same = BindingOptions { + repo: Some(runs::parse_repository("--repo", "O/App").unwrap()), + clear_commit: false, + ..options + }; + let mut body = Map::new(); + server_merge(&same, &saved, &mut body).unwrap(); + assert_eq!(body["repository_branch"], json!("main")); // A move to another revision also carries the saved environment, and // the commit output names the effective repository's URL. let moved = BindingOptions { repo: None, - commit: Some(runs::parse_repository("--commit-output", "O/APP@out").unwrap()), - ..options + commit: Some(runs::parse_repository("--commit-output", "O/APP").unwrap()), + ..same }; let mut body = Map::new(); client_merge_body(&moved, &saved, true, &mut body).unwrap(); assert_eq!(body["environment"], json!("builtin")); assert_eq!( body["output"], - json!({"type": "commit", "repository": "https://github.com/o/app", "branch": "out"}) + json!({"type": "commit", "repository": "https://github.com/o/app"}) ); } + #[test] + fn file_names_and_articles() { + assert!(valid_file_name("notes.md") && valid_file_name("a_b-1")); + for name in [".env", "a..b", "a/b", "a b", ""] { + assert!(!valid_file_name(name), "{name}"); + } + assert!(!valid_file_name(&"a".repeat(201))); + assert_eq!((article("email"), article("schedule")), ("an", "a")); + } + #[test] fn plan_quote_prices_the_binding_as_it_will_run() { let saved = SavedBinding { diff --git a/cli/shared/service/help.v1.json b/cli/shared/service/help.v1.json index 7454de6d..48641fa5 100644 --- a/cli/shared/service/help.v1.json +++ b/cli/shared/service/help.v1.json @@ -1,6 +1,6 @@ { "schema": "openprose.service-help/1", - "manifestSha256": "72ed72d21829edc004f53b5ba77c6e4d8cd77d439b5425d7ce6f16c274356021", + "manifestSha256": "49960e526069a3dbb0666367e65b9811537a523dbec77c97e395cf4d2fb618a9", "topics": { "cli": "Usage: prose [GLOBAL OPTIONS] cli [ARGUMENTS] [OPTIONS]\n\nOpenProse service and account commands. They reach the hosted OpenProse service\nand never prompt. New here? `prose cli service guide` walks through a first\nprogram, a daily schedule, scripting and costs. Commands for this machine\n(doctor, config) are listed by `prose --help`.\n\nCommands:\n run Run a program on the hosted service, then follow, read or cancel it: submit, watch, show, list, cancel, download, input, quote, share.\n program Save your programs and manage their revisions: save, list, show, delete, draft, revisions, visibility.\n example Working example programs to read and copy: list, show.\n job Run a saved program on a schedule or from a webhook: create, list, show, delete, configure, contract, deliveries, rotate-secret, update.\n wallet Balance, usage and credit: balance, usage, events, topup, redeem.\n auth Sign in, check or remove the stored key: login, status, logout.\n model Hosted models: list.\n result Published results of public programs (OWNER/SLUG); a run's own output is `cli run show` or `cli run download`: show, list, publish, unpublish.\n org Organizations, members and invitations: list, show, create, default, invitation, invite, member, rename.\n repo Repositories the service can read: list.\n package Registry packages (no source execution): fetch, list, publish, withdraw.\n service Service status, triage, the guide and the command list: triage, status, guide, capabilities, operations.\n\nExamples:\n prose cli run submit hello.prose.md --preview\n prose cli program save hello hello.prose.md --preview\n prose cli example list --json\n prose cli job create --spec-file job.json --preview\n prose cli wallet balance --json\n prose cli auth login\n prose cli model list --json\n prose cli result show exowner1/hello --latest\n prose cli org list --json\n prose cli repo list --json\n prose cli package publish my-package --organization acme --name tool --version 1.0.0 --json\n prose cli service triage --json\n\nGlobal options:\n --output human|json|jsonl Output mode; the same as the PROSE_OUTPUT setting.\n\nRun `prose cli --help` for details. `prose cli service operations --json` prints every command as JSON.\n", "cli auth": "Usage: prose [GLOBAL OPTIONS] cli auth [ARGUMENTS] [OPTIONS]\n\nAccount credentials for the OpenProse service. Every other service command\nreads the key these commands manage, or OPENPROSE_API_KEY.\n\nCommands:\n login Sign in with the GitHub device flow and store the key in the OS credential store.\n status Report whether a credential is available and valid.\n logout Remove the stored credential.\n\nExamples:\n prose cli auth login\n prose cli auth status --json\n prose cli auth logout --json\n\nGlobal options:\n --output human|json|jsonl Output mode; the same as the PROSE_OUTPUT setting.\n\nCredentials:\n OPENPROSE_API_KEY API key; a non-empty value wins over the stored key.\n\nDevice flow: `cli auth login` prints a one-time code and https://github.com/login/device\non stderr, never the key, and never opens a browser. A person approves the code\nthere within 15 minutes. An agent without a person sets the variable instead.\n\nRun `prose cli auth --help` for details. `prose cli service operations --json` prints every command as JSON.\n", @@ -13,7 +13,7 @@ "cli job": "Usage: prose [GLOBAL OPTIONS] cli job [ARGUMENTS] [OPTIONS]\n\nCommands:\n create Create a job from a JSON spec.\n list List jobs, the account's job limit and the available job types.\n show Show one job and its delivery status.\n delete Delete a job.\n configure Replace a schedule job's cadence and run configuration.\n contract Commands: attach, detach, list.\n deliveries List a webhook job's recent deliveries.\n rotate-secret Rotate a webhook job's signing secret.\n update Change a webhook job's settings.\n\nExamples:\n prose cli job create --spec-file job.json --preview\n prose cli job list --json\n prose cli job show 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --json\n prose cli job delete 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --preview\n prose cli job configure 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --interval-seconds 3600 --yes\n prose cli job contract list 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --json\n prose cli job deliveries 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --json\n prose cli job rotate-secret 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --yes\n prose cli job update 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --spec-file job.json --yes\n\nGlobal options:\n --output human|json|jsonl Output mode; the same as the PROSE_OUTPUT setting.\n\nRun `prose cli job --help` for details. `prose cli service operations --json` prints every command as JSON.\n", "cli job configure": "Usage: prose [GLOBAL OPTIONS] cli job configure [OPTIONS]\n\nReplace a schedule job's cadence and run configuration. Schedule jobs only.\nQuick form: `cli job configure JOB_ID --interval-seconds N` reads the job and resends its current configuration_revision, revision_token and bindings with the new interval.\nFull form: --config-file with the body {\"interval_seconds\":N,\"configuration_revision\":R,\"revision_token\":\"T\",\"bindings\":[{\"program_ref\":\"OWNER/SLUG@REV\",\"run_configuration\":{...}}]}.\nRead the current values with `cli job show JOB_ID --json`: status.configuration_revision, status.revision_token (an opaque token), and each status.contracts[] program_ref and run_configuration, under the same names.\nrun_configuration takes model, environment, reasoning_effort, inputs, files, context_repositories and output as show prints them, except input_entries: move each {name, value} entry into inputs, because every input left out is deleted.\nA stale revision_token is SERVICE_WRITE_CONFLICT; while a run of the schedule is in progress the service answers SERVICE_UNAVAILABLE (retry later).\n\nArguments:\n JOB_ID Job id.\n\nOptions:\n --config-file FILE Schedule configuration: a JSON object of at most 64 KiB (see above), or -. Give exactly one of --config-file and --interval-seconds.\n --interval-seconds N New interval (60 to 2678400); the current configuration is read and resent unchanged otherwise.\n --yes Confirm this operation; required because it replaces the schedule's cadence and run configuration; the next scheduled run uses them.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job configure 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --interval-seconds 3600 --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", "cli job contract": "Usage: prose [GLOBAL OPTIONS] cli job contract [ARGUMENTS] [OPTIONS]\n\nCommands:\n attach Attach a pinned program to a job.\n detach Detach a program from a job.\n list List the programs attached to a job.\n\nExamples:\n prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes\n prose cli job contract detach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes\n prose cli job contract list 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --json\n\nGlobal options:\n --output human|json|jsonl Output mode; the same as the PROSE_OUTPUT setting.\n\nRun `prose cli job contract --help` for details. `prose cli service operations --json` prints every command as JSON.\n", - "cli job contract attach": "Usage: prose [GLOBAL OPTIONS] cli job contract attach [OPTIONS]\n\nAttach a pinned program to a job. The options set a webhook job's run settings; re-attaching a bound program changes only those given.\n\nArguments:\n JOB_ID Job id.\n OWNER/SLUG@REV Pinned program reference.\n\nOptions:\n --model MODEL Hosted model for its runs.\n --reasoning-effort EFFORT Reasoning effort for its runs. One of: minimal, low, medium, high, xhigh.\n --repo OWNER/NAME[@BRANCH] Repository its runs read as context.\n --commit-output OWNER/NAME[@BRANCH]\n Repository receiving each run's commit; the context repository.\n --clear-repo Remove the saved repository and commit output.\n --clear-commit-output Remove the saved commit output.\n --input KEY=VALUE|KEY=@FILE\n Program input; @FILE reads a file. Overrides saved inputs. Repeatable.\n --inputs-file FILE JSON object of string inputs.\n --clear-input KEY Remove a saved input. Repeatable.\n --file [NAME=]PATH UTF-8 file for its runs; replaces all stored files. Repeatable.\n --clear-files Remove the stored files.\n --environment ENV Where its runs execute.\n --replace OWNER/SLUG@REV Bound revision this replaces, keeping its settings.\n --yes Confirm this operation; required because the job starts running this program, a paid run, each time it fires.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes\n prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --repo exowner1/app@main --reasoning-effort medium --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", + "cli job contract attach": "Usage: prose [GLOBAL OPTIONS] cli job contract attach [OPTIONS]\n\nAttach a pinned program to a job. The options set a webhook job's run settings; re-attaching a bound program changes only those given.\n\nArguments:\n JOB_ID Job id.\n OWNER/SLUG@REV Pinned program reference.\n\nOptions:\n --model MODEL Hosted model for its runs.\n --reasoning-effort EFFORT Reasoning effort for its runs. One of: minimal, low, medium, high, xhigh.\n --repo OWNER/NAME[@BRANCH] Repository its runs read as context.\n --commit-output OWNER/NAME Repository receiving each run's commit; the context repository.\n --clear-repo Remove the saved repository and commit output.\n --clear-commit-output Remove the saved commit output.\n --input KEY=VALUE|KEY=@FILE\n Program input; @FILE reads a file. Overrides saved inputs. Repeatable.\n --inputs-file FILE JSON object of string inputs.\n --clear-input KEY Remove a saved input. Repeatable.\n --file [NAME=]PATH UTF-8 file for its runs; replaces all stored files. Repeatable.\n --clear-files Remove the stored files.\n --environment ENV Where its runs execute.\n --replace OWNER/SLUG@REV Bound revision this replaces, carrying its settings but not stored files.\n --allow-reset Reset stored files and environment a service does not report.\n --yes Confirm this operation; required because the job starts running this program, a paid run, each time it fires.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes\n prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --repo exowner1/app@main --reasoning-effort medium --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", "cli job contract detach": "Usage: prose [GLOBAL OPTIONS] cli job contract detach [OPTIONS]\n\nDetach a program from a job.\n\nArguments:\n JOB_ID Job id.\n OWNER/SLUG@REV Pinned program reference.\n\nOptions:\n --yes Confirm this operation; required because the job stops running this program from now on.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job contract detach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", "cli job contract list": "Usage: prose [GLOBAL OPTIONS] cli job contract list [OPTIONS]\n\nList the programs attached to a job.\n\nArguments:\n JOB_ID Job id.\n\nOptions:\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job contract list 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --json\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation or configuration; 10 service or credential error; 24 interrupted.\n", "cli job create": "Usage: prose [GLOBAL OPTIONS] cli job create [OPTIONS]\n\nCreate a job from a JSON spec. A job starts paid runs on its own, on a schedule or from webhook events, until it is deleted.\nSpec keys are snake_case. `type` is required; `cli job list` prints every type with its config_fields. Each type accepts the keys listed under this command's `spec` in `cli service operations --json`; every problem is reported at once.\n Schedule: {\"type\":\"schedule\",\"program_ref\":\"OWNER/SLUG@REV\",\"interval_seconds\":86400}\n interval_seconds 60 to 2678400 (86400 is once a day). The first run starts about one second after the job is created, then one every interval.\n Cron expressions and a time of day are not supported.\n Optional: model, reasoning_effort, environment, inputs (name -> string), files, repository_url, repository_branch, output.\n Webhook: {\"type\":\"webhook\",\"name\":\"NAME\",\"delivery_mode\":\"test\"}\n optional name, program_ref, receiver, receiver_secret, reply, reply_secret; delivery_mode test or live; with program_ref also model, reasoning_effort, repository_url, repository_branch, output. Without program_ref it starts no runs: no hold, effect write.\nREV is the program's program.rev_id (printed by `cli program save` and `cli program show OWNER/SLUG`), not its commit_id.\n\nOptions:\n --spec-file FILE Job spec: a JSON object of at most 64 KiB (see above), or - for standard input. Required.\n --yes Confirm this operation; required because the job starts paid runs on its own schedule or events until it is deleted (a webhook with no program starts none until one is attached).\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job create --spec-file job.json --preview\n prose cli job create --spec-file job.json --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", @@ -87,7 +87,7 @@ "contract": "service/1", "manifest": { "schema": "openprose.service-operations/1", - "sha256": "ff313c6f91b301039bb3688ad59464da1c10c5a6a13abff1d20b73550501da0b", + "sha256": "66a3a4190335950c43afde9c54cbf21a505f5189353d6d367a50352a11b31ab3", "argv": [ "cli", "service", diff --git a/cli/shared/service/operations-public.v1.json b/cli/shared/service/operations-public.v1.json index 089bb4c1..86ced6b1 100644 --- a/cli/shared/service/operations-public.v1.json +++ b/cli/shared/service/operations-public.v1.json @@ -1,7 +1,7 @@ { "schema": "openprose.service-operations-projection/1", "description": "The public projection of operations.v1.json: what `prose cli service operations` prints (as the JSON result, the JSONL records and trailer, and what the capabilities document's manifest sha256 covers). `fields` is an allowlist: a member is `true` (copied whole) or an object (the same allowlist applied to that object, or to each element of that array). Every other member of the manifest (service routes and their catalog entries, service error strings, the service origin, the key format, the vendored export and this client's own transport, journal and identity settings) is read by the products internally and never printed. `maxBytes` is the size budget of the printed result (canonical JSON), and no string in `forbid` may appear in it.", - "maxBytes": 98304, + "maxBytes": 106496, "forbid": [ "/triggers/", "/examples/private", diff --git a/cli/shared/service/operations.v1.json b/cli/shared/service/operations.v1.json index 32b6117e..bf6739ae 100644 --- a/cli/shared/service/operations.v1.json +++ b/cli/shared/service/operations.v1.json @@ -5617,7 +5617,7 @@ }, { "name": "--commit-output", - "value": "OWNER/NAME[@BRANCH]", + "value": "OWNER/NAME", "description": "Repository receiving each run's commit; the context repository.", "repeatable": false, "required": false @@ -5681,7 +5681,14 @@ { "name": "--replace", "value": "OWNER/SLUG@REV", - "description": "Bound revision this replaces, keeping its settings.", + "description": "Bound revision this replaces, carrying its settings but not stored files.", + "repeatable": false, + "required": false + }, + { + "name": "--allow-reset", + "value": null, + "description": "Reset stored files and environment a service does not report.", "repeatable": false, "required": false } @@ -5693,7 +5700,7 @@ "path": "/triggers/{id}", "auth": "bearer", "body": null, - "when": "a webhook option, --replace, or a plan (no --yes)", + "when": "always", "catalogRoute": { "method": "GET", "path": "/triggers/{id}", diff --git a/docs/service/jobs.md b/docs/service/jobs.md index 64651919..3242064a 100644 --- a/docs/service/jobs.md +++ b/docs/service/jobs.md @@ -183,7 +183,7 @@ prose cli job show "$JOB" --json | jq '.result.status | { `SERVICE_REQUEST_REJECTED`). - For a **webhook** job, `job contract attach` also sets how its runs execute: `--model`, `--reasoning-effort`, `--repo OWNER/NAME[@BRANCH]` (a repository - the runs read as context), `--commit-output OWNER/NAME[@BRANCH]` (it must be + the runs read as context), `--commit-output OWNER/NAME` (it must be that repository), `--input KEY=VALUE` / `--inputs-file FILE`, `--environment ENV`, `--file [NAME=]PATH` (UTF-8; it replaces the stored file set, as re-deploying from the editor does; at most 20 files, 5 MiB each @@ -192,22 +192,37 @@ prose cli job show "$JOB" --json | jq '.result.status | { removed on its own: the service returns file metadata, not content, so give the full set with `--file`. These options on another job type are refused before anything is sent (the job is read first to check its type). -- Attaching a program that is already bound changes only the options given. - When the service reports each binding's `environment`, the CLI re-binds the - same reference (`replace_program_ref`) with just the changed fields and - `null` for a clear, and the service keeps the rest, including stored files. - Against a service that does not report it, the CLI merges the saved - settings itself and warns that stored files and environment may be - dropped. `--replace OWNER/SLUG@REV` moves a binding to another revision of - the same program: a full replace that carries the old binding's model, - reasoning effort, repository, inputs and environment, but not its stored - files (give them again with `--file`). `--replace` onto a program that is - already bound is refused; change that binding in place. Settings apply to - deliveries - admitted after the change. The plan (`--preview` or `CONFIRMATION_REQUIRED`) - carries the hold for the binding as it will run (`GET /run/quote` with the - program, the job's type and the effective model, reasoning effort, - environment and repository); a failed quote leaves the plan without one. +- The job is always read first. On another job type, and for a plain re-attach + of a bound program on a webhook, the CLI sends just `program_ref`; the + service treats re-attaching a bound program as a no-op. +- Changing a bound webhook binding changes only the options given. When the + service reports each binding's `environment`, the CLI re-binds the same + reference (`replace_program_ref`) with just the changed fields and `null` + for a clear, and the service keeps the rest, including stored files. A + service that does not report it would reset stored files and environment + on any re-bind, so there the CLI refuses unless `--file` or `--clear-files` + and `--environment` are given, or `--allow-reset` accepts the reset; a plain + re-attach of a bound program is refused. `--input` sends the full resulting + input set, so a concurrent edit of the same binding's inputs can be + overwritten. +- `--repo` naming the saved repository without `@BRANCH` keeps its branch. + A different `--repo` while the saved commit output goes elsewhere is refused + until `--commit-output` or `--clear-commit-output` is also given; the + service never retargets a commit output silently. `--commit-output` takes + `OWNER/NAME`: the service chooses the commit's branch. +- `--file` names must be what the service stores: 1 to 200 letters, digits, + `.`, `_` or `-`, not starting with a dot and without `..`. +- `--replace OWNER/SLUG@REV` moves a binding to another revision of the same + program: a full replace that carries the old binding's model, reasoning + effort, repository, inputs and environment. Stored files cannot be carried, + so when the old binding has any, `--file`, `--clear-files` or + `--allow-reset` is required. `--replace` onto a program that is already + bound is refused; change that binding in place. Settings apply to + deliveries admitted after the change. The plan (`--preview` or + `CONFIRMATION_REQUIRED`) carries the hold for the binding as it will run + (`GET /run/quote` with the program, the job's type and the effective model, + reasoning effort, environment and repository); a failed quote leaves the + plan without one. - `job contract list` reports each contract's `program_ref`, `program_slug`, `enabled`, `model`, `effective_model`, `rev_id`, `bound_at`, `is_platform_default` and, for a binding with saved settings, From a69312b0e2b243e20900cc64b1f414ac5c0b61aa Mon Sep 17 00:00:00 2001 From: Raymond Weitekamp <19483938+rawwerks@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:12:37 -0400 Subject: [PATCH 3/3] Pin the remaining attach refusals and send bare re-attaches Add shared cases for the --file count limit, non-UTF-8 file content, the service's 409 for a --replace target that is already bound, and a service refusal to change a live binding. A bound program on a merging service now gets a bare program_ref when no setting changes (only --allow-reset or a same-ref --replace), the webhook-only refusal names --allow-reset, both ports check --commit-output against --repo before reading files, and the --allow-reset help mentions --replace. Co-Authored-By: Claude Opus 5.5 --- cli/bun/src/core/service/jobs.ts | 15 +- cli/bun/test/service-jobs.test.ts | 13 ++ .../framework/service-capabilities-json.json | 2 +- .../framework/service-capabilities-jsonl.json | 2 +- ...ontract-attach-allow-reset-alone-bare.json | 161 ++++++++++++++++ .../job-contract-attach-file-count-limit.json | 106 +++++++++++ .../job-contract-attach-file-not-utf8.json | 66 +++++++ ...tract-attach-live-refusal-passthrough.json | 170 +++++++++++++++++ .../job-contract-attach-model-refused.json | 2 +- ...contract-attach-replace-same-ref-bare.json | 162 ++++++++++++++++ ...-attach-replace-target-bound-conflict.json | 180 ++++++++++++++++++ ...t-attach-settings-non-webhook-refused.json | 2 +- .../prose-runner-core/src/service/jobs.rs | 19 +- cli/shared/service/help.v1.json | 6 +- cli/shared/service/operations.v1.json | 2 +- 15 files changed, 894 insertions(+), 14 deletions(-) create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-allow-reset-alone-bare.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-file-count-limit.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-file-not-utf8.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-live-refusal-passthrough.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-replace-same-ref-bare.json create mode 100644 cli/conformance/cases/service/jobs/job-contract-attach-replace-target-bound-conflict.json diff --git a/cli/bun/src/core/service/jobs.ts b/cli/bun/src/core/service/jobs.ts index 7fa2035c..2c76c635 100644 --- a/cli/bun/src/core/service/jobs.ts +++ b/cli/bun/src/core/service/jobs.ts @@ -1031,6 +1031,8 @@ interface Binding { allowReset: boolean; /** Any option above was given. */ any: boolean; + /** An option that changes a setting was given (anything but --allow-reset and --replace). */ + changes: boolean; } /** @@ -1065,6 +1067,7 @@ async function bindingOptions(context: Context): Promise { allowReset: context.flag("--allow-reset"), inputs: await parseInputs(context), any: BINDING_OPTIONS.some(given), + changes: BINDING_OPTIONS.some((name) => name !== "--allow-reset" && name !== "--replace" && given(name)), }; const model = modelOption(context); if (model !== undefined) binding.model = model; @@ -1075,14 +1078,14 @@ async function bindingOptions(context: Context): Promise { binding.commit = parseRepository("--commit-output", commitValue); if (binding.commit.branch !== undefined) throw invocationFailure(`--commit-output ${quoteText(commitValue)} takes OWNER/NAME: the service chooses the commit's branch`); } + if (binding.commit !== undefined && binding.repo !== undefined && !sameRepository(binding.commit, binding.repo)) { + throw commitNotRead(binding.commit); + } const environment = tokenOption(context, "--environment", "an environment"); if (environment !== undefined) binding.environment = environment; if (replaceValue !== undefined) binding.replace = pinnedValue(context, replaceValue, "--replace"); const files = await bindingFiles(context); if (files !== undefined) binding.files = files; - if (binding.commit !== undefined && binding.repo !== undefined && !sameRepository(binding.commit, binding.repo)) { - throw commitNotRead(binding.commit); - } return binding; } @@ -1257,8 +1260,10 @@ function attachBody(context: Context, id: string, reference: string, binding: Bi return inputs; }; const body: JsonObject = { program_ref: reference }; + // A bound ref whose settings this request does not change gets a bare + // program_ref on a merging service (a no-op there). + if (saved !== undefined && !moved && merging && !binding.changes) return { body, effective: savedEffective() }; if (saved !== undefined && !moved && !binding.any) { - if (merging) return { body, effective: savedEffective() }; throw refusal( `${reference} is already bound to job ${humanSafeScalar(id)}; this service does not report its stored files or environment, so re-attaching it could reset them`, `List its settings with \`${context.command(`job contract list ${id}`)}\`.`, @@ -1340,7 +1345,7 @@ async function contractAttach(context: Context): Promise { if (binding.any && jobType !== "webhook") { const named = humanSafeScalar(jobType ?? ""); const article = /^[aeiou]/u.test(named) ? "an" : "a"; - const error = invocationFailure(`job ${id} is ${article} ${named} job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace and the --clear options) apply to webhook jobs only`); + const error = invocationFailure(`job ${id} is ${article} ${named} job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace, --allow-reset and the --clear options) apply to webhook jobs only`); throw context.corrected(error, "Attach without those options: `{command}`", withoutBindingOptions(context.invocation.argv)); } // The listed contracts keep a bound program's saved settings. diff --git a/cli/bun/test/service-jobs.test.ts b/cli/bun/test/service-jobs.test.ts index b27d274f..c0909cb5 100644 --- a/cli/bun/test/service-jobs.test.ts +++ b/cli/bun/test/service-jobs.test.ts @@ -176,6 +176,19 @@ describe("Service job contract settings", () => { expect(full.exit).toBe(0); }); + test("a bound program on a merging service gets a bare program_ref when no setting changes", async () => { + for (const extra of [["--allow-reset"], ["--replace", REF], []]) { + const result = await attach([REF, ...extra, "--yes"], [jobRead(), listing([merging()]), post({ program_ref: REF })]); + expect(result.exit).toBe(0); + } + }); + + test("the --commit-output mismatch is reported before any --file is read", async () => { + const result = await attach([REF, "--repo", "exowner1/app", "--commit-output", "exowner1/other", "--file", "missing.md", "--yes"], []); + expect(result.exit).toBe(2); + expect(result.report!.problem.details.reason).toStartWith("--commit-output exowner1/other must also be given as --repo"); + }); + test("a non-webhook job takes the program alone, bound or not, and refuses settings with the right article", async () => { const plain = await attach([REF, "--yes"], [jobRead("email"), listing([merging()]), post({ program_ref: REF })]); expect(plain.exit).toBe(0); diff --git a/cli/conformance/cases/service/framework/service-capabilities-json.json b/cli/conformance/cases/service/framework/service-capabilities-json.json index d86e405f..a8dbd3ad 100644 --- a/cli/conformance/cases/service/framework/service-capabilities-json.json +++ b/cli/conformance/cases/service/framework/service-capabilities-json.json @@ -203,7 +203,7 @@ "--json" ], "schema": "openprose.service-operations/1", - "sha256": "66a3a4190335950c43afde9c54cbf21a505f5189353d6d367a50352a11b31ab3" + "sha256": "901444a098ac3be99d5d6e6be150b7b0df521d9458933b1267e44ac714d5a8a1" }, "nouns": { "auth": [ diff --git a/cli/conformance/cases/service/framework/service-capabilities-jsonl.json b/cli/conformance/cases/service/framework/service-capabilities-jsonl.json index 5f322a8a..163a9680 100644 --- a/cli/conformance/cases/service/framework/service-capabilities-jsonl.json +++ b/cli/conformance/cases/service/framework/service-capabilities-jsonl.json @@ -209,7 +209,7 @@ "--json" ], "schema": "openprose.service-operations/1", - "sha256": "66a3a4190335950c43afde9c54cbf21a505f5189353d6d367a50352a11b31ab3" + "sha256": "901444a098ac3be99d5d6e6be150b7b0df521d9458933b1267e44ac714d5a8a1" }, "nouns": { "auth": [ diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-allow-reset-alone-bare.json b/cli/conformance/cases/service/jobs/job-contract-attach-allow-reset-alone-bare.json new file mode 100644 index 00000000..15bf7da6 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-allow-reset-alone-bare.json @@ -0,0 +1,161 @@ +{ + "id": "job-contract-attach-allow-reset-alone-bare", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "--allow-reset alone changes no setting: a bound program on a merging service gets a bare program_ref.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--allow-reset", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + } + ] + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-file-count-limit.json b/cli/conformance/cases/service/jobs/job-contract-attach-file-count-limit.json new file mode 100644 index 00000000..003e002b --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-file-count-limit.json @@ -0,0 +1,106 @@ +{ + "id": "job-contract-attach-file-count-limit", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "More than 20 --file options are refused before any request: a binding stores at most 20 files.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--file", + "n0=notes.md", + "--file", + "n1=notes.md", + "--file", + "n2=notes.md", + "--file", + "n3=notes.md", + "--file", + "n4=notes.md", + "--file", + "n5=notes.md", + "--file", + "n6=notes.md", + "--file", + "n7=notes.md", + "--file", + "n8=notes.md", + "--file", + "n9=notes.md", + "--file", + "n10=notes.md", + "--file", + "n11=notes.md", + "--file", + "n12=notes.md", + "--file", + "n13=notes.md", + "--file", + "n14=notes.md", + "--file", + "n15=notes.md", + "--file", + "n16=notes.md", + "--file", + "n17=notes.md", + "--file", + "n18=notes.md", + "--file", + "n19=notes.md", + "--file", + "n20=notes.md", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Correct the value named in Detail; `prose --output json cli job contract attach --help` shows the accepted syntax.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "--file was given 21 times; a binding stores at most 20 files", + "suggestedArgv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "--help" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + }, + "files": [ + { + "path": "notes.md", + "content": "Reply in the house style.\n" + } + ] +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-file-not-utf8.json b/cli/conformance/cases/service/jobs/job-contract-attach-file-not-utf8.json new file mode 100644 index 00000000..dd3038c6 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-file-not-utf8.json @@ -0,0 +1,66 @@ +{ + "id": "job-contract-attach-file-not-utf8", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "A --file that is not UTF-8 text is refused before any request.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--file", + "bad.bin", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Correct the value named in Detail; `prose --output json cli job contract attach --help` shows the accepted syntax.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "--file \"bad.bin\" is not UTF-8 text", + "suggestedArgv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "--help" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + }, + "files": [ + { + "path": "bad.bin", + "contentBase64": "/w==" + } + ] +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-live-refusal-passthrough.json b/cli/conformance/cases/service/jobs/job-contract-attach-live-refusal-passthrough.json new file mode 100644 index 00000000..1072f5fe --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-live-refusal-passthrough.json @@ -0,0 +1,170 @@ +{ + "id": "job-contract-attach-live-refusal-passthrough", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "A service refusal to change a live binding passes through as SERVICE_REQUEST_REJECTED with its message.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--reasoning-effort", + "high", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "replace_program_ref": "exowner1/jobs-example@0123456789abcdef", + "reasoning_effort": "high" + }, + "status": 400, + "body": { + "error": "This webhook's execution configuration is immutable while it delivers live." + } + } + ] + }, + "exitCode": 10, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Correct the request as details.serviceMessage describes, then retry.", + "boundary": "hosted-service", + "code": "SERVICE_REQUEST_REJECTED", + "details": { + "serviceMessage": "This webhook's execution configuration is immutable while it delivers live.", + "serviceStatus": 400 + }, + "exitCode": 10, + "message": "The OpenProse service rejected the request.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-model-refused.json b/cli/conformance/cases/service/jobs/job-contract-attach-model-refused.json index 8b84fb33..0da6d359 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach-model-refused.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach-model-refused.json @@ -89,7 +89,7 @@ "boundary": "invocation", "code": "INVOCATION_INVALID", "details": { - "reason": "job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 is an email job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace and the --clear options) apply to webhook jobs only", + "reason": "job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 is an email job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace, --allow-reset and the --clear options) apply to webhook jobs only", "suggestedArgv": [ "--output", "json", diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-replace-same-ref-bare.json b/cli/conformance/cases/service/jobs/job-contract-attach-replace-same-ref-bare.json new file mode 100644 index 00000000..45a97cff --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-replace-same-ref-bare.json @@ -0,0 +1,162 @@ +{ + "id": "job-contract-attach-replace-same-ref-bare", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "--replace naming the attached program itself, with no setting, sends a bare program_ref.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@0123456789abcdef", + "--replace", + "exowner1/jobs-example@0123456789abcdef", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + }, + "status": 201, + "body": { + "bound": "exowner1/jobs-example@0123456789abcdef" + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": null, + "result": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + } + ] + }, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-replace-target-bound-conflict.json b/cli/conformance/cases/service/jobs/job-contract-attach-replace-target-bound-conflict.json new file mode 100644 index 00000000..a94ef738 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-contract-attach-replace-target-bound-conflict.json @@ -0,0 +1,180 @@ +{ + "id": "job-contract-attach-replace-target-bound-conflict", + "feature": "jobs", + "operation": "job.contract.attach", + "description": "If the service finds the --replace target already bound, its 409 passes through and nothing changes.", + "argv": [ + "--output", + "json", + "cli", + "job", + "contract", + "attach", + "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "exowner1/jobs-example@fedcba9876543210", + "--replace", + "exowner1/jobs-example@0123456789abcdef", + "--clear-files", + "--yes" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "status": 200, + "body": { + "trigger": { + "id": "3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10", + "type": "webhook", + "name": null, + "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 + }, + "status": { + "configured": true, + "active": true, + "receiver": { + "profile": "openprose" + }, + "receiverSecretConfigured": false, + "reply": null, + "replySecretConfigured": false, + "replies": [], + "replyDeliveries": [], + "deliveryMode": "test", + "secretRotatedAt": 1790212422203, + "lastEventAt": null, + "lastRunId": null, + "lastError": null, + "counts": { + "pending": 0, + "pending_funds": 0, + "claimed": 0, + "completed": 0, + "failed": 0, + "billing_pending": 0, + "ambiguous": 0 + } + }, + "endpoint": "/webhooks/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10" + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "GET", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "status": 200, + "body": { + "contracts": [ + { + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "owner": "exowner1", + "slug": "jobs-example", + "rev_id": "0123456789abcdef", + "content": "# private program text", + "is_platform_default": false, + "enabled": true, + "bound_at": 1789501241012, + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "model": "model-luna", + "effective_model": "model-luna", + "reasoning_effort": "low", + "repositories": [ + { + "url": "https://github.com/exowner1/app", + "branch": "main" + } + ], + "output": null, + "environment": "builtin", + "files": [ + { + "name": "notes.md", + "size": 12, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "max_contracts": 5 + }, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + } + }, + { + "method": "POST", + "path": "/triggers/3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10/contracts", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "program_ref": "exowner1/jobs-example@fedcba9876543210", + "replace_program_ref": "exowner1/jobs-example@0123456789abcdef", + "model": "model-luna", + "reasoning_effort": "low", + "repository_url": "https://github.com/exowner1/app", + "repository_branch": "main", + "inputs": { + "channel": "C0123", + "cost_center": "ops" + }, + "environment": "builtin" + }, + "status": 409, + "body": { + "error": "The target contract is already bound; edit it in place.", + "code": "binding_target_already_bound" + } + } + ] + }, + "exitCode": 10, + "stdout": { + "json": { + "interaction": "job.contracts", + "operation": "job.contract.attach", + "problem": { + "action": "Read the current state of the resource, reconcile the change, then retry.", + "boundary": "hosted-service", + "code": "SERVICE_WRITE_CONFLICT", + "details": { + "serviceMessage": "The target contract is already bound; edit it in place.", + "serviceStatus": 409 + }, + "exitCode": 10, + "message": "The OpenProse service reported a conflicting write.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-contract-attach-settings-non-webhook-refused.json b/cli/conformance/cases/service/jobs/job-contract-attach-settings-non-webhook-refused.json index e8e0c21d..f1e33cbf 100644 --- a/cli/conformance/cases/service/jobs/job-contract-attach-settings-non-webhook-refused.json +++ b/cli/conformance/cases/service/jobs/job-contract-attach-settings-non-webhook-refused.json @@ -89,7 +89,7 @@ "boundary": "invocation", "code": "INVOCATION_INVALID", "details": { - "reason": "job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 is an email job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace and the --clear options) apply to webhook jobs only", + "reason": "job 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 is an email job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace, --allow-reset and the --clear options) apply to webhook jobs only", "suggestedArgv": [ "--output", "json", 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 46a942eb..42fd874c 100644 --- a/cli/rust/crates/prose-runner-core/src/service/jobs.rs +++ b/cli/rust/crates/prose-runner-core/src/service/jobs.rs @@ -1927,6 +1927,19 @@ impl BindingOptions { || BINDING_FLAGS.iter().any(|flag| context.flag(flag)) } + /// Whether an option changes a setting: any but `--allow-reset` and + /// `--replace` (a move to another revision is decided separately). + fn changes(context: &Context<'_>) -> bool { + BINDING_OPTIONS + .iter() + .filter(|option| **option != "--replace") + .any(|option| !context.invocation.option_values(option).is_empty()) + || BINDING_FLAGS + .iter() + .filter(|flag| **flag != "--allow-reset") + .any(|flag| context.flag(flag)) + } + /// Checked in a fixed order shared by both ports: conflicts, then /// `--clear-input` names, inputs, values and finally `--file` reads. fn parse(context: &Context<'_>) -> Result { @@ -2220,6 +2233,7 @@ fn contract_attach(context: &mut Context<'_>) -> Result { let id = job_id(context)?; let reference = contract_reference(context)?; let settings_given = BindingOptions::any(context); + let changes = BindingOptions::changes(context); let options = BindingOptions::parse(context)?; let plan = context.invocation.preview || !context.invocation.yes; // Request 0: the job's type. Settings apply to webhook jobs only, and a @@ -2234,7 +2248,7 @@ fn contract_attach(context: &mut Context<'_>) -> Result { let webhook = kind == "webhook"; if settings_given && !webhook { let error = invalid(format!( - "job {} is {} {} job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace and the --clear options) apply to webhook jobs only", + "job {} is {} {} job; run settings (--model, --reasoning-effort, --repo, --commit-output, --input, --inputs-file, --environment, --file, --replace, --allow-reset and the --clear options) apply to webhook jobs only", human_safe_scalar(&id), article(kind), human_safe_scalar(kind) @@ -2305,6 +2319,9 @@ fn contract_attach(context: &mut Context<'_>) -> Result { } AttachPath::Bare } + // Nothing changes (only --allow-reset or a same-ref --replace): the + // service keeps what it saved. + (Some(_), None) if merging && !changes => AttachPath::Bare, (Some(_), None) if merging => AttachPath::ServerMerge, (Some(saved), Some(replaced)) => { if !merging && !options.may_reset() { diff --git a/cli/shared/service/help.v1.json b/cli/shared/service/help.v1.json index 48641fa5..bbe1ec0a 100644 --- a/cli/shared/service/help.v1.json +++ b/cli/shared/service/help.v1.json @@ -1,6 +1,6 @@ { "schema": "openprose.service-help/1", - "manifestSha256": "49960e526069a3dbb0666367e65b9811537a523dbec77c97e395cf4d2fb618a9", + "manifestSha256": "4259f1f31971210ca8577752cf320c41c5c1042ade0e012a6cb5d88b69a76436", "topics": { "cli": "Usage: prose [GLOBAL OPTIONS] cli [ARGUMENTS] [OPTIONS]\n\nOpenProse service and account commands. They reach the hosted OpenProse service\nand never prompt. New here? `prose cli service guide` walks through a first\nprogram, a daily schedule, scripting and costs. Commands for this machine\n(doctor, config) are listed by `prose --help`.\n\nCommands:\n run Run a program on the hosted service, then follow, read or cancel it: submit, watch, show, list, cancel, download, input, quote, share.\n program Save your programs and manage their revisions: save, list, show, delete, draft, revisions, visibility.\n example Working example programs to read and copy: list, show.\n job Run a saved program on a schedule or from a webhook: create, list, show, delete, configure, contract, deliveries, rotate-secret, update.\n wallet Balance, usage and credit: balance, usage, events, topup, redeem.\n auth Sign in, check or remove the stored key: login, status, logout.\n model Hosted models: list.\n result Published results of public programs (OWNER/SLUG); a run's own output is `cli run show` or `cli run download`: show, list, publish, unpublish.\n org Organizations, members and invitations: list, show, create, default, invitation, invite, member, rename.\n repo Repositories the service can read: list.\n package Registry packages (no source execution): fetch, list, publish, withdraw.\n service Service status, triage, the guide and the command list: triage, status, guide, capabilities, operations.\n\nExamples:\n prose cli run submit hello.prose.md --preview\n prose cli program save hello hello.prose.md --preview\n prose cli example list --json\n prose cli job create --spec-file job.json --preview\n prose cli wallet balance --json\n prose cli auth login\n prose cli model list --json\n prose cli result show exowner1/hello --latest\n prose cli org list --json\n prose cli repo list --json\n prose cli package publish my-package --organization acme --name tool --version 1.0.0 --json\n prose cli service triage --json\n\nGlobal options:\n --output human|json|jsonl Output mode; the same as the PROSE_OUTPUT setting.\n\nRun `prose cli --help` for details. `prose cli service operations --json` prints every command as JSON.\n", "cli auth": "Usage: prose [GLOBAL OPTIONS] cli auth [ARGUMENTS] [OPTIONS]\n\nAccount credentials for the OpenProse service. Every other service command\nreads the key these commands manage, or OPENPROSE_API_KEY.\n\nCommands:\n login Sign in with the GitHub device flow and store the key in the OS credential store.\n status Report whether a credential is available and valid.\n logout Remove the stored credential.\n\nExamples:\n prose cli auth login\n prose cli auth status --json\n prose cli auth logout --json\n\nGlobal options:\n --output human|json|jsonl Output mode; the same as the PROSE_OUTPUT setting.\n\nCredentials:\n OPENPROSE_API_KEY API key; a non-empty value wins over the stored key.\n\nDevice flow: `cli auth login` prints a one-time code and https://github.com/login/device\non stderr, never the key, and never opens a browser. A person approves the code\nthere within 15 minutes. An agent without a person sets the variable instead.\n\nRun `prose cli auth --help` for details. `prose cli service operations --json` prints every command as JSON.\n", @@ -13,7 +13,7 @@ "cli job": "Usage: prose [GLOBAL OPTIONS] cli job [ARGUMENTS] [OPTIONS]\n\nCommands:\n create Create a job from a JSON spec.\n list List jobs, the account's job limit and the available job types.\n show Show one job and its delivery status.\n delete Delete a job.\n configure Replace a schedule job's cadence and run configuration.\n contract Commands: attach, detach, list.\n deliveries List a webhook job's recent deliveries.\n rotate-secret Rotate a webhook job's signing secret.\n update Change a webhook job's settings.\n\nExamples:\n prose cli job create --spec-file job.json --preview\n prose cli job list --json\n prose cli job show 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --json\n prose cli job delete 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --preview\n prose cli job configure 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --interval-seconds 3600 --yes\n prose cli job contract list 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --json\n prose cli job deliveries 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --json\n prose cli job rotate-secret 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --yes\n prose cli job update 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --spec-file job.json --yes\n\nGlobal options:\n --output human|json|jsonl Output mode; the same as the PROSE_OUTPUT setting.\n\nRun `prose cli job --help` for details. `prose cli service operations --json` prints every command as JSON.\n", "cli job configure": "Usage: prose [GLOBAL OPTIONS] cli job configure [OPTIONS]\n\nReplace a schedule job's cadence and run configuration. Schedule jobs only.\nQuick form: `cli job configure JOB_ID --interval-seconds N` reads the job and resends its current configuration_revision, revision_token and bindings with the new interval.\nFull form: --config-file with the body {\"interval_seconds\":N,\"configuration_revision\":R,\"revision_token\":\"T\",\"bindings\":[{\"program_ref\":\"OWNER/SLUG@REV\",\"run_configuration\":{...}}]}.\nRead the current values with `cli job show JOB_ID --json`: status.configuration_revision, status.revision_token (an opaque token), and each status.contracts[] program_ref and run_configuration, under the same names.\nrun_configuration takes model, environment, reasoning_effort, inputs, files, context_repositories and output as show prints them, except input_entries: move each {name, value} entry into inputs, because every input left out is deleted.\nA stale revision_token is SERVICE_WRITE_CONFLICT; while a run of the schedule is in progress the service answers SERVICE_UNAVAILABLE (retry later).\n\nArguments:\n JOB_ID Job id.\n\nOptions:\n --config-file FILE Schedule configuration: a JSON object of at most 64 KiB (see above), or -. Give exactly one of --config-file and --interval-seconds.\n --interval-seconds N New interval (60 to 2678400); the current configuration is read and resent unchanged otherwise.\n --yes Confirm this operation; required because it replaces the schedule's cadence and run configuration; the next scheduled run uses them.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job configure 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --interval-seconds 3600 --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", "cli job contract": "Usage: prose [GLOBAL OPTIONS] cli job contract [ARGUMENTS] [OPTIONS]\n\nCommands:\n attach Attach a pinned program to a job.\n detach Detach a program from a job.\n list List the programs attached to a job.\n\nExamples:\n prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes\n prose cli job contract detach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes\n prose cli job contract list 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --json\n\nGlobal options:\n --output human|json|jsonl Output mode; the same as the PROSE_OUTPUT setting.\n\nRun `prose cli job contract --help` for details. `prose cli service operations --json` prints every command as JSON.\n", - "cli job contract attach": "Usage: prose [GLOBAL OPTIONS] cli job contract attach [OPTIONS]\n\nAttach a pinned program to a job. The options set a webhook job's run settings; re-attaching a bound program changes only those given.\n\nArguments:\n JOB_ID Job id.\n OWNER/SLUG@REV Pinned program reference.\n\nOptions:\n --model MODEL Hosted model for its runs.\n --reasoning-effort EFFORT Reasoning effort for its runs. One of: minimal, low, medium, high, xhigh.\n --repo OWNER/NAME[@BRANCH] Repository its runs read as context.\n --commit-output OWNER/NAME Repository receiving each run's commit; the context repository.\n --clear-repo Remove the saved repository and commit output.\n --clear-commit-output Remove the saved commit output.\n --input KEY=VALUE|KEY=@FILE\n Program input; @FILE reads a file. Overrides saved inputs. Repeatable.\n --inputs-file FILE JSON object of string inputs.\n --clear-input KEY Remove a saved input. Repeatable.\n --file [NAME=]PATH UTF-8 file for its runs; replaces all stored files. Repeatable.\n --clear-files Remove the stored files.\n --environment ENV Where its runs execute.\n --replace OWNER/SLUG@REV Bound revision this replaces, carrying its settings but not stored files.\n --allow-reset Reset stored files and environment a service does not report.\n --yes Confirm this operation; required because the job starts running this program, a paid run, each time it fires.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes\n prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --repo exowner1/app@main --reasoning-effort medium --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", + "cli job contract attach": "Usage: prose [GLOBAL OPTIONS] cli job contract attach [OPTIONS]\n\nAttach a pinned program to a job. The options set a webhook job's run settings; re-attaching a bound program changes only those given.\n\nArguments:\n JOB_ID Job id.\n OWNER/SLUG@REV Pinned program reference.\n\nOptions:\n --model MODEL Hosted model for its runs.\n --reasoning-effort EFFORT Reasoning effort for its runs. One of: minimal, low, medium, high, xhigh.\n --repo OWNER/NAME[@BRANCH] Repository its runs read as context.\n --commit-output OWNER/NAME Repository receiving each run's commit; the context repository.\n --clear-repo Remove the saved repository and commit output.\n --clear-commit-output Remove the saved commit output.\n --input KEY=VALUE|KEY=@FILE\n Program input; @FILE reads a file. Overrides saved inputs. Repeatable.\n --inputs-file FILE JSON object of string inputs.\n --clear-input KEY Remove a saved input. Repeatable.\n --file [NAME=]PATH UTF-8 file for its runs; replaces all stored files. Repeatable.\n --clear-files Remove the stored files.\n --environment ENV Where its runs execute.\n --replace OWNER/SLUG@REV Bound revision this replaces, carrying its settings but not stored files.\n --allow-reset Accept resetting stored files and environment a service cannot keep, or dropping files on --replace.\n --yes Confirm this operation; required because the job starts running this program, a paid run, each time it fires.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes\n prose cli job contract attach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --repo exowner1/app@main --reasoning-effort medium --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", "cli job contract detach": "Usage: prose [GLOBAL OPTIONS] cli job contract detach [OPTIONS]\n\nDetach a program from a job.\n\nArguments:\n JOB_ID Job id.\n OWNER/SLUG@REV Pinned program reference.\n\nOptions:\n --yes Confirm this operation; required because the job stops running this program from now on.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job contract detach 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 exowner1/hello@0123456789abcdef --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", "cli job contract list": "Usage: prose [GLOBAL OPTIONS] cli job contract list [OPTIONS]\n\nList the programs attached to a job.\n\nArguments:\n JOB_ID Job id.\n\nOptions:\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job contract list 3c1a9e57-0b4d-4f2a-9e61-5d7b2c8a4f10 --json\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation or configuration; 10 service or credential error; 24 interrupted.\n", "cli job create": "Usage: prose [GLOBAL OPTIONS] cli job create [OPTIONS]\n\nCreate a job from a JSON spec. A job starts paid runs on its own, on a schedule or from webhook events, until it is deleted.\nSpec keys are snake_case. `type` is required; `cli job list` prints every type with its config_fields. Each type accepts the keys listed under this command's `spec` in `cli service operations --json`; every problem is reported at once.\n Schedule: {\"type\":\"schedule\",\"program_ref\":\"OWNER/SLUG@REV\",\"interval_seconds\":86400}\n interval_seconds 60 to 2678400 (86400 is once a day). The first run starts about one second after the job is created, then one every interval.\n Cron expressions and a time of day are not supported.\n Optional: model, reasoning_effort, environment, inputs (name -> string), files, repository_url, repository_branch, output.\n Webhook: {\"type\":\"webhook\",\"name\":\"NAME\",\"delivery_mode\":\"test\"}\n optional name, program_ref, receiver, receiver_secret, reply, reply_secret; delivery_mode test or live; with program_ref also model, reasoning_effort, repository_url, repository_branch, output. Without program_ref it starts no runs: no hold, effect write.\nREV is the program's program.rev_id (printed by `cli program save` and `cli program show OWNER/SLUG`), not its commit_id.\n\nOptions:\n --spec-file FILE Job spec: a JSON object of at most 64 KiB (see above), or - for standard input. Required.\n --yes Confirm this operation; required because the job starts paid runs on its own schedule or events until it is deleted (a webhook with no program starts none until one is attached).\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli job create --spec-file job.json --preview\n prose cli job create --spec-file job.json --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", @@ -87,7 +87,7 @@ "contract": "service/1", "manifest": { "schema": "openprose.service-operations/1", - "sha256": "66a3a4190335950c43afde9c54cbf21a505f5189353d6d367a50352a11b31ab3", + "sha256": "901444a098ac3be99d5d6e6be150b7b0df521d9458933b1267e44ac714d5a8a1", "argv": [ "cli", "service", diff --git a/cli/shared/service/operations.v1.json b/cli/shared/service/operations.v1.json index bf6739ae..1e099282 100644 --- a/cli/shared/service/operations.v1.json +++ b/cli/shared/service/operations.v1.json @@ -5688,7 +5688,7 @@ { "name": "--allow-reset", "value": null, - "description": "Reset stored files and environment a service does not report.", + "description": "Accept resetting stored files and environment a service cannot keep, or dropping files on --replace.", "repeatable": false, "required": false }