From 6740a6f897ff7e2d5598276b7cc5a8e5b606b945 Mon Sep 17 00:00:00 2001 From: Chris Hagglund Date: Thu, 20 Aug 2026 11:05:15 -0600 Subject: [PATCH 01/11] fix test that wasn't implemented according to how it needed to be --- spec/integration/integration_helper.rb | 2 +- spec/integration/worker_e2e_spec.rb | 24 ++++++++++-------------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/spec/integration/integration_helper.rb b/spec/integration/integration_helper.rb index 14c0c83..c65ac56 100644 --- a/spec/integration/integration_helper.rb +++ b/spec/integration/integration_helper.rb @@ -32,7 +32,7 @@ def self.configuration key_id = ENV.fetch('CONDUCTOR_AUTH_KEY', nil) key_secret = ENV.fetch('CONDUCTOR_AUTH_SECRET', nil) if key_id && key_secret - config.authentication_settings = Conductor::Configuration::AuthenticationSettings.new( + config.authentication_settings = Conductor::AuthenticationSettings.new( key_id: key_id, key_secret: key_secret ) diff --git a/spec/integration/worker_e2e_spec.rb b/spec/integration/worker_e2e_spec.rb index b6796b5..eede975 100644 --- a/spec/integration/worker_e2e_spec.rb +++ b/spec/integration/worker_e2e_spec.rb @@ -215,23 +215,19 @@ it 'builds, registers, and executes a workflow using the DSL' do # Build workflow using DSL - workflow = Conductor::Workflow::ConductorWorkflow.new( - executor: Conductor::Workflow::WorkflowExecutor.new(IntegrationHelper.configuration) - ) - dsl_wf_name = IntegrationHelper.test_name('dsl_workflow') + dsl_task_name = task_name # capture the `let` into a local: the block below is + # instance_eval'd against the WorkflowBuilder, so bare method calls (like the + # `task_name` helper) wouldn't resolve there, but closed-over locals still do. + executor = Conductor::Workflow::WorkflowExecutor.new(configuration) - workflow.name = dsl_wf_name - workflow.version = 1 - workflow.description = 'DSL integration test' - workflow.timeout_seconds = 300 - workflow.owner_email = 'test@example.com' + workflow = Conductor.workflow(dsl_wf_name, version: 1, description: 'DSL integration test', + executor: executor) do + timeout 300 + owner_email 'test@example.com' - # Add a simple task - simple = Conductor::Workflow::SimpleTask.new(task_name, "#{task_name}_dsl_ref") - simple.input('value', '${workflow.input.value}') - - workflow >> simple + simple dsl_task_name, value: wf[:value] + end # Register the workflow wf_def = workflow.to_workflow_def From 321f8ed043978b258447818885c1b2b55545948e Mon Sep 17 00:00:00 2001 From: Chris Hagglund Date: Thu, 20 Aug 2026 11:31:07 -0600 Subject: [PATCH 02/11] fix other incorrect test implementation --- spec/integration/orkes_spec.rb | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/spec/integration/orkes_spec.rb b/spec/integration/orkes_spec.rb index 8412cce..c0d4406 100644 --- a/spec/integration/orkes_spec.rb +++ b/spec/integration/orkes_spec.rb @@ -353,15 +353,10 @@ def skip_if_limit_reached(error) it 'creates and registers a workflow using the DSL' do # Build workflow using DSL - workflow = Conductor::Workflow::ConductorWorkflow.new(executor: workflow_executor) - workflow.name = workflow_name - workflow.version = 1 - workflow.description = 'Ruby SDK DSL test on Orkes' - - # Add a simple set variable task - set_var = Conductor::Workflow::SetVariableTask.new('set_greeting') - set_var.input('greeting', '${workflow.input.name}') - workflow.add(set_var) + workflow = Conductor.workflow(workflow_name, version: 1, description: 'Ruby SDK DSL test on Orkes', + executor: workflow_executor) do + set greeting: wf[:name] + end # Register the workflow workflow.register(overwrite: true) From 12c79a12fd4d53eee33107244d213c54a0235fc4 Mon Sep 17 00:00:00 2001 From: Chris Hagglund Date: Thu, 20 Aug 2026 11:57:00 -0600 Subject: [PATCH 03/11] add tests against oss for ci --- .github/workflows/ci.yml | 60 ++++++++++++++++++++++++- scripts/docker-compose-oss.yaml | 28 ++++++++++++ scripts/run-integration-oss.sh | 78 +++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 scripts/docker-compose-oss.yaml create mode 100755 scripts/run-integration-oss.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff68306..690ffc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,15 @@ name: CI on: push: - branches: [main, develop] + branches: [main, develop, e2e-against-conductor-with-local-script] pull_request: branches: [main] + workflow_dispatch: + inputs: + oss_conductor_version: + description: 'OSS Conductor image tag (falls back to E2E_TEST_OSS_CONDUCTOR_VERSION org var)' + required: false + type: string jobs: test: @@ -86,7 +92,7 @@ jobs: integration-test: name: Integration Tests runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' needs: [test, build] steps: - name: Checkout code @@ -111,3 +117,53 @@ jobs: run: | bundle exec rspec spec/integration/ --format documentation continue-on-error: true + + # Integration tests (OSS): spins up Conductor OSS + Postgres via + # scripts/docker-compose-oss.yaml and runs the integration spec suite against + # it unauthenticated. The same stack can be run locally with + # scripts/run-integration-oss.sh. Unlike the dormant cloud `integration-test` + # job above, this needs no secrets, so it runs on every push/PR and is not + # continue-on-error. + integration-tests-oss: + name: Integration Tests (OSS) + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: [test, build] + env: + CONDUCTOR_SERVER_URL: http://localhost:8080/api + CONDUCTOR_SERVER_TYPE: oss + CONDUCTOR_INTEGRATION: 'true' + OSS_CONDUCTOR_VERSION: ${{ inputs.oss_conductor_version || vars.E2E_TEST_OSS_CONDUCTOR_VERSION }} + steps: + - name: Verify OSS Conductor version is set + run: | + if [ -z "$OSS_CONDUCTOR_VERSION" ]; then + echo "::error::No Conductor OSS image tag resolved. Set the E2E_TEST_OSS_CONDUCTOR_VERSION organization variable (and ensure its repository access policy includes this repo), or pass the oss_conductor_version input via workflow_dispatch." + exit 1 + fi + echo "Using conductoross/conductor:$OSS_CONDUCTOR_VERSION" + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + + - name: Install dependencies + run: bundle install + + - name: Start Conductor OSS stack + run: docker compose -f scripts/docker-compose-oss.yaml up -d + + - name: Wait for Conductor to be healthy + run: timeout 120 bash -c 'until curl -sf http://localhost:8080/health; do sleep 5; done' + + - name: Run integration tests (OSS) + run: bundle exec rspec spec/integration/ --format documentation + + - name: Dump Conductor logs + if: failure() + run: docker compose -f scripts/docker-compose-oss.yaml logs conductor-server diff --git a/scripts/docker-compose-oss.yaml b/scripts/docker-compose-oss.yaml new file mode 100644 index 0000000..efc5173 --- /dev/null +++ b/scripts/docker-compose-oss.yaml @@ -0,0 +1,28 @@ +services: + conductor-server: + image: conductoross/conductor:${OSS_CONDUCTOR_VERSION:-latest} + environment: + - CONFIG_PROP=config-postgres.properties + ports: + - "8080:8080" + healthcheck: + test: ["CMD", "curl", "-I", "-XGET", "http://localhost:8080/health"] + interval: 10s + timeout: 10s + retries: 20 + links: + - conductor-postgres:postgresdb + depends_on: + conductor-postgres: + condition: service_healthy + + conductor-postgres: + image: postgres:16 + environment: + - POSTGRES_USER=conductor + - POSTGRES_PASSWORD=conductor + healthcheck: + test: timeout 5 bash -c 'cat < /dev/null > /dev/tcp/localhost/5432' + interval: 5s + timeout: 5s + retries: 12 diff --git a/scripts/run-integration-oss.sh b/scripts/run-integration-oss.sh new file mode 100755 index 0000000..78cd760 --- /dev/null +++ b/scripts/run-integration-oss.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# +# Spin up a local Conductor OSS stack and run the integration spec suite +# against it, mirroring the `integration-tests-oss` job in +# .github/workflows/ci.yml. Orkes-Enterprise-only specs/examples are skipped +# via the existing `skip: !ENV['CONDUCTOR_INTEGRATION']` pattern extended with +# an OSS-aware condition (see the individual spec files for the +# empirically-confirmed gaps). +# +# The stack (Conductor OSS + Postgres) is defined in +# scripts/docker-compose-oss.yaml and is torn down automatically on exit. +# +# Usage: +# scripts/run-integration-oss.sh [--keep-up] [--version ] [-- rspec args] +# Examples: +# scripts/run-integration-oss.sh +# scripts/run-integration-oss.sh --version 3.32.0-rc18 +# scripts/run-integration-oss.sh --keep-up +# scripts/run-integration-oss.sh -- spec/integration/workflow_spec.rb +set -euo pipefail + +KEEP_UP=0 +extra=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --keep-up) KEEP_UP=1; shift ;; + --version) OSS_CONDUCTOR_VERSION="${2:?--version needs a tag}"; shift 2 ;; + -h|--help) + echo "Usage: $0 [--keep-up] [--version ] [-- rspec args]" + exit 0 + ;; + --) shift; extra=("$@"); break ;; + *) echo "Unknown argument: $1" >&2; exit 1 ;; + esac +done + +export OSS_CONDUCTOR_VERSION="${OSS_CONDUCTOR_VERSION:-latest}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +COMPOSE_FILE="${SCRIPT_DIR}/docker-compose-oss.yaml" +cd "${REPO_ROOT}" + +compose() { docker compose -f "${COMPOSE_FILE}" "$@"; } + +cleanup() { + if [[ "${KEEP_UP}" == "1" ]]; then + echo "--keep-up set: leaving the OSS stack running. Tear down with:" + echo " docker compose -f ${COMPOSE_FILE} down -v" + return + fi + echo "Tearing down Conductor OSS stack..." + compose down -v || true +} +trap cleanup EXIT + +echo "Starting Conductor OSS stack (conductoross/conductor:${OSS_CONDUCTOR_VERSION})..." +compose up -d + +echo "Waiting for Conductor to be healthy..." +HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-180}" +deadline=$(( SECONDS + HEALTH_TIMEOUT )) +until curl -sf http://localhost:8080/health >/dev/null 2>&1; do + if (( SECONDS >= deadline )); then + echo "Error: Conductor did not become healthy within ${HEALTH_TIMEOUT}s." >&2 + compose logs conductor-server || true + exit 1 + fi + sleep 5 +done +echo "Conductor is up." + +export CONDUCTOR_SERVER_URL="http://localhost:8080/api" +export CONDUCTOR_SERVER_TYPE="oss" +export CONDUCTOR_INTEGRATION="true" + +bundle exec rspec spec/integration/ --format documentation ${extra[@]+"${extra[@]}"} From 83d7d0ce3073621471f98b1c27820040b850b60f Mon Sep 17 00:00:00 2001 From: Chris Hagglund Date: Thu, 20 Aug 2026 12:47:04 -0600 Subject: [PATCH 04/11] fixes to make tests work against both oss and orkes where possible/sensible --- .../http/api/scheduler_resource_api.rb | 43 ++++++++---- scripts/docker-compose-oss.yaml | 7 ++ spec/integration/orkes_spec.rb | 70 ++++++++++++++----- spec/integration/task_ops_spec.rb | 6 +- spec/integration/worker_e2e_spec.rb | 2 +- spec/integration/workflow_ops_spec.rb | 7 +- 6 files changed, 102 insertions(+), 33 deletions(-) diff --git a/lib/conductor/http/api/scheduler_resource_api.rb b/lib/conductor/http/api/scheduler_resource_api.rb index e503b35..ff14799 100644 --- a/lib/conductor/http/api/scheduler_resource_api.rb +++ b/lib/conductor/http/api/scheduler_resource_api.rb @@ -67,27 +67,24 @@ def delete_schedule(name) end # Pause a schedule + # + # Per-schedule pause/resume is PUT-mapped on OSS Conductor but GET-only + # on some Orkes Conductor deployments. PUT is tried first and a 405 + # response falls back to GET, mirroring the python-sdk/csharp-sdk/rust-sdk + # clients. # @param [String] name Schedule name # @return [void] def pause_schedule(name) - @api_client.call_api( - '/scheduler/schedules/{name}/pause', - 'GET', - path_params: { name: name }, - return_http_data_only: true - ) + call_with_verb_fallback('/scheduler/schedules/{name}/pause', name) end # Resume a schedule + # + # See {#pause_schedule} for the PUT-with-GET-fallback rationale. # @param [String] name Schedule name # @return [void] def resume_schedule(name) - @api_client.call_api( - '/scheduler/schedules/{name}/resume', - 'GET', - path_params: { name: name }, - return_http_data_only: true - ) + call_with_verb_fallback('/scheduler/schedules/{name}/resume', name) end # Pause all schedules @@ -205,6 +202,28 @@ def delete_tag_for_schedule(name, tags) return_http_data_only: true ) end + + private + + # Try PUT first (OSS dialect); fall back to GET on 405 (some Orkes + # deployments only accept GET for these two routes). + def call_with_verb_fallback(templated_path, name) + @api_client.call_api( + templated_path, + 'PUT', + path_params: { name: name }, + return_http_data_only: true + ) + rescue Conductor::ApiError => e + raise unless e.status == 405 + + @api_client.call_api( + templated_path, + 'GET', + path_params: { name: name }, + return_http_data_only: true + ) + end end end end diff --git a/scripts/docker-compose-oss.yaml b/scripts/docker-compose-oss.yaml index efc5173..c1e8f66 100644 --- a/scripts/docker-compose-oss.yaml +++ b/scripts/docker-compose-oss.yaml @@ -3,6 +3,13 @@ services: image: conductoross/conductor:${OSS_CONDUCTOR_VERSION:-latest} environment: - CONFIG_PROP=config-postgres.properties + # Dummy, non-sensitive value so OSS's bundled env-backed SecretsDAO has + # something real to read back in spec/integration/orkes_spec.rb (get/list/ + # exists). OSS Conductor has no authentication at all, so an + # unauthenticated /api/secrets/{key} read doesn't change the threat + # model versus any other unauthenticated OSS endpoint -- don't put a + # real credential here. + - CONDUCTOR_SECRET_RUBY_SDK_INTEGRATION_TEST=ruby-sdk-oss-secret-value ports: - "8080:8080" healthcheck: diff --git a/spec/integration/orkes_spec.rb b/spec/integration/orkes_spec.rb index c0d4406..54c21fd 100644 --- a/spec/integration/orkes_spec.rb +++ b/spec/integration/orkes_spec.rb @@ -36,6 +36,10 @@ def skip_if_limit_reached(error) skip "Orkes free tier limit reached: #{error.message}" end + def oss? + ENV['CONDUCTOR_SERVER_TYPE'] == 'oss' + end + describe 'OrkesClients factory' do it 'creates all client types successfully' do expect(clients.get_workflow_client).to be_a(Conductor::Client::WorkflowClient) @@ -56,6 +60,16 @@ def skip_if_limit_reached(error) let(:secret_key) { "#{test_id}_secret" } let(:secret_value) { "test_secret_value_#{SecureRandom.hex(8)}" } + # OSS Conductor registers a full secrets CRUD controller by default (the + # `agentspan` module's `conductor.integrations.ai.enabled=true` default), + # but only ships read-only SecretsDAO backends: writes (put/delete) return + # a real 501 "read-only backend" rather than succeeding. Reads work + # against an env-backed secret seeded via + # CONDUCTOR_SECRET_RUBY_SDK_INTEGRATION_TEST in scripts/docker-compose-oss.yaml + # -- keep these two constants in sync with that file. + OSS_SEEDED_SECRET_NAME = 'RUBY_SDK_INTEGRATION_TEST' + OSS_SEEDED_SECRET_VALUE = 'ruby-sdk-oss-secret-value' + after do # Clean up: delete the test secret if it exists @@ -65,32 +79,54 @@ def skip_if_limit_reached(error) end it 'performs CRUD operations on secrets' do - # Create - secret_client.put_secret(secret_key, secret_value) + if oss? + # Verify reads work against the pre-seeded env-backed secret, and that + # writes fail with a real 501 (read-only backend) rather than silently + # succeeding or failing for some other reason. + expect(secret_client.get_secret(OSS_SEEDED_SECRET_NAME)).to eq(OSS_SEEDED_SECRET_VALUE) + expect(secret_client.secret_exists(OSS_SEEDED_SECRET_NAME)).to be true + expect(secret_client.list_all_secret_names).to include(OSS_SEEDED_SECRET_NAME) + + begin + secret_client.put_secret(secret_key, secret_value) + # A future OSS release might ship a writable backend; if so, clean up. + secret_client.delete_secret(secret_key) + rescue Conductor::ApiError => e + raise unless e.status == 501 + end + else + # Create + secret_client.put_secret(secret_key, secret_value) - # Verify it exists - exists = secret_client.secret_exists(secret_key) - expect(exists).to be true + # Verify it exists + exists = secret_client.secret_exists(secret_key) + expect(exists).to be true - # List secrets should include our key - secrets = secret_client.list_all_secret_names - expect(secrets).to include(secret_key) + # List secrets should include our key + secrets = secret_client.list_all_secret_names + expect(secrets).to include(secret_key) - # Get secret (note: Orkes may return masked value or the actual value depending on permissions) - retrieved = secret_client.get_secret(secret_key) - expect(retrieved).not_to be_nil + # Get secret (note: Orkes may return masked value or the actual value depending on permissions) + retrieved = secret_client.get_secret(secret_key) + expect(retrieved).not_to be_nil - # Delete - secret_client.delete_secret(secret_key) + # Delete + secret_client.delete_secret(secret_key) - # Verify deleted - exists_after = secret_client.secret_exists(secret_key) - expect(exists_after).to be false + # Verify deleted + exists_after = secret_client.secret_exists(secret_key) + expect(exists_after).to be false + end rescue Conductor::ApiError => e skip_if_limit_reached(e) end it 'handles secret tags' do + if oss? + skip 'Secret tags require a writable secrets backend; OSS only ships read-only ' \ + 'SecretsDAO implementations (env/no-op)' + end + # Create secret first secret_client.put_secret(secret_key, secret_value) @@ -354,7 +390,7 @@ def skip_if_limit_reached(error) it 'creates and registers a workflow using the DSL' do # Build workflow using DSL workflow = Conductor.workflow(workflow_name, version: 1, description: 'Ruby SDK DSL test on Orkes', - executor: workflow_executor) do + executor: workflow_executor) do set greeting: wf[:name] end diff --git a/spec/integration/task_ops_spec.rb b/spec/integration/task_ops_spec.rb index c45df6d..b8526c5 100644 --- a/spec/integration/task_ops_spec.rb +++ b/spec/integration/task_ops_spec.rb @@ -445,10 +445,14 @@ def skip_if_limit_reached(error) end it 'search - with query filter' do + # Use the portable `field = "value"` query syntax (not Lucene `field:value` + # colon syntax) so this also works against OSS Conductor's default + # Postgres-backed indexing, which only understands `=`/`>`/`<`/`IN` + # conditions, not full Lucene grammar. results = task_api.search( start: 0, size: 5, - query: "taskType:#{test_id}_simple_task" + query: "taskType = \"#{test_id}_simple_task\"" ) expect(results).not_to be_nil diff --git a/spec/integration/worker_e2e_spec.rb b/spec/integration/worker_e2e_spec.rb index eede975..02fc15f 100644 --- a/spec/integration/worker_e2e_spec.rb +++ b/spec/integration/worker_e2e_spec.rb @@ -222,7 +222,7 @@ executor = Conductor::Workflow::WorkflowExecutor.new(configuration) workflow = Conductor.workflow(dsl_wf_name, version: 1, description: 'DSL integration test', - executor: executor) do + executor: executor) do timeout 300 owner_email 'test@example.com' diff --git a/spec/integration/workflow_ops_spec.rb b/spec/integration/workflow_ops_spec.rb index b0165e7..5408471 100644 --- a/spec/integration/workflow_ops_spec.rb +++ b/spec/integration/workflow_ops_spec.rb @@ -381,11 +381,14 @@ def get_status(workflow) end it 'search - searches workflows with query' do - # Search for our workflow by name + # Search for our workflow by name. Use the portable `field = "value"` query + # syntax (not Lucene `field:value` colon syntax) so this also works against + # OSS Conductor's default Postgres-backed indexing, which only understands + # `=`/`>`/`<`/`IN` conditions, not full Lucene grammar. results = workflow_api.search( start: 0, size: 10, - query: "workflowType:#{test_id}_simple_workflow" + query: "workflowType = \"#{test_id}_simple_workflow\"" ) expect(results).not_to be_nil From cea7e7a405ba09b26b5b1a650231dc29d222e39f Mon Sep 17 00:00:00 2001 From: Chris Hagglund Date: Thu, 20 Aug 2026 13:01:15 -0600 Subject: [PATCH 05/11] disable tests against oss that appear after investigation to not be supported on oss --- spec/integration/event_spec.rb | 6 ++++-- spec/integration/orkes_spec.rb | 31 +++++++++++++++++++++++++++ spec/integration/prompt_spec.rb | 9 ++++++++ spec/integration/scheduler_spec.rb | 9 ++++++++ spec/integration/workflow_ops_spec.rb | 10 +++++++++ 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/spec/integration/event_spec.rb b/spec/integration/event_spec.rb index 880a6f2..34279c6 100644 --- a/spec/integration/event_spec.rb +++ b/spec/integration/event_spec.rb @@ -394,8 +394,10 @@ def skip_if_limit_reached(error) event_api.get_queue_config(queue_type, queue_name) end.to raise_error(Conductor::ApiError) { |e| expect(e.status).to eq(404) } rescue Conductor::ApiError => e - # Queue operations may not be available - if e.status == 501 || e.message.include?('not supported') + # Queue operations may not be available. OSS Conductor doesn't register this route at + # all (plain 404), whereas Orkes Cloud registers it but deprecates it in favor of the + # integrations API (400/501/403 below). + if e.status == 404 || e.status == 501 || e.message.include?('not supported') skip 'Queue configuration API not available in this environment' elsif e.status == 400 && e.message.include?('integrations API') skip 'Queue configuration is managed via integrations API in Orkes Cloud' diff --git a/spec/integration/orkes_spec.rb b/spec/integration/orkes_spec.rb index 54c21fd..377774a 100644 --- a/spec/integration/orkes_spec.rb +++ b/spec/integration/orkes_spec.rb @@ -157,6 +157,13 @@ def oss? describe 'SchemaClient' do let(:schema_client) { clients.get_schema_client } + before do + if oss? + skip 'Schema registry API not implemented in OSS Conductor (SchemaDef is only an inline ' \ + 'WorkflowDef/TaskDef field; there is no standalone SchemaResource/DAO)' + end + end + it 'lists all schemas' do # This should work even on free tier all_schemas = schema_client.get_all_schemas @@ -214,6 +221,14 @@ def oss? describe 'AuthorizationClient' do let(:auth_client) { clients.get_authorization_client } + before do + if oss? + skip 'Authorization/RBAC API not implemented in OSS Conductor (no users/roles/groups/' \ + 'applications/permissions resource at all; OSS explicitly ships with ' \ + 'ACCESS_MANAGEMENT/RBAC disabled)' + end + end + describe 'token operations' do it 'gets user info from current token' do user_info = auth_client.get_user_info_from_token @@ -410,6 +425,14 @@ def oss? describe 'IntegrationClient' do let(:integration_client) { clients.get_integration_client } + before do + if oss? + skip 'Integration Hub API not implemented in OSS Conductor (no IntegrationResource/DAO; ' \ + "the OSS agentspan module's ProviderController only exposes /api/providers/status, " \ + 'a fixed-list LLM-provider health check, not an integration-def registry)' + end + end + it 'lists available integrations' do # This just verifies the API call works (may return empty array) integrations = integration_client.get_integrations @@ -430,6 +453,14 @@ def oss? describe 'PromptClient' do let(:prompt_client) { clients.get_prompt_client } + before do + if oss? + skip 'Prompt template management API not implemented in OSS Conductor (PromptTemplateRef ' \ + 'is only an inert, unresolved reference field on AGENT tasks; there is no ' \ + 'PromptResource/DAO backing a named template store)' + end + end + it 'lists available prompts' do # This just verifies the API call works (may return empty array) prompts = prompt_client.get_prompts diff --git a/spec/integration/prompt_spec.rb b/spec/integration/prompt_spec.rb index bc4025f..a605f14 100644 --- a/spec/integration/prompt_spec.rb +++ b/spec/integration/prompt_spec.rb @@ -42,6 +42,15 @@ def skip_if_limit_reached(error) skip "Orkes free tier limit reached: #{error.message}" end + # Prompt template management is not implemented in OSS Conductor: there is no + # PromptResource/DAO. The `agentspan` module's `PromptTemplateRef` is only an + # inert, unresolved reference field on AGENT tasks -- nothing ever persists or + # looks up a named template by content. See orkes_spec.rb's PromptClient tests + # for the equivalent OSS-aware gating. + before do + skip 'Prompt template management API not implemented in OSS Conductor' if ENV['CONDUCTOR_SERVER_TYPE'] == 'oss' + end + describe 'Prompt CRUD Operations' do let(:prompt_name) { "#{test_id}_test_prompt" } let(:prompt_template) do diff --git a/spec/integration/scheduler_spec.rb b/spec/integration/scheduler_spec.rb index 6ab3e59..e787edc 100644 --- a/spec/integration/scheduler_spec.rb +++ b/spec/integration/scheduler_spec.rb @@ -56,6 +56,10 @@ def skip_if_limit_reached(error) skip "Orkes free tier limit reached: #{error.message}" end + def oss? + ENV['CONDUCTOR_SERVER_TYPE'] == 'oss' + end + # Helper to get attribute from schedule object or hash def get_schedule_attr(schedule, attr_name) value = if schedule.is_a?(Hash) @@ -641,6 +645,11 @@ def get_schedule_attr(schedule, attr_name) let(:schedule_name) { "#{test_id}_tag_test" } before do + if oss? + skip 'Schedule tagging API not implemented in OSS Conductor (no /scheduler/schedules/{name}/tags ' \ + 'route; scheduler tags are an Orkes-only addition)' + end + # Ensure the test workflow exists begin workflow_def = Conductor::Http::Models::WorkflowDef.new( diff --git a/spec/integration/workflow_ops_spec.rb b/spec/integration/workflow_ops_spec.rb index 5408471..90c4f48 100644 --- a/spec/integration/workflow_ops_spec.rb +++ b/spec/integration/workflow_ops_spec.rb @@ -45,6 +45,10 @@ def skip_if_limit_reached(error) skip "Orkes free tier limit reached: #{error.message}" end + def oss? + ENV['CONDUCTOR_SERVER_TYPE'] == 'oss' + end + # Helper to get workflow status def get_status(workflow) workflow.is_a?(Hash) ? workflow['status'] : workflow.status @@ -577,6 +581,12 @@ def get_status(workflow) let(:workflow_id) { @workflow_id } before do + if oss? + skip 'update_workflow_state not implemented in OSS Conductor (no POST /workflow/{id}/variables ' \ + 'route; this is an Orkes-only addition, distinct from the SET_VARIABLE task type which ' \ + 'OSS does support)' + end + begin workflow_def = Conductor::Http::Models::WorkflowDef.new( name: "#{test_id}_wait_workflow", From b52bd9a540ca9559a4cc7ac39c90dbf957314a4f Mon Sep 17 00:00:00 2001 From: Chris Hagglund Date: Fri, 21 Aug 2026 09:33:10 -0600 Subject: [PATCH 06/11] server url and auth key id are not secret --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 690ffc6..5b3fa93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,8 +111,8 @@ jobs: if: env.CONDUCTOR_SERVER_URL != '' env: CONDUCTOR_INTEGRATION: 'true' - CONDUCTOR_SERVER_URL: ${{ secrets.CONDUCTOR_SERVER_URL }} - CONDUCTOR_AUTH_KEY: ${{ secrets.CONDUCTOR_AUTH_KEY }} + CONDUCTOR_SERVER_URL: ${{ vars.CONDUCTOR_SERVER_URL }} + CONDUCTOR_AUTH_KEY: ${{ vars.CONDUCTOR_AUTH_KEY }} CONDUCTOR_AUTH_SECRET: ${{ secrets.CONDUCTOR_AUTH_SECRET }} run: | bundle exec rspec spec/integration/ --format documentation From 4a0a429d9540d37fdcdf7824de18516ee1d4cb35 Mon Sep 17 00:00:00 2001 From: Chris Hagglund Date: Fri, 21 Aug 2026 09:51:09 -0600 Subject: [PATCH 07/11] require the integration tests to pass --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b3fa93..891f1ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,12 +116,11 @@ jobs: CONDUCTOR_AUTH_SECRET: ${{ secrets.CONDUCTOR_AUTH_SECRET }} run: | bundle exec rspec spec/integration/ --format documentation - continue-on-error: true # Integration tests (OSS): spins up Conductor OSS + Postgres via # scripts/docker-compose-oss.yaml and runs the integration spec suite against # it unauthenticated. The same stack can be run locally with - # scripts/run-integration-oss.sh. Unlike the dormant cloud `integration-test` + # scripts/run-integration-oss.sh. Unlike the cloud `integration-test` # job above, this needs no secrets, so it runs on every push/PR and is not # continue-on-error. integration-tests-oss: From 65b929f8e3fc46b26de52ce3db5623400ec1f1d0 Mon Sep 17 00:00:00 2001 From: Chris Hagglund Date: Fri, 21 Aug 2026 12:26:23 -0600 Subject: [PATCH 08/11] Add note about branch name removal todo --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 891f1ec..f155a2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main, develop, e2e-against-conductor-with-local-script] + branches: [main, develop, e2e-against-conductor-with-local-script] # TODO: Remove e2e-against-conductor-with-local-script branch after merging pull_request: branches: [main] workflow_dispatch: From d549916355977c97a25d14f16d13f1cf09bc562e Mon Sep 17 00:00:00 2001 From: Chris Hagglund Date: Tue, 8 Sep 2026 12:47:28 -0600 Subject: [PATCH 09/11] Address review: pin scheduler verb contract, harden e2e assertions Follow-up to the OSS/cloud e2e work, from pre-review feedback. Test coverage: - Add spec/conductor/http/api/scheduler_resource_api_spec.rb, pinning the PUT-with-GET-fallback contract that the rest of this branch relies on: PUT first, 405 falls back to GET on the same path, any other status propagates untouched, no dialect memoization, and the admin/bulk endpoints staying GET. The fallback branch was previously exercised by no job that runs on a PR -- the OSS job takes the PUT path and the cloud job is skipped on pull_request. Mirrors the equivalent guards in python-sdk, go-sdk, rust-sdk and csharp-sdk. - Workflow and task search asserted only `not_to be_nil`. Both server families answer 200-with-zero-rows for a query they parse but cannot match, so the switch to the portable `field = "value"` syntax was unverifiable by its own tests. Both examples now poll until rows appear and assert the rows actually match the expected workflowType/taskType. The task-search group grew a self-contained before hook -- specs run in random order, so it cannot borrow a task from another group -- which also polls the task out of SCHEDULED, since task indexing is driven by task updates on both families. - event_spec's queue-config example had 404 added to its skip condition. OSS registers no queue/config route at all, so gate on the server type instead: a 404 from get_queue_config after a successful put is the regression this example exists to catch on Orkes, and must keep failing there. Bugfixes: - Conductor::Configuration::AuthenticationSettings has never resolved; the class is defined directly under Conductor. Fixed in RactorTaskRunner's in-Ractor config rebuild, where it was a live NameError, and in the Conductor and OrkesClients doc comments, which told users to write the broken form. Local runner and CI parity with the other SDKs: - run-integration-oss.sh now unsets CONDUCTOR_AUTH_KEY/CONDUCTOR_AUTH_SECRET. Plain OSS has no auth layer and no /token endpoint, and IntegrationHelper.configuration builds AuthenticationSettings whenever both are present -- so a shell still holding Orkes creds sent the whole local run through an auth flow the local server cannot serve. - Pull the server image unconditionally: `compose up` only pulls when an image is missing, so a cached mutable `latest` was silently reused. - Raise the CI health wait from 120s to 180s, matching HEALTH_TIMEOUT in the script and staying under the compose healthcheck's own ~200s budget. Cleanups: - Hoist the four copies of `oss?` into IntegrationHelper.oss?, and document CONDUCTOR_SERVER_TYPE alongside the other integration env vars. - Use test@conductoross.io as the fixture ownerEmail throughout. - Record the scheduler and AuthenticationSettings fixes in the changelog. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 5 +- CHANGELOG.md | 5 + lib/conductor.rb | 2 +- lib/conductor/orkes/orkes_clients.rb | 2 +- lib/conductor/worker/ractor_task_runner.rb | 2 +- scripts/run-integration-oss.sh | 24 +++- .../http/api/scheduler_resource_api_spec.rb | 123 ++++++++++++++++++ spec/integration/event_spec.rb | 17 ++- spec/integration/integration_helper.rb | 51 ++++++++ spec/integration/metadata_spec.rb | 2 +- spec/integration/orkes_spec.rb | 16 +-- spec/integration/prompt_spec.rb | 2 +- spec/integration/scheduler_spec.rb | 6 +- spec/integration/task_ops_spec.rb | 81 +++++++++++- spec/integration/worker_e2e_spec.rb | 6 +- spec/integration/workflow_ops_spec.rb | 34 +++-- spec/integration/workflow_spec.rb | 2 +- 17 files changed, 325 insertions(+), 55 deletions(-) create mode 100644 spec/conductor/http/api/scheduler_resource_api_spec.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f155a2c..29b0496 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,7 +158,10 @@ jobs: run: docker compose -f scripts/docker-compose-oss.yaml up -d - name: Wait for Conductor to be healthy - run: timeout 120 bash -c 'until curl -sf http://localhost:8080/health; do sleep 5; done' + # Matches HEALTH_TIMEOUT in scripts/run-integration-oss.sh, and stays under the compose + # healthcheck's own ~200s budget (10s x 20 retries). `curl -sf` alone is the check: + # /health answers non-2xx while the server is still coming up. + run: timeout 180 bash -c 'until curl -sf http://localhost:8080/health; do sleep 5; done' - name: Run integration tests (OSS) run: bundle exec rspec spec/integration/ --format documentation diff --git a/CHANGELOG.md b/CHANGELOG.md index 4595cac..71828dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Task classes: `SimpleTask`, `SwitchTask`, `ForkTask`, `JoinTask`, `DoWhileTask`, `HttpTask`, `SubWorkflowTask`, `WaitTask`, `TerminateTask`, `SetVariableTask`, `DynamicForkTask`, `JavascriptTask`, `JsonJqTask`, `EventTask`, `HttpPollTask`, `DynamicTask`, `HumanTask`, `StartWorkflowTask`, `KafkaPublishTask`, `WaitForWebhookTask` - LLM task classes: `LlmChatCompleteTask`, `LlmTextCompleteTask`, `LlmGenerateEmbeddingsTask`, `LlmIndexTextTask`, `LlmIndexDocumentTask`, `LlmSearchIndexTask`, `LlmQueryEmbeddingsTask`, `LlmStoreEmbeddingsTask`, `LlmSearchEmbeddingsTask`, `GenerateImageTask`, `GenerateAudioTask`, `GetDocumentTask`, `ListMcpToolsTask`, `CallMcpToolTask` +### Fixed + +- `SchedulerResourceApi#pause_schedule` / `#resume_schedule` now work against both Conductor server families. The client sends `PUT` first and falls back to `GET` on a `405` -- and only on a `405`. OSS Conductor maps these two per-schedule routes `@PutMapping`-only, so the previous `GET`-only calls failed there outright; Orkes Conductor accepts both verbs as of the dual `@RequestMapping(method = {GET, PUT})` added in 2026-07, and is `GET`-only in deployments older than that. `pause_all_schedules` / `resume_all_schedules` remain `GET`, which is how both families map those admin endpoints. Matches the python-sdk, go-sdk, javascript-sdk, csharp-sdk and rust-sdk clients; `spec/conductor/http/api/scheduler_resource_api_spec.rb` pins the whole contract +- `Conductor::AuthenticationSettings` is no longer referenced as `Conductor::Configuration::AuthenticationSettings`, which raised `NameError: uninitialized constant`. The class has always been defined directly under `Conductor`. Fixed in `RactorTaskRunner`'s in-Ractor configuration rebuild (where it was a live failure) and in the `Conductor` / `OrkesClients` doc comments (where it told users to write the broken form) + ### Migration Guide **Before (old DSL):** diff --git a/lib/conductor.rb b/lib/conductor.rb index 3c8bf3e..7ce0c88 100644 --- a/lib/conductor.rb +++ b/lib/conductor.rb @@ -147,7 +147,7 @@ def config # @example # Conductor.configure do |config| # config.server_url = 'http://localhost:7001/api' - # config.authentication_settings = Conductor::Configuration::AuthenticationSettings.new( + # config.authentication_settings = Conductor::AuthenticationSettings.new( # key_id: 'my_key', # key_secret: 'my_secret' # ) diff --git a/lib/conductor/orkes/orkes_clients.rb b/lib/conductor/orkes/orkes_clients.rb index 6e36a99..4e29864 100644 --- a/lib/conductor/orkes/orkes_clients.rb +++ b/lib/conductor/orkes/orkes_clients.rb @@ -8,7 +8,7 @@ module Orkes # Usage: # config = Conductor::Configuration.new # config.server_url = 'https://developer.orkescloud.com/api' - # config.authentication_settings = Conductor::Configuration::AuthenticationSettings.new( + # config.authentication_settings = Conductor::AuthenticationSettings.new( # key_id: 'your_key', key_secret: 'your_secret' # ) # clients = Conductor::Orkes::OrkesClients.new(config) diff --git a/lib/conductor/worker/ractor_task_runner.rb b/lib/conductor/worker/ractor_task_runner.rb index e129a3b..efef2f4 100644 --- a/lib/conductor/worker/ractor_task_runner.rb +++ b/lib/conductor/worker/ractor_task_runner.rb @@ -143,7 +143,7 @@ def setup_ractor_resources server_api_url: @configuration_hash[:server_api_url] ) if @configuration_hash[:authentication_settings] - config.authentication_settings = Configuration::AuthenticationSettings.new( + config.authentication_settings = AuthenticationSettings.new( key_id: @configuration_hash[:authentication_settings][:key_id], key_secret: @configuration_hash[:authentication_settings][:key_secret] ) diff --git a/scripts/run-integration-oss.sh b/scripts/run-integration-oss.sh index 78cd760..ee68423 100755 --- a/scripts/run-integration-oss.sh +++ b/scripts/run-integration-oss.sh @@ -45,6 +45,11 @@ cd "${REPO_ROOT}" compose() { docker compose -f "${COMPOSE_FILE}" "$@"; } cleanup() { + local status=$? + if [[ "${status}" -ne 0 ]]; then + echo "Dumping conductor-server logs (exit ${status})..." >&2 + compose logs conductor-server || true + fi if [[ "${KEEP_UP}" == "1" ]]; then echo "--keep-up set: leaving the OSS stack running. Tear down with:" echo " docker compose -f ${COMPOSE_FILE} down -v" @@ -55,7 +60,16 @@ cleanup() { } trap cleanup EXIT -echo "Starting Conductor OSS stack (conductoross/conductor:${OSS_CONDUCTOR_VERSION})..." +echo "Using conductoross/conductor:${OSS_CONDUCTOR_VERSION}" + +# `docker compose up` only pulls an image when it is missing locally, so a +# previously-cached `latest` (or any other mutable tag) would silently be +# reused instead of getting the current version. Pull unconditionally so the +# stack always reflects the tag we just printed. +echo "Pulling conductoross/conductor:${OSS_CONDUCTOR_VERSION} to ensure it's current..." +compose pull conductor-server + +echo "Starting Conductor OSS stack..." compose up -d echo "Waiting for Conductor to be healthy..." @@ -64,7 +78,6 @@ deadline=$(( SECONDS + HEALTH_TIMEOUT )) until curl -sf http://localhost:8080/health >/dev/null 2>&1; do if (( SECONDS >= deadline )); then echo "Error: Conductor did not become healthy within ${HEALTH_TIMEOUT}s." >&2 - compose logs conductor-server || true exit 1 fi sleep 5 @@ -75,4 +88,11 @@ export CONDUCTOR_SERVER_URL="http://localhost:8080/api" export CONDUCTOR_SERVER_TYPE="oss" export CONDUCTOR_INTEGRATION="true" +# Plain OSS Conductor has no authentication layer and no /token endpoint. A +# shell that still has these exported for the Orkes suite would send the whole +# run through an auth flow the local server cannot serve -- +# IntegrationHelper.configuration builds AuthenticationSettings from these two +# env vars whenever both are present. +unset CONDUCTOR_AUTH_KEY CONDUCTOR_AUTH_SECRET + bundle exec rspec spec/integration/ --format documentation ${extra[@]+"${extra[@]}"} diff --git a/spec/conductor/http/api/scheduler_resource_api_spec.rb b/spec/conductor/http/api/scheduler_resource_api_spec.rb new file mode 100644 index 0000000..99f212a --- /dev/null +++ b/spec/conductor/http/api/scheduler_resource_api_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Pins the HTTP verb contract for the scheduler resource. The two per-schedule +# pause/resume routes are mapped differently by the two server families: +# +# - OSS Conductor maps them PUT-only (`@PutMapping` in SchedulerResource.java). +# - Orkes Conductor accepts both GET and PUT as of the dual +# `@RequestMapping(method = {GET, PUT})` added in 2026-07; deployments older +# than that are GET-only. +# +# Hence PUT first, falling back to GET on a 405 -- and only on a 405. The +# admin/bulk endpoints are GET on both families and must never be sent as PUT. +# +# This mirrors the equivalent guards in the other SDKs +# (python-sdk tests/unit/orkes/test_scheduler_resource_contract.py, +# go-sdk sdk/client/api_scheduler_resource_test.go, +# rust-sdk tests/scheduler_verb_fallback_tests.rs, +# csharp-sdk Tests/ApiUnit/SchedulerResourceApiUnitTest.cs). +RSpec.describe Conductor::Http::Api::SchedulerResourceApi do + let(:api_client) { instance_double(Conductor::Http::ApiClient) } + let(:api) { described_class.new(api_client) } + + def method_not_allowed + Conductor::ApiError.new(status: 405, reason: 'Method Not Allowed') + end + + describe 'per-schedule pause/resume verb fallback' do + describe '#pause_schedule' do + it 'sends PUT first' do + expect(api_client).to receive(:call_api).with( + '/scheduler/schedules/{name}/pause', + 'PUT', + hash_including(path_params: { name: 'sched-1' }) + ) + + api.pause_schedule('sched-1') + end + + it 'falls back to GET on the same path when the server answers 405' do + expect(api_client).to receive(:call_api) + .with('/scheduler/schedules/{name}/pause', 'PUT', any_args) + .and_raise(method_not_allowed) + expect(api_client).to receive(:call_api).with( + '/scheduler/schedules/{name}/pause', + 'GET', + hash_including(path_params: { name: 'sched-1' }) + ) + + api.pause_schedule('sched-1') + end + end + + describe '#resume_schedule' do + it 'sends PUT first' do + expect(api_client).to receive(:call_api).with( + '/scheduler/schedules/{name}/resume', + 'PUT', + hash_including(path_params: { name: 'sched-1' }) + ) + + api.resume_schedule('sched-1') + end + + it 'falls back to GET on the same path when the server answers 405' do + expect(api_client).to receive(:call_api) + .with('/scheduler/schedules/{name}/resume', 'PUT', any_args) + .and_raise(method_not_allowed) + expect(api_client).to receive(:call_api).with( + '/scheduler/schedules/{name}/resume', + 'GET', + hash_including(path_params: { name: 'sched-1' }) + ) + + api.resume_schedule('sched-1') + end + end + + it 'does not memoize the dialect: every call attempts PUT first' do + verbs = [] + allow(api_client).to receive(:call_api) do |_path, verb, *_rest| + verbs << verb + raise method_not_allowed if verb == 'PUT' + end + + api.pause_schedule('sched-1') + api.pause_schedule('sched-2') + + expect(verbs).to eq(%w[PUT GET PUT GET]) + end + + # Only a 405 means "wrong verb for this route". A 404/403/500 is a real + # failure and re-sending it as a GET would mask it. + [404, 403, 500].each do |status| + it "propagates a #{status} without falling back to GET" do + error = Conductor::ApiError.new(status: status, reason: 'nope') + expect(api_client).to receive(:call_api) + .with('/scheduler/schedules/{name}/pause', 'PUT', any_args) + .once + .and_raise(error) + + expect { api.pause_schedule('sched-1') }.to raise_error(Conductor::ApiError) { |e| + expect(e.status).to eq(status) + } + end + end + end + + # The admin endpoints are GET on both server families -- they are not part of + # the verb split and must not acquire a PUT attempt. + describe 'admin/bulk endpoints stay GET' do + it '#pause_all_schedules sends GET' do + expect(api_client).to receive(:call_api).with('/scheduler/admin/pause', 'GET', any_args) + api.pause_all_schedules + end + + it '#resume_all_schedules sends GET' do + expect(api_client).to receive(:call_api).with('/scheduler/admin/resume', 'GET', any_args) + api.resume_all_schedules + end + end +end diff --git a/spec/integration/event_spec.rb b/spec/integration/event_spec.rb index 34279c6..1653b7c 100644 --- a/spec/integration/event_spec.rb +++ b/spec/integration/event_spec.rb @@ -374,6 +374,15 @@ def skip_if_limit_reached(error) end it '8-9. put_queue_config and delete_queue_config - manages queue configuration' do + # OSS Conductor does not register a queue/config route at all, so every verb here + # answers a plain 404. Gate on the server type rather than adding 404 to the rescue + # below: a 404 from get_queue_config after a successful put is exactly the regression + # this example exists to catch on Orkes, and must keep failing there. + if IntegrationHelper.oss? + skip 'Queue configuration API not implemented in OSS Conductor (no queue/config route; ' \ + 'queue configuration is an Orkes-only addition)' + end + queue_type = 'conductor' queue_name = "#{test_id}_queue" @@ -394,10 +403,10 @@ def skip_if_limit_reached(error) event_api.get_queue_config(queue_type, queue_name) end.to raise_error(Conductor::ApiError) { |e| expect(e.status).to eq(404) } rescue Conductor::ApiError => e - # Queue operations may not be available. OSS Conductor doesn't register this route at - # all (plain 404), whereas Orkes Cloud registers it but deprecates it in favor of the - # integrations API (400/501/403 below). - if e.status == 404 || e.status == 501 || e.message.include?('not supported') + # Orkes Cloud registers the route but deprecates it in favor of the integrations API + # (400/501/403 below). A bare 404 is deliberately NOT skipped here -- see the + # server-type gate at the top of this example. + if e.status == 501 || e.message.include?('not supported') skip 'Queue configuration API not available in this environment' elsif e.status == 400 && e.message.include?('integrations API') skip 'Queue configuration is managed via integrations API in Orkes Cloud' diff --git a/spec/integration/integration_helper.rb b/spec/integration/integration_helper.rb index c65ac56..16f2b71 100644 --- a/spec/integration/integration_helper.rb +++ b/spec/integration/integration_helper.rb @@ -12,6 +12,12 @@ # CONDUCTOR_AUTH_KEY - Auth key (for Orkes, not needed for OSS) # CONDUCTOR_AUTH_SECRET - Auth secret (for Orkes, not needed for OSS) # CONDUCTOR_INTEGRATION - Set to 'true' to enable integration tests +# CONDUCTOR_SERVER_TYPE - Set to 'oss' when the target is open-source Conductor, +# so specs covering Orkes-Enterprise-only APIs gate +# themselves via IntegrationHelper.oss?. Unset (or any +# other value) means an Orkes server. Exported by +# scripts/run-integration-oss.sh and by the +# integration-tests-oss CI job. # # Usage: # CONDUCTOR_INTEGRATION=true bundle exec rspec spec/integration/ @@ -22,6 +28,51 @@ module IntegrationHelper # Unique prefix for test resources to avoid collisions TEST_PREFIX = "ruby_sdk_test_#{Time.now.to_i}_#{rand(10_000)}".freeze + # True when the suite is running against open-source Conductor rather than an + # Orkes server. Specs use this to skip APIs that OSS does not implement; see + # the individual call sites for the empirically-confirmed gap behind each skip. + def self.oss? + ENV['CONDUCTOR_SERVER_TYPE'] == 'oss' + end + + # Normalize a SearchResult (model or raw Hash) into its result rows. + def self.search_rows(response) + return [] if response.nil? + + rows = if response.is_a?(Hash) + response['results'] || response[:results] + elsif response.respond_to?(:results) + response.results + end + rows || [] + end + + # Read a camelCase field off a search result row. SearchResult#results is typed + # Array, so rows arrive as raw Hashes with wire-format keys. + def self.search_row_field(row, key) + row.is_a?(Hash) ? (row[key.to_s] || row[key.to_sym]) : row.public_send(key) + end + + # Poll a search until it returns at least one row, then return the rows (or [] + # if none appeared before the timeout). + # + # Search is index-backed and eventually consistent on both server families + # (Elasticsearch on Orkes, Postgres-backed indexing on OSS), so asserting on a + # single immediate query is inherently flaky. Callers assert on the return + # value, which is what makes a query-syntax regression visible: a query the + # server accepts but cannot match returns 200 with zero rows. + def self.wait_for_search_rows(timeout: 30, interval: 2) + deadline = Time.now + timeout + loop do + rows = search_rows(yield) + return rows if rows.any? + break if Time.now >= deadline + + sleep(interval) + end + [] + end + def self.configuration @configuration ||= begin config = Conductor::Configuration.new( diff --git a/spec/integration/metadata_spec.rb b/spec/integration/metadata_spec.rb index 5a1466a..bd6fe42 100644 --- a/spec/integration/metadata_spec.rb +++ b/spec/integration/metadata_spec.rb @@ -244,7 +244,7 @@ def build_simple_workflow(name, task_name, version: 1) workflow_def.schema_version = 2 workflow_def.timeout_seconds = 300 workflow_def.timeout_policy = 'TIME_OUT_WF' - workflow_def.owner_email = 'test@example.com' + workflow_def.owner_email = 'test@conductoross.io' workflow_def end end diff --git a/spec/integration/orkes_spec.rb b/spec/integration/orkes_spec.rb index 377774a..96a9ca3 100644 --- a/spec/integration/orkes_spec.rb +++ b/spec/integration/orkes_spec.rb @@ -36,10 +36,6 @@ def skip_if_limit_reached(error) skip "Orkes free tier limit reached: #{error.message}" end - def oss? - ENV['CONDUCTOR_SERVER_TYPE'] == 'oss' - end - describe 'OrkesClients factory' do it 'creates all client types successfully' do expect(clients.get_workflow_client).to be_a(Conductor::Client::WorkflowClient) @@ -79,7 +75,7 @@ def oss? end it 'performs CRUD operations on secrets' do - if oss? + if IntegrationHelper.oss? # Verify reads work against the pre-seeded env-backed secret, and that # writes fail with a real 501 (read-only backend) rather than silently # succeeding or failing for some other reason. @@ -122,7 +118,7 @@ def oss? end it 'handles secret tags' do - if oss? + if IntegrationHelper.oss? skip 'Secret tags require a writable secrets backend; OSS only ships read-only ' \ 'SecretsDAO implementations (env/no-op)' end @@ -158,7 +154,7 @@ def oss? let(:schema_client) { clients.get_schema_client } before do - if oss? + if IntegrationHelper.oss? skip 'Schema registry API not implemented in OSS Conductor (SchemaDef is only an inline ' \ 'WorkflowDef/TaskDef field; there is no standalone SchemaResource/DAO)' end @@ -222,7 +218,7 @@ def oss? let(:auth_client) { clients.get_authorization_client } before do - if oss? + if IntegrationHelper.oss? skip 'Authorization/RBAC API not implemented in OSS Conductor (no users/roles/groups/' \ 'applications/permissions resource at all; OSS explicitly ships with ' \ 'ACCESS_MANAGEMENT/RBAC disabled)' @@ -426,7 +422,7 @@ def oss? let(:integration_client) { clients.get_integration_client } before do - if oss? + if IntegrationHelper.oss? skip 'Integration Hub API not implemented in OSS Conductor (no IntegrationResource/DAO; ' \ "the OSS agentspan module's ProviderController only exposes /api/providers/status, " \ 'a fixed-list LLM-provider health check, not an integration-def registry)' @@ -454,7 +450,7 @@ def oss? let(:prompt_client) { clients.get_prompt_client } before do - if oss? + if IntegrationHelper.oss? skip 'Prompt template management API not implemented in OSS Conductor (PromptTemplateRef ' \ 'is only an inert, unresolved reference field on AGENT tasks; there is no ' \ 'PromptResource/DAO backing a named template store)' diff --git a/spec/integration/prompt_spec.rb b/spec/integration/prompt_spec.rb index a605f14..7e868c2 100644 --- a/spec/integration/prompt_spec.rb +++ b/spec/integration/prompt_spec.rb @@ -48,7 +48,7 @@ def skip_if_limit_reached(error) # looks up a named template by content. See orkes_spec.rb's PromptClient tests # for the equivalent OSS-aware gating. before do - skip 'Prompt template management API not implemented in OSS Conductor' if ENV['CONDUCTOR_SERVER_TYPE'] == 'oss' + skip 'Prompt template management API not implemented in OSS Conductor' if IntegrationHelper.oss? end describe 'Prompt CRUD Operations' do diff --git a/spec/integration/scheduler_spec.rb b/spec/integration/scheduler_spec.rb index e787edc..14b2105 100644 --- a/spec/integration/scheduler_spec.rb +++ b/spec/integration/scheduler_spec.rb @@ -56,10 +56,6 @@ def skip_if_limit_reached(error) skip "Orkes free tier limit reached: #{error.message}" end - def oss? - ENV['CONDUCTOR_SERVER_TYPE'] == 'oss' - end - # Helper to get attribute from schedule object or hash def get_schedule_attr(schedule, attr_name) value = if schedule.is_a?(Hash) @@ -645,7 +641,7 @@ def get_schedule_attr(schedule, attr_name) let(:schedule_name) { "#{test_id}_tag_test" } before do - if oss? + if IntegrationHelper.oss? skip 'Schedule tagging API not implemented in OSS Conductor (no /scheduler/schedules/{name}/tags ' \ 'route; scheduler tags are an Orkes-only addition)' end diff --git a/spec/integration/task_ops_spec.rb b/spec/integration/task_ops_spec.rb index b8526c5..004a5f2 100644 --- a/spec/integration/task_ops_spec.rb +++ b/spec/integration/task_ops_spec.rb @@ -425,6 +425,65 @@ def skip_if_limit_reached(error) end describe 'Task Search' do + # Self-contained setup: specs run in random order (spec_helper sets + # config.order = :random), so this group cannot rely on an earlier group + # having produced a task of this type for the query-filter example below. + before do + begin + task_def = Conductor::Http::Models::TaskDef.new( + name: "#{test_id}_simple_task", + description: 'Test task', + retry_count: 0, + timeout_seconds: 60 + ) + metadata_client.register_task_def(task_def) + rescue Conductor::ApiError + # Task may exist + end + + begin + workflow_def = Conductor::Http::Models::WorkflowDef.new( + name: "#{test_id}_search_workflow", + version: 1, + description: 'Workflow with SIMPLE task, for task search', + tasks: [ + Conductor::Http::Models::WorkflowTask.new( + name: "#{test_id}_simple_task", + task_reference_name: 'simple_task_ref', + type: 'SIMPLE', + input_parameters: { 'input_data' => '${workflow.input.data}' } + ) + ], + schema_version: 2 + ) + metadata_client.register_workflow_def(workflow_def, overwrite: true) + rescue Conductor::ApiError + # Workflow may exist + end + + request = Conductor::Http::Models::StartWorkflowRequest.new( + name: "#{test_id}_search_workflow", + version: 1, + input: { 'data' => 'search_test' } + ) + @workflow_id = workflow_client.start(request) + + # Poll the task so it leaves SCHEDULED. Task indexing is driven by task + # updates on both server families, so a task left sitting in the queue is + # not reliably searchable. + begin + task_api.poll("#{test_id}_simple_task", worker_id: 'ruby_search_worker') + rescue Conductor::ApiError + # Non-fatal: the query-filter example asserts on the search result itself + end + end + + after do + workflow_client.terminate_workflow(@workflow_id, reason: 'Test cleanup') + rescue StandardError + # Ignore + end + it 'search - searches for tasks' do results = task_api.search( start: 0, @@ -449,13 +508,23 @@ def skip_if_limit_reached(error) # colon syntax) so this also works against OSS Conductor's default # Postgres-backed indexing, which only understands `=`/`>`/`<`/`IN` # conditions, not full Lucene grammar. - results = task_api.search( - start: 0, - size: 5, - query: "taskType = \"#{test_id}_simple_task\"" - ) + # + # Assert on the rows, not just on a non-nil response: both server families + # answer 200-with-zero-rows for a query they parse but cannot match, so a + # syntax regression is invisible to a `not_to be_nil` check. + task_type = "#{test_id}_simple_task" + query = "taskType = \"#{task_type}\"" + + rows = IntegrationHelper.wait_for_search_rows do + task_api.search(start: 0, size: 5, query: query) + end - expect(results).not_to be_nil + expect(rows).not_to be_empty, "query #{query.inspect} matched nothing; a task of this " \ + 'type was created in the before hook, so this is a ' \ + 'query-syntax or indexing failure rather than an empty ' \ + 'environment' + expect(rows.map { |row| IntegrationHelper.search_row_field(row, :taskType) }) + .to all(eq(task_type)) rescue Conductor::ApiError => e skip_if_limit_reached(e) end diff --git a/spec/integration/worker_e2e_spec.rb b/spec/integration/worker_e2e_spec.rb index 02fc15f..7e9a11d 100644 --- a/spec/integration/worker_e2e_spec.rb +++ b/spec/integration/worker_e2e_spec.rb @@ -43,7 +43,7 @@ wf_def.schema_version = 2 wf_def.timeout_seconds = 300 wf_def.timeout_policy = 'TIME_OUT_WF' - wf_def.owner_email = 'test@example.com' + wf_def.owner_email = 'test@conductoross.io' metadata_client.register_workflow_def(wf_def) end @@ -164,7 +164,7 @@ wf_def.schema_version = 2 wf_def.timeout_seconds = 300 wf_def.timeout_policy = 'TIME_OUT_WF' - wf_def.owner_email = 'test@example.com' + wf_def.owner_email = 'test@conductoross.io' metadata_client.register_workflow_def(wf_def) handler = Conductor::Worker::TaskHandler.new( @@ -224,7 +224,7 @@ workflow = Conductor.workflow(dsl_wf_name, version: 1, description: 'DSL integration test', executor: executor) do timeout 300 - owner_email 'test@example.com' + owner_email 'test@conductoross.io' simple dsl_task_name, value: wf[:value] end diff --git a/spec/integration/workflow_ops_spec.rb b/spec/integration/workflow_ops_spec.rb index 90c4f48..e3d5cae 100644 --- a/spec/integration/workflow_ops_spec.rb +++ b/spec/integration/workflow_ops_spec.rb @@ -45,10 +45,6 @@ def skip_if_limit_reached(error) skip "Orkes free tier limit reached: #{error.message}" end - def oss? - ENV['CONDUCTOR_SERVER_TYPE'] == 'oss' - end - # Helper to get workflow status def get_status(workflow) workflow.is_a?(Hash) ? workflow['status'] : workflow.status @@ -389,20 +385,22 @@ def get_status(workflow) # syntax (not Lucene `field:value` colon syntax) so this also works against # OSS Conductor's default Postgres-backed indexing, which only understands # `=`/`>`/`<`/`IN` conditions, not full Lucene grammar. - results = workflow_api.search( - start: 0, - size: 10, - query: "workflowType = \"#{test_id}_simple_workflow\"" - ) - - expect(results).not_to be_nil - - # Check results structure - if results.is_a?(Hash) - expect(results).to have_key('results').or have_key('totalHits') - else - expect(results).to respond_to(:results).or respond_to(:total_hits) + # + # Assert on the rows, not just on a non-nil response: both server families + # answer 200-with-zero-rows for a query they parse but cannot match, so a + # syntax regression is invisible to a `not_to be_nil` check. + workflow_type = "#{test_id}_simple_workflow" + query = "workflowType = \"#{workflow_type}\"" + + rows = IntegrationHelper.wait_for_search_rows do + workflow_api.search(start: 0, size: 10, query: query) end + + expect(rows).not_to be_empty, "query #{query.inspect} matched nothing; the workflow " \ + 'was started in the before hook, so this is a query-syntax ' \ + 'or indexing failure rather than an empty environment' + expect(rows.map { |row| IntegrationHelper.search_row_field(row, :workflowType) }) + .to all(eq(workflow_type)) rescue Conductor::ApiError => e skip_if_limit_reached(e) end @@ -581,7 +579,7 @@ def get_status(workflow) let(:workflow_id) { @workflow_id } before do - if oss? + if IntegrationHelper.oss? skip 'update_workflow_state not implemented in OSS Conductor (no POST /workflow/{id}/variables ' \ 'route; this is an Orkes-only addition, distinct from the SET_VARIABLE task type which ' \ 'OSS does support)' diff --git a/spec/integration/workflow_spec.rb b/spec/integration/workflow_spec.rb index 781b6a1..d754c39 100644 --- a/spec/integration/workflow_spec.rb +++ b/spec/integration/workflow_spec.rb @@ -41,7 +41,7 @@ workflow_def.schema_version = 2 workflow_def.timeout_seconds = 300 workflow_def.timeout_policy = 'TIME_OUT_WF' - workflow_def.owner_email = 'test@example.com' + workflow_def.owner_email = 'test@conductoross.io' IntegrationHelper.metadata_client.register_workflow_def(workflow_def) end From 56a9be6ebca29c6fb860db42fcf482c28b87f358 Mon Sep 17 00:00:00 2001 From: Chris Hagglund Date: Wed, 9 Sep 2026 10:10:50 -0600 Subject: [PATCH 10/11] Retry reset connections, add CI concurrency, unblock fork PRs Three follow-ups from pre-review, all in CI or HTTP plumbing rather than the OSS/cloud e2e work itself. Retry Faraday::ConnectionFailed: faraday-retry's DEFAULT_EXCEPTIONS is [Errno::ETIMEDOUT, 'Timeout::Error', Faraday::TimeoutError, Faraday::RetriableResponse] -- no ConnectionFailed, which is what the net_http_persistent adapter raises for Errno::ECONNRESET. A write to a pooled socket the peer had already closed was therefore never retried and surfaced as ApiError(status: 0). net-http-persistent retries stale sockets itself, but only for idempotent requests, so every GET in the suite was silently protected and POSTs were not -- which is how this failed the cloud integration job on run 34265187276 attempt 1, in metadata_client.register_task_def. Passing `exceptions:` explicitly is the whole fix. spec/conductor/http/rest_client_spec.rb pins it, including a behavioral example asserting the request is attempted 4 times rather than 1. CI concurrency: Supersede an in-flight run when a new commit lands on the same ref, matching python-sdk and go-sdk. More than a runner-time saving here: the cloud job's scheduler_spec calls pause_all_schedules/resume_all_schedules, which are not scoped to a test_id, so two runs of one branch overlapping will fight each other. The group is ref-scoped, so concurrent main and develop pushes are still not serialized against the shared tenant. Fork PRs: GitHub withholds org/repo variables from pull_request runs on forks exactly as it withholds secrets, so vars.E2E_TEST_OSS_CONDUCTOR_VERSION resolves empty for an outside contributor. The hard-fail guard would then red every community PR in the job this branch exists to add (observed in csharp-sdk#178). That PR drops the guard in favor of a pinned literal; this keeps the two empty cases distinct instead -- a fork PR pins a version and keeps running, while a missing org variable on a first-party run still fails loudly rather than silently drifting onto the pin. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 40 ++++++++++++++--- lib/conductor/http/rest_client.rb | 9 ++++ spec/conductor/http/rest_client_spec.rb | 60 +++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 spec/conductor/http/rest_client_spec.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29b0496..05a401b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,10 +8,19 @@ on: workflow_dispatch: inputs: oss_conductor_version: - description: 'OSS Conductor image tag (falls back to E2E_TEST_OSS_CONDUCTOR_VERSION org var)' + description: 'OSS Conductor image tag (falls back to E2E_TEST_OSS_CONDUCTOR_VERSION org var, then to a pinned default on fork PRs)' required: false type: string +# Supersede an in-flight run when a new commit lands on the same ref, matching +# python-sdk and go-sdk. The integration jobs make this more than a runner-time +# saving: the cloud job mutates shared state on the sdkdev tenant (scheduler_spec +# calls pause_all_schedules/resume_all_schedules, which are not scoped to a +# test_id), so two runs of the same branch overlapping will fight each other. +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: test: name: Ruby ${{ matrix.ruby }} on ${{ matrix.os }} @@ -132,15 +141,36 @@ jobs: CONDUCTOR_SERVER_URL: http://localhost:8080/api CONDUCTOR_SERVER_TYPE: oss CONDUCTOR_INTEGRATION: 'true' - OSS_CONDUCTOR_VERSION: ${{ inputs.oss_conductor_version || vars.E2E_TEST_OSS_CONDUCTOR_VERSION }} + # Used only when the org variable is unreachable because the run is a fork + # PR -- see the resolve step below. + FORK_PR_FALLBACK_VERSION: '3.32.3' steps: - - name: Verify OSS Conductor version is set + # OSS_CONDUCTOR_VERSION is resolved here rather than in the job `env` so + # that the two ways it can come back empty get different treatment: + # + # - Fork PR: GitHub withholds org/repo variables from pull_request runs + # on forks exactly as it withholds secrets, so vars.* is always "" for + # an outside contributor (observed in csharp-sdk#178). This job needs + # no secrets, only a tag, so pin one and keep running. + # - Anything else: the org variable is genuinely missing or its + # repository access policy no longer covers this repo. Fail loudly + # rather than silently drifting onto the pin. + - name: Resolve OSS Conductor version + env: + REQUESTED_VERSION: ${{ inputs.oss_conductor_version || vars.E2E_TEST_OSS_CONDUCTOR_VERSION }} + IS_FORK_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} run: | - if [ -z "$OSS_CONDUCTOR_VERSION" ]; then + if [ -n "$REQUESTED_VERSION" ]; then + resolved="$REQUESTED_VERSION" + elif [ "$IS_FORK_PR" = "true" ]; then + resolved="$FORK_PR_FALLBACK_VERSION" + echo "::notice::Fork PR: org variables are withheld, pinning conductoross/conductor:${resolved}" + else echo "::error::No Conductor OSS image tag resolved. Set the E2E_TEST_OSS_CONDUCTOR_VERSION organization variable (and ensure its repository access policy includes this repo), or pass the oss_conductor_version input via workflow_dispatch." exit 1 fi - echo "Using conductoross/conductor:$OSS_CONDUCTOR_VERSION" + echo "OSS_CONDUCTOR_VERSION=${resolved}" >> "$GITHUB_ENV" + echo "Using conductoross/conductor:${resolved}" - name: Checkout code uses: actions/checkout@v4 diff --git a/lib/conductor/http/rest_client.rb b/lib/conductor/http/rest_client.rb index c51ff98..27cd8e4 100644 --- a/lib/conductor/http/rest_client.rb +++ b/lib/conductor/http/rest_client.rb @@ -112,11 +112,20 @@ def build_connection end # Retry middleware (3 retries with exponential backoff) + # + # `exceptions` has to be spelled out: faraday-retry's + # DEFAULT_EXCEPTIONS covers timeouts but not Faraday::ConnectionFailed, + # which is what the net_http_persistent adapter raises for + # Errno::ECONNRESET/EPIPE -- a write to a pooled socket the peer closed + # first. net-http-persistent retries stale sockets itself, but only for + # idempotent requests, so POSTs surfaced these as hard ApiErrors while + # every GET was silently protected. conn.request :retry, max: 3, interval: 0.5, backoff_factor: 2, retry_statuses: [408, 429, 500, 502, 503, 504], + exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [Faraday::ConnectionFailed], methods: %i[get post put patch delete] # Connection settings diff --git a/spec/conductor/http/rest_client_spec.rb b/spec/conductor/http/rest_client_spec.rb new file mode 100644 index 0000000..db0feed --- /dev/null +++ b/spec/conductor/http/rest_client_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Pins the Faraday retry configuration, specifically that a reset connection is +# retried. +# +# faraday-retry's DEFAULT_EXCEPTIONS is [Errno::ETIMEDOUT, 'Timeout::Error', +# Faraday::TimeoutError, Faraday::RetriableResponse] -- no +# Faraday::ConnectionFailed. The net_http_persistent adapter raises exactly that +# for Errno::ECONNRESET (see NET_HTTP_EXCEPTIONS in +# faraday/adapter/net_http_persistent.rb), so before `exceptions:` was passed +# explicitly a reset socket became a hard ApiError with no retry at all. That is +# what failed the cloud integration job on run 34265187276 attempt 1, in +# metadata_client.register_task_def -- a POST, which net-http-persistent will +# not retry internally the way it does idempotent verbs. +RSpec.describe Conductor::Http::RestClient do + subject(:client) { described_class.new } + + let(:retry_options) do + handler = client.connection.builder.handlers.find { |h| h.klass == Faraday::Retry::Middleware } + handler.instance_variable_get(:@args).first + end + + describe 'retry configuration' do + it 'retries Faraday::ConnectionFailed on top of the faraday-retry defaults' do + expect(retry_options[:exceptions]).to include(Faraday::ConnectionFailed) + expect(retry_options[:exceptions]).to include(*Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS) + end + + it 'retries POST, not only idempotent verbs' do + expect(retry_options[:methods]).to include(:post) + end + + it 'does not retry client errors' do + expect(retry_options[:retry_statuses]).not_to include(400, 401, 403, 404, 405) + end + end + + describe 'a reset connection' do + before do + # The middleware's own backoff would make this take 3.5s of real time. + allow_any_instance_of(Faraday::Retry::Middleware).to receive(:sleep) + end + + it 'is retried, then surfaces as an ApiError once the retries are exhausted' do + attempts = 0 + allow_any_instance_of(Net::HTTP::Persistent).to receive(:request) do + attempts += 1 + raise Errno::ECONNRESET, 'Connection reset by peer' + end + + expect { client.post('http://conductor.invalid/api/metadata/taskdefs', body: { 'name' => 'x' }) } + .to raise_error(Conductor::ApiError) { |e| expect(e.status).to eq(0) } + + # Initial attempt plus max: 3. Without `exceptions:` this is 1. + expect(attempts).to eq(4) + end + end +end From 52c12b62ee2014f0d338ccf03a1cfcf3afe06661 Mon Sep 17 00:00:00 2001 From: Chris Hagglund Date: Wed, 9 Sep 2026 10:23:16 -0600 Subject: [PATCH 11/11] Unregister the definitions the Task Search hook creates The group's `before` hook registers a TaskDef and a WorkflowDef, and the `after` hook only terminated the workflow execution. `test_id` is a `let`, so each example gets a fresh pair of uniquely-named definitions -- which means every run left two more orphans per example on the shared cloud tenant, accumulating indefinitely. Cleanup goes through this group's own `metadata_client` rather than IntegrationHelper's. The two resolve CONDUCTOR_SERVER_URL to different defaults when it is unset (localhost:7001 vs developer.orkescloud.com), so using the helper here could silently unregister against a different server than the one the `before` hook registered against. CI always sets the variable, but the local suite does not have to. Co-Authored-By: Claude Opus 5 (1M context) --- spec/integration/task_ops_spec.rb | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/spec/integration/task_ops_spec.rb b/spec/integration/task_ops_spec.rb index 004a5f2..f3b30a9 100644 --- a/spec/integration/task_ops_spec.rb +++ b/spec/integration/task_ops_spec.rb @@ -479,9 +479,30 @@ def skip_if_limit_reached(error) end after do - workflow_client.terminate_workflow(@workflow_id, reason: 'Test cleanup') - rescue StandardError - # Ignore + begin + workflow_client.terminate_workflow(@workflow_id, reason: 'Test cleanup') + rescue StandardError + # Ignore + end + + # `test_id` is a `let`, so each example registers its own uniquely-named + # pair of definitions. Unregister them or they accumulate on the shared + # cloud tenant, one pair per example per run, forever. Uses this group's + # own `metadata_client` rather than IntegrationHelper's, so cleanup is + # guaranteed to target the same server the `before` hook registered + # against (the two configurations resolve CONDUCTOR_SERVER_URL to + # different defaults when it is unset). + begin + metadata_client.unregister_workflow_def("#{test_id}_search_workflow", version: 1) + rescue StandardError + # Ignore + end + + begin + metadata_client.unregister_task_def("#{test_id}_simple_task") + rescue StandardError + # Ignore + end end it 'search - searches for tasks' do