diff --git a/docs/concepts/jobs.md b/docs/concepts/jobs.md index e2b183d4f..a6c8d0acf 100644 --- a/docs/concepts/jobs.md +++ b/docs/concepts/jobs.md @@ -689,14 +689,14 @@ cache lookup when you already have a proxy in scope. Symmetric to `post_event`: callers that hold a `job_id` but no `JobProxy` reference can drive the rest of the post-submit lifecycle through module-level facades that share the same registry-URL -resolution + cached-proxy machinery. The Python surface lands in -v2.2; TypeScript and Java parity follows in separate PRs. +resolution + cached-proxy machinery. Python and TypeScript surfaces +land in v2.2; Java parity follows in a separate PR. -| Operation | Facade (Python) | Returns | -| ------------------------ | -------------------------------------------------- | ----------------------------- | -| Cancel a running job | `await mesh.jobs.cancel(job_id, reason=None)` | `None` | -| Read latest job state | `await mesh.jobs.status(job_id)` | `dict` (registry `Job` row) | -| Wait for terminal state | `await mesh.jobs.wait(job_id, timeout_secs=None)` | `result` payload on success | +| Operation | Python | TypeScript | Returns | +| ------------------------ | -------------------------------------------------- | ------------------------------------------------ | ----------------------------- | +| Cancel a running job | `await mesh.jobs.cancel(job_id, reason=None)` | `await mesh.jobs.cancel(jobId, reason?)` | `None` / `void` | +| Read latest job state | `await mesh.jobs.status(job_id)` | `await mesh.jobs.status(jobId)` | `dict` / `JobStatus` | +| Wait for terminal state | `await mesh.jobs.wait(job_id, timeout_secs=None)` | `await mesh.jobs.wait(jobId, timeoutSecs?)` | `result` payload on success | === "Python" @@ -726,13 +726,52 @@ v2.2; TypeScript and Java parity follows in separate PRs. result = await mesh.jobs.wait(job_id, timeout_secs=300.0) return {"result": result} ``` + +=== "TypeScript" + + ```typescript + agent.addTool({ + name: "abort_workflow", + capability: "abort_workflow", + parameters: z.object({ jobId: z.string(), reason: z.string() }), + execute: async ({ jobId, reason }) => { + await mesh.jobs.cancel(jobId, reason); + return { cancelled: jobId }; + }, + }); + + agent.addTool({ + name: "check_progress", + capability: "check_progress", + parameters: z.object({ jobId: z.string() }), + execute: async ({ jobId }) => { + const snapshot = await mesh.jobs.status(jobId); + return { + status: snapshot.status, + progress: snapshot.progress, + message: snapshot.progress_message, + }; + }, + }); + + agent.addTool({ + name: "run_to_completion", + capability: "run_to_completion", + parameters: z.object({ jobId: z.string() }), + execute: async ({ jobId }) => { + const result = await mesh.jobs.wait(jobId, 300); + return { result }; + }, + }); + ``` `cancel` is idempotent — calling it on an already-terminal job returns -ok. `wait` raises `TimeoutError` on `timeout_secs` expiry; pass `None` -(default) to wait until the job reaches a terminal state. `status` -returns the same shape `JobProxy.status()` exposes (registry `Job` -row, field-for-field). +ok. `wait` raises `TimeoutError` (Python) or rejects with an `Error` +whose message starts with `"timeout:"` (TypeScript) on `timeout_secs` +expiry; pass `None` / omit `timeoutSecs` to wait until the job reaches +a terminal state. `status` returns the same shape `JobProxy.status()` +exposes (registry `Job` row, field-for-field). If the calling code already holds a `JobProxy`, the same surface is on the proxy directly: `proxy.cancel(reason)`, `proxy.status()`, diff --git a/src/core/cli/man/content/jobs_typescript.md b/src/core/cli/man/content/jobs_typescript.md index 19dddf320..d3741b881 100644 --- a/src/core/cli/man/content/jobs_typescript.md +++ b/src/core/cli/man/content/jobs_typescript.md @@ -296,6 +296,29 @@ cap 256; tune via `MCP_MESH_JOBPROXY_CACHE_MAX`). If the calling code already holds a `JobProxy`, use `proxy.sendEvent(eventType, payload)` directly — same wire shape, skip the helper. +**Lifecycle facades by `jobId`.** Same DDDI-clean pattern as +`postEvent` — module-level helpers that take a `jobId` and dispatch +through the shared proxy cache, for callers that don't hold a +`JobProxy` reference: + +```typescript +// Cancel a running job (idempotent — already-terminal jobs return ok) +await mesh.jobs.cancel(jobId, "user requested abort"); + +// Read latest job state (JobStatus — registry Job row, field-for-field) +const snapshot = await mesh.jobs.status(jobId); +// snapshot.status ∈ "working" | "input_required" | "completed" | "failed" | "cancelled" + +// Wait for terminal state and return the result payload +const result = await mesh.jobs.wait(jobId, 300); +``` + +`wait` rejects with an `Error` whose message starts with `"timeout:"` +on `timeoutSecs` expiry; omit `timeoutSecs` (or pass `undefined`) to +wait until the job reaches a terminal state. All three reject with +`JobNotFoundError` if the registry has reaped the job; `cancel` also +re-classifies a conflict response into `JobTerminalError`. + **Typed errors** (both extend `Error`): - `JobNotFoundError` — job swept or id typo diff --git a/src/runtime/typescript/src/__tests__/jobs.spec.ts b/src/runtime/typescript/src/__tests__/jobs.spec.ts index 72e69316d..27abda1af 100644 --- a/src/runtime/typescript/src/__tests__/jobs.spec.ts +++ b/src/runtime/typescript/src/__tests__/jobs.spec.ts @@ -26,16 +26,25 @@ vi.mock("@mcpmesh/core", async () => { const proxyCalls: Array<{ jobId: string; registryUrl: string }> = []; const sendEventMock = vi.fn(); const listEventsMock = vi.fn(); + const cancelMock = vi.fn(); + const statusMock = vi.fn(); + const waitMock = vi.fn(); class JobProxy { public readonly jobId: string; public readonly registryUrl: string; public readonly sendEvent: typeof sendEventMock; public readonly listEvents: typeof listEventsMock; + public readonly cancel: typeof cancelMock; + public readonly status: typeof statusMock; + public readonly wait: typeof waitMock; constructor(jobId: string, registryUrl: string) { this.jobId = jobId; this.registryUrl = registryUrl; this.sendEvent = sendEventMock; this.listEvents = listEventsMock; + this.cancel = cancelMock; + this.status = statusMock; + this.wait = waitMock; proxyCalls.push({ jobId, registryUrl }); } } @@ -59,13 +68,19 @@ vi.mock("@mcpmesh/core", async () => { __sendEventMock: sendEventMock, __recvEventMock: recvEventMock, __listEventsMock: listEventsMock, + __cancelMock: cancelMock, + __statusMock: statusMock, + __waitMock: waitMock, __proxyCalls: proxyCalls, }; }); import { + cancel as cancelFacade, postEvent, + status as statusFacade, subscribeEvents, + wait as waitFacade, _getOrCreateProxy, _clearProxyCache, translateJobError, @@ -79,6 +94,9 @@ const core = (await import("@mcpmesh/core")) as unknown as { __sendEventMock: ReturnType; __recvEventMock: ReturnType; __listEventsMock: ReturnType; + __cancelMock: ReturnType; + __statusMock: ReturnType; + __waitMock: ReturnType; __proxyCalls: Array<{ jobId: string; registryUrl: string }>; JobController: new (jobId: string, instanceId: string, registryUrl: string) => { recvEvent: ( @@ -91,12 +109,18 @@ const core = (await import("@mcpmesh/core")) as unknown as { const sendEventMock = core.__sendEventMock; const recvEventMock = core.__recvEventMock; const listEventsMock = core.__listEventsMock; +const cancelMock = core.__cancelMock; +const statusMock = core.__statusMock; +const waitMock = core.__waitMock; const proxyCalls = core.__proxyCalls; beforeEach(() => { sendEventMock.mockReset(); recvEventMock.mockReset(); listEventsMock.mockReset(); + cancelMock.mockReset(); + statusMock.mockReset(); + waitMock.mockReset(); proxyCalls.length = 0; _clearProxyCache(); }); @@ -516,3 +540,254 @@ describe("subscribeEvents", () => { }); }); }); + +// --------------------------------------------------------------------------- +// cancel — DDDI-clean lifecycle facade (issue #1078) +// --------------------------------------------------------------------------- +describe("cancel", () => { + beforeEach(() => { + delete process.env.MCP_MESH_REGISTRY_URL; + }); + + it("constructs a JobProxy from MCP_MESH_REGISTRY_URL + calls cancel with reason", async () => { + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + cancelMock.mockResolvedValueOnce(undefined); + await cancelFacade("j-1", "user requested"); + expect(proxyCalls).toHaveLength(1); + expect(proxyCalls[0]).toEqual({ + jobId: "j-1", + registryUrl: "http://localhost:8000", + }); + expect(cancelMock).toHaveBeenCalledWith("user requested"); + }); + + it("forwards null reason when omitted", async () => { + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + cancelMock.mockResolvedValueOnce(undefined); + await cancelFacade("j-1"); + expect(cancelMock).toHaveBeenCalledWith(null); + }); + + it("throws when MCP_MESH_REGISTRY_URL is unset", async () => { + delete process.env.MCP_MESH_REGISTRY_URL; + await expect(cancelFacade("j-1")).rejects.toThrow( + /MCP_MESH_REGISTRY_URL is not set/, + ); + }); + + it("re-classifies a 'job not found' napi error to JobNotFoundError", async () => { + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + cancelMock.mockRejectedValueOnce( + new Error("backend error: job not found: stale"), + ); + await expect(cancelFacade("stale")).rejects.toBeInstanceOf(JobNotFoundError); + }); + + it("re-classifies a 'job is terminal' napi error to JobTerminalError", async () => { + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + cancelMock.mockRejectedValueOnce( + new Error("job is terminal: completed"), + ); + await expect(cancelFacade("done")).rejects.toBeInstanceOf(JobTerminalError); + }); +}); + +// --------------------------------------------------------------------------- +// status — DDDI-clean lifecycle facade (issue #1078) +// --------------------------------------------------------------------------- +describe("status", () => { + beforeEach(() => { + delete process.env.MCP_MESH_REGISTRY_URL; + }); + + it("constructs a JobProxy from MCP_MESH_REGISTRY_URL + returns the snapshot", async () => { + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + statusMock.mockResolvedValueOnce({ + id: "j-1", + capability: "do_work", + status: "working", + progress: 0.5, + progress_message: "halfway", + result: null, + error: null, + attempt_count: 1, + max_retries: 0, + submitted_at: 1_700_000_000, + submitted_by: "consumer", + }); + const snapshot = await statusFacade("j-1"); + expect(snapshot).toMatchObject({ + id: "j-1", + status: "working", + progress: 0.5, + progress_message: "halfway", + }); + expect(proxyCalls).toHaveLength(1); + expect(proxyCalls[0]).toEqual({ + jobId: "j-1", + registryUrl: "http://localhost:8000", + }); + expect(statusMock).toHaveBeenCalledWith(); + }); + + it("throws when MCP_MESH_REGISTRY_URL is unset", async () => { + delete process.env.MCP_MESH_REGISTRY_URL; + await expect(statusFacade("j-1")).rejects.toThrow( + /MCP_MESH_REGISTRY_URL is not set/, + ); + }); + + it("re-classifies a 'job not found' napi error to JobNotFoundError", async () => { + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + statusMock.mockRejectedValueOnce( + new Error("backend error: job not found: stale"), + ); + await expect(statusFacade("stale")).rejects.toBeInstanceOf(JobNotFoundError); + }); + + it("returns every JobStatus key from the wire snapshot (no key is omitted)", async () => { + // Pin the JobStatus interface contract: the napi binding's + // `job_to_json` (src/runtime/core/src/jobs_napi.rs) always emits + // EVERY field, using `null` for Rust `Option::None`. Downstream + // callers must be able to rely on key-presence — only null-checks + // are needed for nullable fields. + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + statusMock.mockResolvedValueOnce({ + id: "j-1", + capability: "do_work", + owner_instance_id: null, + status: "working", + progress: null, + progress_message: null, + result: null, + error: null, + submitted_payload: { task: "x" }, + attempt_count: 1, + max_retries: 0, + max_duration: null, + total_deadline: null, + lease_expires_at: null, + last_heartbeat_at: null, + submitted_at: 1_700_000_000, + submitted_by: "consumer", + }); + const snapshot = await statusFacade("j-1"); + const expectedKeys = [ + "id", + "capability", + "owner_instance_id", + "status", + "progress", + "progress_message", + "result", + "error", + "submitted_payload", + "attempt_count", + "max_retries", + "max_duration", + "total_deadline", + "lease_expires_at", + "last_heartbeat_at", + "submitted_at", + "submitted_by", + ].sort(); + expect(Object.keys(snapshot).sort()).toEqual(expectedKeys); + }); +}); + +// --------------------------------------------------------------------------- +// wait — DDDI-clean lifecycle facade (issue #1078) +// --------------------------------------------------------------------------- +describe("wait", () => { + beforeEach(() => { + delete process.env.MCP_MESH_REGISTRY_URL; + }); + + it("constructs a JobProxy + returns the result payload on success", async () => { + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + waitMock.mockResolvedValueOnce({ ok: true, value: 42 }); + const result = await waitFacade("j-1", 60); + expect(result).toEqual({ ok: true, value: 42 }); + expect(proxyCalls).toHaveLength(1); + expect(proxyCalls[0]).toEqual({ + jobId: "j-1", + registryUrl: "http://localhost:8000", + }); + expect(waitMock).toHaveBeenCalledWith(60); + }); + + it("forwards null timeoutSecs when omitted", async () => { + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + waitMock.mockResolvedValueOnce("done"); + await waitFacade("j-1"); + expect(waitMock).toHaveBeenCalledWith(null); + }); + + it("throws when MCP_MESH_REGISTRY_URL is unset", async () => { + delete process.env.MCP_MESH_REGISTRY_URL; + await expect(waitFacade("j-1")).rejects.toThrow( + /MCP_MESH_REGISTRY_URL is not set/, + ); + }); + + it("re-classifies a 'job not found' napi error to JobNotFoundError", async () => { + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + waitMock.mockRejectedValueOnce( + new Error("backend error: job not found: stale"), + ); + await expect(waitFacade("stale")).rejects.toBeInstanceOf(JobNotFoundError); + }); + + it("re-classifies a 'job is terminal' napi error to JobTerminalError", async () => { + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + waitMock.mockRejectedValueOnce( + new Error("job is terminal: failed"), + ); + await expect(waitFacade("done")).rejects.toBeInstanceOf(JobTerminalError); + }); + + it("propagates a timeout error as a plain Error with 'timeout:' prefix", async () => { + // The napi binding maps `JobError::Timeout` → message starting with + // `"timeout:"` (see jobs_napi.rs::job_error_to_napi). The current + // `translateJobError` doesn't re-classify this — callers + // discriminate via the message prefix. A typed `TimeoutError` may + // be added in a follow-up. + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + waitMock.mockRejectedValueOnce( + new Error("timeout: wait timed out after 60s"), + ); + await expect(waitFacade("slow", 60)).rejects.toThrow(/^timeout:/); + }); +}); + +// --------------------------------------------------------------------------- +// Shared proxy cache across lifecycle facades (W6-style, issue #1078) +// --------------------------------------------------------------------------- +describe("shared JobProxy cache across cancel/status/wait/postEvent", () => { + it("reuses the same cached proxy across all four facades for the same jobId", async () => { + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + cancelMock.mockResolvedValue(undefined); + statusMock.mockResolvedValue({ id: "shared", status: "working" }); + waitMock.mockResolvedValue({ ok: true }); + sendEventMock.mockResolvedValue({ + job_id: "shared", + seq: 1, + created_at: 1, + }); + + await cancelFacade("shared", "test"); + await statusFacade("shared"); + await waitFacade("shared", 5); + await postEvent("shared", "signal", {}); + + // All four facades dispatched against the SAME cached proxy — + // only one construction recorded. Proves the lifecycle helpers + // share the same `(registryUrl, jobId)` cache as postEvent / + // subscribeEvents. + expect(proxyCalls).toHaveLength(1); + expect(proxyCalls[0]).toEqual({ + jobId: "shared", + registryUrl: "http://localhost:8000", + }); + }); +}); diff --git a/src/runtime/typescript/src/index.ts b/src/runtime/typescript/src/index.ts index e2667a8ac..c870ffe23 100644 --- a/src/runtime/typescript/src/index.ts +++ b/src/runtime/typescript/src/index.ts @@ -58,8 +58,11 @@ import { llmProvider } from "./llm-provider.js"; import { sseStream } from "./sse-stream.js"; import { mount as a2aMount } from "./a2a/producer/index.js"; import { + cancel as jobsCancel, postEvent as jobsPostEvent, + status as jobsStatus, subscribeEvents as jobsSubscribeEvents, + wait as jobsWait, } from "./jobs.js"; /** @@ -89,13 +92,19 @@ const a2a: MeshA2ANamespace = { mount: a2aMount }; * `MCP_MESH_REGISTRY_URL` and reuses a process-cached `JobProxy`). */ interface MeshJobsNamespace { + cancel: typeof jobsCancel; postEvent: typeof jobsPostEvent; + status: typeof jobsStatus; subscribeEvents: typeof jobsSubscribeEvents; + wait: typeof jobsWait; } const jobs: MeshJobsNamespace = { + cancel: jobsCancel, postEvent: jobsPostEvent, + status: jobsStatus, subscribeEvents: jobsSubscribeEvents, + wait: jobsWait, }; // Create mesh namespace with route and llm attached @@ -353,12 +362,16 @@ export { registerCancelRoute } from "./jobs-cancel-route.js"; // classes are exported so consumers can `instanceof`-discriminate // against the napi binding's generic Error. export { + cancel, postEvent, + status, subscribeEvents, + wait, JobNotFoundError, JobTerminalError, type JobEvent, type JobEventReceipt, + type JobStatus, type SubscribeEventsOptions, } from "./jobs.js"; // Re-export napi-rs job primitives for users who want to drop down diff --git a/src/runtime/typescript/src/jobs.ts b/src/runtime/typescript/src/jobs.ts index 6beaf2f73..6ccede1db 100644 --- a/src/runtime/typescript/src/jobs.ts +++ b/src/runtime/typescript/src/jobs.ts @@ -61,6 +61,55 @@ export interface JobEventReceipt { created_at: number; } +/** + * Latest job-row snapshot returned by `JobProxy.status` / {@link status}. + * + * Matches the registry's OpenAPI `Job` schema field-for-field — the same + * shape `job_to_json` in `src/runtime/core/src/jobs_napi.rs` produces. + * Every key is ALWAYS present on the wire: required fields are typed + * `T`, and Rust `Option` fields are emitted as `T | null` (never + * absent / `undefined`). The Rust `serde_json::json!` macro the binding + * uses serialises `None` → `null` for every field unconditionally, so + * downstream callers can rely on key-presence and only need to null-check + * the nullable fields. + */ +export interface JobStatus { + /** Server-assigned job UUID. */ + id: string; + /** Capability the job was submitted against. */ + capability: string; + /** Instance id of the replica currently holding the lease (if any). */ + owner_instance_id: string | null; + /** Lifecycle status. */ + status: "working" | "input_required" | "completed" | "failed" | "cancelled"; + /** Latest progress fraction in `[0.0, 1.0]`. */ + progress: number | null; + /** Latest progress message string. */ + progress_message: string | null; + /** Terminal result payload (set when `status === "completed"`). */ + result: unknown; + /** Terminal error reason (set when `status === "failed"` / `"cancelled"`). */ + error: string | null; + /** Original request payload the job was submitted with. */ + submitted_payload: unknown; + /** Number of attempts so far (1-indexed). */ + attempt_count: number; + /** Maximum retries beyond the initial attempt. */ + max_retries: number; + /** Per-attempt soft timeout in seconds. */ + max_duration: number | null; + /** Hard ceiling across all attempts, as a unix epoch second. */ + total_deadline: number | null; + /** Unix epoch second when the current lease expires. */ + lease_expires_at: number | null; + /** Unix epoch second of the last heartbeat from the owner replica. */ + last_heartbeat_at: number | null; + /** Unix epoch second the job row was created. */ + submitted_at: number; + /** Identifier of the agent that submitted the job. */ + submitted_by: string; +} + // --------------------------------------------------------------------------- // Typed error classes // --------------------------------------------------------------------------- @@ -236,7 +285,7 @@ function resolveRegistryUrl(): string { const url = process.env.MCP_MESH_REGISTRY_URL; if (!url) { throw new Error( - "mesh.jobs.postEvent: MCP_MESH_REGISTRY_URL is not set; " + + "mesh.jobs: MCP_MESH_REGISTRY_URL is not set; " + "cannot resolve registry base URL. Ensure the calling " + "process is running inside a mesh agent.", ); @@ -452,3 +501,201 @@ export async function* subscribeEvents( if (nextAfter > cursor) cursor = nextAfter; } } + +// --------------------------------------------------------------------------- +// cancel / status / wait — DDDI-clean lifecycle facades (issue #1078) +// --------------------------------------------------------------------------- +// +// Mirror the `postEvent` / `subscribeEvents` pattern: take a `jobId` as +// the first positional arg, resolve the registry URL internally via +// `resolveRegistryUrl()`, dispatch through a cached `JobProxy` from +// `_getOrCreateProxy()`, and re-classify the napi layer's generic +// `Error` output via `translateJobError`. +// +// These exist so callers that hold only a `jobId` (e.g. an Express +// route handler, a tool body whose request payload carries a stashed +// id) can operate on the job's lifecycle without constructing a +// `JobProxy` directly — which would leak `MCP_MESH_REGISTRY_URL` +// addressing into user code and break the DDDI contract. + +/** + * Cancel a running job by ID. + * + * Convenience helper for callers that hold a `jobId` but do not have a + * `JobProxy` reference in scope. Constructs (or reuses, via the LRU + * cache) a transient proxy bound to the current agent's registry URL + * and forwards the call. + * + * Per the registry's idempotency contract, calling `cancel` on a job + * that is already in a terminal state returns successfully without + * re-firing cancellation. If the registry surfaces a conflict for + * some other reason, the facade re-classifies it as + * {@link JobTerminalError}. The registry forwards the cancel signal to + * the owner replica via `POST /jobs/{id}/cancel`; the running handler's + * cancel token fires on the next `await` point, and any outbound + * `McpMeshTool` proxy calls abort their underlying `fetch`. + * + * Mirrors Python's `mesh.jobs.cancel` one-for-one. + * + * @param jobId - Target job's server-assigned id. + * @param reason - Optional human-readable reason recorded against the + * cancellation. Surfaces in the synthetic + * `{ type: "cancelled" }` event the registry writes into the job's + * event log, so a handler parked on `recvEvent(["cancelled"])` can + * return cleanly with the reason in scope. + * + * @throws {@link JobNotFoundError} If the registry doesn't know the + * job (sweep already removed it, or wrong id). + * @throws {@link JobTerminalError} If the registry surfaces a conflict + * for this cancel (e.g. the idempotency contract changes upstream or + * the registry treats the targeted terminal state as a conflict). + * @throws Error For transport errors (registry unreachable, 5xx after + * retries, malformed payload, etc.) — the underlying error message + * is preserved. + * + * @example + * Cancel a job from a tool that receives the id in its payload: + * ```ts + * agent.addTool({ + * name: "abort_workflow", + * capability: "abort_workflow", + * parameters: z.object({ jobId: z.string(), reason: z.string() }), + * execute: async ({ jobId, reason }) => { + * await mesh.jobs.cancel(jobId, reason); + * return { cancelled: jobId }; + * }, + * }); + * ``` + */ +export async function cancel(jobId: string, reason?: string): Promise { + const registryUrl = resolveRegistryUrl(); + const proxy = _getOrCreateProxy(registryUrl, jobId); + try { + await proxy.cancel(reason ?? null); + } catch (err) { + const translated = translateJobError(err); + if (translated !== err) { + throw translated; + } + throw err; + } +} + +/** + * Get the current status of a job by ID. + * + * Convenience helper for callers that hold a `jobId` but do not have a + * `JobProxy` reference in scope. Constructs (or reuses, via the LRU + * cache) a transient proxy bound to the current agent's registry URL + * and forwards a single `GET /jobs/{id}` to the registry. + * + * Mirrors Python's `mesh.jobs.status` one-for-one. + * + * @param jobId - Target job's server-assigned id. + * @returns Job status snapshot — the same shape `JobProxy.status()` + * returns, mirroring the registry's `Job` schema field-for-field. + * + * @throws {@link JobNotFoundError} If the registry doesn't know the + * job (sweep already removed it, or wrong id). + * @throws Error For transport errors (registry unreachable, 5xx after + * retries, malformed payload, etc.) — the underlying error message + * is preserved. + * + * @example + * Poll a job's progress from outside the producer agent: + * ```ts + * agent.addTool({ + * name: "check_progress", + * capability: "check_progress", + * parameters: z.object({ jobId: z.string() }), + * execute: async ({ jobId }) => { + * const snapshot = await mesh.jobs.status(jobId); + * return { + * status: snapshot.status, + * progress: snapshot.progress, + * message: snapshot.progress_message, + * }; + * }, + * }); + * ``` + */ +export async function status(jobId: string): Promise { + const registryUrl = resolveRegistryUrl(); + const proxy = _getOrCreateProxy(registryUrl, jobId); + try { + return (await proxy.status()) as JobStatus; + } catch (err) { + const translated = translateJobError(err); + if (translated !== err) { + throw translated; + } + throw err; + } +} + +/** + * Wait for a job to complete and return its result. + * + * Convenience helper for callers that hold a `jobId` but do not have a + * `JobProxy` reference in scope. Constructs (or reuses, via the LRU + * cache) a transient proxy bound to the current agent's registry URL + * and polls until the job reaches a terminal state. + * + * On success, returns the `result` payload the handler passed to + * `JobController.complete` — any JSON-shaped value (object / array / + * primitive). On a non-success terminal (`failed` / `cancelled`) the + * underlying napi layer rejects with a generic `Error` carrying the + * Rust `JobError` display string. On `timeoutSecs` expiry the napi + * layer rejects with an `Error` whose message starts with + * `"timeout:"` — `translateJobError` does NOT (currently) re-classify + * this into a typed exception; callers that need to discriminate + * timeout from other failures should check `err.message.startsWith( + * "timeout:")`. A typed `TimeoutError` may be added in a future PR + * if usage warrants it. + * + * Mirrors Python's `mesh.jobs.wait` one-for-one. + * + * @param jobId - Target job's server-assigned id. + * @param timeoutSecs - Maximum wait duration in seconds. `undefined` / + * `null` ≡ no timeout (default) — wait until the job reaches a + * terminal state. Negative / NaN / infinite values are rejected by + * the napi layer with a clear `Error` before any registry call. + * @returns The job's result payload (whatever the handler passed to + * `complete()`). Shape is application-defined — typically an object, + * but any JSON-shaped value is valid. + * + * @throws {@link JobNotFoundError} If the registry doesn't know the + * job (sweep already removed it, or wrong id). + * @throws Error With message prefixed `"timeout:"` if `timeoutSecs` + * elapses before the job reaches a terminal state. + * @throws Error If the job reached a non-success terminal state + * (`failed` / `cancelled`) or for transport errors — the underlying + * error message is preserved. + * + * @example + * Submit-then-wait from a tool that doesn't hold the proxy: + * ```ts + * agent.addTool({ + * name: "run_to_completion", + * capability: "run_to_completion", + * parameters: z.object({ jobId: z.string() }), + * execute: async ({ jobId }) => { + * const result = await mesh.jobs.wait(jobId, 300); + * return { result }; + * }, + * }); + * ``` + */ +export async function wait(jobId: string, timeoutSecs?: number): Promise { + const registryUrl = resolveRegistryUrl(); + const proxy = _getOrCreateProxy(registryUrl, jobId); + try { + return await proxy.wait(timeoutSecs ?? null); + } catch (err) { + const translated = translateJobError(err); + if (translated !== err) { + throw translated; + } + throw err; + } +}