From c57213abb60b7b19f5bad594926c745f0227709c Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 6 Oct 2026 21:34:51 +0000 Subject: [PATCH 1/3] Expose bounded operator run observations through Server --- .../Api/WorkflowObservationController.php | 80 +++++++++ app/Support/ControlPlaneOperation.php | 8 + app/Support/ControlPlaneResponseContract.php | 5 + composer.json | 4 +- composer.lock | 16 +- docker-compose.dedicated-matching.yml | 4 +- docker-compose.memo-rolling.yml | 4 +- docker-compose.published.yml | 4 +- docker-compose.small-cluster.yml | 2 +- docker-compose.yml | 8 +- k8s/README.md | 10 +- k8s/helm/durable-workflow/Chart.yaml | 6 +- k8s/helm/durable-workflow/README.md | 2 +- .../ci/existing-secrets-values.yaml | 2 +- .../ci/ingress-and-hpa-values.yaml | 2 +- .../ci/inline-secrets-values.yaml | 2 +- .../durable-workflow/templates/_helpers.tpl | 2 +- k8s/helm/durable-workflow/values.yaml | 2 +- k8s/helm/examples/values-dev.yaml | 2 +- .../values-external-secrets-operator.yaml | 2 +- .../values-production-existing-secrets.yaml | 2 +- k8s/migration-job.yaml | 2 +- k8s/scheduler-cronjob.yaml | 2 +- k8s/secret.yaml | 2 +- k8s/server-deployment.yaml | 2 +- k8s/worker-deployment.yaml | 2 +- .../control-plane-api.openapi.yaml | 33 ++++ resources/release/source-release.json | 4 +- routes/api.php | 3 + scripts/k8s-kind-smoke.sh | 2 +- tests/Feature/WorkflowObservationTest.php | 154 ++++++++++++++++++ 31 files changed, 329 insertions(+), 46 deletions(-) create mode 100644 app/Http/Controllers/Api/WorkflowObservationController.php create mode 100644 tests/Feature/WorkflowObservationTest.php diff --git a/app/Http/Controllers/Api/WorkflowObservationController.php b/app/Http/Controllers/Api/WorkflowObservationController.php new file mode 100644 index 00000000..29850c8b --- /dev/null +++ b/app/Http/Controllers/Api/WorkflowObservationController.php @@ -0,0 +1,80 @@ +validate([ + 'search_attribute_keys' => ['nullable', 'array', 'max:20'], + 'search_attribute_keys.*' => ['required', 'string', 'min:1', 'max:255', 'distinct'], + ]); + $namespace = (string) $request->attributes->get('namespace'); + $instanceModel = ConfiguredV2Models::resolve('instance_model', WorkflowInstance::class); + $instance = $instanceModel::query()->setEagerLoads([]) + ->whereKey($workflowId)->where('namespace', $namespace)->first(['id', 'current_run_id']); + $selectedRunId = $runId ?? $instance?->current_run_id; + $runModel = ConfiguredV2Models::resolve('run_model', WorkflowRun::class); + $run = $instance === null || $selectedRunId === null ? null + : $runModel::query()->setEagerLoads([])->whereKey($selectedRunId) + ->where('workflow_instance_id', $workflowId)->where('namespace', $namespace) + ->first([ + 'id', 'workflow_instance_id', 'namespace', 'workflow_class', 'workflow_type', + 'run_number', 'status', 'closed_reason', 'business_key', 'visibility_labels', + 'compatibility', 'connection', 'queue', 'started_at', 'closed_at', 'archived_at', + 'details_pruned_at', 'created_at', 'updated_at', 'execution_deadline_at', 'run_deadline_at', + ]); + if (! $run instanceof WorkflowRun) { + return ControlPlaneProtocol::jsonForRequest($request, [ + 'workflow_id' => $workflowId, 'run_id' => $runId, + 'message' => 'Workflow run observation not found.', + 'reason' => $runId === null ? 'current_run_observation_unavailable' : 'run_not_found', + ], 404); + } + + $observer = app(OperatorObservabilityRepository::class); + if (! method_exists($observer, 'runObservation')) { + return ControlPlaneProtocol::jsonForRequest($request, [ + 'workflow_id' => $workflowId, 'run_id' => $run->id, + 'message' => 'The configured observer does not support bounded run observations.', + 'reason' => 'bounded_run_observation_unsupported', + ], 501); + } + + $observation = $observer->runObservation($run); + $observation['workflow_id'] = $workflowId; + $observation['task_queue'] = $observation['queue']; + $observation['read_mode'] = 'bounded'; + $observation['search_attributes'] = $this->searchAttributes($run, $validated['search_attribute_keys'] ?? []); + + return ControlPlaneProtocol::jsonForRequest($request, $observation); + } + + private function searchAttributes(WorkflowRun $run, array $keys): array + { + if ($keys === []) { + return []; + } + $model = ConfiguredV2Models::resolve('search_attribute_model', WorkflowSearchAttribute::class); + + return (new $model)->setConnection($run->getConnectionName())->newQuery()->setEagerLoads([]) + ->where('workflow_run_id', $run->id)->where('workflow_instance_id', $run->workflow_instance_id) + ->whereIn('key', $keys)->orderBy('key')->limit(20)->get() + ->mapWithKeys(static fn (WorkflowSearchAttribute $attribute): array => [$attribute->key => $attribute->getValue()]) + ->all(); + } +} diff --git a/app/Support/ControlPlaneOperation.php b/app/Support/ControlPlaneOperation.php index 5c5e1f95..362e73dd 100644 --- a/app/Support/ControlPlaneOperation.php +++ b/app/Support/ControlPlaneOperation.php @@ -51,6 +51,14 @@ public static function fromRequest(Request $request): ?self ); } + if ($request->isMethod('GET') && preg_match('#^/api/workflows/([^/]+)/runs/([^/]+)/observation$#', $path, $matches) === 1) { + return new self('observe_workflow', null, rawurldecode($matches[1]), rawurldecode($matches[2])); + } + + if ($request->isMethod('GET') && preg_match('#^/api/workflows/([^/]+)/observation$#', $path, $matches) === 1) { + return new self('observe_workflow', null, rawurldecode($matches[1])); + } + if ($request->isMethod('GET') && preg_match('#^/api/workflows/([^/]+)/runs/([^/]+)/history$#', $path, $matches) === 1) { return new self( 'history', diff --git a/app/Support/ControlPlaneResponseContract.php b/app/Support/ControlPlaneResponseContract.php index 6b154097..c0454bb0 100644 --- a/app/Support/ControlPlaneResponseContract.php +++ b/app/Support/ControlPlaneResponseContract.php @@ -93,6 +93,11 @@ final class ControlPlaneResponseContract 'required_fields' => ['workflow_id'], 'success_fields' => ['run_id', 'diagnostic_status'], ], + 'observe_workflow' => [ + 'operation_name_field' => null, + 'required_fields' => ['workflow_id'], + 'success_fields' => ['run_id', 'read_mode', 'observed_at', 'current_waits', 'parents', 'children', 'recent_failures'], + ], 'history' => [ 'operation_name_field' => null, 'required_fields' => ['workflow_id', 'run_id'], diff --git a/composer.json b/composer.json index e70ec81e..4876df6a 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "require": { "php": "^8.2", "apache/avro": "^1.12", - "durable-workflow/workflow": "2.4.4", + "durable-workflow/workflow": "2.4.5", "laravel/framework": "^13.30", "laravel/tinker": "^3.0", "league/flysystem-aws-s3-v3": "^3.35.3" @@ -48,7 +48,7 @@ }, "extra": { "durable-workflow": { - "product-train": "2.5.4" + "product-train": "2.5.5" }, "laravel": { "dont-discover": [] diff --git a/composer.lock b/composer.lock index 8ae770d4..f3242520 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7a823c5be8beccb9c22053510761acf3", + "content-hash": "0be8bd85f7ddcb8ffc4fc26ce0388861", "packages": [ { "name": "apache/avro", @@ -655,16 +655,16 @@ }, { "name": "durable-workflow/workflow", - "version": "2.4.4", + "version": "2.4.5", "source": { "type": "git", "url": "https://github.com/durable-workflow/workflow.git", - "reference": "95fa7029ca675cd161ecdd16683bf2dda477cb25" + "reference": "baf795cea9bd6f582e001794bd72577f015dd1f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/95fa7029ca675cd161ecdd16683bf2dda477cb25", - "reference": "95fa7029ca675cd161ecdd16683bf2dda477cb25", + "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/baf795cea9bd6f582e001794bd72577f015dd1f2", + "reference": "baf795cea9bd6f582e001794bd72577f015dd1f2", "shasum": "" }, "require": { @@ -697,7 +697,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.4.4", + "product-train": "2.4.5", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" } @@ -724,9 +724,9 @@ "description": "Embedded durable workflow runtime and orchestration engine for Laravel applications.", "support": { "issues": "https://github.com/durable-workflow/workflow/issues", - "source": "https://github.com/durable-workflow/workflow/tree/2.4.4" + "source": "https://github.com/durable-workflow/workflow/tree/2.4.5" }, - "time": "2026-10-06T19:27:02+00:00" + "time": "2026-10-06T21:14:01+00:00" }, { "name": "egulias/email-validator", diff --git a/docker-compose.dedicated-matching.yml b/docker-compose.dedicated-matching.yml index 0a2795db..4efdc0bb 100644 --- a/docker-compose.dedicated-matching.yml +++ b/docker-compose.dedicated-matching.yml @@ -32,13 +32,13 @@ name: durable-workflow-server # daemon reports `shape: dedicated`. # Generated by scripts/ci/sync-source-release.mjs. Do not edit the fallback. -x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.5.4}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.5.5}} x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: ${APP_ENV:-local} DW_SERVER_KEY: ${DW_SERVER_KEY:-} - APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.5.4}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.5.5}} APP_DEBUG: ${APP_DEBUG:-false} DB_CONNECTION: mysql DB_HOST: mysql diff --git a/docker-compose.memo-rolling.yml b/docker-compose.memo-rolling.yml index 827f889c..4bdc26b5 100644 --- a/docker-compose.memo-rolling.yml +++ b/docker-compose.memo-rolling.yml @@ -49,14 +49,14 @@ services: command: ["server-bootstrap"] environment: <<: *runtime-environment - APP_VERSION: ${APP_VERSION:-2.5.4} + APP_VERSION: ${APP_VERSION:-2.5.5} successor: image: ${DW_MEMO_SUCCESSOR_IMAGE:-durable-workflow/server-memo-rolling:local} ports: !override [] environment: <<: *runtime-environment - APP_VERSION: ${APP_VERSION:-2.5.4} + APP_VERSION: ${APP_VERSION:-2.5.5} DW_SERVER_ID: memo-successor DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: server_http_node diff --git a/docker-compose.published.yml b/docker-compose.published.yml index 03b98f18..a0aa2261 100644 --- a/docker-compose.published.yml +++ b/docker-compose.published.yml @@ -1,13 +1,13 @@ name: durable-workflow-server # Generated by scripts/ci/sync-source-release.mjs. Do not edit the fallback. -x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.5.4}} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.5.5}} x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: ${APP_ENV:-local} DW_SERVER_KEY: ${DW_SERVER_KEY:-} - APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.5.4}} + APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.5.5}} APP_DEBUG: ${APP_DEBUG:-false} LOG_CHANNEL: ${LOG_CHANNEL:-stderr} LOG_LEVEL: ${LOG_LEVEL:-info} diff --git a/docker-compose.small-cluster.yml b/docker-compose.small-cluster.yml index 39ce1870..8210de54 100644 --- a/docker-compose.small-cluster.yml +++ b/docker-compose.small-cluster.yml @@ -12,7 +12,7 @@ x-server-build: &server-build x-server-environment: &server-environment APP_NAME: "Durable Workflow Server" APP_ENV: testing - APP_VERSION: ${APP_VERSION:-2.5.4} + APP_VERSION: ${APP_VERSION:-2.5.5} APP_DEBUG: "false" DW_SERVER_KEY: ${DW_SERVER_KEY:-base64:5Zt4nUhlCm3DD0nLXZJQdHiwPfb56yGo9gNV/g3jYbY=} DB_CONNECTION: ${DW_SMALL_CLUSTER_DB:-mysql} diff --git a/docker-compose.yml b/docker-compose.yml index ef488864..df05d53e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: server_http_node - APP_VERSION: "${APP_VERSION:-2.5.4}" + APP_VERSION: "${APP_VERSION:-2.5.5}" APP_DEBUG: "false" DB_CONNECTION: mysql DB_HOST: mysql @@ -62,7 +62,7 @@ services: APP_NAME: "Durable Workflow Server" APP_ENV: local DW_SERVER_KEY: "${DW_SERVER_KEY:-}" - APP_VERSION: "${APP_VERSION:-2.5.4}" + APP_VERSION: "${APP_VERSION:-2.5.5}" APP_DEBUG: "false" DB_CONNECTION: mysql DB_HOST: mysql @@ -124,7 +124,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: worker_node - APP_VERSION: "${APP_VERSION:-2.5.4}" + APP_VERSION: "${APP_VERSION:-2.5.5}" DB_CONNECTION: mysql DB_HOST: mysql DB_PORT: 3306 @@ -178,7 +178,7 @@ services: DW_SERVER_KEY: "${DW_SERVER_KEY:-}" DW_SERVER_TOPOLOGY_SHAPE: standalone_server DW_SERVER_PROCESS_CLASS: scheduler_node - APP_VERSION: "${APP_VERSION:-2.5.4}" + APP_VERSION: "${APP_VERSION:-2.5.5}" DB_CONNECTION: mysql DB_HOST: mysql DB_PORT: 3306 diff --git a/k8s/README.md b/k8s/README.md index 17a3925a..5ad24569 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -13,7 +13,7 @@ The checked-in manifests are synchronized with the repository's stable source release and pin its Docker Hub tag: ```text -durableworkflow/server:2.5.4 +durableworkflow/server:2.5.5 ``` Before production use, patch every workload image to the exact published tag or @@ -21,15 +21,15 @@ digest you intend to run: ```bash kubectl set image -n durable-workflow deploy/durable-workflow-server \ - server=durableworkflow/server:2.5.4 + server=durableworkflow/server:2.5.5 kubectl set image -n durable-workflow deploy/durable-workflow-worker \ - worker=durableworkflow/server:2.5.4 + worker=durableworkflow/server:2.5.5 kubectl set image -n durable-workflow cronjob/durable-workflow-scheduler \ - scheduler=durableworkflow/server:2.5.4 + scheduler=durableworkflow/server:2.5.5 ``` GitHub Container Registry publishes the same release line at -`ghcr.io/durable-workflow/server:2.5.4`. Digest pinning is preferred for strict +`ghcr.io/durable-workflow/server:2.5.5`. Digest pinning is preferred for strict change control. The manifests expect you to provide: diff --git a/k8s/helm/durable-workflow/Chart.yaml b/k8s/helm/durable-workflow/Chart.yaml index 1e8f4cb7..5c36b2ce 100644 --- a/k8s/helm/durable-workflow/Chart.yaml +++ b/k8s/helm/durable-workflow/Chart.yaml @@ -5,11 +5,11 @@ type: application # The chart's own semver version. Bumped on every chart release; treated as # independent of the server image version (appVersion). Breaking-change rules # for this version live in docs/helm-upgrading.md alongside the chart. -version: 0.1.141 +version: 0.1.142 # The immutable Durable Workflow Server identity this chart release packages. # The onboarding default in values.yaml and appVersion are generated from the # checked-in source release record. -appVersion: "2.5.4" +appVersion: "2.5.5" kubeVersion: ">=1.27.0-0" home: https://durable-workflow.github.io/docs/2.0/deployment sources: @@ -30,7 +30,7 @@ annotations: # exact commit that most recently changed the packaged chart. org.opencontainers.image.source: https://github.com/durable-workflow/server dev.durable-workflow.source-revision: "unreleased" - dev.durable-workflow.image-reference: "docker.io/durableworkflow/server:2.5.4" + dev.durable-workflow.image-reference: "docker.io/durableworkflow/server:2.5.5" artifacthub.io/license: MIT artifacthub.io/category: integration-delivery # Free-form changelog for the current chart release shown by Artifact Hub. diff --git a/k8s/helm/durable-workflow/README.md b/k8s/helm/durable-workflow/README.md index ed778ed0..7c1c1e27 100644 --- a/k8s/helm/durable-workflow/README.md +++ b/k8s/helm/durable-workflow/README.md @@ -63,7 +63,7 @@ helm install durable-workflow ./k8s/helm/durable-workflow \ ```yaml image: - tag: "2.5.4" + tag: "2.5.5" # Pin a digest in production: # digest: "sha256:abc123..." # memoPayloadStorage: "raw-json-v1" # Required for a digest or custom image. diff --git a/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml b/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml index 24f351ce..ade0afb1 100644 --- a/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml +++ b/k8s/helm/durable-workflow/ci/existing-secrets-values.yaml @@ -1,7 +1,7 @@ # CI fixture: GitOps / externally-managed-secret path. The chart consumes # existing Secrets and renders no Secret resources of its own. image: - tag: "2.5.4" + tag: "2.5.5" externalDatabase: connection: pgsql diff --git a/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml b/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml index 4bdd5bcc..ae674229 100644 --- a/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml +++ b/k8s/helm/durable-workflow/ci/ingress-and-hpa-values.yaml @@ -1,6 +1,6 @@ # CI fixture: ingress + autoscaling enabled. Exercises optional templates. image: - tag: "2.5.4" + tag: "2.5.5" externalDatabase: connection: mysql diff --git a/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml b/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml index 429322c9..88678847 100644 --- a/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml +++ b/k8s/helm/durable-workflow/ci/inline-secrets-values.yaml @@ -2,7 +2,7 @@ # chart's render path is exercised end-to-end. Real deployments should use # existingSecret instead. image: - tag: "2.5.4" + tag: "2.5.5" externalDatabase: connection: mysql diff --git a/k8s/helm/durable-workflow/templates/_helpers.tpl b/k8s/helm/durable-workflow/templates/_helpers.tpl index 7ba3d0ee..5c8a1a7b 100644 --- a/k8s/helm/durable-workflow/templates/_helpers.tpl +++ b/k8s/helm/durable-workflow/templates/_helpers.tpl @@ -88,7 +88,7 @@ resolved by an explicit capability declaration or an existing workload marker. {{- define "durable-workflow.memoPayloadStorageForImage" -}} {{- $image := toString . -}} {{- $normalized := regexReplaceAll "^index\\.docker\\.io/" $image "docker.io/" -}} -{{- if eq $normalized "docker.io/durableworkflow/server:2.5.4" -}} +{{- if eq $normalized "docker.io/durableworkflow/server:2.5.5" -}} dual-v1 {{- else if regexMatch "^docker\\.io/durableworkflow/server:2\\.0\\.0-rc\\.[0-9]+$" $normalized -}} {{- $releaseCandidate := atoi (regexFind "[0-9]+$" $normalized) -}} diff --git a/k8s/helm/durable-workflow/values.yaml b/k8s/helm/durable-workflow/values.yaml index f6a0d751..72e29d07 100644 --- a/k8s/helm/durable-workflow/values.yaml +++ b/k8s/helm/durable-workflow/values.yaml @@ -21,7 +21,7 @@ image: registry: docker.io repository: durableworkflow/server # Generated by scripts/ci/sync-source-release.mjs. Do not edit this default. - tag: "2.5.4" + tag: "2.5.5" # Optional digest pin. When set, takes precedence over tag for change control. # Example: "sha256:abc123..." digest: "" diff --git a/k8s/helm/examples/values-dev.yaml b/k8s/helm/examples/values-dev.yaml index 158eaded..6a532e50 100644 --- a/k8s/helm/examples/values-dev.yaml +++ b/k8s/helm/examples/values-dev.yaml @@ -3,7 +3,7 @@ # shape in production. image: - tag: "2.5.4" + tag: "2.5.5" externalDatabase: connection: mysql diff --git a/k8s/helm/examples/values-external-secrets-operator.yaml b/k8s/helm/examples/values-external-secrets-operator.yaml index e67827a8..47b32b3b 100644 --- a/k8s/helm/examples/values-external-secrets-operator.yaml +++ b/k8s/helm/examples/values-external-secrets-operator.yaml @@ -5,7 +5,7 @@ # concern. image: - tag: "2.5.4" + tag: "2.5.5" externalDatabase: connection: pgsql diff --git a/k8s/helm/examples/values-production-existing-secrets.yaml b/k8s/helm/examples/values-production-existing-secrets.yaml index 1de89c3e..1646f2ac 100644 --- a/k8s/helm/examples/values-production-existing-secrets.yaml +++ b/k8s/helm/examples/values-production-existing-secrets.yaml @@ -10,7 +10,7 @@ image: repository: durable-workflow/server # Pin a digest in production for change-control auditability. digest: "" # e.g. "sha256:abc123..." - tag: "2.5.4" + tag: "2.5.5" externalDatabase: connection: pgsql diff --git a/k8s/migration-job.yaml b/k8s/migration-job.yaml index 1366396a..2181d129 100644 --- a/k8s/migration-job.yaml +++ b/k8s/migration-job.yaml @@ -13,7 +13,7 @@ spec: restartPolicy: OnFailure containers: - name: migrate - image: durableworkflow/server:2.5.4 + image: durableworkflow/server:2.5.5 command: ["server-entrypoint"] args: ["server-bootstrap"] envFrom: diff --git a/k8s/scheduler-cronjob.yaml b/k8s/scheduler-cronjob.yaml index cbbcbbab..cda8cab5 100644 --- a/k8s/scheduler-cronjob.yaml +++ b/k8s/scheduler-cronjob.yaml @@ -24,7 +24,7 @@ spec: restartPolicy: Never containers: - name: scheduler - image: durableworkflow/server:2.5.4 + image: durableworkflow/server:2.5.5 command: ["server-entrypoint"] args: - sh diff --git a/k8s/secret.yaml b/k8s/secret.yaml index 30ae5bc6..62e8285f 100644 --- a/k8s/secret.yaml +++ b/k8s/secret.yaml @@ -12,7 +12,7 @@ metadata: app.kubernetes.io/name: durable-workflow data: APP_NAME: "Durable Workflow Server" - APP_VERSION: "2.5.4" + APP_VERSION: "2.5.5" APP_ENV: production APP_DEBUG: "false" DB_CONNECTION: mysql diff --git a/k8s/server-deployment.yaml b/k8s/server-deployment.yaml index b5cf9131..d89a7506 100644 --- a/k8s/server-deployment.yaml +++ b/k8s/server-deployment.yaml @@ -23,7 +23,7 @@ spec: spec: containers: - name: server - image: durableworkflow/server:2.5.4 + image: durableworkflow/server:2.5.5 ports: - containerPort: 8080 name: http diff --git a/k8s/worker-deployment.yaml b/k8s/worker-deployment.yaml index dba4e4ce..9a465520 100644 --- a/k8s/worker-deployment.yaml +++ b/k8s/worker-deployment.yaml @@ -19,7 +19,7 @@ spec: spec: containers: - name: worker - image: durableworkflow/server:2.5.4 + image: durableworkflow/server:2.5.5 command: ["server-entrypoint"] args: ["php", "artisan", "queue:work", "--sleep=1", "--tries=3", "--max-time=3600"] envFrom: diff --git a/resources/platform-protocol-specs/control-plane-api.openapi.yaml b/resources/platform-protocol-specs/control-plane-api.openapi.yaml index 8b5ae1ca..8a237efa 100644 --- a/resources/platform-protocol-specs/control-plane-api.openapi.yaml +++ b/resources/platform-protocol-specs/control-plane-api.openapi.yaml @@ -399,6 +399,39 @@ paths: - $ref: "#/components/parameters/WorkflowIdPath" responses: "200": { $ref: "#/components/responses/ControlPlaneEnvelope" } + /workflows/{workflowId}/observation: + get: + tags: [workflows] + operationId: observeWorkflow + description: >- + Bounded run metadata, projected current waits, the first 50 parent and + child links with independent outcomes, and the ten most recent failure + references. Does not load run histories, application values or the + cancellation tree. The stored current pointer is observed without repair + or canonical command validation. Missing evidence stays unknown. Use the + existing debug and history APIs for deeper inspection. + parameters: + - $ref: "#/components/parameters/ControlPlaneVersionHeader" + - $ref: "#/components/parameters/WorkflowIdPath" + - name: search_attribute_keys + in: query + schema: { type: array, maxItems: 20, items: { type: string, minLength: 1, maxLength: 255 } } + responses: + "200": { $ref: "#/components/responses/ControlPlaneEnvelope" } + /workflows/{workflowId}/runs/{runId}/observation: + get: + tags: [workflows] + operationId: observeWorkflowRun + description: The same bounded observation for one explicit retained run. + parameters: + - $ref: "#/components/parameters/ControlPlaneVersionHeader" + - $ref: "#/components/parameters/WorkflowIdPath" + - $ref: "#/components/parameters/RunIdPath" + - name: search_attribute_keys + in: query + schema: { type: array, maxItems: 20, items: { type: string, minLength: 1, maxLength: 255 } } + responses: + "200": { $ref: "#/components/responses/ControlPlaneEnvelope" } /workflows/{workflowId}/debug: get: tags: [workflows] diff --git a/resources/release/source-release.json b/resources/release/source-release.json index 9f21f8e9..6d4afc5c 100644 --- a/resources/release/source-release.json +++ b/resources/release/source-release.json @@ -1,9 +1,9 @@ { "schema": "durable-workflow.server.source-release/v1", "server": { - "version": "2.5.4" + "version": "2.5.5" }, "helm_chart": { - "version": "0.1.141" + "version": "0.1.142" } } diff --git a/routes/api.php b/routes/api.php index 63a0d70d..183ce1ef 100644 --- a/routes/api.php +++ b/routes/api.php @@ -26,6 +26,7 @@ use App\Http\Controllers\Api\WorkerManagementController; use App\Http\Controllers\Api\WorkerSessionController; use App\Http\Controllers\Api\WorkflowController; +use App\Http\Controllers\Api\WorkflowObservationController; use App\Http\Controllers\Api\WorkflowStreamController; use App\Http\Middleware\Authenticate; use App\Http\Middleware\ControlPlaneVersionResolver; @@ -169,9 +170,11 @@ Route::prefix('workflows')->middleware([$operator, $cpv, $httpControl, $ns, $namespaceAdmission])->group(function () { Route::get('/{workflowId}/debug', [WorkflowController::class, 'debug']); + Route::get('/{workflowId}/observation', [WorkflowObservationController::class, 'show']); Route::get('/{workflowId}/runs', [WorkflowController::class, 'runs']); Route::get('/{workflowId}/runs/{runId}', [WorkflowController::class, 'showRun']); Route::get('/{workflowId}/runs/{runId}/debug', [WorkflowController::class, 'debugRun']); + Route::get('/{workflowId}/runs/{runId}/observation', [WorkflowObservationController::class, 'show']); Route::get('/{workflowId}/message-streams', [MessageStreamController::class, 'index']); Route::get('/{workflowId}/message-streams/{streamName}', [MessageStreamController::class, 'show']); diff --git a/scripts/k8s-kind-smoke.sh b/scripts/k8s-kind-smoke.sh index d2d03213..0033e203 100755 --- a/scripts/k8s-kind-smoke.sh +++ b/scripts/k8s-kind-smoke.sh @@ -7,7 +7,7 @@ cluster="${K8S_SMOKE_CLUSTER:-durable-workflow-server-smoke}" image="${K8S_SMOKE_IMAGE:-durableworkflow/server:k8s-smoke}" # Generated by scripts/ci/sync-source-release.mjs so the smoke replaces the # same default shipped by the public manifests. -manifest_image="durableworkflow/server:2.5.4" +manifest_image="durableworkflow/server:2.5.5" kind_node_image="${K8S_SMOKE_KIND_NODE_IMAGE:-kindest/node:v1.29.4}" artifact_dir="${K8S_SMOKE_ARTIFACT_DIR:-/tmp/durable-workflow-k8s-kind-smoke-artifacts}" rendered_dir="${artifact_dir}/rendered-manifests" diff --git a/tests/Feature/WorkflowObservationTest.php b/tests/Feature/WorkflowObservationTest.php new file mode 100644 index 00000000..4fbe873f --- /dev/null +++ b/tests/Feature/WorkflowObservationTest.php @@ -0,0 +1,154 @@ +createNamespace('default'); + $run = $this->makeRun('coordinator', 'completed'); + for ($index = 1; $index <= 101; $index++) { + $child = $this->makeRun(sprintf('child-%03d', $index), $index === 1 ? 'failed' : 'running'); + WorkflowLink::query()->create([ + 'id' => sprintf('link-%03d', $index), 'link_type' => 'child_workflow', 'sequence' => $index, + 'parent_workflow_instance_id' => $run->workflow_instance_id, 'parent_workflow_run_id' => $run->id, + 'child_workflow_instance_id' => $child->workflow_instance_id, 'child_workflow_run_id' => $child->id, + 'is_primary_parent' => true, + ]); + } + $rows = []; + for ($sequence = 1; $sequence <= 1001; $sequence++) { + foreach ([$run->id, 'child-001'] as $id) { + $rows[] = [ + 'id' => $id.'-event-'.$sequence, 'workflow_run_id' => $id, 'sequence' => $sequence, + 'event_type' => $sequence === 1 ? 'WorkflowStarted' : 'SignalReceived', + 'payload' => '{}', 'recorded_at' => now(), + ]; + } + } + foreach (array_chunk($rows, 100) as $chunk) { + DB::table('workflow_history_events')->insert($chunk); + } + WorkflowFailure::query()->create([ + 'id' => 'failure', 'workflow_run_id' => $run->id, 'source_kind' => 'activity', 'source_id' => 'activity', + 'propagation_kind' => 'activity', 'failure_category' => 'application', + 'exception_class' => 'RuntimeException', 'message' => 'Activity failed', + 'file' => 'fixture.php', 'line' => 12, 'trace_preview' => '', + ]); + WorkflowHistoryEvent::query()->create([ + 'id' => 'failure-event', 'workflow_run_id' => $run->id, 'sequence' => 1002, + 'event_type' => 'ActivityFailed', 'payload' => ['failure_id' => 'failure'], 'recorded_at' => now(), + ]); + $retrieved = ['runs' => 0, 'history' => 0]; + WorkflowRun::retrieved(static function (WorkflowRun $model) use (&$retrieved): void { + $retrieved['runs']++; + self::assertArrayNotHasKey('arguments', $model->getAttributes()); + self::assertArrayNotHasKey('output', $model->getAttributes()); + }); + WorkflowHistoryEvent::retrieved(static function () use (&$retrieved): void { + $retrieved['history']++; + }); + $response = $this->withHeaders($this->apiHeaders()) + ->getJson('/api/workflows/coordinator/runs/coordinator/observation') + ->assertOk()->assertJsonPath('control_plane.operation', 'observe_workflow') + ->assertJsonPath('read_mode', 'bounded')->assertJsonPath('status', 'completed') + ->assertJsonPath('history_audit', 'not_evaluated') + ->assertJsonPath('children.returned_count', 50)->assertJsonPath('children.has_more', true) + ->assertJsonPath('children.relationships.0.status', 'failed') + ->assertJsonPath('children.relationships.1.status', 'running') + ->assertJsonPath('recent_failures.failures.0.supporting_event.sequence', 1002); + $this->assertSame(['runs' => 52, 'history' => 2], $retrieved); + $this->assertArrayNotHasKey('arguments', $response->json()); + $this->assertArrayNotHasKey('output', $response->json()); + $this->assertArrayNotHasKey('cancellation_cascade', $response->json()); + } + + public function test_current_selection_is_scoped_and_never_repairs_a_wrong_pointer(): void + { + $this->createNamespace('default'); + $this->createNamespace('other'); + $run = $this->makeRun('selected'); + $other = $this->makeRun('other', namespace: 'other'); + $this->withHeaders($this->apiHeaders())->getJson('/api/workflows/selected/observation') + ->assertOk()->assertJsonPath('run_id', $run->id); + $this->withHeaders($this->apiHeaders('other'))->getJson('/api/workflows/selected/observation')->assertNotFound(); + WorkflowInstance::query()->whereKey('selected')->update(['current_run_id' => $other->id]); + $this->withHeaders($this->apiHeaders())->getJson('/api/workflows/selected/observation') + ->assertNotFound()->assertJsonPath('reason', 'current_run_observation_unavailable'); + $this->withHeaders($this->apiHeaders())->getJson('/api/workflows/selected/runs/selected/observation') + ->assertOk()->assertJsonPath('current_run_id', null)->assertJsonPath('is_current_run', null); + $this->withHeaders($this->apiHeaders())->getJson('/api/workflows/selected/runs/other/observation')->assertNotFound(); + $this->assertSame($other->id, DB::table('workflow_instances')->where('id', 'selected')->value('current_run_id')); + } + + public function test_context_attributes_are_explicitly_selected_and_validated_before_reads(): void + { + $this->createNamespace('default'); + $run = $this->makeRun('context'); + foreach (['order' => '42', 'private' => 'private-value'] as $key => $value) { + WorkflowSearchAttribute::query()->create([ + 'workflow_run_id' => $run->id, 'workflow_instance_id' => $run->workflow_instance_id, + 'key' => $key, 'type' => 'keyword', 'value_keyword' => $value, 'upserted_at_sequence' => 1, + ]); + } + $attributes = 0; + WorkflowSearchAttribute::retrieved(static function () use (&$attributes): void { + $attributes++; + }); + $path = '/api/workflows/context/observation'; + $this->withHeaders($this->apiHeaders())->getJson($path)->assertOk()->assertJsonPath('search_attributes', []); + $this->assertSame(0, $attributes); + $response = $this->withHeaders($this->apiHeaders())->getJson($path.'?search_attribute_keys[]=order') + ->assertOk()->assertJsonPath('search_attributes.order', '42'); + $this->assertSame(1, $attributes); + $this->assertStringNotContainsString('private-value', $response->getContent()); + $this->withHeaders($this->apiHeaders())->getJson($path.'?'.http_build_query([ + 'search_attribute_keys' => array_map(static fn ($id) => 'key-'.$id, range(1, 21)), + ]))->assertStatus(422); + $this->assertSame(1, $attributes); + } + + public function test_an_observer_without_the_optional_capability_is_explicitly_unsupported(): void + { + $this->createNamespace('default'); + $this->makeRun('unsupported'); + $this->app->instance(OperatorObservabilityRepository::class, $this->createMock(OperatorObservabilityRepository::class)); + $this->withHeaders($this->apiHeaders())->getJson('/api/workflows/unsupported/observation') + ->assertStatus(501)->assertJsonPath('reason', 'bounded_run_observation_unsupported'); + } + + private function makeRun(string $id, string $status = 'running', string $namespace = 'default'): WorkflowRun + { + WorkflowInstance::query()->create([ + 'id' => $id, 'namespace' => $namespace, + 'workflow_class' => ExternalGreetingWorkflow::class, + 'workflow_type' => 'tests.external-greeting-workflow', 'run_count' => 1, + ]); + $run = WorkflowRun::query()->create([ + 'id' => $id, 'workflow_instance_id' => $id, 'namespace' => $namespace, + 'workflow_class' => ExternalGreetingWorkflow::class, 'workflow_type' => 'tests.external-greeting-workflow', + 'status' => $status, 'run_number' => 1, 'connection' => 'database', 'queue' => 'default', + 'payload_codec' => 'avro', 'started_at' => now(), + ]); + WorkflowInstance::query()->whereKey($id)->update(['current_run_id' => $id]); + + return $run; + } +} From 12b23219b0fd98c839acdcbe305aa6b0e5f07030 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 6 Oct 2026 21:41:25 +0000 Subject: [PATCH 2/3] Keep observation history bounded and external payloads opaque --- .../Api/WorkflowObservationController.php | 21 ++++++++- app/Support/ControlPlaneResponseContract.php | 2 +- app/Support/RunObservationHistoryToken.php | 36 ++++++++++++++++ phpunit.xml | 2 +- .../control-plane-api.openapi.yaml | 20 +++++++-- tests/Feature/WorkflowObservationTest.php | 43 ++++++++++++++++++- 6 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 app/Support/RunObservationHistoryToken.php diff --git a/app/Http/Controllers/Api/WorkflowObservationController.php b/app/Http/Controllers/Api/WorkflowObservationController.php index 29850c8b..e74f3d02 100644 --- a/app/Http/Controllers/Api/WorkflowObservationController.php +++ b/app/Http/Controllers/Api/WorkflowObservationController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Support\ControlPlaneProtocol; +use App\Support\RunObservationHistoryToken; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Workflow\V2\Contracts\OperatorObservabilityRepository; @@ -22,6 +23,8 @@ public function show(Request $request, string $workflowId, ?string $runId = null $validated = $request->validate([ 'search_attribute_keys' => ['nullable', 'array', 'max:20'], 'search_attribute_keys.*' => ['required', 'string', 'min:1', 'max:255', 'distinct'], + 'history_page_size' => ['nullable', 'integer', 'min:1', 'max:1000'], + 'history_page_token' => ['nullable', 'string', 'max:4096'], ]); $namespace = (string) $request->attributes->get('namespace'); $instanceModel = ConfiguredV2Models::resolve('instance_model', WorkflowInstance::class); @@ -47,7 +50,7 @@ public function show(Request $request, string $workflowId, ?string $runId = null } $observer = app(OperatorObservabilityRepository::class); - if (! method_exists($observer, 'runObservation')) { + if (! method_exists($observer, 'runObservation') || ! method_exists($observer, 'runHistoryPage')) { return ControlPlaneProtocol::jsonForRequest($request, [ 'workflow_id' => $workflowId, 'run_id' => $run->id, 'message' => 'The configured observer does not support bounded run observations.', @@ -55,11 +58,27 @@ public function show(Request $request, string $workflowId, ?string $runId = null ], 501); } + $cursor = RunObservationHistoryToken::decode($validated['history_page_token'] ?? null, $run->id); $observation = $observer->runObservation($run); $observation['workflow_id'] = $workflowId; $observation['task_queue'] = $observation['queue']; $observation['read_mode'] = 'bounded'; $observation['search_attributes'] = $this->searchAttributes($run, $validated['search_attribute_keys'] ?? []); + $history = $observer->runHistoryPage($run, (int) ($validated['history_page_size'] ?? 200), + $cursor['after'] ?? 0, $cursor['through'] ?? null); + $history['next_page_token'] = $history['has_more'] ? RunObservationHistoryToken::encode( + $run->id, $history['next_sequence'], $history['through_sequence'], + ) : null; + $observation['history'] = $history; + foreach ($observation['recent_failures']['failures'] as &$failure) { + if ($failure['supporting_event']['state'] === 'retained') { + $reference = $failure['supporting_event']; + $failure['supporting_event']['next_page_token'] = RunObservationHistoryToken::encode( + $run->id, $reference['after_sequence'], $reference['sequence'], + ); + } + } + unset($failure); return ControlPlaneProtocol::jsonForRequest($request, $observation); } diff --git a/app/Support/ControlPlaneResponseContract.php b/app/Support/ControlPlaneResponseContract.php index c0454bb0..ea5f2eed 100644 --- a/app/Support/ControlPlaneResponseContract.php +++ b/app/Support/ControlPlaneResponseContract.php @@ -96,7 +96,7 @@ final class ControlPlaneResponseContract 'observe_workflow' => [ 'operation_name_field' => null, 'required_fields' => ['workflow_id'], - 'success_fields' => ['run_id', 'read_mode', 'observed_at', 'current_waits', 'parents', 'children', 'recent_failures'], + 'success_fields' => ['run_id', 'read_mode', 'observed_at', 'current_waits', 'parents', 'children', 'recent_failures', 'history'], ], 'history' => [ 'operation_name_field' => null, diff --git a/app/Support/RunObservationHistoryToken.php b/app/Support/RunObservationHistoryToken.php new file mode 100644 index 00000000..6c0543d1 --- /dev/null +++ b/app/Support/RunObservationHistoryToken.php @@ -0,0 +1,36 @@ + 1, 'run_id' => $runId, 'after' => $after, 'through' => $through, + ], JSON_THROW_ON_ERROR)); + } + + /** @return array{after: int, through: int}|null */ + public static function decode(?string $token, string $runId): ?array + { + if ($token === null) { + return null; + } + try { + $value = json_decode(Crypt::decryptString($token), true, 8, JSON_THROW_ON_ERROR); + if (is_array($value) && ($value['version'] ?? null) === 1 && ($value['run_id'] ?? null) === $runId + && is_int($value['after'] ?? null) && $value['after'] >= 0 + && is_int($value['through'] ?? null) && $value['through'] >= $value['after']) { + return ['after' => $value['after'], 'through' => $value['through']]; + } + } catch (Throwable) { + } + + throw ValidationException::withMessages(['history_page_token' => 'Invalid observation history cursor for this run.']); + } +} diff --git a/phpunit.xml b/phpunit.xml index 72791d86..bb4cbc9d 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -20,7 +20,7 @@ - + diff --git a/resources/platform-protocol-specs/control-plane-api.openapi.yaml b/resources/platform-protocol-specs/control-plane-api.openapi.yaml index 8a237efa..e79c5e3e 100644 --- a/resources/platform-protocol-specs/control-plane-api.openapi.yaml +++ b/resources/platform-protocol-specs/control-plane-api.openapi.yaml @@ -406,16 +406,24 @@ paths: description: >- Bounded run metadata, projected current waits, the first 50 parent and child links with independent outcomes, and the ten most recent failure - references. Does not load run histories, application values or the - cancellation tree. The stored current pointer is observed without repair + references and a history page of 200 events by default. Does not load child + histories, decode application values or load the cancellation tree. + External payload references remain opaque. The stored current pointer is observed without repair or canonical command validation. Missing evidence stays unknown. Use the - existing debug and history APIs for deeper inspection. + existing debug API for full inspection. History cursors authenticate the + selected run and preserve the original sequence ceiling. parameters: - $ref: "#/components/parameters/ControlPlaneVersionHeader" - $ref: "#/components/parameters/WorkflowIdPath" - name: search_attribute_keys in: query schema: { type: array, maxItems: 20, items: { type: string, minLength: 1, maxLength: 255 } } + - name: history_page_size + in: query + schema: { type: integer, minimum: 1, maximum: 1000, default: 200 } + - name: history_page_token + in: query + schema: { type: string, maxLength: 4096 } responses: "200": { $ref: "#/components/responses/ControlPlaneEnvelope" } /workflows/{workflowId}/runs/{runId}/observation: @@ -430,6 +438,12 @@ paths: - name: search_attribute_keys in: query schema: { type: array, maxItems: 20, items: { type: string, minLength: 1, maxLength: 255 } } + - name: history_page_size + in: query + schema: { type: integer, minimum: 1, maximum: 1000, default: 200 } + - name: history_page_token + in: query + schema: { type: string, maxLength: 4096 } responses: "200": { $ref: "#/components/responses/ControlPlaneEnvelope" } /workflows/{workflowId}/debug: diff --git a/tests/Feature/WorkflowObservationTest.php b/tests/Feature/WorkflowObservationTest.php index 4fbe873f..3c028ed2 100644 --- a/tests/Feature/WorkflowObservationTest.php +++ b/tests/Feature/WorkflowObservationTest.php @@ -73,11 +73,18 @@ public function test_initial_observation_bounds_related_runs_and_failure_evidenc ->assertJsonPath('children.returned_count', 50)->assertJsonPath('children.has_more', true) ->assertJsonPath('children.relationships.0.status', 'failed') ->assertJsonPath('children.relationships.1.status', 'running') - ->assertJsonPath('recent_failures.failures.0.supporting_event.sequence', 1002); - $this->assertSame(['runs' => 52, 'history' => 2], $retrieved); + ->assertJsonPath('recent_failures.failures.0.supporting_event.sequence', 1002) + ->assertJsonPath('history.returned_count', 200)->assertJsonPath('history.has_more', true) + ->assertJsonPath('history.through_sequence', 1002); + $this->assertSame(['runs' => 52, 'history' => 203], $retrieved); $this->assertArrayNotHasKey('arguments', $response->json()); $this->assertArrayNotHasKey('output', $response->json()); $this->assertArrayNotHasKey('cancellation_cascade', $response->json()); + $token = $response->json('recent_failures.failures.0.supporting_event.next_page_token'); + $this->withHeaders($this->apiHeaders())->getJson('/api/workflows/coordinator/runs/coordinator/observation?'.http_build_query([ + 'history_page_token' => $token, + ]))->assertOk()->assertJsonPath('history.returned_count', 1) + ->assertJsonPath('history.events.0.event_type', 'ActivityFailed'); } public function test_current_selection_is_scoped_and_never_repairs_a_wrong_pointer(): void @@ -134,6 +141,38 @@ public function test_an_observer_without_the_optional_capability_is_explicitly_u ->assertStatus(501)->assertJsonPath('reason', 'bounded_run_observation_unsupported'); } + public function test_history_cursor_keeps_its_original_run_and_ceiling_and_pruning_stays_explicit(): void + { + $this->createNamespace('default'); + $run = $this->makeRun('growing'); + $this->makeRun('other'); + for ($sequence = 1; $sequence <= 3; $sequence++) { + WorkflowHistoryEvent::query()->create([ + 'workflow_run_id' => $run->id, 'sequence' => $sequence, + 'event_type' => 'SignalReceived', 'payload' => ['arguments' => ['external_payload' => 'opaque']], + 'recorded_at' => now(), + ]); + } + $path = '/api/workflows/growing/runs/growing/observation'; + $first = $this->withHeaders($this->apiHeaders())->getJson($path.'?history_page_size=2') + ->assertOk()->assertJsonPath('history.events.0.payload.arguments.external_payload', 'opaque'); + $token = $first->json('history.next_page_token'); + WorkflowHistoryEvent::query()->create([ + 'workflow_run_id' => $run->id, 'sequence' => 4, 'event_type' => 'SignalReceived', + 'payload' => [], 'recorded_at' => now(), + ]); + $query = '?'.http_build_query(['history_page_token' => $token]); + $this->withHeaders($this->apiHeaders())->getJson($path.$query)->assertOk() + ->assertJsonPath('history.through_sequence', 3)->assertJsonCount(1, 'history.events') + ->assertJsonPath('history.events.0.sequence', 3)->assertJsonPath('history.next_page_token', null); + $this->withHeaders($this->apiHeaders())->getJson('/api/workflows/other/runs/other/observation'.$query)->assertStatus(422); + $this->withHeaders($this->apiHeaders())->getJson($path.'?history_page_token=invalid')->assertStatus(422); + $run->update(['details_pruned_at' => now()]); + WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->delete(); + $this->withHeaders($this->apiHeaders())->getJson($path)->assertOk() + ->assertJsonPath('history.details_state', 'pruned')->assertJsonPath('history.returned_count', 0); + } + private function makeRun(string $id, string $status = 'running', string $namespace = 'default'): WorkflowRun { WorkflowInstance::query()->create([ From c7108c6d1c6db19f937a7d3a48a079fa621f5af8 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 6 Oct 2026 21:46:32 +0000 Subject: [PATCH 3/3] Advance the additive control-plane OpenAPI revision --- .../platform-protocol-specs/control-plane-api.openapi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/platform-protocol-specs/control-plane-api.openapi.yaml b/resources/platform-protocol-specs/control-plane-api.openapi.yaml index e79c5e3e..766ed29e 100644 --- a/resources/platform-protocol-specs/control-plane-api.openapi.yaml +++ b/resources/platform-protocol-specs/control-plane-api.openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: durable-workflow.v2.control-plane-api - version: "11" + version: "12" summary: Durable Workflow control-plane HTTP+JSON API description: > Normative OpenAPI specification for the standalone Durable Workflow