Skip to content

Commit e6e95c3

Browse files
volinskeyclaude
andcommitted
feat(jobs): surface optional callback_url on job submit
Mirror the gateway add-job-completion-callback change across the consumer surfaces: @run402/sdk ManagedJobSubmitRequest.callback_url, the MCP submit_managed_job tool input, and the CLI `jobs submit` help. Optional HTTPS URL pushed once on terminal job state (durable, unsigned, dedupe on Run402-Webhook-Id, re-fetch via get before acting). - sdk: callback_url on ManagedJobSubmitRequest, forwarded by submit() - mcp tool: callback_url in managedJobSubmitRequestSchema + handler - cli: documented in `jobs submit` help/example (flows via --file/--stdin body) - tests: sdk (2) + mcp tool (1) forward-through cases Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1890da3 commit e6e95c3

5 files changed

Lines changed: 93 additions & 1 deletion

File tree

cli/lib/jobs.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,14 @@ Example request:
5151
{
5252
"job_type": "kysigned.fflonk_prove.v0_17_0",
5353
"input": { "input.json": {} },
54-
"max_cost_usd_micros": 50000
54+
"max_cost_usd_micros": 50000,
55+
"callback_url": "https://hooks.example.com/jobs"
5556
}
57+
58+
callback_url (optional) is an HTTPS URL pushed once on terminal state
59+
(completed/failed/cancelled), so you need not poll. Durable + unsigned:
60+
dedupe on the Run402-Webhook-Id header and re-fetch with 'jobs get'
61+
before acting.
5662
`,
5763
get: `run402 jobs get — Get a managed job run
5864

sdk/src/namespaces/jobs.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,47 @@ describe("jobs", () => {
7979
assert.deepEqual(JSON.parse(calls[0]!.body as string), submitRequest());
8080
});
8181

82+
it("submit forwards an optional callback_url verbatim", async () => {
83+
const { fetch, calls } = mockFetch(() =>
84+
json(
85+
{
86+
job_id: "job_cb",
87+
job_type: "kysigned.fflonk_prove.v0_17_0",
88+
status: "queued",
89+
created_at: "2026-05-18T00:00:00.000Z",
90+
},
91+
202,
92+
),
93+
);
94+
95+
await sdk(fetch).jobs.submit("prj_k", {
96+
...submitRequest(),
97+
callback_url: "https://hooks.example.com/jobs",
98+
});
99+
100+
const body = JSON.parse(calls[0]!.body as string);
101+
assert.equal(body.callback_url, "https://hooks.example.com/jobs");
102+
});
103+
104+
it("submit omits callback_url from the body when not provided", async () => {
105+
const { fetch, calls } = mockFetch(() =>
106+
json(
107+
{
108+
job_id: "job_nocb",
109+
job_type: "kysigned.fflonk_prove.v0_17_0",
110+
status: "queued",
111+
created_at: "2026-05-18T00:00:00.000Z",
112+
},
113+
202,
114+
),
115+
);
116+
117+
await sdk(fetch).jobs.submit("prj_k", submitRequest());
118+
119+
const body = JSON.parse(calls[0]!.body as string);
120+
assert.equal("callback_url" in body, false);
121+
});
122+
82123
it("get reads a job run by id", async () => {
83124
const { fetch, calls } = mockFetch(() =>
84125
json({

sdk/src/namespaces/jobs.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ export interface ManagedJobSubmitRequest {
2121
"input.json": Record<string, unknown>;
2222
};
2323
max_cost_usd_micros: number;
24+
/**
25+
* Optional HTTPS URL pushed once when the job reaches a terminal state
26+
* (completed/failed/cancelled), so you need not poll. Delivery is durable
27+
* (at-least-once, retried) and unsigned: dedupe on the `Run402-Webhook-Id`
28+
* header and re-fetch authoritative state with `get()` before acting — the
29+
* callback is a trigger, not the source of truth.
30+
*/
31+
callback_url?: string;
2432
}
2533

2634
export interface ManagedJobError {

src/tools/jobs.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,35 @@ describe("jobs MCP tools", () => {
7474
assert.match(calls[0]!.headers["Idempotency-Key"], /^job-/);
7575
});
7676

77+
it("forwards an optional callback_url in the POST body", async () => {
78+
let capturedBody: string | undefined;
79+
globalThis.fetch = (async (_input, init) => {
80+
capturedBody = init?.body as string | undefined;
81+
return new Response(
82+
JSON.stringify({
83+
job_id: "job_cb",
84+
job_type: "kysigned.fflonk_prove.v0_17_0",
85+
status: "queued",
86+
created_at: "2026-05-18T00:00:00.000Z",
87+
}),
88+
{ status: 202, headers: { "Content-Type": "application/json" } },
89+
);
90+
}) as typeof fetch;
91+
92+
await handleJobsSubmit({
93+
project_id: "prj_k",
94+
request: {
95+
job_type: "kysigned.fflonk_prove.v0_17_0",
96+
input: { "input.json": { envelopeId: "env_1" } },
97+
max_cost_usd_micros: 50_000,
98+
callback_url: "https://hooks.example.com/jobs",
99+
},
100+
});
101+
102+
assert.ok(capturedBody, "expected a request body");
103+
assert.equal(JSON.parse(capturedBody!).callback_url, "https://hooks.example.com/jobs");
104+
});
105+
77106
it("gets a job and reads logs", async () => {
78107
const urls: string[] = [];
79108
globalThis.fetch = (async (input) => {

src/tools/jobs.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ const managedJobSubmitRequestSchema = z
2121
.int()
2222
.nonnegative()
2323
.describe("Hard customer charge ceiling in micro-USD"),
24+
callback_url: z
25+
.string()
26+
.url()
27+
.optional()
28+
.describe(
29+
"Optional HTTPS URL pushed once on terminal state (completed/failed/cancelled), so you need not poll. Durable at-least-once + unsigned: dedupe on the Run402-Webhook-Id header and re-fetch with get_managed_job before acting.",
30+
),
2431
})
2532
.strict();
2633

@@ -63,6 +70,7 @@ export async function handleJobsSubmit(args: {
6370
job_type: "kysigned.fflonk_prove.v0_17_0";
6471
input: { "input.json": Record<string, unknown> };
6572
max_cost_usd_micros: number;
73+
callback_url?: string;
6674
};
6775
}): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
6876
try {

0 commit comments

Comments
 (0)