diff --git a/app/Http/Controllers/Api/WorkflowObservationController.php b/app/Http/Controllers/Api/WorkflowObservationController.php new file mode 100644 index 000000000..e74f3d021 --- /dev/null +++ b/app/Http/Controllers/Api/WorkflowObservationController.php @@ -0,0 +1,99 @@ +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); + $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') || ! 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.', + 'reason' => 'bounded_run_observation_unsupported', + ], 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); + } + + 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 5c5e1f950..362e73ddb 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 6b154097b..ea5f2eede 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'], + ], 'history' => [ 'operation_name_field' => null, 'required_fields' => ['workflow_id', 'run_id'], diff --git a/app/Support/RunObservationHistoryToken.php b/app/Support/RunObservationHistoryToken.php new file mode 100644 index 000000000..6c0543d1a --- /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/composer.json b/composer.json index e70ec81e8..4876df6a3 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 8ae770d41..f32425201 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 0a2795db0..4efdc0bb6 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 827f889c3..4bdc26b57 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 03b98f183..a0aa22618 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 39ce1870c..8210de546 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 ef488864e..df05d53ed 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 17a3925a7..5ad24569b 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 1e8f4cb77..5c36b2ceb 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 ed778ed0a..7c1c1e276 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 24f351ce1..ade0afb1a 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 4bdd5bcc5..ae674229d 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 429322c9f..88678847a 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 7ba3d0eed..5c8a1a7be 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 f6a0d751a..72e29d077 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 158eaded2..6a532e507 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 e67827a80..47b32b3b9 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 1de89c3ec..1646f2ac6 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 1366396a4..2181d1299 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 cbbcbbab6..cda8cab56 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 30ae5bc6f..62e8285f1 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 b5cf91315..d89a75066 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 dba4e4ce3..9a465520d 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/phpunit.xml b/phpunit.xml index 72791d865..bb4cbc9d3 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 8b5ae1ca0..766ed29ef 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 @@ -399,6 +399,53 @@ 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 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 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: + 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 } } + - 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: get: tags: [workflows] diff --git a/resources/release/source-release.json b/resources/release/source-release.json index 9f21f8e96..6d4afc5c2 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 63a0d70de..183ce1efa 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 d2d032132..0033e203d 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 000000000..3c028ed23 --- /dev/null +++ b/tests/Feature/WorkflowObservationTest.php @@ -0,0 +1,193 @@ +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) + ->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 + { + $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'); + } + + 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([ + '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; + } +}