diff --git a/src/runtime/core/src/jobs_napi.rs b/src/runtime/core/src/jobs_napi.rs index 7151d1701..93db273cf 100644 --- a/src/runtime/core/src/jobs_napi.rs +++ b/src/runtime/core/src/jobs_napi.rs @@ -40,7 +40,7 @@ use crate::jobs::{ BatchingHandle, JobController, JobError, JobProxy, SubmitJobArgs, }; use crate::task_backend::{ - Job, JobStatus, RegistryHttpBackend, TaskBackend, + Job, JobEvent, JobEventReceipt, JobStatus, RegistryHttpBackend, TaskBackend, }; // ============================================================================= @@ -56,10 +56,36 @@ fn backend_from_url(registry_url: &str) -> napi::Result> { Ok(backend.into_arc()) } +/// Validate and convert a JS-supplied `timeoutSecs` (`Option`) into +/// an `Option`. `Duration::from_secs_f64` panics on negative, +/// NaN, infinite, or out-of-range inputs; this helper traps those at the +/// JS boundary and surfaces a clean `Error` instead so a typo'd literal +/// in user code can't crash the Rust runtime. Uses the fallible +/// `try_from_secs_f64` for the final conversion so even finite-but-huge +/// values (e.g. `f64::MAX`) reject cleanly instead of panicking. Mirrors +/// `parse_timeout_secs` in `jobs_py.rs`. +fn parse_timeout_secs(secs: Option) -> napi::Result> { + match secs { + None => Ok(None), + Some(s) if s.is_nan() || s.is_infinite() || s < 0.0 => Err(Error::from_reason( + format!("timeoutSecs must be non-negative and finite, got {s}"), + )), + Some(s) => Duration::try_from_secs_f64(s) + .map(Some) + .map_err(|e| Error::from_reason(format!("timeoutSecs out of range: {e} (got {s})"))), + } +} + /// Map [`JobError`] onto a napi error with reasonable messages so callers /// can `try/catch` cleanly. We don't have language-level distinct exception /// types like Python's `TimeoutError`, so we encode the variant name as /// the leading token; SDK code can string-match if it needs to discriminate. +/// +/// `JobError::JobTerminal` and the `NotFound` backend error carry the +/// distinct "job is terminal" / "job not found" prefixes that the TS SDK +/// re-classifies into `JobTerminalError` / `JobNotFoundError` typed +/// exceptions — matching the Python SDK's string-based dispatch in +/// `mesh/jobs.py::_translate_job_error`. fn job_error_to_napi(err: JobError) -> Error { match err { JobError::Timeout(d) => { @@ -68,10 +94,44 @@ fn job_error_to_napi(err: JobError) -> Error { JobError::Cancelled => { Error::from_reason("cancelled: job cancelled by enclosing context") } + // `JobTerminal` surfaces the exact "job is terminal" prefix the + // TS SDK's `JobTerminalError` re-classification keys on (mirror + // of Python's `mesh/jobs.py::_translate_job_error`). Keep the + // string stable — it is part of the SDK contract. + JobError::JobTerminal(msg) => { + Error::from_reason(format!("job is terminal: {}", msg)) + } other => Error::from_reason(other.to_string()), } } +/// Convert a [`JobEvent`] into a JSON value the TS layer consumes as a +/// plain JS object via napi-rs's `serde-json` integration. Field shape +/// mirrors the OpenAPI `JobEvent` schema and the Python `job_event_to_pydict` +/// helper field-for-field so cross-runtime test fixtures can read either. +fn job_event_to_json(ev: JobEvent) -> serde_json::Value { + serde_json::json!({ + "job_id": ev.job_id, + "seq": ev.seq, + "type": ev.event_type, + "payload": ev.payload.unwrap_or(serde_json::Value::Null), + "trace_context": ev.trace_context.unwrap_or(serde_json::Value::Null), + "posted_by": ev.posted_by, + "created_at": ev.created_at, + }) +} + +/// Convert a [`JobEventReceipt`] into a JSON value the TS layer consumes +/// as a plain JS object. Mirrors `JobEventPostResponse` field-for-field +/// (= Python's `job_event_receipt_to_pydict`). +fn job_event_receipt_to_json(receipt: JobEventReceipt) -> serde_json::Value { + serde_json::json!({ + "job_id": receipt.job_id, + "seq": receipt.seq, + "created_at": receipt.created_at, + }) +} + /// Convert a [`Job`] into a JSON value the TS layer can `JSON.parse`-style /// consume directly. napi-rs's `serde-json` integration converts to a JS /// object on the boundary so the SDK doesn't need a separate parse step. @@ -240,6 +300,33 @@ impl JsJobController { .map_err(job_error_to_napi)?; Ok(()) } + + /// Wait for the next event posted into this job's event log. + /// + /// Mirrors [`JobController::recv_event`]. Returns the event as a JS + /// object on arrival, `null` on timeout. Cursor is + /// per-controller-instance (shared across `clone`s); a fresh + /// controller for the same `jobId` replays from seq=0. + /// + /// `timeoutSecs` is validated for NaN/Infinity/negative via + /// [`parse_timeout_secs`] — invalid inputs reject with a clear + /// `Error("timeoutSecs must be non-negative and finite")` rather than + /// crashing the Rust runtime via `Duration::from_secs_f64`'s panic + /// (mirror of the Python `PyValueError` path in `jobs_py.rs`). + #[napi] + pub async fn recv_event( + &self, + types: Option>, + timeout_secs: Option, + ) -> Result> { + let timeout = parse_timeout_secs(timeout_secs)?; + let result = self + .inner + .recv_event(types, timeout) + .await + .map_err(job_error_to_napi)?; + Ok(result.map(job_event_to_json)) + } } // ============================================================================= @@ -284,9 +371,13 @@ impl JsJobProxy { /// payload as a JS value (object/array/primitive) on success; /// rejects with a "timeout: ..." error on `timeoutSecs`, or /// "cancelled" / other reason on non-success terminal. + /// + /// `timeoutSecs` is validated via [`parse_timeout_secs`] — NaN / + /// Infinity / negative inputs reject with a clear `Error` rather + /// than panicking inside `Duration::from_secs_f64`. #[napi] pub async fn wait(&self, timeout_secs: Option) -> Result { - let timeout = timeout_secs.map(Duration::from_secs_f64); + let timeout = parse_timeout_secs(timeout_secs)?; self.inner .wait(timeout) .await @@ -301,6 +392,35 @@ impl JsJobProxy { self.inner.cancel(reason).await.map_err(job_error_to_napi)?; Ok(()) } + + /// Post an event into this job's event log. The running handler + /// (inside the `task: true` job) will see it on its next + /// `recvEvent` call — or wake immediately if it's currently + /// long-polling. + /// + /// `payload` is any JSON-shaped JS value (object/array/primitive). + /// The receipt object carries `{ job_id, seq, created_at }` so + /// callers can stitch a follow-up `recvEvent` to it via `after`. + /// + /// Throws on: + /// - job-not-found (registry doesn't know the job) — the SDK + /// re-classifies "job not found" into `JobNotFoundError` + /// - job-terminal (job already completed/failed/cancelled) — the + /// SDK re-classifies "job is terminal" into `JobTerminalError` + /// - other transport / backend errors. + #[napi] + pub async fn send_event( + &self, + event_type: String, + payload: serde_json::Value, + ) -> Result { + let receipt = self + .inner + .send_event(event_type, payload) + .await + .map_err(job_error_to_napi)?; + Ok(job_event_receipt_to_json(receipt)) + } } // ============================================================================= @@ -540,7 +660,19 @@ pub async fn with_job_async_napi( return Err(Error::from_reason(msg)); } let ctx = match deadline_secs { - Some(secs) if secs > 0.0 => JobContext::with_timeout(job_id, Duration::from_secs_f64(secs)), + Some(secs) if secs > 0.0 => { + // `validate_deadline_secs` already rejects NaN / Inf / negative, + // but a finite-but-huge `secs` (e.g. `f64::MAX`) would still + // panic in `Duration::from_secs_f64`. Use the fallible variant + // and surface a clean napi error instead. + let dur = Duration::try_from_secs_f64(secs).map_err(|e| { + Error::from_reason(format!( + "withJobAsync: deadline_secs ({}) out of range for job {} — {}", + secs, job_id, e + )) + })?; + JobContext::with_timeout(job_id, dur) + } _ => JobContext::new(job_id), }; run_as_job(ctx, async move { body.await }).await @@ -548,7 +680,43 @@ pub async fn with_job_async_napi( #[cfg(test)] mod tests { - use super::validate_deadline_secs; + use super::{parse_timeout_secs, validate_deadline_secs}; + + #[test] + fn parse_timeout_secs_accepts_none_and_valid() { + assert_eq!(parse_timeout_secs(None).unwrap(), None); + assert_eq!( + parse_timeout_secs(Some(0.0)).unwrap(), + Some(std::time::Duration::from_secs(0)) + ); + assert_eq!( + parse_timeout_secs(Some(1.5)).unwrap(), + Some(std::time::Duration::from_millis(1500)) + ); + } + + #[test] + fn parse_timeout_secs_rejects_nan_inf_negative() { + assert!(parse_timeout_secs(Some(f64::NAN)).is_err()); + assert!(parse_timeout_secs(Some(f64::INFINITY)).is_err()); + assert!(parse_timeout_secs(Some(f64::NEG_INFINITY)).is_err()); + assert!(parse_timeout_secs(Some(-1.0)).is_err()); + } + + #[test] + fn parse_timeout_secs_rejects_overflow_finite() { + // `f64::MAX` is finite but vastly exceeds `Duration`'s range + // (`u64::MAX` seconds). The pre-fix `Duration::from_secs_f64` + // would panic here; the fix uses `try_from_secs_f64` and surfaces + // a clean napi error instead. Regression test for the panic path. + let err = parse_timeout_secs(Some(f64::MAX)).expect_err("should reject overflow"); + let msg = err.reason.as_str(); + assert!( + msg.contains("out of range"), + "expected 'out of range' in error, got: {msg}" + ); + } + #[test] fn validate_deadline_secs_accepts_none_and_zero_and_positive() { diff --git a/src/runtime/core/src/jobs_py.rs b/src/runtime/core/src/jobs_py.rs index c3f420466..f93247e3c 100644 --- a/src/runtime/core/src/jobs_py.rs +++ b/src/runtime/core/src/jobs_py.rs @@ -39,16 +39,20 @@ use crate::task_backend::{ /// Validate and convert a Python-supplied `timeout_secs` (`Option`) into /// an `Option`. `Duration::from_secs_f64` panics on negative, NaN, -/// or infinite inputs; this helper traps those at the Python boundary and -/// surfaces a clean `ValueError` instead so the runtime can't be crashed by -/// a typo'd timeout literal in user code. +/// infinite, or out-of-range inputs; this helper traps those at the Python +/// boundary and surfaces a clean `ValueError` instead so the runtime can't +/// be crashed by a typo'd timeout literal in user code. Uses the fallible +/// `try_from_secs_f64` for the final conversion so even finite-but-huge +/// values (e.g. `sys.float_info.max`) reject cleanly instead of panicking. fn parse_timeout_secs(secs: Option) -> PyResult> { match secs { None => Ok(None), Some(s) if s.is_nan() || s.is_infinite() || s < 0.0 => Err(PyValueError::new_err( format!("timeout_secs must be non-negative and finite, got {s}"), )), - Some(s) => Ok(Some(Duration::from_secs_f64(s))), + Some(s) => Duration::try_from_secs_f64(s).map(Some).map_err(|e| { + PyValueError::new_err(format!("timeout_secs out of range: {e} (got {s})")) + }), } } @@ -642,8 +646,23 @@ pub fn with_job_async_py<'py>( // to "now" on the Tokio runtime, which matches the semantics of the // SDK reading `X-Mesh-Timeout: ` from the inbound request). let ctx = match deadline_secs { + Some(secs) if secs.is_nan() || secs.is_infinite() => { + return Err(PyValueError::new_err(format!( + "with_job_async: deadline_secs ({}) must be a finite number or None", + secs + ))); + } Some(secs) if secs > 0.0 => { - JobContext::with_timeout(job_id.clone(), Duration::from_secs_f64(secs)) + // Finite-but-huge `secs` (e.g. `sys.float_info.max`) would + // panic in `Duration::from_secs_f64`. Use the fallible variant + // and surface a clean `ValueError` instead. + let dur = Duration::try_from_secs_f64(secs).map_err(|e| { + PyValueError::new_err(format!( + "with_job_async: deadline_secs ({}) out of range for job {} — {}", + secs, job_id, e + )) + })?; + JobContext::with_timeout(job_id.clone(), dur) } _ => JobContext::new(job_id.clone()), }; @@ -744,3 +763,11 @@ pub fn current_job_py(py: Python<'_>) -> PyResult>> { } } } + +// NOTE: no `#[cfg(test)] mod tests` here. `parse_timeout_secs`'s error +// path constructs a `PyValueError` which requires a linked Python +// interpreter — `pyo3`'s `extension-module` feature (the default here) +// deliberately omits the interpreter symbols at static-link time so the +// resulting `.so` can be loaded as a Python C extension. The parallel +// overflow test for the panic path lives in `jobs_napi.rs::tests` +// (same helper shape, same `try_from_secs_f64` logic). diff --git a/src/runtime/python/mesh/jobs.py b/src/runtime/python/mesh/jobs.py index d22297b00..f0f10e264 100644 --- a/src/runtime/python/mesh/jobs.py +++ b/src/runtime/python/mesh/jobs.py @@ -130,10 +130,14 @@ async def _get_or_create_proxy(registry_url: str, job_id: str) -> Any: # variants currently surface as plain `RuntimeError` from the pyo3 layer # (see `src/runtime/core/src/jobs_py.rs::job_error_to_py`). Until the # pyo3 binding switches to a custom exception type, we re-classify on -# the Python side via stable substrings emitted by the Rust error -# `Display` impls — `JobError::Display` in `src/runtime/core/src/jobs.rs` -# is the source of truth. Both classes derive from `RuntimeError` so -# existing `except RuntimeError:` handlers continue to catch them. +# the Python side via stable substrings emitted by the pyo3 wrapper's +# explicit error remap in `src/runtime/core/src/jobs_py.rs` +# (`job_error_to_py` at lines 66-81, specifically the `JobTerminal` arm +# at line 78). The wrapper deliberately remaps `JobError::Display` to a +# stable SDK-facing format — do NOT collapse this remap thinking it just +# passes core's Display through; the substring contract here depends on +# it. Both classes derive from `RuntimeError` so existing +# `except RuntimeError:` handlers continue to catch them. class JobNotFoundError(RuntimeError): diff --git a/src/runtime/typescript/src/__tests__/jobs.spec.ts b/src/runtime/typescript/src/__tests__/jobs.spec.ts new file mode 100644 index 000000000..1d24b1935 --- /dev/null +++ b/src/runtime/typescript/src/__tests__/jobs.spec.ts @@ -0,0 +1,295 @@ +/** + * Tests for `mesh.jobs.postEvent` + the napi-bound `recvEvent` / + * `sendEvent` plumbing (MeshJob event-injection — TS port of Python's + * PR #1041, issue #1032). + * + * Strategy: + * - Mock `@mcpmesh/core` so we can drive the napi-rs surface (`JobController`, + * `JobProxy`) without binding to a real registry. The mock mirrors the + * napi-rs generated TS surface (`recvEvent` / `sendEvent` etc.). + * - Exercise the wrappers' public contracts: type filter, timeout + * validation, typed-error re-classification, LRU cache eviction, + * registry-URL resolution. + * - LRU cap is overridden via `MCP_MESH_JOBPROXY_CACHE_MAX=2` in the + * cache test so eviction is reproducible at small scale. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock napi-rs core so we don't need a real registry. The shapes match +// the generated `index.d.ts` `JobController` / `JobProxy` classes plus +// the new `recvEvent` / `sendEvent` methods added in this PR. +vi.mock("@mcpmesh/core", async () => { + const actual = await vi.importActual>("@mcpmesh/core"); + + // Track constructed JobProxy instances per call so postEvent-cache + // tests can assert how many distinct proxies were built. + const proxyCalls: Array<{ jobId: string; registryUrl: string }> = []; + const sendEventMock = vi.fn(); + class JobProxy { + public readonly jobId: string; + public readonly registryUrl: string; + public readonly sendEvent: typeof sendEventMock; + constructor(jobId: string, registryUrl: string) { + this.jobId = jobId; + this.registryUrl = registryUrl; + this.sendEvent = sendEventMock; + proxyCalls.push({ jobId, registryUrl }); + } + } + + const recvEventMock = vi.fn(); + class JobController { + public readonly jobId: string; + public readonly recvEvent: typeof recvEventMock; + constructor(jobId: string, _instanceId: string, _registryUrl: string) { + this.jobId = jobId; + this.recvEvent = recvEventMock; + } + } + + return { + ...actual, + JobController, + JobProxy, + // Expose the per-test mocks + ctor recorder via the module namespace + // so the tests can reach for them. + __sendEventMock: sendEventMock, + __recvEventMock: recvEventMock, + __proxyCalls: proxyCalls, + }; +}); + +import { + postEvent, + _getOrCreateProxy, + _clearProxyCache, + translateJobError, + JobNotFoundError, + JobTerminalError, +} from "../jobs.js"; + +// Pull the mocks from the mocked module so test bodies can drive return +// values + read call counts. +const core = (await import("@mcpmesh/core")) as unknown as { + __sendEventMock: ReturnType; + __recvEventMock: ReturnType; + __proxyCalls: Array<{ jobId: string; registryUrl: string }>; + JobController: new (jobId: string, instanceId: string, registryUrl: string) => { + recvEvent: ( + types?: string[] | null, + timeoutSecs?: number | null, + ) => Promise; + }; +}; + +const sendEventMock = core.__sendEventMock; +const recvEventMock = core.__recvEventMock; +const proxyCalls = core.__proxyCalls; + +beforeEach(() => { + sendEventMock.mockReset(); + recvEventMock.mockReset(); + proxyCalls.length = 0; + _clearProxyCache(); +}); + +// --------------------------------------------------------------------------- +// translateJobError — substring → typed exception class +// --------------------------------------------------------------------------- +describe("translateJobError", () => { + it("re-classifies 'job is terminal' messages to JobTerminalError", () => { + const raw = new Error("job is terminal: completed at ts=..."); + const out = translateJobError(raw); + expect(out).toBeInstanceOf(JobTerminalError); + expect((out as Error).message).toContain("job is terminal"); + }); + + it("re-classifies 'job not found' messages to JobNotFoundError", () => { + const raw = new Error("backend error: job not found: abc-123"); + const out = translateJobError(raw); + expect(out).toBeInstanceOf(JobNotFoundError); + }); + + it("passes unrelated errors through unchanged", () => { + const raw = new Error("backend error: HTTP 500: boom"); + expect(translateJobError(raw)).toBe(raw); + }); + + it("leaves typed exception subclasses alone", () => { + const already = new JobNotFoundError("nope"); + expect(translateJobError(already)).toBe(already); + }); +}); + +// --------------------------------------------------------------------------- +// JobController.recvEvent — invoked through the napi surface mock +// --------------------------------------------------------------------------- +describe("JobController.recvEvent (via napi mock)", () => { + it("returns the event object on backend success", async () => { + recvEventMock.mockResolvedValueOnce({ + job_id: "j-1", + seq: 1, + type: "signal", + payload: { hello: "world" }, + trace_context: null, + posted_by: "consumer-x", + created_at: 1_700_000_000, + }); + const controller = new core.JobController("j-1", "inst-1", "http://r"); + const ev = await controller.recvEvent(["signal"], 5); + expect(ev).toMatchObject({ type: "signal", seq: 1 }); + expect((ev as { payload: { hello: string } }).payload.hello).toBe("world"); + expect(recvEventMock).toHaveBeenCalledWith(["signal"], 5); + }); + + it("returns null on timeout", async () => { + recvEventMock.mockResolvedValueOnce(null); + const controller = new core.JobController("j-1", "inst-1", "http://r"); + const ev = await controller.recvEvent(undefined, 0.1); + expect(ev).toBeNull(); + }); + + it("forwards the types filter through unchanged", async () => { + recvEventMock.mockResolvedValueOnce(null); + const controller = new core.JobController("j-1", "inst-1", "http://r"); + await controller.recvEvent(["target", "cancelled"], 1); + expect(recvEventMock).toHaveBeenCalledWith(["target", "cancelled"], 1); + }); + + // The actual NaN/Infinity/negative guard lives in the Rust binding + // (parse_timeout_secs in jobs_napi.rs), not in the TS wrapper — we + // simulate the boundary error here so callers can validate they get a + // catchable Error rather than a panic. + it("surfaces a guard error when timeoutSecs is invalid", async () => { + recvEventMock.mockRejectedValueOnce( + new Error("timeoutSecs must be non-negative and finite, got NaN"), + ); + const controller = new core.JobController("j-1", "inst-1", "http://r"); + await expect(controller.recvEvent(undefined, NaN)).rejects.toThrow( + /non-negative and finite/, + ); + }); +}); + +// --------------------------------------------------------------------------- +// postEvent — happy path + error mapping + cache behavior +// --------------------------------------------------------------------------- +describe("postEvent", () => { + beforeEach(() => { + // Explicit reset so each test starts from a known state — prevents + // pollution from the "unset" test below (which deletes the env var) + // bleeding into later additions or reordering. + delete process.env.MCP_MESH_REGISTRY_URL; + }); + + it("constructs a JobProxy from MCP_MESH_REGISTRY_URL + calls sendEvent", async () => { + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + sendEventMock.mockResolvedValueOnce({ + job_id: "j-1", + seq: 1, + created_at: 1_700_000_000, + }); + const receipt = await postEvent("j-1", "signal", { hello: "world" }); + expect(receipt).toEqual({ + job_id: "j-1", + seq: 1, + created_at: 1_700_000_000, + }); + expect(proxyCalls).toHaveLength(1); + expect(proxyCalls[0]).toEqual({ + jobId: "j-1", + registryUrl: "http://localhost:8000", + }); + expect(sendEventMock).toHaveBeenCalledWith("signal", { hello: "world" }); + }); + + it("normalises undefined/null payload to {}", async () => { + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + sendEventMock.mockResolvedValue({ job_id: "j", seq: 1, created_at: 1 }); + await postEvent("j", "signal"); + expect(sendEventMock).toHaveBeenLastCalledWith("signal", {}); + await postEvent("j", "signal", null); + expect(sendEventMock).toHaveBeenLastCalledWith("signal", {}); + }); + + it("re-classifies a 'job not found' napi error to JobNotFoundError", async () => { + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + sendEventMock.mockRejectedValueOnce( + new Error("backend error: job not found: stale"), + ); + await expect(postEvent("stale", "signal", {})).rejects.toBeInstanceOf( + JobNotFoundError, + ); + }); + + it("re-classifies a 'job is terminal' napi error to JobTerminalError", async () => { + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + sendEventMock.mockRejectedValueOnce( + new Error("job is terminal: completed"), + ); + await expect(postEvent("done", "signal", {})).rejects.toBeInstanceOf( + JobTerminalError, + ); + }); + + it("throws when MCP_MESH_REGISTRY_URL is unset", async () => { + delete process.env.MCP_MESH_REGISTRY_URL; + await expect(postEvent("j", "signal", {})).rejects.toThrow( + /MCP_MESH_REGISTRY_URL is not set/, + ); + }); + + it("caches the JobProxy per (registryUrl, jobId)", async () => { + process.env.MCP_MESH_REGISTRY_URL = "http://localhost:8000"; + sendEventMock.mockResolvedValue({ job_id: "j", seq: 1, created_at: 1 }); + await postEvent("j-1", "signal", {}); + await postEvent("j-1", "signal", {}); + // Second call to same jobId must re-use the cached proxy. + expect(proxyCalls).toHaveLength(1); + // Different jobId constructs a fresh proxy. + await postEvent("j-2", "signal", {}); + expect(proxyCalls).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// LRU eviction — cap=2 via env override +// --------------------------------------------------------------------------- +describe("_getOrCreateProxy LRU eviction", () => { + it("evicts the least-recently-used entry when the cap is reached", () => { + process.env.MCP_MESH_JOBPROXY_CACHE_MAX = "2"; + // Insert 3 distinct entries — after the third insert only the 2 + // most recent ('b', 'c') should survive ('a' is evicted as LRU). + const p1 = _getOrCreateProxy("http://r", "a"); + const p2 = _getOrCreateProxy("http://r", "b"); + const p3 = _getOrCreateProxy("http://r", "c"); + expect(proxyCalls).toHaveLength(3); + // Cache state at this point: { b, c }. 'c' is most-recent. + // 'b' and 'c' still cached — no new construction. + expect(_getOrCreateProxy("http://r", "b")).toBe(p2); + expect(_getOrCreateProxy("http://r", "c")).toBe(p3); + expect(proxyCalls).toHaveLength(3); + // Re-fetching 'a' constructs a NEW proxy (the original was evicted + // by the 'c' insert). proxyCalls grows; new ref differs from p1. + const p1b = _getOrCreateProxy("http://r", "a"); + expect(proxyCalls).toHaveLength(4); + expect(p1b).not.toBe(p1); + delete process.env.MCP_MESH_JOBPROXY_CACHE_MAX; + }); + + it("bumps an entry to most-recent on hit (LRU semantics)", () => { + process.env.MCP_MESH_JOBPROXY_CACHE_MAX = "2"; + const a1 = _getOrCreateProxy("http://r", "a"); + const b1 = _getOrCreateProxy("http://r", "b"); + // Touch 'a' so it becomes most-recent and 'b' becomes least-recent. + expect(_getOrCreateProxy("http://r", "a")).toBe(a1); + // Insert 'c' — should evict 'b' (the now-LRU), NOT 'a'. + _getOrCreateProxy("http://r", "c"); + // 'a' is still cached. + expect(_getOrCreateProxy("http://r", "a")).toBe(a1); + // 'b' was evicted — re-fetching constructs a new instance. + const b2 = _getOrCreateProxy("http://r", "b"); + expect(b2).not.toBe(b1); + delete process.env.MCP_MESH_JOBPROXY_CACHE_MAX; + }); +}); diff --git a/src/runtime/typescript/src/index.ts b/src/runtime/typescript/src/index.ts index 12ab0d5fa..9b2473de9 100644 --- a/src/runtime/typescript/src/index.ts +++ b/src/runtime/typescript/src/index.ts @@ -57,6 +57,7 @@ import { llm } from "./llm.js"; import { llmProvider } from "./llm-provider.js"; import { sseStream } from "./sse-stream.js"; import { mount as a2aMount } from "./a2a/producer/index.js"; +import { postEvent as jobsPostEvent } from "./jobs.js"; /** * `mesh.a2a` namespace — A2A v1.0 producer surface (issue #933). @@ -76,6 +77,20 @@ interface MeshA2ANamespace { const a2a: MeshA2ANamespace = { mount: a2aMount }; +/** + * `mesh.jobs` namespace — MeshJob event-injection convenience helpers + * (mirrors Python's `mesh.jobs` submodule shipped via PR #1041 for + * issue #1032). The `postEvent` helper lets MCP tool bodies push an + * event into a running job by id without holding a `JobProxy` reference + * in scope (the SDK resolves the registry URL from + * `MCP_MESH_REGISTRY_URL` and reuses a process-cached `JobProxy`). + */ +interface MeshJobsNamespace { + postEvent: typeof jobsPostEvent; +} + +const jobs: MeshJobsNamespace = { postEvent: jobsPostEvent }; + // Create mesh namespace with route and llm attached interface MeshNamespace { (server: import("fastmcp").FastMCP, config: import("./types.js").AgentConfig): MeshAgent; @@ -91,6 +106,8 @@ interface MeshNamespace { sseStream: typeof sseStream; /** A2A v1.0 producer surface (issue #933). */ a2a: MeshA2ANamespace; + /** MeshJob event-injection helpers (mirrors Python `mesh.jobs`). */ + jobs: MeshJobsNamespace; } /** @@ -111,6 +128,7 @@ const mesh: MeshNamespace = Object.assign(meshFn, { llmProvider, sseStream, a2a, + jobs, }); // Main API @@ -322,6 +340,18 @@ export { } from "./claim-dispatcher.js"; export { registerJobHelperTools } from "./jobs-helper-tools.js"; export { registerCancelRoute } from "./jobs-cancel-route.js"; +// MeshJob event-injection helpers + typed errors (mirrors Python +// `mesh.jobs` shipped via PR #1041 for issue #1032). The +// `mesh.jobs.postEvent` helper is the primary surface; the error +// classes are exported so consumers can `instanceof`-discriminate +// against the napi binding's generic Error. +export { + postEvent, + JobNotFoundError, + JobTerminalError, + type JobEvent, + type JobEventReceipt, +} from "./jobs.js"; // Re-export napi-rs job primitives for users who want to drop down // to the underlying handles directly (e.g. constructing a JobProxy // from a known job_id). diff --git a/src/runtime/typescript/src/jobs.ts b/src/runtime/typescript/src/jobs.ts new file mode 100644 index 000000000..4103b2de9 Binary files /dev/null and b/src/runtime/typescript/src/jobs.ts differ diff --git a/src/runtime/typescript/src/types.ts b/src/runtime/typescript/src/types.ts index 89060790b..17e16d5f8 100644 --- a/src/runtime/typescript/src/types.ts +++ b/src/runtime/typescript/src/types.ts @@ -117,6 +117,18 @@ export interface MeshJob { updateProgress?(progress: number, message?: string): Promise; complete?(result: unknown): Promise; fail?(error: string): Promise; + /** + * Wait for the next event posted into this job's event log. + * + * Returns the event object on arrival, `null` on timeout. Cursor is + * per-controller-instance (shared across `clone`s); a fresh + * controller for the same `jobId` replays from seq=0. + * + * Mirrors Python's `MeshJob.recv_event` (event-channel extension that + * shipped with v2.2 via PR #1041). Throws on `JobNotFound` / + * transport-layer failures; `timeoutSecs` rejects NaN/Infinity/negative. + */ + recvEvent?(types?: string[], timeoutSecs?: number): Promise; // Consumer-side surface (when injected as JobProxy / submitter): submit?(payload?: Record, options?: { @@ -128,6 +140,15 @@ export interface MeshJob { wait?(timeoutSecs?: number): Promise; status?(): Promise>; cancel?(reason?: string): Promise; + /** + * Post an event into this job's event log. The running handler + * (inside the `task: true` job) will see it on its next `recvEvent` + * call — or wake immediately if it's currently long-polling. + * + * Mirrors Python's `MeshJob.send_event`. Throws `JobNotFoundError` / + * `JobTerminalError` for the corresponding registry error codes. + */ + sendEvent?(eventType: string, payload?: unknown): Promise; } /** diff --git a/tests/integration/suites/uc22_meshjob_ts/fixtures/long-task-consumer-ts/src/index.ts b/tests/integration/suites/uc22_meshjob_ts/fixtures/long-task-consumer-ts/src/index.ts index 8abdbc31f..0bf277306 100644 --- a/tests/integration/suites/uc22_meshjob_ts/fixtures/long-task-consumer-ts/src/index.ts +++ b/tests/integration/suites/uc22_meshjob_ts/fixtures/long-task-consumer-ts/src/index.ts @@ -327,6 +327,144 @@ agent.addTool({ }, }); +// --------------------------------------------------------------------------- +// Event-injection scenarios (tc24 / tc25 / tc26) +// --------------------------------------------------------------------------- +// +// Each capability submits one of the new `task: true` producers and +// drives `mesh.jobs.postEvent` from inside the consumer's tool body to +// exercise the producer's `recvEvent` long-poll. `mesh.jobs.postEvent` +// is the surface under test — it discovers the registry URL from +// `MCP_MESH_REGISTRY_URL` (set by the agent startup pipeline) and POSTs +// `/jobs/{id}/events`. +// --------------------------------------------------------------------------- + +function sleepMs(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +agent.addTool({ + name: "commission_event", + capability: "commission_event", + dependencies: [{ capability: "run_with_event" }], + meshJobDepIndex: 0, + description: + "Submit run_with_event, sleep so producer parks on recvEvent, then post one event.", + parameters: z.object({}).passthrough(), + execute: async (_args, runWithEvent: MeshJob | null = null) => { + if (!runWithEvent?.submit) { + return { error: "run_with_event submitter not injected" }; + } + const proxy = await runWithEvent.submit({}, { maxDuration: 60 }); + const jobId = (proxy as { jobId?: string }).jobId ?? ""; + // Brief wait so producer reaches recvEvent before we post. Without + // this, the post may land before the producer's claim worker has + // pulled the job — the event would still be observable (cursor is + // per-controller) but the test wouldn't exercise the long-poll + // wake path. + await sleepMs(2000); + const receipt = await mesh.jobs.postEvent(jobId, "signal", { + hello: "world", + n: 42, + }); + if (!proxy.wait) { + return { job_id: jobId, post_seq: receipt.seq }; + } + const result = await proxy.wait(30); + return { + job_id: jobId, + post_seq: receipt.seq, + job_result: result, + }; + }, +}); + +agent.addTool({ + name: "commission_event_filter", + capability: "commission_event_filter", + dependencies: [{ capability: "run_with_filter" }], + meshJobDepIndex: 0, + description: + "Submit run_with_filter, post 2 ignored events, then the matching one.", + parameters: z.object({}).passthrough(), + execute: async (_args, runWithFilter: MeshJob | null = null) => { + if (!runWithFilter?.submit) { + return { error: "run_with_filter submitter not injected" }; + } + const proxy = await runWithFilter.submit({}, { maxDuration: 60 }); + const jobId = (proxy as { jobId?: string }).jobId ?? ""; + // Give the producer a moment to claim + park on recvEvent. + await sleepMs(2000); + // Post 2 unrelated events — producer must NOT wake on these. + const r1 = await mesh.jobs.postEvent(jobId, "ignore_a", { n: 1 }); + const r2 = await mesh.jobs.postEvent(jobId, "ignore_b", { n: 2 }); + // Brief gap so a buggy filter (one that DID wake on ignore_a) has + // time to drive the producer to completion; if the producer is + // already done by now, the matching post will get JobTerminalError. + await sleepMs(1000); + const r3 = await mesh.jobs.postEvent(jobId, "target", { got_it: true }); + if (!proxy.wait) { + return { + job_id: jobId, + ignore_seqs: [r1.seq, r2.seq], + target_seq: r3.seq, + }; + } + const result = await proxy.wait(30); + return { + job_id: jobId, + ignore_seqs: [r1.seq, r2.seq], + target_seq: r3.seq, + result, + }; + }, +}); + +agent.addTool({ + name: "commission_cancel_via_event", + capability: "commission_cancel_via_event", + dependencies: [{ capability: "run_until_cancel" }], + meshJobDepIndex: 0, + description: + "Submit run_until_cancel, post a 'work' event, then cancel — synthetic 'cancelled' event must arrive.", + parameters: z.object({}).passthrough(), + execute: async (_args, runUntilCancel: MeshJob | null = null) => { + if (!runUntilCancel?.submit) { + return { error: "run_until_cancel submitter not injected" }; + } + const proxy = await runUntilCancel.submit({}, { maxDuration: 60 }); + const jobId = (proxy as { jobId?: string }).jobId ?? ""; + await sleepMs(2000); + const workReceipt = await mesh.jobs.postEvent(jobId, "work", { item: 1 }); + // Give the producer a moment to consume the 'work' event before we + // fire the cancel. This makes the two events strictly ordered in + // the producer's events_seen list (work first, cancelled second). + await sleepMs(1000); + if (proxy.cancel) { + await proxy.cancel("external_stop_requested"); + } + // The job is now cancelled — the producer's recvEvent loop will + // observe the synthetic 'cancelled' event and return its dict via + // the normal task return path. We CANNOT use proxy.wait() because + // wait() raises on a cancelled terminal state. Instead read the + // status row + the producer's log via the test driver. + await sleepMs(3000); + let terminalStatus: string | undefined; + let terminalError: string | undefined; + if (proxy.status) { + const status = (await proxy.status()) as Record; + terminalStatus = status.status as string | undefined; + terminalError = (status.error as string | undefined) ?? undefined; + } + return { + job_id: jobId, + work_seq: workReceipt.seq, + terminal_status: terminalStatus, + terminal_error: terminalError, + }; + }, +}); + console.log( `long-task-consumer-ts uc22 fixture defined on port ${HTTP_PORT}. Waiting for auto-start...`, ); diff --git a/tests/integration/suites/uc22_meshjob_ts/fixtures/long-task-provider-ts/src/index.ts b/tests/integration/suites/uc22_meshjob_ts/fixtures/long-task-provider-ts/src/index.ts index 8b7d99682..2f9a9242a 100644 --- a/tests/integration/suites/uc22_meshjob_ts/fixtures/long-task-provider-ts/src/index.ts +++ b/tests/integration/suites/uc22_meshjob_ts/fixtures/long-task-provider-ts/src/index.ts @@ -312,6 +312,133 @@ agent.addTool({ }, }); +// --------------------------------------------------------------------------- +// Event-injection scenarios (tc24/tc25/tc26 — recvEvent / sendEvent primitive) +// --------------------------------------------------------------------------- +// +// TS port of uc21_meshjob/fixtures/long-task-provider/main.py +// (run_with_event / run_with_filter / run_until_cancel). All three use +// `task: true` and call `job.recvEvent(...)` in the handler body. Each +// one targets a different facet of the event channel: +// +// - run_with_event — happy path: wait for ONE event of a single type. +// - run_with_filter — type-filter correctness: ignore unrelated events. +// - run_until_cancel — synthetic cancel event: loop on recvEvent and +// exit gracefully when the registry posts the +// `{ type: "cancelled" }` synthetic event that +// fires inside CancelJob. +// --------------------------------------------------------------------------- + +agent.addTool({ + name: "run_with_event", + capability: "run_with_event", + task: true, + meshJobParamIndex: 1, + description: + "Wait for one 'signal' event and return its payload — happy path for recvEvent.", + parameters: z.object({}).passthrough(), + execute: async (_args, job: MeshJob | null = null) => { + if (!job?.recvEvent) { + return { status: "no_job_ctx" }; + } + if (job.updateProgress) { + await job.updateProgress(0.1, "parked on recvEvent"); + } + const event = await job.recvEvent(["signal"], 10); + if (event === null) { + return { status: "timeout", received: false }; + } + const payload = { + status: "got_event", + received: true, + type: event.type, + payload: event.payload, + seq: event.seq, + }; + if (job.complete) { + await job.complete(payload); + } + return payload; + }, +}); + +agent.addTool({ + name: "run_with_filter", + capability: "run_with_filter", + task: true, + meshJobParamIndex: 1, + description: + "Wait for a 'target' event and ignore other types — exercises recvEvent filter.", + parameters: z.object({}).passthrough(), + execute: async (_args, job: MeshJob | null = null) => { + if (!job?.recvEvent) { + return { status: "no_job_ctx" }; + } + if (job.updateProgress) { + await job.updateProgress(0.1, "parked with type filter"); + } + // Long timeout so the consumer has slack to post 2 ignored events + // before the matching one. If the filter is broken the producer + // will wake on the FIRST event (ignore_a) and the assertions will + // catch it. + const event = await job.recvEvent(["target"], 15); + if (event === null) { + return { timeout: true }; + } + const payload = { + type: event.type, + payload: event.payload, + seq: event.seq, + }; + if (job.complete) { + await job.complete(payload); + } + return payload; + }, +}); + +agent.addTool({ + name: "run_until_cancel", + capability: "run_until_cancel", + task: true, + meshJobParamIndex: 1, + description: + "Loop on recvEvent for 'work'/'cancelled' types until cancelled-event arrives.", + parameters: z.object({}).passthrough(), + execute: async (_args, job: MeshJob | null = null) => { + if (!job?.recvEvent) { + return { status: "no_job_ctx" }; + } + const eventsSeen: Array<{ type: string; payload: unknown }> = []; + // Bounded loop count — safety net against runaway iterations if the + // cancel event never lands. A correct flow exits via the + // "cancelled" branch within ~3s of the consumer firing cancel. + for (let i = 0; i < 20; i++) { + const event = await job.recvEvent(["work", "cancelled"], 15); + if (event === null) { + return { status: "timeout", events_seen: eventsSeen }; + } + eventsSeen.push({ type: event.type, payload: event.payload }); + if (event.type === "cancelled") { + // Don't call job.complete()/fail() — the registry row is + // already cancelled (the synthetic event was posted by + // CancelJob AFTER the row transition). Auto-complete is a + // no-op once terminal has been recorded, so returning here + // is safe. + // + // Marker line for the test driver to grep on; mirrors the + // Python fixture's `[run_until_cancel] cancelled_gracefully` + // line. + console.log( + `[run_until_cancel] cancelled_gracefully events_seen=${JSON.stringify(eventsSeen)}`, + ); + return { status: "cancelled_gracefully", events_seen: eventsSeen }; + } + } + return { status: "loop_exhausted", events_seen: eventsSeen }; + }, +}); + console.log( `long-task-provider-ts uc22 fixture defined on port ${HTTP_PORT}. Waiting for auto-start...`, ); diff --git a/tests/integration/suites/uc22_meshjob_ts/tc24_event_injection_happy_path_ts/test.yaml b/tests/integration/suites/uc22_meshjob_ts/tc24_event_injection_happy_path_ts/test.yaml new file mode 100644 index 000000000..a6baa2cbd --- /dev/null +++ b/tests/integration/suites/uc22_meshjob_ts/tc24_event_injection_happy_path_ts/test.yaml @@ -0,0 +1,109 @@ +# tc24_event_injection_happy_path_ts — TS port of uc21/tc24 +# (recvEvent / postEvent happy path; issue #1032 / PR #1041). +# +# Verifies the basic event-injection round trip end-to-end through a real +# registry and two real TypeScript agents: +# +# 1. Producer task=true tool `run_with_event` parks on +# `await job.recvEvent(["signal"], 10)`. +# 2. Consumer's `commission_event` submits the job, sleeps ~2s so the +# producer reaches the long-poll, then calls the new SDK helper +# `mesh.jobs.postEvent(jobId, "signal", {hello: "world", n: 42})`. +# 3. The registry persists the event, the producer's long-poll wakes, +# `recvEvent` returns the event object, the handler returns it via +# `job.complete(...)`. +# 4. Consumer awaits `proxy.wait()` and surfaces the structured payload. +# +# Asserts: +# - the producer returned `status=got_event` (not `timeout`) +# - the payload echoes `{hello: "world", n: 42}` exactly +# - the receipt's seq is 1 (first event posted into this job) +# +# This is the TS headline regression for the new `mesh.jobs.postEvent` +# helper in a real-agent context — the unit tests cover the helper +# itself but they mock the napi-rs binding, so this is the first time we +# exercise the helper from inside a running tool body. + +name: "MeshJob TS: postEvent helper wakes a producer parked on recvEvent" +description: "TS producer recvEvent → TS consumer postEvent → handler completes with event payload" +tags: + - meshjob + - typescript + - issue-1032 + - events +timeout: 90 + +pre_run: + - routine: global.setup_for_typescript_agent + +test: + - name: "Copy artifacts" + handler: shell + workdir: /workspace + command: | + cp -rL /uc-artifacts/long-task-provider-ts /workspace/ + cp -rL /uc-artifacts/long-task-consumer-ts /workspace/ + + - name: "Install provider deps" + handler: npm-install + path: /workspace/long-task-provider-ts + + - name: "Install consumer deps" + handler: npm-install + path: /workspace/long-task-consumer-ts + + - routine: start_registry_with_fast_sweep + + - name: "Start provider (port 9110)" + handler: shell + workdir: /workspace + command: meshctl start long-task-provider-ts/src/index.ts --env MCP_MESH_HTTP_PORT=9110 -d + + - name: "Wait for provider registration" + handler: wait + seconds: 15 + + - name: "Start consumer (port 9111)" + handler: shell + workdir: /workspace + command: meshctl start long-task-consumer-ts/src/index.ts --env MCP_MESH_HTTP_PORT=9111 -d + + - name: "Wait for consumer + DI resolution" + handler: wait + seconds: 22 + + - name: "Call commission_event — submit, post one event, wait for result" + handler: shell + workdir: /workspace + command: | + meshctl call commission_event '{}' + capture: commission_response + timeout: 60 + +assertions: + # MCP envelope check: must not be an error response. TS runtime emits + # compact JSON (no spaces) so the assertion form differs from the + # Python suite's `\"isError\": true` shape. + - expr: "${captured.commission_response} not contains '\"isError\":true'" + message: "commission_event should NOT return an MCP error envelope" + # Producer reached recvEvent AND woke on the posted signal. TS + # serializes compactly; meshctl JSON-escapes the inner text so the + # captured string carries `\"status\":\"got_event\"`. + - expr: "${captured.commission_response} contains '\\\"status\\\":\\\"got_event\\\"'" + message: "producer must report status=got_event (proves recvEvent woke on the post)" + - expr: "${captured.commission_response} not contains '\\\"status\\\":\\\"timeout\\\"'" + message: "producer must NOT have timed out — the post should have woken it" + # Payload echo: full event payload must round-trip through the registry. + - expr: "${captured.commission_response} contains '\\\"hello\\\":\\\"world\\\"'" + message: "payload string field must round-trip through recvEvent" + - expr: "${captured.commission_response} contains '\\\"n\\\":42'" + message: "payload numeric field must round-trip through recvEvent" + - expr: "${captured.commission_response} contains '\\\"type\\\":\\\"signal\\\"'" + message: "event type must be surfaced on the producer side" + # Receipt seq: this was the first event posted into the job. + - expr: "${captured.commission_response} contains '\\\"post_seq\\\":1'" + message: "first postEvent into a fresh job must return seq=1" + +post_run: + - routine: stop_all + - routine: global.cleanup_workspace diff --git a/tests/integration/suites/uc22_meshjob_ts/tc25_event_type_filter_ts/test.yaml b/tests/integration/suites/uc22_meshjob_ts/tc25_event_type_filter_ts/test.yaml new file mode 100644 index 000000000..7b53623a9 --- /dev/null +++ b/tests/integration/suites/uc22_meshjob_ts/tc25_event_type_filter_ts/test.yaml @@ -0,0 +1,104 @@ +# tc25_event_type_filter_ts — TS port of uc21/tc25 +# (recvEvent type filter skips unrelated events; issue #1032). +# +# Producer `run_with_filter` calls `recvEvent(["target"], 15)` and +# must IGNORE events whose `type` is not in that list. Consumer posts +# three events in sequence: +# +# 1. `ignore_a` — wrong type, must be skipped +# 2. `ignore_b` — wrong type, must be skipped +# 3. `target` — matching type, producer wakes here +# +# Asserts: +# - the producer returned the THIRD event (seq=3), not the first or +# second +# - payload echoes `{got_it: true}` +# - producer did NOT time out (would fail the seq=3 assertion anyway, +# but a dedicated message helps triage) +# +# This proves the type-filter is applied during the long-poll: a broken +# filter would wake on seq=1 (`ignore_a`), return `{n: 1}` instead of +# `{got_it: true}`, and complete the job with the wrong payload. + +name: "MeshJob TS: recvEvent type filter skips unrelated events and wakes on matching type" +description: "Post 2 ignored events + 1 matching; producer must see only the matching one (seq=3)" +tags: + - meshjob + - typescript + - issue-1032 + - events +timeout: 90 + +pre_run: + - routine: global.setup_for_typescript_agent + +test: + - name: "Copy artifacts" + handler: shell + workdir: /workspace + command: | + cp -rL /uc-artifacts/long-task-provider-ts /workspace/ + cp -rL /uc-artifacts/long-task-consumer-ts /workspace/ + + - name: "Install provider deps" + handler: npm-install + path: /workspace/long-task-provider-ts + + - name: "Install consumer deps" + handler: npm-install + path: /workspace/long-task-consumer-ts + + - routine: start_registry_with_fast_sweep + + - name: "Start provider (port 9110)" + handler: shell + workdir: /workspace + command: meshctl start long-task-provider-ts/src/index.ts --env MCP_MESH_HTTP_PORT=9110 -d + + - name: "Wait for provider registration" + handler: wait + seconds: 15 + + - name: "Start consumer (port 9111)" + handler: shell + workdir: /workspace + command: meshctl start long-task-consumer-ts/src/index.ts --env MCP_MESH_HTTP_PORT=9111 -d + + - name: "Wait for consumer + DI resolution" + handler: wait + seconds: 22 + + - name: "Call commission_event_filter — post 3 events, expect producer to wake only on 'target'" + handler: shell + workdir: /workspace + command: | + meshctl call commission_event_filter '{}' + capture: commission_response + timeout: 60 + +assertions: + - expr: "${captured.commission_response} not contains '\"isError\":true'" + message: "commission_event_filter should NOT return an MCP error envelope" + # Producer must not have timed out. + - expr: "${captured.commission_response} not contains '\\\"timeout\\\":true'" + message: "producer must NOT have timed out — the 'target' event should have woken it" + # The matching event was the 3rd posted; producer must surface its + # type and payload — NOT the ignored events'. + - expr: "${captured.commission_response} contains '\\\"type\\\":\\\"target\\\"'" + message: "producer must wake on the 'target' event type" + - expr: "${captured.commission_response} contains '\\\"got_it\\\":true'" + message: "payload of the matching event must round-trip to the producer" + # Belt-and-suspenders: ensure the producer's result is NOT one of the + # ignored events. A broken filter would wake on the first post and + # complete with payload={n:1} type=ignore_a. + - expr: "${captured.commission_response} not contains '\\\"type\\\":\\\"ignore_a\\\"'" + message: "producer must NOT have woken on the 'ignore_a' event (filter broken)" + - expr: "${captured.commission_response} not contains '\\\"type\\\":\\\"ignore_b\\\"'" + message: "producer must NOT have woken on the 'ignore_b' event (filter broken)" + # Sequence number: the matching event was the 3rd post into this job. + - expr: "${captured.commission_response} contains '\\\"target_seq\\\":3'" + message: "the matching event must have been the 3rd post (seq=3)" + +post_run: + - routine: stop_all + - routine: global.cleanup_workspace diff --git a/tests/integration/suites/uc22_meshjob_ts/tc26_cancel_posts_synthetic_event_ts/test.yaml b/tests/integration/suites/uc22_meshjob_ts/tc26_cancel_posts_synthetic_event_ts/test.yaml new file mode 100644 index 000000000..83dcc97ba --- /dev/null +++ b/tests/integration/suites/uc22_meshjob_ts/tc26_cancel_posts_synthetic_event_ts/test.yaml @@ -0,0 +1,159 @@ +# tc26_cancel_posts_synthetic_event_ts — TS port of uc21/tc26 +# (CancelJob posts a synthetic `{type: "cancelled"}` event the parked +# recvEvent observes; issue #1032). +# +# Producer `run_until_cancel` loops calling `recvEvent(["work", +# "cancelled"], 15)`. Consumer: +# +# 1. submits the job +# 2. posts a 'work' event with payload `{item: 1}` +# 3. sleeps so producer consumes it +# 4. calls `proxy.cancel("external_stop_requested")` +# +# Inside the registry's CancelJob handler the row transitions to +# status=cancelled AND the handler then posts a synthetic +# `{type: "cancelled", payload: {reason: "external_stop_requested"}}` +# event with `allowTerminal=true`. The producer's long-poll wakes on +# that synthetic event and returns `{status: "cancelled_gracefully", +# events_seen: [, ]}` — the test asserts the marker +# line in the producer log. +# +# Asserts: +# - the registry row is in status=cancelled (per existing semantics) +# - the producer logged the cancelled_gracefully marker (proves the +# synthetic event was both posted by the registry AND observed by +# the parked recvEvent call) +# - the producer's events_seen log line contains BOTH the 'work' +# event and the 'cancelled' event (in that order) + +name: "MeshJob TS: cancel posts a synthetic 'cancelled' event the parked recvEvent observes" +description: "Consumer cancels mid-flight; producer's recvEvent must wake on the synthetic 'cancelled' event" +tags: + - meshjob + - typescript + - issue-1032 + - events + - cancel +timeout: 120 + +pre_run: + - routine: global.setup_for_typescript_agent + +test: + - name: "Copy artifacts" + handler: shell + workdir: /workspace + command: | + cp -rL /uc-artifacts/long-task-provider-ts /workspace/ + cp -rL /uc-artifacts/long-task-consumer-ts /workspace/ + + - name: "Install provider deps" + handler: npm-install + path: /workspace/long-task-provider-ts + + - name: "Install consumer deps" + handler: npm-install + path: /workspace/long-task-consumer-ts + + - routine: start_registry_with_fast_sweep + + - name: "Start provider (port 9110)" + handler: shell + workdir: /workspace + command: meshctl start long-task-provider-ts/src/index.ts --env MCP_MESH_HTTP_PORT=9110 -d + + - name: "Wait for provider registration" + handler: wait + seconds: 15 + + - name: "Start consumer (port 9111)" + handler: shell + workdir: /workspace + command: meshctl start long-task-consumer-ts/src/index.ts --env MCP_MESH_HTTP_PORT=9111 -d + + - name: "Wait for consumer + DI resolution" + handler: wait + seconds: 22 + + - name: "Call commission_cancel_via_event — submit, post work, cancel" + handler: shell + workdir: /workspace + command: | + meshctl call commission_cancel_via_event '{}' + capture: commission_response + timeout: 60 + + # Allow a moment for the producer's stdout to flush after the + # cancelled event arrives. The producer's marker line is logged + # inside the recvEvent branch and the runtime captures stdout to + # the agent's log file. + - name: "Wait for producer log to flush" + handler: wait + seconds: 3 + + - name: "Read producer log for the cancelled_gracefully marker" + handler: shell + workdir: /workspace + command: | + meshctl logs long-task-provider-ts 2>&1 | tail -200 | grep "\[run_until_cancel\] cancelled_gracefully" || true + capture: producer_log + + # Independent verification: even if the producer didn't observe the + # synthetic 'cancelled' event in its recvEvent branch (e.g., because + # the cancel-token race fired AbortError on the task body first), the + # registry MUST have persisted it. Hit the events endpoint directly + # to confirm the event-row exists. This separates "registry-side + # broken" from "SDK cancel-token races recvEvent". + - name: "Read registry events log for the synthetic cancelled event" + handler: shell + workdir: /workspace + command: | + # TS runtime wraps the result inside `content[0].text` as a JSON + # string — unwrap that first, then read `.job_id` from the inner + # JSON. (Python tools surface plain JSON at top-level, so the + # Python tc26 used a `..` recursive descent — that doesn't apply + # here.) + JOB_ID=$(echo '${captured.commission_response}' | jq -r '.content[0].text | fromjson | .job_id') + echo "JOB_ID=${JOB_ID}" + curl -s "http://localhost:8000/jobs/${JOB_ID}/events" | jq '.events | map({seq, type, payload})' + capture: registry_events + +assertions: + - expr: "${captured.commission_response} not contains '\"isError\":true'" + message: "commission_cancel_via_event should NOT return an MCP error envelope" + # Registry's terminal status — per the existing cancel semantics. TS + # serialises compactly so the form is `\"terminal_status\":\"cancelled\"`. + - expr: "${captured.commission_response} contains '\\\"terminal_status\\\":\\\"cancelled\\\"'" + message: "registry row must show status=cancelled after proxy.cancel" + - expr: "${captured.commission_response} contains '\\\"work_seq\\\":1'" + message: "the 'work' event must have been the first posted (seq=1)" + # Synthetic-event observability: the producer logs the marker ONLY + # when its recvEvent branch sees type=='cancelled' and the + # events_seen list is the loop's accumulated view of every event the + # producer received. + - expr: "${captured.producer_log} contains '[run_until_cancel] cancelled_gracefully'" + message: "producer must log the cancelled_gracefully marker — proves recvEvent observed the synthetic 'cancelled' event" + # The events_seen list (rendered as JSON in the log line) must + # include BOTH the 'work' event the consumer posted AND the + # 'cancelled' synthetic event the registry stamped in CancelJob. A + # registry that transitioned the row without posting the synthetic + # event would never log this line (the recvEvent would time out at + # 15s instead). + - expr: "${captured.producer_log} contains '\"type\":\"work\"'" + message: "events_seen must include the 'work' event the consumer posted" + - expr: "${captured.producer_log} contains '\"type\":\"cancelled\"'" + message: "events_seen must include the synthetic 'cancelled' event from CancelJob" + - expr: "${captured.producer_log} contains 'external_stop_requested'" + message: "the cancel reason must propagate through the synthetic event payload" + # Registry-side check (independent of producer observability): the + # synthetic cancel event MUST have been posted to the events log. + - expr: "${captured.registry_events} contains '\"type\": \"work\"'" + message: "registry events log must contain the 'work' event" + - expr: "${captured.registry_events} contains '\"type\": \"cancelled\"'" + message: "registry events log must contain the synthetic 'cancelled' event posted by CancelJob" + - expr: "${captured.registry_events} contains 'external_stop_requested'" + message: "the cancel reason must appear in the synthetic event's payload" + +post_run: + - routine: stop_all + - routine: global.cleanup_workspace