From 01e803b262d8ea4f0e191f6ca92b872de8047af3 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:08:38 +0000 Subject: [PATCH 1/3] Server parity: caller-indexed Nexus operation reads, recorded from published 2.11.1 The workflow's and one run's outbound calls in the request's namespace, with no existence check, and every form of the limit query. Recorded three times, identical. --- .github/workflows/server-parity.yml | 4 +- .../conformance/server-parity/contract.mjs | 3 + .../nexus-operations-contract.mjs | 24 ++ .../server-parity/nexus-operations-probe.php | 56 +++++ scripts/conformance/server-parity/probe.php | 6 + .../ServerParity/nexus-operations.json | 205 ++++++++++++++++++ .../nexus-operations-observation.json | 168 ++++++++++++++ ...erverParityNexusOperationsContractTest.mjs | 57 +++++ 8 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 scripts/conformance/server-parity/nexus-operations-contract.mjs create mode 100644 scripts/conformance/server-parity/nexus-operations-probe.php create mode 100644 tests/Fixtures/ServerParity/nexus-operations.json create mode 100644 tests/Fixtures/ServerParityExpected/nexus-operations-observation.json create mode 100644 tests/Unit/ServerParityNexusOperationsContractTest.mjs diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 9960470e7..3256852f4 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -35,6 +35,7 @@ on: - tests/Unit/ServerParityWorkerReplacementContractTest.mjs - tests/Unit/ServerParityOperatorMetricsContractTest.mjs - tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs + - tests/Unit/ServerParityNexusOperationsContractTest.mjs - tests/Unit/ServerParityServiceCatalogContractTest.mjs - tests/Unit/ServerParitySystemMetricsContractTest.mjs - tests/Unit/ServerParityOperatorDashboardTypesContractTest.mjs @@ -91,6 +92,7 @@ on: - tests/Unit/ServerParityWorkerReplacementContractTest.mjs - tests/Unit/ServerParityOperatorMetricsContractTest.mjs - tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs + - tests/Unit/ServerParityNexusOperationsContractTest.mjs - tests/Unit/ServerParityServiceCatalogContractTest.mjs - tests/Unit/ServerParitySystemMetricsContractTest.mjs - tests/Unit/ServerParityOperatorDashboardTypesContractTest.mjs @@ -1529,7 +1531,7 @@ jobs: -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress docker run --rm --user=1000:1000 --entrypoint node -v "$PWD:/source:ro" -w /source \ - "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.mjs tests/Unit/ServerParitySystemMetricsContractTest.mjs tests/Unit/ServerParityOperatorDashboardTypesContractTest.mjs tests/Unit/ServerParityPrometheusMetricsContractTest.mjs tests/Unit/ServerParityPayloadCleanupContractTest.mjs tests/Unit/ServerParityRepairPassContractTest.mjs tests/Unit/ServerParityClusterInfoContractTest.mjs tests/Unit/ServerParityNamespaceDeleteContractTest.mjs > parity-evidence/comparator-tests.txt + "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs tests/Unit/ServerParityNexusOperationsContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.mjs tests/Unit/ServerParitySystemMetricsContractTest.mjs tests/Unit/ServerParityOperatorDashboardTypesContractTest.mjs tests/Unit/ServerParityPrometheusMetricsContractTest.mjs tests/Unit/ServerParityPayloadCleanupContractTest.mjs tests/Unit/ServerParityRepairPassContractTest.mjs tests/Unit/ServerParityClusterInfoContractTest.mjs tests/Unit/ServerParityNamespaceDeleteContractTest.mjs > parity-evidence/comparator-tests.txt docker run --rm --user=1000:1000 --cpus=1 --memory=256m --memory-swap=256m \ --network=none --entrypoint node -v "$PWD:/source:ro" -w /source \ "$image" --test tests/Unit/ServerParityCodecDeadlineLoadTest.mjs > parity-evidence/codec-load-tests.txt diff --git a/scripts/conformance/server-parity/contract.mjs b/scripts/conformance/server-parity/contract.mjs index 69b947698..c74c258c4 100644 --- a/scripts/conformance/server-parity/contract.mjs +++ b/scripts/conformance/server-parity/contract.mjs @@ -13,6 +13,7 @@ import {checkLateWorkflowCompletion} from './late-workflow-completion-contract.m import {checkSearchAttributes} from './search-attribute-contract.mjs'; import {checkStorageTest} from './storage-test-contract.mjs'; import {checkActivityLeaseRenewal} from './activity-lease-renewal-contract.mjs'; +import {checkNexusOperations} from './nexus-operations-contract.mjs'; import {checkServiceCatalog} from './service-catalog-contract.mjs'; import {checkSystemMetrics} from './system-metrics-contract.mjs'; import {checkOperatorDashboardTypes} from './operator-dashboard-types-contract.mjs'; @@ -151,6 +152,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { const searchAttributes = fixture.search_attributes ? checkSearchAttributes(fixture, observation, workflowId) : null; const storageTest = fixture.storage_test ? checkStorageTest(fixture, observation, workflowId) : null; const activityLeaseRenewal = fixture.activity_lease_renewal ? checkActivityLeaseRenewal(fixture, observation, workflowId) : null; + const nexusOperations = fixture.nexus_operations ? checkNexusOperations(fixture, observation, workflowId) : null; const serviceCatalog = fixture.service_catalog ? checkServiceCatalog(fixture, observation, workflowId) : null; const systemMetrics = fixture.system_metrics ? checkSystemMetrics(fixture, observation, workflowId) : null; const operatorDashboardTypes = fixture.operator_dashboard_types ? checkOperatorDashboardTypes(fixture, observation, workflowId) : null; @@ -706,6 +708,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { ...(searchAttributes ? {search_attributes: searchAttributes} : {}), ...(storageTest ? {storage_test: storageTest} : {}), ...(activityLeaseRenewal ? {activity_lease_renewal: activityLeaseRenewal} : {}), + ...(nexusOperations ? {nexus_operations: nexusOperations} : {}), ...(serviceCatalog ? {service_catalog: serviceCatalog} : {}), ...(systemMetrics ? {system_metrics: systemMetrics} : {}), ...(operatorDashboardTypes ? {operator_dashboard_types: operatorDashboardTypes} : {}), diff --git a/scripts/conformance/server-parity/nexus-operations-contract.mjs b/scripts/conformance/server-parity/nexus-operations-contract.mjs new file mode 100644 index 000000000..60e80ab4f --- /dev/null +++ b/scripts/conformance/server-parity/nexus-operations-contract.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; + +// Reviewed caller-indexed Nexus operation reads (ServiceCatalogController:: +// nexusOperationsForWorkflow and nexusOperationsForRun), recorded against +// published Server 2.11.1. The fixture's `expected` holds the whole +// normalized observation, with the probe's workflow prefix as `{id}` and its +// run as `{run}`. Every step compares exactly. +export function checkNexusOperations(fixture, observation, workflowId) { + const state = observation.nexus_operations; + const projection = {scope: 'nexus_operations'}; + assert.ok(state && typeof state === 'object', 'actual nexus operations observation'); + if (observation.mode === 'embedded') { + assert.deepStrictEqual(state, {applicable: false, reason: 'embedded_has_no_http_nexus_operations'}, + 'embedded nexus operations explicitly inapplicable'); + return projection; + } + const definition = fixture.nexus_operations; + const actual = JSON.parse(JSON.stringify(state).replaceAll(workflowId, '{id}')); + for (const [step, expected] of Object.entries(definition.expected)) { + assert.deepStrictEqual(actual[step], expected, `nexus operations ${step}`); + } + assert.deepStrictEqual(Object.keys(actual).sort(), Object.keys(definition.expected).sort(), 'nexus operations steps'); + return projection; +} diff --git a/scripts/conformance/server-parity/nexus-operations-probe.php b/scripts/conformance/server-parity/nexus-operations-probe.php new file mode 100644 index 000000000..f75d66d9e --- /dev/null +++ b/scripts/conformance/server-parity/nexus-operations-probe.php @@ -0,0 +1,56 @@ +createNamespace($namespace, 'nexus operations λ', 30); + $token = getenv('DW_PARITY_TOKEN') ?: null; + $http = new NexusOperationsHttp(['base_uri' => rtrim($url, '/'), 'http_errors' => false, 'connect_timeout' => 1, 'timeout' => 30]); + $raw = static function (string $path, ?string $tenant = null) use ($http, $token): array { + $headers = array_filter(['Authorization' => $token === null ? null : 'Bearer '.$token, + 'Accept' => 'application/json', 'X-Namespace' => $tenant, + 'X-Durable-Workflow-Control-Plane-Version' => '2']); + $response = $http->request('GET', $path, ['headers' => $headers]); + $decoded = json_decode((string) $response->getBody(), true); + if (is_array($decoded)) { + unset($decoded['server_capabilities'], $decoded['control_plane'], $decoded['protocol_version']); + } + + return ['status' => $response->getStatusCode(), 'response' => $decoded]; + }; + $describe = $raw('/api/workflows/'.rawurlencode($workflowId)); + $runId = $describe['response']['run_id'] ?? null; + if (! is_string($runId) || $runId === '') { + throw new RuntimeException('Nexus operations: the probe workflow has no run: '.json_encode($describe)); + } + $label = static function (array $recorded) use ($runId): array { + return json_decode(str_replace(json_encode($runId), '"{run}"', json_encode($recorded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)), true); + }; + $workflow = '/api/workflows/'.rawurlencode($workflowId).'/nexus-operations'; + $run = '/api/workflows/'.rawurlencode($workflowId).'/runs/'.rawurlencode($runId).'/nexus-operations'; + $state = ['applicable' => true, 'namespace' => $namespace]; + $state['workflow'] = $label($raw($workflow)); + $state['run'] = $label($raw($run)); + $state['unknown_workflow'] = $raw('/api/workflows/'.rawurlencode($workflowId.'-nxo-missing').'/nexus-operations'); + $state['unknown_run'] = $label($raw('/api/workflows/'.rawurlencode($workflowId).'/runs/01NOSUCHRUN000000000000000/nexus-operations')); + $state['other_namespace'] = $label($raw($workflow, $namespace)); + $state['other_namespace_run'] = $label($raw($run, $namespace)); + foreach (['5' => 'five', '0' => 'zero', '-3' => 'negative', 'abc' => 'text', '1000' => 'above_cap', '2.7' => 'fraction', + '' => 'empty', '1e2' => 'exponent', ' 7 ' => 'padded'] as $limit => $name) { + $state['limit_'.$name] = $label($raw($workflow.'?limit='.rawurlencode((string) $limit))); + } + $state['run_limit_five'] = $label($raw($run.'?limit=5')); + + return $state; +} diff --git a/scripts/conformance/server-parity/probe.php b/scripts/conformance/server-parity/probe.php index 4df73a65f..3484c67e5 100644 --- a/scripts/conformance/server-parity/probe.php +++ b/scripts/conformance/server-parity/probe.php @@ -431,6 +431,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, $searchAttributes = isset($fixture['search_attributes']) ? finishHttpSearchAttributes($client, $fixture, $workflowId, $url) : null; $storageTest = isset($fixture['storage_test']) ? finishHttpStorageTest($client, $fixture, $workflowId, $url) : null; $activityLeaseRenewal = isset($fixture['activity_lease_renewal']) ? finishHttpActivityLeaseRenewal($client, $fixture, $workflowId, $url) : null; + $nexusOperations = isset($fixture['nexus_operations']) ? finishHttpNexusOperations($client, $fixture, $workflowId, $url) : null; $serviceCatalog = isset($fixture['service_catalog']) ? finishHttpServiceCatalog($client, $fixture, $workflowId, $url) : null; $systemMetrics = isset($fixture['system_metrics']) ? finishHttpSystemMetrics($client, $fixture, $workflowId, $url) : null; $operatorDashboardTypes = isset($fixture['operator_dashboard_types']) ? finishHttpOperatorDashboardTypes($client, $fixture, $workflowId, $url) : null; @@ -539,6 +540,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, 'search_attributes' => $searchAttributes, 'storage_test' => $storageTest, 'activity_lease_renewal' => $activityLeaseRenewal, + 'nexus_operations' => $nexusOperations, 'service_catalog' => $serviceCatalog, 'system_metrics' => $systemMetrics, 'operator_dashboard_types' => $operatorDashboardTypes, @@ -851,6 +853,7 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa require __DIR__.'/search-attribute-probe.php'; require __DIR__.'/storage-test-probe.php'; require __DIR__.'/activity-lease-renewal-probe.php'; + require __DIR__.'/nexus-operations-probe.php'; require __DIR__.'/service-catalog-probe.php'; require __DIR__.'/system-metrics-probe.php'; require __DIR__.'/operator-dashboard-types-probe.php'; @@ -1020,6 +1023,9 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa if ($mode === 'embedded' && isset($fixture['activity_lease_renewal'])) { $observation['activity_lease_renewal'] = ['applicable' => false, 'reason' => 'embedded_has_no_worker_protocol']; } + if ($mode === 'embedded' && isset($fixture['nexus_operations'])) { + $observation['nexus_operations'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_nexus_operations']; + } if ($mode === 'embedded' && isset($fixture['service_catalog'])) { $observation['service_catalog'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_service_catalog']; } diff --git a/tests/Fixtures/ServerParity/nexus-operations.json b/tests/Fixtures/ServerParity/nexus-operations.json new file mode 100644 index 000000000..f071b3654 --- /dev/null +++ b/tests/Fixtures/ServerParity/nexus-operations.json @@ -0,0 +1,205 @@ +{ + "schema": "durable-workflow.server-parity-fixture/v1", + "id": "nexus-operations", + "review_state": "reviewed", + "workflow_type": "parity.v1.echo", + "input": { + "message": "lease renewal λ", + "count": 9007199254740993, + "optional": null + }, + "typed_value": { + "type": "map", + "value": { + "message": { + "type": "string", + "value": "lease renewal λ" + }, + "count": { + "type": "int64", + "value": "9007199254740993" + }, + "optional": { + "type": "null", + "value": null + } + } + }, + "activity": false, + "expected_events": [ + "StartAccepted", + "WorkflowStarted", + "WorkflowCompleted" + ], + "nexus_operations": { + "expected": { + "applicable": true, + "namespace": "{id}-nxo", + "workflow": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "run": { + "status": 200, + "response": { + "workflow_id": "{id}", + "workflow_run_id": "{run}", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "unknown_workflow": { + "status": 200, + "response": { + "workflow_id": "{id}-nxo-missing", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "unknown_run": { + "status": 200, + "response": { + "workflow_id": "{id}", + "workflow_run_id": "01NOSUCHRUN000000000000000", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "other_namespace": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "{id}-nxo", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "other_namespace_run": { + "status": 200, + "response": { + "workflow_id": "{id}", + "workflow_run_id": "{run}", + "caller_namespace": "{id}-nxo", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "limit_five": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 5, + "nexus_operations": [] + } + }, + "limit_zero": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 1, + "nexus_operations": [] + } + }, + "limit_negative": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 1, + "nexus_operations": [] + } + }, + "limit_text": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "limit_above_cap": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "limit_fraction": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 2, + "nexus_operations": [] + } + }, + "limit_empty": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "limit_exponent": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 100, + "nexus_operations": [] + } + }, + "limit_padded": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 7, + "nexus_operations": [] + } + }, + "run_limit_five": { + "status": 200, + "response": { + "workflow_id": "{id}", + "workflow_run_id": "{run}", + "caller_namespace": "default", + "count": 0, + "limit": 5, + "nexus_operations": [] + } + } + } + }, + "contract": "Caller-indexed Nexus operation reads (ServiceCatalogController::nexusOperationsForWorkflow and nexusOperationsForRun) as published Server 2.11.1 serves them: the workflow's and one run's outbound service calls in the request's namespace, newest first, with no check that the workflow or run exists; the caller namespace, count and resolved limit (the configured 200 unless a numeric `limit` asks for less, cast like PHP's (int), at least 1). Nothing starts a Nexus operation here, so every list is empty. Embedded applications have no HTTP surface." +} diff --git a/tests/Fixtures/ServerParityExpected/nexus-operations-observation.json b/tests/Fixtures/ServerParityExpected/nexus-operations-observation.json new file mode 100644 index 000000000..103c939cc --- /dev/null +++ b/tests/Fixtures/ServerParityExpected/nexus-operations-observation.json @@ -0,0 +1,168 @@ +{ + "applicable": true, + "namespace": "pr1-nexus-operations-nxo", + "workflow": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "run": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "workflow_run_id": "{run}", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "unknown_workflow": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations-nxo-missing", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "unknown_run": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "workflow_run_id": "01NOSUCHRUN000000000000000", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "other_namespace": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "pr1-nexus-operations-nxo", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "other_namespace_run": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "workflow_run_id": "{run}", + "caller_namespace": "pr1-nexus-operations-nxo", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "limit_five": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 5, + "nexus_operations": [] + } + }, + "limit_zero": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 1, + "nexus_operations": [] + } + }, + "limit_negative": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 1, + "nexus_operations": [] + } + }, + "limit_text": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "limit_above_cap": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "limit_fraction": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 2, + "nexus_operations": [] + } + }, + "limit_empty": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "limit_exponent": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 100, + "nexus_operations": [] + } + }, + "limit_padded": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 7, + "nexus_operations": [] + } + }, + "run_limit_five": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "workflow_run_id": "{run}", + "caller_namespace": "default", + "count": 0, + "limit": 5, + "nexus_operations": [] + } + } +} diff --git a/tests/Unit/ServerParityNexusOperationsContractTest.mjs b/tests/Unit/ServerParityNexusOperationsContractTest.mjs new file mode 100644 index 000000000..b2f10eb13 --- /dev/null +++ b/tests/Unit/ServerParityNexusOperationsContractTest.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import test from 'node:test'; +import {checkNexusOperations} from '../../scripts/conformance/server-parity/nexus-operations-contract.mjs'; + +const fixture = JSON.parse(readFileSync(new URL('../Fixtures/ServerParity/nexus-operations.json', import.meta.url))); +// The observation published Server 2.11.1 returned for this fixture. +const recorded = JSON.parse(readFileSync( + new URL('../Fixtures/ServerParityExpected/nexus-operations-observation.json', import.meta.url))); +const id = 'pr1-nexus-operations'; +const observation = (change = () => {}) => { + const state = structuredClone(recorded); + change(state); + return {mode: 'http', nexus_operations: state}; +}; +const check = state => checkNexusOperations(fixture, state, id); + +test('the published 2.11.1 recording satisfies the contract', () => { + assert.deepStrictEqual(check(observation()), {scope: 'nexus_operations'}); +}); + +test('embedded applications are explicitly inapplicable', () => { + assert.deepStrictEqual(check({mode: 'embedded', nexus_operations: + {applicable: false, reason: 'embedded_has_no_http_nexus_operations'}}), {scope: 'nexus_operations'}); +}); + +for (const [label, change, message] of [ + // Native answered 501 for both routes before this fixture. + ['an unimplemented route', state => { + state.workflow = {status: 501, response: {reason: 'rust_capability_not_implemented'}}; + }, /nexus operations workflow/], + ['an unknown workflow refused as not found', state => { + state.unknown_workflow = {status: 404, response: {reason: 'instance_not_found'}}; + }, /nexus operations unknown_workflow/], + ['the default namespace for every request', state => { + state.other_namespace.response.caller_namespace = 'default'; + }, /nexus operations other_namespace/], + ['a zero limit kept as zero', state => { + state.limit_zero.response.limit = 0; + }, /nexus operations limit_zero/], + ['an exponent read as its leading digit', state => { + state.limit_exponent.response.limit = 1; + }, /nexus operations limit_exponent/], + ['an uncapped limit', state => { + state.limit_above_cap.response.limit = 1000; + }, /nexus operations limit_above_cap/], + ['a run listing without its run id', state => { + delete state.run.response.workflow_run_id; + }, /nexus operations run/], + ['a missing step', state => { + delete state.limit_padded; + }, /nexus operations limit_padded/], +]) { + test(`the contract refuses ${label}`, () => { + assert.throws(() => check(observation(change)), message); + }); +} From 363a0edc42f7b4c0e5e6c180d84bce81fa8063d8 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:08:38 +0000 Subject: [PATCH 2/3] Rust #325: serve caller-indexed Nexus operation reads like published 2.11.1 GET /api/workflows/{id}/nexus-operations and its run-scoped form list the namespace's service calls of that caller, newest first, in PHP's row shape, with PHP's (int) limit cast capped at the configured 200. Named namespaces may read them. --- rust/src/runtime/execution.rs | 1 + rust/src/runtime/http.rs | 35 +++++- rust/src/runtime/laravel_validation.rs | 2 +- rust/src/runtime/mod.rs | 1 + rust/src/runtime/nexus_operations.rs | 161 +++++++++++++++++++++++++ 5 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 rust/src/runtime/nexus_operations.rs diff --git a/rust/src/runtime/execution.rs b/rust/src/runtime/execution.rs index a75eadc46..3a96dad33 100644 --- a/rust/src/runtime/execution.rs +++ b/rust/src/runtime/execution.rs @@ -1401,6 +1401,7 @@ impl Runtime { delegate!(heartbeat_activity(task_id: &str, body: Value, protocol: &str) -> Result); delegate!(activity_status(task_id: &str, body: Value, protocol: &str) -> Result); delegate!(renew_activity_lease(task_id: &str, body: Value) -> Result); + delegate!(nexus_operations(workflow_id: &str, run_id: Option<&str>, limit: i64) -> Result>); pub(crate) async fn fail_activity(&self, task_id: &str, body: Value) -> Result { let (failure, blob) = super::activity_failures::prepare_failure(&body, self.signal_codec.clone()).await?; diff --git a/rust/src/runtime/http.rs b/rust/src/runtime/http.rs index 3cc195f41..4f45054e5 100644 --- a/rust/src/runtime/http.rs +++ b/rust/src/runtime/http.rs @@ -109,6 +109,8 @@ pub fn router(runtime: Runtime) -> Router { get(super::run_observation::selected), ) .route("/api/workflows/{workflow_id}/runs", get(list_runs)) + .route("/api/workflows/{workflow_id}/nexus-operations", get(nexus_operations_current)) + .route("/api/workflows/{workflow_id}/runs/{run_id}/nexus-operations", get(nexus_operations_run)) .route("/api/workflows/{workflow_id}/cancel", post(cancel_current)) .route( "/api/workflows/{workflow_id}/terminate", @@ -729,7 +731,9 @@ fn workflow_route(rest: &str) -> Option { | [_, "runs", _, "history", "export"] | [_, "runs", _, "streams"] | [_, "runs", _, "streams", _] - | [_, "runs", _, "streams", _, "items"] => Some(READ), + | [_, "runs", _, "streams", _, "items"] + | [_, "nexus-operations"] + | [_, "runs", _, "nexus-operations"] => Some(READ), [_, "signal" | "query" | "update", _] | [_, "runs", _, "signal" | "query" | "update", _] | [_, "repair" | "request-cancellation"] @@ -1081,6 +1085,35 @@ async fn describe_current( )) } +/// `GET /api/workflows/{workflow_id}/nexus-operations`. +async fn nexus_operations_current( + Extension(runtime): Extension, + Path(workflow_id): Path, + Query(query): Query>, +) -> Result> { + nexus_operations(runtime, &workflow_id, None, query).await +} + +/// `GET /api/workflows/{workflow_id}/runs/{run_id}/nexus-operations`. +async fn nexus_operations_run( + Extension(runtime): Extension, + Path((workflow_id, run_id)): Path<(String, String)>, + Query(query): Query>, +) -> Result> { + nexus_operations(runtime, &workflow_id, Some(&run_id), query).await +} + +async fn nexus_operations( + runtime: Runtime, + workflow_id: &str, + run_id: Option<&str>, + query: std::collections::HashMap, +) -> Result> { + let limit = super::nexus_operations::list_limit(query.get("limit").map(String::as_str)); + let calls = runtime.nexus_operations(workflow_id, run_id, limit).await?; + Ok(Json(super::nexus_operations::listing(workflow_id, run_id, runtime.namespace(), limit, calls))) +} + async fn list_runs( Extension(runtime): Extension, Path(workflow): Path, diff --git a/rust/src/runtime/laravel_validation.rs b/rust/src/runtime/laravel_validation.rs index 6ba4c1c2d..8fadac01a 100644 --- a/rust/src/runtime/laravel_validation.rs +++ b/rust/src/runtime/laravel_validation.rs @@ -155,7 +155,7 @@ fn php_string(value: &Value) -> Option { } /// PHP 8's `is_numeric` for a request value. -fn php_numeric(value: &Value) -> Option { +pub(super) fn php_numeric(value: &Value) -> Option { match value { Value::Number(number) => number.as_f64(), Value::String(text) => { diff --git a/rust/src/runtime/mod.rs b/rust/src/runtime/mod.rs index a38f42362..8037badfb 100644 --- a/rust/src/runtime/mod.rs +++ b/rust/src/runtime/mod.rs @@ -75,6 +75,7 @@ mod memos; pub mod mysql; mod namespace_cleanup; mod namespaces; +mod nexus_operations; mod operator_dashboard; mod operator_metrics; mod payload_cleanup; diff --git a/rust/src/runtime/nexus_operations.rs b/rust/src/runtime/nexus_operations.rs new file mode 100644 index 000000000..448f2e253 --- /dev/null +++ b/rust/src/runtime/nexus_operations.rs @@ -0,0 +1,161 @@ +//! Caller-indexed Nexus operation reads, as published Server 2.11.1 serves +//! them (`ServiceCatalogController::nexusOperationsForWorkflow` and +//! `nexusOperationsForRun`): the namespace's outbound service calls of one +//! workflow, or of one of its runs, newest first. Neither route checks that +//! the workflow or run exists. Native starts no Nexus operations, so the rows +//! are those a takeover kept. +use super::Result; +use serde_json::{Map, Value, json}; + +/// `config('server.limits.max_nexus_operations_per_caller')`. Native refuses +/// any other DW_MAX_NEXUS_OPERATIONS_PER_CALLER at startup. +const CALLER_LIMIT: i64 = 200; + +/// `nexusListLimit`: the configured cap unless the query asks for a number; +/// a numeric request is cast like PHP's `(int)`, raised to 1 and capped. +pub(crate) fn list_limit(requested: Option<&str>) -> i64 { + // Laravel trims the query string and turns an empty value into null. + let Some(text) = requested.map(super::laravel_validation::laravel_trim).filter(|text| !text.is_empty()) else { + return CALLER_LIMIT; + }; + let Some(number) = super::laravel_validation::php_numeric(&json!(text)) else { + return CALLER_LIMIT; + }; + // `(int)` of a numeric string truncates toward zero and saturates. + let value = if number.is_nan() { 0 } else { number.trunc().clamp(i64::MIN as f64, i64::MAX as f64) as i64 }; + if value < 1 { 1 } else { value.min(CALLER_LIMIT) } +} + +/// PHP's `json_encode` of a decoded JSON column: an empty map is `[]`. +fn php_array(value: Value) -> Value { + match value { + Value::Object(fields) if fields.is_empty() => json!([]), + Value::Object(fields) => Value::Object(fields.into_iter().map(|(key, value)| (key, php_array(value))).collect()), + Value::Array(items) => Value::Array(items.into_iter().map(php_array).collect()), + other => other, + } +} + +/// `metadataString`: a trimmed, non-empty string field of the metadata. +fn metadata_string(metadata: &Value, key: &str) -> Value { + metadata.get(key).and_then(Value::as_str).map(str::trim).filter(|text| !text.is_empty()).map_or(Value::Null, |text| json!(text)) +} + +/// `serviceCallAttempts`: the first `service_call_attempts` list in the +/// call's metadata, then its outcome metadata, keeping only the maps. +fn attempts(metadata: &Value, outcome_metadata: &Value) -> Vec { + for container in [metadata, outcome_metadata] { + match container.get("service_call_attempts") { + Some(Value::Array(items)) => return items.iter().filter(|item| item.is_object() || item.is_array()).cloned().collect(), + Some(Value::Object(items)) => return items.values().filter(|item| item.is_object() || item.is_array()).cloned().collect(), + _ => {} + } + } + Vec::new() +} + +store_methods! { + /// The caller rows and the resolved limit, newest first. + pub(crate) async fn nexus_operations(&self, workflow_id: &str, run_id: Option<&str>, limit: i64) -> Result> { + let rows = match run_id { + Some(run_id) => Self::query("SELECT * FROM workflow_service_calls WHERE caller_namespace=$1 AND caller_workflow_instance_id=$2 AND caller_workflow_run_id=$3 ORDER BY accepted_at DESC,created_at DESC LIMIT $4") + .bind(self.namespace.as_ref()).bind(workflow_id).bind(run_id).bind(limit).fetch_all(&self.pool).await?, + None => Self::query("SELECT * FROM workflow_service_calls WHERE caller_namespace=$1 AND caller_workflow_instance_id=$2 ORDER BY accepted_at DESC,created_at DESC LIMIT $3") + .bind(self.namespace.as_ref()).bind(workflow_id).bind(limit).fetch_all(&self.pool).await?, + }; + let iso = |row: &DB::Row, field: &str| -> Result { + // Carbon's `toIso8601String`: seconds and a numeric offset. + Ok(DB::optional_instant(row, field)? + .map_or(Value::Null, |at| json!(at.to_rfc3339_opts(chrono::SecondsFormat::Secs, false)))) + }; + let mut calls = Vec::with_capacity(rows.len()); + for row in &rows { + let text = |field: &str| -> Result { Ok(DB::optional_string(row, field)?.map_or(Value::Null, Value::from)) }; + let document = |field: &str| -> Result { Ok(DB::optional_document_row(row, field)?.map(php_array).unwrap_or(Value::Null)) }; + let metadata = document("metadata")?; + let outcome_metadata = document("outcome_metadata")?; + let attempts = attempts(&metadata, &outcome_metadata); + let mut call = Map::new(); + for (name, value) in [ + ("service_call_id", text("id")?), + ("caller_namespace", text("caller_namespace")?), + ("caller_workflow_instance_id", text("caller_workflow_instance_id")?), + ("caller_workflow_run_id", text("caller_workflow_run_id")?), + ("target_namespace", text("target_namespace")?), + ("endpoint_name", text("endpoint_name")?), + ("service_name", text("service_name")?), + ("operation_name", text("operation_name")?), + ("operation_mode", text("operation_mode")?), + ("status", text("status")?), + ("outcome", text("outcome")?), + ("outcome_category", text("outcome_category")?), + ("outcome_reason", text("outcome_reason")?), + ("outcome_message", text("outcome_message")?), + ("service_error_type", metadata_string(&outcome_metadata, "service_error_type")), + ("caller_observed_error_type", metadata_string(&outcome_metadata, "caller_observed_error_type")), + ("typed_error_message", metadata_string(&outcome_metadata, "typed_error_message")), + // PHP lists the metadata before the three fields read from it. + ("outcome_metadata", outcome_metadata), + ("resolved_binding_kind", text("resolved_binding_kind")?), + ("resolved_target_reference", text("resolved_target_reference")?), + ("linked_workflow_instance_id", text("linked_workflow_instance_id")?), + ("linked_workflow_run_id", text("linked_workflow_run_id")?), + ("linked_workflow_update_id", text("linked_workflow_update_id")?), + ("idempotency_key", text("idempotency_key")?), + ("retry_policy", document("retry_policy")?), + ("retry_attempt_count", json!(attempts.len())), + ("service_call_attempts", Value::Array(attempts)), + ("failure_message", text("failure_message")?), + ("caller_principal_subject", text("caller_principal_subject")?), + ("caller_principal_method", text("caller_principal_method")?), + ("accepted_at", iso(row, "accepted_at")?), + ("started_at", iso(row, "started_at")?), + ("completed_at", iso(row, "completed_at")?), + ("failed_at", iso(row, "failed_at")?), + ("cancelled_at", iso(row, "cancelled_at")?), + ] { + call.insert(name.into(), value); + } + calls.push(Value::Object(call)); + } + Ok(calls) + } +} + +/// The response body, in PHP's field order. +pub(crate) fn listing(workflow_id: &str, run_id: Option<&str>, namespace: &str, limit: i64, calls: Vec) -> Value { + let mut body = Map::new(); + body.insert("workflow_id".into(), json!(workflow_id)); + if let Some(run_id) = run_id { + body.insert("workflow_run_id".into(), json!(run_id)); + } + body.insert("caller_namespace".into(), json!(namespace)); + body.insert("count".into(), json!(calls.len())); + body.insert("limit".into(), json!(limit)); + body.insert("nexus_operations".into(), Value::Array(calls)); + Value::Object(body) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn limits_cast_like_php() { + for (requested, limit) in [(None, 200), (Some(""), 200), (Some(" "), 200), (Some("abc"), 200), (Some("5"), 5), + (Some(" 7 "), 7), (Some("0"), 1), (Some("-3"), 1), (Some("2.7"), 2), (Some("1e2"), 100), (Some("1000"), 200), + (Some("99999999999999999999"), 200), (Some("-0.5"), 1), (Some("0x1A"), 200)] { + assert_eq!(list_limit(requested), limit, "{requested:?}"); + } + } + + #[test] + fn decoded_columns_reencode_like_php() { + assert_eq!(php_array(json!({})), json!([])); + assert_eq!(php_array(json!({"a": {}, "b": [{}]})), json!({"a": [], "b": [[]]})); + assert_eq!(metadata_string(&json!({"service_error_type": " x "}), "service_error_type"), json!("x")); + assert_eq!(metadata_string(&json!({"service_error_type": " "}), "service_error_type"), Value::Null); + assert_eq!(attempts(&json!({"service_call_attempts": [{"n": 1}, 2]}), &json!({})), vec![json!({"n": 1})]); + assert_eq!(attempts(&json!({}), &json!({"service_call_attempts": [{"n": 2}]})), vec![json!({"n": 2})]); + } +} From 89428673921c1031ce0db3f1532d6a2e459391ad Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:23:50 +0000 Subject: [PATCH 3/3] Rust #325: test Nexus operation reads against seeded service calls Native writes no service calls and takeover refuses a source that holds any, so the fixture can only record PHP's empty answer. An execution test now seeds rows to check the row shape, newest-first order, the limit, the run-scoped form and namespace scoping, on SQLite, PostgreSQL and MySQL. The module comment no longer says the rows come from a takeover, and the port log lists the reads as served. Co-Authored-By: Claude Opus 5.5 --- docs/rust-server-port.md | 5 ++- rust/src/runtime/nexus_operations.rs | 5 +-- rust/tests/execution.rs | 2 ++ rust/tests/execution/nexus_operations.rs | 40 ++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 rust/tests/execution/nexus_operations.rs diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 27a1e8616..343b2b168 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -158,8 +158,11 @@ any other unknown path or method answers PHP's own 404 or 405 - Service endpoints. (The service catalog reads from Server 2.11 are served: native never holds catalog rows, so every list is PHP's empty page and every identity its not-found answer; the `service-catalog` fixture checks 37 cases.) - - Nexus operations. - Bridge adapters. + - (Caller-indexed Nexus operation reads are served, with the + `nexus-operations` fixture. Native writes no service calls and takeover + refuses a source that holds any, so every list is PHP's empty answer; an + execution test seeds rows for the row shape and newest-first order.) ## Known gaps found in review diff --git a/rust/src/runtime/nexus_operations.rs b/rust/src/runtime/nexus_operations.rs index 448f2e253..3d1ffb2f5 100644 --- a/rust/src/runtime/nexus_operations.rs +++ b/rust/src/runtime/nexus_operations.rs @@ -2,8 +2,9 @@ //! them (`ServiceCatalogController::nexusOperationsForWorkflow` and //! `nexusOperationsForRun`): the namespace's outbound service calls of one //! workflow, or of one of its runs, newest first. Neither route checks that -//! the workflow or run exists. Native starts no Nexus operations, so the rows -//! are those a takeover kept. +//! the workflow or run exists. Native starts no Nexus operations and takeover +//! refuses a source that holds any service call, so today every list is +//! empty; rows a later service-call port writes render as PHP renders them. use super::Result; use serde_json::{Map, Value, json}; diff --git a/rust/tests/execution.rs b/rust/tests/execution.rs index 95d3f3c91..a5ef467c8 100644 --- a/rust/tests/execution.rs +++ b/rust/tests/execution.rs @@ -208,6 +208,8 @@ mod run_deadlines; mod operator_dashboard; #[path = "execution/waterline_reads.rs"] mod waterline_reads; +#[path = "execution/nexus_operations.rs"] +mod nexus_operations; #[path = "execution/task_queue_build_ids.rs"] mod task_queue_build_ids; #[path = "execution/start_timeouts.rs"] diff --git a/rust/tests/execution/nexus_operations.rs b/rust/tests/execution/nexus_operations.rs new file mode 100644 index 000000000..bd74b3757 --- /dev/null +++ b/rust/tests/execution/nexus_operations.rs @@ -0,0 +1,40 @@ +use super::*; + +// Caller-indexed Nexus operation reads list the namespace's service calls of +// one workflow, or one run, newest first in PHP's row shape. Native writes no +// service calls yet, so the rows are seeded; the empty case is the shared +// `nexus-operations` fixture's. +#[tokio::test] +async fn nexus_operation_reads_list_seeded_calls_newest_first() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + namespaces::create(&app, "callers").await; + // Test-owned literals only; no request input reaches this SQL. + for (id, namespace, run, accepted) in [ + ("call-old", "default", "run-a", "2026-10-25 06:00:00"), + ("call-new", "default", "run-b", "2026-10-25 06:00:05"), + ("call-other", "callers", "run-a", "2026-10-25 06:00:09"), + ] { + database.execute(sqlx::AssertSqlSafe(format!( + "INSERT INTO workflow_service_calls(id,endpoint_name,service_name,operation_name,caller_namespace,caller_workflow_instance_id,caller_workflow_run_id,status,operation_mode,outcome_metadata,metadata,accepted_at,created_at,updated_at) \ + VALUES ('{id}','billing','invoices','charge','{namespace}','caller-wf','{run}','completed','sync','{{}}','{{\"service_call_attempts\":[{{\"attempt\":1}},\"skip\"]}}','{accepted}','{accepted}','{accepted}')" + ))).await; + } + let (status, all) = request(&app, "GET", "/api/workflows/caller-wf/nexus-operations", Value::Null).await; + assert_eq!(status, StatusCode::OK, "{all}"); + assert_eq!((all["caller_namespace"].clone(), all["count"].clone(), all["limit"].clone()), (json!("default"), json!(2), json!(200)), "{all}"); + let ids: Vec<_> = all["nexus_operations"].as_array().unwrap().iter().map(|call| call["service_call_id"].clone()).collect(); + assert_eq!(ids, [json!("call-new"), json!("call-old")], "newest first, this namespace only"); + let newest = &all["nexus_operations"][0]; + assert_eq!(newest["outcome_metadata"], json!([]), "an empty map renders as PHP's []"); + assert_eq!((newest["retry_attempt_count"].clone(), newest["service_call_attempts"].clone()), (json!(1), json!([{"attempt":1}]))); + assert_eq!(newest["accepted_at"], json!("2026-10-25T06:00:05+00:00")); + let (status, limited) = request(&app, "GET", "/api/workflows/caller-wf/nexus-operations?limit=1", Value::Null).await; + assert_eq!((status, limited["count"].clone(), limited["limit"].clone()), (StatusCode::OK, json!(1), json!(1)), "{limited}"); + let (status, run) = request(&app, "GET", "/api/workflows/caller-wf/runs/run-a/nexus-operations", Value::Null).await; + assert_eq!((status, run["workflow_run_id"].clone(), run["count"].clone()), (StatusCode::OK, json!("run-a"), json!(1)), "{run}"); + assert_eq!(run["nexus_operations"][0]["service_call_id"], "call-old"); + let (status, other) = namespaces::scoped(&app, "callers", "GET", "/api/workflows/caller-wf/nexus-operations", Value::Null).await; + assert_eq!((status, other["count"].clone()), (StatusCode::OK, json!(1)), "{other}"); + assert_eq!(other["nexus_operations"][0]["service_call_id"], "call-other"); +}