Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 93 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,24 @@ name: CI

on:
push:
branches: [main, develop]
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:
inputs:
oss_conductor_version:
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:
Expand Down Expand Up @@ -86,7 +101,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
Expand All @@ -105,9 +120,82 @@ 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
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 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'
# 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:
# 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 [ -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 "OSS_CONDUCTOR_VERSION=${resolved}" >> "$GITHUB_ENV"
echo "Using conductoross/conductor:${resolved}"

- 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
# 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

- name: Dump Conductor logs
if: failure()
run: docker compose -f scripts/docker-compose-oss.yaml logs conductor-server
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):**
Expand Down
2 changes: 1 addition & 1 deletion lib/conductor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
# )
Expand Down
43 changes: 31 additions & 12 deletions lib/conductor/http/api/scheduler_resource_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions lib/conductor/http/rest_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/conductor/orkes/orkes_clients.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion lib/conductor/worker/ractor_task_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
)
Expand Down
35 changes: 35 additions & 0 deletions scripts/docker-compose-oss.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
services:
conductor-server:
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:
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
98 changes: 98 additions & 0 deletions scripts/run-integration-oss.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/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 <tag>] [-- 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 <tag>] [-- 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() {
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"
return
fi
echo "Tearing down Conductor OSS stack..."
compose down -v || true
}
trap cleanup EXIT

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..."
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
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"

# 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[@]}"}
Loading
Loading