From 8d64d511197aaed3eb06a025dcf0acae2786df50 Mon Sep 17 00:00:00 2001 From: Viren Baraiya Date: Fri, 17 Jul 2026 23:34:45 -0700 Subject: [PATCH 01/14] refactor(files): simplify FileClient transfers --- CHANGELOG.md | 2 + README.md | 22 + .../ConductorClientAutoConfiguration.java | 4 +- ...OrkesConductorClientAutoConfiguration.java | 2 +- .../ConductorClientAutoConfigurationTest.java | 10 +- conductor-client/README.md | 13 + conductor-client/build.gradle | 2 +- .../client/automator/TaskRunner.java | 37 +- .../automator/TaskRunnerConfigurer.java | 11 +- .../conductor/common/metadata/tasks/Task.java | 32 - .../executor/task/AnnotatedWorker.java | 40 +- .../client/AzureFileTransferAdapter.java | 63 ++ .../conductor/client/FileClient.java | 877 +++++++++++++----- .../client/FileClientProperties.java | 33 +- .../conductor/client/FileTransferAdapter.java | 36 + .../FileTransferException.java} | 30 +- .../client/GcsFileTransferAdapter.java | 52 ++ .../GenericHttpFileTransferAdapter.java | 53 ++ .../client/LocalFileTransferAdapter.java | 73 ++ ...=> NonRetryableFileTransferException.java} | 24 +- .../client/S3FileTransferAdapter.java | 56 ++ .../client/SignedUrlHttpTransfer.java | 229 +++++ .../model/file/FileDownloadUrlResponse.java | 5 +- .../{FileHandle.java => FileMetadata.java} | 30 +- .../file/FileUploadCompleteResponse.java | 2 +- .../client/model/file/FileUploadResponse.java | 21 +- .../model/file/FileUploadUrlResponse.java | 5 +- .../model/file/MultipartCompleteRequest.java | 8 +- .../model/file/MultipartInitResponse.java | 7 +- .../storage/AzureFileStorageBackend.java | 110 --- .../client/storage/GcsFileStorageBackend.java | 96 -- .../conductor/client/storage/HttpBodies.java | 69 -- .../storage/LocalFileStorageBackend.java | 80 -- .../client/storage/S3FileStorageBackend.java | 105 --- .../conductor/sdk/file/FileHandler.java | 132 --- .../sdk/file/FileHandlerConverter.java | 47 - .../sdk/file/FileHandlerDeserializer.java | 50 - .../sdk/file/FileHandlerSerializer.java | 36 - .../sdk/file/FileStorageBackend.java | 71 -- .../sdk/file/FileStorageException.java | 4 +- .../conductor/sdk/file/FileUploadOptions.java | 24 +- .../conductor/sdk/file/FileUploader.java | 43 - .../conductor/sdk/file/LocalFileHandler.java | 67 -- .../sdk/file/ManagedFileHandler.java | 154 --- .../sdk/file/WorkflowFileClient.java | 67 -- .../automator/TaskRunnerFatalAuthTest.java | 6 +- .../automator/TaskRunnerFileStorageTest.java | 171 ---- .../tasks/TaskGetInputFileHandlerTest.java | 87 -- .../task/AnnotatedWorkerFileStorageTest.java | 341 ------- .../client/FileClientPropertiesTest.java | 13 +- .../conductor/client/FileClientTest.java | 618 +++++++----- .../client/FileTransferAdaptersTest.java | 180 ++++ .../sdk/file/FileHandlerDeserializerTest.java | 98 -- .../file/FileHandlerSerializationTest.java | 114 --- .../conductor/sdk/file/FileHandlerTest.java | 76 -- .../sdk/file/LocalFileHandlerTest.java | 84 -- .../sdk/file/ManagedFileHandlerTest.java | 190 ---- .../sdk/file/StubFileStorageBackend.java | 76 -- .../storage/LocalFileStorageBackendTest.java | 88 -- docs/design/file-client.md | 92 ++ docs/file-client.md | 212 +++++ examples/README.md | 8 + .../file-storage/media-transcoder/README.md | 56 ++ .../file-storage/media-transcoder/pom.xml | 2 +- .../mediatranscoder/FileClientUsage.java | 76 ++ .../mediatranscoder/MediaTranscoderApp.java | 61 +- .../workers/ManifestWorker.java | 69 +- .../workers/ThumbnailWorker.java | 37 +- .../workers/TranscodeWorker.java | 33 +- .../workers/UploadPrimaryVideoWorker.java | 14 +- .../FileStorageIntegrationTest.java | 156 +--- 71 files changed, 2562 insertions(+), 3330 deletions(-) create mode 100644 conductor-client/src/main/java/org/conductoross/conductor/client/AzureFileTransferAdapter.java create mode 100644 conductor-client/src/main/java/org/conductoross/conductor/client/FileTransferAdapter.java rename conductor-client/src/main/java/org/conductoross/conductor/{sdk/file/FileDownloadStatus.java => client/FileTransferException.java} (51%) create mode 100644 conductor-client/src/main/java/org/conductoross/conductor/client/GcsFileTransferAdapter.java create mode 100644 conductor-client/src/main/java/org/conductoross/conductor/client/GenericHttpFileTransferAdapter.java create mode 100644 conductor-client/src/main/java/org/conductoross/conductor/client/LocalFileTransferAdapter.java rename conductor-client/src/main/java/org/conductoross/conductor/client/{model/file/StorageType.java => NonRetryableFileTransferException.java} (50%) create mode 100644 conductor-client/src/main/java/org/conductoross/conductor/client/S3FileTransferAdapter.java create mode 100644 conductor-client/src/main/java/org/conductoross/conductor/client/SignedUrlHttpTransfer.java rename conductor-client/src/main/java/org/conductoross/conductor/client/model/file/{FileHandle.java => FileMetadata.java} (76%) delete mode 100644 conductor-client/src/main/java/org/conductoross/conductor/client/storage/AzureFileStorageBackend.java delete mode 100644 conductor-client/src/main/java/org/conductoross/conductor/client/storage/GcsFileStorageBackend.java delete mode 100644 conductor-client/src/main/java/org/conductoross/conductor/client/storage/HttpBodies.java delete mode 100644 conductor-client/src/main/java/org/conductoross/conductor/client/storage/LocalFileStorageBackend.java delete mode 100644 conductor-client/src/main/java/org/conductoross/conductor/client/storage/S3FileStorageBackend.java delete mode 100644 conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandler.java delete mode 100644 conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandlerConverter.java delete mode 100644 conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandlerDeserializer.java delete mode 100644 conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandlerSerializer.java delete mode 100644 conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileStorageBackend.java delete mode 100644 conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileUploader.java delete mode 100644 conductor-client/src/main/java/org/conductoross/conductor/sdk/file/LocalFileHandler.java delete mode 100644 conductor-client/src/main/java/org/conductoross/conductor/sdk/file/ManagedFileHandler.java delete mode 100644 conductor-client/src/main/java/org/conductoross/conductor/sdk/file/WorkflowFileClient.java delete mode 100644 conductor-client/src/test/java/com/netflix/conductor/client/automator/TaskRunnerFileStorageTest.java delete mode 100644 conductor-client/src/test/java/com/netflix/conductor/common/metadata/tasks/TaskGetInputFileHandlerTest.java delete mode 100644 conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/executor/task/AnnotatedWorkerFileStorageTest.java create mode 100644 conductor-client/src/test/java/org/conductoross/conductor/client/FileTransferAdaptersTest.java delete mode 100644 conductor-client/src/test/java/org/conductoross/conductor/sdk/file/FileHandlerDeserializerTest.java delete mode 100644 conductor-client/src/test/java/org/conductoross/conductor/sdk/file/FileHandlerSerializationTest.java delete mode 100644 conductor-client/src/test/java/org/conductoross/conductor/sdk/file/FileHandlerTest.java delete mode 100644 conductor-client/src/test/java/org/conductoross/conductor/sdk/file/LocalFileHandlerTest.java delete mode 100644 conductor-client/src/test/java/org/conductoross/conductor/sdk/file/ManagedFileHandlerTest.java delete mode 100644 conductor-client/src/test/java/org/conductoross/conductor/sdk/file/StubFileStorageBackend.java delete mode 100644 conductor-client/src/test/java/org/conductoross/conductor/sdk/file/storage/LocalFileStorageBackendTest.java create mode 100644 docs/design/file-client.md create mode 100644 docs/file-client.md create mode 100644 examples/file-storage/media-transcoder/README.md create mode 100644 examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/FileClientUsage.java diff --git a/CHANGELOG.md b/CHANGELOG.md index b4752eb97..199951085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,10 +22,12 @@ All notable changes to this project will be documented in this file. ### Removed - The source-less publishing shim modules `java-sdk` (`org.conductoross:java-sdk`), `orkes-client` (`io.orkes.conductor:orkes-conductor-client`), and `orkes-spring` (`io.orkes.conductor:orkes-conductor-client-spring`) are deleted — depend on `org.conductoross:conductor-client` / `conductor-client-spring` directly instead +- File lifecycle objects and automatic worker integration (`FileHandler`, `ManagedFileHandler`, `LocalFileHandler`, `FileUploader`, `WorkflowFileClient`, `Task.getFileUploader()`, `Task.getInputFileHandler()`, and `TaskRunnerConfigurer.withFileClient()`) are removed. Workers now inject `FileClient` and explicitly upload/download opaque handle strings. ### Changed - `useEnvVariables(true)` (and the `new ApiClient()` no-arg constructor) no longer throws when `CONDUCTOR_SERVER_URL` is unset — it falls back to `AGENTSPAN_SERVER_URL`, then `http://localhost:8080/api`; the resolved URL is normalized to end in `/api`, and credentials gain the `AGENTSPAN_AUTH_KEY`/`AGENTSPAN_AUTH_SECRET` fallback +- `FileClient` now requires workflow context for every operation, returns raw `conductor://file/` strings from uploads, selects multipart automatically, and performs atomic explicit downloads. Existing workflows that exchange `{fileHandleId, fileName, contentType}` objects must be drained or upgraded in coordination because new workers exchange raw strings. See the [FileClient guide](docs/file-client.md) and [design note](docs/design/file-client.md). ## [5.1.0] diff --git a/README.md b/README.md index 60b1fc3f8..7ae6f0dd9 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ If you find [Conductor](https://github.com/conductor-oss/conductor) useful, plea * [Workers](#workers) * [Monitoring Workers](#monitoring-workers) * [Workflows](#workflows) + * [File Storage](#file-storage) * [Troubleshooting](#troubleshooting) * [AI & LLM Workflows](#ai--llm-workflows) * [Examples](#examples) @@ -390,6 +391,24 @@ workflowClient.restartWorkflow(workflowId, false); - [Workflow SDK Guide](docs/workflow_sdk.md) — Workflow-as-code documentation - [Workflow Testing](docs/testing_framework.md) — Unit testing workflows +## File Storage + +Use `FileClient` to upload and download workflow-scoped binary payloads. Workflow inputs and outputs carry only opaque `conductor://file/` strings: + +```java +FileClient files = new FileClient(client); + +String handle = files.upload( + workflowId, + Path.of("report.pdf"), + new FileUploadOptions().setContentType("application/pdf")); + +FileMetadata metadata = files.getMetadata(workflowId, handle); +Path local = files.download(workflowId, handle, Path.of("downloads/report.pdf")); +``` + +Multipart is selected automatically for supported providers. See the [FileClient guide](docs/file-client.md) for every upload/download form and the [Media Transcoder](examples/file-storage/media-transcoder/) for a complete workflow. + ## Troubleshooting **Worker stops polling or crashes:** @@ -522,6 +541,7 @@ See the [Examples Guide](examples/README.md) for the full catalog. Key examples: | [Workflow Operations](examples/src/main/java/io/orkes/conductor/sdk/examples/workflowops/) | Pause, resume, terminate workflows | `./gradlew :examples:run -PmainClass=io.orkes.conductor.sdk.examples.workflowops.Main` | | [Shipment Workflow](examples/src/main/java/com/netflix/conductor/sdk/examples/shipment/) | Real-world order processing | `./gradlew :examples:run -PmainClass=com.netflix.conductor.sdk.examples.shipment.Main` | | [Events](examples/src/main/java/com/netflix/conductor/sdk/examples/events/) | Event-driven workflows | `./gradlew :examples:run -PmainClass=com.netflix.conductor.sdk.examples.events.EventHandlerExample` | +| [FileClient Media Transcoder](examples/file-storage/media-transcoder/) | Path/stream upload, metadata, atomic download, handle passing | See example README | | [All AI examples](examples/src/main/java/io/orkes/conductor/sdk/examples/agentic/AgenticExamplesRunner.java) | All agentic/LLM workflows | `./gradlew :examples:run --args="--all"` | | [RAG Workflow](examples/src/main/java/io/orkes/conductor/sdk/examples/agentic/RagWorkflowExample.java) | RAG pipeline (index → search → answer) | `./gradlew :examples:run -PmainClass=io.orkes.conductor.sdk.examples.agentic.RagWorkflowExample` | @@ -542,6 +562,8 @@ End-to-end examples covering all APIs for each domain: |----------|-------------| | [Worker SDK](docs/worker_sdk.md) | Complete worker framework guide | | [Workflow SDK](docs/workflow_sdk.md) | Workflow-as-code documentation | +| [FileClient](docs/file-client.md) | Workflow-scoped upload, download, metadata, and worker examples | +| [FileClient Design](docs/design/file-client.md) | Transfer adapters, retry ownership, multipart, and security boundaries | | [Testing Framework](docs/testing_framework.md) | Unit testing workflows and workers | | [Conductor Client](conductor-client/README.md) | HTTP client library documentation | | [Client Metrics](conductor-client-metrics/README.md) | Prometheus metrics collection | diff --git a/conductor-client-spring/src/main/java/com/netflix/conductor/client/spring/ConductorClientAutoConfiguration.java b/conductor-client-spring/src/main/java/com/netflix/conductor/client/spring/ConductorClientAutoConfiguration.java index 28e2150e9..0dc00aa29 100644 --- a/conductor-client-spring/src/main/java/com/netflix/conductor/client/spring/ConductorClientAutoConfiguration.java +++ b/conductor-client-spring/src/main/java/com/netflix/conductor/client/spring/ConductorClientAutoConfiguration.java @@ -85,7 +85,6 @@ public TaskRunnerConfigurer taskRunnerConfigurer(Environment env, TaskClient taskClient, ClientProperties clientProperties, List workers, - Optional fileClient, Optional metricsCollector) { Map taskThreadCount = new HashMap<>(); for (Worker worker : workers) { @@ -109,7 +108,6 @@ public TaskRunnerConfigurer taskRunnerConfigurer(Environment env, .withTaskToDomain(clientProperties.getTaskToDomain()) .withShutdownGracePeriodSeconds(clientProperties.getShutdownGracePeriodSeconds()) .withTaskPollTimeout(clientProperties.getTaskPollTimeout()); - fileClient.ifPresent(builder::withFileClient); metricsCollector.ifPresent(builder::withMetricsCollector); return builder.build(); } @@ -140,6 +138,6 @@ public FileClientProperties fileClientProperties() { @ConditionalOnBean(ConductorClient.class) @ConditionalOnMissingBean public FileClient fileClient(ConductorClient client, FileClientProperties fileClientProperties) { - return new FileClient(client, fileClientProperties, null); + return new FileClient(client, fileClientProperties); } } diff --git a/conductor-client-spring/src/main/java/io/orkes/conductor/client/spring/OrkesConductorClientAutoConfiguration.java b/conductor-client-spring/src/main/java/io/orkes/conductor/client/spring/OrkesConductorClientAutoConfiguration.java index 6e23f48ff..9f8d0a53f 100644 --- a/conductor-client-spring/src/main/java/io/orkes/conductor/client/spring/OrkesConductorClientAutoConfiguration.java +++ b/conductor-client-spring/src/main/java/io/orkes/conductor/client/spring/OrkesConductorClientAutoConfiguration.java @@ -143,6 +143,6 @@ public FileClientProperties fileClientProperties() { @ConditionalOnBean(ApiClient.class) @ConditionalOnMissingBean public FileClient fileClient(ApiClient client, FileClientProperties fileClientProperties) { - return new FileClient(client, fileClientProperties, null); + return new FileClient(client, fileClientProperties); } } diff --git a/conductor-client-spring/src/test/java/com/netflix/conductor/client/spring/ConductorClientAutoConfigurationTest.java b/conductor-client-spring/src/test/java/com/netflix/conductor/client/spring/ConductorClientAutoConfigurationTest.java index 0a1c10342..ddf0623a0 100644 --- a/conductor-client-spring/src/test/java/com/netflix/conductor/client/spring/ConductorClientAutoConfigurationTest.java +++ b/conductor-client-spring/src/test/java/com/netflix/conductor/client/spring/ConductorClientAutoConfigurationTest.java @@ -32,20 +32,20 @@ void zeroConfigYieldsWorkingFileClientBean() { .run(context -> { assertThat(context).hasSingleBean(FileClient.class); FileClientProperties properties = context.getBean(FileClientProperties.class); - assertThat(properties.getLocalCacheDirectory()) - .startsWith(System.getProperty("java.io.tmpdir")); + assertThat(properties.getMultipartThreshold()) + .isEqualTo(100L * 1024 * 1024); }); } @Test - void cacheDirectoryOverrideIsRespected() { + void multipartThresholdOverrideIsRespected() { contextRunner .withPropertyValues( "conductor.client.base-path=http://localhost:8080/api", - "conductor.file-client.local-cache-directory=/tmp/custom-cache") + "conductor.file-client.multipart-threshold=4096") .run(context -> { FileClientProperties properties = context.getBean(FileClientProperties.class); - assertThat(properties.getLocalCacheDirectory()).isEqualTo("/tmp/custom-cache"); + assertThat(properties.getMultipartThreshold()).isEqualTo(4096); }); } diff --git a/conductor-client/README.md b/conductor-client/README.md index 18be60b21..f20c90933 100644 --- a/conductor-client/README.md +++ b/conductor-client/README.md @@ -90,3 +90,16 @@ public class HelloWorker implements Worker { ``` > **Note:** The full code for the above examples can be found [here](../examples/src/main/java/com/netflix/conductor/gettingstarted). + +## File uploads and downloads + +`FileClient` explicitly transfers workflow-scoped files and returns opaque `conductor://file/` strings: + +```java +FileClient files = new FileClient(client); +String handle = files.upload(workflowId, Path.of("input.csv")); +FileMetadata metadata = files.getMetadata(workflowId, handle); +Path local = files.download(workflowId, handle, Path.of("work/input.csv")); +``` + +For path metadata, caller-owned streams, automatic multipart, atomic replacement, raw workers, and annotated workers, see the [FileClient guide](../docs/file-client.md) and [media-transcoder example](../examples/file-storage/media-transcoder/). diff --git a/conductor-client/build.gradle b/conductor-client/build.gradle index 5ca0b419f..7d8b6d2d1 100644 --- a/conductor-client/build.gradle +++ b/conductor-client/build.gradle @@ -15,7 +15,7 @@ ext { apply plugin: 'publish-config' dependencies { - implementation "com.squareup.okhttp3:okhttp:${versions.okHttp}" + api "com.squareup.okhttp3:okhttp:${versions.okHttp}" // test dependencies testImplementation "org.junit.jupiter:junit-jupiter-api:${versions.junit}" diff --git a/conductor-client/src/main/java/com/netflix/conductor/client/automator/TaskRunner.java b/conductor-client/src/main/java/com/netflix/conductor/client/automator/TaskRunner.java index 2ffc1dada..c3b288cb0 100644 --- a/conductor-client/src/main/java/com/netflix/conductor/client/automator/TaskRunner.java +++ b/conductor-client/src/main/java/com/netflix/conductor/client/automator/TaskRunner.java @@ -14,7 +14,6 @@ import java.io.PrintWriter; import java.io.StringWriter; -import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; @@ -35,11 +34,6 @@ import java.util.function.Function; import org.apache.commons.lang3.concurrent.BasicThreadFactory; -import org.conductoross.conductor.client.FileClient; -import org.conductoross.conductor.sdk.file.FileHandler; -import org.conductoross.conductor.sdk.file.FileUploadOptions; -import org.conductoross.conductor.sdk.file.LocalFileHandler; -import org.conductoross.conductor.sdk.file.WorkflowFileClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -89,7 +83,6 @@ class TaskRunner { private final EventDispatcher eventDispatcher; private final LinkedBlockingQueue tasksTobeExecuted; private final boolean enableUpdateV2; - private final FileClient fileClient; private static final int LEASE_EXTEND_RETRY_COUNT = 3; private static final double LEASE_EXTEND_DURATION_FACTOR = 0.8; private final ScheduledExecutorService leaseExtendExecutorService; @@ -106,11 +99,9 @@ class TaskRunner { int taskPollTimeout, List pollFilters, EventDispatcher eventDispatcher, - boolean useVirtualThreads, - FileClient fileClient) { + boolean useVirtualThreads) { this.worker = worker; this.taskClient = taskClient; - this.fileClient = fileClient; this.updateRetryCount = updateRetryCount; this.taskPollTimeout = taskPollTimeout; this.pollingIntervalInMillis = worker.getPollingInterval(); @@ -408,11 +399,6 @@ private void executeTask(Worker worker, Task task) { return; } - // Set FileClient on task for file storage support - if (fileClient != null) { - task.setWorkflowFileClient(new WorkflowFileClient(fileClient, task.getWorkflowInstanceId())); - } - // Calculate inbound network latency try { if(task.getExecutionMetadata().getServerSendTime() != null ){ @@ -435,7 +421,6 @@ private void executeTask(Worker worker, Task task) { worker.getIdentity()); result = worker.execute(task); stopwatch.stop(); - uploadFilesToFileStorage(result, task.getWorkflowInstanceId(), task.getTaskId()); eventDispatcher.publish(new TaskExecutionCompleted(taskType, task.getTaskId(), worker.getIdentity(), stopwatch.elapsed(TimeUnit.MILLISECONDS))); // record execution end time in task task.getExecutionMetadata().setExecutionEndTime(System.currentTimeMillis()); @@ -537,26 +522,6 @@ private void updateTaskResult(int count, Task task, TaskResult result, Worker wo } } - @VisibleForTesting - void uploadFilesToFileStorage(TaskResult result, String workflowId, String taskId) { - if (fileClient == null || result.getOutputData() == null) { - return; - } - for (var entry : result.getOutputData().entrySet()) { - if (!(entry.getValue() instanceof FileHandler fh)) { - continue; - } - if (fh.getFileHandleId() == null) { - Path path = ((LocalFileHandler) fh).getPath(); - FileUploadOptions options = new FileUploadOptions() - .setContentType(fh.getContentType()) - .setTaskId(taskId); - FileHandler uploaded = fileClient.upload(workflowId, path, options); - entry.setValue(uploaded); - } - } - } - private Optional upload(TaskResult result, String taskType) { try { return taskClient.evaluateAndUploadLargePayload(result.getOutputData(), taskType); diff --git a/conductor-client/src/main/java/com/netflix/conductor/client/automator/TaskRunnerConfigurer.java b/conductor-client/src/main/java/com/netflix/conductor/client/automator/TaskRunnerConfigurer.java index c198792eb..030246917 100644 --- a/conductor-client/src/main/java/com/netflix/conductor/client/automator/TaskRunnerConfigurer.java +++ b/conductor-client/src/main/java/com/netflix/conductor/client/automator/TaskRunnerConfigurer.java @@ -21,7 +21,6 @@ import java.util.function.Consumer; import org.apache.commons.lang3.concurrent.BasicThreadFactory; -import org.conductoross.conductor.client.FileClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -56,7 +55,6 @@ public class TaskRunnerConfigurer { private final EventDispatcher eventDispatcher; private final MetricsCollector metricsCollector; private final boolean useVirtualThreads; - private final FileClient fileClient; /** * @see TaskRunnerConfigurer.Builder @@ -79,7 +77,6 @@ private TaskRunnerConfigurer(TaskRunnerConfigurer.Builder builder) { this.eventDispatcher = builder.eventDispatcher; this.metricsCollector = builder.metricsCollector; this.useVirtualThreads = builder.useVirtualThreads; - this.fileClient = builder.fileClient; builder.workers.forEach(this.workers::add); taskRunners = new LinkedList<>(); } @@ -182,8 +179,7 @@ private void startWorker(Worker worker) { taskPollTimeout, pollFilters, eventDispatcher, - useVirtualThreads, - fileClient); + useVirtualThreads); // startWorker(worker) is executed by several threads. // taskRunners.add(taskRunner) without synchronization could lead to a race condition and unpredictable behavior, // including potential null values being inserted or corrupted state. @@ -216,7 +212,6 @@ public static class Builder { private final List pollFilters = new LinkedList<>(); private final EventDispatcher eventDispatcher = new EventDispatcher<>(); private boolean useVirtualThreads; - private FileClient fileClient; /** * Returns the event dispatcher used by this builder, allowing direct @@ -388,9 +383,5 @@ public Builder withUseVirtualThreads(boolean useVirtualThreads) { return this; } - public Builder withFileClient(FileClient fileClient) { - this.fileClient = fileClient; - return this; - } } } diff --git a/conductor-client/src/main/java/com/netflix/conductor/common/metadata/tasks/Task.java b/conductor-client/src/main/java/com/netflix/conductor/common/metadata/tasks/Task.java index 56efdf3be..f441c836e 100644 --- a/conductor-client/src/main/java/com/netflix/conductor/common/metadata/tasks/Task.java +++ b/conductor-client/src/main/java/com/netflix/conductor/common/metadata/tasks/Task.java @@ -18,16 +18,10 @@ import java.util.Optional; import org.apache.commons.lang3.StringUtils; -import org.conductoross.conductor.sdk.file.FileHandler; -import org.conductoross.conductor.sdk.file.FileStorageException; -import org.conductoross.conductor.sdk.file.FileUploader; -import org.conductoross.conductor.sdk.file.ManagedFileHandler; -import org.conductoross.conductor.sdk.file.WorkflowFileClient; import com.netflix.conductor.common.metadata.workflow.WorkflowTask; import com.netflix.conductor.common.run.tasks.TypedTask; -import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.*; @Data @@ -192,32 +186,6 @@ public boolean isRetriable() { */ private Map runtimeMetadata; - @JsonIgnore - private transient WorkflowFileClient workflowFileClient; - - @JsonIgnore - public FileUploader getFileUploader() { - return workflowFileClient; - } - - public void setWorkflowFileClient(WorkflowFileClient workflowFileClient) { - this.workflowFileClient = workflowFileClient; - } - - public FileHandler getInputFileHandler(String key) { - if (workflowFileClient == null) { - throw new FileStorageException("FileClient is not configured; cannot access file input for key '" + key + "'"); - } - Object value = getInputData().get(key); - String fileHandleId = FileHandler.extractFileHandleId(value); - if (FileHandler.isFileHandleId(fileHandleId)) { - return new ManagedFileHandler(fileHandleId, workflowFileClient); - } - throw new FileStorageException( - "Expected " + FileHandler.PREFIX - + " reference for key '" + key + "', got: " + value); - } - public void setInputData(Map inputData) { if (inputData == null) { inputData = new HashMap<>(); diff --git a/conductor-client/src/main/java/com/netflix/conductor/sdk/workflow/executor/task/AnnotatedWorker.java b/conductor-client/src/main/java/com/netflix/conductor/sdk/workflow/executor/task/AnnotatedWorker.java index 7339a0ab5..3a1b5bfd6 100644 --- a/conductor-client/src/main/java/com/netflix/conductor/sdk/workflow/executor/task/AnnotatedWorker.java +++ b/conductor-client/src/main/java/com/netflix/conductor/sdk/workflow/executor/task/AnnotatedWorker.java @@ -21,11 +21,6 @@ import java.lang.reflect.Type; import java.util.*; -import org.conductoross.conductor.sdk.file.FileHandler; -import org.conductoross.conductor.sdk.file.FileHandlerDeserializer; -import org.conductoross.conductor.sdk.file.FileStorageException; -import org.conductoross.conductor.sdk.file.ManagedFileHandler; - import com.netflix.conductor.client.worker.Worker; import com.netflix.conductor.common.config.ObjectMapperProvider; import com.netflix.conductor.common.metadata.tasks.Task; @@ -140,7 +135,7 @@ private Object[] getParameters(Task task, Class[] parameterTypes, Parameter[] Class parameterType = parameterTypes[i]; values[i] = getInputValue(task, parameterType, type, paramAnnotation); } else { - values[i] = convertWithContext(task.getInputData(), parameterTypes[i], task); + values[i] = convertValue(task.getInputData(), parameterTypes[i]); } } @@ -166,7 +161,7 @@ private Object getInputValue( InputParam ip = findInputParamAnnotation(paramAnnotation); if (ip == null) { - return convertWithContext(task.getInputData(), parameterType, task); + return convertValue(task.getInputData(), parameterType); } final String name = ip.value(); @@ -175,16 +170,6 @@ private Object getInputValue( return null; } - if (parameterType == FileHandler.class) { - String fileHandleId = FileHandler.extractFileHandleId(value); - if (FileHandler.isFileHandleId(fileHandleId)) { - return new ManagedFileHandler(fileHandleId, task.getWorkflowFileClient()); - } - throw new FileStorageException( - "Expected " + FileHandler.PREFIX - + " reference for param '" + name + "', got: " + value); - } - if (List.class.isAssignableFrom(parameterType)) { List list = om.convertValue(value, List.class); if (type instanceof ParameterizedType) { @@ -192,7 +177,7 @@ private Object getInputValue( Class typeOfParameter = (Class) parameterizedType.getActualTypeArguments()[0]; List parameterizedList = new ArrayList<>(); for (Object item : list) { - parameterizedList.add(convertWithContext(item, typeOfParameter, task)); + parameterizedList.add(convertValue(item, typeOfParameter)); } return parameterizedList; @@ -200,18 +185,14 @@ private Object getInputValue( return list; } } else { - return convertWithContext(value, parameterType, task); + return convertValue(value, parameterType); } } - private Object convertWithContext(Object source, Class targetType, Task task) { + private Object convertValue(Object source, Class targetType) { JsonNode tree = om.valueToTree(source); try (JsonParser parser = tree.traverse(om)) { - return om.readerFor(targetType) - .withAttribute( - FileHandlerDeserializer.WORKFLOW_FILE_CLIENT_ATTR, - task.getWorkflowFileClient()) - .readValue(parser); + return om.readerFor(targetType).readValue(parser); } catch (IOException e) { throw new RuntimeException("Failed to bind task input to " + targetType.getName(), e); } @@ -232,15 +213,6 @@ private TaskResult setValue(Object invocationResult, TaskResult result) { return result; } - if (invocationResult instanceof FileHandler fh) { - OutputParam opAnn = - workerMethod.getAnnotatedReturnType().getAnnotation(OutputParam.class); - String key = opAnn != null ? opAnn.value() : "result"; - result.getOutputData().put(key, fh); - result.setStatus(TaskResult.Status.COMPLETED); - return result; - } - OutputParam opAnnotation = workerMethod.getAnnotatedReturnType().getAnnotation(OutputParam.class); if (opAnnotation != null) { diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/AzureFileTransferAdapter.java b/conductor-client/src/main/java/org/conductoross/conductor/client/AzureFileTransferAdapter.java new file mode 100644 index 000000000..1ff5ac348 --- /dev/null +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/AzureFileTransferAdapter.java @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Conductor Authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package org.conductoross.conductor.client; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Base64; +import java.util.Locale; +import java.util.Map; + +import okhttp3.OkHttpClient; + +final class AzureFileTransferAdapter implements FileTransferAdapter { + + private static final Map BLOCK_BLOB_HEADER = + Map.of("x-ms-blob-type", "BlockBlob"); + + private final SignedUrlHttpTransfer http; + + AzureFileTransferAdapter(OkHttpClient client) { + this.http = new SignedUrlHttpTransfer(client); + } + + @Override + public String storageType() { return "AZURE_BLOB"; } + + @Override + public void upload(String signedUrl, Path source) throws IOException { + http.upload(signedUrl, source, BLOCK_BLOB_HEADER); + } + + @Override + public void download(String signedUrl, Path destination) throws IOException { + http.download(signedUrl, destination); + } + + @Override + public boolean supportsMultipart() { return true; } + + @Override + public String uploadPart( + String signedUrl, Path source, long offset, long length, int partNumber) + throws IOException { + String blockId = Base64.getEncoder().encodeToString( + String.format(Locale.ROOT, "block-%08d", partNumber) + .getBytes(StandardCharsets.UTF_8)); + String blockUrl = http.urlWithQuery( + signedUrl, Map.of("comp", "block", "blockid", blockId)).toString(); + http.uploadRange(blockUrl, source, offset, length, Map.of()); + return blockId; + } +} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/FileClient.java b/conductor-client/src/main/java/org/conductoross/conductor/client/FileClient.java index a0acfe7c6..131fcb731 100644 --- a/conductor-client/src/main/java/org/conductoross/conductor/client/FileClient.java +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/FileClient.java @@ -14,357 +14,754 @@ import java.io.IOException; import java.io.InputStream; +import java.io.InterruptedIOException; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.ArrayList; -import java.util.EnumMap; +import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; import org.conductoross.conductor.client.model.file.FileDownloadUrlResponse; -import org.conductoross.conductor.client.model.file.FileHandle; +import org.conductoross.conductor.client.model.file.FileMetadata; import org.conductoross.conductor.client.model.file.FileUploadCompleteResponse; import org.conductoross.conductor.client.model.file.FileUploadRequest; import org.conductoross.conductor.client.model.file.FileUploadResponse; +import org.conductoross.conductor.client.model.file.FileUploadStatus; import org.conductoross.conductor.client.model.file.FileUploadUrlResponse; import org.conductoross.conductor.client.model.file.MultipartCompleteRequest; import org.conductoross.conductor.client.model.file.MultipartInitResponse; -import org.conductoross.conductor.client.model.file.StorageType; -import org.conductoross.conductor.client.storage.AzureFileStorageBackend; -import org.conductoross.conductor.client.storage.GcsFileStorageBackend; -import org.conductoross.conductor.client.storage.LocalFileStorageBackend; -import org.conductoross.conductor.client.storage.S3FileStorageBackend; -import org.conductoross.conductor.sdk.file.FileHandler; -import org.conductoross.conductor.sdk.file.FileHandlerConverter; -import org.conductoross.conductor.sdk.file.FileStorageBackend; import org.conductoross.conductor.sdk.file.FileStorageException; import org.conductoross.conductor.sdk.file.FileUploadOptions; -import org.conductoross.conductor.sdk.file.FileUploader; -import org.conductoross.conductor.sdk.file.ManagedFileHandler; -import org.conductoross.conductor.sdk.file.WorkflowFileClient; import com.netflix.conductor.client.http.ConductorClient; import com.netflix.conductor.client.http.ConductorClientRequest; import com.netflix.conductor.client.http.ConductorClientRequest.Method; -import com.netflix.conductor.sdk.workflow.executor.task.TaskContext; + +import io.orkes.conductor.client.http.ApiException; import com.fasterxml.jackson.core.type.TypeReference; +import okhttp3.OkHttpClient; /** - * Client for the Conductor file-storage REST API. - * - *

Implements {@link FileUploader} for developer-facing uploads, and exposes SDK-internal - * methods used by {@link ManagedFileHandler} for lazy metadata fetch and download. Composes a - * {@code ConductorClient} for REST calls and a {@code Map} for - * byte transfer to the underlying storage backend. - * - *

On upload, the server reports which {@link StorageType} it is configured for; the client - * looks up the matching {@link FileStorageBackend} in the map. If no backend is registered for - * the server's type the upload fails fast with a {@link FileStorageException}. + * Workflow-scoped client for Conductor file storage. * - *

REST paths use the bare {@code fileId} (no prefix) as the path variable. JSON bodies carry - * the prefixed {@code fileHandleId} ({@code conductor://file/}); conversion is handled - * via {@link FileHandler}. + *

File content is represented publicly only by an opaque + * {@code conductor://file/} string. This class owns the complete lifecycle: server records, + * signed URL refresh, retries, multipart orchestration, completion, and safe downloads. Internal + * transfer adapters perform a single provider-specific request and never retry independently. */ -public class FileClient { - - private static final TypeReference FILE_UPLOAD_RESPONSE_TYPE = new TypeReference<>() { - }; - private static final TypeReference FILE_UPLOAD_URL_RESPONSE_TYPE = new TypeReference<>() { - }; - private static final TypeReference FILE_UPLOAD_COMPLETE_RESPONSE_TYPE = new TypeReference<>() { - }; - private static final TypeReference FILE_DOWNLOAD_URL_RESPONSE_TYPE = new TypeReference<>() { - }; - private static final TypeReference FILE_HANDLE_TYPE = new TypeReference<>() { - }; - private static final TypeReference MULTIPART_INIT_RESPONSE_TYPE = new TypeReference<>() { - }; +public final class FileClient { + + static final String FILE_HANDLE_PREFIX = "conductor://file/"; + + private static final int MAX_FILENAME_LENGTH = 255; + private static final int MAX_MULTIPART_PARTS = 10_000; + private static final Pattern FILE_ID_PATTERN = + Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,255}"); + + private static final TypeReference FILE_UPLOAD_RESPONSE_TYPE = + new TypeReference<>() {}; + private static final TypeReference FILE_UPLOAD_URL_RESPONSE_TYPE = + new TypeReference<>() {}; + private static final TypeReference FILE_UPLOAD_COMPLETE_RESPONSE_TYPE = + new TypeReference<>() {}; + private static final TypeReference FILE_DOWNLOAD_URL_RESPONSE_TYPE = + new TypeReference<>() {}; + private static final TypeReference FILE_METADATA_TYPE = new TypeReference<>() {}; + private static final TypeReference MULTIPART_INIT_RESPONSE_TYPE = + new TypeReference<>() {}; private final ConductorClient client; private final FileClientProperties properties; - private final Map fileStorageBackendsByStorageType; + private final Map adapters; + private final FileTransferAdapter genericHttpAdapter; - /** - * Creates a client with default {@link FileClientProperties} and the full set of built-in - * backends (LOCAL, S3, AZURE_BLOB, GCS). - */ public FileClient(ConductorClient client) { - this(client, new FileClientProperties(), createDefaultFileStorageBackends()); + this(client, new FileClientProperties(), new OkHttpClient()); + } + + public FileClient(ConductorClient client, FileClientProperties properties) { + this(client, properties, new OkHttpClient()); } /** - * Full-args constructor. + * Creates a client with a separately configured HTTP transport for signed URLs. * - * @param properties {@code null} to use defaults - * @param fileStorageBackendsByStorageType {@code null} to use the built-in backends + *

Application and network interceptors, authenticators, cookies, cache, event listeners, + * and redirects are removed from the supplied transport before it is used. Timeouts, proxy, + * DNS, TLS, and connection-pool settings are retained. */ - public FileClient(ConductorClient client, FileClientProperties properties, Map fileStorageBackendsByStorageType) { - this.client = client; + public FileClient( + ConductorClient client, + FileClientProperties properties, + OkHttpClient transferHttpClient) { + this.client = Objects.requireNonNull(client, "client is required"); this.properties = properties == null ? new FileClientProperties() : properties; - this.fileStorageBackendsByStorageType = fileStorageBackendsByStorageType == null ? createDefaultFileStorageBackends() : fileStorageBackendsByStorageType; + OkHttpClient rawClient = transferHttpClient == null ? new OkHttpClient() : transferHttpClient; + + List builtIns = List.of( + new S3FileTransferAdapter(rawClient), + new AzureFileTransferAdapter(rawClient), + new GcsFileTransferAdapter(rawClient), + new LocalFileTransferAdapter()); + Map byType = new HashMap<>(); + builtIns.forEach(adapter -> byType.put(adapter.storageType(), adapter)); + this.adapters = Map.copyOf(byType); + this.genericHttpAdapter = new GenericHttpFileTransferAdapter(rawClient); + validateProperties(); } - // --- FileUploader (developer-facing) --- + public String upload(String workflowId, Path source) { + return upload(workflowId, source, new FileUploadOptions()); + } - /** - * Uploads a local file with the given {@link FileUploadOptions}. - * - *

{@code workflowId} is required; pass {@code null} only if you are prepared to see a - * {@link FileStorageException}. When called from inside a worker and {@code options.taskId} - * is null, {@code taskId} is auto-filled from the active {@link TaskContext}. - * - * @throws FileStorageException if {@code workflowId} is null, if no backend is registered for - * the server's storage type, or if any step of the upload fails - */ - public FileHandler upload(String workflowId, Path localFile, FileUploadOptions options) { - if (workflowId == null) { - throw new FileStorageException("workflowId is required"); - } + public String upload(String workflowId, Path source, FileUploadOptions options) { + validateWorkflowId(workflowId); + validateProperties(); + validateSource(source); + stopIfInterrupted("File upload"); + + FileUploadOptions resolved = resolveOptions(source, options); + long fileSize; try { - fillDefaults(options, localFile); - long fileSize = Files.size(localFile); - FileUploadRequest request = FileHandlerConverter.toFileUploadRequest(workflowId, options); + fileSize = Files.size(source); + } catch (IOException e) { + throw new FileStorageException("Unable to read upload source size", e); + } - FileUploadResponse response = createFileOnServer(request); + FileUploadResponse created; + try { + created = createFileOnServer(workflowId, resolved); + } catch (RuntimeException e) { + preserveInterruption(e); + throw e; + } + String fileHandleId = requireHandle(created.getFileHandleId()); + if (created.getWorkflowId() != null && !workflowId.equals(created.getWorkflowId())) { + throw new FileStorageException("The server returned a file owned by a different workflow"); + } - FileStorageBackend storageBackend = fileStorageBackendsByStorageType.get(response.getStorageType()); - if (storageBackend == null) { - throw new FileStorageException("Server uses " + response.getStorageType() - + " but SDK only supports: " + fileStorageBackendsByStorageType.keySet()); - } + FileTransferAdapter adapter = selectAdapter(created.getStorageType(), created.getUploadUrl()); + if (fileSize > properties.getMultipartThreshold() && adapter.supportsMultipart()) { + uploadMultipart(workflowId, fileHandleId, source, fileSize, adapter); + } else { + uploadSingle(workflowId, fileHandleId, created.getUploadUrl(), source, adapter); + completeUpload(workflowId, fileHandleId); + } + return fileHandleId; + } - if (options.isMultipart() && storageBackend.hasMultipartSupport()) { - uploadMultipart(response.getFileHandleId(), response.getStorageType(), localFile); - } else { - storageBackend.upload(response.getUploadUrl(), localFile); - confirmUpload(response.getFileHandleId()); - } + public String upload(String workflowId, InputStream source, FileUploadOptions options) { + validateWorkflowId(workflowId); + validateProperties(); + stopIfInterrupted("File upload"); + if (source == null) { + throw new FileStorageException("Upload source stream is required"); + } + if (options == null || options.getFileName() == null) { + throw new FileStorageException("A filename is required when uploading a stream"); + } + validateFileName(options.getFileName()); - return FileHandlerConverter.toManagedFileHandler(response, new WorkflowFileClient(this, workflowId), localFile, fileSize); + Path temporary = null; + try { + temporary = Files.createTempFile("conductor-upload-", ".tmp"); + Files.copy(source, temporary, StandardCopyOption.REPLACE_EXISTING); + return upload(workflowId, temporary, copyOptions(options)); } catch (IOException e) { - throw new FileStorageException("Upload failed for: " + localFile, e); + preserveInterruption(e); + throw new FileStorageException("Unable to buffer upload stream", e); + } finally { + deleteQuietly(temporary); } } - /** - * Buffers the stream to a temp file, then delegates to {@link #upload(String, Path, FileUploadOptions)}. - */ - public FileHandler upload(String workflowId, InputStream inputStream, FileUploadOptions options) { + public Path download(String workflowId, String fileHandleId, Path destination) { + validateWorkflowId(workflowId); + String handle = requireHandle(fileHandleId); + validateProperties(); + Path target = validateDestination(destination); + stopIfInterrupted("File download"); + + Path temporary = null; try { - Path temp = Files.createTempFile("conductor-upload-", ".tmp"); - Files.copy(inputStream, temp, StandardCopyOption.REPLACE_EXISTING); - return upload(workflowId, temp, options); + Path parent = target.getParent(); + Files.createDirectories(parent); + if (!Files.isWritable(parent)) { + throw new FileStorageException("Download destination directory is not writable"); + } + temporary = Files.createTempFile(parent, temporaryPrefix(target), ".part"); + + FileMetadata metadata = getMetadata(workflowId, handle); + Path transferTarget = temporary; + runTransferWithRetries( + "File download", + attempt -> { + FileDownloadUrlResponse response = getDownloadUrl(workflowId, handle); + FileTransferAdapter adapter = + selectAdapter(metadata.getStorageType(), response.getDownloadUrl()); + adapter.download(response.getDownloadUrl(), transferTarget); + }); + + try { + Files.move( + temporary, + target, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + throw new FileStorageException( + "Destination filesystem does not support atomic replacement", e); + } + temporary = null; + return target; + } catch (FileStorageException e) { + throw e; } catch (IOException e) { - throw new FileStorageException("Failed to write InputStream to temp file", e); + preserveInterruption(e); + throw new FileStorageException("File download failed", e); + } finally { + deleteQuietly(temporary); } } - public FileHandler upload(String workflowId, Path localFile) { - return upload(workflowId, localFile, new FileUploadOptions()); + public FileMetadata getMetadata(String workflowId, String fileHandleId) { + validateWorkflowId(workflowId); + String handle = requireHandle(fileHandleId); + stopIfInterrupted("File metadata request"); + return runServerReadWithRetries( + "File metadata request", () -> getMetadataOnce(workflowId, handle)); } - public FileHandler upload(String workflowId, InputStream inputStream) { - return upload(workflowId, inputStream, new FileUploadOptions()); + private void uploadSingle( + String workflowId, + String fileHandleId, + String initialUrl, + Path source, + FileTransferAdapter initialAdapter) { + runTransferWithRetries( + "File upload", + attempt -> { + String signedUrl = initialUrl; + FileTransferAdapter adapter = initialAdapter; + if (attempt > 0) { + signedUrl = getUploadUrl(workflowId, fileHandleId).getUploadUrl(); + adapter = selectAdapter(initialAdapter.storageType(), signedUrl); + } + adapter.upload(signedUrl, source); + }); } - /** - * Populates unset options from the local file and active {@link TaskContext}. Mutates - * {@code options} in place — SDK-internal helper only. - */ - private static void fillDefaults(FileUploadOptions options, Path localFile) { - if (options.getFileName() == null) { - options.setFileName(localFile.getFileName().toString()); + private void uploadMultipart( + String workflowId, + String fileHandleId, + Path source, + long fileSize, + FileTransferAdapter adapter) { + MultipartInitResponse initiated; + try { + initiated = initiateMultipartUpload(workflowId, fileHandleId); + } catch (RuntimeException e) { + preserveInterruption(e); + throw e; } - if (options.getContentType() == null) { - options.setContentType("application/octet-stream"); + if (initiated == null || initiated.getUploadId() == null || initiated.getUploadId().isBlank()) { + throw new FileStorageException("Server did not return a multipart upload ID"); } - if (options.getTaskId() == null) { - TaskContext ctx = TaskContext.get(); - if (ctx != null) { - options.setTaskId(ctx.getTaskId()); + String uploadId = initiated.getUploadId(); + boolean completed = false; + try { + long partSize = properties.getMultipartPartSize(); + long partCount = ((fileSize - 1) / partSize) + 1; + if (partCount > MAX_MULTIPART_PARTS) { + throw new FileStorageException( + "Multipart upload exceeds the maximum of " + MAX_MULTIPART_PARTS + " parts"); } - } - } - // --- SDK-internal (public for cross-package access, not developer-facing) --- + List completionTokens = new ArrayList<>((int) partCount); + for (int partNumber = 1; partNumber <= partCount; partNumber++) { + long offset = (partNumber - 1L) * partSize; + long length = Math.min(partSize, fileSize - offset); + int currentPart = partNumber; + completionTokens.add(runPartTransferWithRetries( + attempt -> { + String signedUrl = getPartUploadUrl( + workflowId, fileHandleId, uploadId, currentPart); + return adapter.uploadPart( + signedUrl, source, offset, length, currentPart); + })); + } - /** - * Downloads the content for a file to {@code destination}. Fetches a fresh presigned - * download URL from the server on each call and delegates the byte transfer to the - * {@link FileStorageBackend} registered for {@code storageType}. - */ - public void download(String workflowId, String fileHandleId, StorageType storageType, Path destination) { - FileDownloadUrlResponse urlResponse = getDownloadUrl(workflowId, fileHandleId); - try { - Files.createDirectories(destination.getParent()); - } catch (IOException e) { - throw new FileStorageException("Failed to create cache directory", e); + completeMultipartUpload( + workflowId, fileHandleId, uploadId, completionTokens); + completed = true; + } finally { + if (!completed) { + abortMultipartUpload(workflowId, fileHandleId, uploadId); + } } - fileStorageBackendsByStorageType.get(storageType).download(urlResponse.getDownloadUrl(), destination); } - /** - * Fetches the {@link FileHandle} metadata via {@code GET /api/files/{fileId}}. - */ - public FileHandle getMetadata(String fileHandleId) { + private FileUploadResponse createFileOnServer( + String workflowId, FileUploadOptions options) { + FileUploadRequest body = new FileUploadRequest(); + body.setWorkflowId(workflowId); + body.setFileName(options.getFileName()); + body.setContentType(options.getContentType()); + body.setTaskId(options.getTaskId()); ConductorClientRequest request = ConductorClientRequest.builder() - .method(Method.GET) - .path("/files/{fileId}") - .addPathParam("fileId", FileHandler.toFileId(fileHandleId)) + .method(Method.POST) + .path("/files") + .body(body) .build(); - return client.execute(request, FILE_HANDLE_TYPE).getData(); + FileUploadResponse response = client.execute(request, FILE_UPLOAD_RESPONSE_TYPE).getData(); + if (response == null) { + throw new FileStorageException("Server returned an empty file creation response"); + } + return response; } - public int getRetryCount() { - return properties.getRetryCount(); + private FileUploadUrlResponse getUploadUrl(String workflowId, String fileHandleId) { + ConductorClientRequest request = scopedRequest( + Method.GET, "/files/{workflowId}/{fileId}/upload-url", workflowId, fileHandleId) + .build(); + FileUploadUrlResponse response = + client.execute(request, FILE_UPLOAD_URL_RESPONSE_TYPE).getData(); + if (response == null || response.getUploadUrl() == null) { + throw new FileStorageException("Server returned an empty upload URL response"); + } + return response; } - public String getCacheDirectory() { - return properties.getLocalCacheDirectory(); + private FileDownloadUrlResponse getDownloadUrl(String workflowId, String fileHandleId) { + ConductorClientRequest request = scopedRequest( + Method.GET, "/files/{workflowId}/{fileId}/download-url", workflowId, fileHandleId) + .build(); + FileDownloadUrlResponse response = + client.execute(request, FILE_DOWNLOAD_URL_RESPONSE_TYPE).getData(); + if (response == null || response.getDownloadUrl() == null) { + throw new FileStorageException("Server returned an empty download URL response"); + } + return response; } - /** - * Confirms that byte transfer is complete via - * {@code POST /api/files/{fileId}/upload-complete}. The server verifies the object on the - * backend, reads {@code contentHash} and actual size, and transitions the record to - * {@code UPLOADED}. - */ - void confirmUpload(String fileHandleId) { - ConductorClientRequest request = ConductorClientRequest.builder() - .method(Method.POST) - .path("/files/{fileId}/upload-complete") - .addPathParam("fileId", FileHandler.toFileId(fileHandleId)) + private FileMetadata getMetadataOnce(String workflowId, String fileHandleId) { + ConductorClientRequest request = scopedRequest( + Method.GET, "/files/{workflowId}/{fileId}", workflowId, fileHandleId) .build(); - client.execute(request, FILE_UPLOAD_COMPLETE_RESPONSE_TYPE); + FileMetadata metadata = client.execute(request, FILE_METADATA_TYPE).getData(); + if (metadata == null) { + throw new FileStorageException("Server returned empty file metadata"); + } + return metadata; } - // --- Private --- + private void completeUpload(String workflowId, String fileHandleId) { + completeWithReconciliation( + workflowId, + fileHandleId, + () -> { + ConductorClientRequest request = scopedRequest( + Method.POST, + "/files/{workflowId}/{fileId}/upload-complete", + workflowId, + fileHandleId) + .build(); + client.execute(request, FILE_UPLOAD_COMPLETE_RESPONSE_TYPE); + }); + } - private FileUploadResponse createFileOnServer(FileUploadRequest uploadRequest) { - ConductorClientRequest request = ConductorClientRequest.builder() - .method(Method.POST) - .path("/files") - .body(uploadRequest) + private MultipartInitResponse initiateMultipartUpload( + String workflowId, String fileHandleId) { + ConductorClientRequest request = scopedRequest( + Method.POST, + "/files/{workflowId}/{fileId}/multipart", + workflowId, + fileHandleId) .build(); - return client.execute(request, FILE_UPLOAD_RESPONSE_TYPE).getData(); + return client.execute(request, MULTIPART_INIT_RESPONSE_TYPE).getData(); } - private FileDownloadUrlResponse getDownloadUrl(String workflowId, String fileHandleId) { - if (workflowId == null) { - throw new FileStorageException("WorkflowInstanceId is null"); - } - ConductorClientRequest request = ConductorClientRequest.builder() - .method(Method.GET) - .path("/files/{workflowId}/{fileId}/download-url") - .addPathParam("workflowId", workflowId) - .addPathParam("fileId", FileHandler.toFileId(fileHandleId)) + private String getPartUploadUrl( + String workflowId, String fileHandleId, String uploadId, int partNumber) { + ConductorClientRequest request = scopedRequest( + Method.GET, + "/files/{workflowId}/{fileId}/multipart/{uploadId}/part/{partNumber}", + workflowId, + fileHandleId) + .addPathParam("uploadId", uploadId) + .addPathParam("partNumber", partNumber) .build(); - return client.execute(request, FILE_DOWNLOAD_URL_RESPONSE_TYPE).getData(); + FileUploadUrlResponse response = + client.execute(request, FILE_UPLOAD_URL_RESPONSE_TYPE).getData(); + if (response == null || response.getUploadUrl() == null) { + throw new FileStorageException("Server returned an empty multipart upload URL response"); + } + return response.getUploadUrl(); } - private void uploadMultipart(String fileHandleId, StorageType storageType, Path localFile) { + private void completeMultipartUpload( + String workflowId, + String fileHandleId, + String uploadId, + List completionTokens) { + MultipartCompleteRequest body = new MultipartCompleteRequest(); + body.setPartETags(completionTokens); + completeWithReconciliation( + workflowId, + fileHandleId, + () -> { + ConductorClientRequest request = scopedRequest( + Method.POST, + "/files/{workflowId}/{fileId}/multipart/{uploadId}/complete", + workflowId, + fileHandleId) + .addPathParam("uploadId", uploadId) + .body(body) + .build(); + client.execute(request, FILE_UPLOAD_COMPLETE_RESPONSE_TYPE); + }); + } + + private void abortMultipartUpload( + String workflowId, String fileHandleId, String uploadId) { + if (Thread.currentThread().isInterrupted()) { + return; + } try { - MultipartInitResponse init = initiateMultipartUpload(fileHandleId); - String uploadId = init.getUploadId(); - long partSize = properties.getMultipartPartSize(); - long fileSize = Files.size(localFile); - int totalParts = (int) Math.ceil((double) fileSize / partSize); + ConductorClientRequest request = scopedRequest( + Method.DELETE, + "/files/{workflowId}/{fileId}/multipart/{uploadId}", + workflowId, + fileHandleId) + .addPathParam("uploadId", uploadId) + .build(); + client.execute(request); + } catch (RuntimeException ignored) { + preserveInterruption(ignored); + // Best effort: retain the original multipart failure. + } + } + + private void completeWithReconciliation( + String workflowId, String fileHandleId, ServerMutation mutation) { + RuntimeException last = null; + for (int attempt = 0; attempt <= properties.getRetryCount(); attempt++) { + stopIfInterrupted("File upload completion"); + try { + mutation.run(); + return; + } catch (RuntimeException e) { + if (preserveInterruption(e)) { + throw new FileStorageException("File upload completion interrupted", e); + } + if (!isRetryableServerFailure(e)) { + throw e; + } + last = e; + if (isAlreadyUploaded(workflowId, fileHandleId)) { + return; + } + } + } + throw new FileStorageException("Unable to confirm file upload completion", last); + } - List partETags = new ArrayList<>(); + private boolean isAlreadyUploaded(String workflowId, String fileHandleId) { + try { + FileMetadata metadata = getMetadataOnce(workflowId, fileHandleId); + return metadata.getUploadStatus() == FileUploadStatus.UPLOADED; + } catch (RuntimeException ignored) { + if (preserveInterruption(ignored)) { + throw ignored; + } + return false; + } + } - for (int part = 1; part <= totalParts; part++) { - long offset = (long) (part - 1) * partSize; - long length = Math.min(partSize, fileSize - offset); + private void runTransferWithRetries(String operation, TransferAttempt transfer) { + Exception last = null; + for (int attempt = 0; attempt <= properties.getRetryCount(); attempt++) { + stopIfInterrupted(operation); + try { + transfer.run(attempt); + return; + } catch (IOException e) { + if (preserveInterruption(e) + || !isRetryableTransferFailure(e) + || attempt == properties.getRetryCount()) { + throw new FileStorageException(operation + " failed", e); + } + last = e; + } catch (RuntimeException e) { + if (preserveInterruption(e)) { + throw new FileStorageException(operation + " interrupted", e); + } + if (!isRetryableServerFailure(e) || attempt == properties.getRetryCount()) { + throw e; + } + last = e; + } + } + throw new FileStorageException(operation + " failed", last); + } - String url = getPartUploadUrl(fileHandleId, uploadId, part); - String etag = fileStorageBackendsByStorageType.get(storageType).uploadPart(url, localFile, offset, length); - partETags.add(etag); + private String runPartTransferWithRetries(PartTransferAttempt transfer) { + Exception last = null; + for (int attempt = 0; attempt <= properties.getRetryCount(); attempt++) { + stopIfInterrupted("Multipart upload"); + try { + String token = transfer.run(attempt); + if (token == null || token.isBlank()) { + throw new IOException("Multipart part did not return a completion token"); + } + return token; + } catch (IOException e) { + if (preserveInterruption(e) + || !isRetryableTransferFailure(e) + || attempt == properties.getRetryCount()) { + throw new FileStorageException("Multipart part upload failed", e); + } + last = e; + } catch (RuntimeException e) { + if (preserveInterruption(e)) { + throw new FileStorageException("Multipart upload interrupted", e); + } + if (!isRetryableServerFailure(e) || attempt == properties.getRetryCount()) { + throw e; + } + last = e; } + } + throw new FileStorageException("Multipart part upload failed", last); + } - completeMultipartUpload(fileHandleId, uploadId, partETags); - } catch (IOException e) { - throw new FileStorageException("Multipart upload failed for: " + fileHandleId, e); + private T runServerReadWithRetries(String operation, ServerRead read) { + RuntimeException last = null; + for (int attempt = 0; attempt <= properties.getRetryCount(); attempt++) { + stopIfInterrupted(operation); + try { + return read.run(); + } catch (RuntimeException e) { + if (preserveInterruption(e)) { + throw new FileStorageException(operation + " interrupted", e); + } + if (!isRetryableServerFailure(e) || attempt == properties.getRetryCount()) { + throw e; + } + last = e; + } } + throw new FileStorageException(operation + " failed", last); } - private MultipartInitResponse initiateMultipartUpload(String fileHandleId) { - ConductorClientRequest request = ConductorClientRequest.builder() - .method(Method.POST) - .path("/files/{fileId}/multipart") - .addPathParam("fileId", FileHandler.toFileId(fileHandleId)) - .build(); - return client.execute(request, MULTIPART_INIT_RESPONSE_TYPE).getData(); + private FileTransferAdapter selectAdapter(String storageType, String signedUrl) { + if (storageType != null) { + FileTransferAdapter adapter = + adapters.get(storageType.trim().toUpperCase(Locale.ROOT)); + if (adapter != null) { + return adapter; + } + } + if (SignedUrlHttpTransfer.isHttpUrl(signedUrl)) { + return genericHttpAdapter; + } + throw new FileStorageException( + "No transfer adapter is available for storage type " + safeStorageType(storageType)); } - private String getPartUploadUrl(String fileHandleId, String uploadId, int partNumber) { - ConductorClientRequest request = ConductorClientRequest.builder() - .method(Method.GET) - .path("/files/{fileId}/multipart/{uploadId}/part/{partNumber}") - .addPathParam("fileId", FileHandler.toFileId(fileHandleId)) - .addPathParam("uploadId", uploadId) - .addPathParam("partNumber", String.valueOf(partNumber)) - .build(); - return client.execute(request, FILE_UPLOAD_URL_RESPONSE_TYPE).getData().getUploadUrl(); + private static String safeStorageType(String storageType) { + return storageType == null || storageType.isBlank() ? "" : storageType; } - private void completeMultipartUpload(String fileHandleId, String uploadId, - List partETags) { - MultipartCompleteRequest body = new MultipartCompleteRequest(); - body.setPartETags(partETags); - ConductorClientRequest request = ConductorClientRequest.builder() - .method(Method.POST) - .path("/files/{fileId}/multipart/{uploadId}/complete") - .addPathParam("fileId", FileHandler.toFileId(fileHandleId)) - .addPathParam("uploadId", uploadId) - .body(body) - .build(); - client.execute(request, FILE_UPLOAD_COMPLETE_RESPONSE_TYPE); + private static boolean isRetryableTransferFailure(IOException error) { + if (error instanceof NonRetryableFileTransferException) { + return false; + } + if (error instanceof FileTransferException transferError) { + int status = transferError.statusCode(); + return status == 401 + || status == 403 + || status == 408 + || status == 425 + || status == 429 + || status >= 500; + } + return true; + } + + private static boolean isRetryableServerFailure(RuntimeException error) { + if (error instanceof FileStorageException) { + return false; + } + if (error instanceof ApiException apiException) { + int status = apiException.getStatusCode(); + return status == 0 || status == 408 || status == 425 || status == 429 || status >= 500; + } + return false; } - private static Map createDefaultFileStorageBackends() { - return Map.ofEntries( - Map.entry(StorageType.LOCAL, new LocalFileStorageBackend()), - Map.entry(StorageType.S3, new S3FileStorageBackend()), - Map.entry(StorageType.AZURE_BLOB, new AzureFileStorageBackend()), - Map.entry(StorageType.GCS, new GcsFileStorageBackend()) - ); + private static boolean preserveInterruption(Throwable error) { + for (Throwable current = error; current != null; current = current.getCause()) { + if (current instanceof InterruptedException + || current instanceof InterruptedIOException) { + Thread.currentThread().interrupt(); + return true; + } + } + return Thread.currentThread().isInterrupted(); } - public static Builder builder(ConductorClient client) { - return new Builder(client); + private static void stopIfInterrupted(String operation) { + if (Thread.currentThread().isInterrupted()) { + throw new FileStorageException(operation + " interrupted", new InterruptedIOException()); + } } - /** - * Builder for configuring a {@link FileClient}. The built-in backends (LOCAL, S3, AZURE_BLOB, - * GCS) are always included; a backend registered via {@link #addStorageBackend} overrides - * the built-in backend for the same {@link StorageType}. - */ - public static class Builder { + private static ConductorClientRequest.Builder scopedRequest( + Method method, String path, String workflowId, String fileHandleId) { + return ConductorClientRequest.builder() + .method(method) + .path(path) + .addPathParam("workflowId", workflowId) + .addPathParam("fileId", toFileId(fileHandleId)); + } - private final ConductorClient client; - private FileClientProperties properties; - private final Map backends = new EnumMap<>(StorageType.class); + private FileUploadOptions resolveOptions(Path source, FileUploadOptions options) { + FileUploadOptions resolved = copyOptions(options == null ? new FileUploadOptions() : options); + if (resolved.getFileName() == null) { + Path fileName = source.getFileName(); + if (fileName == null) { + throw new FileStorageException("Upload source must have a filename"); + } + resolved.setFileName(fileName.toString()); + } + validateFileName(resolved.getFileName()); + if (resolved.getContentType() == null || resolved.getContentType().isBlank()) { + resolved.setContentType("application/octet-stream"); + } + return resolved; + } - private Builder(ConductorClient client) { - this.client = client; + private static FileUploadOptions copyOptions(FileUploadOptions source) { + return new FileUploadOptions() + .setFileName(source.getFileName()) + .setContentType(source.getContentType()) + .setTaskId(source.getTaskId()); + } + + private void validateProperties() { + if (properties.getRetryCount() < 0) { + throw new FileStorageException("retryCount must not be negative"); + } + if (properties.getMultipartPartSize() <= 0) { + throw new FileStorageException("multipartPartSize must be greater than zero"); } + if (properties.getMultipartThreshold() < 0) { + throw new FileStorageException("multipartThreshold must not be negative"); + } + } - public Builder properties(FileClientProperties properties) { - this.properties = properties; - return this; + private static void validateWorkflowId(String workflowId) { + if (workflowId == null || workflowId.isBlank()) { + throw new FileStorageException("workflowId is required"); + } + } + + private static void validateSource(Path source) { + if (source == null) { + throw new FileStorageException("Upload source is required"); + } + if (!Files.isRegularFile(source) || !Files.isReadable(source)) { + throw new FileStorageException("Upload source must be a readable regular file"); } + } - public Builder addStorageBackend(FileStorageBackend backend) { - this.backends.put(backend.getStorageType(), backend); - return this; + private static void validateFileName(String fileName) { + if (fileName == null + || fileName.isBlank() + || fileName.length() > MAX_FILENAME_LENGTH + || ".".equals(fileName) + || "..".equals(fileName) + || fileName.indexOf('/') >= 0 + || fileName.indexOf('\\') >= 0 + || fileName.matches(".*[\\x00-\\x1f:*?\"<>|].*")) { + throw new FileStorageException("Filename is unsafe"); } + } - public FileClient build() { - Map resolved = new EnumMap<>(createDefaultFileStorageBackends()); - resolved.putAll(backends); - return new FileClient(client, properties, Map.copyOf(resolved)); + private static Path validateDestination(Path destination) { + if (destination == null) { + throw new FileStorageException("Download destination is required"); + } + Path target = destination.toAbsolutePath().normalize(); + if (target.getFileName() == null || target.getParent() == null) { + throw new FileStorageException("Download destination must name a file"); } + if (Files.exists(target) + && (Files.isDirectory(target) + || Files.isSymbolicLink(target) + || !Files.isRegularFile(target))) { + throw new FileStorageException("Download destination must name a regular file"); + } + return target; + } + + private static String temporaryPrefix(Path destination) { + String prefix = "." + destination.getFileName() + "."; + return prefix.length() >= 3 ? prefix : ".file."; + } + + private static String requireHandle(String fileHandleId) { + toFileId(fileHandleId); + return fileHandleId; + } + + private static String toFileId(String fileHandleId) { + if (fileHandleId == null || !fileHandleId.startsWith(FILE_HANDLE_PREFIX)) { + throw new FileStorageException("Malformed file handle"); + } + String fileId = fileHandleId.substring(FILE_HANDLE_PREFIX.length()); + if (!FILE_ID_PATTERN.matcher(fileId).matches()) { + throw new FileStorageException("Malformed file handle"); + } + return fileId; + } + + private static void deleteQuietly(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // Best effort cleanup must not hide the primary failure. + } + } + + @FunctionalInterface + private interface TransferAttempt { + void run(int attempt) throws IOException; + } + + @FunctionalInterface + private interface PartTransferAttempt { + String run(int attempt) throws IOException; + } + + @FunctionalInterface + private interface ServerMutation { + void run(); + } + + @FunctionalInterface + private interface ServerRead { + T run(); } } diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/FileClientProperties.java b/conductor-client/src/main/java/org/conductoross/conductor/client/FileClientProperties.java index 32685a75e..8669a2dd6 100644 --- a/conductor-client/src/main/java/org/conductoross/conductor/client/FileClientProperties.java +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/FileClientProperties.java @@ -12,28 +12,21 @@ */ package org.conductoross.conductor.client; -import java.nio.file.Path; - /** * Configuration for {@link FileClient}. Populated from {@code conductor.file-client.*} * properties when using Spring auto-configuration. */ public class FileClientProperties { - /** Upload and download retry count on transient failures. Default: 3. */ + /** Number of retries after the initial signed transfer attempt. Default: 3. */ private int retryCount = 3; - /** - * Local directory under which downloaded content is cached. Default: - * {@code ${java.io.tmpdir}/conductor/files-cache}. Files are never deleted — by design - * the SDK does not clean up cached content. - */ - private String localCacheDirectory = - Path.of(getTempDirectory(), "conductor", "files-cache").toString(); - - /** Multipart upload part size in bytes. Default: 10 MiB (S3 minimum). */ + /** Multipart upload part size in bytes. Default: 10 MiB. */ private long multipartPartSize = 10L * 1024 * 1024; + /** Files larger than this value use multipart when the provider supports it. Default: 100 MiB. */ + private long multipartThreshold = 100L * 1024 * 1024; + public int getRetryCount() { return retryCount; } @@ -42,14 +35,6 @@ public void setRetryCount(int retryCount) { this.retryCount = retryCount; } - public String getLocalCacheDirectory() { - return localCacheDirectory; - } - - public void setLocalCacheDirectory(String localCacheDirectory) { - this.localCacheDirectory = localCacheDirectory; - } - public long getMultipartPartSize() { return multipartPartSize; } @@ -58,7 +43,11 @@ public void setMultipartPartSize(long multipartPartSize) { this.multipartPartSize = multipartPartSize; } - private static String getTempDirectory() { - return System.getProperty("java.io.tmpdir"); + public long getMultipartThreshold() { + return multipartThreshold; + } + + public void setMultipartThreshold(long multipartThreshold) { + this.multipartThreshold = multipartThreshold; } } diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/FileTransferAdapter.java b/conductor-client/src/main/java/org/conductoross/conductor/client/FileTransferAdapter.java new file mode 100644 index 000000000..aa5fa6311 --- /dev/null +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/FileTransferAdapter.java @@ -0,0 +1,36 @@ +/* + * Copyright 2026 Conductor Authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package org.conductoross.conductor.client; + +import java.io.IOException; +import java.nio.file.Path; + +/** Performs one provider-specific transfer attempt against a server-issued URL. */ +interface FileTransferAdapter { + + String storageType(); + + void upload(String signedUrl, Path source) throws IOException; + + void download(String signedUrl, Path destination) throws IOException; + + boolean supportsMultipart(); + + String uploadPart( + String signedUrl, + Path source, + long offset, + long length, + int partNumber) + throws IOException; +} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileDownloadStatus.java b/conductor-client/src/main/java/org/conductoross/conductor/client/FileTransferException.java similarity index 51% rename from conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileDownloadStatus.java rename to conductor-client/src/main/java/org/conductoross/conductor/client/FileTransferException.java index b998c10d6..7f43e4b7b 100644 --- a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileDownloadStatus.java +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/FileTransferException.java @@ -10,19 +10,21 @@ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the * specific language governing permissions and limitations under the License. */ -package org.conductoross.conductor.sdk.file; +package org.conductoross.conductor.client; -/** - * Per-worker download status for a {@link ManagedFileHandler}. SDK-only — not exchanged with - * the server (the server tracks upload status, not download status). - */ -public enum FileDownloadStatus { - /** No download has been attempted yet. */ - NOT_STARTED, - /** A download is in progress under {@code ManagedFileHandler.downloadLock}. */ - DOWNLOADING, - /** Content has been cached locally and is ready for repeated reads. */ - DOWNLOADED, - /** The download exhausted its retries and failed. */ - FAILED +import java.io.IOException; + +/** HTTP transfer failure whose message deliberately excludes the signed URL. */ +final class FileTransferException extends IOException { + + private final int statusCode; + + FileTransferException(String operation, int statusCode) { + super(operation + " failed with HTTP status " + statusCode); + this.statusCode = statusCode; + } + + int statusCode() { + return statusCode; + } } diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/GcsFileTransferAdapter.java b/conductor-client/src/main/java/org/conductoross/conductor/client/GcsFileTransferAdapter.java new file mode 100644 index 000000000..a979e5185 --- /dev/null +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/GcsFileTransferAdapter.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Conductor Authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package org.conductoross.conductor.client; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Map; + +import okhttp3.OkHttpClient; + +final class GcsFileTransferAdapter implements FileTransferAdapter { + + private final SignedUrlHttpTransfer http; + + GcsFileTransferAdapter(OkHttpClient client) { + this.http = new SignedUrlHttpTransfer(client); + } + + @Override + public String storageType() { return "GCS"; } + + @Override + public void upload(String signedUrl, Path source) throws IOException { + http.upload(signedUrl, source, Map.of()); + } + + @Override + public void download(String signedUrl, Path destination) throws IOException { + http.download(signedUrl, destination); + } + + @Override + public boolean supportsMultipart() { return false; } + + @Override + public String uploadPart( + String signedUrl, Path source, long offset, long length, int partNumber) + throws IOException { + throw new NonRetryableFileTransferException( + "GCS resumable multipart upload is not supported"); + } +} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/GenericHttpFileTransferAdapter.java b/conductor-client/src/main/java/org/conductoross/conductor/client/GenericHttpFileTransferAdapter.java new file mode 100644 index 000000000..8b9a119b0 --- /dev/null +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/GenericHttpFileTransferAdapter.java @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Conductor Authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package org.conductoross.conductor.client; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Map; + +import okhttp3.OkHttpClient; + +/** Conservative fallback for a server storage type newer than this SDK. */ +final class GenericHttpFileTransferAdapter implements FileTransferAdapter { + + private final SignedUrlHttpTransfer http; + + GenericHttpFileTransferAdapter(OkHttpClient client) { + this.http = new SignedUrlHttpTransfer(client); + } + + @Override + public String storageType() { return "HTTP"; } + + @Override + public void upload(String signedUrl, Path source) throws IOException { + http.upload(signedUrl, source, Map.of()); + } + + @Override + public void download(String signedUrl, Path destination) throws IOException { + http.download(signedUrl, destination); + } + + @Override + public boolean supportsMultipart() { return false; } + + @Override + public String uploadPart( + String signedUrl, Path source, long offset, long length, int partNumber) + throws IOException { + throw new NonRetryableFileTransferException( + "Generic HTTP multipart upload is not supported"); + } +} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/LocalFileTransferAdapter.java b/conductor-client/src/main/java/org/conductoross/conductor/client/LocalFileTransferAdapter.java new file mode 100644 index 000000000..2f11dd6db --- /dev/null +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/LocalFileTransferAdapter.java @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Conductor Authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package org.conductoross.conductor.client; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +final class LocalFileTransferAdapter implements FileTransferAdapter { + + @Override + public String storageType() { return "LOCAL"; } + + @Override + public void upload(String signedUrl, Path source) throws IOException { + Path destination = filePath(signedUrl, "upload"); + try { + Path parent = destination.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new IOException("Local file upload failed"); + } + } + + @Override + public void download(String signedUrl, Path destination) throws IOException { + Path source = filePath(signedUrl, "download"); + try { + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new IOException("Local file download failed"); + } + } + + @Override + public boolean supportsMultipart() { return false; } + + @Override + public String uploadPart( + String signedUrl, Path source, long offset, long length, int partNumber) + throws IOException { + throw new NonRetryableFileTransferException("Local multipart upload is not supported"); + } + + private static Path filePath(String value, String operation) throws IOException { + try { + URI uri = URI.create(value); + if (!"file".equalsIgnoreCase(uri.getScheme())) { + throw new NonRetryableFileTransferException( + "Local " + operation + " URL must use the file scheme"); + } + return Path.of(uri); + } catch (RuntimeException e) { + throw new NonRetryableFileTransferException( + "Invalid local " + operation + " URL"); + } + } +} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/StorageType.java b/conductor-client/src/main/java/org/conductoross/conductor/client/NonRetryableFileTransferException.java similarity index 50% rename from conductor-client/src/main/java/org/conductoross/conductor/client/model/file/StorageType.java rename to conductor-client/src/main/java/org/conductoross/conductor/client/NonRetryableFileTransferException.java index 824de6718..b7762ebe9 100644 --- a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/StorageType.java +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/NonRetryableFileTransferException.java @@ -10,20 +10,14 @@ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the * specific language governing permissions and limitations under the License. */ -package org.conductoross.conductor.client.model.file; +package org.conductoross.conductor.client; -/** - * Storage backend identifier. Shared vocabulary between server and SDK — the server stamps its - * configured type onto every file in {@code FileUploadResponse} and {@code FileHandle}; the SDK - * selects a matching {@code FileStorageBackend} for byte transfer. - */ -public enum StorageType { - /** AWS S3 (and S3-compatible services such as MinIO). */ - S3, - /** Azure Blob Storage. */ - AZURE_BLOB, - /** Google Cloud Storage. */ - GCS, - /** Server-local filesystem. Does not support multipart. */ - LOCAL +import java.io.IOException; + +/** Deterministic transfer contract failure that a refreshed URL cannot fix. */ +final class NonRetryableFileTransferException extends IOException { + + NonRetryableFileTransferException(String message) { + super(message); + } } diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/S3FileTransferAdapter.java b/conductor-client/src/main/java/org/conductoross/conductor/client/S3FileTransferAdapter.java new file mode 100644 index 000000000..3cff63da8 --- /dev/null +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/S3FileTransferAdapter.java @@ -0,0 +1,56 @@ +/* + * Copyright 2026 Conductor Authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package org.conductoross.conductor.client; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Map; + +import okhttp3.OkHttpClient; +final class S3FileTransferAdapter implements FileTransferAdapter { + + private final SignedUrlHttpTransfer http; + + S3FileTransferAdapter(OkHttpClient client) { + this.http = new SignedUrlHttpTransfer(client); + } + + @Override + public String storageType() { return "S3"; } + + @Override + public void upload(String signedUrl, Path source) throws IOException { + http.upload(signedUrl, source, Map.of()); + } + + @Override + public void download(String signedUrl, Path destination) throws IOException { + http.download(signedUrl, destination); + } + + @Override + public boolean supportsMultipart() { return true; } + + @Override + public String uploadPart( + String signedUrl, Path source, long offset, long length, int partNumber) + throws IOException { + String etag = http.uploadRangeReturningHeader( + signedUrl, source, offset, length, Map.of(), "ETag"); + if (etag == null || etag.isBlank()) { + throw new NonRetryableFileTransferException( + "S3 part upload response did not include an ETag"); + } + return etag; + } +} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/SignedUrlHttpTransfer.java b/conductor-client/src/main/java/org/conductoross/conductor/client/SignedUrlHttpTransfer.java new file mode 100644 index 000000000..b8d086712 --- /dev/null +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/SignedUrlHttpTransfer.java @@ -0,0 +1,229 @@ +/* + * Copyright 2026 Conductor Authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package org.conductoross.conductor.client; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Map; + +import okhttp3.Authenticator; +import okhttp3.CookieJar; +import okhttp3.EventListener; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.BufferedSink; + +/** Shared HTTP mechanics for signed URLs. No Conductor credentials are permitted on this client. */ +final class SignedUrlHttpTransfer { + + private final OkHttpClient client; + + SignedUrlHttpTransfer(OkHttpClient rawClient) { + this.client = sanitize(rawClient); + } + + static OkHttpClient sanitize(OkHttpClient rawClient) { + OkHttpClient.Builder builder = rawClient.newBuilder(); + builder.interceptors().clear(); + builder.networkInterceptors().clear(); + return builder + .authenticator(Authenticator.NONE) + .proxyAuthenticator(Authenticator.NONE) + .cookieJar(CookieJar.NO_COOKIES) + .eventListener(EventListener.NONE) + .cache(null) + .followRedirects(false) + .followSslRedirects(false) + .build(); + } + + void upload(String signedUrl, Path source, Map headers) throws IOException { + RequestBody body = RequestBody.create(source.toFile(), null); + executePut(signedUrl, body, headers, "Signed URL upload"); + } + + void uploadRange( + String signedUrl, + Path source, + long offset, + long length, + Map headers) + throws IOException { + executePut(signedUrl, rangeBody(source, offset, length), headers, "Signed URL part upload"); + } + + String uploadRangeReturningHeader( + String signedUrl, + Path source, + long offset, + long length, + Map headers, + String responseHeader) + throws IOException { + Request request = buildPutRequest(signedUrl, rangeBody(source, offset, length), headers); + try (Response response = execute(request, "Signed URL part upload")) { + return response.header(responseHeader); + } + } + + void download(String signedUrl, Path destination) throws IOException { + Request request; + try { + request = new Request.Builder().url(requireHttpUrl(signedUrl)).get().build(); + } catch (IllegalArgumentException e) { + throw new NonRetryableFileTransferException("Invalid signed download URL"); + } + + try (Response response = execute(request, "Signed URL download")) { + ResponseBody body = response.body(); + if (body == null) { + throw new NonRetryableFileTransferException( + "Signed URL download returned no response body"); + } + try (var input = body.byteStream()) { + Files.copy(input, destination, StandardCopyOption.REPLACE_EXISTING); + } catch (InterruptedIOException e) { + Thread.currentThread().interrupt(); + throw new InterruptedIOException("Signed URL download interrupted"); + } catch (IOException e) { + throw new IOException("Signed URL download failed"); + } + } + } + + HttpUrl urlWithQuery(String signedUrl, Map queryParameters) throws IOException { + try { + HttpUrl.Builder builder = requireHttpUrl(signedUrl).newBuilder(); + queryParameters.forEach( + (name, value) -> { + builder.removeAllQueryParameters(name); + builder.addQueryParameter(name, value); + }); + return builder.build(); + } catch (IllegalArgumentException e) { + throw new NonRetryableFileTransferException("Invalid signed upload URL"); + } + } + + static boolean isHttpUrl(String value) { + if (value == null) { + return false; + } + try { + requireHttpUrl(value); + return true; + } catch (IllegalArgumentException ignored) { + return false; + } + } + + private void executePut( + String signedUrl, + RequestBody body, + Map headers, + String operation) + throws IOException { + Request request = buildPutRequest(signedUrl, body, headers); + try (Response ignored = execute(request, operation)) { + // A successful status is the complete response for PUT transfers. + } + } + + private Request buildPutRequest( + String signedUrl, RequestBody body, Map headers) throws IOException { + try { + Request.Builder builder = new Request.Builder().url(requireHttpUrl(signedUrl)).put(body); + headers.forEach(builder::header); + return builder.build(); + } catch (IllegalArgumentException e) { + throw new NonRetryableFileTransferException("Invalid signed upload URL"); + } + } + + private Response execute(Request request, String operation) throws IOException { + try { + Response response = client.newCall(request).execute(); + if (!response.isSuccessful()) { + int statusCode = response.code(); + response.close(); + throw new FileTransferException(operation, statusCode); + } + return response; + } catch (InterruptedIOException e) { + Thread.currentThread().interrupt(); + throw new InterruptedIOException(operation + " interrupted"); + } catch (FileTransferException e) { + throw e; + } catch (NonRetryableFileTransferException e) { + throw e; + } catch (IOException e) { + throw new IOException(operation + " failed"); + } + } + + private static HttpUrl requireHttpUrl(String value) { + HttpUrl url = HttpUrl.get(value); + String scheme = url.scheme(); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + throw new IllegalArgumentException("Signed URL must use HTTP(S)"); + } + return url; + } + + private static RequestBody rangeBody(Path source, long offset, long length) { + return new RequestBody() { + @Override + public okhttp3.MediaType contentType() { + return null; + } + + @Override + public long contentLength() { + return length; + } + + @Override + public void writeTo(BufferedSink sink) throws IOException { + try (FileChannel channel = FileChannel.open(source, StandardOpenOption.READ)) { + channel.position(offset); + ByteBuffer buffer = ByteBuffer.allocate(8192); + long remaining = length; + while (remaining > 0) { + buffer.clear(); + buffer.limit((int) Math.min(buffer.capacity(), remaining)); + int read = channel.read(buffer); + if (read < 0) { + throw new NonRetryableFileTransferException( + "Source ended before the requested range was read"); + } + if (read == 0) { + continue; + } + sink.write(buffer.array(), 0, read); + remaining -= read; + } + } + } + }; + } +} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileDownloadUrlResponse.java b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileDownloadUrlResponse.java index 9e74cefcc..9ac6a70cb 100644 --- a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileDownloadUrlResponse.java +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileDownloadUrlResponse.java @@ -13,8 +13,9 @@ package org.conductoross.conductor.client.model.file; /** - * Response to {@code GET /api/files/{fileId}/download-url} — a freshly issued presigned - * download URL. Requires the underlying file to be in {@link FileUploadStatus#UPLOADED}. + * Response to {@code GET /api/files/{workflowId}/{fileId}/download-url} — a freshly issued + * presigned download URL. Requires the underlying file to be in + * {@link FileUploadStatus#UPLOADED}. */ public class FileDownloadUrlResponse { diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileHandle.java b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileMetadata.java similarity index 76% rename from conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileHandle.java rename to conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileMetadata.java index b29dcbc91..91da69819 100644 --- a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileHandle.java +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileMetadata.java @@ -12,61 +12,41 @@ */ package org.conductoross.conductor.client.model.file; -/** - * Client-side mirror of the server's {@code FileHandle} — full file metadata returned by - * {@code GET /api/files/{fileId}}. Does not expose the server-internal {@code storagePath}. - */ -public class FileHandle { +/** Metadata for an opaque {@code conductor://file/} handle. */ +public class FileMetadata { - /** Prefixed handle: {@code conductor://file/}. */ private String fileHandleId; private String fileName; private String contentType; private long fileSize; - /** - * Content hash from the storage provider (S3 ETag / Azure Content-MD5 / GCS md5Hash). - * {@code null} for the local backend and before upload is confirmed. - */ private String contentHash; - private StorageType storageType; + private String storageType; private FileUploadStatus uploadStatus; private String workflowId; private String taskId; - /** Epoch millis. */ private long createdAt; - /** Epoch millis. */ private long updatedAt; public String getFileHandleId() { return fileHandleId; } public void setFileHandleId(String fileHandleId) { this.fileHandleId = fileHandleId; } - public String getFileName() { return fileName; } public void setFileName(String fileName) { this.fileName = fileName; } - public String getContentType() { return contentType; } public void setContentType(String contentType) { this.contentType = contentType; } - public long getFileSize() { return fileSize; } public void setFileSize(long fileSize) { this.fileSize = fileSize; } - public String getContentHash() { return contentHash; } public void setContentHash(String contentHash) { this.contentHash = contentHash; } - - public StorageType getStorageType() { return storageType; } - public void setStorageType(StorageType storageType) { this.storageType = storageType; } - + public String getStorageType() { return storageType; } + public void setStorageType(String storageType) { this.storageType = storageType; } public FileUploadStatus getUploadStatus() { return uploadStatus; } public void setUploadStatus(FileUploadStatus uploadStatus) { this.uploadStatus = uploadStatus; } - public String getWorkflowId() { return workflowId; } public void setWorkflowId(String workflowId) { this.workflowId = workflowId; } - public String getTaskId() { return taskId; } public void setTaskId(String taskId) { this.taskId = taskId; } - public long getCreatedAt() { return createdAt; } public void setCreatedAt(long createdAt) { this.createdAt = createdAt; } - public long getUpdatedAt() { return updatedAt; } public void setUpdatedAt(long updatedAt) { this.updatedAt = updatedAt; } } diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadCompleteResponse.java b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadCompleteResponse.java index 16069de77..3b6d82a12 100644 --- a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadCompleteResponse.java +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadCompleteResponse.java @@ -13,7 +13,7 @@ package org.conductoross.conductor.client.model.file; /** - * Response to {@code POST /api/files/{fileId}/upload-complete}. Status is + * Response to {@code POST /api/files/{workflowId}/{fileId}/upload-complete}. Status is * {@link FileUploadStatus#UPLOADED} on success; {@code contentHash} is the backend-reported * hash (or {@code null} for backends that do not expose one). */ diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadResponse.java b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadResponse.java index 5bc93a965..6e99a98a9 100644 --- a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadResponse.java +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadResponse.java @@ -15,7 +15,8 @@ /** * Response to {@code POST /api/files}. Carries the newly assigned {@code fileHandleId} plus a * presigned upload URL and its expiry. Status is {@link FileUploadStatus#UPLOADING} at this - * point; the client confirms completion via {@code POST /api/files/{fileId}/upload-complete}. + * point; the client confirms completion via {@code POST + * /api/files/{workflowId}/{fileId}/upload-complete}. */ public class FileUploadResponse { @@ -23,35 +24,29 @@ public class FileUploadResponse { private String fileHandleId; private String fileName; private String contentType; - private StorageType storageType; + private String storageType; private FileUploadStatus uploadStatus; private String uploadUrl; - /** Epoch millis. */ private long uploadUrlExpiresAt; - /** Epoch millis. */ private long createdAt; + private String workflowId; public String getFileHandleId() { return fileHandleId; } public void setFileHandleId(String fileHandleId) { this.fileHandleId = fileHandleId; } - public String getFileName() { return fileName; } public void setFileName(String fileName) { this.fileName = fileName; } - public String getContentType() { return contentType; } public void setContentType(String contentType) { this.contentType = contentType; } - - public StorageType getStorageType() { return storageType; } - public void setStorageType(StorageType storageType) { this.storageType = storageType; } - + public String getStorageType() { return storageType; } + public void setStorageType(String storageType) { this.storageType = storageType; } public FileUploadStatus getUploadStatus() { return uploadStatus; } public void setUploadStatus(FileUploadStatus uploadStatus) { this.uploadStatus = uploadStatus; } - public String getUploadUrl() { return uploadUrl; } public void setUploadUrl(String uploadUrl) { this.uploadUrl = uploadUrl; } - public long getUploadUrlExpiresAt() { return uploadUrlExpiresAt; } public void setUploadUrlExpiresAt(long uploadUrlExpiresAt) { this.uploadUrlExpiresAt = uploadUrlExpiresAt; } - public long getCreatedAt() { return createdAt; } public void setCreatedAt(long createdAt) { this.createdAt = createdAt; } + public String getWorkflowId() { return workflowId; } + public void setWorkflowId(String workflowId) { this.workflowId = workflowId; } } diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadUrlResponse.java b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadUrlResponse.java index 925b5c8f1..90c5e689c 100644 --- a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadUrlResponse.java +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadUrlResponse.java @@ -13,8 +13,9 @@ package org.conductoross.conductor.client.model.file; /** - * Response to {@code GET /api/files/{fileId}/upload-url} — a freshly issued presigned upload - * URL, used on retry when the original URL from {@code FileUploadResponse} has expired. + * Response to {@code GET /api/files/{workflowId}/{fileId}/upload-url} — a freshly issued + * presigned upload URL, used on retry when the original URL from {@code FileUploadResponse} has + * expired. */ public class FileUploadUrlResponse { diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/MultipartCompleteRequest.java b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/MultipartCompleteRequest.java index dab2b5828..0155d2c2d 100644 --- a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/MultipartCompleteRequest.java +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/MultipartCompleteRequest.java @@ -14,12 +14,10 @@ import java.util.List; -import org.conductoross.conductor.sdk.file.FileStorageBackend; - /** - * Payload for {@code POST /api/files/{fileId}/multipart/{uploadId}/complete}. {@code partETags} - * is the ordered list of ETags (or backend-equivalent identifiers) returned by each - * {@link FileStorageBackend#uploadPart uploadPart} call. + * Payload for {@code POST /api/files/{workflowId}/{fileId}/multipart/{uploadId}/complete}. + * {@code partETags} is the ordered list of provider completion tokens returned by each part + * transfer. */ public class MultipartCompleteRequest { diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/MultipartInitResponse.java b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/MultipartInitResponse.java index e54a3dc27..3e91e1a64 100644 --- a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/MultipartInitResponse.java +++ b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/MultipartInitResponse.java @@ -12,12 +12,15 @@ */ package org.conductoross.conductor.client.model.file; -/** Response to {@code POST /api/files/{fileId}/multipart} — initiates a multipart upload. */ +/** + * Response to {@code POST /api/files/{workflowId}/{fileId}/multipart} — initiates a multipart + * upload. + */ public class MultipartInitResponse { /** Prefixed handle: {@code conductor://file/}. */ private String fileHandleId; - /** Backend-specific multipart identifier (S3 {@code UploadId}, GCS resumable session ID). */ + /** Backend-specific multipart identifier. */ private String uploadId; public String getFileHandleId() { return fileHandleId; } diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/storage/AzureFileStorageBackend.java b/conductor-client/src/main/java/org/conductoross/conductor/client/storage/AzureFileStorageBackend.java deleted file mode 100644 index 21e987916..000000000 --- a/conductor-client/src/main/java/org/conductoross/conductor/client/storage/AzureFileStorageBackend.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.client.storage; - -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; - -import org.conductoross.conductor.client.model.file.StorageType; -import org.conductoross.conductor.sdk.file.FileStorageBackend; -import org.conductoross.conductor.sdk.file.FileStorageException; - -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; - -/** {@link FileStorageBackend} for Azure Blob Storage. Supports multipart via resumable block uploads. */ -public class AzureFileStorageBackend implements FileStorageBackend { - - private static final String BLOB_TYPE_HEADER = "x-ms-blob-type"; - private static final String BLOB_TYPE_VALUE = "BlockBlob"; - private static final String X_MS_CONTENT_MD_5 = "x-ms-content-md5"; - - @Override - public StorageType getStorageType() { return StorageType.AZURE_BLOB; } - - @Override - public void upload(String url, Path localFile) { - Request request = new Request.Builder() - .url(url) - .header(BLOB_TYPE_HEADER, BLOB_TYPE_VALUE) - .put(RequestBody.create(localFile.toFile(), null)) - .build(); - try (Response response = HttpBodies.CLIENT.newCall(request).execute()) { - if (!response.isSuccessful()) { - throw new FileStorageException("Azure upload failed with status: " + response.code()); - } - } catch (FileStorageException e) { - throw e; - } catch (Exception e) { - throw new FileStorageException("Azure upload failed: " + url, e); - } - } - - @Override - public void upload(String url, InputStream inputStream, long contentLength) { - Request request = new Request.Builder() - .url(url) - .header(BLOB_TYPE_HEADER, BLOB_TYPE_VALUE) - .put(HttpBodies.stream(inputStream, contentLength)) - .build(); - try (Response response = HttpBodies.CLIENT.newCall(request).execute()) { - if (!response.isSuccessful()) { - throw new FileStorageException("Azure stream upload failed with status: " + response.code()); - } - } catch (FileStorageException e) { - throw e; - } catch (Exception e) { - throw new FileStorageException("Azure stream upload failed: " + url, e); - } - } - - @Override - public void download(String url, Path destination) { - Request request = new Request.Builder().url(url).get().build(); - try (Response response = HttpBodies.CLIENT.newCall(request).execute()) { - if (!response.isSuccessful()) { - throw new FileStorageException("Azure download failed with status: " + response.code()); - } - Files.createDirectories(destination.getParent()); - try (InputStream in = response.body().byteStream()) { - Files.copy(in, destination, StandardCopyOption.REPLACE_EXISTING); - } - } catch (FileStorageException e) { - throw e; - } catch (Exception e) { - throw new FileStorageException("Azure download failed: " + url, e); - } - } - - @Override - public String uploadPart(String url, Path localFile, long offset, long length) { - Request request = new Request.Builder() - .url(url) - .header(BLOB_TYPE_HEADER, BLOB_TYPE_VALUE) - .put(HttpBodies.range(localFile, offset, length)) - .build(); - try (Response response = HttpBodies.CLIENT.newCall(request).execute()) { - if (!response.isSuccessful()) { - throw new FileStorageException("Azure part upload failed with status: " + response.code()); - } - return response.header(X_MS_CONTENT_MD_5); - } catch (FileStorageException e) { - throw e; - } catch (Exception e) { - throw new FileStorageException("Azure part upload failed: " + url, e); - } - } -} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/storage/GcsFileStorageBackend.java b/conductor-client/src/main/java/org/conductoross/conductor/client/storage/GcsFileStorageBackend.java deleted file mode 100644 index fc0c05589..000000000 --- a/conductor-client/src/main/java/org/conductoross/conductor/client/storage/GcsFileStorageBackend.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.client.storage; - -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; - -import org.conductoross.conductor.client.model.file.StorageType; -import org.conductoross.conductor.sdk.file.FileStorageBackend; -import org.conductoross.conductor.sdk.file.FileStorageException; - -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; - -/** {@link FileStorageBackend} for Google Cloud Storage. Supports multipart via resumable sessions. */ -public class GcsFileStorageBackend implements FileStorageBackend { - - @Override - public StorageType getStorageType() { return StorageType.GCS; } - - @Override - public void upload(String url, Path localFile) { - Request request = new Request.Builder() - .url(url) - .put(RequestBody.create(localFile.toFile(), null)) - .build(); - try (Response response = HttpBodies.CLIENT.newCall(request).execute()) { - if (!response.isSuccessful()) { - throw new FileStorageException("GCS upload failed with status: " + response.code()); - } - } catch (FileStorageException e) { - throw e; - } catch (Exception e) { - throw new FileStorageException("GCS upload failed: " + url, e); - } - } - - @Override - public void upload(String url, InputStream inputStream, long contentLength) { - Request request = new Request.Builder() - .url(url) - .put(HttpBodies.stream(inputStream, contentLength)) - .build(); - try (Response response = HttpBodies.CLIENT.newCall(request).execute()) { - if (!response.isSuccessful()) { - throw new FileStorageException("GCS upload failed with status: " + response.code()); - } - } catch (FileStorageException e) { - throw e; - } catch (Exception e) { - throw new FileStorageException("GCS stream upload failed: " + url, e); - } - } - - @Override - public void download(String url, Path destination) { - Request request = new Request.Builder().url(url).get().build(); - try (Response response = HttpBodies.CLIENT.newCall(request).execute()) { - if (!response.isSuccessful()) { - throw new FileStorageException("GCS download failed with status: " + response.code()); - } - Files.createDirectories(destination.getParent()); - try (InputStream in = response.body().byteStream()) { - Files.copy(in, destination, StandardCopyOption.REPLACE_EXISTING); - } - } catch (FileStorageException e) { - throw e; - } catch (Exception e) { - throw new FileStorageException("GCS download failed: " + url, e); - } - } - - @Override - public String uploadPart(String url, Path localFile, long offset, long length) { - // Unsupported by design - throw new UnsupportedOperationException("Not supported."); - } - - @Override - public boolean hasMultipartSupport() { - return false; - } -} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/storage/HttpBodies.java b/conductor-client/src/main/java/org/conductoross/conductor/client/storage/HttpBodies.java deleted file mode 100644 index eb00706f5..000000000 --- a/conductor-client/src/main/java/org/conductoross/conductor/client/storage/HttpBodies.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.client.storage; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; - -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.RequestBody; -import okio.BufferedSink; -import okio.Okio; -import okio.Source; - -final class HttpBodies { - - static final OkHttpClient CLIENT = new OkHttpClient(); - - private HttpBodies() {} - - static RequestBody stream(InputStream inputStream, long contentLength) { - return new RequestBody() { - @Override public MediaType contentType() { return null; } - @Override public long contentLength() { return contentLength; } - @Override public void writeTo(BufferedSink sink) throws IOException { - try (Source source = Okio.source(inputStream)) { - sink.writeAll(source); - } - } - }; - } - - static RequestBody range(Path file, long offset, long length) { - return new RequestBody() { - @Override public MediaType contentType() { return null; } - @Override public long contentLength() { return length; } - @Override public void writeTo(BufferedSink sink) throws IOException { - try (InputStream in = Files.newInputStream(file)) { - long skipped = 0; - while (skipped < offset) { - long s = in.skip(offset - skipped); - if (s <= 0) break; - skipped += s; - } - byte[] buf = new byte[8192]; - long remaining = length; - while (remaining > 0) { - int n = in.read(buf, 0, (int) Math.min(buf.length, remaining)); - if (n < 0) break; - sink.write(buf, 0, n); - remaining -= n; - } - } - } - }; - } -} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/storage/LocalFileStorageBackend.java b/conductor-client/src/main/java/org/conductoross/conductor/client/storage/LocalFileStorageBackend.java deleted file mode 100644 index 81c078bcb..000000000 --- a/conductor-client/src/main/java/org/conductoross/conductor/client/storage/LocalFileStorageBackend.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.client.storage; - -import java.io.IOException; -import java.io.InputStream; -import java.net.URI; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; - -import org.conductoross.conductor.client.model.file.StorageType; -import org.conductoross.conductor.sdk.file.FileStorageBackend; -import org.conductoross.conductor.sdk.file.FileStorageException; - -/** - * {@link FileStorageBackend} for the server-local filesystem (development / zero-infra mode). - * Does NOT support multipart — {@link #hasMultipartSupport()} returns {@code false}. - */ -public class LocalFileStorageBackend implements FileStorageBackend { - - @Override - public StorageType getStorageType() { - return StorageType.LOCAL; - } - - @Override - public void upload(String url, Path localFile) { - try { - Path dest = Path.of(URI.create(url)); - Files.createDirectories(dest.getParent()); - Files.copy(localFile, dest, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException e) { - throw new FileStorageException("Local upload failed: " + localFile + " → " + url, e); - } - } - - @Override - public void upload(String url, InputStream inputStream, long contentLength) { - try { - Path dest = Path.of(URI.create(url)); - Files.createDirectories(dest.getParent()); - Files.copy(inputStream, dest, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException e) { - throw new FileStorageException("Local upload from stream failed: " + url, e); - } - } - - @Override - public void download(String url, Path destination) { - try { - Path source = Path.of(URI.create(url)); - Files.createDirectories(destination.getParent()); - Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException e) { - throw new FileStorageException("Local download failed: " + url, e); - } - } - - @Override - public String uploadPart(String url, Path localFile, long offset, long length) { - // Unsupported by design - throw new UnsupportedOperationException("Not supported."); - } - - @Override - public boolean hasMultipartSupport() { - return false; - } -} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/storage/S3FileStorageBackend.java b/conductor-client/src/main/java/org/conductoross/conductor/client/storage/S3FileStorageBackend.java deleted file mode 100644 index 66b8602bd..000000000 --- a/conductor-client/src/main/java/org/conductoross/conductor/client/storage/S3FileStorageBackend.java +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.client.storage; - -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; - -import org.conductoross.conductor.client.model.file.StorageType; -import org.conductoross.conductor.sdk.file.FileStorageBackend; -import org.conductoross.conductor.sdk.file.FileStorageException; - -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; - -/** {@link FileStorageBackend} for AWS S3 and S3-compatible services (e.g. MinIO). Supports multipart. */ -public class S3FileStorageBackend implements FileStorageBackend { - - private static final String E_TAG = "ETag"; - - @Override - public StorageType getStorageType() { return StorageType.S3; } - - @Override - public void upload(String url, Path localFile) { - Request request = new Request.Builder() - .url(url) - .put(RequestBody.create(localFile.toFile(), null)) - .build(); - try (Response response = HttpBodies.CLIENT.newCall(request).execute()) { - if (!response.isSuccessful()) { - throw new FileStorageException("S3 upload failed with status: " + response.code()); - } - } catch (FileStorageException e) { - throw e; - } catch (Exception e) { - throw new FileStorageException("S3 upload failed: " + url, e); - } - } - - @Override - public void upload(String url, InputStream inputStream, long contentLength) { - Request request = new Request.Builder() - .url(url) - .put(HttpBodies.stream(inputStream, contentLength)) - .build(); - try (Response response = HttpBodies.CLIENT.newCall(request).execute()) { - if (!response.isSuccessful()) { - throw new FileStorageException("S3 upload failed with status: " + response.code()); - } - } catch (FileStorageException e) { - throw e; - } catch (Exception e) { - throw new FileStorageException("S3 stream upload failed: " + url, e); - } - } - - @Override - public void download(String url, Path destination) { - Request request = new Request.Builder().url(url).get().build(); - try (Response response = HttpBodies.CLIENT.newCall(request).execute()) { - if (!response.isSuccessful()) { - throw new FileStorageException("S3 download failed with status: " + response.code()); - } - Files.createDirectories(destination.getParent()); - try (InputStream in = response.body().byteStream()) { - Files.copy(in, destination, StandardCopyOption.REPLACE_EXISTING); - } - } catch (FileStorageException e) { - throw e; - } catch (Exception e) { - throw new FileStorageException("S3 download failed: " + url, e); - } - } - - @Override - public String uploadPart(String url, Path localFile, long offset, long length) { - Request request = new Request.Builder() - .url(url) - .put(HttpBodies.range(localFile, offset, length)) - .build(); - try (Response response = HttpBodies.CLIENT.newCall(request).execute()) { - if (response.isSuccessful()) { - return response.header(E_TAG); - } - throw new FileStorageException("S3 part upload failed with status: " + response.code()); - } catch (FileStorageException e) { - throw e; - } catch (Exception e) { - throw new FileStorageException("S3 part upload failed: " + url, e); - } - } -} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandler.java b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandler.java deleted file mode 100644 index c4b99b658..000000000 --- a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandler.java +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.sdk.file; - -import java.io.InputStream; -import java.nio.file.Path; -import java.util.Map; - -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; - -/** - * Abstraction over a file passed into or out of a Conductor worker. - * - *

A {@code FileHandler} is used in two directions: - *

    - *
  • Input — a file referenced by a workflow task. The SDK resolves the incoming - * {@link #getFileHandleId() fileHandleId} string (see format below) into a - * {@link ManagedFileHandler} that downloads the content lazily on first read.
  • - *
  • Output — a local file a worker wants to publish. Create one via - * {@link #fromLocalFile(Path)}; when the worker returns, the task runner uploads the - * file and substitutes the resulting {@code fileHandleId} into the task output so - * downstream tasks can consume it.
  • - *
- */ -@JsonSerialize(using = FileHandlerSerializer.class) -@JsonDeserialize(using = FileHandlerDeserializer.class) -public interface FileHandler { - - /** The {@code conductor://file/} prefix. */ - public static final String PREFIX = "conductor://file/"; - - /** - * Returns the Conductor-assigned identifier for this file, or {@code null} if the file - * has not been uploaded yet. - * - *

The id is an opaque string in the form - * {@code conductor://file/} — the {@code conductor://file/} prefix (see {@link #PREFIX}) - * is what lets the SDK distinguish a file reference from any other string value in task - * input/output maps and auto-convert it to a {@code FileHandler} for worker parameters. - * - *

Lifecycle: - *

    - *
  • A {@link LocalFileHandler} built via {@link #fromLocalFile(Path)} returns - * {@code null} here until the task runner uploads it. Do not read this value before - * the worker returns — read it from the uploaded handler the runner produces (e.g. - * in tests) or rely on the runner substituting it into the task output automatically.
  • - *
  • A {@link ManagedFileHandler} (produced when a worker receives a file input, or - * returned by {@code FileClient.upload(...)}) always has a non-null id.
  • - *
- * - *

The returned value is safe to store in workflow input/output maps; any task that - * consumes it will see a {@code FileHandler} injected by the SDK. - * - * @return the {@code conductor://file/} identifier, or {@code null} for a local file - * that has not yet been uploaded - */ - String getFileHandleId(); - - InputStream getInputStream(); - - String getFileName(); - - String getContentType(); - - long getFileSize(); - - default String getFileId() { - return toFileId(getFileHandleId()); - } - - /** - * Creates a handler for a local file to be uploaded when the worker returns. - * Content type defaults to {@code application/octet-stream}. - */ - static FileHandler fromLocalFile(Path path) { - return new LocalFileHandler(path, "application/octet-stream"); - } - - /** - * Creates a handler for a local file to be uploaded when the worker returns, using the - * given content type. - */ - static FileHandler fromLocalFile(Path path, String contentType) { - return new LocalFileHandler(path, contentType); - } - - /** Wraps a bare {@code fileId} with the prefix. Returns the input unchanged if already prefixed. */ - static String toFileHandleId(String fileId) { - return fileId.startsWith(PREFIX) ? fileId : PREFIX + fileId; - } - - /** Strips the prefix from a {@code fileHandleId}. Returns the input unchanged if the prefix is absent. */ - static String toFileId(String fileHandleId) { - return fileHandleId.startsWith(PREFIX) ? fileHandleId.substring(PREFIX.length()) : fileHandleId; - } - - /** {@code true} if {@code value} is a non-null String starting with the prefix. */ - static boolean isFileHandleId(Object value) { - return value instanceof String s && s.startsWith(PREFIX); - } - - /** - * Extracts a {@code fileHandleId} from a task input value. Accepts: - *

    - *
  • a {@link String} — returned unchanged;
  • - *
  • a {@link Map} carrying a {@code fileHandleId} entry — that entry's value is returned - * (the serialized JSON form produced by {@link FileHandlerSerializer});
  • - *
- * and returns {@code null} for anything else. The returned string is not validated against - * {@link #PREFIX}; callers should pass it to {@link #isFileHandleId} to confirm. - */ - static String extractFileHandleId(Object value) { - if (value instanceof String s) { - return s; - } - if (value instanceof Map m && m.get("fileHandleId") instanceof String s) { - return s; - } - return null; - } -} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandlerConverter.java b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandlerConverter.java deleted file mode 100644 index 12a7de5a1..000000000 --- a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandlerConverter.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.sdk.file; - -import java.nio.file.Path; - -import org.conductoross.conductor.client.model.file.FileUploadRequest; -import org.conductoross.conductor.client.model.file.FileUploadResponse; - -/** Conversion helpers between the file-storage DTOs and the internal {@link ManagedFileHandler}. */ -public class FileHandlerConverter { - - /** - * Builds a {@link ManagedFileHandler} from a completed upload response. The handler already - * has its metadata and {@code localPath} populated — no server round-trip needed on first - * access. - */ - public static ManagedFileHandler toManagedFileHandler(FileUploadResponse response, WorkflowFileClient workflowFileClient, Path localPath, long fileSize) { - ManagedFileHandler handler = new ManagedFileHandler(response.getFileHandleId(), workflowFileClient); - handler.setFileName(response.getFileName()); - handler.setContentType(response.getContentType()); - handler.setFileSize(fileSize); - handler.setStorageType(response.getStorageType()); - handler.setLocalPath(localPath); - return handler; - } - - /** Builds the {@link FileUploadRequest} payload for {@code POST /api/files}. */ - public static FileUploadRequest toFileUploadRequest(String workflowId, FileUploadOptions options) { - FileUploadRequest request = new FileUploadRequest(); - request.setFileName(options.getFileName()); - request.setContentType(options.getContentType()); - request.setWorkflowId(workflowId); - request.setTaskId(options.getTaskId()); - return request; - } -} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandlerDeserializer.java b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandlerDeserializer.java deleted file mode 100644 index 7f7792842..000000000 --- a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandlerDeserializer.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.sdk.file; - -import java.io.IOException; - -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; - -/** - * Deserializes a {@link FileHandler} field on any POJO when the source JSON is either the raw - * {@code conductor://file/} string or a {@link FileHandlerSerializer}-shaped JSON object. - * - *

A {@link WorkflowFileClient} must be supplied via the {@code DeserializationContext} - * attribute keyed by {@link #WORKFLOW_FILE_CLIENT_ATTR}; the task runner's {@code AnnotatedWorker} - * sets this per-task before binding worker input. Without it this deserializer cannot construct - * a {@link ManagedFileHandler} and fails. - */ -public class FileHandlerDeserializer extends JsonDeserializer { - - public static final String WORKFLOW_FILE_CLIENT_ATTR = - "org.conductoross.conductor.sdk.file.workflowFileClient"; - - @Override - public FileHandler deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { - Object raw = p.readValueAs(Object.class); - String fileHandleId = FileHandler.extractFileHandleId(raw); - if (!FileHandler.isFileHandleId(fileHandleId)) { - throw new FileStorageException( - "Expected " + FileHandler.PREFIX + " reference, got: " + raw); - } - Object attr = ctxt.getAttribute(WORKFLOW_FILE_CLIENT_ATTR); - if (!(attr instanceof WorkflowFileClient client)) { - throw new FileStorageException( - "FileHandler input requires a WorkflowFileClient in deserialization context"); - } - return new ManagedFileHandler(fileHandleId, client); - } -} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandlerSerializer.java b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandlerSerializer.java deleted file mode 100644 index 7fd9d880b..000000000 --- a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandlerSerializer.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.sdk.file; - -import java.io.IOException; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.SerializerProvider; - -/** - * Serializes any {@link FileHandler} as a fixed three-field JSON object: - * {@code {fileHandleId, contentType, fileName}}. Other accessors on the interface - * (e.g. {@code getInputStream}, {@code getFileSize}) and impl-specific getters are not emitted. - */ -public class FileHandlerSerializer extends JsonSerializer { - - @Override - public void serialize(FileHandler handler, JsonGenerator gen, SerializerProvider provider) throws IOException { - gen.writeStartObject(); - gen.writeStringField("fileHandleId", handler.getFileHandleId()); - gen.writeStringField("contentType", handler.getContentType()); - gen.writeStringField("fileName", handler.getFileName()); - gen.writeEndObject(); - } -} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileStorageBackend.java b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileStorageBackend.java deleted file mode 100644 index e9b0ec089..000000000 --- a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileStorageBackend.java +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.sdk.file; - -import java.io.InputStream; -import java.nio.file.Path; - -import org.conductoross.conductor.client.model.file.StorageType; - -/** - * SDK-side counterpart to the server's {@code FileStorage}. Handles the actual byte transfer - * between the worker and the configured storage backend (S3, Azure Blob, GCS, Local). - * - *

One implementation per backend. {@link org.conductoross.conductor.client.FileClient} - * selects the backend that matches the server-reported {@link StorageType} on each upload or - * download. - */ -public interface FileStorageBackend { - - /** Returns the {@link StorageType} this implementation handles. */ - StorageType getStorageType(); - - /** - * Uploads the full content of {@code localFile} to a server-issued URL (presigned PUT for - * S3, SAS URL for Azure, signed URL for GCS, or a direct path for local). - */ - void upload(String url, Path localFile); - - /** - * Uploads the content of {@code inputStream} to the server-issued URL. {@code contentLength} - * is required by backends that cannot accept chunked transfer. - */ - void upload(String url, InputStream inputStream, long contentLength); - - /** - * Downloads the content at the server-issued URL to {@code destination}. Creates parent - * directories as needed. - */ - void download(String url, Path destination); - - /** - * Uploads a single part of a multipart upload. - * - * @param url per-part presigned URL (S3) or resumable session URL (GCS/Azure) - * @param localFile source file - * @param offset byte offset into {@code localFile} - * @param length number of bytes to upload for this part - * @return the part's ETag (or backend-equivalent identifier), to be supplied to the - * server's complete-multipart call - */ - String uploadPart(String url, Path localFile, long offset, long length); - - /** - * Whether this backend supports multipart upload. Return {@code false} for backends that do - * not (e.g. local); {@link org.conductoross.conductor.client.FileClient} will then fall back - * to single-part upload regardless of the configured multipart threshold. - */ - default boolean hasMultipartSupport() { - return true; - } -} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileStorageException.java b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileStorageException.java index 916488228..cf9adf755 100644 --- a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileStorageException.java +++ b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileStorageException.java @@ -13,9 +13,7 @@ package org.conductoross.conductor.sdk.file; /** - * Unchecked exception raised by the SDK for file-storage failures — upload, download, metadata - * fetch, or storage-type mismatch between the server and the registered - * {@link FileStorageBackend}s. + * Unchecked exception raised by the SDK for file upload, download, and metadata failures. */ public class FileStorageException extends RuntimeException { diff --git a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileUploadOptions.java b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileUploadOptions.java index cb3ced83a..309af483e 100644 --- a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileUploadOptions.java +++ b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileUploadOptions.java @@ -13,21 +13,14 @@ package org.conductoross.conductor.sdk.file; /** - * Optional metadata to attach to a file upload. {@code workflowId} is supplied as the explicit - * first argument on {@link FileUploader#upload}; this type carries everything else. - * - *

When uploading inside a worker, {@code taskId} is populated automatically from the active - * {@link com.netflix.conductor.sdk.workflow.executor.task.TaskContext} if not set here. - * Providing it explicitly overrides the auto-detected value. - * - *

Set {@link #setMultipart(boolean) multipart=true} to force multipart upload when the - * underlying backend supports it; otherwise the SDK falls back to a single-request upload. + * Optional metadata to attach to a file upload. The owning workflow is always supplied directly + * to {@code FileClient}; multipart selection is automatic and is not part of this API. * *

{@code
  * FileUploadOptions options = new FileUploadOptions()
  *         .setContentType("application/pdf")
- *         .setMultipart(true);
- * FileHandler handler = fileUploader.upload(workflowId, path, options);
+ *         .setTaskId(taskId);
+ * String fileHandleId = fileClient.upload(workflowId, path, options);
  * }
*/ public class FileUploadOptions { @@ -35,7 +28,6 @@ public class FileUploadOptions { private String taskId; private String fileName; private String contentType; - private boolean multipart; public String getTaskId() { return taskId; @@ -64,12 +56,4 @@ public FileUploadOptions setContentType(String contentType) { return this; } - public boolean isMultipart() { - return multipart; - } - - public FileUploadOptions setMultipart(boolean multipart) { - this.multipart = multipart; - return this; - } } diff --git a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileUploader.java b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileUploader.java deleted file mode 100644 index 775cfe981..000000000 --- a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileUploader.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.sdk.file; - -import java.io.InputStream; -import java.nio.file.Path; - -/** - * Developer-facing upload API. Implemented by {@link org.conductoross.conductor.client.FileClient}. - * Use for explicit uploads — e.g. before starting a workflow, or inside a task when you want to - * control upload timing. - * - *

All overloads return a {@link FileHandler} whose {@link FileHandler#getFileHandleId()} is - * populated with the prefixed handle {@code conductor://file/}. The {@link InputStream} - * overloads stream the content to a temporary file first, then delegate to the {@link Path} - * overload. - * - *

When called from within a worker, {@code workflowId} and {@code taskId} are injected - * automatically from the active task context. Use {@link FileUploadOptions} to override them or - * to supply additional metadata when uploading outside a worker. - */ -public interface FileUploader { - - FileHandler upload(Path localFile); - - FileHandler upload(InputStream inputStream); - - /** Upload from a local file with the given options. */ - FileHandler upload(Path localFile, FileUploadOptions options); - - /** Upload from an {@link InputStream} with the given options. */ - FileHandler upload(InputStream inputStream, FileUploadOptions options); -} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/LocalFileHandler.java b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/LocalFileHandler.java deleted file mode 100644 index bead95385..000000000 --- a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/LocalFileHandler.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.sdk.file; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; - -import org.conductoross.conductor.client.FileClient; - -/** - * {@link FileHandler} wrapping a local file that has not been uploaded. - * {@link #getFileHandleId()} returns {@code null}. Returned by - * {@link FileHandler#fromLocalFile(Path)}; consumed by - * {@link FileClient#upload(String, Path)} or by - * {@code TaskRunner}'s auto-upload interception on task completion. - */ -public class LocalFileHandler implements FileHandler { - - private final Path path; - private final String contentType; - - LocalFileHandler(Path path, String contentType) { - this.path = path; - this.contentType = contentType; - } - - @Override - public String getFileHandleId() { return null; } - - @Override - public InputStream getInputStream() { - try { - return Files.newInputStream(path); - } catch (IOException e) { - throw new FileStorageException("Failed to open local file: " + path, e); - } - } - - @Override - public String getFileName() { return path.getFileName().toString(); } - - @Override - public String getContentType() { return contentType; } - - @Override - public long getFileSize() { - try { - return Files.size(path); - } catch (IOException e) { - throw new FileStorageException("Failed to get file size: " + path, e); - } - } - - public Path getPath() { return path; } -} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/ManagedFileHandler.java b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/ManagedFileHandler.java deleted file mode 100644 index 1d6d6c3d6..000000000 --- a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/ManagedFileHandler.java +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.sdk.file; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.concurrent.locks.ReentrantLock; - -import org.conductoross.conductor.client.FileClient; -import org.conductoross.conductor.client.model.file.FileHandle; -import org.conductoross.conductor.client.model.file.StorageType; - -/** - * {@link FileHandler} for a file already registered on the server. Holds the prefixed - * {@code fileHandleId} and a reference to a {@link FileClient}; fetches metadata lazily on - * first accessor call, and downloads content lazily on first {@link #getInputStream()} call. - * - *

Thread-safe: concurrent {@code getInputStream()} calls serialize through a - * {@link ReentrantLock} so only one download runs per instance. Downloaded content is cached - * under {@link org.conductoross.conductor.client.FileClientProperties#getLocalCacheDirectory()} - * at {@code /_}. Two {@code ManagedFileHandler}s for the same file - * on the same worker node will therefore skip the re-download. - */ -public class ManagedFileHandler implements FileHandler { - - private final String fileHandleId; - private String fileName; - private String contentType; - private long fileSize; - private StorageType storageType; - private Path localPath; - private FileDownloadStatus downloadStatus = FileDownloadStatus.NOT_STARTED; - private final WorkflowFileClient workflowFileClient; - private final ReentrantLock downloadLock = new ReentrantLock(); - - public ManagedFileHandler(String fileHandleId, WorkflowFileClient workflowFileClient) { - this.fileHandleId = fileHandleId; - this.workflowFileClient = workflowFileClient; - } - - @Override - public String getFileHandleId() { return fileHandleId; } - - @Override - public InputStream getInputStream() { - ensureDownloaded(); - try { - return Files.newInputStream(localPath); - } catch (IOException e) { - throw new FileStorageException("Failed to open cached file: " + localPath, e); - } - } - - @Override - public String getFileName() { - ensureMetadataLoaded(); - return fileName; - } - - @Override - public String getContentType() { - ensureMetadataLoaded(); - return contentType; - } - - @Override - public long getFileSize() { - ensureMetadataLoaded(); - return fileSize; - } - - /** Fetches the {@link FileHandle} from the server on first call; a no-op thereafter. */ - private void ensureMetadataLoaded() { - if (fileName != null) { - return; - } - FileHandle metadata = workflowFileClient.getMetadata(fileHandleId); - this.fileName = metadata.getFileName(); - this.contentType = metadata.getContentType(); - this.fileSize = metadata.getFileSize(); - this.storageType = metadata.getStorageType(); - } - - /** - * Downloads content to the cache on first call. Concurrent callers block on - * {@link #downloadLock}; only the first acquires the lock and performs the download. If - * another {@code ManagedFileHandler} has already cached the file at the predictable path, - * the download is skipped. - */ - private void ensureDownloaded() { - if (downloadStatus == FileDownloadStatus.DOWNLOADED && localPath != null - && Files.exists(localPath)) { - return; - } - - downloadLock.lock(); - try { - if (downloadStatus == FileDownloadStatus.DOWNLOADED && localPath != null - && Files.exists(localPath)) { - return; - } - downloadStatus = FileDownloadStatus.DOWNLOADING; - - ensureMetadataLoaded(); - - Path destination = getCachePath(); - if (Files.exists(destination)) { - this.localPath = destination; - this.downloadStatus = FileDownloadStatus.DOWNLOADED; - return; - } - - int maxRetries = workflowFileClient.getRetryCount(); - for (int attempt = 1; attempt <= maxRetries; attempt++) { - try { - workflowFileClient.download(fileHandleId, storageType, destination); - this.localPath = destination; - this.downloadStatus = FileDownloadStatus.DOWNLOADED; - return; - } catch (Exception e) { - if (attempt == maxRetries) throw e; - } - } - } catch (Exception e) { - this.downloadStatus = FileDownloadStatus.FAILED; - throw new FileStorageException("Download failed for " + fileHandleId, e); - } finally { - downloadLock.unlock(); - } - } - - private Path getCachePath() { - String fileId = FileHandler.toFileId(fileHandleId); - return Path.of(workflowFileClient.getCacheDirectory(), fileId + "_" + fileName); - } - - void setFileName(String fileName) { this.fileName = fileName; } - void setContentType(String contentType) { this.contentType = contentType; } - void setFileSize(long fileSize) { this.fileSize = fileSize; } - void setStorageType(StorageType storageType) { this.storageType = storageType; } - void setLocalPath(Path localPath) { this.localPath = localPath; } -} diff --git a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/WorkflowFileClient.java b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/WorkflowFileClient.java deleted file mode 100644 index 2cae44b13..000000000 --- a/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/WorkflowFileClient.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.sdk.file; - -import java.io.InputStream; -import java.nio.file.Path; - -import org.conductoross.conductor.client.FileClient; -import org.conductoross.conductor.client.model.file.FileHandle; -import org.conductoross.conductor.client.model.file.StorageType; - -/** - * Decorator for {@link FileClient} that exposes the same file operations while keeping the - * implementation behind a simpler SDK-facing type. - */ -public class WorkflowFileClient implements FileUploader { - - private final FileClient delegate; - private final String workflowId; - - public WorkflowFileClient(FileClient delegate, String workflowId) { - this.delegate = delegate; - this.workflowId = workflowId; - } - - public FileHandler upload(Path localFile, FileUploadOptions options) { - return delegate.upload(workflowId, localFile, options); - } - - public FileHandler upload(InputStream inputStream, FileUploadOptions options) { - return delegate.upload(workflowId, inputStream, options); - } - - public FileHandler upload(Path localFile) { - return delegate.upload(workflowId, localFile); - } - - public FileHandler upload(InputStream inputStream) { - return delegate.upload(workflowId, inputStream); - } - - public void download(String fileHandleId, StorageType storageType, Path destination) { - delegate.download(workflowId, fileHandleId, storageType, destination); - } - - public FileHandle getMetadata(String fileHandleId) { - return delegate.getMetadata(fileHandleId); - } - - public int getRetryCount() { - return delegate.getRetryCount(); - } - - public String getCacheDirectory() { - return delegate.getCacheDirectory(); - } -} diff --git a/conductor-client/src/test/java/com/netflix/conductor/client/automator/TaskRunnerFatalAuthTest.java b/conductor-client/src/test/java/com/netflix/conductor/client/automator/TaskRunnerFatalAuthTest.java index e41a6d615..d7b599862 100644 --- a/conductor-client/src/test/java/com/netflix/conductor/client/automator/TaskRunnerFatalAuthTest.java +++ b/conductor-client/src/test/java/com/netflix/conductor/client/automator/TaskRunnerFatalAuthTest.java @@ -77,8 +77,7 @@ void exitActionInvokedOnFatalAuthenticationException() throws Exception { 100, List.of(), eventDispatcher, - false, - null); + false); CountDownLatch exitLatch = new CountDownLatch(1); runner.setExitAction(exitLatch::countDown); @@ -144,8 +143,7 @@ void exitActionInvokedWhenInterceptorHitsFatalThreshold() throws Exception { 100, List.of(), eventDispatcher, - false, - null); + false); CountDownLatch exitLatch = new CountDownLatch(1); runner.setExitAction(exitLatch::countDown); diff --git a/conductor-client/src/test/java/com/netflix/conductor/client/automator/TaskRunnerFileStorageTest.java b/conductor-client/src/test/java/com/netflix/conductor/client/automator/TaskRunnerFileStorageTest.java deleted file mode 100644 index 3b152ef8d..000000000 --- a/conductor-client/src/test/java/com/netflix/conductor/client/automator/TaskRunnerFileStorageTest.java +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package com.netflix.conductor.client.automator; - -import java.io.InputStream; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -import org.conductoross.conductor.client.FileClient; -import org.conductoross.conductor.sdk.file.FileHandler; -import org.conductoross.conductor.sdk.file.FileUploadOptions; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; - -import com.netflix.conductor.client.events.dispatcher.EventDispatcher; -import com.netflix.conductor.client.events.taskrunner.TaskRunnerEvent; -import com.netflix.conductor.client.http.TaskClient; -import com.netflix.conductor.client.worker.Worker; -import com.netflix.conductor.common.metadata.tasks.TaskResult; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -class TaskRunnerFileStorageTest { - - private static final String FILE_ID = "conductor://file/" + UUID.randomUUID(); - - private FileClient fileClient; - private TaskRunner runner; - - @BeforeEach - void setUp() { - fileClient = mock(FileClient.class); - Worker worker = mock(Worker.class); - when(worker.getPollingInterval()).thenReturn(100); - when(worker.getTaskDefName()).thenReturn("test_task"); - runner = newRunner(worker, fileClient); - } - - @AfterEach - void tearDown() { - if (runner != null) { - runner.shutdown(1); - } - } - - @Test - @DisplayName("uploadFilesToFileStorage uploads LocalFileHandler and replaces entry with the uploaded FileHandler") - void uploadFilesToFileStorage_uploadsFileHandlerValues() { - FileHandler uploaded = mock(FileHandler.class); - when(uploaded.getFileHandleId()).thenReturn(FILE_ID); - when(fileClient.upload(any(), any(Path.class), any(FileUploadOptions.class))) - .thenReturn(uploaded); - - TaskResult result = new TaskResult(); - FileHandler local = FileHandler.fromLocalFile(Path.of("/tmp/a.pdf"), "application/pdf"); - result.getOutputData().put("result", local); - - runner.uploadFilesToFileStorage(result, "wf-1", "task-1"); - - ArgumentCaptor opts = ArgumentCaptor.forClass(FileUploadOptions.class); - verify(fileClient, times(1)) - .upload(eq("wf-1"), eq(Path.of("/tmp/a.pdf")), opts.capture()); - assertEquals("application/pdf", opts.getValue().getContentType()); - assertEquals("task-1", opts.getValue().getTaskId()); - assertSame(uploaded, result.getOutputData().get("result")); - } - - @Test - @DisplayName("uploadFilesToFileStorage leaves an already-uploaded FileHandler in place (no upload, no flatten)") - void uploadFilesToFileStorage_keepsHandlerForAlreadyUploaded() { - PreUploadedFileHandler handler = new PreUploadedFileHandler(FILE_ID); - TaskResult result = new TaskResult(); - result.getOutputData().put("result", handler); - - runner.uploadFilesToFileStorage(result, "wf-1", "task-1"); - - verify(fileClient, never()) - .upload(any(), any(Path.class), any(FileUploadOptions.class)); - assertSame(handler, result.getOutputData().get("result")); - } - - @Test - @DisplayName("uploadFilesToFileStorage is a no-op when fileClient is null") - void uploadFilesToFileStorage_noopWhenFileClientNull() { - runner.shutdown(1); - Worker worker = mock(Worker.class); - when(worker.getPollingInterval()).thenReturn(100); - when(worker.getTaskDefName()).thenReturn("test_task"); - runner = newRunner(worker, null); - - TaskResult result = new TaskResult(); - FileHandler local = FileHandler.fromLocalFile(Path.of("/tmp/a.pdf"), "application/pdf"); - result.getOutputData().put("result", local); - - runner.uploadFilesToFileStorage(result, "wf-1", "task-1"); - - assertEquals(local, result.getOutputData().get("result")); - } - - @Test - @DisplayName("uploadFilesToFileStorage ignores non-FileHandler outputData values") - void uploadFilesToFileStorage_ignoresNonFileHandlerValues() { - FileHandler uploaded = mock(FileHandler.class); - when(uploaded.getFileHandleId()).thenReturn(FILE_ID); - when(fileClient.upload(any(), any(Path.class), any(FileUploadOptions.class))) - .thenReturn(uploaded); - - TaskResult result = new TaskResult(); - FileHandler local = FileHandler.fromLocalFile(Path.of("/tmp/a.pdf"), "application/pdf"); - result.getOutputData().put("text", "hello"); - result.getOutputData().put("count", 42); - result.getOutputData().put("file", local); - - runner.uploadFilesToFileStorage(result, "wf-1", "task-1"); - - verify(fileClient, times(1)) - .upload(eq("wf-1"), eq(Path.of("/tmp/a.pdf")), any(FileUploadOptions.class)); - assertEquals("hello", result.getOutputData().get("text")); - assertEquals(42, result.getOutputData().get("count")); - assertSame(uploaded, result.getOutputData().get("file")); - } - - @SuppressWarnings("unchecked") - private static TaskRunner newRunner(Worker worker, FileClient fileClient) { - return new TaskRunner( - worker, - mock(TaskClient.class), - 3, - Map.of(), - "test-worker-", - 1, - 100, - List.of(), - (EventDispatcher) mock(EventDispatcher.class), - false, - fileClient); - } - - static class PreUploadedFileHandler implements FileHandler { - private final String id; - PreUploadedFileHandler(String id) { this.id = id; } - @Override public String getFileHandleId() { return id; } - @Override public InputStream getInputStream() { return null; } - @Override public String getFileName() { return "pre.bin"; } - @Override public String getContentType() { return "application/octet-stream"; } - @Override public long getFileSize() { return 0L; } - } -} diff --git a/conductor-client/src/test/java/com/netflix/conductor/common/metadata/tasks/TaskGetInputFileHandlerTest.java b/conductor-client/src/test/java/com/netflix/conductor/common/metadata/tasks/TaskGetInputFileHandlerTest.java deleted file mode 100644 index b09f1480d..000000000 --- a/conductor-client/src/test/java/com/netflix/conductor/common/metadata/tasks/TaskGetInputFileHandlerTest.java +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package com.netflix.conductor.common.metadata.tasks; - -import java.util.Map; -import java.util.UUID; - -import org.conductoross.conductor.sdk.file.FileHandler; -import org.conductoross.conductor.sdk.file.FileStorageException; -import org.conductoross.conductor.sdk.file.WorkflowFileClient; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.mock; - -class TaskGetInputFileHandlerTest { - - private static final String FILE_ID = "conductor://file/" + UUID.randomUUID(); - - @Test - void wrapsRawStringFileHandleId() { - Task task = newTask(Map.of("file", FILE_ID)); - - FileHandler handler = task.getInputFileHandler("file"); - - assertEquals(FILE_ID, handler.getFileHandleId()); - } - - @Test - void wrapsJsonObjectWithFileHandleId() { - Map serialized = Map.of( - "fileHandleId", FILE_ID, - "fileName", "doc.pdf", - "contentType", "application/pdf"); - Task task = newTask(Map.of("file", serialized)); - - FileHandler handler = task.getInputFileHandler("file"); - - assertEquals(FILE_ID, handler.getFileHandleId()); - } - - @Test - void throwsWhenStringLacksPrefix() { - Task task = newTask(Map.of("file", "not-a-file-id")); - - assertThrows(FileStorageException.class, () -> task.getInputFileHandler("file")); - } - - @Test - void throwsWhenJsonObjectMissingFileHandleId() { - Task task = newTask(Map.of("file", Map.of("fileName", "x.pdf"))); - - assertThrows(FileStorageException.class, () -> task.getInputFileHandler("file")); - } - - @Test - void throwsWhenJsonObjectFileHandleIdLacksPrefix() { - Task task = newTask(Map.of("file", Map.of("fileHandleId", "raw-uuid"))); - - assertThrows(FileStorageException.class, () -> task.getInputFileHandler("file")); - } - - @Test - void throwsWhenKeyAbsent() { - Task task = newTask(Map.of()); - - assertThrows(FileStorageException.class, () -> task.getInputFileHandler("file")); - } - - private static Task newTask(Map input) { - Task task = new Task(); - task.setInputData(input); - task.setWorkflowFileClient(mock(WorkflowFileClient.class)); - return task; - } -} diff --git a/conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/executor/task/AnnotatedWorkerFileStorageTest.java b/conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/executor/task/AnnotatedWorkerFileStorageTest.java deleted file mode 100644 index 2a309d0be..000000000 --- a/conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/executor/task/AnnotatedWorkerFileStorageTest.java +++ /dev/null @@ -1,341 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package com.netflix.conductor.sdk.workflow.executor.task; - -import java.io.InputStream; -import java.nio.file.Path; -import java.util.Map; -import java.util.UUID; - -import org.conductoross.conductor.sdk.file.FileHandler; -import org.conductoross.conductor.sdk.file.FileStorageException; -import org.conductoross.conductor.sdk.file.WorkflowFileClient; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import com.netflix.conductor.common.metadata.tasks.Task; -import com.netflix.conductor.common.metadata.tasks.TaskResult; -import com.netflix.conductor.sdk.workflow.task.InputParam; -import com.netflix.conductor.sdk.workflow.task.OutputParam; -import com.netflix.conductor.sdk.workflow.task.WorkerTask; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; - -class AnnotatedWorkerFileStorageTest { - - private static final String FILE_ID = "conductor://file/" + UUID.randomUUID(); - - // --- Workers --- - - static class FileReturnWorker { - @WorkerTask("file_return") - public @OutputParam("result") FileHandler doWork() { - return FileHandler.fromLocalFile(Path.of("/tmp/out.pdf"), "application/pdf"); - } - } - - static class FileReturnCustomKeyWorker { - @WorkerTask("file_return_custom_key") - public @OutputParam("doc") FileHandler doWork() { - return FileHandler.fromLocalFile(Path.of("/tmp/out.pdf"), "application/pdf"); - } - } - - static class AlreadyUploadedFileReturnWorker { - @WorkerTask("file_return_already_uploaded") - public @OutputParam("result") FileHandler doWork() { - return new PreUploadedFileHandler(FILE_ID); - } - } - - static class FileInputWorker { - @WorkerTask("file_input") - public @OutputParam("got") String doWork(@InputParam("file") FileHandler file) { - return file.getFileHandleId(); - } - } - - // POJO carrying a FileHandler field — exercises Jackson deserializer path. - // Public so Jackson's Afterburner module can generate an accessor class for it. - public static class Attachment { - public String label; - public FileHandler file; - } - - public static class AttachmentInputWorker { - @WorkerTask("attachment_input") - public @OutputParam("got") String doWork(@InputParam("att") Attachment att) { - return att.label + ":" + (att.file == null ? "null" : att.file.getFileHandleId()); - } - } - - // Non-ManagedFileHandler impl w/ a non-null handle id — simulates a worker - // returning a FileHandler that was already uploaded upstream. - static class PreUploadedFileHandler implements FileHandler { - private final String id; - PreUploadedFileHandler(String id) { this.id = id; } - @Override public String getFileHandleId() { return id; } - @Override public InputStream getInputStream() { return null; } - @Override public String getFileName() { return "pre.bin"; } - @Override public String getContentType() { return "application/octet-stream"; } - @Override public long getFileSize() { return 0L; } - } - - // --- setValue tests --- - - @Test - @DisplayName("setValue puts LocalFileHandler in outputData without uploading (TaskRunner handles upload)") - void setValue_putsFileHandlerInOutput_whenFileIdNull() throws NoSuchMethodException { - WorkflowFileClient workflowFileClient = mock(WorkflowFileClient.class); - - Task task = new Task(); - task.setStatus(Task.Status.IN_PROGRESS); - task.setWorkflowFileClient(workflowFileClient); - - FileReturnWorker bean = new FileReturnWorker(); - AnnotatedWorker worker = new AnnotatedWorker( - "file_return", bean.getClass().getMethod("doWork"), bean); - - TaskResult result = worker.execute(task); - - verify(workflowFileClient, never()).upload(any(Path.class), any()); - Object value = result.getOutputData().get("result"); - assertInstanceOf(FileHandler.class, value); - assertNull(((FileHandler) value).getFileHandleId()); - assertEquals(TaskResult.Status.COMPLETED, result.getStatus()); - } - - @Test - @DisplayName("setValue puts pre-uploaded FileHandler in outputData unchanged") - void setValue_putsFileHandlerInOutput_whenFileIdSet() throws NoSuchMethodException { - WorkflowFileClient workflowFileClient = mock(WorkflowFileClient.class); - - Task task = new Task(); - task.setStatus(Task.Status.IN_PROGRESS); - task.setWorkflowFileClient(workflowFileClient); - - AlreadyUploadedFileReturnWorker bean = new AlreadyUploadedFileReturnWorker(); - AnnotatedWorker worker = new AnnotatedWorker( - "file_return_already_uploaded", bean.getClass().getMethod("doWork"), bean); - - TaskResult result = worker.execute(task); - - verify(workflowFileClient, never()).upload(any(Path.class), any()); - Object value = result.getOutputData().get("result"); - assertInstanceOf(FileHandler.class, value); - assertEquals(FILE_ID, ((FileHandler) value).getFileHandleId()); - } - - @Test - @DisplayName("setValue honors @OutputParam value as outputData key for FileHandler return") - void setValue_usesOutputParamKey() throws NoSuchMethodException { - WorkflowFileClient workflowFileClient = mock(WorkflowFileClient.class); - - Task task = new Task(); - task.setStatus(Task.Status.IN_PROGRESS); - task.setWorkflowFileClient(workflowFileClient); - - FileReturnCustomKeyWorker bean = new FileReturnCustomKeyWorker(); - AnnotatedWorker worker = new AnnotatedWorker( - "file_return_custom_key", bean.getClass().getMethod("doWork"), bean); - - TaskResult result = worker.execute(task); - - assertInstanceOf(FileHandler.class, result.getOutputData().get("doc")); - assertNull(result.getOutputData().get("result")); - } - - // --- getInputValue tests --- - - @Test - @DisplayName("getInputValue wraps conductor://file/ id as ManagedFileHandler") - void getInputValue_wrapsFileIdAsManagedFileHandler() throws NoSuchMethodException { - WorkflowFileClient workflowFileClient = mock(WorkflowFileClient.class); - - FileInputWorker bean = new FileInputWorker(); - AnnotatedWorker worker = new AnnotatedWorker( - "file_input", bean.getClass().getMethod("doWork", FileHandler.class), bean); - - Task task = new Task(); - task.setStatus(Task.Status.IN_PROGRESS); - task.setInputData(Map.of("file", FILE_ID)); - task.setWorkflowFileClient(workflowFileClient); - - TaskResult result = worker.execute(task); - - assertEquals(FILE_ID, result.getOutputData().get("got")); - } - - @Test - @DisplayName("getInputValue accepts JSON object with fileHandleId and wraps as ManagedFileHandler") - void getInputValue_acceptsJsonObjectWithFileHandleId() throws NoSuchMethodException { - WorkflowFileClient workflowFileClient = mock(WorkflowFileClient.class); - - FileInputWorker bean = new FileInputWorker(); - AnnotatedWorker worker = new AnnotatedWorker( - "file_input", bean.getClass().getMethod("doWork", FileHandler.class), bean); - - Map serialized = Map.of( - "fileHandleId", FILE_ID, - "fileName", "doc.pdf", - "contentType", "application/pdf"); - - Task task = new Task(); - task.setStatus(Task.Status.IN_PROGRESS); - task.setInputData(Map.of("file", serialized)); - task.setWorkflowFileClient(workflowFileClient); - - TaskResult result = worker.execute(task); - - assertEquals(FILE_ID, result.getOutputData().get("got")); - } - - @Test - @DisplayName("getInputValue throws when JSON object lacks fileHandleId") - void getInputValue_throws_whenJsonObjectMissingFileHandleId() throws NoSuchMethodException { - WorkflowFileClient workflowFileClient = mock(WorkflowFileClient.class); - - FileInputWorker bean = new FileInputWorker(); - AnnotatedWorker worker = new AnnotatedWorker( - "file_input", bean.getClass().getMethod("doWork", FileHandler.class), bean); - - Task task = new Task(); - task.setStatus(Task.Status.IN_PROGRESS); - task.setInputData(Map.of("file", Map.of("fileName", "x.pdf"))); - task.setWorkflowFileClient(workflowFileClient); - - RuntimeException thrown = assertThrows(RuntimeException.class, () -> worker.execute(task)); - assertInstanceOf(FileStorageException.class, thrown.getCause()); - } - - @Test - @DisplayName("getInputValue throws FileStorageException when FileHandler param value is not a file id") - void getInputValue_throwsFileStorageException_whenValueIsNotFileId() throws NoSuchMethodException { - WorkflowFileClient workflowFileClient = mock(WorkflowFileClient.class); - - FileInputWorker bean = new FileInputWorker(); - AnnotatedWorker worker = new AnnotatedWorker( - "file_input", bean.getClass().getMethod("doWork", FileHandler.class), bean); - - Task task = new Task(); - task.setStatus(Task.Status.IN_PROGRESS); - task.setInputData(Map.of("file", "not-a-file-id")); - task.setWorkflowFileClient(workflowFileClient); - - // FileStorageException is thrown during argument resolution, caught by the - // outer catch(Exception) in execute() and rewrapped as RuntimeException. - RuntimeException thrown = assertThrows(RuntimeException.class, () -> worker.execute(task)); - assertInstanceOf(FileStorageException.class, thrown.getCause()); - } - - @Test - @DisplayName("POJO field FileHandler is bound from JSON object form") - void pojoFieldFileHandler_fromJsonObject() throws NoSuchMethodException { - WorkflowFileClient workflowFileClient = mock(WorkflowFileClient.class); - - AttachmentInputWorker bean = new AttachmentInputWorker(); - AnnotatedWorker worker = new AnnotatedWorker( - "attachment_input", bean.getClass().getMethod("doWork", Attachment.class), bean); - - Map attachment = Map.of( - "label", "invoice", - "file", Map.of( - "fileHandleId", FILE_ID, - "fileName", "inv.pdf", - "contentType", "application/pdf")); - - Task task = new Task(); - task.setStatus(Task.Status.IN_PROGRESS); - task.setInputData(Map.of("att", attachment)); - task.setWorkflowFileClient(workflowFileClient); - - TaskResult result = worker.execute(task); - - assertEquals("invoice:" + FILE_ID, result.getOutputData().get("got")); - } - - @Test - @DisplayName("POJO field FileHandler is bound from raw conductor://file/ string") - void pojoFieldFileHandler_fromRawString() throws NoSuchMethodException { - WorkflowFileClient workflowFileClient = mock(WorkflowFileClient.class); - - AttachmentInputWorker bean = new AttachmentInputWorker(); - AnnotatedWorker worker = new AnnotatedWorker( - "attachment_input", bean.getClass().getMethod("doWork", Attachment.class), bean); - - Map attachment = Map.of("label", "raw", "file", FILE_ID); - - Task task = new Task(); - task.setStatus(Task.Status.IN_PROGRESS); - task.setInputData(Map.of("att", attachment)); - task.setWorkflowFileClient(workflowFileClient); - - TaskResult result = worker.execute(task); - - assertEquals("raw:" + FILE_ID, result.getOutputData().get("got")); - } - - @Test - @DisplayName("POJO field FileHandler throws when value is not a valid reference") - void pojoFieldFileHandler_throwsOnInvalidValue() throws NoSuchMethodException { - WorkflowFileClient workflowFileClient = mock(WorkflowFileClient.class); - - AttachmentInputWorker bean = new AttachmentInputWorker(); - AnnotatedWorker worker = new AnnotatedWorker( - "attachment_input", bean.getClass().getMethod("doWork", Attachment.class), bean); - - Map attachment = Map.of("label", "bad", "file", "not-a-file-id"); - - Task task = new Task(); - task.setStatus(Task.Status.IN_PROGRESS); - task.setInputData(Map.of("att", attachment)); - task.setWorkflowFileClient(workflowFileClient); - - RuntimeException thrown = assertThrows(RuntimeException.class, () -> worker.execute(task)); - assertInstanceOf(FileStorageException.class, rootCause(thrown)); - } - - private static Throwable rootCause(Throwable t) { - Throwable cause = t; - while (cause.getCause() != null && cause.getCause() != cause) { - cause = cause.getCause(); - } - return cause; - } - - @Test - @DisplayName("getInputValue returns null FileHandler when input key is absent") - void getInputValue_returnsNull_whenValueMissing() throws NoSuchMethodException { - WorkflowFileClient workflowFileClient = mock(WorkflowFileClient.class); - - FileInputWorker bean = new FileInputWorker(); - AnnotatedWorker worker = new AnnotatedWorker( - "file_input", bean.getClass().getMethod("doWork", FileHandler.class), bean); - - Task task = new Task(); - task.setStatus(Task.Status.IN_PROGRESS); - task.setInputData(Map.of()); - task.setWorkflowFileClient(workflowFileClient); - - // Worker body dereferences a null FileHandler → NPE → FAILED - TaskResult result = worker.execute(task); - assertEquals(TaskResult.Status.FAILED, result.getStatus()); - } -} diff --git a/conductor-client/src/test/java/org/conductoross/conductor/client/FileClientPropertiesTest.java b/conductor-client/src/test/java/org/conductoross/conductor/client/FileClientPropertiesTest.java index a5295d67b..2e01a9993 100644 --- a/conductor-client/src/test/java/org/conductoross/conductor/client/FileClientPropertiesTest.java +++ b/conductor-client/src/test/java/org/conductoross/conductor/client/FileClientPropertiesTest.java @@ -12,17 +12,18 @@ */ package org.conductoross.conductor.client; -import java.nio.file.Path; - import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; class FileClientPropertiesTest { @Test - void defaultCacheDirectoryIsUnderJavaTmpDir() { - String expected = Path.of(System.getProperty("java.io.tmpdir")).toString(); - assertTrue(new FileClientProperties().getLocalCacheDirectory().startsWith(expected)); + void defaultsAreSuitableForAutomaticMultipartUploads() { + FileClientProperties properties = new FileClientProperties(); + + assertEquals(3, properties.getRetryCount()); + assertEquals(10L * 1024 * 1024, properties.getMultipartPartSize()); + assertEquals(100L * 1024 * 1024, properties.getMultipartThreshold()); } } diff --git a/conductor-client/src/test/java/org/conductoross/conductor/client/FileClientTest.java b/conductor-client/src/test/java/org/conductoross/conductor/client/FileClientTest.java index ca453d982..314a7047e 100644 --- a/conductor-client/src/test/java/org/conductoross/conductor/client/FileClientTest.java +++ b/conductor-client/src/test/java/org/conductoross/conductor/client/FileClientTest.java @@ -13,344 +13,528 @@ package org.conductoross.conductor.client; import java.io.ByteArrayInputStream; +import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Predicate; import org.conductoross.conductor.client.model.file.FileDownloadUrlResponse; -import org.conductoross.conductor.client.model.file.FileHandle; +import org.conductoross.conductor.client.model.file.FileMetadata; import org.conductoross.conductor.client.model.file.FileUploadCompleteResponse; +import org.conductoross.conductor.client.model.file.FileUploadRequest; import org.conductoross.conductor.client.model.file.FileUploadResponse; import org.conductoross.conductor.client.model.file.FileUploadStatus; import org.conductoross.conductor.client.model.file.FileUploadUrlResponse; +import org.conductoross.conductor.client.model.file.MultipartCompleteRequest; import org.conductoross.conductor.client.model.file.MultipartInitResponse; -import org.conductoross.conductor.client.model.file.StorageType; -import org.conductoross.conductor.sdk.file.FileHandler; -import org.conductoross.conductor.sdk.file.FileStorageBackend; import org.conductoross.conductor.sdk.file.FileStorageException; import org.conductoross.conductor.sdk.file.FileUploadOptions; -import org.conductoross.conductor.sdk.file.StubFileStorageBackend; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.mockito.ArgumentCaptor; +import com.netflix.conductor.client.exception.ConductorClientException; import com.netflix.conductor.client.http.ConductorClient; import com.netflix.conductor.client.http.ConductorClientRequest; import com.netflix.conductor.client.http.ConductorClientResponse; import com.netflix.conductor.client.http.Param; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import okhttp3.mockwebserver.SocketPolicy; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class FileClientTest { - private static final String RAW_ID = UUID.randomUUID().toString(); - private static final String HANDLE_ID = FileHandler.PREFIX + RAW_ID; - private static final String UPLOAD_URL = "stub://upload/" + RAW_ID; - private static final String DOWNLOAD_URL = "stub://download/" + RAW_ID; + private static final String WORKFLOW_ID = "workflow-1"; + private static final String FILE_ID = "file-123"; + private static final String HANDLE = FileClient.FILE_HANDLE_PREFIX + FILE_ID; - @TempDir - Path tempDir; + @TempDir Path tempDir; - private ConductorClient conductorClient; - private StubFileStorageBackend backend; private final List stubs = new ArrayList<>(); + private ConductorClient conductorClient; + private MockWebServer storageServer; @BeforeEach - void setUp() { + void setUp() throws IOException { conductorClient = mock(ConductorClient.class); - backend = new StubFileStorageBackend(); - stubs.clear(); - // Single dispatcher — registering a matcher later just appends to the list. when(conductorClient.execute(any(ConductorClientRequest.class), any())) - .thenAnswer(invocation -> { - ConductorClientRequest req = invocation.getArgument(0); - for (Stub stub : stubs) { - if (stub.matcher.test(req)) { - return new ConductorClientResponse<>(200, Map.of(), stub.data); - } - } - throw new AssertionError("No stubbed response for path=" + req.getPath()); - }); + .thenAnswer( + invocation -> { + ConductorClientRequest request = invocation.getArgument(0); + for (Stub stub : stubs) { + if (stub.matcher().test(request)) { + Object data = stub.responder().respond(request); + return new ConductorClientResponse<>(200, Map.of(), data); + } + } + throw new AssertionError("No stub for " + request.getPath()); + }); + storageServer = new MockWebServer(); + storageServer.start(); } - @Test - void uploadSinglePartByDefault() throws Exception { - Path source = writeFile("small.txt", "hello world".getBytes()); - stubCreateFile(StorageType.LOCAL); - stubConfirmUpload(); - - FileClient client = clientWithBackends(Map.of(StorageType.LOCAL, backend)); - - FileHandler handler = client.upload( - "wf-1", source, - new FileUploadOptions().setContentType("text/plain")); - - assertEquals(HANDLE_ID, handler.getFileHandleId()); - assertEquals("hello world", new String(backend.getUploaded(UPLOAD_URL))); - verifyPathCalled("/files/{fileId}/upload-complete", RAW_ID); - verifyPathNotCalled("/files/{fileId}/multipart"); + @AfterEach + void tearDown() throws IOException { + Thread.interrupted(); + storageServer.shutdown(); } @Test - void uploadUsesMultipartWhenFlagTrueAndBackendSupports() throws Exception { - Path source = writeFile("big.bin", new byte[16]); - stubCreateFile(StorageType.S3); - stubMultipartInit(); - stubMultipartComplete(); - - FileClientProperties props = new FileClientProperties(); - props.setMultipartPartSize(5); // partSize 5 + 16 bytes → 4 parts - MultipartBackend mp = new MultipartBackend(StorageType.S3); - FileClient client = clientBuilder() - .properties(props) - .addStorageBackend(mp) - .build(); - - FileHandler handler = client.upload( - "wf-1", source, - new FileUploadOptions().setMultipart(true)); - - assertEquals(HANDLE_ID, handler.getFileHandleId()); - assertEquals(4, mp.parts.get()); - verifyPathCalled("/files/{fileId}/multipart", RAW_ID); - verifyPathCalled("/files/{fileId}/multipart/{uploadId}/complete", RAW_ID); + void uploadReturnsOpaqueHandleAndScopesCompletionToOwningWorkflow() throws Exception { + storageServer.enqueue(new MockResponse().setResponseCode(200)); + stubCreate("S3", storageServer.url("/upload?signature=secret").toString()); + stubCompletion(); + + Path source = Files.writeString(tempDir.resolve("report.txt"), "hello"); + String handle = new FileClient(conductorClient).upload( + WORKFLOW_ID, + source, + new FileUploadOptions().setContentType("text/plain").setTaskId("task-1")); + + assertEquals(HANDLE, handle); + RecordedRequest transfer = storageServer.takeRequest(); + assertEquals("PUT", transfer.getMethod()); + assertEquals("hello", transfer.getBody().readUtf8()); + + ConductorClientRequest create = findRequest("/files"); + FileUploadRequest body = (FileUploadRequest) create.getBody(); + assertEquals(WORKFLOW_ID, body.getWorkflowId()); + assertEquals("report.txt", body.getFileName()); + assertEquals("text/plain", body.getContentType()); + assertEquals("task-1", body.getTaskId()); + assertScopedRequest( + "/files/{workflowId}/{fileId}/upload-complete", WORKFLOW_ID, FILE_ID); } @Test - void uploadFallsBackToSinglePartWhenBackendLacksMultipart() throws Exception { - Path source = writeFile("big.bin", new byte[32]); - stubCreateFile(StorageType.LOCAL); - stubConfirmUpload(); - - StubFileStorageBackend noMultipart = new StubFileStorageBackend() { - @Override public boolean hasMultipartSupport() { return false; } - }; - FileClient client = clientBuilder() - .addStorageBackend(noMultipart) - .build(); - - client.upload("wf-1", source, new FileUploadOptions().setMultipart(true)); - - verifyPathCalled("/files/{fileId}/upload-complete", RAW_ID); - verifyPathNotCalled("/files/{fileId}/multipart"); + void multipartIsAutomaticAboveThresholdAndCompletionTokensStayOrdered() throws Exception { + for (int part = 1; part <= 3; part++) { + storageServer.enqueue( + new MockResponse().setResponseCode(200).addHeader("ETag", "etag-" + part)); + } + stubCreate("S3", storageServer.url("/unused").toString()); + stubMultipart(storageServer.url("/part").toString()); + stub( + path("/files/{workflowId}/{fileId}/multipart/{uploadId}/complete"), + request -> new FileUploadCompleteResponse()); + + FileClientProperties properties = new FileClientProperties(); + properties.setMultipartThreshold(5); + properties.setMultipartPartSize(2); + Path source = Files.write(tempDir.resolve("six.bin"), new byte[] {0, 1, 2, 3, 4, 5}); + + assertEquals(HANDLE, new FileClient(conductorClient, properties).upload(WORKFLOW_ID, source)); + + assertArrayEquals(new byte[] {0, 1}, storageServer.takeRequest().getBody().readByteArray()); + assertArrayEquals(new byte[] {2, 3}, storageServer.takeRequest().getBody().readByteArray()); + assertArrayEquals(new byte[] {4, 5}, storageServer.takeRequest().getBody().readByteArray()); + MultipartCompleteRequest completion = + (MultipartCompleteRequest) + findRequest("/files/{workflowId}/{fileId}/multipart/{uploadId}/complete") + .getBody(); + assertEquals(List.of("etag-1", "etag-2", "etag-3"), completion.getPartETags()); } @Test - void uploadThrowsWhenBackendMissingForServerStorageType() throws Exception { - Path source = writeFile("x.bin", new byte[] {1}); - stubCreateFile(StorageType.AZURE_BLOB); - - // Register only LOCAL; server reports AZURE_BLOB. - FileClient client = clientWithBackends(Map.of(StorageType.LOCAL, backend)); - - FileStorageException ex = assertThrows( - FileStorageException.class, () -> client.upload("wf-1", source)); - assertTrue(ex.getMessage().contains("AZURE_BLOB")); - assertTrue(ex.getMessage().contains("LOCAL")); + void gcsUsesSingleRequestAboveMultipartThreshold() throws Exception { + storageServer.enqueue(new MockResponse().setResponseCode(200)); + stubCreate("GCS", storageServer.url("/upload").toString()); + stubCompletion(); + FileClientProperties properties = new FileClientProperties(); + properties.setMultipartThreshold(5); + properties.setMultipartPartSize(2); + + Path source = Files.write(tempDir.resolve("large.bin"), new byte[6]); + new FileClient(conductorClient, properties).upload(WORKFLOW_ID, source); + + assertEquals(1, storageServer.getRequestCount()); + verify(conductorClient, never()) + .execute( + org.mockito.ArgumentMatchers.argThat( + request -> request != null + && request.getPath().contains("/multipart")), + any()); } @Test - void uploadFromInputStreamStreamsThroughTempFile() { - byte[] payload = "from-stream".getBytes(); - stubCreateFile(StorageType.LOCAL); - stubConfirmUpload(); - - FileClient client = clientWithBackends(Map.of(StorageType.LOCAL, backend)); - - FileHandler handler = client.upload( - "wf-1", new ByteArrayInputStream(payload), - new FileUploadOptions().setContentType("text/plain")); - - assertEquals(HANDLE_ID, handler.getFileHandleId()); - assertEquals("from-stream", new String(backend.getUploaded(UPLOAD_URL))); + void fileAtMultipartThresholdUsesSingleRequest() throws Exception { + storageServer.enqueue(new MockResponse().setResponseCode(200)); + stubCreate("S3", storageServer.url("/upload").toString()); + stubCompletion(); + FileClientProperties properties = new FileClientProperties(); + properties.setMultipartThreshold(5); + properties.setMultipartPartSize(2); + + new FileClient(conductorClient, properties).upload( + WORKFLOW_ID, Files.write(tempDir.resolve("boundary.bin"), new byte[5])); + + assertEquals(1, storageServer.getRequestCount()); + assertEquals("/upload", storageServer.takeRequest().getPath()); } @Test - void downloadFetchesUrlAndDelegatesToBackend() throws Exception { - // Pre-seed the stub so its download() has something to serve at DOWNLOAD_URL. - Path src = writeFile("remote.bin", new byte[] {9, 8, 7}); - backend.upload(DOWNLOAD_URL, src); - stubDownloadUrl(); + void failedMultipartUploadIsAbortedWithOwningWorkflow() throws Exception { + storageServer.enqueue(new MockResponse().setResponseCode(400)); + stubCreate("S3", storageServer.url("/unused").toString()); + stubMultipart(storageServer.url("/part").toString()); + FileClientProperties properties = new FileClientProperties(); + properties.setMultipartThreshold(0); + properties.setMultipartPartSize(2); + + assertThrows( + FileStorageException.class, + () -> new FileClient(conductorClient, properties).upload( + WORKFLOW_ID, + Files.write(tempDir.resolve("multipart.bin"), new byte[] {1, 2, 3}))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(ConductorClientRequest.class); + verify(conductorClient).execute(captor.capture()); + ConductorClientRequest abort = captor.getValue(); + assertEquals( + "/files/{workflowId}/{fileId}/multipart/{uploadId}", abort.getPath()); + assertTrue(hasParam(abort.getPathParams(), "workflowId", WORKFLOW_ID)); + assertTrue(hasParam(abort.getPathParams(), "fileId", FILE_ID)); + assertTrue(hasParam(abort.getPathParams(), "uploadId", "upload-1")); + } - FileClient client = clientWithBackends(Map.of(StorageType.LOCAL, backend)); + @Test + void unknownStorageTypeFallsBackForHttpSignedUrl() throws Exception { + storageServer.enqueue(new MockResponse().setResponseCode(200)); + stubCreate("NEW_PROVIDER", storageServer.url("/upload").toString()); + stubCompletion(); - Path dest = tempDir.resolve("out/remote.bin"); - client.download("wf-1", HANDLE_ID, StorageType.LOCAL, dest); + String handle = new FileClient(conductorClient).upload( + WORKFLOW_ID, Files.writeString(tempDir.resolve("a.txt"), "a")); - assertTrue(Files.exists(dest)); - verifyPathCalled("/files/{workflowId}/{fileId}/download-url", RAW_ID); + assertEquals(HANDLE, handle); + assertEquals("PUT", storageServer.takeRequest().getMethod()); } @Test - void getMetadataSendsStrippedFileIdAndReturnsHandle() { - FileHandle fixture = new FileHandle(); - fixture.setFileHandleId(HANDLE_ID); - fixture.setFileName("r.pdf"); - registerStub(req -> "/files/{fileId}".equals(req.getPath()), fixture); - - FileClient client = clientWithBackends(Map.of(StorageType.LOCAL, backend)); + void expiredSignedUrlIsRefreshedBeforeRetry() throws Exception { + storageServer.enqueue(new MockResponse().setResponseCode(403)); + storageServer.enqueue(new MockResponse().setResponseCode(200)); + stubCreate("S3", storageServer.url("/expired").toString()); + FileUploadUrlResponse refreshed = new FileUploadUrlResponse(); + refreshed.setUploadUrl(storageServer.url("/fresh").toString()); + stub(path("/files/{workflowId}/{fileId}/upload-url"), request -> refreshed); + stubCompletion(); + FileClientProperties properties = new FileClientProperties(); + properties.setRetryCount(1); + + new FileClient(conductorClient, properties).upload( + WORKFLOW_ID, Files.writeString(tempDir.resolve("retry.txt"), "retry")); + + assertEquals("/expired", storageServer.takeRequest().getPath()); + assertEquals("/fresh", storageServer.takeRequest().getPath()); + assertScopedRequest( + "/files/{workflowId}/{fileId}/upload-url", WORKFLOW_ID, FILE_ID); + } - FileHandle got = client.getMetadata(HANDLE_ID); - assertEquals("r.pdf", got.getFileName()); - verifyPathCalled("/files/{fileId}", RAW_ID); + @Test + void nonTransientSignedUrlFailureIsNotRetriedAndDoesNotExposeUrl() throws Exception { + String signedUrl = storageServer.url("/upload?signature=do-not-leak").toString(); + storageServer.enqueue(new MockResponse().setResponseCode(400)); + stubCreate("S3", signedUrl); + FileClientProperties properties = new FileClientProperties(); + properties.setRetryCount(3); + + FileStorageException error = assertThrows( + FileStorageException.class, + () -> new FileClient(conductorClient, properties).upload( + WORKFLOW_ID, Files.writeString(tempDir.resolve("bad.txt"), "bad"))); + + assertEquals(1, storageServer.getRequestCount()); + assertFalse(stackTraceMessages(error).contains("do-not-leak")); } @Test - void confirmUploadSendsStrippedFileId() { - stubConfirmUpload(); - FileClient client = clientWithBackends(Map.of(StorageType.LOCAL, backend)); + void ambiguousCompletionIsReconciledWithWorkflowScopedMetadata() throws Exception { + storageServer.enqueue(new MockResponse().setResponseCode(200)); + stubCreate("S3", storageServer.url("/upload").toString()); + stub( + path("/files/{workflowId}/{fileId}/upload-complete"), + request -> { + throw new ConductorClientException(503, "temporarily unavailable"); + }); + FileMetadata metadata = metadata("S3"); + metadata.setUploadStatus(FileUploadStatus.UPLOADED); + stub(path("/files/{workflowId}/{fileId}"), request -> metadata); - client.confirmUpload(HANDLE_ID); + String handle = new FileClient(conductorClient).upload( + WORKFLOW_ID, Files.writeString(tempDir.resolve("complete.txt"), "complete")); - verifyPathCalled("/files/{fileId}/upload-complete", RAW_ID); + assertEquals(HANDLE, handle); + assertScopedRequest("/files/{workflowId}/{fileId}", WORKFLOW_ID, FILE_ID); } @Test - void propertiesDefaultsAppliedWhenNull() { - FileClient client = new FileClient(conductorClient, null, null); + void downloadAtomicallyReplacesDestinationAndLeavesNoPartFile() throws Exception { + storageServer.enqueue(new MockResponse().setResponseCode(200).setBody("new-content")); + stubMetadata("S3"); + stubDownloadUrl(storageServer.url("/download").toString()); + Path destination = Files.writeString(tempDir.resolve("result.bin"), "old-content"); + + Path downloaded = new FileClient(conductorClient).download( + WORKFLOW_ID, HANDLE, destination); + + assertEquals(destination.toAbsolutePath(), downloaded); + assertEquals("new-content", Files.readString(destination)); + assertFalse(hasPartFile(tempDir)); + assertScopedRequest( + "/files/{workflowId}/{fileId}/download-url", WORKFLOW_ID, FILE_ID); + } - assertEquals(3, client.getRetryCount()); - assertNotNull(client.getCacheDirectory()); + @Test + void failedDownloadPreservesExistingDestinationAndCleansTemporaryFile() throws Exception { + storageServer.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody("partial-response-body") + .setSocketPolicy(SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY)); + stubMetadata("S3"); + stubDownloadUrl(storageServer.url("/download").toString()); + FileClientProperties properties = new FileClientProperties(); + properties.setRetryCount(0); + Path destination = Files.writeString(tempDir.resolve("result.bin"), "keep-me"); + + assertThrows( + FileStorageException.class, + () -> new FileClient(conductorClient, properties) + .download(WORKFLOW_ID, HANDLE, destination)); + + assertEquals("keep-me", Files.readString(destination)); + assertFalse(hasPartFile(tempDir)); } @Test - void uploadThrowsWhenWorkflowIdNull() throws Exception { - Path source = writeFile("wf.bin", new byte[] {1}); - FileClient client = clientWithBackends(Map.of(StorageType.LOCAL, backend)); + void streamUploadRequiresFilenameDoesNotCloseCallerStreamAndCleansBuffer() { + byte[] bytes = "stream".getBytes(StandardCharsets.UTF_8); + AtomicBoolean closed = new AtomicBoolean(); + ByteArrayInputStream input = new ByteArrayInputStream(bytes) { + @Override + public void close() throws IOException { + closed.set(true); + super.close(); + } + }; + storageServer.enqueue(new MockResponse().setResponseCode(200)); + stubCreate("S3", storageServer.url("/stream").toString()); + stubCompletion(); + + String handle = new FileClient(conductorClient).upload( + WORKFLOW_ID, + input, + new FileUploadOptions().setFileName("stream.txt")); - FileStorageException ex = assertThrows( - FileStorageException.class, () -> client.upload(null, source)); - assertTrue(ex.getMessage().contains("workflowId")); + assertEquals(HANDLE, handle); + assertFalse(closed.get()); } @Test - void builderCustomBackendOverridesBuiltIn() throws Exception { - Path source = writeFile("overridden.txt", "x".getBytes()); - stubCreateFile(StorageType.LOCAL); - stubConfirmUpload(); + void failedStreamBufferingDeletesTemporaryFileBeforeAnyServerRequest() throws Exception { + Set before = uploadTemporaryFiles(); + InputStream failing = new InputStream() { + private int reads; + + @Override + public int read() throws IOException { + if (reads++ < 4) { + return 'x'; + } + throw new IOException("simulated source failure"); + } + }; - StubFileStorageBackend override = new StubFileStorageBackend(); - FileClient client = FileClient.builder(conductorClient) - .addStorageBackend(override) - .build(); + assertThrows( + FileStorageException.class, + () -> new FileClient(conductorClient).upload( + WORKFLOW_ID, + failing, + new FileUploadOptions().setFileName("failed.bin"))); - client.upload("wf-1", source); + assertEquals(before, uploadTemporaryFiles()); + verify(conductorClient, never()).execute(any(ConductorClientRequest.class), any()); + } - assertNotNull(override.getUploaded(UPLOAD_URL), - "override backend should have received the upload"); + @Test + void validationHappensBeforeServerRequests() throws Exception { + FileClient client = new FileClient(conductorClient); + Path source = Files.writeString(tempDir.resolve("source.txt"), "x"); + + assertThrows(FileStorageException.class, () -> client.upload(" ", source)); + assertThrows( + FileStorageException.class, + () -> client.upload( + WORKFLOW_ID, + source, + new FileUploadOptions().setFileName("../unsafe.txt"))); + assertThrows( + FileStorageException.class, + () -> client.download(WORKFLOW_ID, "not-a-handle", tempDir.resolve("out"))); + assertThrows( + FileStorageException.class, + () -> client.download(WORKFLOW_ID, HANDLE, tempDir)); + assertThrows( + FileStorageException.class, + () -> client.upload(WORKFLOW_ID, new ByteArrayInputStream(new byte[0]), null)); + + verify(conductorClient, never()).execute(any(ConductorClientRequest.class), any()); } - // --- helpers --- + @Test + void interruptionIsPreservedAndStopsTransferRetries() throws Exception { + Thread.currentThread().interrupt(); - private Path writeFile(String name, byte[] bytes) throws Exception { - return Files.write(tempDir.resolve(name), bytes); - } + assertThrows( + FileStorageException.class, + () -> new FileClient(conductorClient).upload( + WORKFLOW_ID, Files.writeString(tempDir.resolve("interrupt.txt"), "x"))); - private FileClient clientWithBackends(Map backends) { - return new FileClient(conductorClient, new FileClientProperties(), backends); + assertTrue(Thread.currentThread().isInterrupted()); + assertEquals(0, storageServer.getRequestCount()); + verify(conductorClient, never()).execute(any(ConductorClientRequest.class), any()); } - private FileClient.Builder clientBuilder() { - return FileClient.builder(conductorClient); + @Test + void invalidPartConfigurationFailsBeforeServerRequest() { + FileClientProperties properties = new FileClientProperties(); + properties.setMultipartPartSize(0); + + assertThrows( + FileStorageException.class, + () -> new FileClient(conductorClient, properties)); + verify(conductorClient, never()).execute(any(ConductorClientRequest.class), any()); } - private void stubCreateFile(StorageType storageType) { + private void stubCreate(String storageType, String uploadUrl) { FileUploadResponse response = new FileUploadResponse(); - response.setFileHandleId(HANDLE_ID); + response.setFileHandleId(HANDLE); + response.setWorkflowId(WORKFLOW_ID); response.setStorageType(storageType); response.setUploadStatus(FileUploadStatus.UPLOADING); - response.setUploadUrl(UPLOAD_URL); - registerStub(req -> "/files".equals(req.getPath()), response); + response.setUploadUrl(uploadUrl); + stub(path("/files"), request -> response); } - private void stubConfirmUpload() { - registerStub(req -> "/files/{fileId}/upload-complete".equals(req.getPath()), - new FileUploadCompleteResponse()); + private void stubCompletion() { + stub( + path("/files/{workflowId}/{fileId}/upload-complete"), + request -> new FileUploadCompleteResponse()); } - private void stubDownloadUrl() { - FileDownloadUrlResponse resp = new FileDownloadUrlResponse(); - resp.setFileHandleId(HANDLE_ID); - resp.setDownloadUrl(DOWNLOAD_URL); - registerStub(req -> "/files/{workflowId}/{fileId}/download-url".equals(req.getPath()), resp); + private void stubMultipart(String partUrl) { + MultipartInitResponse initiated = new MultipartInitResponse(); + initiated.setFileHandleId(HANDLE); + initiated.setUploadId("upload-1"); + stub(path("/files/{workflowId}/{fileId}/multipart"), request -> initiated); + + FileUploadUrlResponse response = new FileUploadUrlResponse(); + response.setFileHandleId(HANDLE); + response.setUploadUrl(partUrl); + stub( + path("/files/{workflowId}/{fileId}/multipart/{uploadId}/part/{partNumber}"), + request -> response); } - private void stubMultipartInit() { - MultipartInitResponse init = new MultipartInitResponse(); - init.setFileHandleId(HANDLE_ID); - init.setUploadId("up-1"); - registerStub(req -> "/files/{fileId}/multipart".equals(req.getPath()), init); + private void stubMetadata(String storageType) { + FileMetadata metadata = metadata(storageType); + stub(path("/files/{workflowId}/{fileId}"), request -> metadata); + } - FileUploadUrlResponse partUrl = new FileUploadUrlResponse(); - partUrl.setFileHandleId(HANDLE_ID); - partUrl.setUploadUrl(UPLOAD_URL + "/part"); - registerStub( - req -> "/files/{fileId}/multipart/{uploadId}/part/{partNumber}".equals(req.getPath()), - partUrl); + private static FileMetadata metadata(String storageType) { + FileMetadata metadata = new FileMetadata(); + metadata.setFileHandleId(HANDLE); + metadata.setStorageType(storageType); + metadata.setUploadStatus(FileUploadStatus.UPLOADED); + return metadata; } - private void stubMultipartComplete() { - registerStub( - req -> "/files/{fileId}/multipart/{uploadId}/complete".equals(req.getPath()), - new FileUploadCompleteResponse()); + private void stubDownloadUrl(String url) { + FileDownloadUrlResponse response = new FileDownloadUrlResponse(); + response.setFileHandleId(HANDLE); + response.setDownloadUrl(url); + stub(path("/files/{workflowId}/{fileId}/download-url"), request -> response); } - private void registerStub(Predicate matcher, Object data) { - stubs.add(new Stub(matcher, data)); + private void stub(Predicate matcher, Responder responder) { + stubs.add(new Stub(matcher, responder)); } - private void verifyPathCalled(String path, String expectedFileId) { - ArgumentCaptor captor = ArgumentCaptor.forClass(ConductorClientRequest.class); - verify(conductorClient, atLeastOnce()).execute(captor.capture(), any()); - boolean matched = captor.getAllValues().stream() - .anyMatch(req -> path.equals(req.getPath()) - && pathParamMatches(req.getPathParams(), "fileId", expectedFileId)); - assertTrue(matched, "expected " + path + " with fileId=" + expectedFileId); + private static Predicate path(String expected) { + return request -> expected.equals(request.getPath()); } - private void verifyPathNotCalled(String path) { - ArgumentCaptor captor = ArgumentCaptor.forClass(ConductorClientRequest.class); + private ConductorClientRequest findRequest(String path) { + ArgumentCaptor captor = + ArgumentCaptor.forClass(ConductorClientRequest.class); verify(conductorClient, atLeastOnce()).execute(captor.capture(), any()); - assertTrue(captor.getAllValues().stream().noneMatch(req -> path.equals(req.getPath())), - "did not expect request to " + path); + return captor.getAllValues().stream() + .filter(request -> path.equals(request.getPath())) + .findFirst() + .orElseThrow(); } - private static boolean pathParamMatches(List params, String name, String value) { - return params != null && params.stream() - .anyMatch(p -> name.equals(p.name()) && value.equals(p.value())); + private void assertScopedRequest(String path, String workflowId, String fileId) { + ConductorClientRequest request = findRequest(path); + assertTrue(hasParam(request.getPathParams(), "workflowId", workflowId)); + assertTrue(hasParam(request.getPathParams(), "fileId", fileId)); } - private record Stub(Predicate matcher, Object data) {} + private static boolean hasParam(List params, String name, String value) { + return params.stream().anyMatch(param -> name.equals(param.name()) && value.equals(param.value())); + } - // Backend stub that claims multipart support and records parts. - private static final class MultipartBackend implements FileStorageBackend { - private final StorageType type; - final AtomicInteger parts = new AtomicInteger(); + private static boolean hasPartFile(Path directory) throws IOException { + try (var paths = Files.list(directory)) { + return paths.anyMatch(path -> path.getFileName().toString().endsWith(".part")); + } + } - MultipartBackend(StorageType type) { this.type = type; } + private static Set uploadTemporaryFiles() throws IOException { + try (var paths = Files.list(Path.of(System.getProperty("java.io.tmpdir")))) { + return paths + .filter(path -> path.getFileName().toString().startsWith("conductor-upload-")) + .collect(java.util.stream.Collectors.toSet()); + } + } - @Override public StorageType getStorageType() { return type; } - @Override public void upload(String url, Path localFile) { throw new AssertionError("single-part not expected"); } - @Override public void upload(String url, InputStream in, long len) { throw new AssertionError(); } - @Override public void download(String url, Path dest) { throw new AssertionError(); } - @Override public String uploadPart(String url, Path localFile, long offset, long length) { - return "etag-" + parts.incrementAndGet(); + private static String stackTraceMessages(Throwable error) { + StringBuilder messages = new StringBuilder(); + for (Throwable current = error; current != null; current = current.getCause()) { + messages.append(current.getMessage()).append('\n'); } - @Override public boolean hasMultipartSupport() { return true; } + return messages.toString(); + } + + private record Stub(Predicate matcher, Responder responder) {} + + @FunctionalInterface + private interface Responder { + Object respond(ConductorClientRequest request); } } diff --git a/conductor-client/src/test/java/org/conductoross/conductor/client/FileTransferAdaptersTest.java b/conductor-client/src/test/java/org/conductoross/conductor/client/FileTransferAdaptersTest.java new file mode 100644 index 000000000..0ebacc6d8 --- /dev/null +++ b/conductor-client/src/test/java/org/conductoross/conductor/client/FileTransferAdaptersTest.java @@ -0,0 +1,180 @@ +/* + * Copyright 2026 Conductor Authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package org.conductoross.conductor.client; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FileTransferAdaptersTest { + + @TempDir Path tempDir; + + private MockWebServer server; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void tearDown() throws IOException { + server.shutdown(); + Thread.interrupted(); + } + + @Test + void azureWholeUploadUsesBlockBlobHeader() throws Exception { + server.enqueue(new MockResponse().setResponseCode(201)); + Path source = Files.writeString(tempDir.resolve("blob.bin"), "blob"); + + new AzureFileTransferAdapter(new OkHttpClient()) + .upload(server.url("/blob?sig=secret").toString(), source); + + RecordedRequest request = server.takeRequest(); + assertEquals("PUT", request.getMethod()); + assertEquals("BlockBlob", request.getHeader("x-ms-blob-type")); + assertEquals("blob", request.getBody().readUtf8()); + } + + @Test + void azureMultipartUsesStableBlockIdBlockQueryAndExactRange() throws Exception { + server.enqueue(new MockResponse().setResponseCode(201)); + Path source = Files.write( + tempDir.resolve("blob.bin"), new byte[] {0, 1, 2, 3, 4, 5, 6}); + AzureFileTransferAdapter adapter = new AzureFileTransferAdapter(new OkHttpClient()); + + String blockId = adapter.uploadPart( + server.url("/blob?sig=secret&comp=old").toString(), source, 2, 3, 7); + + String expected = Base64.getEncoder().encodeToString( + "block-00000007".getBytes(StandardCharsets.UTF_8)); + assertEquals(expected, blockId); + RecordedRequest request = server.takeRequest(); + assertEquals("block", request.getRequestUrl().queryParameter("comp")); + assertEquals(expected, request.getRequestUrl().queryParameter("blockid")); + assertEquals("secret", request.getRequestUrl().queryParameter("sig")); + assertEquals(null, request.getHeader("x-ms-blob-type")); + assertArrayEquals(new byte[] {2, 3, 4}, request.getBody().readByteArray()); + } + + @Test + void s3MultipartRequiresAndReturnsEtag() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).addHeader("ETag", "\"etag-1\"")); + Path source = Files.write(tempDir.resolve("object.bin"), new byte[] {1, 2, 3, 4}); + S3FileTransferAdapter adapter = new S3FileTransferAdapter(new OkHttpClient()); + + String etag = adapter.uploadPart(server.url("/part").toString(), source, 1, 2, 1); + + assertEquals("\"etag-1\"", etag); + assertArrayEquals(new byte[] {2, 3}, server.takeRequest().getBody().readByteArray()); + + server.enqueue(new MockResponse().setResponseCode(200)); + IOException error = assertThrows( + IOException.class, + () -> adapter.uploadPart(server.url("/part-2").toString(), source, 0, 1, 2)); + assertTrue(error.getMessage().contains("ETag")); + } + + @Test + void rangeUploadFailsInsteadOfSilentlySendingTooFewBytes() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).addHeader("ETag", "etag")); + Path source = Files.write(tempDir.resolve("short.bin"), new byte[] {1, 2}); + + IOException error = assertThrows( + IOException.class, + () -> new S3FileTransferAdapter(new OkHttpClient()) + .uploadPart(server.url("/part").toString(), source, 1, 5, 1)); + + assertFalse(error.getMessage().contains(server.getHostName())); + } + + @Test + void gcsAndLocalRemainSingleRequestOnly() throws Exception { + GcsFileTransferAdapter gcs = new GcsFileTransferAdapter(new OkHttpClient()); + assertFalse(gcs.supportsMultipart()); + + LocalFileTransferAdapter local = new LocalFileTransferAdapter(); + assertFalse(local.supportsMultipart()); + Path source = Files.write(tempDir.resolve("source.bin"), new byte[] {7, 8}); + Path stored = tempDir.resolve("storage/stored.bin"); + local.upload(stored.toUri().toString(), source); + Path downloaded = tempDir.resolve("download.bin"); + local.download(stored.toUri().toString(), downloaded); + assertArrayEquals(new byte[] {7, 8}, Files.readAllBytes(downloaded)); + + assertThrows( + IOException.class, + () -> local.upload("https://example.invalid/file", source)); + } + + @Test + void signedTransfersDisableRedirectsAndRedactSignedUrl() throws Exception { + String target = server.url("/target").toString(); + String signedUrl = server.url("/redirect?signature=do-not-leak").toString(); + server.enqueue(new MockResponse().setResponseCode(302).addHeader("Location", target)); + Path source = Files.writeString(tempDir.resolve("source.txt"), "content"); + + IOException error = assertThrows( + IOException.class, + () -> new S3FileTransferAdapter(new OkHttpClient()).upload(signedUrl, source)); + + assertEquals(1, server.getRequestCount()); + assertFalse(allMessages(error).contains("do-not-leak")); + } + + @Test + void injectedHttpClientInterceptorsAreNotUsedForSignedRequests() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + OkHttpClient configured = new OkHttpClient.Builder() + .addInterceptor( + chain -> chain.proceed( + chain.request().newBuilder() + .header("Authorization", "Bearer conductor-secret") + .build())) + .build(); + Path source = Files.writeString(tempDir.resolve("source.txt"), "content"); + + new GcsFileTransferAdapter(configured) + .upload(server.url("/upload").toString(), source); + + assertEquals(null, server.takeRequest().getHeader("Authorization")); + } + + private static String allMessages(Throwable error) { + StringBuilder value = new StringBuilder(); + for (Throwable current = error; current != null; current = current.getCause()) { + value.append(current.getMessage()).append('\n'); + } + return value.toString(); + } +} diff --git a/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/FileHandlerDeserializerTest.java b/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/FileHandlerDeserializerTest.java deleted file mode 100644 index 0c0c691ff..000000000 --- a/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/FileHandlerDeserializerTest.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.sdk.file; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import com.netflix.conductor.common.config.ObjectMapperProvider; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.ObjectReader; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.mock; - - -class FileHandlerDeserializerTest { - - private static final String FILE_HANDLE_ID = "conductor://file/abc-123"; - - private final ObjectMapper mapper = new ObjectMapperProvider().getObjectMapper(); - private WorkflowFileClient client; - - @BeforeEach - void setUp() { - client = mock(WorkflowFileClient.class); - } - - @Test - void deserializesRawStringToManagedFileHandler() throws Exception { - FileHandler handler = readerWithClient().readValue("\"" + FILE_HANDLE_ID + "\""); - - assertInstanceOf(ManagedFileHandler.class, handler); - assertEquals(FILE_HANDLE_ID, handler.getFileHandleId()); - } - - @Test - void deserializesJsonObjectWithFileHandleIdToManagedFileHandler() throws Exception { - String json = """ - { - "fileHandleId": "%s", - "fileName": "doc.pdf", - "contentType": "application/pdf" - } - """.formatted(FILE_HANDLE_ID); - - FileHandler handler = readerWithClient().readValue(json); - - assertInstanceOf(ManagedFileHandler.class, handler); - assertEquals(FILE_HANDLE_ID, handler.getFileHandleId()); - } - - @Test - void throwsWhenStringLacksPrefix() { - assertThrows(FileStorageException.class, - () -> readerWithClient().readValue("\"raw-uuid\"")); - } - - @Test - void throwsWhenJsonObjectFileHandleIdLacksPrefix() { - String json = "{\"fileHandleId\":\"raw-uuid\"}"; - - assertThrows(FileStorageException.class, () -> readerWithClient().readValue(json)); - } - - @Test - void throwsWhenJsonObjectMissingFileHandleId() { - String json = "{\"fileName\":\"doc.pdf\"}"; - - assertThrows(FileStorageException.class, () -> readerWithClient().readValue(json)); - } - - @Test - void throwsWhenWorkflowFileClientAttributeMissing() { - // No .withAttribute(...) — context attribute is null. - ObjectReader readerWithoutClient = mapper.readerFor(FileHandler.class); - - assertThrows(FileStorageException.class, - () -> readerWithoutClient.readValue("\"" + FILE_HANDLE_ID + "\"")); - } - - private ObjectReader readerWithClient() { - return mapper.readerFor(FileHandler.class) - .withAttribute(FileHandlerDeserializer.WORKFLOW_FILE_CLIENT_ATTR, client); - } -} diff --git a/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/FileHandlerSerializationTest.java b/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/FileHandlerSerializationTest.java deleted file mode 100644 index 227fd50a9..000000000 --- a/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/FileHandlerSerializationTest.java +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.sdk.file; - -import java.io.InputStream; -import java.nio.file.Path; -import java.util.Map; -import java.util.Set; - -import org.junit.jupiter.api.Test; - -import com.netflix.conductor.common.config.ObjectMapperProvider; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; - -class FileHandlerSerializationTest { - - private static final ObjectMapper MAPPER = new ObjectMapperProvider().getObjectMapper(); - private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; - private static final Set EXPECTED_FIELDS = Set.of("fileHandleId", "contentType", "fileName"); - - @Test - void localFileHandlerSerializesToThreeFields() { - FileHandler handler = FileHandler.fromLocalFile(Path.of("docs/note.txt"), "text/plain"); - - Map json = MAPPER.convertValue(handler, MAP_TYPE); - - assertEquals(EXPECTED_FIELDS, json.keySet()); - assertNull(json.get("fileHandleId")); // local handler not yet uploaded - assertEquals("text/plain", json.get("contentType")); - assertEquals("note.txt", json.get("fileName")); - } - - @Test - void stubManagedFileHandlerSerializesToThreeFields() { - FileHandler handler = new StubManagedFileHandler( - "conductor://file/abc-123", "report.pdf", "application/pdf"); - - Map json = MAPPER.convertValue(handler, MAP_TYPE); - - assertEquals(EXPECTED_FIELDS, json.keySet()); - assertEquals("conductor://file/abc-123", json.get("fileHandleId")); - assertEquals("application/pdf", json.get("contentType")); - assertEquals("report.pdf", json.get("fileName")); - } - - @Test - void userImplSerializesToThreeFieldsOnly() throws Exception { - FileHandler handler = new UserImpl(); - - String jsonString = MAPPER.writeValueAsString(handler); - Map json = MAPPER.readValue(jsonString, MAP_TYPE); - - assertEquals(EXPECTED_FIELDS, json.keySet()); - assertEquals("conductor://file/u-1", json.get("fileHandleId")); - assertFalse(jsonString.contains("inputStream")); - assertFalse(jsonString.contains("fileSize")); - assertFalse(jsonString.contains("extra")); - } - - @Test - void serializesInsideTaskOutputMap() { - FileHandler handler = new UserImpl(); - Map output = Map.of("file", handler, "answer", 42); - - Map json = MAPPER.convertValue(output, MAP_TYPE); - - @SuppressWarnings("unchecked") - Map fileJson = (Map) json.get("file"); - assertEquals(EXPECTED_FIELDS, fileJson.keySet()); - assertEquals(42, json.get("answer")); - } - - private static final class UserImpl implements FileHandler { - @Override public String getFileHandleId() { return "conductor://file/u-1"; } - @Override public InputStream getInputStream() { throw new UnsupportedOperationException(); } - @Override public String getFileName() { return "u.bin"; } - @Override public String getContentType() { return "application/octet-stream"; } - @Override public long getFileSize() { return 7L; } - public String getExtra() { return "should-not-leak"; } - } - - /** Minimal {@link ManagedFileHandler}-like impl for testing without a workflow client. */ - private static final class StubManagedFileHandler implements FileHandler { - private final String fileHandleId; - private final String fileName; - private final String contentType; - StubManagedFileHandler(String id, String fileName, String contentType) { - this.fileHandleId = id; - this.fileName = fileName; - this.contentType = contentType; - } - @Override public String getFileHandleId() { return fileHandleId; } - @Override public InputStream getInputStream() { throw new UnsupportedOperationException(); } - @Override public String getFileName() { return fileName; } - @Override public String getContentType() { return contentType; } - @Override public long getFileSize() { return 0L; } - } -} diff --git a/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/FileHandlerTest.java b/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/FileHandlerTest.java deleted file mode 100644 index 1fee77164..000000000 --- a/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/FileHandlerTest.java +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.sdk.file; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class FileHandlerTest { - - private static final String RAW_ID = "abc-123"; - private static final String HANDLE_ID = FileHandler.PREFIX + RAW_ID; - - @Test - void toFileHandleIdAddsPrefixOnce() { - assertEquals(HANDLE_ID, FileHandler.toFileHandleId(RAW_ID)); - } - - @Test - void toFileHandleIdIsIdempotent() { - String already = FileHandler.toFileHandleId(HANDLE_ID); - assertSame(HANDLE_ID, already, "must not re-prefix an already-prefixed id"); - } - - @Test - void toFileIdStripsPrefix() { - assertEquals(RAW_ID, FileHandler.toFileId(HANDLE_ID)); - } - - @Test - void toFileIdReturnsInputWhenPrefixAbsent() { - assertEquals(RAW_ID, FileHandler.toFileId(RAW_ID)); - } - - @Test - void roundTripFromRawIdIsStable() { - String handle = FileHandler.toFileHandleId(RAW_ID); - String raw = FileHandler.toFileId(handle); - assertEquals(RAW_ID, raw); - } - - @Test - void isFileHandleIdTrueForPrefixedString() { - assertTrue(FileHandler.isFileHandleId(HANDLE_ID)); - } - - @Test - void isFileHandleIdFalseForUnprefixedString() { - assertFalse(FileHandler.isFileHandleId(RAW_ID)); - } - - @Test - void isFileHandleIdFalseForNonStringValues() { - assertFalse(FileHandler.isFileHandleId(null)); - assertFalse(FileHandler.isFileHandleId(42)); - assertFalse(FileHandler.isFileHandleId(new Object())); - } - - @Test - void prefixIsTheDocumentedScheme() { - assertEquals("conductor://file/", FileHandler.PREFIX); - } -} diff --git a/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/LocalFileHandlerTest.java b/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/LocalFileHandlerTest.java deleted file mode 100644 index 69533010a..000000000 --- a/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/LocalFileHandlerTest.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.sdk.file; - -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import static org.junit.jupiter.api.Assertions.*; - -class LocalFileHandlerTest { - - @TempDir - Path temp; - - @Test - void testGetFileHandleIdReturnsNull() throws Exception { - Path file = temp.resolve("test.txt"); - Files.writeString(file, "hello"); - FileHandler handler = FileHandler.fromLocalFile(file); - assertNull(handler.getFileHandleId()); - } - - @Test - void testGetInputStream() throws Exception { - Path file = temp.resolve("test.txt"); - Files.writeString(file, "hello"); - FileHandler handler = FileHandler.fromLocalFile(file); - try (InputStream in = handler.getInputStream()) { - assertEquals("hello", new String(in.readAllBytes())); - } - } - - @Test - void testGetFileName() throws Exception { - Path file = temp.resolve("report.pdf"); - Files.createFile(file); - FileHandler handler = FileHandler.fromLocalFile(file); - assertEquals("report.pdf", handler.getFileName()); - } - - @Test - void testGetContentType() throws Exception { - Path file = temp.resolve("doc.pdf"); - Files.createFile(file); - FileHandler handler = FileHandler.fromLocalFile(file, "application/pdf"); - assertEquals("application/pdf", handler.getContentType()); - } - - @Test - void testGetContentTypeDefault() throws Exception { - Path file = temp.resolve("data.bin"); - Files.createFile(file); - FileHandler handler = FileHandler.fromLocalFile(file); - assertEquals("application/octet-stream", handler.getContentType()); - } - - @Test - void testGetFileSize() throws Exception { - Path file = temp.resolve("data.bin"); - Files.write(file, new byte[]{1, 2, 3, 4, 5}); - FileHandler handler = FileHandler.fromLocalFile(file); - assertEquals(5, handler.getFileSize()); - } - - @Test - void testGetInputStreamMissingFile() { - FileHandler handler = FileHandler.fromLocalFile(Path.of("/nonexistent/file.txt")); - assertThrows(FileStorageException.class, handler::getInputStream); - } -} diff --git a/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/ManagedFileHandlerTest.java b/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/ManagedFileHandlerTest.java deleted file mode 100644 index 6ec22f5ce..000000000 --- a/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/ManagedFileHandlerTest.java +++ /dev/null @@ -1,190 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.sdk.file; - -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; - -import org.conductoross.conductor.client.model.file.FileHandle; -import org.conductoross.conductor.client.model.file.StorageType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -class ManagedFileHandlerTest { - - private static final String FILE_ID = "conductor://file/abc-123"; - private static final String RAW_ID = "abc-123"; - private static final byte[] CONTENT = {1, 2, 3, 4, 5}; - - @TempDir - Path cacheDir; - - private WorkflowFileClient workflowFileClient; - - @BeforeEach - void setUp() { - workflowFileClient = mock(WorkflowFileClient.class); - when(workflowFileClient.getCacheDirectory()).thenReturn(cacheDir.toString()); - when(workflowFileClient.getRetryCount()).thenReturn(3); - } - - @Test - void metadataLazilyLoadedOnFirstAccessAndCached() { - when(workflowFileClient.getMetadata(FILE_ID)).thenReturn(metadataFixture()); - ManagedFileHandler handler = new ManagedFileHandler(FILE_ID, workflowFileClient); - - assertEquals("report.pdf", handler.getFileName()); - assertEquals("application/pdf", handler.getContentType()); - assertEquals(1234L, handler.getFileSize()); - - // three getters, only one server call - verify(workflowFileClient, times(1)).getMetadata(FILE_ID); - } - - @Test - void metadataNotFetchedIfPrePopulated() { - ManagedFileHandler handler = new ManagedFileHandler(FILE_ID, workflowFileClient); - handler.setFileName("pre.txt"); - handler.setContentType("text/plain"); - handler.setFileSize(99L); - handler.setStorageType(StorageType.LOCAL); - - assertEquals("pre.txt", handler.getFileName()); - assertEquals("text/plain", handler.getContentType()); - assertEquals(99L, handler.getFileSize()); - - verify(workflowFileClient, never()).getMetadata(anyString()); - } - - @Test - void getFileHandleIdReturnsConstructorValue() { - ManagedFileHandler handler = new ManagedFileHandler(FILE_ID, workflowFileClient); - assertEquals(FILE_ID, handler.getFileHandleId()); - } - - @Test - void getInputStreamDownloadsAndReadsContent() throws Exception { - when(workflowFileClient.getMetadata(FILE_ID)).thenReturn(metadataFixture()); - stubSuccessfulDownload(); - - ManagedFileHandler handler = new ManagedFileHandler(FILE_ID, workflowFileClient); - - try (InputStream in = handler.getInputStream()) { - assertArrayEquals(CONTENT, in.readAllBytes()); - } - - verify(workflowFileClient, times(1)).download(eq(FILE_ID), eq(StorageType.LOCAL), any(Path.class)); - } - - @Test - void downloadIsIdempotentAcrossCalls() throws Exception { - when(workflowFileClient.getMetadata(FILE_ID)).thenReturn(metadataFixture()); - stubSuccessfulDownload(); - - ManagedFileHandler handler = new ManagedFileHandler(FILE_ID, workflowFileClient); - handler.getInputStream().close(); - handler.getInputStream().close(); - handler.getInputStream().close(); - - verify(workflowFileClient, times(1)).download(anyString(), any(), any(Path.class)); - } - - @Test - void preExistingCacheFileSkipsDownload() throws Exception { - when(workflowFileClient.getMetadata(FILE_ID)).thenReturn(metadataFixture()); - // Seed the cache at the exact path ManagedFileHandler computes. - Path expected = cacheDir.resolve(RAW_ID + "_report.pdf"); - Files.write(expected, CONTENT); - - ManagedFileHandler handler = new ManagedFileHandler(FILE_ID, workflowFileClient); - try (InputStream in = handler.getInputStream()) { - assertArrayEquals(CONTENT, in.readAllBytes()); - } - - verify(workflowFileClient, never()).download(anyString(), any(), any(Path.class)); - } - - @Test - void downloadRetriesUpToConfiguredCount() throws Exception { - when(workflowFileClient.getMetadata(FILE_ID)).thenReturn(metadataFixture()); - - // Fail twice, then succeed on the 3rd attempt (retryCount = 3). - int[] calls = {0}; - doAnswer(inv -> { - calls[0]++; - if (calls[0] < 3) { - throw new FileStorageException("transient"); - } - Path dest = inv.getArgument(2); - Files.createDirectories(dest.getParent()); - Files.write(dest, CONTENT); - return null; - }).when(workflowFileClient).download(anyString(), any(), any(Path.class)); - - ManagedFileHandler handler = new ManagedFileHandler(FILE_ID, workflowFileClient); - try (InputStream in = handler.getInputStream()) { - assertArrayEquals(CONTENT, in.readAllBytes()); - } - - verify(workflowFileClient, times(3)).download(anyString(), any(), any(Path.class)); - } - - @Test - void downloadFailsAfterExhaustingRetries() { - when(workflowFileClient.getMetadata(FILE_ID)).thenReturn(metadataFixture()); - doThrow(new FileStorageException("boom")) - .when(workflowFileClient).download(anyString(), any(), any(Path.class)); - - ManagedFileHandler handler = new ManagedFileHandler(FILE_ID, workflowFileClient); - - FileStorageException ex = assertThrows( - FileStorageException.class, handler::getInputStream); - assertEquals(true, ex.getMessage().contains(FILE_ID)); - verify(workflowFileClient, times(3)).download(anyString(), any(), any(Path.class)); - } - - private FileHandle metadataFixture() { - FileHandle h = new FileHandle(); - h.setFileHandleId(FILE_ID); - h.setFileName("report.pdf"); - h.setContentType("application/pdf"); - h.setFileSize(1234L); - h.setStorageType(StorageType.LOCAL); - return h; - } - - private void stubSuccessfulDownload() { - doAnswer(inv -> { - Path dest = inv.getArgument(2); - Files.createDirectories(dest.getParent()); - Files.write(dest, CONTENT); - return null; - }).when(workflowFileClient).download(anyString(), any(), any(Path.class)); - } -} diff --git a/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/StubFileStorageBackend.java b/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/StubFileStorageBackend.java deleted file mode 100644 index 5e600d108..000000000 --- a/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/StubFileStorageBackend.java +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.sdk.file; - -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -import org.conductoross.conductor.client.model.file.StorageType; - -public class StubFileStorageBackend implements FileStorageBackend { - - private final Map uploads = new ConcurrentHashMap<>(); - - @Override - public StorageType getStorageType() { return StorageType.LOCAL; } - - @Override - public void upload(String url, Path localFile) { - try { - uploads.put(url, Files.readAllBytes(localFile)); - } catch (Exception e) { - throw new FileStorageException("Stub upload failed", e); - } - } - - @Override - public void upload(String url, InputStream inputStream, long contentLength) { - try { - uploads.put(url, inputStream.readAllBytes()); - } catch (Exception e) { - throw new FileStorageException("Stub stream upload failed", e); - } - } - - @Override - public void download(String url, Path destination) { - try { - byte[] data = uploads.get(url); - if (data == null) { - throw new FileStorageException("No data at: " + url); - } - Files.createDirectories(destination.getParent()); - Files.write(destination, data); - } catch (FileStorageException e) { - throw e; - } catch (Exception e) { - throw new FileStorageException("Stub download failed", e); - } - } - - @Override - public String uploadPart(String url, Path localFile, long offset, long length) { - try { - byte[] data = Files.readAllBytes(localFile); - uploads.put(url + ":" + offset, data); - return "stub-etag-" + offset; - } catch (Exception e) { - throw new FileStorageException("Stub part upload failed", e); - } - } - - public byte[] getUploaded(String url) { return uploads.get(url); } -} diff --git a/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/storage/LocalFileStorageBackendTest.java b/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/storage/LocalFileStorageBackendTest.java deleted file mode 100644 index 6b9c4f91e..000000000 --- a/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/storage/LocalFileStorageBackendTest.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.sdk.file.storage; - -import java.io.ByteArrayInputStream; -import java.nio.file.FileSystemNotFoundException; -import java.nio.file.Files; -import java.nio.file.Path; - -import org.conductoross.conductor.client.model.file.StorageType; -import org.conductoross.conductor.client.storage.LocalFileStorageBackend; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import static org.junit.jupiter.api.Assertions.*; - -class LocalFileStorageBackendTest { - - private final LocalFileStorageBackend backend = new LocalFileStorageBackend(); - - @Test - void storageTypeIsLocal() { - assertEquals(StorageType.LOCAL, backend.getStorageType()); - } - - @Test - void uploadFromPathWritesToFileUri(@TempDir Path tempDir) throws Exception { - Path source = Files.write(tempDir.resolve("src.bin"), new byte[] {1, 2, 3}); - Path dest = tempDir.resolve("uploads/out.bin"); - String url = dest.toUri().toString(); - - backend.upload(url, source); - - assertTrue(Files.exists(dest)); - assertArrayEquals(new byte[] {1, 2, 3}, Files.readAllBytes(dest)); - } - - @Test - void uploadFromStreamWritesToFileUri(@TempDir Path tempDir) throws Exception { - byte[] payload = "hello".getBytes(); - Path dest = tempDir.resolve("uploads/stream.bin"); - String url = dest.toUri().toString(); - - backend.upload(url, new ByteArrayInputStream(payload), payload.length); - - assertTrue(Files.exists(dest)); - assertArrayEquals(payload, Files.readAllBytes(dest)); - } - - @Test - void downloadReadsFromFileUri(@TempDir Path tempDir) throws Exception { - Path source = Files.write(tempDir.resolve("src.bin"), new byte[] {9, 8, 7}); - Path dest = tempDir.resolve("cache/out.bin"); - String url = source.toUri().toString(); - - backend.download(url, dest); - - assertTrue(Files.exists(dest)); - assertArrayEquals(new byte[] {9, 8, 7}, Files.readAllBytes(dest)); - } - - @Test - void httpSchemeRaisesFileSystemNotFound(@TempDir Path tempDir) throws Exception { - Path source = Files.write(tempDir.resolve("src.bin"), new byte[] {0}); - String httpUrl = "https://example.com/uploads/foo"; - - assertThrows( - FileSystemNotFoundException.class, () -> backend.upload(httpUrl, source)); - } - - @Test - void relativeUrlRaisesIllegalArgument(@TempDir Path tempDir) throws Exception { - Path source = Files.write(tempDir.resolve("src.bin"), new byte[] {0}); - String relativeUrl = "uploads/foo"; - - assertThrows(IllegalArgumentException.class, () -> backend.upload(relativeUrl, source)); - } -} diff --git a/docs/design/file-client.md b/docs/design/file-client.md new file mode 100644 index 000000000..b11a9cfb3 --- /dev/null +++ b/docs/design/file-client.md @@ -0,0 +1,92 @@ +# FileClient Design + +## Decision + +Provider-specific signed transfer behavior remains necessary, but it is implemented as package-private transfer adapters beneath one public `FileClient`. Applications do not select or extend adapters and do not orchestrate multipart sessions. + +## Public boundary + +`FileClient` exposes five workflow-scoped operations: + +```java +String upload(String workflowId, Path source); +String upload(String workflowId, Path source, FileUploadOptions options); +String upload(String workflowId, InputStream source, FileUploadOptions options); +Path download(String workflowId, String fileHandleId, Path destination); +FileMetadata getMetadata(String workflowId, String fileHandleId); +``` + +Uploads return an opaque `conductor://file/` string. That string is the only file representation stored in workflow input/output. `FileUploadOptions` carries filename, content type, and optional task ID; multipart selection is not caller policy. + +## Responsibilities + +```text +FileClient + +-- Conductor API: create, refresh, complete, metadata, multipart lifecycle + +-- retry classification and interruption + +-- temporary files and atomic destination replacement + +-- adapter selection + +-- S3FileTransferAdapter + +-- AzureFileTransferAdapter + +-- GcsFileTransferAdapter + +-- LocalFileTransferAdapter + +-- GenericHttpFileTransferAdapter + +-- SignedUrlHttpTransfer +``` + +`FileClient` owns orchestration. An adapter performs exactly one upload, ranged part upload, or download attempt. This split keeps URL refresh and retry state in one layer while preserving provider protocol rules. + +## Signed HTTP isolation + +The Conductor API client and signed-transfer client have different trust boundaries. `FileClient` derives a raw `OkHttpClient` for signed transfers that has: + +- no application interceptors or network interceptors; +- no authenticator; +- no cookie jar; +- redirects disabled. + +This prevents Conductor credentials or ambient cookies from reaching object-storage hosts. Transfer errors contain operation/status information but not the signed URL. + +## Upload paths + +Path uploads validate workflow ID, source readability, filename safety, and multipart configuration before creating a server record. + +Stream uploads require a filename and copy the stream to a temporary path before server creation. This makes retries repeatable and avoids an orphaned metadata record if buffering fails. The client deletes the temporary path but never closes the caller-owned stream. + +## Multipart selection + +Multipart is used when: + +```text +source size > multipartThreshold AND selected adapter supports multipart +``` + +Parts are sequential, 1-based, and bounded by the provider-neutral maximum part count. Each ranged body positions a `FileChannel` at the requested offset and must emit exactly the requested length. + +| Adapter | Multipart | Completion token | +|---|---|---| +| S3 | Yes | Required non-blank `ETag` response header. | +| Azure | Yes | Deterministic Base64 block ID. | +| GCS | No | — | +| Local | No | — | +| Generic HTTP | No | — | + +Azure whole-file upload includes `x-ms-blob-type: BlockBlob`. Azure part upload omits it, adds `comp=block&blockid=...`, and returns the block ID for `commitBlockList`. + +## Retry ownership + +Adapters never retry. `FileClient` retries transient I/O, HTTP 408/429, expired-signature responses, and 5xx responses up to `retryCount` after the initial attempt. It refreshes the signed URL before every retry. Permanent 4xx responses fail immediately. + +Completion is reconciled through workflow-scoped metadata because a lost response is ambiguous. If metadata reports `UPLOADED`, the operation succeeded. A failed multipart session triggers a best-effort server abort without replacing the original failure. + +All retry loops check and preserve thread interruption. + +## Download safety + +Downloads validate the handle and destination before network requests, create missing parent directories, and transfer into a unique sibling temporary file. Success requires a non-null HTTP body and an atomic replace of the destination. Any failure deletes the temporary file and leaves the prior destination untouched. + +## Server compatibility + +This client requires workflow-aware file routes for upload refresh, completion, metadata, download, and multipart operations. Upload mutation calls must use the exact owning workflow ID; metadata and download calls may use a server-authorized workflow-family member. + +The previous smart-file types and task-runner integration are intentionally removed. Mixed worker versions emit incompatible workflow shapes (`{fileHandleId, ...}` versus a raw handle string) and must be deployed in coordination. diff --git a/docs/file-client.md b/docs/file-client.md new file mode 100644 index 000000000..18ad575a8 --- /dev/null +++ b/docs/file-client.md @@ -0,0 +1,212 @@ +# FileClient Guide + +`FileClient` uploads, downloads, and inspects workflow-scoped binary files. Workflow data contains only opaque strings such as `conductor://file/`; workers never pass storage URLs or SDK file objects to one another. + +The Conductor server must have file storage enabled and must expose the workflow-scoped File API. + +## Create a client + +```java +ConductorClient conductor = ConductorClient.builder() + .basePath("http://localhost:8080/api") + .build(); + +FileClient files = new FileClient(conductor); +``` + +Spring applications can inject the auto-configured `FileClient` bean. Configuration uses `conductor.file-client.*`: + +```properties +conductor.file-client.retry-count=3 +conductor.file-client.multipart-threshold=104857600 +conductor.file-client.multipart-part-size=10485760 +``` + +## Public API + +```java +String upload(String workflowId, Path source); +String upload(String workflowId, Path source, FileUploadOptions options); +String upload(String workflowId, InputStream source, FileUploadOptions options); +Path download(String workflowId, String fileHandleId, Path destination); +FileMetadata getMetadata(String workflowId, String fileHandleId); +``` + +Every call requires workflow context. Upload mutations belong to the exact workflow that created the file. Downloads and metadata reads are available to that workflow's parent/sub-workflow family. + +## Upload examples + +### Path with inferred filename + +```java +Path source = Path.of("/work/input.csv"); +String handle = files.upload(workflowId, source); +``` + +The source must be a readable regular file. `input.csv` becomes the stored filename. + +### Path with filename, content type, and task ID + +```java +FileUploadOptions options = new FileUploadOptions() + .setFileName("customer-export.csv") + .setContentType("text/csv") + .setTaskId(task.getTaskId()); + +String handle = files.upload(task.getWorkflowInstanceId(), source, options); +``` + +`fileName` overrides the path's final segment. Filenames must be simple names, not absolute paths or values containing path separators. + +### Caller-owned stream + +```java +FileUploadOptions options = new FileUploadOptions() + .setFileName("events.ndjson") + .setContentType("application/x-ndjson") + .setTaskId(task.getTaskId()); + +try (InputStream source = eventStore.openExport()) { + String handle = files.upload(task.getWorkflowInstanceId(), source, options); +} +``` + +A stream upload requires `fileName`. The client buffers the stream to a temporary file before it creates the server record, deletes the temporary file afterward, and does not close the stream. The caller remains responsible for closing it. + +### Large file + +Application code uses the same path overload for large files: + +```java +String handle = files.upload( + workflowId, + Path.of("/work/archive.tar"), + new FileUploadOptions().setContentType("application/x-tar")); +``` + +Multipart is automatic when the file size is greater than `multipart-threshold` and the provider supports it. S3 uses validated part ETags; Azure uses deterministic block IDs. GCS, local storage, and generic HTTP(S) signed URLs remain single-request. There is no multipart flag on `FileUploadOptions`. + +## Metadata example + +```java +FileMetadata metadata = files.getMetadata(workflowId, handle); + +System.out.printf( + "%s (%s), %d bytes, hash=%s, status=%s%n", + metadata.getFileName(), + metadata.getContentType(), + metadata.getFileSize(), + metadata.getContentHash(), + metadata.getUploadStatus()); +``` + +Metadata reads also support completion reconciliation: an `UPLOADED` status means a completion request succeeded even if its HTTP response was lost. + +## Download examples + +### New destination + +```java +Path destination = Path.of("/work/downloads/input.csv"); +Path downloaded = files.download(workflowId, handle, destination); +``` + +The client creates missing parent directories. + +### Replace an existing destination safely + +```java +Path existing = Path.of("/work/current-model.bin"); +files.download(workflowId, modelHandle, existing); +``` + +The download first goes to a unique sibling `.part` file. Only a complete transfer atomically replaces `current-model.bin`. Failure removes the partial file and preserves the existing destination. A filesystem that cannot perform atomic replacement is rejected explicitly. + +## Raw worker example + +```java +public final class ConvertWorker implements Worker { + private final FileClient files; + + public ConvertWorker(FileClient files) { + this.files = files; + } + + @Override + public String getTaskDefName() { + return "convert_document"; + } + + @Override + public TaskResult execute(Task task) { + TaskResult result = new TaskResult(task); + Path input = null; + Path output = null; + try { + String workflowId = task.getWorkflowInstanceId(); + String inputHandle = (String) task.getInputData().get("document"); + input = Files.createTempFile("document-", ".bin"); + output = Files.createTempFile("converted-", ".pdf"); + + files.download(workflowId, inputHandle, input); + convertToPdf(input, output); + + String outputHandle = files.upload( + workflowId, + output, + new FileUploadOptions() + .setFileName("converted.pdf") + .setContentType("application/pdf") + .setTaskId(task.getTaskId())); + + result.setStatus(TaskResult.Status.COMPLETED); + result.addOutputData("document", outputHandle); + } catch (Exception e) { + result.setStatus(TaskResult.Status.FAILED); + result.setReasonForIncompletion(e.getMessage()); + } finally { + deleteQuietly(input); + deleteQuietly(output); + } + return result; + } +} +``` + +The task input and output are strings. No task-runner file integration is required. + +## Annotated worker example + +```java +public record ConvertInput(String document) {} + +@WorkerTask("convert_document") +public @OutputParam("document") String convert( + ConvertInput input, + @WorkflowInstanceIdInputParam String workflowId) throws IOException { + Path source = Files.createTempFile("document-", ".bin"); + Path output = Files.createTempFile("converted-", ".pdf"); + try { + files.download(workflowId, input.document(), source); + convertToPdf(source, output); + return files.upload( + workflowId, + output, + new FileUploadOptions().setContentType("application/pdf")); + } finally { + Files.deleteIfExists(source); + Files.deleteIfExists(output); + } +} +``` + +## Retry and security behavior + +- A new signed URL is requested before every retry. +- Retries are limited to transient I/O failures, throttling, expired signatures, and server errors. +- Thread interruption is preserved and stops retries. +- Signed URLs are redacted from exceptions. +- Signed requests use a separate raw HTTP client with redirects disabled and no Conductor authentication, cookies, or application interceptors. +- Unknown server storage types can use the generic adapter only when the supplied signed URL is HTTP(S). + +See [FileClient Design](design/file-client.md) for component boundaries and [Media Transcoder](../examples/file-storage/media-transcoder/) for a complete multi-worker workflow. diff --git a/examples/README.md b/examples/README.md index bddf9de95..b6d6de1f1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,6 +26,14 @@ See [`manifest.json`](manifest.json) for per-example metadata: category, workflo --- +## File Storage + +| Example | Description | +|---|---| +| [media-transcoder](file-storage/media-transcoder/) | Complete `FileClient` workflow covering path and stream uploads, metadata, downloads, raw and annotated workers, handle passing, and automatic multipart selection. | + +--- + ## Advanced (50) | Example | Description | diff --git a/examples/file-storage/media-transcoder/README.md b/examples/file-storage/media-transcoder/README.md new file mode 100644 index 000000000..43308fb4c --- /dev/null +++ b/examples/file-storage/media-transcoder/README.md @@ -0,0 +1,56 @@ +# FileClient Media Transcoder + +This example passes videos, thumbnails, and a JSON manifest through a workflow as opaque `conductor://file/` strings. Workers inject `FileClient` and explicitly transfer files; there are no smart file objects or automatic task-runner uploads. + +## Cases covered + +| Case | Source | +|---|---| +| Upload a caller-owned `InputStream` with required filename and content type | [`UploadPrimaryVideoWorker.java`](src/main/java/io/conductor/example/mediatranscoder/workers/UploadPrimaryVideoWorker.java) | +| Download a handle, process the file, and upload a `Path` from an annotated worker | [`TranscodeWorker.java`](src/main/java/io/conductor/example/mediatranscoder/workers/TranscodeWorker.java) | +| Download and upload from a raw `Worker`, including task ID metadata | [`ThumbnailWorker.java`](src/main/java/io/conductor/example/mediatranscoder/workers/ThumbnailWorker.java) | +| Read metadata and download before producing another handle | [`ManifestWorker.java`](src/main/java/io/conductor/example/mediatranscoder/workers/ManifestWorker.java) | +| Every public `FileClient` overload in small copyable methods | [`FileClientUsage.java`](src/main/java/io/conductor/example/mediatranscoder/FileClientUsage.java) | +| Pass only handle strings between sequential and parallel tasks | [`media_transcode.json`](src/main/resources/workflow/media_transcode.json) | + +Large `Path` uploads use the same call shown in the workers. `FileClient` automatically selects multipart for S3 and Azure when the configured threshold is exceeded; GCS and local storage use one request. + +## Prerequisites + +- Java 21 and Maven 3.8+ +- A Conductor server at `http://localhost:8080/api` +- File storage enabled on that server +- The current SDK published to Maven local when running from a source checkout + +For local storage, start Conductor with settings equivalent to: + +```properties +conductor.file-storage.enabled=true +conductor.file-storage.type=local +``` + +## Run from this repository + +Publish the current client source locally so the standalone Maven example uses the matching API: + +```shell +./gradlew :conductor-client:publishToMavenLocal +cd examples/file-storage/media-transcoder +mvn package +CONDUCTOR_SERVER_URL=http://localhost:8080/api \ + java -jar target/media-transcoder-1.0.0.jar +``` + +The application checks the registered task definitions, creates any missing `upload_primary_video`, `transcode_video`, `extract_thumbnail`, and `create_manifest` definitions, registers `media_transcode`, starts all four workers, and starts an execution. The output contains raw handles for the original video, transcoded video, thumbnail, and manifest. + +## Client configuration + +Spring applications can tune automatic multipart and retry behavior without changing worker code: + +```properties +conductor.file-client.retry-count=3 +conductor.file-client.multipart-threshold=104857600 +conductor.file-client.multipart-part-size=10485760 +``` + +See the [FileClient guide](../../../docs/file-client.md) for validation, stream ownership, atomic download, and retry details. diff --git a/examples/file-storage/media-transcoder/pom.xml b/examples/file-storage/media-transcoder/pom.xml index 98b4d497b..c94e7a16c 100644 --- a/examples/file-storage/media-transcoder/pom.xml +++ b/examples/file-storage/media-transcoder/pom.xml @@ -19,7 +19,7 @@ org.conductoross conductor-client - 4.2.0 + 5.1.0 com.fasterxml.jackson.core diff --git a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/FileClientUsage.java b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/FileClientUsage.java new file mode 100644 index 000000000..dd20df3ee --- /dev/null +++ b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/FileClientUsage.java @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Conductor Authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package io.conductor.example.mediatranscoder; + +import java.io.InputStream; +import java.nio.file.Path; + +import org.conductoross.conductor.client.FileClient; +import org.conductoross.conductor.client.model.file.FileMetadata; +import org.conductoross.conductor.sdk.file.FileUploadOptions; + +/** Copyable examples for every public {@link FileClient} operation. */ +public final class FileClientUsage { + + private FileClientUsage() {} + + /** Upload a readable path and infer its filename. */ + public static String uploadPath(FileClient files, String workflowId, Path source) { + return files.upload(workflowId, source); + } + + /** Upload a path with explicit workflow metadata. Multipart selection remains automatic. */ + public static String uploadPathWithMetadata( + FileClient files, + String workflowId, + String taskId, + Path source, + String fileName, + String contentType) { + FileUploadOptions options = new FileUploadOptions() + .setFileName(fileName) + .setContentType(contentType) + .setTaskId(taskId); + return files.upload(workflowId, source, options); + } + + /** + * Upload a caller-owned stream. A filename is mandatory, and FileClient does not close the + * stream. + */ + public static String uploadStream( + FileClient files, + String workflowId, + String taskId, + InputStream source, + String fileName, + String contentType) { + FileUploadOptions options = new FileUploadOptions() + .setFileName(fileName) + .setContentType(contentType) + .setTaskId(taskId); + return files.upload(workflowId, source, options); + } + + /** Read workflow-authorized metadata for an opaque handle. */ + public static FileMetadata metadata( + FileClient files, String workflowId, String fileHandleId) { + return files.getMetadata(workflowId, fileHandleId); + } + + /** Download to a new or existing destination using atomic replacement. */ + public static Path download( + FileClient files, String workflowId, String fileHandleId, Path destination) { + return files.download(workflowId, fileHandleId, destination); + } +} diff --git a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/MediaTranscoderApp.java b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/MediaTranscoderApp.java index cafdd0408..d4c5f7d58 100644 --- a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/MediaTranscoderApp.java +++ b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/MediaTranscoderApp.java @@ -14,8 +14,10 @@ import java.io.IOException; import java.io.InputStream; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import com.fasterxml.jackson.databind.ObjectMapper; import com.netflix.conductor.client.automator.TaskRunnerConfigurer; @@ -24,6 +26,8 @@ import com.netflix.conductor.client.http.TaskClient; import com.netflix.conductor.client.http.WorkflowClient; import com.netflix.conductor.client.worker.Worker; +import com.netflix.conductor.common.metadata.tasks.TaskDef; +import com.netflix.conductor.common.metadata.tasks.TaskDef.RetryLogic; import com.netflix.conductor.common.metadata.workflow.StartWorkflowRequest; import com.netflix.conductor.common.metadata.workflow.WorkflowDef; import com.netflix.conductor.common.run.Workflow; @@ -36,6 +40,12 @@ public class MediaTranscoderApp { + private static final List TASK_TYPES = List.of( + "upload_primary_video", + "transcode_video", + "extract_thumbnail", + "create_manifest"); + public static void main(String[] args) throws Exception { String serverUrl = System.getenv().getOrDefault( "CONDUCTOR_SERVER_URL", "http://localhost:8080/api"); @@ -52,29 +62,29 @@ public static void main(String[] args) throws Exception { FileClient fileClient = new FileClient(client); - // 1. Register (or update) the workflow definition. + // 1. Register every SIMPLE task definition, then register (or update) the workflow. + registerMissingTaskDefinitions(metadataClient); WorkflowDef workflowDef = register(metadataClient); // 2. Start workers. upload_primary_video runs first inside the workflow and publishes // the primary video handle; downstream tasks consume it via // ${upload_primary_video_ref.output.primary_video}. - // TranscodeWorker is an @WorkerTask-annotated bean — wrap it as an AnnotatedWorker so - // the TaskRunner treats it like any Worker. Its input is bound to a TranscodeInput POJO - // by the SDK's FileHandlerDeserializer, demonstrating FileHandler as a POJO field. - TranscodeWorker transcodeBean = new TranscodeWorker(); + // TranscodeWorker is an @WorkerTask-annotated bean. Both raw and annotated workers inject + // FileClient explicitly and exchange only opaque handle strings. + TranscodeWorker transcodeBean = new TranscodeWorker(fileClient); AnnotatedWorker transcodeWorker = new AnnotatedWorker( "transcode_video", - TranscodeWorker.class.getMethod("transcode", TranscodeWorker.TranscodeInput.class), + TranscodeWorker.class.getMethod( + "transcode", TranscodeWorker.TranscodeInput.class, String.class), transcodeBean); List workers = List.of( - new UploadPrimaryVideoWorker(), + new UploadPrimaryVideoWorker(fileClient), transcodeWorker, - new ThumbnailWorker(), - new ManifestWorker()); + new ThumbnailWorker(fileClient), + new ManifestWorker(fileClient)); TaskRunnerConfigurer configurer = new TaskRunnerConfigurer.Builder(taskClient, workers) - .withFileClient(fileClient) .withThreadCount(4) .build(); configurer.init(); @@ -116,4 +126,35 @@ public static WorkflowDef register(MetadataClient metadataClient) throws IOExcep return def; } } + + private static void registerMissingTaskDefinitions(MetadataClient metadataClient) { + Set existing = new HashSet<>(); + for (TaskDef taskDef : metadataClient.getAllTaskDefs()) { + existing.add(taskDef.getName()); + } + + List missing = TASK_TYPES.stream() + .filter(name -> !existing.contains(name)) + .map(MediaTranscoderApp::taskDefinition) + .toList(); + if (!missing.isEmpty()) { + metadataClient.registerTaskDefs(missing); + System.out.println("Registered SIMPLE task definitions: " + + missing.stream().map(TaskDef::getName).toList()); + } + } + + private static TaskDef taskDefinition(String name) { + TaskDef taskDef = new TaskDef(); + taskDef.setName(name); + taskDef.setDescription("FileClient media-transcoder example task"); + taskDef.setOwnerEmail("examples@conductor-oss.org"); + taskDef.setRetryCount(3); + taskDef.setRetryLogic(RetryLogic.EXPONENTIAL_BACKOFF); + taskDef.setRetryDelaySeconds(1); + taskDef.setPollTimeoutSeconds(60); + taskDef.setResponseTimeoutSeconds(30); + taskDef.setTimeoutSeconds(300); + return taskDef; + } } diff --git a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ManifestWorker.java b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ManifestWorker.java index f22794e49..2d27b6d62 100644 --- a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ManifestWorker.java +++ b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ManifestWorker.java @@ -20,12 +20,18 @@ import com.netflix.conductor.client.worker.Worker; import com.netflix.conductor.common.metadata.tasks.Task; import com.netflix.conductor.common.metadata.tasks.TaskResult; -import org.conductoross.conductor.sdk.file.FileHandler; +import org.conductoross.conductor.client.FileClient; +import org.conductoross.conductor.client.model.file.FileMetadata; import org.conductoross.conductor.sdk.file.FileUploadOptions; public class ManifestWorker implements Worker { - public static final java.lang.String STRING = "{\"video\":\"%%s\",\"videoName\":\"%%s\",\"videoSize\":%%d,\"thumbnail\":\"%%s\",\"thumbName\":\"%%s\",\"thumbSize\":%%d}"; + private static final String MANIFEST = "{\"video\":\"%s\",\"videoName\":\"%s\",\"videoSize\":%d,\"thumbnail\":\"%s\",\"thumbName\":\"%s\",\"thumbSize\":%d}"; + private final FileClient fileClient; + + public ManifestWorker(FileClient fileClient) { + this.fileClient = fileClient; + } @Override public String getTaskDefName() { @@ -35,46 +41,63 @@ public String getTaskDefName() { @Override public TaskResult execute(Task task) { TaskResult result = new TaskResult(task); - FileHandler video = task.getInputFileHandler("transcoded_video"); - FileHandler thumb = task.getInputFileHandler("thumbnail"); + String video = (String) task.getInputData().get("transcoded_video"); + String thumb = (String) task.getInputData().get("thumbnail"); + Path manifestFile = null; try { + String workflowId = task.getWorkflowInstanceId(); System.out.println("[create_manifest] Inputs:"); - describe(video); - describe(thumb); + FileMetadata videoMetadata = describe(workflowId, video); + FileMetadata thumbMetadata = describe(workflowId, thumb); String manifest = String.format( - STRING.formatted(), - video.getFileHandleId(), video.getFileName(), video.getFileSize(), - thumb.getFileHandleId(), thumb.getFileName(), thumb.getFileSize()); + MANIFEST, + video, videoMetadata.getFileName(), videoMetadata.getFileSize(), + thumb, thumbMetadata.getFileName(), thumbMetadata.getFileSize()); - Path manifestFile = Files.createTempFile("manifest-", ".json"); + manifestFile = Files.createTempFile("manifest-", ".json"); Files.writeString(manifestFile, manifest); FileUploadOptions options = new FileUploadOptions().setContentType("application/json").setTaskId(task.getTaskId()); - FileHandler uploaded = task.getFileUploader().upload(manifestFile, options); - result.getOutputData().put("output_file", uploaded.getFileHandleId()); + String uploaded = fileClient.upload(workflowId, manifestFile, options); + result.getOutputData().put("output_file", uploaded); result.setStatus(TaskResult.Status.COMPLETED); - System.out.println("[create_manifest] Uploaded: " + uploaded.getFileHandleId()); + System.out.println("[create_manifest] Uploaded: " + uploaded); System.out.println("[create_manifest] Content: " + manifest); } catch (Exception e) { result.setStatus(TaskResult.Status.FAILED); result.setReasonForIncompletion(e.getMessage()); e.printStackTrace(); + } finally { + if (manifestFile != null) { + try { + Files.deleteIfExists(manifestFile); + } catch (IOException ignored) { + } + } } return result; } - private static void describe(FileHandler fileHandler) throws IOException { - String content = new String(fileHandler.getInputStream().readAllBytes(), StandardCharsets.UTF_8); - System.out.println("================="); - System.out.printf("fileHandleId=%s fileName=%s contentType=%s size=%d bytes%n content=%s%n", - fileHandler.getFileHandleId(), - fileHandler.getFileName(), - fileHandler.getContentType(), - fileHandler.getFileSize(), - content); - System.out.println("================="); + private FileMetadata describe(String workflowId, String fileHandleId) throws IOException { + FileMetadata metadata = fileClient.getMetadata(workflowId, fileHandleId); + Path downloaded = Files.createTempFile("manifest-input-", ".bin"); + try { + fileClient.download(workflowId, fileHandleId, downloaded); + String content = Files.readString(downloaded, StandardCharsets.UTF_8); + System.out.println("================="); + System.out.printf("fileHandleId=%s fileName=%s contentType=%s size=%d bytes%n content=%s%n", + fileHandleId, + metadata.getFileName(), + metadata.getContentType(), + metadata.getFileSize(), + content); + System.out.println("================="); + return metadata; + } finally { + Files.deleteIfExists(downloaded); + } } } diff --git a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ThumbnailWorker.java b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ThumbnailWorker.java index 67a96d587..c17bc2c4b 100644 --- a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ThumbnailWorker.java +++ b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ThumbnailWorker.java @@ -12,42 +12,61 @@ */ package io.conductor.example.mediatranscoder.workers; -import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import com.netflix.conductor.client.worker.Worker; import com.netflix.conductor.common.metadata.tasks.Task; import com.netflix.conductor.common.metadata.tasks.TaskResult; -import org.conductoross.conductor.sdk.file.FileHandler; +import org.conductoross.conductor.client.FileClient; import org.conductoross.conductor.sdk.file.FileUploadOptions; -import org.conductoross.conductor.sdk.file.FileUploader; public class ThumbnailWorker implements Worker { + private final FileClient fileClient; + + public ThumbnailWorker(FileClient fileClient) { + this.fileClient = fileClient; + } + @Override public String getTaskDefName() { return "extract_thumbnail"; } @Override public TaskResult execute(Task task) { TaskResult result = new TaskResult(task); - FileHandler primary = task.getInputFileHandler("primary_video"); + String primary = (String) task.getInputData().get("primary_video"); + Path downloaded = null; + Path thumbnail = null; - try (InputStream in = primary.getInputStream()) { - Path thumbnail = Files.createTempFile("thumb-", ".png"); + try { + downloaded = Files.createTempFile("primary-", ".mov"); + fileClient.download(task.getWorkflowInstanceId(), primary, downloaded); + thumbnail = Files.createTempFile("thumb-", ".png"); Files.write(thumbnail, "PNG_THUMBNAIL_DATA".getBytes()); FileUploadOptions options = new FileUploadOptions().setContentType("image/png").setTaskId(task.getTaskId()); - FileHandler uploaded = task.getFileUploader().upload(thumbnail, options); - result.getOutputData().put("output_file", uploaded.getFileHandleId()); + String uploaded = fileClient.upload(task.getWorkflowInstanceId(), thumbnail, options); + result.getOutputData().put("output_file", uploaded); result.setStatus(TaskResult.Status.COMPLETED); - System.out.println("[extract_thumbnail] Uploaded: " + uploaded.getFileHandleId()); + System.out.println("[extract_thumbnail] Uploaded: " + uploaded); } catch (Exception e) { result.setStatus(TaskResult.Status.FAILED); result.setReasonForIncompletion(e.getMessage()); e.printStackTrace(); + } finally { + delete(downloaded); + delete(thumbnail); } return result; } + + private static void delete(Path path) { + if (path == null) return; + try { + Files.deleteIfExists(path); + } catch (Exception ignored) { + } + } } diff --git a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/TranscodeWorker.java b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/TranscodeWorker.java index fe7861461..1c6010f26 100644 --- a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/TranscodeWorker.java +++ b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/TranscodeWorker.java @@ -13,30 +13,47 @@ package io.conductor.example.mediatranscoder.workers; import java.io.IOException; -import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import com.netflix.conductor.sdk.workflow.task.OutputParam; import com.netflix.conductor.sdk.workflow.task.WorkerTask; -import org.conductoross.conductor.sdk.file.FileHandler; +import com.netflix.conductor.sdk.workflow.task.WorkflowInstanceIdInputParam; +import org.conductoross.conductor.client.FileClient; +import org.conductoross.conductor.sdk.file.FileUploadOptions; public class TranscodeWorker { + private final FileClient fileClient; + + public TranscodeWorker(FileClient fileClient) { + this.fileClient = fileClient; + } + public static class TranscodeInput { - public FileHandler primary_video; + public String primary_video; public String resolution; } @WorkerTask("transcode_video") - public @OutputParam("output_file") FileHandler transcode(TranscodeInput input) throws IOException { + public @OutputParam("output_file") String transcode( + TranscodeInput input, + @WorkflowInstanceIdInputParam String workflowId) throws IOException { + Path downloaded = Files.createTempFile("primary-", ".mov"); Path transcoded = Files.createTempFile("transcoded-" + input.resolution + "-", ".mp4"); - try (InputStream in = input.primary_video.getInputStream()) { + try { + fileClient.download(workflowId, input.primary_video, downloaded); Files.write(transcoded, ("TRANSCODED:" + input.resolution + "\n").getBytes()); - Files.write(transcoded, in.readAllBytes(), StandardOpenOption.APPEND); + Files.write(transcoded, Files.readAllBytes(downloaded), StandardOpenOption.APPEND); + System.out.println("[transcode_video] Transcoded to " + transcoded); + return fileClient.upload( + workflowId, + transcoded, + new FileUploadOptions().setContentType("video/mp4")); + } finally { + Files.deleteIfExists(downloaded); + Files.deleteIfExists(transcoded); } - System.out.println("[transcode_video] Transcoded to " + transcoded); - return FileHandler.fromLocalFile(transcoded, "video/mp4"); } } diff --git a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/UploadPrimaryVideoWorker.java b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/UploadPrimaryVideoWorker.java index f93c3060f..7811878a4 100644 --- a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/UploadPrimaryVideoWorker.java +++ b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/UploadPrimaryVideoWorker.java @@ -17,12 +17,17 @@ import com.netflix.conductor.client.worker.Worker; import com.netflix.conductor.common.metadata.tasks.Task; import com.netflix.conductor.common.metadata.tasks.TaskResult; -import org.conductoross.conductor.sdk.file.FileHandler; +import org.conductoross.conductor.client.FileClient; import org.conductoross.conductor.sdk.file.FileUploadOptions; public class UploadPrimaryVideoWorker implements Worker { private static final String RESOURCE_PATH = "/data/primary_video.mov"; + private final FileClient fileClient; + + public UploadPrimaryVideoWorker(FileClient fileClient) { + this.fileClient = fileClient; + } @Override public String getTaskDefName() { @@ -40,13 +45,14 @@ public TaskResult execute(Task task) { FileUploadOptions options = new FileUploadOptions() .setFileName("primary_video.mov") - .setContentType("video/quicktime"); - FileHandler uploaded = task.getFileUploader().upload(in, options); + .setContentType("video/quicktime") + .setTaskId(task.getTaskId()); + String uploaded = fileClient.upload(task.getWorkflowInstanceId(), in, options); result.getOutputData().put("primary_video", uploaded); result.setStatus(TaskResult.Status.COMPLETED); - System.out.println("[upload_primary_video] Uploaded: " + uploaded.getFileHandleId()); + System.out.println("[upload_primary_video] Uploaded: " + uploaded); } catch (Exception e) { result.setStatus(TaskResult.Status.FAILED); result.setReasonForIncompletion(e.getMessage()); diff --git a/tests/src/test/java/io/orkes/conductor/client/filestorage/FileStorageIntegrationTest.java b/tests/src/test/java/io/orkes/conductor/client/filestorage/FileStorageIntegrationTest.java index e64386a38..9dc157018 100644 --- a/tests/src/test/java/io/orkes/conductor/client/filestorage/FileStorageIntegrationTest.java +++ b/tests/src/test/java/io/orkes/conductor/client/filestorage/FileStorageIntegrationTest.java @@ -13,31 +13,21 @@ package io.orkes.conductor.client.filestorage; import java.io.ByteArrayInputStream; -import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.Map; -import java.util.Set; import org.conductoross.conductor.client.FileClient; -import org.conductoross.conductor.sdk.file.FileHandler; +import org.conductoross.conductor.client.model.file.FileMetadata; import org.conductoross.conductor.sdk.file.FileUploadOptions; -import org.conductoross.conductor.sdk.file.FileUploader; -import org.conductoross.conductor.sdk.file.ManagedFileHandler; -import org.conductoross.conductor.sdk.file.WorkflowFileClient; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - -import com.netflix.conductor.common.config.ObjectMapperProvider; import com.netflix.conductor.common.metadata.workflow.StartWorkflowRequest; import com.netflix.conductor.common.metadata.workflow.WorkflowDef; import com.netflix.conductor.sdk.workflow.def.ConductorWorkflow; @@ -58,11 +48,7 @@ public class FileStorageIntegrationTest { private static final String WAIT_WF_NAME = "file_storage_integration_wait_wf"; - private static final ObjectMapper MAPPER = new ObjectMapperProvider().getObjectMapper(); - private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; - - @TempDir - static Path tempDir; + @TempDir static Path tempDir; private static FileClient fileClient; private static OrkesWorkflowClient workflowClient; @@ -76,19 +62,19 @@ static void setUp() { metadataClient = ClientTestUtil.getOrkesClients().getMetadataClient(); WorkflowExecutor executor = new WorkflowExecutor(ClientTestUtil.getClient(), 10); - ConductorWorkflow wf = new ConductorWorkflow<>(executor); - wf.setName(WAIT_WF_NAME); - wf.setVersion(1); - wf.add(new Wait("wait_task", Duration.ofSeconds(300))); - wf.setTimeoutPolicy(WorkflowDef.TimeoutPolicy.TIME_OUT_WF); - wf.setTimeoutSeconds(600); - wf.registerWorkflow(true, true); - - StartWorkflowRequest req = new StartWorkflowRequest(); - req.setName(WAIT_WF_NAME); - req.setVersion(1); - req.setInput(Map.of()); - workflowId = workflowClient.startWorkflow(req); + ConductorWorkflow workflow = new ConductorWorkflow<>(executor); + workflow.setName(WAIT_WF_NAME); + workflow.setVersion(1); + workflow.add(new Wait("wait_task", Duration.ofSeconds(300))); + workflow.setTimeoutPolicy(WorkflowDef.TimeoutPolicy.TIME_OUT_WF); + workflow.setTimeoutSeconds(600); + workflow.registerWorkflow(true, true); + + StartWorkflowRequest request = new StartWorkflowRequest(); + request.setName(WAIT_WF_NAME); + request.setVersion(1); + request.setInput(Map.of()); + workflowId = workflowClient.startWorkflow(request); assertNotNull(workflowId); } @@ -105,101 +91,51 @@ static void cleanUp() { } @Test - @DisplayName("upload(Path) returns prefixed handle and preserves filename + content type") - void uploadFromPath() throws Exception { - Path file = writeFile("hello.txt", "hello world"); - - FileHandler handler = fileClient.upload(workflowId, file, + void pathUploadReturnsRawHandleAndMetadata() throws Exception { + String handle = fileClient.upload( + workflowId, + writeFile("hello.txt", "hello world"), new FileUploadOptions().setContentType("text/plain")); - assertNotNull(handler.getFileHandleId()); - assertTrue(handler.getFileHandleId().startsWith(FileHandler.PREFIX)); - assertEquals("hello.txt", handler.getFileName()); - assertEquals("text/plain", handler.getContentType()); + assertTrue(handle.startsWith("conductor://file/")); + FileMetadata metadata = fileClient.getMetadata(workflowId, handle); + assertEquals("hello.txt", metadata.getFileName()); + assertEquals("text/plain", metadata.getContentType()); + assertEquals(11L, metadata.getFileSize()); } @Test - @DisplayName("upload(InputStream) buffers to temp file and uploads") - void uploadFromInputStream() { - byte[] payload = "stream payload".getBytes(StandardCharsets.UTF_8); - - FileHandler handler = fileClient.upload(workflowId, new ByteArrayInputStream(payload), - new FileUploadOptions().setFileName("stream.txt").setContentType("text/plain")); - - assertNotNull(handler.getFileHandleId()); - assertTrue(handler.getFileHandleId().startsWith(FileHandler.PREFIX)); + void streamUploadDoesNotRequireAWorkerFileObject() { + String handle = fileClient.upload( + workflowId, + new ByteArrayInputStream("stream payload".getBytes(StandardCharsets.UTF_8)), + new FileUploadOptions() + .setFileName("stream.txt") + .setContentType("text/plain")); + + assertTrue(handle.startsWith("conductor://file/")); } @Test - @DisplayName("a fresh ManagedFileHandler downloads the bytes uploaded under the same handle id") - void downloadRoundTripsContent() throws Exception { - Path file = writeFile("data.bin", "round-trip content 12345"); - - FileHandler uploaded = fileClient.upload(workflowId, file, + void explicitDownloadRoundTripsContent() throws Exception { + String handle = fileClient.upload( + workflowId, + writeFile("data.bin", "round-trip content"), new FileUploadOptions().setContentType("application/octet-stream")); + Path destination = tempDir.resolve("downloaded/data.bin"); - ManagedFileHandler downloaded = new ManagedFileHandler( - uploaded.getFileHandleId(), new WorkflowFileClient(fileClient, workflowId)); - - try (InputStream in = downloaded.getInputStream()) { - assertEquals("round-trip content 12345", new String(in.readAllBytes(), StandardCharsets.UTF_8)); - } - } - - @Test - @DisplayName("multiple uploads produce distinct handle ids") - void multipleUploadsProduceDistinctHandleIds() throws Exception { - FileHandler h1 = fileClient.upload(workflowId, writeFile("doc1.pdf", "pdf 1"), - new FileUploadOptions().setContentType("application/pdf")); - FileHandler h2 = fileClient.upload(workflowId, writeFile("doc2.pdf", "pdf 2"), - new FileUploadOptions().setContentType("application/pdf")); - - assertNotEquals(h1.getFileHandleId(), h2.getFileHandleId()); - } - - @Test - @DisplayName("FileUploader interface (WorkflowFileClient) yields the same handle shape as FileClient") - void uploadViaFileUploaderInterface() throws Exception { - FileUploader uploader = new WorkflowFileClient(fileClient, workflowId); - - FileHandler handler = uploader.upload(writeFile("via-interface.txt", "interface test"), - new FileUploadOptions().setContentType("text/plain")); - - assertNotNull(handler.getFileHandleId()); - assertTrue(handler.getFileHandleId().startsWith(FileHandler.PREFIX)); - } - - @Test - @DisplayName("multipart=false (default) uploads via single-request path") - void singleRequestUploadIsDefault() throws Exception { - FileHandler handler = fileClient.upload(workflowId, writeFile("default.txt", "single"), - new FileUploadOptions().setContentType("text/plain")); - - assertNotNull(handler.getFileHandleId()); + assertEquals( + destination.toAbsolutePath(), + fileClient.download(workflowId, handle, destination)); + assertEquals("round-trip content", Files.readString(destination)); } @Test - @DisplayName("multipart=true uploads succeed (multipart path on capable backends; falls back otherwise)") - void multipartFlagTrueDoesNotFail() throws Exception { - FileHandler handler = fileClient.upload(workflowId, writeFile("mp.bin", "multipart payload"), - new FileUploadOptions().setContentType("application/octet-stream").setMultipart(true)); - - assertNotNull(handler.getFileHandleId()); - assertTrue(handler.getFileHandleId().startsWith(FileHandler.PREFIX)); - } - - @Test - @DisplayName("FileHandler serializes to the fixed three-field shape") - void fileHandlerSerializesToThreeFields() throws Exception { - FileHandler uploaded = fileClient.upload(workflowId, writeFile("ser.txt", "json shape"), - new FileUploadOptions().setContentType("text/plain")); - - Map json = MAPPER.convertValue(uploaded, MAP_TYPE); + void multipleUploadsProduceDistinctRawHandles() throws Exception { + String first = fileClient.upload(workflowId, writeFile("doc1.pdf", "pdf 1")); + String second = fileClient.upload(workflowId, writeFile("doc2.pdf", "pdf 2")); - assertEquals(Set.of("fileHandleId", "contentType", "fileName"), json.keySet()); - assertEquals(uploaded.getFileHandleId(), json.get("fileHandleId")); - assertEquals("text/plain", json.get("contentType")); - assertEquals("ser.txt", json.get("fileName")); + assertNotEquals(first, second); } private static Path writeFile(String name, String content) throws Exception { From cb575791ff5be5808faab8d388aef124cc5daf0d Mon Sep 17 00:00:00 2001 From: Viren Baraiya Date: Fri, 17 Jul 2026 23:38:25 -0700 Subject: [PATCH 02/14] docs(examples): standardize annotated file workers --- README.md | 8 +- docs/file-client.md | 74 ++++++------------- examples/README.md | 2 +- .../file-storage/media-transcoder/README.md | 64 +++++++++++++++- .../mediatranscoder/MediaTranscoderApp.java | 33 +++------ .../workers/ManifestWorker.java | 36 ++++----- .../workers/ThumbnailWorker.java | 36 ++++----- .../workers/UploadPrimaryVideoWorker.java | 35 +++------ 8 files changed, 139 insertions(+), 149 deletions(-) diff --git a/README.md b/README.md index 7ae6f0dd9..f5790e4c3 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,7 @@ public class Workers { } ``` -**Start workers** with `TaskRunnerConfigurer` or `WorkflowExecutor`: +**Start workers** with `TaskRunnerConfigurer` or `AnnotatedWorkerExecutor`: ```java // Option 1: Using TaskRunnerConfigurer @@ -272,9 +272,9 @@ TaskRunnerConfigurer configurer = new TaskRunnerConfigurer.Builder( .build(); configurer.init(); -// Option 2: Using WorkflowExecutor (auto-discovers @WorkerTask annotations) -WorkflowExecutor executor = new WorkflowExecutor(client, 10); -executor.initWorkers("com.mycompany.workers"); // Package to scan for @WorkerTask +// Option 2: Register dependency-injected @WorkerTask instances +AnnotatedWorkerExecutor executor = new AnnotatedWorkerExecutor(taskClient); +executor.initWorkersFromInstances(List.of(new Workers())); ``` **Worker Design Principles:** diff --git a/docs/file-client.md b/docs/file-client.md index 18ad575a8..bb7ed7c49 100644 --- a/docs/file-client.md +++ b/docs/file-client.md @@ -122,84 +122,54 @@ files.download(workflowId, modelHandle, existing); The download first goes to a unique sibling `.part` file. Only a complete transfer atomically replaces `current-model.bin`. Failure removes the partial file and preserves the existing destination. A filesystem that cannot perform atomic replacement is rejected explicitly. -## Raw worker example +## Recommended annotated worker pattern ```java -public final class ConvertWorker implements Worker { +public final class ConvertWorker { private final FileClient files; public ConvertWorker(FileClient files) { this.files = files; } - @Override - public String getTaskDefName() { - return "convert_document"; + public static class ConvertInput { + public String document; } - @Override - public TaskResult execute(Task task) { - TaskResult result = new TaskResult(task); - Path input = null; - Path output = null; + @WorkerTask("convert_document") + public @OutputParam("document") String convert( + ConvertInput input, + @WorkflowInstanceIdInputParam String workflowId) throws IOException { + Path source = Files.createTempFile("document-", ".bin"); + Path output = Files.createTempFile("converted-", ".pdf"); try { - String workflowId = task.getWorkflowInstanceId(); - String inputHandle = (String) task.getInputData().get("document"); - input = Files.createTempFile("document-", ".bin"); - output = Files.createTempFile("converted-", ".pdf"); - - files.download(workflowId, inputHandle, input); - convertToPdf(input, output); - - String outputHandle = files.upload( + files.download(workflowId, input.document, source); + convertToPdf(source, output); + return files.upload( workflowId, output, new FileUploadOptions() .setFileName("converted.pdf") - .setContentType("application/pdf") - .setTaskId(task.getTaskId())); - - result.setStatus(TaskResult.Status.COMPLETED); - result.addOutputData("document", outputHandle); - } catch (Exception e) { - result.setStatus(TaskResult.Status.FAILED); - result.setReasonForIncompletion(e.getMessage()); + .setContentType("application/pdf")); } finally { - deleteQuietly(input); - deleteQuietly(output); + Files.deleteIfExists(source); + Files.deleteIfExists(output); } - return result; } } ``` -The task input and output are strings. No task-runner file integration is required. +`@WorkerTask` names the `SIMPLE` task, the unannotated POJO receives the full resolved input map, `@WorkflowInstanceIdInputParam` supplies the workflow context required by `FileClient`, and `@OutputParam` maps the returned handle to `output.document`. Thrown exceptions become failed task results. -## Annotated worker example +Register already-constructed worker instances so normal constructor injection continues to work: ```java -public record ConvertInput(String document) {} - -@WorkerTask("convert_document") -public @OutputParam("document") String convert( - ConvertInput input, - @WorkflowInstanceIdInputParam String workflowId) throws IOException { - Path source = Files.createTempFile("document-", ".bin"); - Path output = Files.createTempFile("converted-", ".pdf"); - try { - files.download(workflowId, input.document(), source); - convertToPdf(source, output); - return files.upload( - workflowId, - output, - new FileUploadOptions().setContentType("application/pdf")); - } finally { - Files.deleteIfExists(source); - Files.deleteIfExists(output); - } -} +AnnotatedWorkerExecutor workers = new AnnotatedWorkerExecutor(taskClient); +workers.initWorkersFromInstances(List.of(new ConvertWorker(files))); ``` +The annotation value must exactly match both the workflow task's `name` and a registered task definition. The task input and output remain plain strings; no task-runner file integration is required. Implement the lower-level `Worker` interface only when the method-binding model is insufficient and direct access to the full `Task` or `TaskResult` is necessary. + ## Retry and security behavior - A new signed URL is requested before every retry. diff --git a/examples/README.md b/examples/README.md index b6d6de1f1..d7e508884 100644 --- a/examples/README.md +++ b/examples/README.md @@ -30,7 +30,7 @@ See [`manifest.json`](manifest.json) for per-example metadata: category, workflo | Example | Description | |---|---| -| [media-transcoder](file-storage/media-transcoder/) | Complete `FileClient` workflow covering path and stream uploads, metadata, downloads, raw and annotated workers, handle passing, and automatic multipart selection. | +| [media-transcoder](file-storage/media-transcoder/) | Complete `FileClient` workflow covering path and stream uploads, metadata, downloads, a consistent `@WorkerTask` pattern, handle passing, and automatic multipart selection. | --- diff --git a/examples/file-storage/media-transcoder/README.md b/examples/file-storage/media-transcoder/README.md index 43308fb4c..e89f52785 100644 --- a/examples/file-storage/media-transcoder/README.md +++ b/examples/file-storage/media-transcoder/README.md @@ -6,15 +6,71 @@ This example passes videos, thumbnails, and a JSON manifest through a workflow a | Case | Source | |---|---| -| Upload a caller-owned `InputStream` with required filename and content type | [`UploadPrimaryVideoWorker.java`](src/main/java/io/conductor/example/mediatranscoder/workers/UploadPrimaryVideoWorker.java) | -| Download a handle, process the file, and upload a `Path` from an annotated worker | [`TranscodeWorker.java`](src/main/java/io/conductor/example/mediatranscoder/workers/TranscodeWorker.java) | -| Download and upload from a raw `Worker`, including task ID metadata | [`ThumbnailWorker.java`](src/main/java/io/conductor/example/mediatranscoder/workers/ThumbnailWorker.java) | -| Read metadata and download before producing another handle | [`ManifestWorker.java`](src/main/java/io/conductor/example/mediatranscoder/workers/ManifestWorker.java) | +| Upload a caller-owned `InputStream` with required filename and content type | Annotated [`UploadPrimaryVideoWorker.java`](src/main/java/io/conductor/example/mediatranscoder/workers/UploadPrimaryVideoWorker.java) | +| Download a handle, process the file, and upload a `Path` | Annotated [`TranscodeWorker.java`](src/main/java/io/conductor/example/mediatranscoder/workers/TranscodeWorker.java) | +| Download and upload a second derived file in a parallel workflow branch | Annotated [`ThumbnailWorker.java`](src/main/java/io/conductor/example/mediatranscoder/workers/ThumbnailWorker.java) | +| Read metadata and download before producing another handle | Annotated [`ManifestWorker.java`](src/main/java/io/conductor/example/mediatranscoder/workers/ManifestWorker.java) | | Every public `FileClient` overload in small copyable methods | [`FileClientUsage.java`](src/main/java/io/conductor/example/mediatranscoder/FileClientUsage.java) | | Pass only handle strings between sequential and parallel tasks | [`media_transcode.json`](src/main/resources/workflow/media_transcode.json) | Large `Path` uploads use the same call shown in the workers. `FileClient` automatically selects multipart for S3 and Azure when the configured threshold is exceeded; GCS and local storage use one request. +## Annotated worker pattern + +Every worker follows the same four-part pattern from [`WorkerTask.java`](../../../conductor-client/src/main/java/com/netflix/conductor/sdk/workflow/task/WorkerTask.java): + +1. Inject `FileClient` into a plain Java object. +2. Put the workflow task name on a method with `@WorkerTask`. +3. Receive the resolved task input as a typed object and workflow context with `@WorkflowInstanceIdInputParam`. +4. Return the handle string and use `@OutputParam` to name the task output. + +```java +public final class TranscodeWorker { + private final FileClient files; + + public TranscodeWorker(FileClient files) { + this.files = files; + } + + public static class TranscodeInput { + public String primary_video; + public String resolution; + } + + @WorkerTask("transcode_video") + public @OutputParam("output_file") String transcode( + TranscodeInput input, + @WorkflowInstanceIdInputParam String workflowId) throws IOException { + Path source = Files.createTempFile("source-", ".mov"); + Path output = Files.createTempFile("transcoded-", ".mp4"); + try { + files.download(workflowId, input.primary_video, source); + transcode(source, output, input.resolution); + return files.upload( + workflowId, + output, + new FileUploadOptions().setContentType("video/mp4")); + } finally { + Files.deleteIfExists(source); + Files.deleteIfExists(output); + } + } +} +``` + +Register dependency-injected instances directly. `AnnotatedWorkerExecutor` discovers the annotated methods, binds task inputs, maps return values to task outputs, and converts thrown exceptions into failed task results: + +```java +AnnotatedWorkerExecutor workers = new AnnotatedWorkerExecutor(taskClient); +workers.initWorkersFromInstances(List.of( + new UploadPrimaryVideoWorker(fileClient), + new TranscodeWorker(fileClient), + new ThumbnailWorker(fileClient), + new ManifestWorker(fileClient))); +``` + +The annotation value must exactly match the `name` of the corresponding `SIMPLE` task and its registered task definition. This application checks all four task definitions before starting the workflow, so a missing worker definition cannot silently leave the workflow queued. + ## Prerequisites - Java 21 and Maven 3.8+ diff --git a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/MediaTranscoderApp.java b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/MediaTranscoderApp.java index d4c5f7d58..442508867 100644 --- a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/MediaTranscoderApp.java +++ b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/MediaTranscoderApp.java @@ -20,18 +20,16 @@ import java.util.Set; import com.fasterxml.jackson.databind.ObjectMapper; -import com.netflix.conductor.client.automator.TaskRunnerConfigurer; import com.netflix.conductor.client.http.ConductorClient; import com.netflix.conductor.client.http.MetadataClient; import com.netflix.conductor.client.http.TaskClient; import com.netflix.conductor.client.http.WorkflowClient; -import com.netflix.conductor.client.worker.Worker; import com.netflix.conductor.common.metadata.tasks.TaskDef; import com.netflix.conductor.common.metadata.tasks.TaskDef.RetryLogic; import com.netflix.conductor.common.metadata.workflow.StartWorkflowRequest; import com.netflix.conductor.common.metadata.workflow.WorkflowDef; import com.netflix.conductor.common.run.Workflow; -import com.netflix.conductor.sdk.workflow.executor.task.AnnotatedWorker; +import com.netflix.conductor.sdk.workflow.executor.task.AnnotatedWorkerExecutor; import io.conductor.example.mediatranscoder.workers.ManifestWorker; import io.conductor.example.mediatranscoder.workers.ThumbnailWorker; import io.conductor.example.mediatranscoder.workers.TranscodeWorker; @@ -69,25 +67,14 @@ public static void main(String[] args) throws Exception { // 2. Start workers. upload_primary_video runs first inside the workflow and publishes // the primary video handle; downstream tasks consume it via // ${upload_primary_video_ref.output.primary_video}. - // TranscodeWorker is an @WorkerTask-annotated bean. Both raw and annotated workers inject - // FileClient explicitly and exchange only opaque handle strings. - TranscodeWorker transcodeBean = new TranscodeWorker(fileClient); - AnnotatedWorker transcodeWorker = new AnnotatedWorker( - "transcode_video", - TranscodeWorker.class.getMethod( - "transcode", TranscodeWorker.TranscodeInput.class, String.class), - transcodeBean); - - List workers = List.of( + // Every worker is an ordinary dependency-injected object whose work method is annotated + // with @WorkerTask. The executor discovers those methods and handles input/output binding. + AnnotatedWorkerExecutor workerExecutor = new AnnotatedWorkerExecutor(taskClient); + workerExecutor.initWorkersFromInstances(List.of( new UploadPrimaryVideoWorker(fileClient), - transcodeWorker, + new TranscodeWorker(fileClient), new ThumbnailWorker(fileClient), - new ManifestWorker(fileClient)); - - TaskRunnerConfigurer configurer = new TaskRunnerConfigurer.Builder(taskClient, workers) - .withThreadCount(4) - .build(); - configurer.init(); + new ManifestWorker(fileClient))); System.out.println("Workers started: upload_primary_video, transcode_video, extract_thumbnail, create_manifest"); // 3. Start workflow — no inputs; upload_primary_video publishes primaryVideo. @@ -99,7 +86,7 @@ public static void main(String[] args) throws Exception { String workflowId = workflowClient.startWorkflow(request); System.out.println("Workflow started: " + workflowId); - // 5. Poll for completion + // 4. Poll for completion. System.out.println("Waiting for workflow to complete..."); for (int i = 0; i < 30; i++) { Thread.sleep(2000); @@ -108,13 +95,13 @@ public static void main(String[] args) throws Exception { if (workflow.getStatus().isTerminal()) { System.out.println("Workflow " + workflow.getStatus() + "!"); System.out.println("Output: " + workflow.getOutput()); - configurer.shutdown(); + workerExecutor.shutdown(); System.exit(workflow.getStatus().isSuccessful() ? 0 : 1); } } System.err.println("Workflow did not complete in 60s"); - configurer.shutdown(); + workerExecutor.shutdown(); System.exit(1); } diff --git a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ManifestWorker.java b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ManifestWorker.java index 2d27b6d62..9b391e4a9 100644 --- a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ManifestWorker.java +++ b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ManifestWorker.java @@ -17,14 +17,14 @@ import java.nio.file.Files; import java.nio.file.Path; -import com.netflix.conductor.client.worker.Worker; -import com.netflix.conductor.common.metadata.tasks.Task; -import com.netflix.conductor.common.metadata.tasks.TaskResult; +import com.netflix.conductor.sdk.workflow.task.OutputParam; +import com.netflix.conductor.sdk.workflow.task.WorkerTask; +import com.netflix.conductor.sdk.workflow.task.WorkflowInstanceIdInputParam; import org.conductoross.conductor.client.FileClient; import org.conductoross.conductor.client.model.file.FileMetadata; import org.conductoross.conductor.sdk.file.FileUploadOptions; -public class ManifestWorker implements Worker { +public class ManifestWorker { private static final String MANIFEST = "{\"video\":\"%s\",\"videoName\":\"%s\",\"videoSize\":%d,\"thumbnail\":\"%s\",\"thumbName\":\"%s\",\"thumbSize\":%d}"; private final FileClient fileClient; @@ -33,20 +33,20 @@ public ManifestWorker(FileClient fileClient) { this.fileClient = fileClient; } - @Override - public String getTaskDefName() { - return "create_manifest"; + public static class ManifestInput { + public String transcoded_video; + public String thumbnail; } - @Override - public TaskResult execute(Task task) { - TaskResult result = new TaskResult(task); - String video = (String) task.getInputData().get("transcoded_video"); - String thumb = (String) task.getInputData().get("thumbnail"); + @WorkerTask("create_manifest") + public @OutputParam("output_file") String create( + ManifestInput input, + @WorkflowInstanceIdInputParam String workflowId) throws IOException { + String video = input.transcoded_video; + String thumb = input.thumbnail; Path manifestFile = null; try { - String workflowId = task.getWorkflowInstanceId(); System.out.println("[create_manifest] Inputs:"); FileMetadata videoMetadata = describe(workflowId, video); FileMetadata thumbMetadata = describe(workflowId, thumb); @@ -59,17 +59,12 @@ public TaskResult execute(Task task) { manifestFile = Files.createTempFile("manifest-", ".json"); Files.writeString(manifestFile, manifest); - FileUploadOptions options = new FileUploadOptions().setContentType("application/json").setTaskId(task.getTaskId()); + FileUploadOptions options = new FileUploadOptions().setContentType("application/json"); String uploaded = fileClient.upload(workflowId, manifestFile, options); - result.getOutputData().put("output_file", uploaded); - result.setStatus(TaskResult.Status.COMPLETED); System.out.println("[create_manifest] Uploaded: " + uploaded); System.out.println("[create_manifest] Content: " + manifest); - } catch (Exception e) { - result.setStatus(TaskResult.Status.FAILED); - result.setReasonForIncompletion(e.getMessage()); - e.printStackTrace(); + return uploaded; } finally { if (manifestFile != null) { try { @@ -78,7 +73,6 @@ public TaskResult execute(Task task) { } } } - return result; } private FileMetadata describe(String workflowId, String fileHandleId) throws IOException { diff --git a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ThumbnailWorker.java b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ThumbnailWorker.java index c17bc2c4b..899d3706f 100644 --- a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ThumbnailWorker.java +++ b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ThumbnailWorker.java @@ -12,16 +12,17 @@ */ package io.conductor.example.mediatranscoder.workers; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import com.netflix.conductor.client.worker.Worker; -import com.netflix.conductor.common.metadata.tasks.Task; -import com.netflix.conductor.common.metadata.tasks.TaskResult; +import com.netflix.conductor.sdk.workflow.task.OutputParam; +import com.netflix.conductor.sdk.workflow.task.WorkerTask; +import com.netflix.conductor.sdk.workflow.task.WorkflowInstanceIdInputParam; import org.conductoross.conductor.client.FileClient; import org.conductoross.conductor.sdk.file.FileUploadOptions; -public class ThumbnailWorker implements Worker { +public class ThumbnailWorker { private final FileClient fileClient; @@ -29,37 +30,32 @@ public ThumbnailWorker(FileClient fileClient) { this.fileClient = fileClient; } - @Override - public String getTaskDefName() { return "extract_thumbnail"; } + public static class ThumbnailInput { + public String primary_video; + } - @Override - public TaskResult execute(Task task) { - TaskResult result = new TaskResult(task); - String primary = (String) task.getInputData().get("primary_video"); + @WorkerTask("extract_thumbnail") + public @OutputParam("output_file") String extract( + ThumbnailInput input, + @WorkflowInstanceIdInputParam String workflowId) throws IOException { Path downloaded = null; Path thumbnail = null; try { downloaded = Files.createTempFile("primary-", ".mov"); - fileClient.download(task.getWorkflowInstanceId(), primary, downloaded); + fileClient.download(workflowId, input.primary_video, downloaded); thumbnail = Files.createTempFile("thumb-", ".png"); Files.write(thumbnail, "PNG_THUMBNAIL_DATA".getBytes()); - FileUploadOptions options = new FileUploadOptions().setContentType("image/png").setTaskId(task.getTaskId()); - String uploaded = fileClient.upload(task.getWorkflowInstanceId(), thumbnail, options); - result.getOutputData().put("output_file", uploaded); - result.setStatus(TaskResult.Status.COMPLETED); + FileUploadOptions options = new FileUploadOptions().setContentType("image/png"); + String uploaded = fileClient.upload(workflowId, thumbnail, options); System.out.println("[extract_thumbnail] Uploaded: " + uploaded); - } catch (Exception e) { - result.setStatus(TaskResult.Status.FAILED); - result.setReasonForIncompletion(e.getMessage()); - e.printStackTrace(); + return uploaded; } finally { delete(downloaded); delete(thumbnail); } - return result; } private static void delete(Path path) { diff --git a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/UploadPrimaryVideoWorker.java b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/UploadPrimaryVideoWorker.java index 7811878a4..77d255553 100644 --- a/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/UploadPrimaryVideoWorker.java +++ b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/UploadPrimaryVideoWorker.java @@ -12,15 +12,16 @@ */ package io.conductor.example.mediatranscoder.workers; +import java.io.IOException; import java.io.InputStream; -import com.netflix.conductor.client.worker.Worker; -import com.netflix.conductor.common.metadata.tasks.Task; -import com.netflix.conductor.common.metadata.tasks.TaskResult; +import com.netflix.conductor.sdk.workflow.task.OutputParam; +import com.netflix.conductor.sdk.workflow.task.WorkerTask; +import com.netflix.conductor.sdk.workflow.task.WorkflowInstanceIdInputParam; import org.conductoross.conductor.client.FileClient; import org.conductoross.conductor.sdk.file.FileUploadOptions; -public class UploadPrimaryVideoWorker implements Worker { +public class UploadPrimaryVideoWorker { private static final String RESOURCE_PATH = "/data/primary_video.mov"; private final FileClient fileClient; @@ -29,15 +30,9 @@ public UploadPrimaryVideoWorker(FileClient fileClient) { this.fileClient = fileClient; } - @Override - public String getTaskDefName() { - return "upload_primary_video"; - } - - @Override - public TaskResult execute(Task task) { - TaskResult result = new TaskResult(task); - + @WorkerTask("upload_primary_video") + public @OutputParam("primary_video") String upload( + @WorkflowInstanceIdInputParam String workflowId) throws IOException { try (InputStream in = UploadPrimaryVideoWorker.class.getResourceAsStream(RESOURCE_PATH)) { if (in == null) { throw new IllegalStateException("Primary video not found on classpath at " + RESOURCE_PATH); @@ -45,19 +40,11 @@ public TaskResult execute(Task task) { FileUploadOptions options = new FileUploadOptions() .setFileName("primary_video.mov") - .setContentType("video/quicktime") - .setTaskId(task.getTaskId()); - String uploaded = fileClient.upload(task.getWorkflowInstanceId(), in, options); - - result.getOutputData().put("primary_video", uploaded); - result.setStatus(TaskResult.Status.COMPLETED); + .setContentType("video/quicktime"); + String uploaded = fileClient.upload(workflowId, in, options); System.out.println("[upload_primary_video] Uploaded: " + uploaded); - } catch (Exception e) { - result.setStatus(TaskResult.Status.FAILED); - result.setReasonForIncompletion(e.getMessage()); - e.printStackTrace(); + return uploaded; } - return result; } } From d934f06ec544632c0d7d49005023eeaa67a7be53 Mon Sep 17 00:00:00 2001 From: Viren Baraiya Date: Sat, 18 Jul 2026 00:02:30 -0700 Subject: [PATCH 03/14] refactor(agent): remove redundant JSON property annotations --- .../client/model/agent/AgentRequest.java | 13 +-- .../model/agent/AgentStatusResponse.java | 5 - .../client/model/agent/CompileResponse.java | 3 - .../client/model/agent/PendingTool.java | 2 - .../client/model/agent/RespondBody.java | 2 + .../client/model/agent/StartResponse.java | 4 - .../model/agent/AgentModelJsonTest.java | 99 +++++++++++++++++++ 7 files changed, 103 insertions(+), 25 deletions(-) create mode 100644 conductor-client/src/test/java/io/orkes/conductor/client/model/agent/AgentModelJsonTest.java diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentRequest.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentRequest.java index a3b4bab4b..09a4e4a08 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentRequest.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentRequest.java @@ -15,6 +15,7 @@ import java.util.List; import java.util.Map; +import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -32,48 +33,38 @@ * are omitted from the JSON body. */ @JsonInclude(JsonInclude.Include.NON_NULL) +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) public final class AgentRequest { // ── Agent definition (mutually exclusive shapes) ───────────────────── /** Serialized agent definition for native agents; {@code null} on the framework path. */ - @JsonProperty("agentConfig") private final Object agentConfig; /** Framework wire name (e.g. {@code "openai"}); {@code null} on the native path. */ - @JsonProperty("framework") private final String framework; /** Serialized agent definition for framework-backed agents; {@code null} on the native path. */ - @JsonProperty("rawConfig") private final Object rawConfig; // ── Execution fields (only meaningful for /start) ──────────────────── - @JsonProperty("prompt") private final String prompt; - @JsonProperty("sessionId") private final String sessionId; - @JsonProperty("runId") private final String runId; @JsonProperty("static_plan") private final Object staticPlan; // ── Optional fields ────────────────────────────────────────────────── - @JsonProperty("media") private final List media; - @JsonProperty("context") private final Map context; - @JsonProperty("idempotencyKey") private final String idempotencyKey; - @JsonProperty("credentials") private final List credentials; - @JsonProperty("timeoutSeconds") private final Integer timeoutSeconds; private AgentRequest(Builder b) { diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentStatusResponse.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentStatusResponse.java index 41fa81301..d0dd8825b 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentStatusResponse.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentStatusResponse.java @@ -27,10 +27,8 @@ @JsonIgnoreProperties(ignoreUnknown = true) public final class AgentStatusResponse { - @JsonProperty("executionId") private String executionId; - @JsonProperty("status") private String status; @JsonProperty("isComplete") @@ -42,13 +40,10 @@ public final class AgentStatusResponse { @JsonProperty("isWaiting") private boolean waiting; - @JsonProperty("output") private Map output; - @JsonProperty("reasonForIncompletion") private String reasonForIncompletion; - @JsonProperty("pendingTool") private PendingTool pendingTool; public AgentStatusResponse() {} diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/CompileResponse.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/CompileResponse.java index 237afc88a..8079c0b8a 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/CompileResponse.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/CompileResponse.java @@ -17,7 +17,6 @@ import java.util.Map; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; /** * Response from {@code POST /api/agent/compile}. @@ -27,10 +26,8 @@ @JsonIgnoreProperties(ignoreUnknown = true) public final class CompileResponse { - @JsonProperty("workflowDef") private Map workflowDef; - @JsonProperty("requiredWorkers") private List requiredWorkers; public CompileResponse() {} diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/PendingTool.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/PendingTool.java index 42bd914c7..02ff51b4b 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/PendingTool.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/PendingTool.java @@ -27,13 +27,11 @@ @JsonIgnoreProperties(ignoreUnknown = true) public final class PendingTool { - @JsonProperty("taskRefName") private String taskRefName; @JsonProperty("tool_name") private String toolName; - @JsonProperty("parameters") private Map parameters; @JsonProperty("response_schema") diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/RespondBody.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/RespondBody.java index 089ab0d4d..7a6e7c582 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/RespondBody.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/RespondBody.java @@ -42,6 +42,8 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public final class RespondBody { + // This write-only DTO intentionally has no accessors, so these annotations provide field + // visibility in addition to naming the properties. @JsonProperty("approved") private final Boolean approved; diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/StartResponse.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/StartResponse.java index ab7abe2d5..357594886 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/StartResponse.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/StartResponse.java @@ -17,7 +17,6 @@ import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; /** * Response from {@code POST /api/agent/deploy} and {@code POST /api/agent/start}. @@ -30,14 +29,11 @@ public final class StartResponse { /** Current canonical field name. {@code @JsonAlias} handles older server versions. */ - @JsonProperty("executionId") @JsonAlias({"workflowId", "id", "correlationId"}) private String executionId; - @JsonProperty("agentName") private String agentName; - @JsonProperty("requiredWorkers") private List requiredWorkers; public StartResponse() {} diff --git a/conductor-client/src/test/java/io/orkes/conductor/client/model/agent/AgentModelJsonTest.java b/conductor-client/src/test/java/io/orkes/conductor/client/model/agent/AgentModelJsonTest.java new file mode 100644 index 000000000..be02064cf --- /dev/null +++ b/conductor-client/src/test/java/io/orkes/conductor/client/model/agent/AgentModelJsonTest.java @@ -0,0 +1,99 @@ +/* + * Copyright 2026 Conductor Authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package io.orkes.conductor.client.model.agent; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class AgentModelJsonTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + public void serializesAgentRequestWithDefaultNamesAndExplicitWireExceptions() throws Exception { + AgentRequest request = AgentRequest.frameworkAgent("openai", Map.of("model", "gpt-4o")) + .prompt("hello") + .sessionId("session-1") + .staticPlan(Map.of("step", 1)) + .timeoutSeconds(30) + .build(); + + JsonNode json = objectMapper.valueToTree(request); + + assertEquals("openai", json.get("framework").asText()); + assertEquals("gpt-4o", json.get("rawConfig").get("model").asText()); + assertEquals("hello", json.get("prompt").asText()); + assertEquals("session-1", json.get("sessionId").asText()); + assertEquals(1, json.get("static_plan").get("step").asInt()); + assertEquals(30, json.get("timeoutSeconds").asInt()); + assertFalse(json.has("staticPlan")); + assertFalse(json.has("agentConfig")); + } + + @Test + public void deserializesResponsesWithoutRedundantPropertyAnnotations() throws Exception { + StartResponse start = objectMapper.readValue( + "{\"executionId\":\"execution-1\",\"agentName\":\"researcher\"," + + "\"requiredWorkers\":[\"search\"]}", + StartResponse.class); + CompileResponse compile = objectMapper.readValue( + "{\"workflowDef\":{\"name\":\"researcher\"}," + + "\"requiredWorkers\":[\"search\"]}", + CompileResponse.class); + + assertEquals("execution-1", start.getExecutionId()); + assertEquals("researcher", start.getAgentName()); + assertEquals(List.of("search"), start.getRequiredWorkers()); + assertEquals("researcher", compile.getWorkflowDef().get("name")); + assertEquals(List.of("search"), compile.getRequiredWorkers()); + } + + @Test + public void retainsAnnotationsWhereJavaAndWireNamesDiffer() throws Exception { + AgentStatusResponse status = objectMapper.readValue( + "{\"executionId\":\"execution-1\",\"status\":\"PAUSED\"," + + "\"isComplete\":false,\"isRunning\":true,\"isWaiting\":true," + + "\"pendingTool\":{\"taskRefName\":\"approve_ref\"," + + "\"tool_name\":\"approve\",\"parameters\":{\"amount\":42}," + + "\"response_schema\":{\"type\":\"object\"}," + + "\"response_ui_schema\":{\"widget\":\"approval\"}}}", + AgentStatusResponse.class); + + assertEquals("execution-1", status.getExecutionId()); + assertTrue(status.isRunning()); + assertTrue(status.isWaiting()); + assertFalse(status.isComplete()); + assertEquals("approve_ref", status.getPendingTool().getTaskRefName()); + assertEquals("approve", status.getPendingTool().getToolName()); + assertEquals(42, status.getPendingTool().getParameters().get("amount")); + } + + @Test + public void retainsExecutionIdAliasesWithoutJsonProperty() throws Exception { + StartResponse response = objectMapper.readValue( + "{\"workflowId\":\"legacy-execution\",\"agentName\":\"researcher\"}", + StartResponse.class); + + assertEquals("legacy-execution", response.getExecutionId()); + assertEquals("researcher", response.getAgentName()); + } +} From 5fedb2886517cfa6e3beeb031dfb631fb3783322 Mon Sep 17 00:00:00 2001 From: Viren Baraiya Date: Sat, 18 Jul 2026 00:04:38 -0700 Subject: [PATCH 04/14] refactor(agent): use Lombok for model accessors --- .../client/model/agent/AgentRequest.java | 6 +- .../model/agent/AgentStatusResponse.java | 56 +------------------ .../client/model/agent/CompileResponse.java | 31 +++------- .../client/model/agent/PendingTool.java | 33 +---------- .../client/model/agent/RespondBody.java | 33 +++-------- .../client/model/agent/StartResponse.java | 32 ++--------- .../model/agent/AgentModelJsonTest.java | 13 +++++ 7 files changed, 42 insertions(+), 162 deletions(-) diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentRequest.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentRequest.java index 09a4e4a08..2aa898401 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentRequest.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentRequest.java @@ -15,9 +15,10 @@ import java.util.List; import java.util.Map; -import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.ToString; /** * Request payload for {@code POST /api/agent/compile}, {@code /deploy}, and {@code /start}. @@ -33,7 +34,8 @@ * are omitted from the JSON body. */ @JsonInclude(JsonInclude.Include.NON_NULL) -@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) +@Data +@ToString(onlyExplicitlyIncluded = true) public final class AgentRequest { // ── Agent definition (mutually exclusive shapes) ───────────────────── diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentStatusResponse.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentStatusResponse.java index d0dd8825b..0614ec193 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentStatusResponse.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentStatusResponse.java @@ -16,6 +16,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; /** * Response from {@code GET /api/agent/{executionId}/status}. @@ -25,6 +26,7 @@ * {@code AgentResult} after completion. */ @JsonIgnoreProperties(ignoreUnknown = true) +@Data public final class AgentStatusResponse { private String executionId; @@ -46,58 +48,4 @@ public final class AgentStatusResponse { private PendingTool pendingTool; - public AgentStatusResponse() {} - - public String getExecutionId() { - return executionId; - } - - /** - * Conductor workflow status string: {@code RUNNING}, {@code COMPLETED}, - * {@code FAILED}, {@code TERMINATED}, {@code TIMED_OUT}, {@code PAUSED}. - */ - public String getStatus() { - return status; - } - - /** {@code true} when status is terminal (COMPLETED, FAILED, TERMINATED, TIMED_OUT). */ - public boolean isComplete() { - return complete; - } - - public boolean isRunning() { - return running; - } - - /** {@code true} when a HITL task is paused waiting for human input. */ - public boolean isWaiting() { - return waiting; - } - - /** - * Final workflow output. Only present when {@link #isComplete()} is {@code true}. - */ - public Map getOutput() { - return output; - } - - /** - * Failure or termination reason. Only present for non-COMPLETED terminal runs. - */ - public String getReasonForIncompletion() { - return reasonForIncompletion; - } - - /** - * Details of the paused HITL task. Only present when {@link #isWaiting()} is {@code true}. - */ - public PendingTool getPendingTool() { - return pendingTool; - } - - @Override - public String toString() { - return "AgentStatusResponse{executionId=" + executionId + ", status=" + status + ", complete=" + complete - + ", waiting=" + waiting + "}"; - } } diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/CompileResponse.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/CompileResponse.java index 8079c0b8a..e0aa7cc4f 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/CompileResponse.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/CompileResponse.java @@ -17,6 +17,9 @@ import java.util.Map; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import lombok.Data; /** * Response from {@code POST /api/agent/compile}. @@ -24,30 +27,12 @@ *

Returned by the agent runtime's {@code plan(Agent)}. */ @JsonIgnoreProperties(ignoreUnknown = true) +@Data public final class CompileResponse { - private Map workflowDef; + @JsonSetter(nulls = Nulls.AS_EMPTY) + private Map workflowDef = Collections.emptyMap(); - private List requiredWorkers; - - public CompileResponse() {} - - /** The compiled Conductor workflow definition. */ - public Map getWorkflowDef() { - return workflowDef != null ? workflowDef : Collections.emptyMap(); - } - - /** - * Task type names the SDK must register local workers for before the agent - * can make progress. The SDK handles this automatically inside - * {@code AgentRuntime#run}. - */ - public List getRequiredWorkers() { - return requiredWorkers != null ? requiredWorkers : Collections.emptyList(); - } - - @Override - public String toString() { - return "CompileResponse{requiredWorkers=" + getRequiredWorkers() + "}"; - } + @JsonSetter(nulls = Nulls.AS_EMPTY) + private List requiredWorkers = Collections.emptyList(); } diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/PendingTool.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/PendingTool.java index 02ff51b4b..16c8f1562 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/PendingTool.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/PendingTool.java @@ -16,6 +16,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; /** * Details of the HITL task that is currently paused, embedded in {@link AgentStatusResponse}. @@ -25,6 +26,7 @@ * {@link io.orkes.conductor.client.AgentClient#respond(String, RespondBody)} to resume execution. */ @JsonIgnoreProperties(ignoreUnknown = true) +@Data public final class PendingTool { private String taskRefName; @@ -40,35 +42,4 @@ public final class PendingTool { @JsonProperty("response_ui_schema") private Object responseUiSchema; - public PendingTool() {} - - /** Conductor task reference name — echoed back in the respond body when needed. */ - public String getTaskRefName() { - return taskRefName; - } - - /** Logical tool name shown to the human reviewer. */ - public String getToolName() { - return toolName; - } - - /** Arguments the agent passed to the tool (what the human is being asked to approve). */ - public Map getParameters() { - return parameters; - } - - /** JSON Schema the response body must conform to, or {@code null} if unconstrained. */ - public Object getResponseSchema() { - return responseSchema; - } - - /** UI rendering hints for approval form rendering, or {@code null} if absent. */ - public Object getResponseUiSchema() { - return responseUiSchema; - } - - @Override - public String toString() { - return "PendingTool{toolName=" + toolName + ", taskRefName=" + taskRefName + "}"; - } } diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/RespondBody.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/RespondBody.java index 7a6e7c582..e55cf1b51 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/RespondBody.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/RespondBody.java @@ -16,9 +16,9 @@ import java.util.Map; import com.fasterxml.jackson.annotation.JsonAnyGetter; -import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.Getter; /** * Request body for {@code POST /api/agent/{executionId}/respond}. @@ -35,22 +35,20 @@ * {@link #of(Map)} with the schema-defined keys. * * - *

{@code @JsonAnyGetter} / {@code @JsonAnySetter} flatten {@code extraFields} - * into the top-level JSON object so all fields appear at the root level, matching - * how the server reads the body as a plain {@code Map}. + *

{@code @JsonAnyGetter} flattens {@code extraFields} into the top-level JSON object so all + * fields appear at the root level, matching how the server reads the body as a plain {@code + * Map}. */ @JsonInclude(JsonInclude.Include.NON_NULL) +@Data public final class RespondBody { - // This write-only DTO intentionally has no accessors, so these annotations provide field - // visibility in addition to naming the properties. - @JsonProperty("approved") private final Boolean approved; - @JsonProperty("reason") private final String reason; /** Arbitrary extra fields serialized at the top level via {@link JsonAnyGetter}. */ + @Getter(onMethod_ = @JsonAnyGetter) private final Map extraFields; private RespondBody(Boolean approved, String reason, Map extraFields) { @@ -84,21 +82,4 @@ public static RespondBody of(Map data) { return new RespondBody(null, null, data != null ? new LinkedHashMap<>(data) : null); } - // ── Jackson ─────────────────────────────────────────────────────────── - - @JsonAnyGetter - public Map getExtraFields() { - return extraFields; - } - - @JsonAnySetter - void setExtraField(String key, Object value) { - // no-op: this class is write-only (we never deserialize RespondBody) - } - - @Override - public String toString() { - if (extraFields != null) return "RespondBody" + extraFields; - return "RespondBody{approved=" + approved + ", reason=" + reason + "}"; - } } diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/StartResponse.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/StartResponse.java index 357594886..eb6c94424 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/StartResponse.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/StartResponse.java @@ -17,6 +17,9 @@ import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import lombok.Data; /** * Response from {@code POST /api/agent/deploy} and {@code POST /api/agent/start}. @@ -26,6 +29,7 @@ * {@link io.orkes.conductor.client.AgentClient#getAgentStatus(String)} and {@link io.orkes.conductor.client.AgentClient#respond(String, RespondBody)}. */ @JsonIgnoreProperties(ignoreUnknown = true) +@Data public final class StartResponse { /** Current canonical field name. {@code @JsonAlias} handles older server versions. */ @@ -34,30 +38,6 @@ public final class StartResponse { private String agentName; - private List requiredWorkers; - - public StartResponse() {} - - /** Conductor workflow ID for this execution, or {@code null} for deploy-only calls. */ - public String getExecutionId() { - return executionId; - } - - /** The registered workflow name on the server. */ - public String getAgentName() { - return agentName; - } - - /** - * Task type names the SDK must have workers polling before the agent can progress. - * Handled automatically by the runtime. - */ - public List getRequiredWorkers() { - return requiredWorkers != null ? requiredWorkers : Collections.emptyList(); - } - - @Override - public String toString() { - return "StartResponse{executionId=" + executionId + ", agentName=" + agentName + "}"; - } + @JsonSetter(nulls = Nulls.AS_EMPTY) + private List requiredWorkers = Collections.emptyList(); } diff --git a/conductor-client/src/test/java/io/orkes/conductor/client/model/agent/AgentModelJsonTest.java b/conductor-client/src/test/java/io/orkes/conductor/client/model/agent/AgentModelJsonTest.java index be02064cf..655a06eb3 100644 --- a/conductor-client/src/test/java/io/orkes/conductor/client/model/agent/AgentModelJsonTest.java +++ b/conductor-client/src/test/java/io/orkes/conductor/client/model/agent/AgentModelJsonTest.java @@ -96,4 +96,17 @@ public void retainsExecutionIdAliasesWithoutJsonProperty() throws Exception { assertEquals("legacy-execution", response.getExecutionId()); assertEquals("researcher", response.getAgentName()); } + + @Test + public void serializesRespondBodyThroughLombokGeneratedAccessors() { + JsonNode approval = objectMapper.valueToTree(RespondBody.approve("looks good")); + JsonNode custom = objectMapper.valueToTree(RespondBody.of(Map.of("selected", "writer"))); + + assertEquals(objectMapper.createObjectNode() + .put("approved", true) + .put("reason", "looks good"), + approval); + assertEquals(objectMapper.createObjectNode().put("selected", "writer"), custom); + assertFalse(custom.has("extraFields")); + } } From dbc6fb84fe8b453255210c66349486b65b52da91 Mon Sep 17 00:00:00 2001 From: Viren Baraiya Date: Sat, 18 Jul 2026 00:10:19 -0700 Subject: [PATCH 05/14] fix(agent): keep Lombok models Javadoc-compatible --- .../io/orkes/conductor/client/model/agent/PendingTool.java | 4 ++-- .../io/orkes/conductor/client/model/agent/RespondBody.java | 4 +++- .../io/orkes/conductor/client/model/agent/StartResponse.java | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/PendingTool.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/PendingTool.java index 16c8f1562..ae355363c 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/PendingTool.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/PendingTool.java @@ -21,8 +21,8 @@ /** * Details of the HITL task that is currently paused, embedded in {@link AgentStatusResponse}. * - *

Present only when {@link AgentStatusResponse#isWaiting()} is {@code true}. - * Pass {@link #getTaskRefName()} back to the server via + *

Present only when the enclosing agent status is waiting. Pass its task reference name back to + * the server via * {@link io.orkes.conductor.client.AgentClient#respond(String, RespondBody)} to resume execution. */ @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/RespondBody.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/RespondBody.java index e55cf1b51..cdb751406 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/RespondBody.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/RespondBody.java @@ -17,6 +17,7 @@ import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AccessLevel; import lombok.Data; import lombok.Getter; @@ -48,7 +49,8 @@ public final class RespondBody { private final String reason; /** Arbitrary extra fields serialized at the top level via {@link JsonAnyGetter}. */ - @Getter(onMethod_ = @JsonAnyGetter) + @JsonAnyGetter + @Getter(AccessLevel.NONE) private final Map extraFields; private RespondBody(Boolean approved, String reason, Map extraFields) { diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/StartResponse.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/StartResponse.java index eb6c94424..a672f6818 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/StartResponse.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/StartResponse.java @@ -24,8 +24,8 @@ /** * Response from {@code POST /api/agent/deploy} and {@code POST /api/agent/start}. * - *

For deploy, {@link #getExecutionId()} is {@code null} — no execution was started. - * For start, {@link #getExecutionId()} is the Conductor workflow ID to pass to + *

For deploy, the execution ID is {@code null} — no execution was started. For start, the + * execution ID is the Conductor workflow ID to pass to * {@link io.orkes.conductor.client.AgentClient#getAgentStatus(String)} and {@link io.orkes.conductor.client.AgentClient#respond(String, RespondBody)}. */ @JsonIgnoreProperties(ignoreUnknown = true) From 63d487d3c9abbb0a33db900b1fcde263fc385a4b Mon Sep 17 00:00:00 2001 From: Viren Baraiya Date: Sat, 18 Jul 2026 00:13:29 -0700 Subject: [PATCH 06/14] agent fixes --- .../orkes/conductor/client/AgentClient.java | 11 ++++ .../client/http/OrkesAgentClient.java | 12 ++++ .../client/model/agent/AgentRequest.java | 59 ++++++++++++++--- .../model/agent/AgentStatusResponse.java | 4 ++ .../client/http/OrkesAgentClientTest.java | 41 ++++++++++++ .../model/agent/AgentModelJsonTest.java | 64 +++++++++++++++++++ 6 files changed, 183 insertions(+), 8 deletions(-) diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/AgentClient.java b/conductor-client/src/main/java/io/orkes/conductor/client/AgentClient.java index 33f6d94f4..363bad79b 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/AgentClient.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/AgentClient.java @@ -70,6 +70,17 @@ public interface AgentClient extends AutoCloseable { /** {@code POST /api/agent/{executionId}/stop} — graceful deterministic stop. */ void stopAgent(String executionId); + /** + * {@code DELETE /api/agent/{executionId}/cancel} — immediately cancel an execution and + * optionally record a reason. + * + *

The default preserves compatibility for custom {@code AgentClient} implementations + * compiled before cancellation was added. Transport implementations should override it. + */ + default void cancelAgent(String executionId, String reason) { + throw new UnsupportedOperationException("Agent cancellation is not supported"); + } + /** {@code POST /api/agent/{executionId}/signal} — inject persistent context. */ void signalAgent(String executionId, String message); diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/http/OrkesAgentClient.java b/conductor-client/src/main/java/io/orkes/conductor/client/http/OrkesAgentClient.java index a96a26c17..202c75540 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/http/OrkesAgentClient.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/http/OrkesAgentClient.java @@ -135,6 +135,18 @@ public void stopAgent(String executionId) { execute(req); } + @Override + public void cancelAgent(String executionId, String reason) { + ConductorClientRequest.Builder builder = ConductorClientRequest.builder() + .method(Method.DELETE) + .path("/agent/{executionId}/cancel") + .addPathParam("executionId", executionId); + if (reason != null && !reason.isBlank()) { + builder.addQueryParam("reason", reason); + } + execute(builder.build()); + } + @Override public void signalAgent(String executionId, String message) { ConductorClientRequest req = ConductorClientRequest.builder() diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentRequest.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentRequest.java index 2aa898401..0f2de9bcf 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentRequest.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentRequest.java @@ -26,12 +26,13 @@ *

All three endpoints share the same server-side {@code StartRequest} DTO. The agent * definition arrives pre-serialized as a JSON-ready map — domain serialization is owned by * the agent SDK ({@code conductor-client-ai}), keeping this transport DTO free of agent types. - * Native agents carry it under {@code "agentConfig"}; framework-backed agents under - * {@code "framework"} + {@code "rawConfig"}. + * Deployed agents carry {@code "name"} + optional {@code "version"}; native agents carry their + * definition under {@code "agentConfig"}; framework-backed agents use {@code "framework"} plus + * {@code "rawConfig"} or {@code "skillRef"}. * - *

Build via {@link #nativeAgent(Object)} or {@link #frameworkAgent(String, Object)}, - * then chain builder methods for execution-specific fields. Unset ({@code null}) fields - * are omitted from the JSON body. + *

Build via {@link #deployedAgent(String, Integer)}, {@link #nativeAgent(Object)}, or {@link + * #frameworkAgent(String, Object)}, then chain builder methods for execution-specific fields. + * Unset ({@code null}) fields are omitted from the JSON body. */ @JsonInclude(JsonInclude.Include.NON_NULL) @Data @@ -39,6 +40,12 @@ public final class AgentRequest { // ── Agent definition (mutually exclusive shapes) ───────────────────── + /** Name of an already-deployed agent; {@code null} for inline definitions. */ + private final String name; + + /** Optional version of an already-deployed agent. */ + private final Integer version; + /** Serialized agent definition for native agents; {@code null} on the framework path. */ private final Object agentConfig; @@ -48,6 +55,12 @@ public final class AgentRequest { /** Serialized agent definition for framework-backed agents; {@code null} on the native path. */ private final Object rawConfig; + /** Optional model override understood by the server's agent control plane. */ + private final String model; + + /** Skill reference for framework-backed agents that do not provide {@code rawConfig}. */ + private final Map skillRef; + // ── Execution fields (only meaningful for /start) ──────────────────── private final String prompt; @@ -70,9 +83,13 @@ public final class AgentRequest { private final Integer timeoutSeconds; private AgentRequest(Builder b) { + this.name = b.name; + this.version = b.version; this.agentConfig = b.agentConfig; this.framework = b.framework; this.rawConfig = b.rawConfig; + this.model = b.model; + this.skillRef = b.skillRef; this.prompt = b.prompt; this.sessionId = b.sessionId; this.runId = b.runId; @@ -84,22 +101,31 @@ private AgentRequest(Builder b) { this.timeoutSeconds = b.timeoutSeconds; } + /** Build a request that starts an already-deployed agent by name and optional version. */ + public static Builder deployedAgent(String name, Integer version) { + return new Builder(name, version, null, null, null); + } + /** Build a request for a native (non-framework) agent from its serialized definition. */ public static Builder nativeAgent(Object agentConfig) { - return new Builder(agentConfig, null, null); + return new Builder(null, null, agentConfig, null, null); } /** Build a request for a framework-backed agent (OpenAI, ADK, Skill) from its serialized definition. */ public static Builder frameworkAgent(String framework, Object rawConfig) { - return new Builder(null, framework, rawConfig); + return new Builder(null, null, null, framework, rawConfig); } // ── Builder ────────────────────────────────────────────────────────── public static final class Builder { + private final String name; + private final Integer version; private final Object agentConfig; private final String framework; private final Object rawConfig; + private String model; + private Map skillRef; private String prompt; private String sessionId; private String runId; @@ -110,12 +136,29 @@ public static final class Builder { private List credentials; private Integer timeoutSeconds; - private Builder(Object agentConfig, String framework, Object rawConfig) { + private Builder( + String name, + Integer version, + Object agentConfig, + String framework, + Object rawConfig) { + this.name = name; + this.version = version; this.agentConfig = agentConfig; this.framework = framework; this.rawConfig = rawConfig; } + public Builder model(String v) { + this.model = v; + return this; + } + + public Builder skillRef(Map v) { + this.skillRef = v; + return this; + } + public Builder prompt(String v) { this.prompt = v; return this; diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentStatusResponse.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentStatusResponse.java index 0614ec193..f2b100928 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentStatusResponse.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/agent/AgentStatusResponse.java @@ -33,6 +33,10 @@ public final class AgentStatusResponse { private String status; + private Long startTime; + + private Long endTime; + @JsonProperty("isComplete") private boolean complete; diff --git a/conductor-client/src/test/java/io/orkes/conductor/client/http/OrkesAgentClientTest.java b/conductor-client/src/test/java/io/orkes/conductor/client/http/OrkesAgentClientTest.java index 34b7cbaf7..649697d01 100644 --- a/conductor-client/src/test/java/io/orkes/conductor/client/http/OrkesAgentClientTest.java +++ b/conductor-client/src/test/java/io/orkes/conductor/client/http/OrkesAgentClientTest.java @@ -139,6 +139,32 @@ public void stopAgentPostsWithNoBody() throws InterruptedException { assertEquals("", request.getBody().readUtf8()); } + @Test + public void cancelAgentDeletesWithReasonQueryParameter() throws InterruptedException { + server.enqueue(json("{}")); + + agentClient.cancelAgent("e1", "cancelled by user"); + + RecordedRequest request = takeRequest(); + assertEquals("DELETE", request.getMethod()); + assertEquals("/api/agent/e1/cancel?reason=cancelled%20by%20user", request.getPath()); + assertEquals("cancelled by user", request.getRequestUrl().queryParameter("reason")); + assertEquals("", request.getBody().readUtf8()); + } + + @Test + public void cancelAgentOmitsNullAndBlankReasons() throws InterruptedException { + for (String reason : new String[] {null, "", " "}) { + server.enqueue(json("{}")); + + agentClient.cancelAgent("e1", reason); + + RecordedRequest request = takeRequest(); + assertEquals("DELETE", request.getMethod()); + assertEquals("/api/agent/e1/cancel", request.getPath()); + } + } + @Test public void signalAgentPostsMessageBody() throws InterruptedException { server.enqueue(json("{}")); @@ -166,6 +192,21 @@ public void serverErrorMapsToAgentAPIException() { assertEquals(500, ex.getStatusCode()); } + @Test + public void cancelAgentMapsHttpErrorsToTypedExceptions() { + server.enqueue(json("{\"message\":\"missing\"}").setResponseCode(404)); + assertThrows( + AgentNotFoundException.class, + () -> agentClient.cancelAgent("missing", "no longer needed")); + + server.enqueue(json("{\"message\":\"boom\"}").setResponseCode(500)); + AgentAPIException ex = + assertThrows( + AgentAPIException.class, + () -> agentClient.cancelAgent("e1", "no longer needed")); + assertEquals(500, ex.getStatusCode()); + } + @Test public void streamSseRequiresApiClientTransport() { OrkesAgentClient plainTransport = diff --git a/conductor-client/src/test/java/io/orkes/conductor/client/model/agent/AgentModelJsonTest.java b/conductor-client/src/test/java/io/orkes/conductor/client/model/agent/AgentModelJsonTest.java index 655a06eb3..b949231cc 100644 --- a/conductor-client/src/test/java/io/orkes/conductor/client/model/agent/AgentModelJsonTest.java +++ b/conductor-client/src/test/java/io/orkes/conductor/client/model/agent/AgentModelJsonTest.java @@ -22,12 +22,73 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; public class AgentModelJsonTest { private final ObjectMapper objectMapper = new ObjectMapper(); + @Test + public void serializesDeployedAgentNameAndVersionAtTopLevel() { + AgentRequest request = AgentRequest.deployedAgent("researcher", 7).build(); + + JsonNode json = objectMapper.valueToTree(request); + + assertEquals( + objectMapper.createObjectNode().put("name", "researcher").put("version", 7), + json); + assertEquals("researcher", request.getName()); + assertEquals(7, request.getVersion()); + assertNull(request.getAgentConfig()); + assertNull(request.getFramework()); + assertNull(request.getRawConfig()); + + JsonNode unversioned = + objectMapper.valueToTree(AgentRequest.deployedAgent("researcher", null).build()); + assertEquals(objectMapper.createObjectNode().put("name", "researcher"), unversioned); + } + + @Test + public void serializesModelAndSkillRefWithExactPropertyNames() { + AgentRequest request = + AgentRequest.frameworkAgent("skill", null) + .model("claude-sonnet") + .skillRef(Map.of("name", "code-review", "version", 2)) + .build(); + + JsonNode json = objectMapper.valueToTree(request); + + assertEquals("skill", json.get("framework").asText()); + assertEquals("claude-sonnet", json.get("model").asText()); + assertEquals("code-review", json.get("skillRef").get("name").asText()); + assertEquals(2, json.get("skillRef").get("version").asInt()); + assertFalse(json.has("rawConfig")); + assertFalse(json.has("skill_ref")); + } + + @Test + public void nativeAndFrameworkAgentShapesRemainUnchangedAndNullsAreOmitted() { + JsonNode nativeJson = objectMapper.valueToTree( + AgentRequest.nativeAgent(Map.of("goal", "review")).build()); + JsonNode frameworkJson = objectMapper.valueToTree( + AgentRequest.frameworkAgent("openai", Map.of("model", "gpt-4o")).build()); + + assertEquals( + objectMapper.createObjectNode() + .set("agentConfig", objectMapper.createObjectNode().put("goal", "review")), + nativeJson); + assertEquals( + objectMapper.createObjectNode() + .put("framework", "openai") + .set("rawConfig", objectMapper.createObjectNode().put("model", "gpt-4o")), + frameworkJson); + for (String field : List.of("name", "version", "model", "skillRef")) { + assertFalse(nativeJson.has(field)); + assertFalse(frameworkJson.has(field)); + } + } + @Test public void serializesAgentRequestWithDefaultNamesAndExplicitWireExceptions() throws Exception { AgentRequest request = AgentRequest.frameworkAgent("openai", Map.of("model", "gpt-4o")) @@ -71,6 +132,7 @@ public void deserializesResponsesWithoutRedundantPropertyAnnotations() throws Ex public void retainsAnnotationsWhereJavaAndWireNamesDiffer() throws Exception { AgentStatusResponse status = objectMapper.readValue( "{\"executionId\":\"execution-1\",\"status\":\"PAUSED\"," + + "\"startTime\":1710000000000,\"endTime\":1710000005000," + "\"isComplete\":false,\"isRunning\":true,\"isWaiting\":true," + "\"pendingTool\":{\"taskRefName\":\"approve_ref\"," + "\"tool_name\":\"approve\",\"parameters\":{\"amount\":42}," @@ -79,6 +141,8 @@ public void retainsAnnotationsWhereJavaAndWireNamesDiffer() throws Exception { AgentStatusResponse.class); assertEquals("execution-1", status.getExecutionId()); + assertEquals(1710000000000L, status.getStartTime()); + assertEquals(1710000005000L, status.getEndTime()); assertTrue(status.isRunning()); assertTrue(status.isWaiting()); assertFalse(status.isComplete()); From 55c74ddd32f2533c7863a82d458c29cc94a6e31c Mon Sep 17 00:00:00 2001 From: Viren Baraiya Date: Sat, 18 Jul 2026 12:44:40 -0700 Subject: [PATCH 07/14] agentspan changes --- .github/workflows/agent-e2e.yml | 4 +- CHANGELOG.md | 80 ---- README.md | 45 +- agent-examples/VERIFICATION.md | 16 +- .../ai/examples/Example01BasicAgent.java | 4 +- .../ai/examples/Example04HttpAndMcpTools.java | 2 +- .../examples/Example108PlanExecuteRefs.java | 17 +- .../ai/examples/Example115PlannerContext.java | 4 +- .../ai/examples/Example16CredentialsTool.java | 6 +- .../examples/Example31ToolInputGuardrail.java | 12 +- .../ai/examples/Example38TechTrends.java | 2 +- .../ai/examples/Example56RagAgent.java | 12 +- .../ai/examples/Example57PlanDryRun.java | 2 +- .../ai/examples/Example58ScatterGather.java | 2 +- .../Example68ContextCondensation.java | 2 +- .../ai/examples/Example69Skills.java | 6 +- .../ai/examples/Example70AnnotatedAgent.java | 4 +- .../examples/Example71AgentControlPlane.java | 90 ++++ .../ai/examples/Example99ScheduledAgent.java | 112 ++--- .../conductor/ai/examples/Settings.java | 18 +- .../ai/examples/adk/Example00HelloWorld.java | 6 +- .../ai/examples/adk/Example01BasicAgent.java | 4 +- .../examples/adk/Example02FunctionTools.java | 2 +- .../ai/examples/adk/Example19SupplyChain.java | 8 +- .../ai/examples/adk/Example23Callbacks.java | 2 +- .../examples/adk/Example34MlEngineering.java | 2 +- .../ai/examples/adk/Example35RagAgent.java | 2 +- .../examples/adk/Example37DeployAndServe.java | 10 +- ...java => Example38ConductorGuardrails.java} | 16 +- .../langchain/Example01HelloWorld.java | 12 +- .../langchain/Example02ReactWithTools.java | 10 +- .../langchain/Example03CustomTools.java | 8 +- .../langchain/Example04StructuredOutput.java | 10 +- .../langchain/Example05PromptTemplates.java | 8 +- .../langchain/Example06ChatHistory.java | 14 +- .../langchain/Example07MemoryAgent.java | 14 +- .../langchain/Example08MultiToolAgent.java | 8 +- .../langchain/Example09MathCalculator.java | 4 +- .../langchain/Example10WebSearchAgent.java | 4 +- .../langchain/Example11CodeReviewAgent.java | 4 +- .../Example12DocumentSummarizer.java | 4 +- .../Example13CustomerServiceAgent.java | 4 +- .../langchain/Example14ResearchAssistant.java | 4 +- .../langchain/Example15DataAnalyst.java | 4 +- .../langchain/Example16ContentWriter.java | 4 +- .../examples/langchain/Example17SqlAgent.java | 4 +- .../langchain/Example18EmailDrafter.java | 4 +- .../langchain/Example19FactChecker.java | 4 +- .../langchain/Example20TranslationAgent.java | 4 +- .../langchain/Example21SentimentAnalysis.java | 4 +- .../Example22ClassificationAgent.java | 4 +- .../Example23RecommendationAgent.java | 4 +- .../langchain/Example24OutputParsers.java | 6 +- .../Example25AdvancedOrchestration.java | 4 +- ...java => Example26ConductorGuardrails.java} | 16 +- .../langchain/ExampleCredentials.java | 22 +- .../examples/langchain/ExamplePipeline.java | 16 +- .../langgraph/Example01HelloWorld.java | 8 +- .../langgraph/Example02ReactWithTools.java | 8 +- .../examples/langgraph/Example03Memory.java | 10 +- .../langgraph/Example04SimpleStateGraph.java | 8 +- .../examples/langgraph/Example05ToolNode.java | 4 +- .../Example06ConditionalRouting.java | 4 +- .../langgraph/Example07SystemPrompt.java | 6 +- .../langgraph/Example08StructuredOutput.java | 4 +- .../langgraph/Example09MathAgent.java | 4 +- .../langgraph/Example10ResearchAgent.java | 4 +- .../langgraph/Example11CustomerSupport.java | 4 +- .../examples/openai/Example01BasicAgent.java | 6 +- .../openai/Example02FunctionTools.java | 10 +- .../openai/Example03StructuredOutput.java | 4 +- .../ai/examples/openai/Example04Handoffs.java | 4 +- .../examples/openai/Example05Guardrails.java | 4 +- .../openai/Example06ModelSettings.java | 4 +- .../examples/openai/Example07Streaming.java | 4 +- .../examples/openai/Example08AgentAsTool.java | 4 +- .../openai/Example09DynamicInstructions.java | 4 +- .../examples/openai/Example10MultiModel.java | 4 +- .../ai/spring/AgentAutoConfiguration.java | 6 +- .../conductor/ai/spring/AgentProperties.java | 12 +- .../ai/spring/AgentAutoConfigurationTest.java | 12 +- .../conductor/ai/AgentConfig.java | 34 +- .../conductor/ai/AgentRuntime.java | 168 ++++---- .../conductor/ai/RunSettings.java | 35 +- .../conductor/ai/enums/Framework.java | 2 +- .../CredentialNotFoundException.java | 4 +- .../ai/exceptions/WorkerStallError.java | 4 +- .../ai/execution/DockerCodeExecutor.java | 2 +- .../ai/execution/LocalCodeExecutor.java | 2 +- .../conductor/ai/frameworks/AdkBridge.java | 30 +- .../ai/frameworks/LangChain4jAgent.java | 8 +- .../ai/frameworks/LangChainBridge.java | 10 +- .../conductor/ai/frameworks/OpenAIAgent.java | 6 +- .../conductor/ai/guardrail/Guardrail.java | 6 - .../ai/internal/AgentConfigSerializer.java | 2 +- .../ai/internal/GuardrailHandlerFactory.java | 70 ++++ .../ai/internal/ServerLivenessMonitor.java | 2 +- .../conductor/ai/internal/WorkerManager.java | 20 +- .../conductor/ai/model/AgentEvent.java | 2 +- .../conductor/ai/model/AgentHandle.java | 6 +- .../conductor/ai/model/GuardrailDef.java | 8 +- .../conductor/ai/model/ToolDef.java | 27 ++ .../ai/openai/GPTAssistantAgent.java | 2 +- .../conductoross/conductor/ai/plans/Plan.java | 2 +- .../conductor/ai/schedule/Schedule.java | 162 -------- .../ai/schedule/ScheduleException.java | 52 --- .../conductor/ai/schedule/ScheduleInfo.java | 146 ------- .../conductor/ai/schedule/Schedules.java | 384 ------------------ .../conductor/ai/skill/Skill.java | 2 +- .../conductor/ai/AgentConfigTest.java | 32 +- .../ai/AgentRuntimeRunSettingsWireTest.java | 41 +- .../ai/AgentRuntimeSchedulerClientTest.java | 66 +++ .../conductor/ai/RunSettingsTest.java | 8 + .../ai/ToolGuardrailRegistrationTest.java | 80 ++++ .../ai/exceptions/ExceptionsTest.java | 6 +- .../ai/execution/CliCommandExecutorTest.java | 2 +- .../ai/frameworks/AdkBridgeTest.java | 10 +- .../ai/guardrail/GuardrailDefaultsTest.java | 6 + .../internal/GuardrailHandlerFactoryTest.java | 93 +++++ .../WorkerManagerCredentialsTest.java | 2 +- .../ai/schedule/ScheduleIntegrationTest.java | 267 ------------ .../ai/schedule/ScheduleRunNowTest.java | 190 --------- .../conductor/ai/schedule/ScheduleTest.java | 231 ----------- .../conductor/ai/tools/ToolsTest.java | 51 +++ .../client/automator/TaskRunner.java | 2 +- .../client/http/ConductorClient.java | 5 +- .../conductor/client/worker/Worker.java | 2 +- .../conductor/common/metadata/tasks/Task.java | 4 +- .../common/metadata/tasks/TaskDef.java | 6 +- .../io/orkes/conductor/client/ApiClient.java | 14 +- .../conductor/client/SchedulerClient.java | 13 + .../client/exceptions/AgentAPIException.java | 4 +- ...spanException.java => AgentException.java} | 12 +- .../exceptions/SSEUnavailableException.java | 2 +- .../client/http/OrkesSchedulerClient.java | 7 +- .../client/http/SchedulerResource.java | 42 +- .../client/model/SaveScheduleRequest.java | 7 +- .../client/model/WorkflowSchedule.java | 9 +- .../client/ApiClientEnvResolutionTest.java | 37 +- .../exceptions/AgentExceptionsTest.java | 6 +- .../client/http/SchedulerResourceTest.java | 104 +++++ .../model/TestSerDerWorkflowSchedule.java | 3 +- .../model/agent/AgentModelJsonTest.java | 3 + .../test/resources/ser_deser_json_string.json | 3 +- docs/agents/README.md | 11 +- docs/agents/agent-client-api.md | 85 +++- docs/agents/agent-runtime-api.md | 63 +-- docs/agents/agent-schema.json | 2 +- docs/agents/agent-schema.md | 4 +- docs/agents/api-reference.md | 86 ++-- docs/agents/concepts/agents.md | 4 +- docs/agents/concepts/deploy-serve-run.md | 20 +- docs/agents/concepts/guardrails.md | 25 ++ docs/agents/concepts/multi-agent.md | 2 +- docs/agents/concepts/scheduling.md | 78 ++-- docs/agents/concepts/stateful.md | 2 +- docs/agents/concepts/termination.md | 17 + docs/agents/concepts/tools.md | 4 +- docs/agents/frameworks/google-adk.md | 12 +- docs/agents/frameworks/langchain4j.md | 4 +- docs/agents/frameworks/langgraph4j.md | 8 +- docs/agents/frameworks/openai.md | 2 +- docs/agents/generated/generate.py | 7 +- docs/agents/getting-started.md | 6 +- docs/agents/index.md | 13 +- docs/agents/spring-boot.md | 14 +- docs/design/secret-injection-contract.md | 17 +- e2e/build.gradle | 4 +- e2e/src/test/java/BaseTest.java | 6 +- e2e/src/test/java/Suite11LangChain4j.java | 2 +- .../java/Suite2ToolCallingCredentials.java | 14 +- e2e/src/test/java/Suite6PdfTools.java | 2 +- e2e/src/test/java/SuiteHttpApi404.java | 4 +- .../ClaudeBuildMessagesWorkerTest.java | 8 +- gradle.properties | 2 +- 175 files changed, 1632 insertions(+), 2306 deletions(-) delete mode 100644 CHANGELOG.md create mode 100644 agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example71AgentControlPlane.java rename agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/{Example38AgentspanGuardrails.java => Example38ConductorGuardrails.java} (91%) rename agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/{Example26AgentspanGuardrails.java => Example26ConductorGuardrails.java} (89%) create mode 100644 conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/GuardrailHandlerFactory.java delete mode 100644 conductor-client-ai/src/main/java/org/conductoross/conductor/ai/schedule/Schedule.java delete mode 100644 conductor-client-ai/src/main/java/org/conductoross/conductor/ai/schedule/ScheduleException.java delete mode 100644 conductor-client-ai/src/main/java/org/conductoross/conductor/ai/schedule/ScheduleInfo.java delete mode 100644 conductor-client-ai/src/main/java/org/conductoross/conductor/ai/schedule/Schedules.java create mode 100644 conductor-client-ai/src/test/java/org/conductoross/conductor/ai/AgentRuntimeSchedulerClientTest.java create mode 100644 conductor-client-ai/src/test/java/org/conductoross/conductor/ai/ToolGuardrailRegistrationTest.java create mode 100644 conductor-client-ai/src/test/java/org/conductoross/conductor/ai/internal/GuardrailHandlerFactoryTest.java delete mode 100644 conductor-client-ai/src/test/java/org/conductoross/conductor/ai/schedule/ScheduleIntegrationTest.java delete mode 100644 conductor-client-ai/src/test/java/org/conductoross/conductor/ai/schedule/ScheduleRunNowTest.java delete mode 100644 conductor-client-ai/src/test/java/org/conductoross/conductor/ai/schedule/ScheduleTest.java rename conductor-client/src/main/java/io/orkes/conductor/client/exceptions/{AgentspanException.java => AgentException.java} (72%) create mode 100644 conductor-client/src/test/java/io/orkes/conductor/client/http/SchedulerResourceTest.java diff --git a/.github/workflows/agent-e2e.yml b/.github/workflows/agent-e2e.yml index 587fadee4..532001e84 100644 --- a/.github/workflows/agent-e2e.yml +++ b/.github/workflows/agent-e2e.yml @@ -2,7 +2,7 @@ name: Agent E2E # Runs the agent e2e suites (e2e/) against the Conductor OSS # server boot JAR (Maven Central) — the server this SDK ships against, with -# the agent runtime on by default from 3.32.0-rc.8 onward. The Agentspan +# the agent runtime on by default from 3.32.0-rc.8 onward. A separate agent # server JAR is no longer used here. # # These tests call real LLMs via the OPENAI_API_KEY / ANTHROPIC_API_KEY @@ -30,7 +30,7 @@ jobs: env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - AGENTSPAN_SERVER_URL: http://localhost:8080/api + CONDUCTOR_SERVER_URL: http://localhost:8080/api steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 199951085..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,80 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -## [Unreleased] - -### Added - -- Merged the Agentspan agent SDK into this repository as four new modules: `conductor-client-ai` (durable AI agents — `Agent`, `AgentRuntime`, `@Tool` functions, guardrails, handoffs, multi-agent strategies), `conductor-client-ai-spring` (Spring Boot auto-configuration), `agent-examples` (150+ runnable examples), and `e2e` (e2e suites, gated behind `-Pe2e`) — docs under [docs/agents/](docs/agents/index.md) -- `conductor-client-ai` and `conductor-client-ai-spring` publish as `org.conductoross:conductor-client-ai` and `org.conductoross:conductor-client-ai-spring`, superseding `org.conductoross.conductor:conductor-agent-sdk[-spring]@0.1.0`; the java package `org.conductoross.conductor.ai[.spring]` is unchanged apart from the client relocation below, so migrating is a dependency-coordinate swap plus — only if those types are imported directly — the relocated imports -- Agent control-plane clients now live in `conductor-client`: `AgentClient` is an interface (implemented by `io.orkes.conductor.client.http.OrkesAgentClient`) covering the full `/agent/*` control plane — `compileAgent`, `deployAgent`, `startAgent`, `getAgentStatus`, `getExecution`, `listExecutions`, `respond`, `stopAgent`, `signalAgent`, `streamSse`, `close` — handed out by `OrkesClients.getAgentClient()`; the SSE streaming client `SseClient` moved to `io.orkes.conductor.client`; their transport DTOs (`AgentRequest`, `StartResponse`, `AgentStatusResponse`, `PendingTool`, `RespondBody`, `CompileResponse`) moved to `io.orkes.conductor.client.model.agent` and the typed exceptions (`AgentspanException`, `AgentAPIException`, `AgentNotFoundException`, `SSEUnavailableException`) to `io.orkes.conductor.client.exceptions` -- SSE streaming hardened: the initial connect throws `SSEUnavailableException` when the server rejects streaming (never a silently-empty stream — `stream()` degrades to status polling), `id:` frames are tracked, and mid-stream drops reconnect with a `Last-Event-ID` header; `AgentRuntime.getClient()` exposes the runtime's own `AgentClient` instance -- Verb contract: `serve` = deploy + serve (each served agent is compiled + registered first, idempotently) with a non-blocking variant `serve(false, agents...)` that returns once workers are polling; `AgentHandle` gains the lifecycle verbs `send(message)` (now actually delivers `{"message": ...}` — it previously discarded the message), `stop()` (graceful, deterministic), `pause()`/`resume()` (un-pause; distinct from `AgentRuntime.resume(executionId, agent)` which re-attaches workers), `cancel(reason)`, and `getStatus()` -- `RunSettings` — per-run LLM overrides (`model`, `temperature`, `maxTokens`, `reasoningEffort`, `thinkingBudgetTokens`) on `run`/`start`/`stream` and their async variants; only non-null fields override the agent (zero values apply), mutating the serialized root config so overrides flow into the LLM tasks -- Connection environment: standard `CONDUCTOR_SERVER_URL`/`CONDUCTOR_AUTH_KEY`/`CONDUCTOR_AUTH_SECRET` now win over the legacy `AGENTSPAN_*` names (still honored as fallbacks); the default server URL is `http://localhost:8080` (was `6767`); blank variables no longer clobber the chain. The resolution lives in the client layer — `ApiClient.builder().useEnvVariables(true)` / `new ApiClient()` — not on `AgentRuntime` (matching the Python SDK, where `AgentRuntime()` defaults to `Configuration()`); `AgentRuntime` exposes no client factories -- `AgentConfig` knobs with lenient env parsing (invalid/empty → default): `autoStartWorkers`, `daemonWorkers`, `streamingEnabled`, `livenessEnabled`, `livenessStallSeconds`, `livenessCheckIntervalSeconds` -- Worker credentials ride the `runtimeMetadata` wire contract: declared secret names are stamped on `TaskDef.runtimeMetadata` at (every) registration and a capable server (agentspan > 0.4.2 / conductor-oss ≥ 3.32.0-rc, conductor-oss PR #1255) delivers values on the wire-only `Task.runtimeMetadata` at poll time; dispatch is fail-closed (missing delivery → terminal failure; ambient process env is never read) — the `/workers/secrets` fetch path and its transport exceptions are deleted; see [docs/design/secret-injection-contract.md](docs/design/secret-injection-contract.md) -- Liveness for stateful runs: a `SCHEDULED` task with zero polls beyond `livenessStallSeconds` surfaces as `WorkerStallError` from the handle's wait instead of burning the full timeout -- Swarm hand-offs: transfer tools echo the hand-off `message`; `check_transfer` is first-wins with `transfer_message` and surfaces non-winning transfers in `dropped_transfers` (with a warning) instead of silently discarding them -- `agent-e2e` GitHub workflow runs the e2e suites as a two-server matrix: the Conductor OSS server boot JAR `3.32.0-rc.8` (Maven Central) and the released `agentspan-server-0.4.4.jar` - -### Removed - -- The source-less publishing shim modules `java-sdk` (`org.conductoross:java-sdk`), `orkes-client` (`io.orkes.conductor:orkes-conductor-client`), and `orkes-spring` (`io.orkes.conductor:orkes-conductor-client-spring`) are deleted — depend on `org.conductoross:conductor-client` / `conductor-client-spring` directly instead -- File lifecycle objects and automatic worker integration (`FileHandler`, `ManagedFileHandler`, `LocalFileHandler`, `FileUploader`, `WorkflowFileClient`, `Task.getFileUploader()`, `Task.getInputFileHandler()`, and `TaskRunnerConfigurer.withFileClient()`) are removed. Workers now inject `FileClient` and explicitly upload/download opaque handle strings. - -### Changed - -- `useEnvVariables(true)` (and the `new ApiClient()` no-arg constructor) no longer throws when `CONDUCTOR_SERVER_URL` is unset — it falls back to `AGENTSPAN_SERVER_URL`, then `http://localhost:8080/api`; the resolved URL is normalized to end in `/api`, and credentials gain the `AGENTSPAN_AUTH_KEY`/`AGENTSPAN_AUTH_SECRET` fallback -- `FileClient` now requires workflow context for every operation, returns raw `conductor://file/` strings from uploads, selects multipart automatically, and performs atomic explicit downloads. Existing workflows that exchange `{fileHandleId, fileName, contentType}` objects must be drained or upgraded in coordination because new workers exchange raw strings. See the [FileClient guide](docs/file-client.md) and [design note](docs/design/file-client.md). - -## [5.1.0] - -### Added - -- Standardized Prometheus metrics: `PrometheusMetricsCollector` now emits the harmonized cross-SDK metric surface — [details](conductor-client-metrics/README.md) -- Automatic metrics wiring: `ConductorClient.Builder.withMetricsCollector(...)` installs the HTTP interceptor and auto-registers listeners on `TaskClient`, `WorkflowClient`, and `TaskRunnerConfigurer` -- HTTP API client metrics via OkHttp interceptor (`http_api_client_request_seconds`, `task_result_size_bytes`, `workflow_input_size_bytes`) -- Event-driven metrics architecture with `EventDispatcher` and typed event POJOs -- File storage support: `FileClient` for uploading and downloading files via S3, Azure Blob, GCS, or local storage backends, with single-part and multipart upload support -- `FileHandler` abstraction for passing files into and out of workers — the SDK auto-resolves `conductor://file/` references in task input and uploads `FileHandler` values in task output -- `@InputParam`-annotated worker parameters of type `FileHandler` are automatically deserialized from file references -- Spring auto-configuration for `FileClient` and `FileClientProperties` (`conductor.file-client.*` properties) -- Automatic token refresh via `TokenRefreshInterceptor`: transparently retries requests that fail with `EXPIRED_TOKEN` or `INVALID_TOKEN` (401/403), minting a fresh token and replaying the request once -- `FatalAuthenticationException` and JVM termination when token refresh is permanently exhausted (5 consecutive failures), preventing workers from silently spinning on bad credentials - -### Changed - -- `PrometheusMetricsCollector` metric names updated to the harmonized cross-SDK catalog (e.g. `task_poll_total`, `task_execute_time_seconds`) -- `micrometer-registry-prometheus` is now a transitive (`api`) dependency -- Token refresh reworked to a reactive interceptor model replacing the previous scheduled refresh mechanism; includes exponential backoff and thundering-herd prevention - -### Removed - -- Removed non-functioning scheduled token refresh mechanism (replaced by automatic reactive refresh) - -### Deprecated - -- `TaskClient.ack(String, String)` — use `ack(String taskType, String taskId, String workerId)` - -## [4.0.0] - 2024-10-09 -- New major release – [Read more](https://orkes.io/blog/conductor-java-client-v4/) - -## [4.0.1] - 2024-10-30 -- Improve Spring modules with auto-configuration - https://github.com/conductor-oss/conductor/pull/287 -- Added Jackson Kotlin module to client ObjectMapper - https://github.com/conductor-oss/conductor/pull/294 -- Fix chronounit issue in java sdk - https://github.com/conductor-oss/conductor/pull/298 - -## [4.0.2] - 2024-12-09 -- Added ZoneId to `SaveScheduleRequest` - https://github.com/conductor-oss/conductor/pull/302 -- Add callTimeout field to ConductorClient builder - https://github.com/conductor-oss/conductor/pull/317 -- Task poll update v2 - https://github.com/conductor-oss/conductor/pull/328 - -## [4.0.3] - 2024-12-17 -- Add testWorkflow to OrkesWorkflowClient - https://github.com/conductor-oss/conductor/pull/333 - -## [4.0.4] - 2025-01-07 -- Added lease extension to java sdk and fixed tests - https://github.com/conductor-oss/conductor/pull/349 -- Unify environment variable usage - https://github.com/conductor-oss/conductor/pull/353 -- Add support for configurable MetricsCollector in Spring client - https://github.com/conductor-oss/conductor/pull/356 diff --git a/README.md b/README.md index f5790e4c3..92ba2dd2d 100644 --- a/README.md +++ b/README.md @@ -32,31 +32,28 @@ If you find [Conductor](https://github.com/conductor-oss/conductor) useful, plea +## Install Conductor CLI +```shell +npm install -g @conductor-oss/conductor-cli +``` +> [!TIP] +> Alternatively, you can use a hosted Conductor at https://developer.orkescloud.com/ + ## Start Conductor server If you don't already have a Conductor server running, pick one: - -**Docker (recommended, includes UI):** - -```shell -docker run -p 8080:8080 conductoross/conductor:latest -``` -The UI will be available at `http://localhost:8080` and the API at `http://localhost:8080/api` - -**MacOS / Linux (one-liner):** (If you don't want to use docker, you can install and run the binary directly) ```shell -curl -sSL https://raw.githubusercontent.com/conductor-oss/conductor/main/conductor_server.sh | sh -``` +# Use CLI to start the server -- recommended +# use --help for all the options including port override +conductor server start -**Conductor CLI** -```shell -# Installs conductor cli -npm install -g @conductor-oss/conductor-cli +# Alternatively, if you prefer to use docker use: +# docker run -p 8080:8080 conductoross/conductor:latest -# Start the open source conductor server -conductor server start -# see conductor server --help for all the available commands +# And if you really want, you can install and download binaries to run locally +# curl -sSL https://raw.githubusercontent.com/conductor-oss/conductor/main/conductor_server.sh | sh ``` +Once the server starts, navigate to http://localhost:8080/ for the UI ## Install the SDK @@ -66,10 +63,10 @@ The SDK requires Java 17+. Add the following dependency to your project: ```gradle dependencies { - implementation 'org.conductoross:conductor-client:5.0.0' + implementation 'org.conductoross:conductor-client:' // Optionally, you can also add spring module for auto configuration - // implementation 'org.conductoross:conductor-client-spring:5.0.0' + // implementation 'org.conductoross:conductor-client-spring:' } ``` @@ -79,7 +76,7 @@ dependencies { org.conductoross conductor-client - 5.0.0 + VERSION ``` *Optionally, you can also add spring module for auto configuration* @@ -87,10 +84,10 @@ dependencies { org.conductoross conductor-client-spring - 5.0.0 + VERSION ``` - +**For the latest release version, see https://github.com/conductor-oss/java-sdk/releases** ## 60-Second Quickstart @@ -445,7 +442,7 @@ Conductor supports AI-native workflows including agentic tool calling, RAG pipel **Durable AI Agents** -The `conductor-client-ai` module is a full agent SDK on top of Conductor: `Agent`, `AgentRuntime`, `@Tool` functions, guardrails, handoffs, and multi-agent strategies, with Spring Boot auto-configuration in `conductor-client-ai-spring` and 150+ runnable examples in `agent-examples`. Start with the [agent docs](docs/agents/index.md). +The `conductor-client-ai` module is a full agent SDK on top of Conductor: `Agent`, `AgentRuntime`, `@Tool` functions, guardrails, handoffs, and multi-agent strategies, with Spring Boot auto-configuration in `conductor-client-ai-spring` and 150+ runnable examples in `agent-examples`. Start with the [agent docs](docs/agents/index.md), or use the low-level [AgentClient control-plane API](docs/agents/agent-client-api.md) to start deployed agents and stop or cancel executions. **Agentic Workflows** diff --git a/agent-examples/VERIFICATION.md b/agent-examples/VERIFICATION.md index 792597bd3..de2347e94 100644 --- a/agent-examples/VERIFICATION.md +++ b/agent-examples/VERIFICATION.md @@ -1,13 +1,13 @@ # Java Examples — Verification Report End-to-end verification of every example in this module, run against a -live Agentspan server and inspected at the Conductor workflow level to +live Conductor server and inspected at the Conductor workflow level to confirm LLM calls, tool calls, sub-agent orchestration, and guardrails all execute **server-side**. - **Last full run:** 2026-05-21 against a local server at `localhost:6767` - **Model:** `anthropic/claude-sonnet-4-6` for every example (configurable via - `AGENTSPAN_LLM_MODEL`) + `CONDUCTOR_AGENT_LLM_MODEL`) - **Examples covered:** 88 (39 ADK + 28 LangChain + 11 LangGraph + 10 OpenAI) - **Workflow-level pass rate:** 88 / 88 COMPLETED - **Sub-task error rate:** 1 example with errored sub-tasks @@ -16,7 +16,7 @@ all execute **server-side**. > **Note on OpenAI Agents:** Unlike ADK / LangChain4j / LangGraph4j, there > is **no native OpenAI Agents Java SDK** at the time of this writing — > only the raw `com.openai:openai-java` HTTP client, which has zero agent -> abstractions. The OpenAI examples therefore use Agentspan' own +> abstractions. The OpenAI examples therefore use Conductor' own > `OpenAIAgent.builder()` (in `org.conductoross.conductor.ai.frameworks`) — that builder > IS the Java equivalent of the Python `openai-agents` library, not a > bridge over something native. The same bug-bounty fixes applied to @@ -49,13 +49,13 @@ each verified row landed with the expected shape. ## How to reproduce ```bash -# 1. Start the Agentspan server (separate terminal) +# 1. Start the Conductor server (separate terminal) cd server && ./gradlew bootJar -java -jar build/libs/agentspan-runtime.jar +java -jar build/libs/conductor-runtime.jar # 2. Run a single example cd sdk/java -AGENTSPAN_LLM_MODEL=openai/gpt-4o-mini \ +CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini \ ./gradlew :examples:run -PmainClass=org.conductoross.conductor.ai.examples.adk.Example02FunctionTools # 3. Inspect the workflow @@ -146,7 +146,7 @@ not bridge-side. Tracked separately. | 36 | adk.Example35RagAgent | `b6fae52b-dec8-4996-bc80-9b526ec0892a` | rag_assistant | COMPLETED | 22 | 0 | | 37 | adk.Example36BuiltInTools | `8208d703-5ad2-4508-ae12-1f7faffec862` | research_assistant | COMPLETED | 60 | **12** ⚠️ | | 38 | adk.Example37DeployAndServe | `554e7495-8042-437f-9132-36d2c081f809` | deploy_demo_agent | COMPLETED | 15 | 0 | -| 39 | adk.Example38AgentspanGuardrails | `8d6b96ea-00bb-4bf1-8b99-d3d05aa3b432` | contact_directory | COMPLETED | 8 | 0 | +| 39 | adk.Example38ConductorGuardrails | `8d6b96ea-00bb-4bf1-8b99-d3d05aa3b432` | contact_directory | COMPLETED | 8 | 0 | | 40 | langchain.Example01HelloWorld | `e3bc9290-4c66-4418-ab53-0b9d930e03a4` | langchain_agent | COMPLETED | 1 | 0 | | 41 | langchain.Example02ReactWithTools | `ffad44aa-b457-4194-9930-fe5343df4d00` | langchain_agent | COMPLETED | 17 | 0 | | 42 | langchain.Example03CustomTools | `3e65dca9-8b8a-4b24-9298-c2869da45673` | langchain_agent | COMPLETED | 17 | 0 | @@ -172,7 +172,7 @@ not bridge-side. Tracked separately. | 62 | langchain.Example23RecommendationAgent | `f976873e-a9c5-4934-b4cb-ea1a140413a5` | langchain_agent | COMPLETED | 25 | 0 | | 63 | langchain.Example24OutputParsers | `4a2785b1-30d1-4642-a948-dfece8c99185` | langchain_agent | COMPLETED | 25 | 0 | | 64 | langchain.Example25AdvancedOrchestration | `6f783be7-2540-477a-ab1b-1c69c48d61c7` | langchain_agent | COMPLETED | **240** | 0 | -| 65 | langchain.Example26AgentspanGuardrails | `43a161fd-42f9-4ce1-9124-a614a720cc09` | contact_directory_lc | COMPLETED | 8 | 0 | +| 65 | langchain.Example26ConductorGuardrails | `43a161fd-42f9-4ce1-9124-a614a720cc09` | contact_directory_lc | COMPLETED | 8 | 0 | | 66 | langchain.ExampleCredentials | `e8faaffa-69f1-409c-9c25-796d444cc1c5` | lc4j_weather_agent | COMPLETED | 16 | 0 | | 67 | langchain.ExamplePipeline | `dca4108c-d748-4127-87e6-93829fe92e5d` | data_gatherer_report_writer | COMPLETED | 9 | 0 | | 68 | langgraph.Example01HelloWorld | `bb744b58-00dc-4f7c-a469-483e9f4dd345` | langgraph_agent | COMPLETED | 1 | 0 | diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example01BasicAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example01BasicAgent.java index cbee4cd49..845e8f59e 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example01BasicAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example01BasicAgent.java @@ -23,8 +23,8 @@ * *

Requirements: *

    - *
  • AGENTSPAN_SERVER_URL=http://localhost:6767/api
  • - *
  • AGENTSPAN_LLM_MODEL=openai/gpt-4o
  • + *
  • CONDUCTOR_SERVER_URL=http://localhost:6767/api
  • + *
  • CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o
  • *
*/ public class Example01BasicAgent { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example04HttpAndMcpTools.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example04HttpAndMcpTools.java index dab0ed401..a9ff0c28a 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example04HttpAndMcpTools.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example04HttpAndMcpTools.java @@ -45,7 +45,7 @@ *
    *
  • Conductor server with LLM support
  • *
  • MCP weather server on http://localhost:3001/mcp
  • - *
  • AGENTSPAN_SERVER_URL=http://localhost:6767
  • + *
  • CONDUCTOR_SERVER_URL=http://localhost:6767
  • *
*/ public class Example04HttpAndMcpTools { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example108PlanExecuteRefs.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example108PlanExecuteRefs.java index e5d87e3fb..cccdd8046 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example108PlanExecuteRefs.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example108PlanExecuteRefs.java @@ -21,7 +21,6 @@ import java.util.Map; import org.conductoross.conductor.ai.Agent; -import org.conductoross.conductor.ai.AgentConfig; import org.conductoross.conductor.ai.AgentRuntime; import org.conductoross.conductor.ai.enums.Strategy; import org.conductoross.conductor.ai.model.AgentResult; @@ -33,6 +32,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; + /** * 108 — Plan-Execute with cross-step output piping via {@link Ref}. * @@ -55,10 +55,7 @@ public class Example108PlanExecuteRefs { private static final String MODEL = - System.getenv().getOrDefault("AGENTSPAN_LLM_MODEL", "anthropic/claude-sonnet-4-6"); - private static final String BASE_URL = - System.getenv().getOrDefault("AGENTSPAN_SERVER_URL", "http://localhost:6767/api") - .replace("/api", ""); + System.getenv().getOrDefault("CONDUCTOR_AGENT_LLM_MODEL", "anthropic/claude-sonnet-4-6"); public static void main(String[] args) throws Exception { ToolDef produce = ToolDef.builder() @@ -165,11 +162,7 @@ public static void main(String[] args) throws Exception { .build()) .build(); - try (AgentRuntime runtime = new AgentRuntime( - io.orkes.conductor.client.ApiClient.builder() - .basePath(BASE_URL + "/api") - .build(), - new AgentConfig(100, 1))) { + try (AgentRuntime runtime = new AgentRuntime()) { AgentResult result = runtime.run(harness, "demo", plan); System.out.println("status=" + result.getStatus() + " executionId=" + result.getExecutionId()); @@ -205,7 +198,9 @@ private static void showPipelineOutputs(String executionId) throws Exception { } } } - + private static final String BASE_URL = + System.getenv().getOrDefault("CONDUCTOR_SERVER_URL", "http://localhost:8080/api") + .replace("/api", ""); @SuppressWarnings("unchecked") private static Map fetchWorkflow( HttpClient http, ObjectMapper mapper, String id) throws Exception { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example115PlannerContext.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example115PlannerContext.java index 3353aeaf4..2ba6837c7 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example115PlannerContext.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example115PlannerContext.java @@ -59,9 +59,9 @@ public class Example115PlannerContext { private static final String MODEL = - System.getenv().getOrDefault("AGENTSPAN_LLM_MODEL", "anthropic/claude-sonnet-4-6"); + System.getenv().getOrDefault("CONDUCTOR_AGENT_LLM_MODEL", "anthropic/claude-sonnet-4-6"); private static final String BASE_URL = - System.getenv().getOrDefault("AGENTSPAN_SERVER_URL", "http://localhost:6767/api") + System.getenv().getOrDefault("CONDUCTOR_SERVER_URL", "http://localhost:8080/api") .replace("/api", ""); public static void main(String[] args) throws Exception { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example16CredentialsTool.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example16CredentialsTool.java index febe23001..a598819db 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example16CredentialsTool.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example16CredentialsTool.java @@ -46,7 +46,7 @@ * *

Setup (one-time, via CLI): *

- *   agentspan secrets set GITHUB_TOKEN ghp_xxx
+ *   conductor secrets set GITHUB_TOKEN ghp_xxx
  * 
* *

If the credential isn't set on the server, this tool's task is reported @@ -78,7 +78,7 @@ public Map listGithubRepos(String username, int limit, ToolConte + "/repos?per_page=" + n + "&sort=updated")) .timeout(Duration.ofSeconds(10)) .header("Accept", "application/vnd.github+json") - .header("User-Agent", "agentspan-java-example/1.0"); + .header("User-Agent", "conductor-java-example/1.0"); if (token != null && !token.isEmpty()) { reqBuilder.header("Authorization", "Bearer " + token); } @@ -111,7 +111,7 @@ public Map getGithubUser(String username, ToolContext ctx) { .uri(URI.create("https://api.github.com/users/" + username)) .timeout(Duration.ofSeconds(10)) .header("Accept", "application/vnd.github+json") - .header("User-Agent", "agentspan-java-example/1.0"); + .header("User-Agent", "conductor-java-example/1.0"); if (token != null && !token.isEmpty()) { reqBuilder.header("Authorization", "Bearer " + token); } diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example31ToolInputGuardrail.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example31ToolInputGuardrail.java index 852c810a3..d57182ccc 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example31ToolInputGuardrail.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example31ToolInputGuardrail.java @@ -68,16 +68,8 @@ public static void main(String[] args) { }) .build(); - // Re-wrap the tool with the guardrail attached at tool level - ToolDef guardedTool = ToolDef.builder() - .name(rawTool.getName()) - .description(rawTool.getDescription()) - .inputSchema(rawTool.getInputSchema()) - .outputSchema(rawTool.getOutputSchema()) - .toolType(rawTool.getToolType()) - .func(rawTool.getFunc()) - .guardrails(List.of(sqlInjectionGuard)) - .build(); + // Preserve all fields discovered from @Tool while attaching the guardrail. + ToolDef guardedTool = rawTool.withGuardrails(List.of(sqlInjectionGuard)); Agent agent = Agent.builder() .name("db_assistant") diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example38TechTrends.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example38TechTrends.java index 2a4ea6ac7..2ce79bb83 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example38TechTrends.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example38TechTrends.java @@ -148,7 +148,7 @@ private static String get(String url) throws IOException, InterruptedException { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(10)) - .header("User-Agent", "agentspan-java-example/1.0") + .header("User-Agent", "conductor-java-example/1.0") .GET() .build(); return HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()).body(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example56RagAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example56RagAgent.java index 4d0d5a3dc..768a91e2b 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example56RagAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example56RagAgent.java @@ -39,7 +39,7 @@ public class Example56RagAgent { private static final String VECTOR_DB = "pgvectordb"; - private static final String INDEX = "agentspan_docs_56"; + private static final String INDEX = "conductor_docs_56"; private static final String EMBED_PROVIDER = "openai"; private static final String EMBED_MODEL = "text-embedding-3-small"; @@ -72,16 +72,16 @@ public static void main(String[] args) { System.out.println("=== Phase 1: Indexing documents ==="); AgentResult indexResult = runtime.run(indexerAgent, "Index the following documents:\n\n" + - "Title: Agentspan Overview\n" + - "Content: Agentspan is a multi-agent orchestration platform built on Conductor. " + + "Title: Conductor Overview\n" + + "Content: Conductor is a multi-agent orchestration platform built on Conductor. " + "It enables developers to build, deploy, and manage AI agent workflows at scale. " + "Agents can use tools, call sub-agents, and maintain shared state.\n\n" + "Title: Agent Strategies\n" + - "Content: Agentspan supports multiple agent strategies: ROUTER (LLM picks the next agent), " + + "Content: Conductor supports multiple agent strategies: ROUTER (LLM picks the next agent), " + "HANDOFF (transfer control to another agent), SWARM (agents collaborate simultaneously), " + "and LOOP (repeat until a condition is met). Each strategy suits different workflow patterns.\n\n" + "Title: Tool Types\n" + - "Content: Agentspan tools include local Python/Java workers, RAG search/index tools, " + + "Content: Conductor tools include local Python/Java workers, RAG search/index tools, " + "HTTP tools for calling REST APIs, and AgentTool for calling sub-agents. " + "Tools are registered with name, description, and JSON schema for the LLM.\n\n" + "Title: Guardrails\n" + @@ -105,7 +105,7 @@ public static void main(String[] args) { System.out.println("\n=== Phase 2: Answering questions from indexed docs ==="); AgentResult qaResult = runtime.run(qaAgent, "Answer these questions using the knowledge base:\n" + - "1. What is Agentspan and what platform is it built on?\n" + + "1. What is Conductor and what platform is it built on?\n" + "2. What agent strategies are available and when would you use HANDOFF?\n" + "3. What happens when a guardrail fails?"); qaResult.printResult(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example57PlanDryRun.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example57PlanDryRun.java index 08e79bc06..2af4b573f 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example57PlanDryRun.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example57PlanDryRun.java @@ -32,7 +32,7 @@ *

    *
  • Debugging agent setup without consuming LLM credits
  • *
  • Validating tool schemas and guardrail definitions
  • - *
  • Understanding exactly what gets sent to the Agentspan server
  • + *
  • Understanding exactly what gets sent to the Conductor server
  • *
* *

The {@link AgentConfigSerializer} converts the in-memory {@link Agent} into diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example58ScatterGather.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example58ScatterGather.java index 4eefbc667..2c9a616b2 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example58ScatterGather.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example58ScatterGather.java @@ -63,7 +63,7 @@ public static void main(String[] args) { Agent researcher = Agent.builder() .name("researcher") - .model("anthropic/claude-sonnet-4-20250514") + .model("anthropic/claude-sonnet-4-6") .instructions( "You are a country analyst. You will be given the name of a country. " + "Use the search_knowledge_base tool ONCE to research that country, then " diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example68ContextCondensation.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example68ContextCondensation.java index fabd87de1..29c2e4a6b 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example68ContextCondensation.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example68ContextCondensation.java @@ -42,7 +42,7 @@ * *

To trigger condensation, add to server's {@code application.properties}: *

- *   agentspan.default-context-window=10000
+ *   conductor.default-context-window=10000
  * 
*/ public class Example68ContextCondensation { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example69Skills.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example69Skills.java index 821f9d33d..ec55b7d2f 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example69Skills.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example69Skills.java @@ -26,14 +26,14 @@ /** * Example 69 — Skills * - *

Loads an agentskills.io skill directory as an Agentspan Agent. Skill scripts + *

Loads an agentskills.io skill directory as an Conductor Agent. Skill scripts * become worker tools, and resource files are available through the generated * read_skill_file tool. * *

Usage: *

- *   AGENTSPAN_SERVER_URL=http://localhost:6767/api \
- *   AGENTSPAN_LLM_MODEL=openai/gpt-4o-mini \
+ *   CONDUCTOR_SERVER_URL=http://localhost:6767/api \
+ *   CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini \
  *   ./gradlew :examples:run -PmainClass=org.conductoross.conductor.ai.examples.Example69Skills \
  *     --args="/path/to/skill 'Review this repository'"
  * 
diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example70AnnotatedAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example70AnnotatedAgent.java index 0761b9ecd..ca639bb52 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example70AnnotatedAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example70AnnotatedAgent.java @@ -28,8 +28,8 @@ * *

Requirements: *

    - *
  • AGENTSPAN_SERVER_URL=http://localhost:6767/api
  • - *
  • AGENTSPAN_LLM_MODEL=openai/gpt-4o
  • + *
  • CONDUCTOR_SERVER_URL=http://localhost:6767/api
  • + *
  • CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o
  • *
*/ public class Example70AnnotatedAgent { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example71AgentControlPlane.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example71AgentControlPlane.java new file mode 100644 index 000000000..b541e8ec4 --- /dev/null +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example71AgentControlPlane.java @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Conductor Authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package org.conductoross.conductor.ai.examples; + +import io.orkes.conductor.client.AgentClient; +import io.orkes.conductor.client.ApiClient; +import io.orkes.conductor.client.OrkesClients; +import io.orkes.conductor.client.model.agent.AgentRequest; +import io.orkes.conductor.client.model.agent.AgentStatusResponse; +import io.orkes.conductor.client.model.agent.StartResponse; + +/** + * Example 71 — start and control an already-deployed agent through {@link AgentClient}. + * + *

Deploy the agent definition separately, then run: + * + *

{@code
+ * ./gradlew :agent-examples:run \
+ *   -PmainClass=org.conductoross.conductor.ai.examples.Example71AgentControlPlane \
+ *   --args="researcher 3 'Summarize the latest release' status"
+ * }
+ * + *

The final argument is {@code status}, {@code stop}, or {@code cancel}. Use {@code -} for the + * version to select the server's deployed default. + */ +public final class Example71AgentControlPlane { + + private Example71AgentControlPlane() {} + + public static void main(String[] args) { + if (args.length == 0) { + throw new IllegalArgumentException( + "Usage: [version|-] [prompt] [status|stop|cancel]"); + } + + String name = args[0]; + Integer version = args.length > 1 && !"-".equals(args[1]) + ? Integer.valueOf(args[1]) + : null; + String prompt = args.length > 2 ? args[2] : "Summarize the latest release."; + String action = args.length > 3 ? args[3] : "status"; + + ApiClient transport = createTransport(); + try (AgentClient agents = new OrkesClients(transport).getAgentClient()) { + AgentRequest request = AgentRequest.deployedAgent(name, version) + .prompt(prompt) + .build(); + StartResponse started = agents.startAgent(request); + String executionId = started.getExecutionId(); + System.out.println("Started " + name + " as " + executionId); + + switch (action) { + case "stop" -> { + agents.stopAgent(executionId); + System.out.println("Graceful stop requested after the current iteration."); + } + case "cancel" -> { + agents.cancelAgent(executionId, "Cancelled from Example71AgentControlPlane"); + System.out.println("Execution cancelled immediately."); + } + case "status" -> printStatus(agents.getAgentStatus(executionId)); + default -> throw new IllegalArgumentException( + "Action must be status, stop, or cancel: " + action); + } + } + } + + private static ApiClient createTransport() { + if (Settings.AUTH_KEY != null && Settings.AUTH_SECRET != null) { + return new ApiClient(Settings.SERVER_URL, Settings.AUTH_KEY, Settings.AUTH_SECRET); + } + return new ApiClient(Settings.SERVER_URL); + } + + private static void printStatus(AgentStatusResponse status) { + System.out.println("Status: " + status.getStatus()); + System.out.println("Started: " + status.getStartTime()); + System.out.println("Ended: " + status.getEndTime()); + } +} diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example99ScheduledAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example99ScheduledAgent.java index ec6fd2f49..d222cb4d9 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example99ScheduledAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example99ScheduledAgent.java @@ -17,27 +17,30 @@ import org.conductoross.conductor.ai.Agent; import org.conductoross.conductor.ai.AgentRuntime; -import org.conductoross.conductor.ai.schedule.Schedule; -import org.conductoross.conductor.ai.schedule.ScheduleInfo; + +import com.netflix.conductor.common.metadata.workflow.StartWorkflowRequest; + +import io.orkes.conductor.client.SchedulerClient; +import io.orkes.conductor.client.model.SaveScheduleRequest; +import io.orkes.conductor.client.model.WorkflowSchedule; /** * Example 99 — Scheduled Agent * - *

Deploys an agent on two named cron schedules and exercises the full - * lifecycle: list, pause, resume, run-now (ad-hoc), preview next fires, - * and purge on cleanup. + *

Deploys an agent, creates two native Conductor schedules, and exercises + * the scheduler lifecycle: list, pause, resume, preview, and cleanup. * *

Usage: *

- *   AGENTSPAN_SERVER_URL=http://localhost:6767/api \
- *   AGENTSPAN_LLM_MODEL=openai/gpt-4o-mini \
+ *   CONDUCTOR_SERVER_URL=http://localhost:6767/api \
+ *   CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini \
  *   ./gradlew :examples:run -PmainClass=org.conductoross.conductor.ai.examples.Example99ScheduledAgent
  * 
*/ public class Example99ScheduledAgent { public static void main(String[] args) throws Exception { - String model = System.getenv().getOrDefault("AGENTSPAN_LLM_MODEL", "anthropic/claude-sonnet-4-6"); + String model = System.getenv().getOrDefault("CONDUCTOR_AGENT_LLM_MODEL", "anthropic/claude-sonnet-4-6"); Agent agent = Agent.builder() .name("eng_digest_99") @@ -50,33 +53,33 @@ public static void main(String[] args) throws Exception { try (AgentRuntime runtime = new AgentRuntime()) { - // 1. Deploy with two schedules. - runtime.deploy(agent, List.of( - Schedule.builder() - .name("weekday-9am") - .cron("0 0 9 * * MON-FRI") - .timezone("America/Los_Angeles") - .input(Map.of("channel", "#eng")) - .description("Weekday morning digest") - .build(), - Schedule.builder() - .name("friday-5pm") - .cron("0 0 17 * * FRI") - .timezone("America/Los_Angeles") - .input(Map.of("channel", "#all-hands", "mode", "weekly")) - .description("Weekly all-hands digest") - .build() - )); + // 1. Deploy the agent, then use the typed SchedulerClient directly. + runtime.deploy(agent); + SchedulerClient schedules = runtime.getSchedulerClient(); + String weekdayName = agent.getName() + "_weekday"; + String fridayName = agent.getName() + "_friday"; + saveSchedule( + schedules, + weekdayName, + agent.getName(), + "0 0 9 * * MON-FRI", + Map.of("channel", "#eng"), + "Weekday morning digest"); + saveSchedule( + schedules, + fridayName, + agent.getName(), + "0 0 17 * * FRI", + Map.of("channel", "#all-hands", "mode", "weekly"), + "Weekly all-hands digest"); System.out.printf("✓ Deployed '%s' with 2 schedules%n", agent.getName()); - var sched = runtime.schedules(); - // 2. List schedules for this agent. - List infos = sched.list(agent.getName()); + List infos = schedules.getAllSchedules(agent.getName()); System.out.printf("%nSchedules (%d):%n", infos.size()); - for (ScheduleInfo s : infos) { + for (WorkflowSchedule s : infos) { System.out.printf(" %s %s [%s]%n", - s.getName(), s.getCron(), s.isPaused() ? "PAUSED" : "active"); + s.getName(), s.getCronExpression(), s.isPaused() ? "PAUSED" : "active"); } if (infos.size() < 2) { @@ -84,39 +87,46 @@ public static void main(String[] args) throws Exception { return; } - String weekdayName = infos.stream() - .filter(s -> "weekday-9am".equals(s.getShortName())) - .findFirst().orElseThrow().getName(); - String fridayName = infos.stream() - .filter(s -> "friday-5pm".equals(s.getShortName())) - .findFirst().orElseThrow().getName(); - // 3. Pause the weekday schedule. - sched.pause(weekdayName, "rate-limit cooldown demo"); - ScheduleInfo afterPause = sched.get(weekdayName); + schedules.pauseSchedule(weekdayName, "rate-limit cooldown demo"); + WorkflowSchedule afterPause = schedules.getSchedule(weekdayName); System.out.printf("%n✓ Paused '%s': paused=%b, reason=%s%n", weekdayName, afterPause.isPaused(), afterPause.getPausedReason()); // 4. Resume it. - sched.resume(weekdayName); - ScheduleInfo afterResume = sched.get(weekdayName); + schedules.resumeSchedule(weekdayName); + WorkflowSchedule afterResume = schedules.getSchedule(weekdayName); System.out.printf("✓ Resumed '%s': paused=%b%n", weekdayName, afterResume.isPaused()); - // 5. Ad-hoc run of the friday schedule. - ScheduleInfo fridayInfo = sched.get(fridayName); - String execId = sched.runNow(fridayInfo); - System.out.printf("%n✓ runNow '%s' → execution id: %s%n", fridayName, execId); - - // 6. Preview next 5 fire times for the weekday cron. - List nextFires = sched.previewNext("0 0 9 * * MON-FRI", 5); - System.out.println("\nNext 5 fires for weekday-9am:"); + // 5. Preview next 5 fire times for the weekday cron. + List nextFires = schedules.getNextFewSchedules("0 0 9 * * MON-FRI", null, null, 5); + System.out.println("\nNext 5 fires for weekday:"); for (int i = 0; i < nextFires.size(); i++) { System.out.printf(" %d. %s%n", i + 1, new java.util.Date(nextFires.get(i))); } - // 7. Cleanup: redeploy with empty list to purge all schedules. - runtime.deploy(agent, List.of()); + // 6. Cleanup: remove each schedule explicitly. + schedules.deleteSchedule(weekdayName); + schedules.deleteSchedule(fridayName); System.out.printf("%n✓ Purged all schedules for '%s'%n", agent.getName()); } } + + private static void saveSchedule( + SchedulerClient schedules, + String scheduleName, + String workflowName, + String cron, + Map input, + String description) { + StartWorkflowRequest workflow = new StartWorkflowRequest(); + workflow.setName(workflowName); + workflow.setInput(input); + schedules.saveSchedule(new SaveScheduleRequest() + .name(scheduleName) + .cronExpression(cron) + .zoneId("America/Los_Angeles") + .startWorkflowRequest(workflow) + .description(description)); + } } diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Settings.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Settings.java index 061f31941..067515dbe 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Settings.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Settings.java @@ -17,29 +17,29 @@ * *

Set these before running examples: *

- * export AGENTSPAN_SERVER_URL=http://localhost:6767/api
- * export AGENTSPAN_LLM_MODEL=openai/gpt-4o
- * export AGENTSPAN_AUTH_KEY=your-key       # optional
- * export AGENTSPAN_AUTH_SECRET=your-secret # optional
+ * export CONDUCTOR_SERVER_URL=http://localhost:6767/api
+ * export CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o
+ * export CONDUCTOR_AUTH_KEY=your-key       # optional
+ * export CONDUCTOR_AUTH_SECRET=your-secret # optional
  * 
*/ public class Settings { private static final java.util.Map ENV = System.getenv(); public static final String SERVER_URL = - ENV.getOrDefault("AGENTSPAN_SERVER_URL", "http://localhost:6767/api"); + ENV.getOrDefault("CONDUCTOR_SERVER_URL", "http://localhost:6767/api"); public static final String LLM_MODEL = - ENV.getOrDefault("AGENTSPAN_LLM_MODEL", "openai/gpt-4o"); + ENV.getOrDefault("CONDUCTOR_AGENT_LLM_MODEL", "openai/gpt-4o"); public static final String SECONDARY_LLM_MODEL = - ENV.getOrDefault("AGENT_SECONDARY_LLM_MODEL", "anthropic/claude-sonnet-4-6"); + ENV.getOrDefault("AGENT_SECONDARY_LLM_MODEL", "openai/gpt-4o"); public static final String AUTH_KEY = - ENV.get("AGENTSPAN_AUTH_KEY"); + ENV.get("CONDUCTOR_AUTH_KEY"); public static final String AUTH_SECRET = - ENV.get("AGENTSPAN_AUTH_SECRET"); + ENV.get("CONDUCTOR_AUTH_SECRET"); private Settings() {} } diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example00HelloWorld.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example00HelloWorld.java index 3c913bfde..86b60bef2 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example00HelloWorld.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example00HelloWorld.java @@ -22,12 +22,12 @@ * Example Adk 00 — Hello World using the native Google ADK Java SDK. * *

Defines a real {@link LlmAgent} with {@code com.google.adk.agents.LlmAgent.builder()}, - * and hands it directly to {@link org.conductoross.conductor.ai.Agentspan#run(Object, String)} - * for execution on the durable Agentspan runtime. + * and hands it directly to {@link org.conductoross.conductor.ai.AgentRuntime#run(Object, String)} + * for execution on the durable Conductor runtime. * *

Requirements: *

    - *
  • {@code AGENTSPAN_SERVER_URL=http://localhost:6767/api}
  • + *
  • {@code CONDUCTOR_SERVER_URL=http://localhost:6767/api}
  • *
  • OpenAI/Gemini key configured in server credentials
  • *
*/ diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example01BasicAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example01BasicAgent.java index 94fc7076b..6c1c90f7c 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example01BasicAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example01BasicAgent.java @@ -24,8 +24,8 @@ *

Java port of sdk/python/examples/adk/01_basic_agent.py. * *

Demonstrates: the simplest Google ADK agent — defined via the - * native {@link LlmAgent} builder and bridged to the Agentspan durable - * runtime via {@link org.conductoross.conductor.ai.Agentspan#run(Object, String)}. + * native {@link LlmAgent} builder and bridged to the Conductor durable + * runtime via {@link org.conductoross.conductor.ai.AgentRuntime#run(Object, String)}. */ public class Example01BasicAgent { public static void main(String[] args) { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example02FunctionTools.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example02FunctionTools.java index 6b5615606..aee575e0e 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example02FunctionTools.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example02FunctionTools.java @@ -27,7 +27,7 @@ * *

Tools are static methods annotated with {@code @Schema} — the idiomatic * ADK pattern — and packaged via {@code FunctionTool.create(Class, "methodName")}. - * No Agentspan-specific annotations. + * No Conductor-specific annotations. */ public class Example02FunctionTools { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example19SupplyChain.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example19SupplyChain.java index b42da311d..ad64bb8a2 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example19SupplyChain.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example19SupplyChain.java @@ -34,6 +34,11 @@ */ public class Example19SupplyChain { + @Schema(description = "List all the warehouses") + public static List listWarehouses() { + return List.of("east","west"); + } + @Schema(description = "Get current inventory levels at a warehouse.") public static Map getInventoryLevels( @Schema(name = "warehouse", description = "Warehouse name") String warehouse) { @@ -123,7 +128,8 @@ public static void main(String[] args) { .instruction("Check inventory levels and supplier status. Flag items below reorder points.") .tools( FunctionTool.create(Example19SupplyChain.class, "getInventoryLevels"), - FunctionTool.create(Example19SupplyChain.class, "checkSupplierStatus")) + FunctionTool.create(Example19SupplyChain.class, "checkSupplierStatus"), + FunctionTool.create(Example19SupplyChain.class, "listWarehouses")) .build(); LlmAgent logisticsAgent = LlmAgent.builder() diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example23Callbacks.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example23Callbacks.java index a51985399..1eadf8572 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example23Callbacks.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example23Callbacks.java @@ -27,7 +27,7 @@ * *

Demonstrates: native ADK {@code beforeModelCallback} and * {@code afterModelCallback} attached to an {@link LlmAgent}. The bridge - * forwards both as Agentspan {@code CallbackHandler} workers. + * forwards both as Conductor {@code CallbackHandler} workers. * *

Server-side limitation (matches Python's * {@code 14_callbacks.py} comment): the server's workflow compiler does diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example34MlEngineering.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example34MlEngineering.java index fc58e134e..dfd02a912 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example34MlEngineering.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example34MlEngineering.java @@ -26,7 +26,7 @@ *

Demonstrates: a multi-agent ML workflow combining sequential, parallel, * and loop strategies. The Java port encodes the strategy semantics inline * (sub-agents with instructions describing parallel/loop intent) since the - * Agentspan {@link org.conductoross.conductor.ai.Agentspan#run(Object, String)} currently translates {@link LlmAgent}s with + * Conductor {@link org.conductoross.conductor.ai.AgentRuntime#run(Object, String)} currently translates {@link LlmAgent}s with * sub-agents but does not extract {@code ParallelAgent}/{@code LoopAgent} * primitives directly. */ diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example35RagAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example35RagAgent.java index 7f0549158..f609c45ad 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example35RagAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example35RagAgent.java @@ -31,7 +31,7 @@ *

Java port of sdk/python/examples/adk/35_rag_agent.py. * *

Demonstrates: a RAG agent flow with index + search tools. The Python - * source uses Agentspan's {@code search_tool} / {@code index_tool} factory + * source uses Conductor's {@code search_tool} / {@code index_tool} factory * helpers (mapped to Conductor's LLM_INDEX_TEXT / LLM_SEARCH_INDEX system * tasks). * diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example37DeployAndServe.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example37DeployAndServe.java index c8c6109c8..0b34f7a53 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example37DeployAndServe.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example37DeployAndServe.java @@ -28,17 +28,17 @@ /** * Example Adk 37 — deploy + serve + run round-trip * - *

Demonstrates the three Agentspan entry points working together with a + *

Demonstrates the three Conductor entry points working together with a * native ADK {@link LlmAgent} and zero bridge calls in user code: * *

    - *
  1. {@link Agentspan#deploy(Object...)} — register the agent on the + *
  2. {@link AgentRuntime#deploy(Object...)} — register the agent on the * server. CI/CD step; idempotent; no workers polled.
  3. - *
  4. {@link Agentspan#serve(Object...)} — register local worker handlers + *
  5. {@link AgentRuntime#serve(Object...)} — register local worker handlers * and keep them polling. Long-running; normally a separate process. * This example runs it on a daemon thread so a single JVM can play * both client and worker roles.
  6. - *
  7. {@link Agentspan#start(Object, String)} — trigger an execution + *
  8. {@link AgentRuntime#start(Object, String)} — trigger an execution * against the deployed agent and stream events back.
  9. *
* @@ -52,7 +52,7 @@ * $ java -cp ... WorkerProcess // runtime.serve(rootAgent) * * # any caller, e.g. an HTTP handler - * $ curl -X POST .../api/agent/start -d '{"agentName":"deploy_demo_agent",...}' + * $ curl -X POST .../api/agent/start -d '{"name":"deploy_demo_agent","prompt":"..."}' * } */ public class Example37DeployAndServe { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example38AgentspanGuardrails.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example38ConductorGuardrails.java similarity index 91% rename from agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example38AgentspanGuardrails.java rename to agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example38ConductorGuardrails.java index 1d5e14844..1ca1ba720 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example38AgentspanGuardrails.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example38ConductorGuardrails.java @@ -27,25 +27,25 @@ import com.google.adk.agents.LlmAgent; /** - * Example Adk 38 — Agentspan guardrails on a native ADK agent + * Example ADK 38 — Conductor guardrails on a native ADK agent * *

ADK itself doesn't ship a guardrail abstraction — its safety story is * {@code beforeModelCallback} returning a short-circuit response, which the - * server doesn't yet compile into hook tasks (see Example23). Agentspan + * server doesn't yet compile into hook tasks (see Example23). Conductor * provides a separate, server-compiled guardrail mechanism that runs as a * Conductor task inside the agent's loop. This example shows how to attach - * Agentspan guardrails to a pure ADK agent without giving up the drop-in + * Conductor guardrails to a pure ADK agent without giving up the drop-in * pattern. * *

Pattern: *

    *
  1. Build the native ADK {@link LlmAgent} exactly as you would for ADK.
  2. - *
  3. Hand it to {@link AdkBridge#agentBuilder} (NOT {@code toAgentspan} — + *
  4. Hand it to {@link AdkBridge#agentBuilder} (NOT {@code toConductor} — * the builder variant lets you decorate before building).
  5. *
  6. Attach a {@link GuardrailDef} with a validation function that * returns {@link GuardrailResult#pass()}, {@link GuardrailResult#fail * fail(message)}, or {@link GuardrailResult#fix fix(rewrittenOutput)}.
  7. - *
  8. {@link Agentspan#run} executes the agent with the guardrail running + *
  9. {@link AgentRuntime#run} executes the agent with the guardrail running * server-side after each LLM turn; failures retry (up to * {@code maxRetries}) or fix-substitute depending on {@link OnFail}.
  10. *
@@ -54,7 +54,7 @@ * model's response using {@code OnFail.FIX} so the LLM doesn't have to * regenerate — the guardrail rewrites the output in place. */ -public class Example38AgentspanGuardrails { +public class Example38ConductorGuardrails { private static final Pattern EMAIL = Pattern.compile( "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b"); @@ -101,10 +101,10 @@ public static void main(String[] args) { .position(Position.OUTPUT) .onFail(OnFail.FIX) .maxRetries(1) - .func(Example38AgentspanGuardrails::redactPii) + .func(Example38ConductorGuardrails::redactPii) .build(); - // Drop in the native ADK agent, then bolt Agentspan's server-side + // Drop in the native ADK agent, then bolt Conductor's server-side // guardrail onto the bridged builder before .build(). Agent guarded = AdkBridge.agentBuilder(helper) .guardrails(piiRedaction) diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example01HelloWorld.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example01HelloWorld.java index fae71fd4e..98f15bb03 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example01HelloWorld.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example01HelloWorld.java @@ -23,23 +23,23 @@ * *

Builds a real {@code dev.langchain4j.model.openai.OpenAiChatModel} * (the canonical LangChain4j chat-model class) and hands it directly to - * {@link Agentspan#run(ChatModel, String)} via the drop-in overload so the - * agent runs on the durable Agentspan runtime. + * {@link AgentRuntime#run(ChatModel, String)} via the drop-in overload so the + * agent runs on the durable Conductor runtime. * *

Requirements: *

    - *
  • {@code AGENTSPAN_SERVER_URL=http://localhost:6767/api}
  • - *
  • Agentspan server with OpenAI credentials configured server-side.
  • + *
  • {@code CONDUCTOR_SERVER_URL=http://localhost:6767/api}
  • + *
  • Conductor server with OpenAI credentials configured server-side.
  • *
*/ public class Example01HelloWorld { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example02ReactWithTools.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example02ReactWithTools.java index a2f234c4a..86d675b41 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example02ReactWithTools.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example02ReactWithTools.java @@ -32,14 +32,14 @@ *
    *
  • Defining tools with {@link Tool @Tool} on a POJO
  • *
  • Building a native {@link OpenAiChatModel} and passing it to - * {@link Agentspan#run(ChatModel, String, Object...)} alongside the tool POJOs
  • + * {@link AgentRuntime#run(ChatModel, String, Object...)} alongside the tool POJOs *
  • Calculator, string, and date utilities
  • *
* *

Requirements: *

    - *
  • {@code AGENTSPAN_SERVER_URL=http://localhost:6767/api}
  • - *
  • Agentspan server with OpenAI credentials configured server-side.
  • + *
  • {@code CONDUCTOR_SERVER_URL=http://localhost:6767/api}
  • + *
  • Conductor server with OpenAI credentials configured server-side.
  • *
*/ public class Example02ReactWithTools { @@ -177,10 +177,10 @@ private double parsePrimary() { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example03CustomTools.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example03CustomTools.java index 27973a8d1..2afc56219 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example03CustomTools.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example03CustomTools.java @@ -40,8 +40,8 @@ * *

Requirements: *

    - *
  • {@code AGENTSPAN_SERVER_URL=http://localhost:6767/api}
  • - *
  • Agentspan server with OpenAI credentials configured server-side.
  • + *
  • {@code CONDUCTOR_SERVER_URL=http://localhost:6767/api}
  • + *
  • Conductor server with OpenAI credentials configured server-side.
  • *
*/ public class Example03CustomTools { @@ -107,10 +107,10 @@ private static String trimNumber(double v) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example04StructuredOutput.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example04StructuredOutput.java index ed56dae08..f0c46ec58 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example04StructuredOutput.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example04StructuredOutput.java @@ -34,7 +34,7 @@ * *

LangChain4j adaptation: there is no clean parity for * {@code with_structured_output} when the {@code @Tool}-bearing POJO is executed - * inside Agentspan's server-side LLM loop. The closest semantically-equivalent + * inside Conductor's server-side LLM loop. The closest semantically-equivalent * shape is to have the tool itself return a structured JSON payload that matches * the Pydantic schema — the LLM then receives the same structured-tool-output it * would have under Pydantic. Field names, types, and ranges all match the @@ -49,8 +49,8 @@ * *

Requirements: *

    - *
  • {@code AGENTSPAN_SERVER_URL=http://localhost:6767/api}
  • - *
  • Agentspan server with OpenAI credentials configured server-side.
  • + *
  • {@code CONDUCTOR_SERVER_URL=http://localhost:6767/api}
  • + *
  • Conductor server with OpenAI credentials configured server-side.
  • *
*/ public class Example04StructuredOutput { @@ -177,10 +177,10 @@ private static String escape(String s) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example05PromptTemplates.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example05PromptTemplates.java index 6e083fa64..73f16419a 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example05PromptTemplates.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example05PromptTemplates.java @@ -39,8 +39,8 @@ * *

Requirements: *

    - *
  • {@code AGENTSPAN_SERVER_URL=http://localhost:6767/api}
  • - *
  • Agentspan server with OpenAI credentials configured server-side.
  • + *
  • {@code CONDUCTOR_SERVER_URL=http://localhost:6767/api}
  • + *
  • Conductor server with OpenAI credentials configured server-side.
  • *
*/ public class Example05PromptTemplates { @@ -101,10 +101,10 @@ public String suggestSynonyms(@P("word") String word) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example06ChatHistory.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example06ChatHistory.java index fd670e576..d1be39d04 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example06ChatHistory.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example06ChatHistory.java @@ -35,13 +35,13 @@ * *

LangChain4j adaptation: with the server-side LLM loop, there is * no client-side {@code MessageWindowChatMemory} that survives across - * {@link Agentspan#run} invocations. The closest semantically-equivalent + * {@link AgentRuntime#run} invocations. The closest semantically-equivalent * shape is to mark the agent as {@code stateful(true)} so that the server * persists conversation history across runs in a dedicated worker domain — * multi-turn calls against the same stateful agent will see prior exchanges. * The single-turn driver below mirrors the Python source exactly; toggling * stateful demonstrates how the Java SDK surfaces persistent context. Because - * {@code stateful(true)} is an Agentspan-side flag rather than a LangChain4j + * {@code stateful(true)} is an Conductor-side flag rather than a LangChain4j * one, we use the advanced {@link LangChainBridge#agentBuilder} path so we * can decorate the agent before {@code .build()}. * @@ -54,8 +54,8 @@ * *

Requirements: *

    - *
  • {@code AGENTSPAN_SERVER_URL=http://localhost:6767/api}
  • - *
  • Agentspan server with OpenAI credentials configured server-side.
  • + *
  • {@code CONDUCTOR_SERVER_URL=http://localhost:6767/api}
  • + *
  • Conductor server with OpenAI credentials configured server-side.
  • *
*/ public class Example06ChatHistory { @@ -82,16 +82,16 @@ public String recallFact(@P("topic") String topic) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); // Use the advanced LangChainBridge.agentBuilder(...) path so we can // mark the agent stateful(true) — server-side cross-run conversation - // persistence is an Agentspan feature on top of LangChain4j. + // persistence is an Conductor feature on top of LangChain4j. Agent agent = LangChainBridge.agentBuilder( "chat_history_agent", model, diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example07MemoryAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example07MemoryAgent.java index 0c32a06ae..e24af38f3 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example07MemoryAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example07MemoryAgent.java @@ -34,12 +34,12 @@ * *

LangChain4j adaptation: with the server-side LLM loop, there is * no client-side {@code MessageWindowChatMemory} that survives across - * {@link Agentspan#run} calls. The closest semantically-equivalent shape is + * {@link AgentRuntime#run} calls. The closest semantically-equivalent shape is * to mark the agent as {@code stateful(true)} so that the server persists * conversation history across runs in a dedicated worker domain. Subsequent * runs against the same stateful agent then see prior exchanges. The * single-turn driver below mirrors the Python source. Because - * {@code stateful(true)} is an Agentspan-side flag, we use the advanced + * {@code stateful(true)} is an Conductor-side flag, we use the advanced * {@link LangChainBridge#agentBuilder} path so we can decorate the agent * before {@code .build()}. * @@ -52,8 +52,8 @@ * *

Requirements: *

    - *
  • {@code AGENTSPAN_SERVER_URL=http://localhost:6767/api}
  • - *
  • Agentspan server with OpenAI credentials configured server-side.
  • + *
  • {@code CONDUCTOR_SERVER_URL=http://localhost:6767/api}
  • + *
  • Conductor server with OpenAI credentials configured server-side.
  • *
*/ public class Example07MemoryAgent { @@ -82,16 +82,16 @@ public static void main(String[] args) { String instructions = "You are a helpful HR assistant. Remember information from earlier in the conversation."; - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); // Use the advanced LangChainBridge.agentBuilder(...) path so we can // mark the agent stateful(true) — server-side cross-run conversation - // persistence is an Agentspan feature on top of LangChain4j. + // persistence is an Conductor feature on top of LangChain4j. Agent agent = LangChainBridge.agentBuilder( "memory_agent", model, diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example08MultiToolAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example08MultiToolAgent.java index fee9bdaef..15f84d920 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example08MultiToolAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example08MultiToolAgent.java @@ -39,8 +39,8 @@ * *

Requirements: *

    - *
  • {@code AGENTSPAN_SERVER_URL=http://localhost:6767/api}
  • - *
  • Agentspan server with OpenAI credentials configured server-side.
  • + *
  • {@code CONDUCTOR_SERVER_URL=http://localhost:6767/api}
  • + *
  • Conductor server with OpenAI credentials configured server-side.
  • *
*/ public class Example08MultiToolAgent { @@ -104,10 +104,10 @@ public String getNewsHeadline(@P("topic") String topic) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example09MathCalculator.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example09MathCalculator.java index 99f9b59e2..a742e4054 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example09MathCalculator.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example09MathCalculator.java @@ -244,10 +244,10 @@ private static String trimNum(double v) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example10WebSearchAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example10WebSearchAgent.java index 96cc3e25b..4b9c91c86 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example10WebSearchAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example10WebSearchAgent.java @@ -121,10 +121,10 @@ public String summarizeResults(@dev.langchain4j.agent.tool.P("text") String text public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example11CodeReviewAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example11CodeReviewAgent.java index 1dd53821b..099b1fc74 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example11CodeReviewAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example11CodeReviewAgent.java @@ -161,10 +161,10 @@ private static boolean isValidIdent(String s) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example12DocumentSummarizer.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example12DocumentSummarizer.java index 2c1690b4c..d2130c974 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example12DocumentSummarizer.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example12DocumentSummarizer.java @@ -125,10 +125,10 @@ public String extractKeySentences( public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example13CustomerServiceAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example13CustomerServiceAgent.java index c769fcfbf..f04b8df35 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example13CustomerServiceAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example13CustomerServiceAgent.java @@ -99,10 +99,10 @@ public String createSupportTicket( public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example14ResearchAssistant.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example14ResearchAssistant.java index e1932c4c7..d10662c72 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example14ResearchAssistant.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example14ResearchAssistant.java @@ -114,10 +114,10 @@ public String getStatistics(@dev.langchain4j.agent.tool.P("domain") String domai public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example15DataAnalyst.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example15DataAnalyst.java index bba2a0396..7979bbecc 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example15DataAnalyst.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example15DataAnalyst.java @@ -194,10 +194,10 @@ public String detectOutliers( public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example16ContentWriter.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example16ContentWriter.java index 2934e06fd..92ff055a5 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example16ContentWriter.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example16ContentWriter.java @@ -146,10 +146,10 @@ private static String titleCase(String s) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example17SqlAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example17SqlAgent.java index b0a573ceb..8735ba8f3 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example17SqlAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example17SqlAgent.java @@ -427,10 +427,10 @@ public static void main(String[] args) { @SuppressWarnings("unused") List _unused = Arrays.asList("ref"); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example18EmailDrafter.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example18EmailDrafter.java index da344a7e8..662f3792a 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example18EmailDrafter.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example18EmailDrafter.java @@ -113,10 +113,10 @@ public String formatEmailTemplate( public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example19FactChecker.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example19FactChecker.java index 74c89b921..589711d28 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example19FactChecker.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example19FactChecker.java @@ -157,10 +157,10 @@ public String extractClaims(@dev.langchain4j.agent.tool.P("text") String text) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example20TranslationAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example20TranslationAgent.java index 291d6e456..330b7f8c7 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example20TranslationAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example20TranslationAgent.java @@ -161,10 +161,10 @@ public String getLanguageFacts(@dev.langchain4j.agent.tool.P("language") String public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example21SentimentAnalysis.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example21SentimentAnalysis.java index 565ac0e28..3904fa4cf 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example21SentimentAnalysis.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example21SentimentAnalysis.java @@ -163,10 +163,10 @@ private static int countIntersect(Set a, Set b) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example22ClassificationAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example22ClassificationAgent.java index 489ff8cdd..0af1cbc80 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example22ClassificationAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example22ClassificationAgent.java @@ -154,10 +154,10 @@ public String getCategoryExamples(@dev.langchain4j.agent.tool.P("category") Stri public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example23RecommendationAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example23RecommendationAgent.java index 87d1460f2..cb511ae85 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example23RecommendationAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example23RecommendationAgent.java @@ -189,10 +189,10 @@ private static Book findByTitle(String title) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example24OutputParsers.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example24OutputParsers.java index 6f16250d7..21a68b111 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example24OutputParsers.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example24OutputParsers.java @@ -39,7 +39,7 @@ * {@code PydanticOutputParser}. The Java port uses prompt-based JSON return * shapes plus consumer-side Jackson deserialization — LangChain4j's * {@code AiServices} typed-return analog isn't applicable in - * {@link Agentspan#run(ChatModel, String, Object...)} extraction mode + * {@link AgentRuntime#run(ChatModel, String, Object...)} extraction mode * (server-side LLM loop). */ public class Example24OutputParsers { @@ -163,10 +163,10 @@ public static void main(String[] args) { @SuppressWarnings("unused") ExtractedFields example = new ExtractedFields("2025-03-15", "$249.99", "billing@example.com"); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example25AdvancedOrchestration.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example25AdvancedOrchestration.java index 632f5b643..f22a59197 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example25AdvancedOrchestration.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example25AdvancedOrchestration.java @@ -233,10 +233,10 @@ private static Map parseSimpleJsonNumbers(String json) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example26AgentspanGuardrails.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example26ConductorGuardrails.java similarity index 89% rename from agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example26AgentspanGuardrails.java rename to agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example26ConductorGuardrails.java index 826802446..904b55d4d 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example26AgentspanGuardrails.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/Example26ConductorGuardrails.java @@ -27,25 +27,25 @@ import dev.langchain4j.model.openai.OpenAiChatModel; /** - * Example LangChain 26 — Agentspan guardrails on a native LangChain4j agent. + * Example LangChain4j 26 — Conductor guardrails on a native LangChain4j agent. * - *

LangChain4j has no built-in guardrail abstraction. Agentspan provides a + *

LangChain4j has no built-in guardrail abstraction. Conductor provides a * server-compiled guardrail mechanism that runs as a Conductor task inside the * agent's loop. This example attaches a PII-redaction guardrail to a pure * LangChain4j agent built from a native {@code ChatModel}. * *

Pattern (same as - * {@code adk.Example38AgentspanGuardrails}): + * {@code adk.Example38ConductorGuardrails}): *

    *
  1. Build native LangChain4j {@code ChatModel}.
  2. *
  3. Hand it to {@link LangChainBridge#agentBuilder} (the builder variant * lets you decorate before building).
  4. *
  5. Attach a {@link GuardrailDef} with {@link OnFail#FIX} so the * guardrail rewrites the output in place.
  6. - *
  7. {@link Agentspan#run}.
  8. + *
  9. {@link AgentRuntime#run}.
  10. *
*/ -public class Example26AgentspanGuardrails { +public class Example26ConductorGuardrails { private static final Pattern EMAIL = Pattern.compile( "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b"); @@ -64,10 +64,10 @@ private static GuardrailResult redactPii(String content) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); @@ -76,7 +76,7 @@ public static void main(String[] args) { .position(Position.OUTPUT) .onFail(OnFail.FIX) .maxRetries(1) - .func(Example26AgentspanGuardrails::redactPii) + .func(Example26ConductorGuardrails::redactPii) .build(); Agent guarded = LangChainBridge.agentBuilder( diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/ExampleCredentials.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/ExampleCredentials.java index b2a3a5ce6..501981824 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/ExampleCredentials.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/ExampleCredentials.java @@ -32,14 +32,14 @@ *

Demonstrates mixing: *

    *
  • Native LangChain4j {@code @Tool} methods that perform pure computation (no secrets)
  • - *
  • An Agentspan {@link Tool}-annotated method that reads a credential injected + *
  • An Conductor {@link Tool}-annotated method that reads a credential injected * as an environment variable by the server (via the {@code credentials} field)
  • *
* *

Credential injection pattern: *

    *
  1. Declare credential names in {@code @Tool(credentials = {"MY_API_KEY"})}
  2. - *
  3. Store the secret once via the CLI: {@code agentspan credentials set --name MY_API_KEY}
  4. + *
  5. Store the secret once via the CLI: {@code conductor credentials set --name MY_API_KEY}
  6. *
  7. At runtime, the server resolves the credential and injects it as * an environment variable before invoking the worker. Read it with * {@code System.getenv("MY_API_KEY")}.
  8. @@ -49,10 +49,10 @@ * *

    Requirements: *

      - *
    • {@code AGENTSPAN_SERVER_URL=http://localhost:6767}
    • + *
    • {@code CONDUCTOR_SERVER_URL=http://localhost:6767}
    • *
    • langchain4j on the classpath (see examples/build.gradle)
    • - *
    • Agentspan server with OpenAI credentials configured server-side.
    • - *
    • Credential {@code WEATHER_API_KEY} registered in Agentspan (optional — example + *
    • Conductor server with OpenAI credentials configured server-side.
    • + *
    • Credential {@code WEATHER_API_KEY} registered in Conductor (optional — example * works without it, falling back to a stubbed response)
    • *
    */ @@ -79,14 +79,14 @@ public double fahrenheitToCelsius(@dev.langchain4j.agent.tool.P("fahrenheit") do } } - // ── Agentspan @Tool class: reads a credential injected by the server ────── + // ── Conductor @Tool class: reads a credential injected by the server ────── static class WeatherTools { /** * Fetches current weather for a city. * - *

    The server resolves {@code WEATHER_API_KEY} from the Agentspan credential + *

    The server resolves {@code WEATHER_API_KEY} from the Conductor credential * store and injects it as an environment variable before calling this worker. * The tool reads it via {@code System.getenv("WEATHER_API_KEY")}. */ @@ -111,16 +111,16 @@ public String getWeather(String city) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); // Build the LangChain4j-backed agent (unit conversion tools) via the // advanced LangChainBridge.agentBuilder(...) path so we can merge in - // Agentspan @Tool credential-aware tools before .build(). + // Conductor @Tool credential-aware tools before .build(). Agent lc4jAgent = LangChainBridge.agentBuilder( "lc4j_weather_agent", model, @@ -129,7 +129,7 @@ public static void main(String[] args) { new UnitTools()) .build(); - // Agentspan @Tool tools (credential-aware) — build separately and merge in. + // Conductor @Tool tools (credential-aware) — build separately and merge in. List credentialTools = ToolRegistry.fromInstance(new WeatherTools()); // Merge the credential-aware tool into the agent by rebuilding it. diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/ExamplePipeline.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/ExamplePipeline.java index 3eb727e61..5e94f1636 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/ExamplePipeline.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langchain/ExamplePipeline.java @@ -24,28 +24,28 @@ * Example Lc4j 04 — LangChain4j Agent in a Sequential Pipeline (native LangChain4j SDK) * *

    Demonstrates interoperability between a LangChain4j-backed agent and a - * regular Agentspan agent inside a sequential pipeline (using {@link Agent#then}). + * regular Conductor agent inside a sequential pipeline (using {@link Agent#then}). * *

    Pipeline stages: *

      *
    1. data_gatherer — built from native LangChain4j {@code @Tool} methods * that look up product data. The LLM calls the tools and produces * a structured data payload.
    2. - *
    3. report_writer — a plain Agentspan agent (no tools) that + *
    4. report_writer — a plain Conductor agent (no tools) that * receives the data payload as its input and writes a human-readable * report.
    5. *
    * *

    This pattern shows that * {@link LangChainBridge#agentBuilder} returns a standard {@link Agent} via - * {@code .build()} — it composes naturally with any other Agentspan agent or + * {@code .build()} — it composes naturally with any other Conductor agent or * orchestration strategy. * *

    Requirements: *

      - *
    • {@code AGENTSPAN_SERVER_URL=http://localhost:6767}
    • + *
    • {@code CONDUCTOR_SERVER_URL=http://localhost:6767}
    • *
    • langchain4j on the classpath (see examples/build.gradle)
    • - *
    • Agentspan server with OpenAI credentials configured server-side.
    • + *
    • Conductor server with OpenAI credentials configured server-side.
    • *
    */ public class ExamplePipeline { @@ -89,10 +89,10 @@ public java.util.Map getSalesStats( public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); @@ -107,7 +107,7 @@ public static void main(String[] args) { new ProductDataTools()) .build(); - // Stage 2: Plain Agentspan agent (no tools) — receives the data summary and writes a report. + // Stage 2: Plain Conductor agent (no tools) — receives the data summary and writes a report. // Use the same provider/model string the bridge derived for stage 1. Agent reportWriter = Agent.builder() .name("report_writer") diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example01HelloWorld.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example01HelloWorld.java index 3d8f94094..8a9b106ed 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example01HelloWorld.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example01HelloWorld.java @@ -24,17 +24,17 @@ * *

    Builds a real LangGraph4j {@code AgentExecutor.Builder} (the same builder * the LangGraph4j docs use for the prebuilt ReAct agent) and hands it directly - * to {@link Agentspan#run(AgentExecutor.Builder, String, Object...)} via the - * drop-in overload so it runs on the durable Agentspan runtime. + * to {@link AgentRuntime#run(AgentExecutor.Builder, String, Object...)} via the + * drop-in overload so it runs on the durable Conductor runtime. */ public class Example01HelloWorld { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example02ReactWithTools.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example02ReactWithTools.java index 770a6674f..40f69eac7 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example02ReactWithTools.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example02ReactWithTools.java @@ -29,13 +29,13 @@ *

    Java port (concepts) of * sdk/python/examples/langgraph/02_react_with_tools.py. Builds a * real LangGraph4j {@code AgentExecutor.Builder} (a ReAct {@code StateGraph}) - * and hands it straight to {@link Agentspan#run} via the drop-in overload. + * and hands it straight to {@link AgentRuntime#run} via the drop-in overload. * *

    Demonstrates: *

      *
    • Defining tools with native {@link Tool @Tool} on a POJO
    • *
    • Passing the tool POJO straight to - * {@link Agentspan#run(AgentExecutor.Builder, String, Object...)} via + * {@link AgentRuntime#run(AgentExecutor.Builder, String, Object...)} via * the drop-in overload — internally LangGraph4j calls * {@code toolsFromObject(...)}
    • *
    • Calculator, word count, and date utilities
    • @@ -68,10 +68,10 @@ public String getToday() { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example03Memory.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example03Memory.java index cb3f4b0ae..95a5da87d 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example03Memory.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example03Memory.java @@ -24,15 +24,15 @@ * *

      Inspired by sdk/python/examples/langgraph/03_memory.py which * attaches a {@code MemorySaver} checkpointer to {@code create_agent}. In this - * Java/Agentspan port we demonstrate the same surface — a single agent invoked + * Java/Conductor port we demonstrate the same surface — a single agent invoked * three times in sequence — but without an in-process checkpointer. The history * is passed back to the LLM directly inside the prompt for each turn, which is - * the simplest portable pattern when running on the durable Agentspan runtime. + * the simplest portable pattern when running on the durable Conductor runtime. * *

      Demonstrates: *

        *
      • Reusing a single {@link AgentExecutor.Builder}-built agent for - * multiple turns via the drop-in {@link Agentspan#run} overload
      • + * multiple turns via the drop-in {@link AgentRuntime#run} overload *
      • Pure prompt-based history (no in-memory checkpointer required)
      • *
      • How an LLM can recall facts when given prior context
      • *
      @@ -41,10 +41,10 @@ public class Example03Memory { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example04SimpleStateGraph.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example04SimpleStateGraph.java index f49e66e42..a090ca784 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example04SimpleStateGraph.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example04SimpleStateGraph.java @@ -26,7 +26,7 @@ * *

      Conceptually mirrors sdk/python/examples/langgraph/04_simple_stategraph.py * which builds a 3-node {@code StateGraph} pipeline. In the LangGraph4j - * agent-executor + Agentspan pattern, multi-node pipelines are most cleanly + * agent-executor + Conductor pattern, multi-node pipelines are most cleanly * expressed by giving the ReAct loop a small set of pipeline-stage tools and * letting the LLM walk through them in order — the agent still produces the * same query -> refined -> answer flow as the Python pipeline. @@ -37,7 +37,7 @@ *

    • Tool-sequencing instructions folded into the user message * (validate -> refine -> answer)
    • *
    • Building the LangGraph4j {@code AgentExecutor.Builder} and handing it - * straight to {@link Agentspan#run}
    • + * straight to {@link AgentRuntime#run} *
    */ public class Example04SimpleStateGraph { @@ -69,10 +69,10 @@ public String recordAnswer(@P("answer") String answer) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example05ToolNode.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example05ToolNode.java index fd0ee6174..710b83e88 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example05ToolNode.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example05ToolNode.java @@ -76,10 +76,10 @@ public String lookupPopulation(@P("country") String country) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example06ConditionalRouting.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example06ConditionalRouting.java index 3ff749988..b0985ff91 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example06ConditionalRouting.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example06ConditionalRouting.java @@ -73,10 +73,10 @@ public String handleNeutral(@P("text") String text) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example07SystemPrompt.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example07SystemPrompt.java index 22cb05bc9..32940113e 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example07SystemPrompt.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example07SystemPrompt.java @@ -24,7 +24,7 @@ * *

    Mirrors sdk/python/examples/langgraph/07_system_prompt.py * which passes a {@code system_prompt=...} to {@code create_agent}. The - * drop-in {@link Agentspan#run} overload takes a single user prompt — fold + * drop-in {@link AgentRuntime#run} overload takes a single user prompt — fold * the persona into that prompt as a leading section so it steers every reply. * *

    Demonstrates: @@ -53,10 +53,10 @@ public class Example07SystemPrompt { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example08StructuredOutput.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example08StructuredOutput.java index 5f2758d00..cb0d22774 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example08StructuredOutput.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example08StructuredOutput.java @@ -65,10 +65,10 @@ private static String escape(String s) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example09MathAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example09MathAgent.java index ad3c3d003..e4d04223a 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example09MathAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example09MathAgent.java @@ -83,10 +83,10 @@ public String factorial(@P("n") int n) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example10ResearchAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example10ResearchAgent.java index fde82cd6e..fb9da22fd 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example10ResearchAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example10ResearchAgent.java @@ -114,10 +114,10 @@ public String citeSource(@P("claim") String claim, @P("source_type") String sour public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example11CustomerSupport.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example11CustomerSupport.java index fc35d467d..f1c084a57 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example11CustomerSupport.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/langgraph/Example11CustomerSupport.java @@ -84,10 +84,10 @@ public String handleGeneral(@P("user_message") String userMessage) { public static void main(String[] args) { AgentRuntime runtime = new AgentRuntime(); - // apiKey is required by LangChain4j's builder but unused — Agentspan + // apiKey is required by LangChain4j's builder but unused — Conductor // runs the LLM call on the server with server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example01BasicAgent.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example01BasicAgent.java index 9f65c57c6..c252d54c8 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example01BasicAgent.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example01BasicAgent.java @@ -24,14 +24,14 @@ *

    Java port of sdk/python/examples/openai/01_basic_agent.py. * *

    Demonstrates: the simplest possible OpenAI Agents SDK agent — no tools, - * just a name + instructions + model — wired through the Agentspan + * just a name + instructions + model — wired through the Conductor * {@link OpenAIAgent} factory so the server normalizes it into a Conductor * workflow. * *

    Requirements: *

      - *
    • AGENTSPAN_SERVER_URL=http://localhost:6767/api
    • - *
    • AGENTSPAN_LLM_MODEL=openai/gpt-4o-mini
    • + *
    • CONDUCTOR_SERVER_URL=http://localhost:6767/api
    • + *
    • CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini
    • *
    */ public class Example01BasicAgent { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example02FunctionTools.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example02FunctionTools.java index ba9418c4d..c33fdde4d 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example02FunctionTools.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example02FunctionTools.java @@ -29,19 +29,19 @@ * *

    Demonstrates: an OpenAI Agents SDK-style agent that calls multiple * function-tools (weather, calculator, population lookup). Each tool method - * carries an Agentspan {@link Tool} annotation — the {@link OpenAIAgent} - * reflection bridge wraps them as Agentspan worker tools. + * carries an Conductor {@link Tool} annotation — the {@link OpenAIAgent} + * reflection bridge wraps them as Conductor worker tools. * *

    Note on annotation choice: the Python example uses * {@code @function_tool}; in Java the {@code OpenAIAgent} factory accepts * both {@code @org.conductoross.conductor.ai.annotations.Tool} and - * {@code @dev.langchain4j.agent.tool.Tool}. We use the Agentspan annotation + * {@code @dev.langchain4j.agent.tool.Tool}. We use the Conductor annotation * here because LangChain4j is only a {@code compileOnly} SDK dependency. * *

    Requirements: *

      - *
    • AGENTSPAN_SERVER_URL=http://localhost:6767/api
    • - *
    • AGENTSPAN_LLM_MODEL=openai/gpt-4o-mini
    • + *
    • CONDUCTOR_SERVER_URL=http://localhost:6767/api
    • + *
    • CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini
    • *
    */ public class Example02FunctionTools { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example03StructuredOutput.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example03StructuredOutput.java index 489d6ce13..296f2ff9b 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example03StructuredOutput.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example03StructuredOutput.java @@ -47,8 +47,8 @@ * *

    Requirements: *

      - *
    • AGENTSPAN_SERVER_URL=http://localhost:6767/api
    • - *
    • AGENTSPAN_LLM_MODEL=openai/gpt-4o-mini
    • + *
    • CONDUCTOR_SERVER_URL=http://localhost:6767/api
    • + *
    • CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini
    • *
    */ public class Example03StructuredOutput { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example04Handoffs.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example04Handoffs.java index 0b764f7fd..9a2380194 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example04Handoffs.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example04Handoffs.java @@ -34,8 +34,8 @@ * *

    Requirements: *

      - *
    • AGENTSPAN_SERVER_URL=http://localhost:6767/api
    • - *
    • AGENTSPAN_LLM_MODEL=openai/gpt-4o-mini
    • + *
    • CONDUCTOR_SERVER_URL=http://localhost:6767/api
    • + *
    • CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini
    • *
    */ public class Example04Handoffs { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example05Guardrails.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example05Guardrails.java index 5218de0b0..961944fdb 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example05Guardrails.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example05Guardrails.java @@ -45,8 +45,8 @@ * *

    Requirements: *

      - *
    • AGENTSPAN_SERVER_URL=http://localhost:6767/api
    • - *
    • AGENTSPAN_LLM_MODEL=openai/gpt-4o-mini
    • + *
    • CONDUCTOR_SERVER_URL=http://localhost:6767/api
    • + *
    • CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini
    • *
    */ public class Example05Guardrails { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example06ModelSettings.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example06ModelSettings.java index 04d1828b4..dec96e71c 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example06ModelSettings.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example06ModelSettings.java @@ -38,8 +38,8 @@ * *

    Requirements: *

      - *
    • AGENTSPAN_SERVER_URL=http://localhost:6767/api
    • - *
    • AGENTSPAN_LLM_MODEL=openai/gpt-4o-mini
    • + *
    • CONDUCTOR_SERVER_URL=http://localhost:6767/api
    • + *
    • CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini
    • *
    */ public class Example06ModelSettings { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example07Streaming.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example07Streaming.java index 0fd14d799..5a3d3ef96 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example07Streaming.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example07Streaming.java @@ -34,8 +34,8 @@ * *

    Requirements: *

      - *
    • AGENTSPAN_SERVER_URL=http://localhost:6767/api
    • - *
    • AGENTSPAN_LLM_MODEL=openai/gpt-4o-mini
    • + *
    • CONDUCTOR_SERVER_URL=http://localhost:6767/api
    • + *
    • CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini
    • *
    */ public class Example07Streaming { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example08AgentAsTool.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example08AgentAsTool.java index beb607b7a..a6af30166 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example08AgentAsTool.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example08AgentAsTool.java @@ -42,8 +42,8 @@ * *

    Requirements: *

      - *
    • AGENTSPAN_SERVER_URL=http://localhost:6767/api
    • - *
    • AGENTSPAN_LLM_MODEL=openai/gpt-4o-mini
    • + *
    • CONDUCTOR_SERVER_URL=http://localhost:6767/api
    • + *
    • CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini
    • *
    */ public class Example08AgentAsTool { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example09DynamicInstructions.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example09DynamicInstructions.java index 311006397..6aa03ed14 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example09DynamicInstructions.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example09DynamicInstructions.java @@ -39,8 +39,8 @@ * *

    Requirements: *

      - *
    • AGENTSPAN_SERVER_URL=http://localhost:6767/api
    • - *
    • AGENTSPAN_LLM_MODEL=openai/gpt-4o-mini
    • + *
    • CONDUCTOR_SERVER_URL=http://localhost:6767/api
    • + *
    • CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini
    • *
    */ public class Example09DynamicInstructions { diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example10MultiModel.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example10MultiModel.java index efffcafd4..e33b3caf2 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example10MultiModel.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/openai/Example10MultiModel.java @@ -44,8 +44,8 @@ * *

    Requirements: *

      - *
    • AGENTSPAN_SERVER_URL=http://localhost:6767/api
    • - *
    • AGENTSPAN_LLM_MODEL=openai/gpt-4o-mini
    • + *
    • CONDUCTOR_SERVER_URL=http://localhost:6767/api
    • + *
    • CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini
    • *
    • AGENT_SECONDARY_LLM_MODEL=openai/gpt-4o
    • *
    */ diff --git a/conductor-client-ai-spring/src/main/java/org/conductoross/conductor/ai/spring/AgentAutoConfiguration.java b/conductor-client-ai-spring/src/main/java/org/conductoross/conductor/ai/spring/AgentAutoConfiguration.java index 617a3f9ef..063fb0dd6 100644 --- a/conductor-client-ai-spring/src/main/java/org/conductoross/conductor/ai/spring/AgentAutoConfiguration.java +++ b/conductor-client-ai-spring/src/main/java/org/conductoross/conductor/ai/spring/AgentAutoConfiguration.java @@ -24,9 +24,9 @@ import io.orkes.conductor.client.spring.OrkesConductorClientAutoConfiguration; /** - * Spring Boot auto-configuration for the Agentspan SDK. + * Spring Boot auto-configuration for the Conductor agent SDK. * - *

    This configuration wires two beans from {@code agentspan.*} properties: + *

    This configuration wires two beans from {@code conductor.agent.*} properties: *

      *
    • {@link AgentConfig} — worker-runner tuning (poll interval, thread count)
    • *
    • {@link AgentRuntime} — the SDK entry point
    • @@ -50,7 +50,7 @@ public class AgentAutoConfiguration { @Bean @ConditionalOnMissingBean - public AgentConfig agentspanConfig(AgentProperties props) { + public AgentConfig conductorAgentConfig(AgentProperties props) { return new AgentConfig(props.getWorkerPollIntervalMs(), props.getWorkerThreadCount()); } diff --git a/conductor-client-ai-spring/src/main/java/org/conductoross/conductor/ai/spring/AgentProperties.java b/conductor-client-ai-spring/src/main/java/org/conductoross/conductor/ai/spring/AgentProperties.java index cea805efb..421251a44 100644 --- a/conductor-client-ai-spring/src/main/java/org/conductoross/conductor/ai/spring/AgentProperties.java +++ b/conductor-client-ai-spring/src/main/java/org/conductoross/conductor/ai/spring/AgentProperties.java @@ -15,12 +15,12 @@ import org.springframework.boot.context.properties.ConfigurationProperties; /** - * Agentspan-specific tuning knobs for the Spring Boot auto-configuration. + * Conductor agent tuning knobs for the Spring Boot auto-configuration. * *

      Server connectivity (URL, auth key/secret) is handled by the Conductor * Java SDK's own Spring starter via {@code conductor.*} properties — see * {@link io.orkes.conductor.client.spring.OrkesConductorClientAutoConfiguration}. - * Only the Agentspan worker-runner settings live here. + * Only the Conductor agent worker-runner settings live here. * *

      {@code
        * # application.properties
      @@ -30,12 +30,12 @@
        * conductor.security.client.key-id=my-key       # optional
        * conductor.security.client.secret=my-secret    # optional
        *
      - * # Agentspan worker tuning (this class):
      - * agentspan.worker-poll-interval-ms=100
      - * agentspan.worker-thread-count=1
      + * # Conductor agent worker tuning (this class):
      + * conductor.agent.worker-poll-interval-ms=100
      + * conductor.agent.worker-thread-count=1
        * }
      */ -@ConfigurationProperties(prefix = "agentspan") +@ConfigurationProperties(prefix = "conductor.agent") public class AgentProperties { private int workerPollIntervalMs = 100; diff --git a/conductor-client-ai-spring/src/test/java/org/conductoross/conductor/ai/spring/AgentAutoConfigurationTest.java b/conductor-client-ai-spring/src/test/java/org/conductoross/conductor/ai/spring/AgentAutoConfigurationTest.java index 55aaa5abf..0783a784c 100644 --- a/conductor-client-ai-spring/src/test/java/org/conductoross/conductor/ai/spring/AgentAutoConfigurationTest.java +++ b/conductor-client-ai-spring/src/test/java/org/conductoross/conductor/ai/spring/AgentAutoConfigurationTest.java @@ -41,12 +41,12 @@ private static ApiClient stubApiClient() { @Test void wiresConfigAndRuntimeWithDefaults() { runner.run(ctx -> { - assertTrue(ctx.containsBean("agentspanConfig"), "AgentConfig bean must be present"); + assertTrue(ctx.containsBean("conductorAgentConfig"), "AgentConfig bean must be present"); assertTrue(ctx.containsBean("agentRuntime"), "AgentRuntime bean must be present"); - // No agentspanConductorClient bean — ApiClient comes from conductor-client-spring. + // No agent-specific client bean — ApiClient comes from conductor-client-spring. assertFalse( - ctx.containsBean("agentspanConductorClient"), + ctx.containsBean("conductorAgentClient"), "auto-config must NOT create its own ApiClient — " + "that is OrkesConductorClientAutoConfiguration's job"); @@ -58,7 +58,7 @@ void wiresConfigAndRuntimeWithDefaults() { @Test void respectsWorkerTuningProperties() { - runner.withPropertyValues("agentspan.worker-thread-count=4", "agentspan.worker-poll-interval-ms=250") + runner.withPropertyValues("conductor.agent.worker-thread-count=4", "conductor.agent.worker-poll-interval-ms=250") .run(ctx -> { AgentConfig config = ctx.getBean(AgentConfig.class); assertEquals(4, config.getWorkerThreadCount()); @@ -68,9 +68,9 @@ void respectsWorkerTuningProperties() { @Test void serverUrlPropertiesAreNotAccepted() { - // agentspan.server-url no longer exists — setting it must not cause an error + // conductor.agent.server-url does not exist — setting it must not cause an error // (Spring ignores unknown properties by default) and must not affect the client. - runner.withPropertyValues("agentspan.server-url=http://ignored:9090") + runner.withPropertyValues("conductor.agent.server-url=http://ignored:9090") .run(ctx -> assertFalse( ctx.getStartupFailure() != null, "unknown property must not break context startup")); } diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/AgentConfig.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/AgentConfig.java index a54b8541b..8c849d12e 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/AgentConfig.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/AgentConfig.java @@ -15,7 +15,7 @@ import java.util.function.Function; /** - * Worker-runner tuning for the Agentspan SDK. + * Worker-runner tuning for the Conductor agent SDK. * *

      Connection details (server URL, auth key/secret) are NOT here — those are * transport concerns owned by the Conductor client @@ -26,15 +26,15 @@ * *

      Environment variables (invalid or empty values fall back to the default): *

        - *
      • {@code AGENTSPAN_WORKER_POLL_INTERVAL} — worker poll interval in ms (default: 100)
      • - *
      • {@code AGENTSPAN_WORKER_THREADS} — worker thread count (default: 1)
      • - *
      • {@code AGENTSPAN_AUTO_START_WORKERS} — register + start workers on run/start/stream (default: true)
      • - *
      • {@code AGENTSPAN_DAEMON_WORKERS} — SDK-owned threads are daemons (default: true)
      • - *
      • {@code AGENTSPAN_STREAMING_ENABLED} — use SSE for stream(); false = status polling (default: true)
      • - *
      • {@code AGENTSPAN_LIVENESS_ENABLED} — monitor stateful runs for worker stalls (default: true)
      • - *
      • {@code AGENTSPAN_LIVENESS_STALL_SECONDS} — seconds a task may sit unpolled before it counts + *
      • {@code CONDUCTOR_AGENT_WORKER_POLL_INTERVAL} — worker poll interval in ms (default: 100)
      • + *
      • {@code CONDUCTOR_AGENT_WORKER_THREADS} — worker thread count (default: 1)
      • + *
      • {@code CONDUCTOR_AGENT_AUTO_START_WORKERS} — register + start workers on run/start/stream (default: true)
      • + *
      • {@code CONDUCTOR_AGENT_DAEMON_WORKERS} — SDK-owned threads are daemons (default: true)
      • + *
      • {@code CONDUCTOR_AGENT_STREAMING_ENABLED} — use SSE for stream(); false = status polling (default: true)
      • + *
      • {@code CONDUCTOR_AGENT_LIVENESS_ENABLED} — monitor stateful runs for worker stalls (default: true)
      • + *
      • {@code CONDUCTOR_AGENT_LIVENESS_STALL_SECONDS} — seconds a task may sit unpolled before it counts * as a stall (default: 30.0)
      • - *
      • {@code AGENTSPAN_LIVENESS_CHECK_INTERVAL_SECONDS} — seconds between liveness checks + *
      • {@code CONDUCTOR_AGENT_LIVENESS_CHECK_INTERVAL_SECONDS} — seconds between liveness checks * (default: 10.0)
      • *
      */ @@ -73,14 +73,14 @@ public static AgentConfig fromEnv() { /** Env-seam variant so tests can exercise parsing without mutating process env. */ static AgentConfig fromEnv(Function env) { AgentConfig config = new AgentConfig( - intVar(env, "AGENTSPAN_WORKER_POLL_INTERVAL", 100), - intVar(env, "AGENTSPAN_WORKER_THREADS", 1)); - config.autoStartWorkers = boolVar(env, "AGENTSPAN_AUTO_START_WORKERS", true); - config.daemonWorkers = boolVar(env, "AGENTSPAN_DAEMON_WORKERS", true); - config.streamingEnabled = boolVar(env, "AGENTSPAN_STREAMING_ENABLED", true); - config.livenessEnabled = boolVar(env, "AGENTSPAN_LIVENESS_ENABLED", true); - config.livenessStallSeconds = doubleVar(env, "AGENTSPAN_LIVENESS_STALL_SECONDS", 30.0); - config.livenessCheckIntervalSeconds = doubleVar(env, "AGENTSPAN_LIVENESS_CHECK_INTERVAL_SECONDS", 10.0); + intVar(env, "CONDUCTOR_AGENT_WORKER_POLL_INTERVAL", 100), + intVar(env, "CONDUCTOR_AGENT_WORKER_THREADS", 1)); + config.autoStartWorkers = boolVar(env, "CONDUCTOR_AGENT_AUTO_START_WORKERS", true); + config.daemonWorkers = boolVar(env, "CONDUCTOR_AGENT_DAEMON_WORKERS", true); + config.streamingEnabled = boolVar(env, "CONDUCTOR_AGENT_STREAMING_ENABLED", true); + config.livenessEnabled = boolVar(env, "CONDUCTOR_AGENT_LIVENESS_ENABLED", true); + config.livenessStallSeconds = doubleVar(env, "CONDUCTOR_AGENT_LIVENESS_STALL_SECONDS", 30.0); + config.livenessCheckIntervalSeconds = doubleVar(env, "CONDUCTOR_AGENT_LIVENESS_CHECK_INTERVAL_SECONDS", 10.0); return config; } diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/AgentRuntime.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/AgentRuntime.java index c89cef859..e0f2a6240 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/AgentRuntime.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/AgentRuntime.java @@ -32,6 +32,7 @@ import org.conductoross.conductor.ai.execution.CliCommandExecutor; import org.conductoross.conductor.ai.execution.CliConfig; import org.conductoross.conductor.ai.internal.AgentConfigSerializer; +import org.conductoross.conductor.ai.internal.GuardrailHandlerFactory; import org.conductoross.conductor.ai.internal.ServerLivenessMonitor; import org.conductoross.conductor.ai.internal.WorkerManager; import org.conductoross.conductor.ai.model.AgentHandle; @@ -39,11 +40,8 @@ import org.conductoross.conductor.ai.model.AgentStream; import org.conductoross.conductor.ai.model.DeploymentInfo; import org.conductoross.conductor.ai.model.GuardrailDef; -import org.conductoross.conductor.ai.model.GuardrailResult; import org.conductoross.conductor.ai.model.ToolDef; import org.conductoross.conductor.ai.plans.Plan; -import org.conductoross.conductor.ai.schedule.Schedule; -import org.conductoross.conductor.ai.schedule.Schedules; import org.conductoross.conductor.ai.skill.Skill; import org.conductoross.conductor.ai.termination.AndTermination; import org.conductoross.conductor.ai.termination.MaxMessageTermination; @@ -58,6 +56,8 @@ import io.orkes.conductor.client.AgentClient; import io.orkes.conductor.client.ApiClient; +import io.orkes.conductor.client.OrkesClients; +import io.orkes.conductor.client.SchedulerClient; import io.orkes.conductor.client.SseClient; import io.orkes.conductor.client.exceptions.SSEUnavailableException; import io.orkes.conductor.client.http.OrkesAgentClient; @@ -84,16 +84,16 @@ public class AgentRuntime implements AutoCloseable { private final AgentConfig config; /** Single native Conductor client (ApiClient) for the whole runtime — shared by every - * typed client (AgentClient/WorkerManager/Schedules) and the SSE stream. */ + * typed client (AgentClient/WorkerManager/SchedulerClient) and the SSE stream. */ private final ApiClient conductorClient; /** Agent control-plane client (/api/agent/*) built on the shared Conductor client. */ private final AgentClient agentClient; - /** Standard Conductor workflow client for /api/workflow/* — used by AgentHandle to - * enrich results with token usage and tool calls after execution completes. */ + /** Standard Conductor workflow client used by AgentHandle result handling. */ private final WorkflowClient workflowClient; + /** Typed scheduler transport shared by the runtime. */ + private final SchedulerClient schedulerClient; private final WorkerManager workerManager; - private volatile Schedules schedules; /** Create a runtime with a Conductor client and worker tuning both from environment. */ public AgentRuntime() { @@ -122,10 +122,16 @@ public AgentRuntime(ApiClient conductorClient) { * @param config worker-runner tuning */ public AgentRuntime(ApiClient conductorClient, AgentConfig config) { + this(conductorClient, config, new OrkesClients(conductorClient).getSchedulerClient()); + } + + /** Package-visible typed-client seam for deterministic runtime scheduling tests. */ + AgentRuntime(ApiClient conductorClient, AgentConfig config, SchedulerClient schedulerClient) { this.config = config; this.conductorClient = conductorClient; this.agentClient = new OrkesAgentClient(conductorClient); this.workflowClient = new WorkflowClient(conductorClient); + this.schedulerClient = schedulerClient; this.workerManager = new WorkerManager(config, conductorClient); logger.info("AgentRuntime initialized: {}", conductorClient.getBasePath()); } @@ -140,6 +146,17 @@ public AgentClient getClient() { return agentClient; } + /** + * Returns the typed client for workflow schedule management. + * + *

      Use this client directly with {@code SaveScheduleRequest} and + * {@code WorkflowSchedule}; the agent runtime does not add a separate + * schedule abstraction. + */ + public SchedulerClient getSchedulerClient() { + return schedulerClient; + } + /** * Environment-based bootstrap for the no-arg constructors. Construction and * env resolution (spec R3 chain) live in the client layer — @@ -313,6 +330,16 @@ public CompletableFuture startAsync(Agent agent, String prompt, Run * {@link RunSettings} overrides (either may be null). */ public CompletableFuture startAsync(Agent agent, String prompt, Plan plan, RunSettings runSettings) { + // Capture execution metadata before scheduling the async request so a + // caller mutating a reused RunSettings instance cannot change this + // start's deduplication identity after startAsync returns. + final String configuredIdempotencyKey = + runSettings != null ? runSettings.getIdempotencyKey() : null; + final String idempotencyKey = + configuredIdempotencyKey == null || configuredIdempotencyKey.isBlank() + ? null + : configuredIdempotencyKey; + // Stateful agents get a per-execution domain UUID. The server uses it // as taskToDomain for every worker task in this run; local workers are // registered under the same domain so they poll the per-execution @@ -338,6 +365,7 @@ public CompletableFuture startAsync(Agent agent, String prompt, Pla .sessionId(sessionId != null && !sessionId.isEmpty() ? sessionId : null) .runId(runId != null && !runId.isEmpty() ? runId : null) .staticPlan(plan != null ? plan.toJson() : null) + .idempotencyKey(idempotencyKey) .build()); String executionId = response.getExecutionId(); if (executionId == null) { @@ -483,40 +511,6 @@ public CompletableFuture> deployAsync(Agent... agents) { return CompletableFuture.supplyAsync(() -> deploy(agents)); } - /** - * Deploy a single agent and reconcile its cron schedules declaratively. - * - *

      {@code schedules} semantics: - *

        - *
      • {@code null} → leave existing schedules untouched.
      • - *
      • empty list → purge all schedules for this agent.
      • - *
      • non-empty list → upsert these and prune any others for this agent.
      • - *
      - * - * @param agent the agent to deploy - * @param schedules schedules to attach (tri-state semantics above) - * @return the {@link DeploymentInfo} - */ - public DeploymentInfo deploy(Agent agent, List schedules) { - List infos = deploy(new Agent[] {agent}); - if (schedules != null) { - schedules().reconcile(agent.getName(), schedules); - } - return infos.get(0); - } - - /** Accessor for the cron-schedule lifecycle API. */ - public Schedules schedules() { - if (schedules == null) { - synchronized (this) { - if (schedules == null) { - schedules = new Schedules(conductorClient); - } - } - } - return schedules; - } - /** * Deploy agents, register their workers, and keep polling until interrupted. * @@ -563,7 +557,7 @@ public void serve(Boolean blocking, Agent... agents) { return; } logger.info("Serving {} agent(s) — waiting for tasks (Ctrl+C to stop)", agents.length); - Runtime.getRuntime().addShutdownHook(new Thread(workerManager::stop, "agentspan-serve-shutdown")); + Runtime.getRuntime().addShutdownHook(new Thread(workerManager::stop, "conductor-agent-serve-shutdown")); try { Thread.currentThread().join(); } catch (InterruptedException e) { @@ -628,47 +622,47 @@ public void close() { // ── Drop-in support for native framework agents (run / start / stream / // deploy / serve / plan / resume all accept the raw native object) ── - /** Drop-in: accepts a native ADK {@code BaseAgent} or any Agentspan {@link Agent}. */ + /** Drop-in: accepts a native ADK {@code BaseAgent} or any Conductor {@link Agent}. */ public AgentResult run(Object agent, String prompt) { return run(coerceAgent(agent), prompt); } - /** Drop-in: accepts a native ADK {@code BaseAgent} or any Agentspan {@link Agent}. */ + /** Drop-in: accepts a native ADK {@code BaseAgent} or any Conductor {@link Agent}. */ public CompletableFuture runAsync(Object agent, String prompt) { return runAsync(coerceAgent(agent), prompt); } - /** Drop-in: accepts a native ADK {@code BaseAgent} or any Agentspan {@link Agent}. */ + /** Drop-in: accepts a native ADK {@code BaseAgent} or any Conductor {@link Agent}. */ public AgentHandle start(Object agent, String prompt) { return start(coerceAgent(agent), prompt); } - /** Drop-in: accepts a native ADK {@code BaseAgent} or any Agentspan {@link Agent}. */ + /** Drop-in: accepts a native ADK {@code BaseAgent} or any Conductor {@link Agent}. */ public CompletableFuture startAsync(Object agent, String prompt) { return startAsync(coerceAgent(agent), prompt); } - /** Drop-in: accepts a native ADK {@code BaseAgent} or any Agentspan {@link Agent}. */ + /** Drop-in: accepts a native ADK {@code BaseAgent} or any Conductor {@link Agent}. */ public AgentStream stream(Object agent, String prompt) { return stream(coerceAgent(agent), prompt); } - /** Drop-in: accepts a native ADK {@code BaseAgent} or any Agentspan {@link Agent}. */ + /** Drop-in: accepts a native ADK {@code BaseAgent} or any Conductor {@link Agent}. */ public CompletableFuture streamAsync(Object agent, String prompt) { return streamAsync(coerceAgent(agent), prompt); } - /** Drop-in: accepts native ADK {@code BaseAgent} instances (or Agentspan {@link Agent}s). */ + /** Drop-in: accepts native ADK {@code BaseAgent} instances (or Conductor {@link Agent}s). */ public List deploy(Object... agents) { return deploy(coerceAgents(agents)); } - /** Drop-in: accepts native ADK {@code BaseAgent} instances (or Agentspan {@link Agent}s). */ + /** Drop-in: accepts native ADK {@code BaseAgent} instances (or Conductor {@link Agent}s). */ public CompletableFuture> deployAsync(Object... agents) { return deployAsync(coerceAgents(agents)); } - /** Drop-in: accepts native ADK {@code BaseAgent} instances (or Agentspan {@link Agent}s). */ + /** Drop-in: accepts native ADK {@code BaseAgent} instances (or Conductor {@link Agent}s). */ public void serve(Object... agents) { serve(true, coerceAgents(agents)); } @@ -678,17 +672,17 @@ public void serve(Boolean blocking, Object... agents) { serve(blocking, coerceAgents(agents)); } - /** Drop-in: accepts a native ADK {@code BaseAgent} or any Agentspan {@link Agent}. */ + /** Drop-in: accepts a native ADK {@code BaseAgent} or any Conductor {@link Agent}. */ public CompileResponse plan(Object agent) { return plan(coerceAgent(agent)); } - /** Drop-in: accepts a native ADK {@code BaseAgent} or any Agentspan {@link Agent}. */ + /** Drop-in: accepts a native ADK {@code BaseAgent} or any Conductor {@link Agent}. */ public AgentHandle resume(String executionId, Object agent) { return resume(executionId, coerceAgent(agent)); } - /** Drop-in: accepts a native ADK {@code BaseAgent} or any Agentspan {@link Agent}. */ + /** Drop-in: accepts a native ADK {@code BaseAgent} or any Conductor {@link Agent}. */ public CompletableFuture resumeAsync(String executionId, Object agent) { return resumeAsync(executionId, coerceAgent(agent)); } @@ -747,7 +741,7 @@ private static Object[] withoutRunSettings(Object[] tools) { } /** - * Coerce a user-provided agent object to an Agentspan {@link Agent}. + * Coerce a user-provided agent object to a Conductor {@link Agent}. * *

      Supports {@link Agent} (returned as-is) and these native framework objects, each * detected by fully-qualified-name so the core class never hard-references a @@ -771,7 +765,7 @@ private static Agent coerceAgent(Object agent, Object[] tools) { return a; } if (isInstanceOf(agent, "com.google.adk.agents.BaseAgent")) { - return org.conductoross.conductor.ai.frameworks.AdkBridge.toAgentspan( + return org.conductoross.conductor.ai.frameworks.AdkBridge.toConductor( (com.google.adk.agents.BaseAgent) agent); } if (isInstanceOf(agent, "dev.langchain4j.model.chat.ChatModel")) { @@ -931,6 +925,7 @@ public void prepareWorkers(Agent agent) { if ("agent_tool".equals(tool.getToolType()) && tool.getAgentRef() != null) { prepareWorkers(tool.getAgentRef()); } + registerLocalGuardrailWorker(tool.getName(), tool.getGuardrails()); } // Register callback workers (legacy single-function style) @@ -997,44 +992,9 @@ public void prepareWorkers(Agent agent) { } } - // Register combined guardrail worker per agent (matches Python: {agent_name}_output_guardrail) - List customGuardrails = - agent.getGuardrails().stream().filter(g -> g.getFunc() != null).collect(Collectors.toList()); - if (!customGuardrails.isEmpty()) { - String taskName = agent.getName() + "_output_guardrail"; - workerManager.register(taskName, inputData -> { - Object rawContent = inputData.get("content"); - String content = rawContent != null ? rawContent.toString() : ""; - int iteration = inputData.get("iteration") instanceof Number - ? ((Number) inputData.get("iteration")).intValue() - : 0; - for (GuardrailDef g : customGuardrails) { - GuardrailResult result = g.getFunc().apply(content); - if (!result.isPassed()) { - String onFail = g.getOnFail().toJsonValue(); - String fixedOutput = result.getFixedOutput(); - if ("retry".equals(onFail) && iteration >= g.getMaxRetries()) onFail = "raise"; - if ("fix".equals(onFail) && fixedOutput == null) onFail = "raise"; - Map out = new LinkedHashMap<>(); - out.put("passed", false); - out.put("message", result.getMessage() != null ? result.getMessage() : ""); - out.put("on_fail", onFail); - out.put("fixed_output", fixedOutput); - out.put("guardrail_name", g.getName()); - out.put("should_continue", "retry".equals(onFail)); - return out; - } - } - Map out = new LinkedHashMap<>(); - out.put("passed", true); - out.put("message", ""); - out.put("on_fail", "pass"); - out.put("fixed_output", null); - out.put("guardrail_name", ""); - out.put("should_continue", false); - return out; - }); - } + // A local function owns its guardrail worker; regex, LLM and external guardrails + // remain server- or externally-owned. Agent and tool workers share the same protocol. + registerLocalGuardrailWorker(agent.getName(), agent.getGuardrails()); // Register termination condition worker for agents that have one if (agent.getTermination() != null) { @@ -1110,6 +1070,24 @@ public void prepareWorkers(Agent agent) { } } + private void registerLocalGuardrailWorker(String scopeName, List guardrails) { + List localGuardrails = guardrails.stream() + .filter(guardrail -> guardrail.getFunc() != null) + .collect(Collectors.toList()); + if (!localGuardrails.isEmpty()) { + workerManager.register(scopeName + "_output_guardrail", GuardrailHandlerFactory.create(localGuardrails)); + } + } + + // Package-visible test hooks. They expose registration state only, not worker mutation. + boolean isWorkerRegisteredForTest(String taskName) { + return workerManager.isRegistered(taskName); + } + + String workerDomainForTest(String taskName) { + return workerManager.getRegisteredTaskDomain(taskName); + } + /** * Evaluate a termination condition given the current LLM result and iteration count. * @@ -1368,7 +1346,7 @@ private Map executeCode(String language, String code, int timeou interpreter = language; } // Write code to temp file - File tmpFile = File.createTempFile("agentspan_code_", language.startsWith("python") ? ".py" : ".sh"); + File tmpFile = File.createTempFile("conductor_agent_code_", language.startsWith("python") ? ".py" : ".sh"); tmpFile.deleteOnExit(); try (FileWriter fw = new FileWriter(tmpFile)) { fw.write(code); diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/RunSettings.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/RunSettings.java index e00de4aa3..5fe0420b7 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/RunSettings.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/RunSettings.java @@ -16,13 +16,14 @@ import java.util.Map; /** - * Per-invocation LLM overrides applied on top of an {@link Agent}'s settings. + * Per-invocation settings applied when an {@link Agent} is executed. * *

      Pass to {@code run}/{@code start}/{@code stream} (and their async - * variants). Only non-{@code null} fields override the agent; everything else - * is left as the agent defined it. Overrides mutate the serialized root - * agent config before compile+register+start, so they flow into the LLM tasks - * without a new server field — sub-agents keep their own settings. + * variants). LLM fields override the serialized root agent config before + * compile+register+start, while execution metadata such as {@link + * #idempotencyKey(String)} is sent at the top level of the start request. + * Unset fields leave the agent and request unchanged; sub-agents keep their own + * settings. * *

      Example: *

      {@code
      @@ -39,6 +40,7 @@ public class RunSettings {
           private Integer maxTokens;
           private String reasoningEffort;
           private Integer thinkingBudgetTokens;
      +    private String idempotencyKey;
       
           /** Provider/model id (e.g. {@code "openai/gpt-4o"}). */
           public RunSettings model(String model) {
      @@ -70,6 +72,16 @@ public RunSettings thinkingBudgetTokens(Integer thinkingBudgetTokens) {
               return this;
           }
       
      +    /**
      +     * Stable key used by the server to deduplicate starts of the same logical
      +     * execution. The runtime omits {@code null}, empty, and whitespace-only
      +     * values; it never generates a key automatically.
      +     */
      +    public RunSettings idempotencyKey(String idempotencyKey) {
      +        this.idempotencyKey = idempotencyKey;
      +        return this;
      +    }
      +
           public String getModel() {
               return model;
           }
      @@ -90,11 +102,16 @@ public Integer getThinkingBudgetTokens() {
               return thinkingBudgetTokens;
           }
       
      +    public String getIdempotencyKey() {
      +        return idempotencyKey;
      +    }
      +
           /**
      -     * Map the set fields to {@code agentConfig} wire keys — mirrors the Python
      -     * SDK's {@code RunSettings.to_config_overrides} so wire-key names match
      -     * across SDKs. Uses {@code != null} so {@code temperature(0.0)} and
      -     * {@code maxTokens(0)} are honored.
      +     * Map only the LLM override fields to {@code agentConfig} wire keys —
      +     * mirrors the Python SDK's {@code RunSettings.to_config_overrides} so
      +     * wire-key names match across SDKs. Execution metadata such as the
      +     * idempotency key is deliberately excluded. Uses {@code != null} so
      +     * {@code temperature(0.0)} and {@code maxTokens(0)} are honored.
            */
           public Map toConfigOverrides() {
               Map overrides = new LinkedHashMap<>();
      diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/enums/Framework.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/enums/Framework.java
      index e58434834..ceb461ff6 100644
      --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/enums/Framework.java
      +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/enums/Framework.java
      @@ -22,7 +22,7 @@
        *
        * 

      The server routes each framework through a matching {@code AgentConfigNormalizer} * before compilation. Every value here corresponds to a {@code frameworkId()} in the - * server's normalizer registry. Native Agentspan agents have no framework — their + * server's normalizer registry. Native Conductor agents have no framework — their * config is sent as {@code agentConfig}. * *

      Known server normalizers and their IDs: diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/exceptions/CredentialNotFoundException.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/exceptions/CredentialNotFoundException.java index 6597b3421..be2adbd89 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/exceptions/CredentialNotFoundException.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/exceptions/CredentialNotFoundException.java @@ -14,7 +14,7 @@ import java.util.List; -import io.orkes.conductor.client.exceptions.AgentspanException; +import io.orkes.conductor.client.exceptions.AgentException; /** * One or more declared credentials could not be resolved from the server. @@ -26,7 +26,7 @@ *

      Mirrors Python's {@code CredentialNotFoundError} and .NET's * {@code CredentialNotFoundException}.

      */ -public class CredentialNotFoundException extends AgentspanException { +public class CredentialNotFoundException extends AgentException { private final List missingNames; diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/exceptions/WorkerStallError.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/exceptions/WorkerStallError.java index 5a49df431..702d14d3a 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/exceptions/WorkerStallError.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/exceptions/WorkerStallError.java @@ -12,7 +12,7 @@ */ package org.conductoross.conductor.ai.exceptions; -import io.orkes.conductor.client.exceptions.AgentspanException; +import io.orkes.conductor.client.exceptions.AgentException; /** * A worker task in a stateful run sat {@code SCHEDULED} with zero polls beyond @@ -21,7 +21,7 @@ * is a stateful run whose per-execution domain has no live worker (e.g. the * owning process died or never registered under that domain). */ -public class WorkerStallError extends AgentspanException { +public class WorkerStallError extends AgentException { private final String taskReferenceName; private final String executionId; diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/execution/DockerCodeExecutor.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/execution/DockerCodeExecutor.java index fad7cf54c..486826c76 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/execution/DockerCodeExecutor.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/execution/DockerCodeExecutor.java @@ -55,7 +55,7 @@ public ExecutionResult execute(String code) { Path tempFile = null; try { String extension = getExtension(language); - tempFile = Files.createTempFile("agentspan_code_", extension); + tempFile = Files.createTempFile("conductor_agent_code_", extension); Files.writeString(tempFile, code); String containerPath = "/tmp/code" + extension; diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/execution/LocalCodeExecutor.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/execution/LocalCodeExecutor.java index 2ddce069d..e29edf3a3 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/execution/LocalCodeExecutor.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/execution/LocalCodeExecutor.java @@ -75,7 +75,7 @@ public ExecutionResult execute(String code) { Path tempFile = null; try { - tempFile = Files.createTempFile("agentspan_code_", fileExtension(language)); + tempFile = Files.createTempFile("conductor_agent_code_", fileExtension(language)); Files.writeString(tempFile, code); List command = new ArrayList<>(interpreter); diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/frameworks/AdkBridge.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/frameworks/AdkBridge.java index 81ade86e8..1182f8b32 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/frameworks/AdkBridge.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/frameworks/AdkBridge.java @@ -47,7 +47,7 @@ /** * Adapter that takes a native Google ADK {@link BaseAgent} and produces an - * Agentspan {@link Agent} configured so the durable Agentspan runtime can + * Conductor {@link Agent} configured so the durable agent runtime can * execute the agent server-side. * *

      This bridge extracts every field the server's @@ -74,7 +74,7 @@ *

    * *

    Symmetry with the Python {@code google.adk} serializer in - * {@code sdk/python/src/agentspan/agents/frameworks/serializer.py} — both walk + * {@code sdk/python/src/agent_sdk/frameworks/serializer.py} — both walk * the native agent tree and emit the same wire shape consumed by the server's * {@code GoogleADKNormalizer}. */ @@ -99,15 +99,15 @@ private AdkBridge() {} /** * Convert any native ADK {@link BaseAgent} ({@code LlmAgent}, * {@code SequentialAgent}, {@code ParallelAgent}, {@code LoopAgent}, …) - * into an Agentspan {@link Agent} ready for {@code runtime.run(...)}. + * into a Conductor {@link Agent} ready for {@code runtime.run(...)}. */ - public static Agent toAgentspan(BaseAgent adk) { + public static Agent toConductor(BaseAgent adk) { return agentBuilder(adk).build(); } /** - * Same as {@link #toAgentspan} but returns the populated - * {@link Agent.Builder} so callers can attach Agentspan-only features + * Same as {@link #toConductor} but returns the populated + * {@link Agent.Builder} so callers can attach Conductor-specific features * (guardrails, gate, termination conditions, callbacks, …) on top of a * native ADK agent before building. * @@ -125,7 +125,7 @@ public static Agent.Builder agentBuilder(BaseAgent adk) { return agentBuilder(adk, new java.util.IdentityHashMap<>()); } - private static Agent toAgentspan(BaseAgent adk, java.util.IdentityHashMap visited) { + private static Agent toConductor(BaseAgent adk, java.util.IdentityHashMap visited) { return agentBuilder(adk, visited).build(); } @@ -137,7 +137,7 @@ private static Agent.Builder agentBuilder(BaseAgent adk, java.util.IdentityHashM Agent.Builder b = Agent.builder().name(adk.name()).framework("google_adk"); - // Model + instruction live at the Agentspan top level so the + // Model + instruction live at the Conductor agent top level so the // worker poller / debug tools see them directly. Everything else // goes into frameworkConfig (flattened by AgentConfigSerializer into // the rawConfig the server consumes). @@ -155,13 +155,13 @@ private static Agent.Builder agentBuilder(BaseAgent adk, java.util.IdentityHashM // sub_agents Map list below. List subAgentChildren = new ArrayList<>(); for (BaseAgent sub : safeSubAgents(adk)) { - subAgentChildren.add(toAgentspan(sub, visited)); + subAgentChildren.add(toConductor(sub, visited)); } if (!subAgentChildren.isEmpty()) { b.agents(subAgentChildren.toArray(new Agent[0])); } - // Callbacks: wrap ADK callbacks as an Agentspan CallbackHandler so the + // Callbacks: wrap ADK callbacks as a Conductor CallbackHandler so the // runtime registers worker handlers and the server schedules hook tasks // at the right positions. Best-effort — contexts are stubbed; see // wrapCallbacks() for the constraint matrix. @@ -273,7 +273,7 @@ private static Map buildRawConfig( // Callbacks: emit `_worker_ref` placeholders for each non-empty // callback list. The matching CallbackHandler attached on the - // Agent.Builder (see toAgentspan) registers the local worker so + // Agent.Builder (see toConductor) registers the local worker so // any server-scheduled hook task lands somewhere. // // KNOWN SERVER LIMITATION (matches Python — see python ADK @@ -335,7 +335,7 @@ private static String extractInstruction(Instruction inst) { /** * Top-level tools — extracted from {@code LlmAgent.tools()} and wrapped as - * {@link ToolDef} so the Agentspan worker poller registers handlers AND + * {@link ToolDef} so the Conductor worker poller registers handlers AND * the serializer emits the expected {@code _worker_ref} / {@code _type: * AgentTool} wire shape. */ @@ -482,9 +482,9 @@ private static ToolDef functionToolToDef(FunctionTool ft) { private static ToolDef agentToolToDef(AgentTool at, java.util.IdentityHashMap visited) { BaseAgent inner = at.getAgent(); - Agent childAgent = toAgentspan(inner, visited); + Agent childAgent = toConductor(inner, visited); // AgentTool produces an empty input schema in ADK by default; the - // Agentspan serializer's AgentTool path builds a stock {request: + // The Conductor serializer's AgentTool path builds a stock {request: // string} schema for us. return new ToolDef.Builder() .name(at.name()) @@ -657,7 +657,7 @@ private static boolean callbackListIsNonEmpty(LlmAgent llm, String getter) { /** * Wrap any ADK callbacks attached to {@code llm} as a single - * {@link CallbackHandler} that the Agentspan runtime can dispatch to. + * {@link CallbackHandler} that the Conductor agent runtime can dispatch to. * *

    Returns {@code null} if no callbacks are attached. * diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/frameworks/LangChain4jAgent.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/frameworks/LangChain4jAgent.java index 4f8dc07eb..1888c513f 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/frameworks/LangChain4jAgent.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/frameworks/LangChain4jAgent.java @@ -25,7 +25,7 @@ import org.conductoross.conductor.ai.model.ToolDef; /** - * Bridges LangChain4j tool objects to Agentspan {@link Agent}. + * Bridges LangChain4j tool objects to Conductor {@link Agent}. * *

    Users who have existing POJOs annotated with * {@code @dev.langchain4j.agent.tool.Tool} can hand them directly to @@ -55,13 +55,13 @@ private LangChain4jAgent() {} // ── Public API ─────────────────────────────────────────────────────────── /** - * Create an Agentspan {@link Agent} from one or more LangChain4j tool objects. + * Create a Conductor {@link Agent} from one or more LangChain4j tool objects. * * @param name agent name (must match {@code ^[a-zA-Z_][a-zA-Z0-9_-]*$}) * @param model LLM model string, e.g. {@code "anthropic/claude-sonnet-4-6"} * @param instructions system prompt / instructions for the agent * @param toolObjects objects with {@code @dev.langchain4j.agent.tool.Tool} methods - * @return an Agentspan Agent ready to pass to + * @return a Conductor Agent ready to pass to * {@link org.conductoross.conductor.ai.AgentRuntime#plan(Agent)} or * {@link org.conductoross.conductor.ai.AgentRuntime#run(Agent, String)} */ @@ -99,7 +99,7 @@ public static boolean isLangChain4jTools(Object obj) { // ── Package-private helpers (visible to tests) ─────────────────────────── /** - * Extract Agentspan {@link ToolDef} objects from an array of LangChain4j + * Extract Conductor {@link ToolDef} objects from an array of LangChain4j * {@code @Tool}-annotated objects. * * @param toolObjects objects to inspect via reflection diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/frameworks/LangChainBridge.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/frameworks/LangChainBridge.java index da57d4209..309a6d88a 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/frameworks/LangChainBridge.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/frameworks/LangChainBridge.java @@ -19,10 +19,10 @@ /** * Adapter that takes native LangChain4j components (a {@link ChatModel} and - * {@code @Tool}-annotated POJOs) and produces an Agentspan {@link Agent}. + * {@code @Tool}-annotated POJOs) and produces a Conductor {@link Agent}. * *

    The model object is used only to extract the {@code provider/model} string - * that the Agentspan server needs — Agentspan owns the LLM call and the + * that the Conductor server needs — it owns the LLM call and the * credentials live on the server, so the client never invokes the local * {@link ChatModel} directly. * @@ -34,9 +34,9 @@ public final class LangChainBridge { private LangChainBridge() {} /** - * Build an Agentspan {@link Agent.Builder} from a native LangChain4j + * Build a Conductor {@link Agent.Builder} from a native LangChain4j * {@link ChatModel} and {@code @Tool}-annotated POJOs. Returning the - * Builder lets callers attach Agentspan-only features (guardrails, + * Builder lets callers attach Conductor-specific features (guardrails, * gate, termination, callbacks) before {@code .build()}: * *

    {@code
    @@ -64,7 +64,7 @@ public static Agent.Builder agentBuilder(String name, ChatModel model, String sy
     
         /**
          * Map a LangChain4j {@link ChatModel} to the {@code provider/model} string
    -     * format expected by the Agentspan server (e.g. {@code anthropic/claude-sonnet-4-6}).
    +     * format expected by the Conductor server (e.g. {@code anthropic/claude-sonnet-4-6}).
          *
          * 

    The provider id is read from {@link ChatModel#provider()} and the model * name from {@code defaultRequestParameters().modelName()}; both are part of diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/frameworks/OpenAIAgent.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/frameworks/OpenAIAgent.java index 061934e33..14ac08482 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/frameworks/OpenAIAgent.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/frameworks/OpenAIAgent.java @@ -25,7 +25,7 @@ import org.conductoross.conductor.ai.model.ToolDef; /** - * Bridges the OpenAI Agents SDK shape to Agentspan {@link Agent}. + * Bridges the OpenAI Agents SDK shape to Conductor {@link Agent}. * *

    Mirrors the Python pattern: *

    {@code
    @@ -49,7 +49,7 @@
      * without a provider prefix are auto-prefixed with {@code openai/} server-side.
      *
      * 

    Local @Tool-annotated POJOs are wrapped using the same reflection bridge - * as {@link LangChain4jAgent} — they are registered as Agentspan worker tools + * as {@link LangChain4jAgent} — they are registered as Conductor worker tools * and the OpenAI Agents server-side runner calls them via the standard tool-call * dispatch. */ @@ -84,7 +84,7 @@ public Builder instructions(String instructions) { return this; } - /** Add @Tool-annotated POJO(s); each annotated method becomes an Agentspan worker tool. */ + /** Add @Tool-annotated POJO(s); each annotated method becomes a Conductor worker tool. */ public Builder tools(Object... toolObjects) { this.tools.addAll(extractTools(toolObjects)); return this; diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/guardrail/Guardrail.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/guardrail/Guardrail.java index eb4bdf8f6..6202d8e68 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/guardrail/Guardrail.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/guardrail/Guardrail.java @@ -90,12 +90,6 @@ public Builder maxRetries(int maxRetries) { } public GuardrailDef build() { - // on_fail=HUMAN is only valid for output guardrails — input guardrails - // are client-side and cannot pause a workflow (parity with Python's ValueError). - if (onFail == OnFail.HUMAN && position == Position.INPUT) { - throw new IllegalArgumentException("onFail=HUMAN is only valid for position=OUTPUT " - + "(input guardrails are client-side and cannot pause a workflow)"); - } String guardrailType = isExternal ? "external" : "custom"; return GuardrailDef.builder() .name(name) diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/AgentConfigSerializer.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/AgentConfigSerializer.java index 20927c8b1..34d7808db 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/AgentConfigSerializer.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/AgentConfigSerializer.java @@ -120,7 +120,7 @@ private Map serializeAgent(Agent agent) { map.put("tools", toolsList); } // Guardrails — emit so framework normalizers can preserve - // Agentspan-side safety hooks. Without this, attaching + // Server-side safety hooks. Without this, attaching // .guardrails(...) to a bridged ADK / OpenAI agent silently // drops them at the wire layer. if (agent.getGuardrails() != null && !agent.getGuardrails().isEmpty()) { diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/GuardrailHandlerFactory.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/GuardrailHandlerFactory.java new file mode 100644 index 000000000..6b1d525e0 --- /dev/null +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/GuardrailHandlerFactory.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025 Conductor Authors. + *

    + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

    + * http://www.apache.org/licenses/LICENSE-2.0 + *

    + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package org.conductoross.conductor.ai.internal; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.conductoross.conductor.ai.model.GuardrailDef; +import org.conductoross.conductor.ai.model.GuardrailResult; + +/** Creates the common worker handler used by agent- and tool-scoped local guardrails. */ +public final class GuardrailHandlerFactory { + private GuardrailHandlerFactory() {} + + /** + * Creates a handler which evaluates local guardrails in declaration order and returns the + * first failure. Exceptions from user functions deliberately propagate to the worker runner, + * where they are reported as failed tasks. + */ + public static Function, Object> create(List guardrails) { + List localGuardrails = List.copyOf(guardrails); + return inputData -> { + Object rawContent = inputData.get("content"); + String content = rawContent != null ? rawContent.toString() : ""; + int iteration = inputData.get("iteration") instanceof Number + ? ((Number) inputData.get("iteration")).intValue() + : 0; + for (GuardrailDef guardrail : localGuardrails) { + GuardrailResult result = guardrail.getFunc().apply(content); + if (result == null) { + throw new IllegalStateException("Guardrail '" + guardrail.getName() + "' returned null"); + } + if (!result.isPassed()) { + String onFail = guardrail.getOnFail().toJsonValue(); + String fixedOutput = result.getFixedOutput(); + if ("retry".equals(onFail) && iteration >= guardrail.getMaxRetries()) onFail = "raise"; + if ("fix".equals(onFail) && fixedOutput == null) onFail = "raise"; + Map out = new LinkedHashMap<>(); + out.put("passed", false); + out.put("message", result.getMessage() != null ? result.getMessage() : ""); + out.put("on_fail", onFail); + out.put("fixed_output", fixedOutput); + out.put("guardrail_name", guardrail.getName()); + out.put("should_continue", "retry".equals(onFail)); + return out; + } + } + Map out = new LinkedHashMap<>(); + out.put("passed", true); + out.put("message", ""); + out.put("on_fail", "pass"); + out.put("fixed_output", null); + out.put("guardrail_name", ""); + out.put("should_continue", false); + return out; + }; + } +} diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/ServerLivenessMonitor.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/ServerLivenessMonitor.java index ff35c80d8..40f2d0b59 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/ServerLivenessMonitor.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/ServerLivenessMonitor.java @@ -63,7 +63,7 @@ public ServerLivenessMonitor( /** Start the background check thread (no-op transport errors, see class doc). */ public void start() { - Thread t = new Thread(this::loop, "agentspan-liveness-" + executionId); + Thread t = new Thread(this::loop, "conductor-agent-liveness-" + executionId); t.setDaemon(daemon); t.start(); thread = t; diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/WorkerManager.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/WorkerManager.java index 3e51bc798..86d8e3c4e 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/WorkerManager.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/WorkerManager.java @@ -44,7 +44,7 @@ * here returns {@code leaseExtendEnabled() == true}). A handler that blocks for * minutes keeps its lease alive instead of being reclaimed and re-dispatched. * - *

    Agentspan registers workers incrementally (per run, sometimes under a + *

    The agent runtime registers workers incrementally (per run, sometimes under a * per-execution domain), whereas a {@link TaskRunnerConfigurer} is built from a * fixed worker set. We bridge the two models by (re)building the configurer in * {@link #startAll()} whenever a new task type has been registered since @@ -60,7 +60,7 @@ * *

    Credentials ride the {@code runtimeMetadata} contract (spec R6): declared * secret names are stamped on {@code TaskDef.runtimeMetadata} at registration; - * a capable host (agentspan > 0.4.2, conductor-oss PR #1255) resolves them at + * a capable Conductor OSS host (PR #1255) resolves them at * poll time and delivers the values on the wire-only * {@code Task.runtimeMetadata} map. There is no fetch call, no execution token, * and ambient process env is never read — a declared-but-undelivered name fails @@ -172,6 +172,16 @@ String getTaskDomain(String taskName) { return taskDomains.get(taskName); } + /** Whether a handler has been registered for the supplied task type. */ + public boolean isRegistered(String taskName) { + return handlers.containsKey(taskName); + } + + /** Returns the worker domain assigned during registration, if any. */ + public String getRegisteredTaskDomain(String taskName) { + return taskDomains.get(taskName); + } + /** Visible for testing: whether a runner (re)build is pending. */ boolean isWorkerSetChanged() { synchronized (lifecycleLock) { @@ -331,7 +341,7 @@ public void startAll() { TaskRunnerConfigurer.Builder builder = new TaskRunnerConfigurer.Builder(taskClient, workers) .withThreadCount(threadCount) - .withWorkerNamePrefix("agentspan-worker-"); + .withWorkerNamePrefix("conductor-agent-worker-"); if (!taskToDomain.isEmpty()) { builder.withTaskToDomain(taskToDomain); } @@ -421,7 +431,7 @@ TaskResult executeHandler(String taskName, Task task) { result.setReasonForIncompletion("Missing credentials " + missing + ": not delivered by the server on this task. Ensure each secret is stored and " + "declared on the tool/agent, and that the server supports runtimeMetadata " - + "delivery (agentspan > 0.4.2 / conductor-oss PR #1255)."); + + "delivery (Conductor OSS with PR #1255)."); return result; } resolvedSecrets = resolved; @@ -440,7 +450,7 @@ TaskResult executeHandler(String taskName, Task task) { Object out = handler.apply(inputData); result.setStatus(TaskResult.Status.COMPLETED); result.setOutputData(buildOutput(out)); - logger.debug("Completed task {} ({})", taskName, task.getTaskId()); + logger.trace("Completed task {} ({})", taskName, task.getTaskId()); } finally { CredentialContext.clear(); } diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/AgentEvent.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/AgentEvent.java index 654e4ad68..1ac971dbf 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/AgentEvent.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/AgentEvent.java @@ -154,7 +154,7 @@ public List getPendingToolCalls() { */ /** Internal keys injected by the server that should not be shown as tool arguments. */ private static final Set INTERNAL_KEYS = - new HashSet<>(Arrays.asList("__agentspan_ctx__", "_agent_state", "method")); + new HashSet<>(Arrays.asList("_agent_state", "method")); @SuppressWarnings("unchecked") public static AgentEvent fromMap(Map data) { diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/AgentHandle.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/AgentHandle.java index 8c21961ea..9e631278e 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/AgentHandle.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/AgentHandle.java @@ -398,7 +398,7 @@ private static TaskExtract extractFromTasks(Workflow workflow) { } // Tool worker task — capture name, input args (stripping - // internal Agentspan context), and output result. + // internal runtime fields), and output result. // referenceTaskName starts with "call_" for LLM-dispatched tool calls. String refName = task.getReferenceTaskName(); if (refName != null && refName.startsWith("call_") && outputData != null) { @@ -411,7 +411,6 @@ private static TaskExtract extractFromTasks(Workflow workflow) { String k = e.getKey(); if (k.startsWith("_") || "method".equals(k) - || "__agentspan_ctx__".equals(k) || "evaluatorType".equals(k) || "expression".equals(k) || "ctx".equals(k) @@ -435,8 +434,7 @@ private static TaskExtract extractFromTasks(Workflow workflow) { * Build an {@link AgentResult} from a terminal {@link Workflow}. * *

    Shared workflow → {@link AgentResult} extraction used by callers that - * already hold a completed {@link Workflow} (e.g. the scheduler's - * {@code runNowAndWait}). Maps the workflow status to an {@link AgentStatus}, + * already hold a completed {@link Workflow}. Maps the workflow status to an {@link AgentStatus}, * normalizes the output map, surfaces {@code reasonForIncompletion} as the * error for non-completed runs, and reuses {@link #extractFromTasks} for the * token-usage and tool-call aggregation. diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/GuardrailDef.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/GuardrailDef.java index 6c739e062..de6dcbd1e 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/GuardrailDef.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/GuardrailDef.java @@ -120,9 +120,15 @@ public Builder config(Map config) { } public GuardrailDef build() { - if (name == null || name.isEmpty()) { + if (name == null || name.trim().isEmpty()) { throw new IllegalArgumentException("GuardrailDef requires a name"); } + if (maxRetries < 0) { + throw new IllegalArgumentException("GuardrailDef maxRetries must be nonnegative"); + } + if (onFail == OnFail.HUMAN && position != Position.OUTPUT) { + throw new IllegalArgumentException("onFail=HUMAN is only valid for position=OUTPUT"); + } return new GuardrailDef(this); } } diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/ToolDef.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/ToolDef.java index 7174a4beb..e21924fe6 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/ToolDef.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/ToolDef.java @@ -138,6 +138,33 @@ public boolean isStateful() { return stateful; } + /** + * Returns a copy of this tool with the supplied guardrails. Every execution and + * serialization field, including credentials, retries, statefulness, configuration and + * agent-tool references, is preserved. + */ + public ToolDef withGuardrails(List guardrails) { + return builder() + .name(name) + .description(description) + .inputSchema(inputSchema) + .outputSchema(outputSchema) + .func(func) + .approvalRequired(approvalRequired) + .timeoutSeconds(timeoutSeconds) + .retryCount(retryCount) + .retryDelaySeconds(retryDelaySeconds) + .retryPolicy(retryPolicy) + .toolType(toolType) + .config(config) + .credentials(credentials) + .guardrails(List.copyOf(guardrails)) + .maxCalls(maxCalls) + .agentRef(agentRef) + .stateful(stateful) + .build(); + } + public static Builder builder() { return new Builder(); } diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/openai/GPTAssistantAgent.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/openai/GPTAssistantAgent.java index 6527dbccb..079ac99eb 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/openai/GPTAssistantAgent.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/openai/GPTAssistantAgent.java @@ -29,7 +29,7 @@ * An agent backed by the OpenAI Assistants API. * *

    Wraps an OpenAI Assistant (with its own instructions, tools, and file search - * capabilities) as an Agentspan Agent. The assistant's execution is handled via the + * capabilities) as a Conductor Agent. The assistant's execution is handled via the * Assistants API Threads and Runs. * *

    {@code
    diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/plans/Plan.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/plans/Plan.java
    index 810d639c4..0323e5921 100644
    --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/plans/Plan.java
    +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/plans/Plan.java
    @@ -30,7 +30,7 @@
      * and run a fully deterministic pipeline.
      *
      * 

    The {@code toJson()} output is the wire format PAC consumes — - * identical to what the Python {@code agentspan.agents.plans.Plan} and + * identical to the Python agent SDK's {@code Plan} and * TypeScript {@code Plan} emit. */ public final class Plan { diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/schedule/Schedule.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/schedule/Schedule.java deleted file mode 100644 index a2810212d..000000000 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/schedule/Schedule.java +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

    - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

    - * http://www.apache.org/licenses/LICENSE-2.0 - *

    - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.ai.schedule; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * A cron trigger attached to an agent. Mirrors {@code Schedule} in the Python / - * TypeScript SDKs. See {@code docs/design/scheduling.md}. - * - *

    One agent can carry multiple schedules; each is identified by a {@code name} - * unique within that agent. The SDK auto-prefixes the wire name as - * {@code {agent.name}-{name}} so Conductor's org-wide uniqueness is satisfied. - * - *

    Construct via {@link #builder()}. - */ -public final class Schedule { - - private final String name; - private final String cron; - private final String timezone; - private final Map input; - private final boolean catchup; - private final boolean paused; - private final Long startAt; - private final Long endAt; - private final String description; - - private Schedule(Builder b) { - if (b.name == null || b.name.trim().isEmpty()) { - throw new ScheduleException("Schedule.name is required and must be non-empty"); - } - if (b.cron == null || b.cron.trim().isEmpty()) { - throw new ScheduleException("Schedule.cron is required and must be non-empty"); - } - if (b.startAt != null && b.endAt != null && b.startAt >= b.endAt) { - throw new ScheduleException("Schedule.startAt must be < endAt"); - } - this.name = b.name; - this.cron = b.cron; - this.timezone = b.timezone != null ? b.timezone : "UTC"; - this.input = b.input == null ? Collections.emptyMap() : new LinkedHashMap<>(b.input); - this.catchup = b.catchup; - this.paused = b.paused; - this.startAt = b.startAt; - this.endAt = b.endAt; - this.description = b.description; - } - - public static Builder builder() { - return new Builder(); - } - - public String getName() { - return name; - } - - public String getCron() { - return cron; - } - - public String getTimezone() { - return timezone; - } - - public Map getInput() { - return input; - } - - public boolean isCatchup() { - return catchup; - } - - public boolean isPaused() { - return paused; - } - - public Long getStartAt() { - return startAt; - } - - public Long getEndAt() { - return endAt; - } - - public String getDescription() { - return description; - } - - public static final class Builder { - private String name; - private String cron; - private String timezone; - private Map input; - private boolean catchup; - private boolean paused; - private Long startAt; - private Long endAt; - private String description; - - public Builder name(String v) { - this.name = v; - return this; - } - - public Builder cron(String v) { - this.cron = v; - return this; - } - - public Builder timezone(String v) { - this.timezone = v; - return this; - } - - public Builder input(Map v) { - this.input = v; - return this; - } - - public Builder catchup(boolean v) { - this.catchup = v; - return this; - } - - public Builder paused(boolean v) { - this.paused = v; - return this; - } - - public Builder startAt(Long v) { - this.startAt = v; - return this; - } - - public Builder endAt(Long v) { - this.endAt = v; - return this; - } - - public Builder description(String v) { - this.description = v; - return this; - } - - public Schedule build() { - return new Schedule(this); - } - } -} diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/schedule/ScheduleException.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/schedule/ScheduleException.java deleted file mode 100644 index 75be2b8fc..000000000 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/schedule/ScheduleException.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

    - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

    - * http://www.apache.org/licenses/LICENSE-2.0 - *

    - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.ai.schedule; - -/** Base class for schedule errors. */ -public class ScheduleException extends RuntimeException { - public ScheduleException(String message) { - super(message); - } - - public ScheduleException(String message, Throwable cause) { - super(message, cause); - } - - /** Two schedules in the same agent share a name. */ - public static class NameConflict extends ScheduleException { - public NameConflict(String message) { - super(message); - } - } - - /** No schedule matches the given name. */ - public static class NotFound extends ScheduleException { - public NotFound(String message) { - super(message); - } - } - - /** Server rejected the cron expression as malformed. */ - public static class InvalidCron extends ScheduleException { - public InvalidCron(String message) { - super(message); - } - } - - /** A {@code runNow(..., wait=true)} workflow did not finish within the timeout. */ - public static class Timeout extends ScheduleException { - public Timeout(String message) { - super(message); - } - } -} diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/schedule/ScheduleInfo.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/schedule/ScheduleInfo.java deleted file mode 100644 index 0a9e5861f..000000000 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/schedule/ScheduleInfo.java +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

    - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

    - * http://www.apache.org/licenses/LICENSE-2.0 - *

    - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.ai.schedule; - -import java.util.Map; - -/** Server view of a schedule, returned by {@link Schedules#list(String)} / {@link Schedules#get(String)}. */ -public final class ScheduleInfo { - private final String name; - private final String shortName; - private final String agent; - private final String cron; - private final String timezone; - private final Map input; - private final boolean paused; - private final String pausedReason; - private final boolean catchup; - private final Long startAt; - private final Long endAt; - private final String description; - private final Long nextRun; - private final Long createTime; - private final Long updateTime; - private final String createdBy; - private final String updatedBy; - - public ScheduleInfo( - String name, - String shortName, - String agent, - String cron, - String timezone, - Map input, - boolean paused, - String pausedReason, - boolean catchup, - Long startAt, - Long endAt, - String description, - Long nextRun, - Long createTime, - Long updateTime, - String createdBy, - String updatedBy) { - this.name = name; - this.shortName = shortName; - this.agent = agent; - this.cron = cron; - this.timezone = timezone; - this.input = input; - this.paused = paused; - this.pausedReason = pausedReason; - this.catchup = catchup; - this.startAt = startAt; - this.endAt = endAt; - this.description = description; - this.nextRun = nextRun; - this.createTime = createTime; - this.updateTime = updateTime; - this.createdBy = createdBy; - this.updatedBy = updatedBy; - } - - public String getName() { - return name; - } - - public String getShortName() { - return shortName; - } - - public String getAgent() { - return agent; - } - - public String getCron() { - return cron; - } - - public String getTimezone() { - return timezone; - } - - public Map getInput() { - return input; - } - - public boolean isPaused() { - return paused; - } - - public String getPausedReason() { - return pausedReason; - } - - public boolean isCatchup() { - return catchup; - } - - public Long getStartAt() { - return startAt; - } - - public Long getEndAt() { - return endAt; - } - - public String getDescription() { - return description; - } - - public Long getNextRun() { - return nextRun; - } - - public Long getCreateTime() { - return createTime; - } - - public Long getUpdateTime() { - return updateTime; - } - - public String getCreatedBy() { - return createdBy; - } - - public String getUpdatedBy() { - return updatedBy; - } - - @Override - public String toString() { - return "ScheduleInfo{name=" + name + ", agent=" + agent + ", cron=" + cron + ", paused=" + paused + "}"; - } -} diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/schedule/Schedules.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/schedule/Schedules.java deleted file mode 100644 index ea1100a1d..000000000 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/schedule/Schedules.java +++ /dev/null @@ -1,384 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

    - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

    - * http://www.apache.org/licenses/LICENSE-2.0 - *

    - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.ai.schedule; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import org.conductoross.conductor.ai.model.AgentHandle; -import org.conductoross.conductor.ai.model.AgentResult; - -import com.netflix.conductor.client.exception.ConductorClientException; -import com.netflix.conductor.client.http.ConductorClient; -import com.netflix.conductor.client.http.ConductorClientRequest; -import com.netflix.conductor.client.http.ConductorClientRequest.Method; -import com.netflix.conductor.client.http.WorkflowClient; -import com.netflix.conductor.common.metadata.workflow.StartWorkflowRequest; -import com.netflix.conductor.common.run.Workflow; - -import io.orkes.conductor.client.exceptions.AgentAPIException; - -import com.fasterxml.jackson.core.type.TypeReference; - -/** - * Lifecycle API for cron-based agent schedules. Obtained via {@code runtime.schedules()}. - * - *

    All requests ride the shared native Conductor {@link ConductorClient}/ApiClient - * (same HTTP + token-auth backend as every other client) — the scheduler CRUD via - * {@link ConductorClientRequest}/{@link ConductorClient#execute} ({@code /api/scheduler/*}, - * for which the Conductor client ships no typed {@code SchedulerClient}), and - * {@code runNow} via the typed {@link WorkflowClient}. - * - *

    Operations are keyed by the wire name (prefixed with - * {@code agent-}) returned by {@link #list(String)}. Use {@link Schedule} to - * construct the user-facing short name; the SDK prefixes it at deploy time. - */ -public class Schedules { - - private static final TypeReference> MAP_TYPE = new TypeReference>() {}; - private static final TypeReference>> LIST_MAP_TYPE = - new TypeReference>>() {}; - private static final TypeReference> LIST_LONG_TYPE = new TypeReference>() {}; - - private final ConductorClient client; - /** Shared native Conductor client for starting workflows (runNow). */ - private final WorkflowClient workflowClient; - - public Schedules(ConductorClient conductorClient) { - this.client = conductorClient; - this.workflowClient = new WorkflowClient(conductorClient); - } - - /** Test seam: inject a {@link WorkflowClient} so {@code runNow}/{@code runNowAndWait} can be unit-tested. */ - Schedules(ConductorClient conductorClient, WorkflowClient workflowClient) { - this.client = conductorClient; - this.workflowClient = workflowClient; - } - - // ── CRUD ──────────────────────────────────────────────────────────── - - public void save(Schedule schedule, String agentName) { - Map body = toSaveRequest(schedule, agentName); - execVoid(ConductorClientRequest.builder() - .method(Method.POST) - .path("/scheduler/schedules") - .body(body) - .build()); - } - - public ScheduleInfo get(String wireName) { - Map resp = exec( - ConductorClientRequest.builder() - .method(Method.GET) - .path("/scheduler/schedules/{name}") - .addPathParam("name", wireName) - .build(), - MAP_TYPE); - if (resp == null || resp.isEmpty() || resp.get("name") == null) { - throw new ScheduleException.NotFound("Schedule '" + wireName + "' not found"); - } - return fromWorkflowSchedule(resp, null); - } - - public List list(String agentName) { - List> resp = exec( - ConductorClientRequest.builder() - .method(Method.GET) - .path("/scheduler/schedules") - .addQueryParam("workflowName", agentName) - .build(), - LIST_MAP_TYPE); - if (resp == null) return new ArrayList<>(); - List out = new ArrayList<>(); - for (Map item : resp) { - if (item != null) out.add(fromWorkflowSchedule(item, agentName)); - } - return out; - } - - public void pause(String wireName) { - pause(wireName, null); - } - - public void pause(String wireName, String reason) { - ConductorClientRequest.Builder b = ConductorClientRequest.builder() - .method(Method.PUT) - .path("/scheduler/schedules/{name}/pause") - .addPathParam("name", wireName); - if (reason != null) b.addQueryParam("reason", reason); - execVoid(b.build()); - } - - public void resume(String wireName) { - execVoid(ConductorClientRequest.builder() - .method(Method.PUT) - .path("/scheduler/schedules/{name}/resume") - .addPathParam("name", wireName) - .build()); - } - - public void delete(String wireName) { - execVoid(ConductorClientRequest.builder() - .method(Method.DELETE) - .path("/scheduler/schedules/{name}") - .addPathParam("name", wireName) - .build()); - } - - /** - * Start the scheduled agent's workflow immediately via the official Conductor - * {@link WorkflowClient#startWorkflow} (returns the new workflowId). - */ - public String runNow(ScheduleInfo info) { - StartWorkflowRequest req = new StartWorkflowRequest(); - req.setName(info.getAgent()); - if (info.getInput() != null) req.setInput(info.getInput()); - return workflowClient.startWorkflow(req); - } - - /** Default timeout (ms) for {@link #runNowAndWait}, mirroring Python's 600s default. */ - private static final long DEFAULT_WAIT_TIMEOUT_MS = 600_000L; - /** Default poll interval (ms) for {@link #runNowAndWait}, mirroring Python's 1s default. */ - private static final long DEFAULT_POLL_INTERVAL_MS = 1_000L; - - /** - * Fetch the schedule by its wire {@code name} and start its agent's workflow - * immediately with the schedule's stored input. Returns the new workflowId. - * - *

    Name-keyed parity with the Python/TS {@code run_now(name)}. - */ - public String runNow(String name) { - return runNow(get(name)); - } - - /** - * Fetch the schedule by its wire {@code name} and start its agent's workflow. - * - *

    When {@code wait} is {@code false} (default behaviour) returns the - * workflowId immediately. When {@code wait} is {@code true} this blocks until - * the workflow reaches a terminal state and returns an {@link AgentResult} - * built from the completed workflow (parity with Python's - * {@code run_now(name, wait=True)} and the C#/TS SDKs, which return an - * {@link AgentResult} from the wait variant). - * - * @return a {@link String} workflowId when {@code wait=false}, or an - * {@link AgentResult} when {@code wait=true} - */ - public Object runNow(String name, boolean wait) { - if (!wait) { - return runNow(name); - } - return runNowAndWait(name); - } - - /** - * Fetch the schedule by its wire {@code name}, start it, then poll until the - * triggered workflow reaches a terminal state and return it as an - * {@link AgentResult}. - * - * @throws ScheduleException.Timeout if the workflow has not finished within the timeout - */ - public AgentResult runNowAndWait(String name) { - return runNowAndWait(name, DEFAULT_WAIT_TIMEOUT_MS, DEFAULT_POLL_INTERVAL_MS); - } - - /** - * Fetch the schedule by its wire {@code name}, start it, then poll until the - * triggered workflow reaches a terminal state and return it as an - * {@link AgentResult}. - * - *

    The completed {@link Workflow} is converted via the SDK's shared - * workflow → {@link AgentResult} extraction ({@link AgentHandle#fromWorkflow}) - * — the same logic the {@code AgentHandle.waitForResult} path uses — so the - * output, status, error, token usage, and tool calls match a direct run. - * - * @param name the schedule's wire name - * @param timeoutMs maximum time to wait, in milliseconds - * @param pollIntervalMs delay between status polls, in milliseconds - * @throws ScheduleException.Timeout if the workflow has not finished within {@code timeoutMs} - */ - public AgentResult runNowAndWait(String name, long timeoutMs, long pollIntervalMs) { - String executionId = runNow(name); - long deadline = System.currentTimeMillis() + timeoutMs; - while (true) { - Workflow wf = workflowClient.getWorkflow(executionId, true); - if (isTerminal(wf)) { - return AgentHandle.fromWorkflow(wf); - } - if (System.currentTimeMillis() >= deadline) { - throw new ScheduleException.Timeout("runNow('" + name + "') did not finish within " + timeoutMs + "ms"); - } - if (pollIntervalMs > 0) { - try { - Thread.sleep(pollIntervalMs); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new ScheduleException.Timeout("runNow('" + name + "') was interrupted while waiting"); - } - } - } - } - - /** {@code true} if the workflow has reached a terminal state (completed/failed/terminated/timed-out). */ - static boolean isTerminal(Workflow wf) { - Workflow.WorkflowStatus status = wf != null ? wf.getStatus() : null; - return status != null && status.isTerminal(); - } - - public List previewNext(String cron, int n) { - List resp = exec( - ConductorClientRequest.builder() - .method(Method.GET) - .path("/scheduler/nextFewSchedules") - .addQueryParam("cronExpression", cron) - .addQueryParam("limit", Integer.valueOf(n)) - .build(), - LIST_LONG_TYPE); - return resp != null ? resp : new ArrayList<>(); - } - - // ── Declarative reconcile ─────────────────────────────────────────── - - /** - * Apply declarative scheduling semantics: - *

      - *
    • {@code null} → no-op
    • - *
    • empty list → purge all schedules whose workflow == agent
    • - *
    • non-empty list → upsert listed, delete any other schedule for this agent
    • - *
    - */ - public void reconcile(String agentName, List desired) { - if (desired == null) return; - checkUniqueNames(desired); - - Map existingWireByShort = new LinkedHashMap<>(); - for (ScheduleInfo info : list(agentName)) { - existingWireByShort.put(info.getShortName(), info.getName()); - } - Set desiredShort = new HashSet<>(); - for (Schedule s : desired) desiredShort.add(s.getName()); - - for (Map.Entry entry : existingWireByShort.entrySet()) { - if (!desiredShort.contains(entry.getKey())) { - delete(entry.getValue()); - } - } - for (Schedule s : desired) { - save(s, agentName); - } - } - - // ── Internals ─────────────────────────────────────────────────────── - - static String prefix(String agentName, String shortName) { - return agentName + "-" + shortName; - } - - static String unprefix(String agentName, String wireName) { - String p = agentName + "-"; - return wireName.startsWith(p) ? wireName.substring(p.length()) : wireName; - } - - static void checkUniqueNames(List schedules) { - Set seen = new HashSet<>(); - for (Schedule s : schedules) { - if (!seen.add(s.getName())) { - throw new ScheduleException.NameConflict( - "Duplicate schedule name '" + s.getName() + "' — names must be unique per agent"); - } - } - } - - static Map toSaveRequest(Schedule s, String agentName) { - Map swr = new LinkedHashMap<>(); - swr.put("name", agentName); - swr.put("input", s.getInput() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(s.getInput())); - - Map req = new LinkedHashMap<>(); - req.put("name", prefix(agentName, s.getName())); - req.put("cronExpression", s.getCron()); - req.put("zoneId", s.getTimezone()); - req.put("runCatchupScheduleInstances", s.isCatchup()); - req.put("paused", s.isPaused()); - if (s.getStartAt() != null) req.put("scheduleStartTime", s.getStartAt()); - if (s.getEndAt() != null) req.put("scheduleEndTime", s.getEndAt()); - if (s.getDescription() != null) req.put("description", s.getDescription()); - req.put("startWorkflowRequest", swr); - return req; - } - - @SuppressWarnings("unchecked") - static ScheduleInfo fromWorkflowSchedule(Map ws, String agentHint) { - Map swr = (Map) ws.getOrDefault("startWorkflowRequest", new HashMap<>()); - String wireName = (String) ws.getOrDefault("name", ""); - String swrName = (String) swr.getOrDefault("name", ""); - String agent = agentHint != null ? agentHint : (swrName.isEmpty() ? "" : swrName); - - return new ScheduleInfo( - wireName, - unprefix(agent, wireName), - swrName, - (String) ws.getOrDefault("cronExpression", ""), - (String) ws.getOrDefault("zoneId", "UTC"), - (Map) swr.getOrDefault("input", new HashMap<>()), - Boolean.TRUE.equals(ws.get("paused")), - (String) ws.get("pausedReason"), - Boolean.TRUE.equals(ws.get("runCatchupScheduleInstances")), - longOrNull(ws.get("scheduleStartTime")), - longOrNull(ws.get("scheduleEndTime")), - (String) ws.get("description"), - longOrNull(ws.get("nextRunTime")), - longOrNull(ws.get("createTime")), - longOrNull(ws.get("updatedTime")), - (String) ws.get("createdBy"), - (String) ws.get("updatedBy")); - } - - private static Long longOrNull(Object o) { - return o instanceof Number ? ((Number) o).longValue() : null; - } - - /** Execute a scheduler request returning a typed body via the native Conductor client. */ - private T exec(ConductorClientRequest req, TypeReference type) { - try { - return client.execute(req, type).getData(); - } catch (ConductorClientException e) { - throw mapException(e); - } - } - - /** Execute a scheduler request that returns no body. */ - private void execVoid(ConductorClientRequest req) { - try { - client.execute(req); - } catch (ConductorClientException e) { - throw mapException(e); - } - } - - /** Map Conductor's exception to the scheduler's typed exceptions (preserves the contract). */ - private static RuntimeException mapException(ConductorClientException e) { - int status = e.getStatus(); - String msg = e.getMessage() != null ? e.getMessage() : ""; - if (status == 404) return new ScheduleException.NotFound(msg); - if (status == 400 && msg.toLowerCase().contains("cron")) { - return new ScheduleException.InvalidCron(msg); - } - return new AgentAPIException(status, msg); - } -} diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/skill/Skill.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/skill/Skill.java index 2df112c53..ca91ff236 100644 --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/skill/Skill.java +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/skill/Skill.java @@ -37,7 +37,7 @@ import org.conductoross.conductor.ai.Agent; /** - * Load an Agent Skills directory as an Agentspan Agent. + * Load an Agent Skills directory as a Conductor Agent. * *

    A skill directory must contain a {@code SKILL.md} file with YAML frontmatter (including a * {@code name} field) followed by the skill body. Optionally it may contain {@code *-agent.md} diff --git a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/AgentConfigTest.java b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/AgentConfigTest.java index 7076ed3d9..7b90e7435 100644 --- a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/AgentConfigTest.java +++ b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/AgentConfigTest.java @@ -49,14 +49,14 @@ void defaultsWhenEnvEmpty() { @Test void parsesAllKnobsFromEnv() { Map env = new HashMap<>(); - env.put("AGENTSPAN_WORKER_POLL_INTERVAL", "250"); - env.put("AGENTSPAN_WORKER_THREADS", "4"); - env.put("AGENTSPAN_AUTO_START_WORKERS", "false"); - env.put("AGENTSPAN_DAEMON_WORKERS", "false"); - env.put("AGENTSPAN_STREAMING_ENABLED", "false"); - env.put("AGENTSPAN_LIVENESS_ENABLED", "false"); - env.put("AGENTSPAN_LIVENESS_STALL_SECONDS", "45.5"); - env.put("AGENTSPAN_LIVENESS_CHECK_INTERVAL_SECONDS", "2.5"); + env.put("CONDUCTOR_AGENT_WORKER_POLL_INTERVAL", "250"); + env.put("CONDUCTOR_AGENT_WORKER_THREADS", "4"); + env.put("CONDUCTOR_AGENT_AUTO_START_WORKERS", "false"); + env.put("CONDUCTOR_AGENT_DAEMON_WORKERS", "false"); + env.put("CONDUCTOR_AGENT_STREAMING_ENABLED", "false"); + env.put("CONDUCTOR_AGENT_LIVENESS_ENABLED", "false"); + env.put("CONDUCTOR_AGENT_LIVENESS_STALL_SECONDS", "45.5"); + env.put("CONDUCTOR_AGENT_LIVENESS_CHECK_INTERVAL_SECONDS", "2.5"); AgentConfig config = fromEnv(env); @@ -73,8 +73,8 @@ void parsesAllKnobsFromEnv() { @Test void invalidNumberFallsBackToDefault() { AgentConfig config = fromEnv(Map.of( - "AGENTSPAN_WORKER_POLL_INTERVAL", "not-a-number", - "AGENTSPAN_LIVENESS_STALL_SECONDS", "soon")); + "CONDUCTOR_AGENT_WORKER_POLL_INTERVAL", "not-a-number", + "CONDUCTOR_AGENT_LIVENESS_STALL_SECONDS", "soon")); assertEquals( 100, @@ -86,8 +86,8 @@ void invalidNumberFallsBackToDefault() { @Test void emptyStringFallsBackToDefault() { Map env = new HashMap<>(); - env.put("AGENTSPAN_WORKER_THREADS", ""); - env.put("AGENTSPAN_STREAMING_ENABLED", " "); + env.put("CONDUCTOR_AGENT_WORKER_THREADS", ""); + env.put("CONDUCTOR_AGENT_STREAMING_ENABLED", " "); AgentConfig config = fromEnv(env); @@ -97,11 +97,11 @@ void emptyStringFallsBackToDefault() { @Test void booleanVariantsParse() { - assertFalse(fromEnv(Map.of("AGENTSPAN_AUTO_START_WORKERS", "0")).isAutoStartWorkers()); - assertTrue(fromEnv(Map.of("AGENTSPAN_AUTO_START_WORKERS", "TRUE")).isAutoStartWorkers()); - assertFalse(fromEnv(Map.of("AGENTSPAN_AUTO_START_WORKERS", "no")).isAutoStartWorkers()); + assertFalse(fromEnv(Map.of("CONDUCTOR_AGENT_AUTO_START_WORKERS", "0")).isAutoStartWorkers()); + assertTrue(fromEnv(Map.of("CONDUCTOR_AGENT_AUTO_START_WORKERS", "TRUE")).isAutoStartWorkers()); + assertFalse(fromEnv(Map.of("CONDUCTOR_AGENT_AUTO_START_WORKERS", "no")).isAutoStartWorkers()); assertTrue( - fromEnv(Map.of("AGENTSPAN_AUTO_START_WORKERS", "banana")).isAutoStartWorkers(), + fromEnv(Map.of("CONDUCTOR_AGENT_AUTO_START_WORKERS", "banana")).isAutoStartWorkers(), "unrecognized boolean falls back to the default"); } diff --git a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/AgentRuntimeRunSettingsWireTest.java b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/AgentRuntimeRunSettingsWireTest.java index 7a270f47f..91ec78244 100644 --- a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/AgentRuntimeRunSettingsWireTest.java +++ b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/AgentRuntimeRunSettingsWireTest.java @@ -80,11 +80,16 @@ private static MockResponse json(String body) { } @SuppressWarnings("unchecked") - private Map takeStartAgentConfig() throws Exception { + private Map takeStartAgentBody() throws Exception { RecordedRequest request = server.takeRequest(5, TimeUnit.SECONDS); assertNotNull(request, "expected the start request to reach the stub server"); assertEquals("/api/agent/start", request.getPath()); - Map body = MAPPER.readValue(request.getBody().readUtf8(), Map.class); + return MAPPER.readValue(request.getBody().readUtf8(), Map.class); + } + + @SuppressWarnings("unchecked") + private Map takeStartAgentConfig() throws Exception { + Map body = takeStartAgentBody(); Map agentConfig = (Map) body.get("agentConfig"); assertNotNull(agentConfig, "start payload must carry the serialized agentConfig"); return agentConfig; @@ -119,10 +124,31 @@ void partialOverridesKeepAgentValues() throws Exception { void noSettingsLeavesConfigUntouched() throws Exception { runtime.start(agent, "hi"); - Map agentConfig = takeStartAgentConfig(); + Map body = takeStartAgentBody(); + @SuppressWarnings("unchecked") + Map agentConfig = (Map) body.get("agentConfig"); assertEquals("openai/gpt-4o", agentConfig.get("model")); assertFalse(agentConfig.containsKey("thinkingConfig")); assertFalse(agentConfig.containsKey("reasoningEffort")); + assertFalse(body.containsKey("idempotencyKey")); + } + + @Test + void idempotencyKeyIsTopLevelExecutionMetadata() throws Exception { + runtime.start(agent, "hi", new RunSettings().idempotencyKey("logical-run-123")); + + Map body = takeStartAgentBody(); + assertEquals("logical-run-123", body.get("idempotencyKey")); + @SuppressWarnings("unchecked") + Map agentConfig = (Map) body.get("agentConfig"); + assertFalse(agentConfig.containsKey("idempotencyKey")); + } + + @Test + void blankIdempotencyKeyIsOmitted() throws Exception { + runtime.start(agent, "hi", new RunSettings().idempotencyKey(" ")); + + assertFalse(takeStartAgentBody().containsKey("idempotencyKey")); } @Test @@ -137,9 +163,14 @@ void asyncVariantForwardsSettings() throws Exception { void dropInVarargsExtractRunSettings() throws Exception { // Through the Object drop-in, RunSettings arrives in the tools varargs — // it must be applied as overrides, not coerced (and dropped) as a tool. - runtime.start((Object) agent, "hi", new RunSettings().model("varargs/model")); + runtime.start((Object) agent, "hi", new RunSettings() + .model("varargs/model") + .idempotencyKey("varargs-run-123")); - Map agentConfig = takeStartAgentConfig(); + Map body = takeStartAgentBody(); + @SuppressWarnings("unchecked") + Map agentConfig = (Map) body.get("agentConfig"); assertEquals("varargs/model", agentConfig.get("model")); + assertEquals("varargs-run-123", body.get("idempotencyKey")); } } diff --git a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/AgentRuntimeSchedulerClientTest.java b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/AgentRuntimeSchedulerClientTest.java new file mode 100644 index 000000000..62edc24ae --- /dev/null +++ b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/AgentRuntimeSchedulerClientTest.java @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Conductor Authors. + *

    + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

    + * http://www.apache.org/licenses/LICENSE-2.0 + *

    + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package org.conductoross.conductor.ai; + +import java.lang.reflect.Proxy; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import io.orkes.conductor.client.SchedulerClient; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +class AgentRuntimeSchedulerClientTest { + + @Test + void getSchedulerClientReturnsTheSharedInjectedClient() { + SchedulerClient scheduler = (SchedulerClient) Proxy.newProxyInstance( + SchedulerClient.class.getClassLoader(), + new Class[] {SchedulerClient.class}, + (proxy, method, args) -> null); + AgentRuntime runtime = new AgentRuntime( + TestClients.forUrl("http://localhost:8080"), new AgentConfig(), scheduler); + + assertSame(scheduler, runtime.getSchedulerClient()); + } + + @Test + void deployDoesNotManageSchedules() throws Exception { + AtomicInteger schedulerCalls = new AtomicInteger(); + SchedulerClient scheduler = (SchedulerClient) Proxy.newProxyInstance( + SchedulerClient.class.getClassLoader(), + new Class[] {SchedulerClient.class}, + (proxy, method, args) -> { + schedulerCalls.incrementAndGet(); + return null; + }); + + try (MockWebServer server = new MockWebServer()) { + server.start(); + server.enqueue(new MockResponse() + .setHeader("Content-Type", "application/json") + .setBody("{\"agentName\":\"digest\",\"requiredWorkers\":[]}")); + AgentRuntime runtime = new AgentRuntime( + TestClients.forUrl(server.url("/").toString()), new AgentConfig(), scheduler); + + runtime.deploy(Agent.builder().name("digest").model("openai/gpt-4o-mini").build()); + + assertEquals(0, schedulerCalls.get()); + } + } +} diff --git a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/RunSettingsTest.java b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/RunSettingsTest.java index e775bcb46..6a5b5c081 100644 --- a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/RunSettingsTest.java +++ b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/RunSettingsTest.java @@ -66,6 +66,14 @@ void emptySettingsProduceNoOverrides() { assertTrue(new RunSettings().toConfigOverrides().isEmpty()); } + @Test + void idempotencyKeyIsExecutionMetadataNotConfigOverride() { + RunSettings settings = new RunSettings().idempotencyKey("logical-run-123"); + + assertEquals("logical-run-123", settings.getIdempotencyKey()); + assertTrue(settings.toConfigOverrides().isEmpty()); + } + @Test void noTopPKnob() { // The spec deliberately omits topP from RunSettings — guard against diff --git a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/ToolGuardrailRegistrationTest.java b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/ToolGuardrailRegistrationTest.java new file mode 100644 index 000000000..776280efc --- /dev/null +++ b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/ToolGuardrailRegistrationTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Conductor Authors. + *

    + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

    + * http://www.apache.org/licenses/LICENSE-2.0 + *

    + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package org.conductoross.conductor.ai; + +import java.util.List; + +import org.conductoross.conductor.ai.guardrail.Guardrail; +import org.conductoross.conductor.ai.guardrail.LLMGuardrail; +import org.conductoross.conductor.ai.guardrail.RegexGuardrail; +import org.conductoross.conductor.ai.model.GuardrailDef; +import org.conductoross.conductor.ai.model.GuardrailResult; +import org.conductoross.conductor.ai.model.ToolDef; +import org.junit.jupiter.api.Test; + +import io.orkes.conductor.client.ApiClient; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Regression coverage for local tool guardrail worker registration. */ +class ToolGuardrailRegistrationTest { + private static final String DOMAIN = "guardrail-test-domain"; + + @Test + void local_guardrail_is_registered_for_every_tool_type() { + AgentRuntime runtime = runtime(); + try { + for (String toolType : List.of("worker", "http", "api", "mcp", "agent_tool", "human", + "pull_workflow_messages", "rag", "generate_pdf", "generate_image", "generate_audio", "generate_video")) { + ToolDef tool = ToolDef.builder().name("tool_" + toolType).toolType(toolType) + .guardrails(List.of(local("local_" + toolType))).build(); + runtime.prepareWorkers(agent(tool), DOMAIN); + String taskName = tool.getName() + "_output_guardrail"; + assertTrue(runtime.isWorkerRegisteredForTest(taskName), "local tool guardrail must have a worker"); + assertEquals(DOMAIN, runtime.workerDomainForTest(taskName), "guardrail must poll the execution domain"); + } + } finally { + runtime.shutdown(); + } + } + + @Test + void server_and_external_guardrails_do_not_create_local_workers() { + AgentRuntime runtime = runtime(); + try { + ToolDef tool = ToolDef.builder().name("server_owned").toolType("http").guardrails(List.of( + RegexGuardrail.builder().name("regex").patterns("secret").build(), + LLMGuardrail.builder().name("judge").model("openai/gpt-4o-mini").policy("safe").build(), + Guardrail.external("external_guard").build())).build(); + runtime.prepareWorkers(agent(tool), DOMAIN); + assertFalse(runtime.isWorkerRegisteredForTest("server_owned_output_guardrail")); + } finally { + runtime.shutdown(); + } + } + + private static GuardrailDef local(String name) { + return Guardrail.of(name, content -> GuardrailResult.pass()).build(); + } + + private static Agent agent(ToolDef tool) { + return Agent.builder().name("registration_agent_" + tool.getName()).model("openai/gpt-4o-mini") + .tools(List.of(tool)).build(); + } + + private static AgentRuntime runtime() { + return new AgentRuntime(ApiClient.builder().basePath("http://127.0.0.1:1/api").connectTimeout(1).readTimeout(1).build()); + } +} diff --git a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/exceptions/ExceptionsTest.java b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/exceptions/ExceptionsTest.java index 555ba00ea..08a156760 100644 --- a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/exceptions/ExceptionsTest.java +++ b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/exceptions/ExceptionsTest.java @@ -16,7 +16,7 @@ import org.junit.jupiter.api.Test; -import io.orkes.conductor.client.exceptions.AgentspanException; +import io.orkes.conductor.client.exceptions.AgentException; import static org.junit.jupiter.api.Assertions.*; @@ -41,7 +41,7 @@ void credentialNotFoundListsMissingNames() { } @Test - void credentialNotFoundIsAnAgentspanException() { - assertInstanceOf(AgentspanException.class, new CredentialNotFoundException("ONLY")); + void credentialNotFoundIsAnAgentException() { + assertInstanceOf(AgentException.class, new CredentialNotFoundException("ONLY")); } } diff --git a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/execution/CliCommandExecutorTest.java b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/execution/CliCommandExecutorTest.java index 877acad5d..66495eb16 100644 --- a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/execution/CliCommandExecutorTest.java +++ b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/execution/CliCommandExecutorTest.java @@ -155,7 +155,7 @@ void run_nonZeroExitReportsError() { @DisabledOnOs(OS.WINDOWS) void run_commandNotFound() { Map result = - CliCommandExecutor.run("agentspan_no_such_binary_xyz", null, null, false, List.of(), 30, null, false); + CliCommandExecutor.run("conductor_agent_no_such_binary_xyz", null, null, false, List.of(), 30, null, false); assertEquals("error", result.get("status")); assertTrue(((String) result.get("stderr")).contains("Command not found"), "stderr: " + result.get("stderr")); } diff --git a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/frameworks/AdkBridgeTest.java b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/frameworks/AdkBridgeTest.java index f4b61546a..ba661ff96 100644 --- a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/frameworks/AdkBridgeTest.java +++ b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/frameworks/AdkBridgeTest.java @@ -27,7 +27,7 @@ import static org.junit.jupiter.api.Assertions.*; /** - * Server-free unit tests for the Google ADK bridge ({@link AdkBridge#toAgentspan}). + * Server-free unit tests for the Google ADK bridge ({@link AdkBridge#toConductor}). * *

    Mirrors how Python's framework e2e validates serialization (framework tagging, * identity, tool extraction with a valid JSON Schema) without needing the model @@ -54,8 +54,8 @@ private LlmAgent buildAdkAgent() { } @Test - void toAgentspanTagsFrameworkAndCopiesIdentity() { - Agent a = AdkBridge.toAgentspan(buildAdkAgent()); + void toConductorTagsFrameworkAndCopiesIdentity() { + Agent a = AdkBridge.toConductor(buildAdkAgent()); assertEquals( "google_adk", a.getFramework(), @@ -69,8 +69,8 @@ void toAgentspanTagsFrameworkAndCopiesIdentity() { } @Test - void toAgentspanExtractsFunctionToolWithSchema() { - Agent a = AdkBridge.toAgentspan(buildAdkAgent()); + void toConductorExtractsFunctionToolWithSchema() { + Agent a = AdkBridge.toConductor(buildAdkAgent()); List tools = a.getTools(); assertNotNull(tools, "tools list must not be null"); assertFalse( diff --git a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/guardrail/GuardrailDefaultsTest.java b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/guardrail/GuardrailDefaultsTest.java index 399c40a81..33bd724f1 100644 --- a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/guardrail/GuardrailDefaultsTest.java +++ b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/guardrail/GuardrailDefaultsTest.java @@ -82,4 +82,10 @@ void human_output_guardrail_is_allowed() { assertEquals(OnFail.HUMAN, g.getOnFail()); assertEquals(Position.OUTPUT, g.getPosition()); } + + @Test + void core_builder_rejects_blank_names_and_negative_retries() { + assertThrows(IllegalArgumentException.class, () -> GuardrailDef.builder().name(" ").build()); + assertThrows(IllegalArgumentException.class, () -> GuardrailDef.builder().name("g").maxRetries(-1).build()); + } } diff --git a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/internal/GuardrailHandlerFactoryTest.java b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/internal/GuardrailHandlerFactoryTest.java new file mode 100644 index 000000000..97e591003 --- /dev/null +++ b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/internal/GuardrailHandlerFactoryTest.java @@ -0,0 +1,93 @@ +/* + * Copyright 2026 Conductor Authors. + *

    + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

    + * http://www.apache.org/licenses/LICENSE-2.0 + *

    + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package org.conductoross.conductor.ai.internal; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import org.conductoross.conductor.ai.enums.OnFail; +import org.conductoross.conductor.ai.model.GuardrailDef; +import org.conductoross.conductor.ai.model.GuardrailResult; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Server-free contract tests for the shared local guardrail worker handler. */ +class GuardrailHandlerFactoryTest { + + private static GuardrailDef guard(String name, OnFail onFail, Function function) { + return GuardrailDef.builder().name(name).onFail(onFail).func(function).build(); + } + + @Test + void passes_when_every_guardrail_passes() { + Map out = output(GuardrailHandlerFactory.create(List.of( + guard("first", OnFail.RAISE, value -> GuardrailResult.pass()))).apply(Map.of())); + assertTrue((Boolean) out.get("passed")); + assertEquals("pass", out.get("on_fail")); + } + + @Test + void returns_first_failure_in_declaration_order() { + AtomicInteger secondCalls = new AtomicInteger(); + Map out = output(GuardrailHandlerFactory.create(List.of( + guard("first", OnFail.RAISE, value -> GuardrailResult.fail("first failure")), + guard("second", OnFail.RAISE, value -> { + secondCalls.incrementAndGet(); + return GuardrailResult.fail("second failure"); + }))).apply(Map.of())); + assertFalse((Boolean) out.get("passed")); + assertEquals("first", out.get("guardrail_name")); + assertEquals(0, secondCalls.get()); + } + + @Test + void preserves_retry_fix_and_human_semantics() { + Map retry = output(GuardrailHandlerFactory.create(List.of( + guard("retry", OnFail.RETRY, value -> GuardrailResult.fail("no")))).apply(Map.of("content", "x", "iteration", 0))); + assertEquals("retry", retry.get("on_fail")); + assertTrue((Boolean) retry.get("should_continue")); + + Map escalated = output(GuardrailHandlerFactory.create(List.of( + GuardrailDef.builder().name("retry").onFail(OnFail.RETRY).maxRetries(1) + .func(value -> GuardrailResult.fail("no")).build())).apply(Map.of("iteration", 1))); + assertEquals("raise", escalated.get("on_fail")); + + Map fix = output(GuardrailHandlerFactory.create(List.of( + guard("fix", OnFail.FIX, value -> GuardrailResult.fix("clean")))).apply(Map.of())); + assertEquals("fix", fix.get("on_fail")); + assertEquals("clean", fix.get("fixed_output")); + + Map human = output(GuardrailHandlerFactory.create(List.of( + guard("human", OnFail.HUMAN, value -> GuardrailResult.fail("review")))).apply(Map.of())); + assertEquals("human", human.get("on_fail")); + } + + @Test + void thrown_or_null_guardrail_result_fails_the_task() { + assertThrows(RuntimeException.class, () -> GuardrailHandlerFactory.create(List.of( + guard("throws", OnFail.RAISE, value -> { throw new IllegalStateException("boom"); }))).apply(Map.of())); + assertThrows(IllegalStateException.class, () -> GuardrailHandlerFactory.create(List.of( + guard("null", OnFail.RAISE, value -> null))).apply(Map.of())); + } + + @SuppressWarnings("unchecked") + private static Map output(Object value) { + return (Map) value; + } +} diff --git a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/internal/WorkerManagerCredentialsTest.java b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/internal/WorkerManagerCredentialsTest.java index 501aa2f43..ad0201400 100644 --- a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/internal/WorkerManagerCredentialsTest.java +++ b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/internal/WorkerManagerCredentialsTest.java @@ -193,7 +193,7 @@ void missingDeliveryFailsTerminallyAndNeverReadsAmbientEnv() { assertFalse(handlerRan.get(), "the handler must not run without its declared credentials"); assertTrue(result.getReasonForIncompletion().contains("PATH"), "the missing name must be reported"); assertTrue( - result.getReasonForIncompletion().contains("agentspan > 0.4.2"), + result.getReasonForIncompletion().contains("Conductor OSS with PR #1255"), "the server capability requirement must be named: " + result.getReasonForIncompletion()); } diff --git a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/schedule/ScheduleIntegrationTest.java b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/schedule/ScheduleIntegrationTest.java deleted file mode 100644 index 7edd094f1..000000000 --- a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/schedule/ScheduleIntegrationTest.java +++ /dev/null @@ -1,267 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

    - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

    - * http://www.apache.org/licenses/LICENSE-2.0 - *

    - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.ai.schedule; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.condition.EnabledIf; - -import com.netflix.conductor.client.http.ConductorClient; - -import io.orkes.conductor.client.ApiClient; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Integration tests for the Java schedule SDK against the live agentspan-runtime. - * Skipped if the scheduler endpoint isn't reachable. - */ -@EnabledIf("schedulerAvailable") -class ScheduleIntegrationTest { - - // Base server URL WITHOUT a trailing "/api"; this suite appends "/api/..." itself. - // AGENTSPAN_SERVER_URL conventionally INCLUDES "/api" (see BaseTest), so normalize it - // away here — otherwise every URL gets a double "/api" and the scheduler probe 404s, - // silently skipping the whole suite. - private static final String SERVER = - stripApiSuffix(System.getenv().getOrDefault("AGENTSPAN_SERVER_URL", "http://localhost:8080")); - - private static String stripApiSuffix(String url) { - if (url.endsWith("/")) url = url.substring(0, url.length() - 1); - if (url.endsWith("/api")) url = url.substring(0, url.length() - 4); - return url; - } - - private static final HttpClient HTTP = - HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); - - private static final String AGENT_NAME = - "e2e_java_sched_noop_" + UUID.randomUUID().toString().substring(0, 8); - - private static Schedules schedules; - - static boolean schedulerAvailable() { - try { - HttpResponse r = HTTP.send( - HttpRequest.newBuilder() - .uri(URI.create(SERVER + "/api/scheduler/schedules")) - .timeout(Duration.ofSeconds(3)) - .GET() - .build(), - HttpResponse.BodyHandlers.ofString()); - return r.statusCode() == 200; - } catch (Exception e) { - return false; - } - } - - @BeforeAll - static void registerWorkflow() throws Exception { - ConductorClient cc = - new ApiClient((SERVER.endsWith("/") ? SERVER.substring(0, SERVER.length() - 1) : SERVER) + "/api"); - schedules = new Schedules(cc); - - String body = "{\"name\":\"" + AGENT_NAME + "\",\"version\":1,\"schemaVersion\":2," - + "\"ownerEmail\":\"e2e@agentspan.test\",\"timeoutSeconds\":60,\"timeoutPolicy\":\"TIME_OUT_WF\"," - + "\"tasks\":[{\"name\":\"noop_terminate\",\"taskReferenceName\":\"noop_terminate_ref\"," - + "\"type\":\"TERMINATE\",\"inputParameters\":{\"terminationStatus\":\"COMPLETED\"," - + "\"workflowOutput\":{\"ok\":true}}}]}"; - - HttpResponse r = HTTP.send( - HttpRequest.newBuilder() - .uri(URI.create(SERVER + "/api/metadata/workflow")) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(body)) - .build(), - HttpResponse.BodyHandlers.ofString()); - if (r.statusCode() >= 400) { - throw new RuntimeException("Workflow register failed: " + r.statusCode() + " " + r.body()); - } - } - - @AfterAll - static void unregisterWorkflow() throws Exception { - if (schedules != null) { - try { - schedules.reconcile(AGENT_NAME, List.of()); - } catch (Exception ignored) { - } - } - HTTP.send( - HttpRequest.newBuilder() - .uri(URI.create(SERVER + "/api/metadata/workflow/" + AGENT_NAME + "/1")) - .DELETE() - .build(), - HttpResponse.BodyHandlers.ofString()); - } - - @AfterEach - void clean() { - try { - schedules.reconcile(AGENT_NAME, List.of()); - } catch (Exception ignored) { - } - } - - @Test - void reconcileCreatesSchedules() { - Map input = new HashMap<>(); - input.put("k", 1); - schedules.reconcile( - AGENT_NAME, - List.of( - Schedule.builder() - .name("daily") - .cron("0 0 9 * * ?") - .input(input) - .build(), - Schedule.builder().name("weekly").cron("0 0 9 * * MON").build())); - List infos = schedules.list(AGENT_NAME); - assertEquals(2, infos.size()); - - ScheduleInfo daily = infos.stream() - .filter(i -> "daily".equals(i.getShortName())) - .findFirst() - .orElseThrow(); - assertEquals(AGENT_NAME + "-daily", daily.getName()); - assertEquals("0 0 9 * * ?", daily.getCron()); - assertEquals(input, daily.getInput()); - assertEquals(AGENT_NAME, daily.getAgent()); - } - - @Test - void upsertAndPrune() { - schedules.reconcile( - AGENT_NAME, - List.of( - Schedule.builder().name("a").cron("0 0 1 * * ?").build(), - Schedule.builder().name("b").cron("0 0 2 * * ?").build())); - schedules.reconcile( - AGENT_NAME, - List.of( - Schedule.builder().name("a").cron("0 0 9 * * ?").build(), - Schedule.builder().name("c").cron("0 0 17 * * ?").build())); - List infos = schedules.list(AGENT_NAME); - assertEquals(2, infos.size()); - ScheduleInfo a = infos.stream() - .filter(i -> "a".equals(i.getShortName())) - .findFirst() - .orElseThrow(); - assertEquals("0 0 9 * * ?", a.getCron()); - } - - @Test - void emptyListPurges() { - schedules.reconcile( - AGENT_NAME, - List.of(Schedule.builder().name("x").cron("0 * * * * ?").build())); - assertEquals(1, schedules.list(AGENT_NAME).size()); - schedules.reconcile(AGENT_NAME, List.of()); - assertTrue(schedules.list(AGENT_NAME).isEmpty()); - } - - @Test - void nullPreserves() { - schedules.reconcile( - AGENT_NAME, - List.of(Schedule.builder().name("x").cron("0 * * * * ?").build())); - schedules.reconcile(AGENT_NAME, null); - assertEquals(1, schedules.list(AGENT_NAME).size()); - } - - @Test - void duplicateNameRaises() { - assertThrows( - ScheduleException.NameConflict.class, - () -> schedules.reconcile( - AGENT_NAME, - List.of( - Schedule.builder() - .name("dup") - .cron("0 * * * * ?") - .build(), - Schedule.builder() - .name("dup") - .cron("0 0 9 * * ?") - .build()))); - assertTrue(schedules.list(AGENT_NAME).isEmpty()); - } - - @Test - void pauseResume() { - schedules.reconcile( - AGENT_NAME, - List.of(Schedule.builder().name("p").cron("0 0 9 * * ?").build())); - String wire = AGENT_NAME + "-p"; - assertFalse(schedules.get(wire).isPaused()); - schedules.pause(wire, "rate limit"); - assertTrue(schedules.get(wire).isPaused()); - schedules.resume(wire); - assertFalse(schedules.get(wire).isPaused()); - } - - @Test - void pausedOnCreatePreservesState() { - schedules.reconcile( - AGENT_NAME, - List.of(Schedule.builder() - .name("silent") - .cron("0 0 9 * * ?") - .paused(true) - .build())); - assertTrue(schedules.get(AGENT_NAME + "-silent").isPaused()); - } - - @Test - void deleteRemoves() { - schedules.reconcile( - AGENT_NAME, - List.of(Schedule.builder().name("d").cron("0 * * * * ?").build())); - schedules.delete(AGENT_NAME + "-d"); - assertTrue(schedules.list(AGENT_NAME).isEmpty()); - } - - @Test - void getAfterDeleteRaises() { - schedules.reconcile( - AGENT_NAME, - List.of(Schedule.builder().name("g").cron("0 * * * * ?").build())); - String wire = AGENT_NAME + "-g"; - schedules.delete(wire); - assertThrows(ScheduleException.NotFound.class, () -> schedules.get(wire)); - } - - @Test - void previewNext() { - List times = schedules.previewNext("0 0 9 * * ?", 3); - assertEquals(3, times.size()); - for (int i = 1; i < times.size(); i++) { - assertTrue(times.get(i) > times.get(i - 1)); - } - } -} diff --git a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/schedule/ScheduleRunNowTest.java b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/schedule/ScheduleRunNowTest.java deleted file mode 100644 index ccea66774..000000000 --- a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/schedule/ScheduleRunNowTest.java +++ /dev/null @@ -1,190 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

    - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

    - * http://www.apache.org/licenses/LICENSE-2.0 - *

    - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.ai.schedule; - -import java.util.Map; - -import org.conductoross.conductor.ai.enums.AgentStatus; -import org.conductoross.conductor.ai.model.AgentResult; -import org.junit.jupiter.api.Test; - -import com.netflix.conductor.client.http.WorkflowClient; -import com.netflix.conductor.common.metadata.workflow.StartWorkflowRequest; -import com.netflix.conductor.common.run.Workflow; -import com.netflix.conductor.common.run.Workflow.WorkflowStatus; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Pure unit tests for the name-keyed {@code runNow} overloads (Fix 4). - * - *

    No network: the {@link WorkflowClient} is subclassed to stub - * {@code startWorkflow} / {@code getWorkflow}, and {@link Schedules} is - * subclassed to stub the {@code get(name)} lookup. - */ -class ScheduleRunNowTest { - - private static ScheduleInfo info(String agent) { - return new ScheduleInfo( - agent + "-daily", - "daily", - agent, - "0 0 9 * * ?", - "UTC", - Map.of("k", "v"), - false, - null, - false, - null, - null, - null, - null, - null, - null, - null, - null); - } - - /** A WorkflowClient that records start requests and serves canned getWorkflow results. */ - private static final class FakeWorkflowClient extends WorkflowClient { - String startedName; - Map startedInput; - Workflow[] statusSequence; - int polls = 0; - - FakeWorkflowClient() { - // Avoid touching any real ConductorClient/network. - super(new com.netflix.conductor.client.http.ConductorClient("http://localhost:0")); - } - - @Override - public String startWorkflow(StartWorkflowRequest req) { - this.startedName = req.getName(); - this.startedInput = req.getInput(); - return "wf-123"; - } - - @Override - public Workflow getWorkflow(String workflowId, boolean includeTasks) { - Workflow wf = statusSequence[Math.min(polls, statusSequence.length - 1)]; - polls++; - return wf; - } - } - - private static Workflow wf(WorkflowStatus status) { - Workflow w = new Workflow(); - w.setStatus(status); - w.setWorkflowId("wf-123"); - return w; - } - - private static Workflow wfWithOutput(WorkflowStatus status, Map output) { - Workflow w = wf(status); - w.setOutput(output); - return w; - } - - /** Schedules subclass that returns a canned ScheduleInfo for get(name). */ - private static Schedules schedulesWith(FakeWorkflowClient fwc, ScheduleInfo canned) { - return new Schedules(new com.netflix.conductor.client.http.ConductorClient("http://localhost:0"), fwc) { - @Override - public ScheduleInfo get(String wireName) { - return canned; - } - }; - } - - @Test - void runNowByName_startsWorkflowWithStoredInput_returnsExecutionId() { - FakeWorkflowClient fwc = new FakeWorkflowClient(); - Schedules schedules = schedulesWith(fwc, info("my_agent")); - - String executionId = schedules.runNow("my_agent-daily"); - - assertEquals("wf-123", executionId); - assertEquals("my_agent", fwc.startedName, "must start the schedule's agent workflow"); - assertEquals(Map.of("k", "v"), fwc.startedInput, "must use the schedule's stored input"); - } - - @Test - void runNowByName_noWait_returnsExecutionId() { - FakeWorkflowClient fwc = new FakeWorkflowClient(); - Schedules schedules = schedulesWith(fwc, info("my_agent")); - - Object result = schedules.runNow("my_agent-daily", false); - assertEquals("wf-123", result); - assertEquals(0, fwc.polls, "non-wait must not poll for status"); - } - - @Test - void runNowAndWait_pollsToTerminalAndReturnsAgentResult() { - FakeWorkflowClient fwc = new FakeWorkflowClient(); - Workflow running = wf(WorkflowStatus.RUNNING); - Workflow done = wfWithOutput(WorkflowStatus.COMPLETED, Map.of("result", "done")); - fwc.statusSequence = new Workflow[] {running, running, done}; - - Schedules schedules = schedulesWith(fwc, info("my_agent")); - - // 0ms poll interval keeps the test fast/deterministic. - AgentResult result = schedules.runNowAndWait("my_agent-daily", 5_000L, 0L); - - assertEquals(3, fwc.polls, "must poll until terminal"); - assertEquals(AgentStatus.COMPLETED, result.getStatus(), "terminal workflow → COMPLETED"); - assertTrue(result.isSuccess()); - assertEquals("wf-123", result.getExecutionId()); - assertEquals(Map.of("result", "done"), result.getOutput(), "output carried from the workflow"); - } - - @Test - void runNowAndWait_failedWorkflow_mapsToFailedResult() { - FakeWorkflowClient fwc = new FakeWorkflowClient(); - Workflow failed = wf(WorkflowStatus.FAILED); - failed.setReasonForIncompletion("boom"); - fwc.statusSequence = new Workflow[] {failed}; - - Schedules schedules = schedulesWith(fwc, info("my_agent")); - - AgentResult result = schedules.runNowAndWait("my_agent-daily", 5_000L, 0L); - - assertEquals(AgentStatus.FAILED, result.getStatus()); - assertFalse(result.isSuccess()); - assertEquals("boom", result.getError()); - } - - @Test - void runNowAndWait_timesOut() { - FakeWorkflowClient fwc = new FakeWorkflowClient(); - fwc.statusSequence = new Workflow[] {wf(WorkflowStatus.RUNNING)}; - - Schedules schedules = schedulesWith(fwc, info("my_agent")); - - assertThrows( - ScheduleException.class, - () -> schedules.runNowAndWait("my_agent-daily", 0L, 0L), - "must raise once the deadline passes without a terminal state"); - } - - @Test - void isTerminal_helper() { - assertTrue(Schedules.isTerminal(wf(WorkflowStatus.COMPLETED))); - assertTrue(Schedules.isTerminal(wf(WorkflowStatus.FAILED))); - assertTrue(Schedules.isTerminal(wf(WorkflowStatus.TERMINATED))); - assertTrue(Schedules.isTerminal(wf(WorkflowStatus.TIMED_OUT))); - assertFalse(Schedules.isTerminal(wf(WorkflowStatus.RUNNING))); - assertFalse(Schedules.isTerminal(wf(WorkflowStatus.PAUSED))); - } -} diff --git a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/schedule/ScheduleTest.java b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/schedule/ScheduleTest.java deleted file mode 100644 index eb03e4bcd..000000000 --- a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/schedule/ScheduleTest.java +++ /dev/null @@ -1,231 +0,0 @@ -/* - * Copyright 2026 Conductor Authors. - *

    - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

    - * http://www.apache.org/licenses/LICENSE-2.0 - *

    - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on - * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the - * specific language governing permissions and limitations under the License. - */ -package org.conductoross.conductor.ai.schedule; - -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** Unit tests for Schedule + Schedules helpers (no network). */ -class ScheduleTest { - - // ── Schedule construction ───────────────────────────────────────── - - @Test - void minimal() { - Schedule s = Schedule.builder().name("daily").cron("0 0 9 * * ?").build(); - assertEquals("daily", s.getName()); - assertEquals("UTC", s.getTimezone()); - assertFalse(s.isCatchup()); - assertFalse(s.isPaused()); - assertTrue(s.getInput().isEmpty()); - } - - @Test - void full() { - Map input = new HashMap<>(); - input.put("c", "#eng"); - Schedule s = Schedule.builder() - .name("w") - .cron("0 0 9 * * MON") - .timezone("America/Los_Angeles") - .input(input) - .catchup(true) - .paused(true) - .startAt(1000L) - .endAt(2000L) - .description("desc") - .build(); - assertEquals("America/Los_Angeles", s.getTimezone()); - assertEquals(input, s.getInput()); - assertTrue(s.isCatchup()); - assertTrue(s.isPaused()); - assertEquals(1000L, s.getStartAt()); - assertEquals(2000L, s.getEndAt()); - } - - @Test - void rejectsEmptyName() { - assertThrows( - ScheduleException.class, - () -> Schedule.builder().name("").cron("* * * * * ?").build()); - assertThrows( - ScheduleException.class, - () -> Schedule.builder().name(" ").cron("* * * * * ?").build()); - } - - @Test - void rejectsEmptyCron() { - assertThrows( - ScheduleException.class, - () -> Schedule.builder().name("x").cron("").build()); - } - - @Test - void rejectsInvertedWindow() { - assertThrows(ScheduleException.class, () -> Schedule.builder() - .name("x") - .cron("* * * * * ?") - .startAt(2000L) - .endAt(1000L) - .build()); - assertThrows(ScheduleException.class, () -> Schedule.builder() - .name("x") - .cron("* * * * * ?") - .startAt(1000L) - .endAt(1000L) - .build()); - } - - // ── Wire-name prefix/unprefix ───────────────────────────────────── - - @Test - void prefixRoundtrips() { - assertEquals("digest-daily", Schedules.prefix("digest", "daily")); - assertEquals("daily", Schedules.unprefix("digest", "digest-daily")); - } - - @Test - void unprefixNoMatchReturnsInput() { - assertEquals("unrelated", Schedules.unprefix("agent", "unrelated")); - } - - @Test - void agentNameWithHyphen() { - String wire = Schedules.prefix("my-agent", "daily"); - assertEquals("my-agent-daily", wire); - assertEquals("daily", Schedules.unprefix("my-agent", wire)); - } - - // ── Payload mapping ─────────────────────────────────────────────── - - @Test - void toSaveRequestMinimal() { - Schedule s = Schedule.builder().name("daily").cron("0 0 9 * * ?").build(); - Map req = Schedules.toSaveRequest(s, "digest"); - assertEquals("digest-daily", req.get("name")); - assertEquals("0 0 9 * * ?", req.get("cronExpression")); - assertEquals("UTC", req.get("zoneId")); - assertEquals(false, req.get("paused")); - assertEquals(false, req.get("runCatchupScheduleInstances")); - @SuppressWarnings("unchecked") - Map swr = (Map) req.get("startWorkflowRequest"); - assertEquals("digest", swr.get("name")); - assertTrue(((Map) swr.get("input")).isEmpty()); - } - - @Test - void toSaveRequestFull() { - Map input = new LinkedHashMap<>(); - input.put("c", "#eng"); - input.put("n", 42); - Schedule s = Schedule.builder() - .name("w") - .cron("0 0 9 * * MON") - .timezone("America/Los_Angeles") - .input(input) - .catchup(true) - .paused(true) - .startAt(1000L) - .endAt(2000L) - .description("desc") - .build(); - Map req = Schedules.toSaveRequest(s, "digest"); - assertEquals("America/Los_Angeles", req.get("zoneId")); - assertEquals(true, req.get("paused")); - assertEquals(true, req.get("runCatchupScheduleInstances")); - assertEquals(1000L, req.get("scheduleStartTime")); - assertEquals(2000L, req.get("scheduleEndTime")); - assertEquals("desc", req.get("description")); - } - - @Test - void inputCopiedNotShared() { - Map original = new LinkedHashMap<>(); - original.put("a", 1); - Schedule s = - Schedule.builder().name("x").cron("* * * * * ?").input(original).build(); - Map req = Schedules.toSaveRequest(s, "agent"); - @SuppressWarnings("unchecked") - Map swrInput = - (Map) ((Map) req.get("startWorkflowRequest")).get("input"); - swrInput.put("mutated", true); - assertNull(original.get("mutated")); - } - - @Test - void fromWorkflowScheduleBasic() { - Map ws = new LinkedHashMap<>(); - ws.put("name", "digest-daily"); - ws.put("cronExpression", "0 0 9 * * ?"); - ws.put("zoneId", "UTC"); - ws.put("paused", false); - Map swr = new LinkedHashMap<>(); - swr.put("name", "digest"); - Map input = new LinkedHashMap<>(); - input.put("c", "#eng"); - swr.put("input", input); - ws.put("startWorkflowRequest", swr); - ws.put("createTime", 111L); - ws.put("createdBy", "alice"); - - ScheduleInfo info = Schedules.fromWorkflowSchedule(ws, "digest"); - assertEquals("digest-daily", info.getName()); - assertEquals("daily", info.getShortName()); - assertEquals("digest", info.getAgent()); - assertEquals("0 0 9 * * ?", info.getCron()); - assertFalse(info.isPaused()); - assertEquals(input, info.getInput()); - assertEquals(111L, info.getCreateTime()); - assertEquals("alice", info.getCreatedBy()); - } - - @Test - void fromWorkflowScheduleDerivesAgentWhenOmitted() { - Map ws = new LinkedHashMap<>(); - ws.put("name", "digest-daily"); - Map swr = new LinkedHashMap<>(); - swr.put("name", "digest"); - ws.put("startWorkflowRequest", swr); - ScheduleInfo info = Schedules.fromWorkflowSchedule(ws, null); - assertEquals("digest", info.getAgent()); - assertEquals("daily", info.getShortName()); - } - - // ── Unique-name validation ──────────────────────────────────────── - - @Test - void distinctNamesOk() { - Schedules.checkUniqueNames(List.of( - Schedule.builder().name("a").cron("* * * * * ?").build(), - Schedule.builder().name("b").cron("* * * * * ?").build())); - } - - @Test - void duplicateNameRaises() { - assertThrows( - ScheduleException.NameConflict.class, - () -> Schedules.checkUniqueNames(List.of( - Schedule.builder().name("a").cron("* * * * * ?").build(), - Schedule.builder().name("a").cron("0 0 9 * * ?").build()))); - } -} diff --git a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/tools/ToolsTest.java b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/tools/ToolsTest.java index d85df3e2f..5a775a639 100644 --- a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/tools/ToolsTest.java +++ b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/tools/ToolsTest.java @@ -12,6 +12,12 @@ */ package org.conductoross.conductor.ai.tools; +import java.util.List; +import java.util.Map; + +import org.conductoross.conductor.ai.Agent; +import org.conductoross.conductor.ai.guardrail.Guardrail; +import org.conductoross.conductor.ai.model.GuardrailResult; import org.conductoross.conductor.ai.model.ToolDef; import org.junit.jupiter.api.Test; @@ -74,4 +80,49 @@ void imageToolShape() { assertEquals("img", t.getName()); assertNotNull(t.getToolType()); } + + @Test + void withGuardrails_preserves_every_tool_field() { + Agent child = Agent.builder().name("child").model("openai/gpt-4o-mini").build(); + ToolDef original = ToolDef.builder() + .name("complete") + .description("original description") + .inputSchema(Map.of("type", "object")) + .outputSchema(Map.of("type", "string")) + .func(input -> "ok") + .approvalRequired(true) + .timeoutSeconds(42) + .retryCount(5) + .retryDelaySeconds(7) + .retryPolicy("exponential_backoff") + .toolType("agent_tool") + .config(Map.of("endpoint", "https://example.test")) + .credentials(List.of("API_KEY")) + .maxCalls(3) + .agentRef(child) + .stateful(true) + .build(); + + ToolDef guarded = original.withGuardrails(List.of( + Guardrail.of("safe", content -> GuardrailResult.pass()).build())); + + assertEquals(original.getName(), guarded.getName()); + assertEquals(original.getDescription(), guarded.getDescription()); + assertEquals(original.getInputSchema(), guarded.getInputSchema()); + assertEquals(original.getOutputSchema(), guarded.getOutputSchema()); + assertSame(original.getFunc(), guarded.getFunc()); + assertEquals(original.isApprovalRequired(), guarded.isApprovalRequired()); + assertEquals(original.getTimeoutSeconds(), guarded.getTimeoutSeconds()); + assertEquals(original.getRetryCount(), guarded.getRetryCount()); + assertEquals(original.getRetryDelaySeconds(), guarded.getRetryDelaySeconds()); + assertEquals(original.getRetryPolicy(), guarded.getRetryPolicy()); + assertEquals(original.getToolType(), guarded.getToolType()); + assertEquals(original.getConfig(), guarded.getConfig()); + assertEquals(original.getCredentials(), guarded.getCredentials()); + assertEquals(original.getMaxCalls(), guarded.getMaxCalls()); + assertSame(original.getAgentRef(), guarded.getAgentRef()); + assertEquals(original.isStateful(), guarded.isStateful()); + assertEquals(1, guarded.getGuardrails().size()); + assertThrows(UnsupportedOperationException.class, () -> guarded.getGuardrails().add(null)); + } } diff --git a/conductor-client/src/main/java/com/netflix/conductor/client/automator/TaskRunner.java b/conductor-client/src/main/java/com/netflix/conductor/client/automator/TaskRunner.java index c3b288cb0..e8b70f28b 100644 --- a/conductor-client/src/main/java/com/netflix/conductor/client/automator/TaskRunner.java +++ b/conductor-client/src/main/java/com/netflix/conductor/client/automator/TaskRunner.java @@ -290,7 +290,7 @@ private List pollTasksForWorker() { permits.release(pollCount - tasks.size()); //release extra permits stopwatch.stop(); long elapsed = stopwatch.elapsed(TimeUnit.MILLISECONDS); - LOGGER.debug("Time taken to poll {} task with a batch size of {} is {} ms", taskType, tasks.size(), elapsed); + LOGGER.trace("Time taken to poll {} task with a batch size of {} is {} ms", taskType, tasks.size(), elapsed); eventDispatcher.publish(new PollCompleted(taskType, elapsed)); } catch (Throwable e) { permits.release(pollCount - tasks.size()); diff --git a/conductor-client/src/main/java/com/netflix/conductor/client/http/ConductorClient.java b/conductor-client/src/main/java/com/netflix/conductor/client/http/ConductorClient.java index 90bd776b8..aaa3073d3 100644 --- a/conductor-client/src/main/java/com/netflix/conductor/client/http/ConductorClient.java +++ b/conductor-client/src/main/java/com/netflix/conductor/client/http/ConductorClient.java @@ -735,14 +735,13 @@ protected void validateAndAssignDefaults() { /** * Resolves the server base path from environment: {@code CONDUCTOR_SERVER_URL} - * → {@code AGENTSPAN_SERVER_URL} (legacy fallback) → {@code http://localhost:8080/api}. + * → {@code http://localhost:8080/api}. * Blank values are treated as unset. The resolved URL is normalized to end in * {@code /api}, so both {@code http://host:8080} and {@code http://host:8080/api} * forms are accepted. */ protected void applyEnvVariables() { - String serverUrl = envOrDefault("CONDUCTOR_SERVER_URL", - envOrDefault("AGENTSPAN_SERVER_URL", null)); + String serverUrl = envOrDefault("CONDUCTOR_SERVER_URL", null); this.basePath(normalizeServerUrl(serverUrl)); } diff --git a/conductor-client/src/main/java/com/netflix/conductor/client/worker/Worker.java b/conductor-client/src/main/java/com/netflix/conductor/client/worker/Worker.java index cfeda2603..bc5a7cb8d 100644 --- a/conductor-client/src/main/java/com/netflix/conductor/client/worker/Worker.java +++ b/conductor-client/src/main/java/com/netflix/conductor/client/worker/Worker.java @@ -86,7 +86,7 @@ default String getIdentity() { serverId = System.getenv("HOSTNAME"); } - LoggerHolder.logger.debug("Setting worker id to {}", serverId); + LoggerHolder.logger.trace("Setting worker id to {}", serverId); return serverId; } diff --git a/conductor-client/src/main/java/com/netflix/conductor/common/metadata/tasks/Task.java b/conductor-client/src/main/java/com/netflix/conductor/common/metadata/tasks/Task.java index f441c836e..720d8df23 100644 --- a/conductor-client/src/main/java/com/netflix/conductor/common/metadata/tasks/Task.java +++ b/conductor-client/src/main/java/com/netflix/conductor/common/metadata/tasks/Task.java @@ -180,8 +180,8 @@ public boolean isRetriable() { private long firstStartTime; /** - * Secret values delivered by a capable host (agentspan > 0.4.2, conductor-oss - * PR #1255) for the names declared on {@code TaskDef.runtimeMetadata} — resolved + * Secret values delivered by a Conductor OSS host with PR #1255 for the names declared on + * {@code TaskDef.runtimeMetadata} — resolved * at poll time, wire-only, never persisted to task input. */ private Map runtimeMetadata; diff --git a/conductor-client/src/main/java/com/netflix/conductor/common/metadata/tasks/TaskDef.java b/conductor-client/src/main/java/com/netflix/conductor/common/metadata/tasks/TaskDef.java index 1dd5ae994..b41968572 100644 --- a/conductor-client/src/main/java/com/netflix/conductor/common/metadata/tasks/TaskDef.java +++ b/conductor-client/src/main/java/com/netflix/conductor/common/metadata/tasks/TaskDef.java @@ -109,8 +109,8 @@ public enum RetryLogic { private long totalTimeoutSeconds; /** - * Declared secret names for the task's worker. Capable hosts (agentspan > 0.4.2, - * conductor-oss PR #1255) resolve these at poll time and deliver the values on the + * Declared secret names for the task's worker. Conductor OSS hosts with PR #1255 resolve + * these at poll time and deliver the values on the * wire-only {@code Task.runtimeMetadata} map — never persisted to task input. */ private List runtimeMetadata; @@ -180,4 +180,4 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(getName(), getDescription(), getRetryCount(), getTimeoutSeconds(), getInputKeys(), getOutputKeys(), getTimeoutPolicy(), getRetryLogic(), getRetryDelaySeconds(), getBackoffScaleFactor(), getResponseTimeoutSeconds(), getConcurrentExecLimit(), getRateLimitPerFrequency(), getInputTemplate(), getIsolationGroupId(), getExecutionNameSpace(), getOwnerEmail(), getBaseType(), getInputSchema(), getOutputSchema(), getTotalTimeoutSeconds()); } -} \ No newline at end of file +} diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/ApiClient.java b/conductor-client/src/main/java/io/orkes/conductor/client/ApiClient.java index 3252a7ec9..191ad2178 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/ApiClient.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/ApiClient.java @@ -44,7 +44,7 @@ * users of orkes-conductor-client v2. */ @Slf4j -public final class ApiClient extends ConductorClient { +public class ApiClient extends ConductorClient { private final OrkesAuthentication authentication; @@ -209,19 +209,15 @@ public ApiClient build() { /** * Resolves credentials from environment on top of the base-path chain: - * {@code CONDUCTOR_AUTH_KEY} → {@code CONDUCTOR_SERVER_AUTH_KEY} → - * {@code AGENTSPAN_AUTH_KEY} (legacy fallback), same order for the secret. + * {@code CONDUCTOR_AUTH_KEY} → {@code CONDUCTOR_SERVER_AUTH_KEY}, same order for the secret. */ @Override protected void applyEnvVariables() { super.applyEnvVariables(); - String authKey = envOrDefault("CONDUCTOR_AUTH_KEY", - envOrDefault("CONDUCTOR_SERVER_AUTH_KEY", - envOrDefault("AGENTSPAN_AUTH_KEY", null))); - String authSecret = envOrDefault("CONDUCTOR_AUTH_SECRET", - envOrDefault("CONDUCTOR_SERVER_AUTH_SECRET", - envOrDefault("AGENTSPAN_AUTH_SECRET", null))); + String authKey = envOrDefault("CONDUCTOR_AUTH_KEY", envOrDefault("CONDUCTOR_SERVER_AUTH_KEY", null)); + String authSecret = envOrDefault( + "CONDUCTOR_AUTH_SECRET", envOrDefault("CONDUCTOR_SERVER_AUTH_SECRET", null)); if (authKey != null && authSecret != null) { this.credentials(authKey, authSecret); diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/SchedulerClient.java b/conductor-client/src/main/java/io/orkes/conductor/client/SchedulerClient.java index 878176fd6..9568d1c1a 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/SchedulerClient.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/SchedulerClient.java @@ -92,6 +92,19 @@ public interface SchedulerClient { */ void pauseSchedule(String name); + /** + * Pauses a specific schedule and records an optional human-readable reason. + * + *

    The default preserves source and binary compatibility for custom client + * implementations that only support pausing without a reason. + * + * @param name the name of the schedule to pause + * @param reason optional reason for pausing the schedule + */ + default void pauseSchedule(String name, String reason) { + pauseSchedule(name); + } + /** * Resumes a paused schedule, allowing new workflow executions. * diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/exceptions/AgentAPIException.java b/conductor-client/src/main/java/io/orkes/conductor/client/exceptions/AgentAPIException.java index b969320ac..ad52105b6 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/exceptions/AgentAPIException.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/exceptions/AgentAPIException.java @@ -13,9 +13,9 @@ package io.orkes.conductor.client.exceptions; /** - * Thrown when the Agentspan server returns a non-2xx HTTP response. + * Thrown when the Conductor agent server returns a non-2xx HTTP response. */ -public class AgentAPIException extends AgentspanException { +public class AgentAPIException extends AgentException { private final int statusCode; private final String responseBody; diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/exceptions/AgentspanException.java b/conductor-client/src/main/java/io/orkes/conductor/client/exceptions/AgentException.java similarity index 72% rename from conductor-client/src/main/java/io/orkes/conductor/client/exceptions/AgentspanException.java rename to conductor-client/src/main/java/io/orkes/conductor/client/exceptions/AgentException.java index 288d55a8f..505020681 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/exceptions/AgentspanException.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/exceptions/AgentException.java @@ -1,5 +1,5 @@ /* - * Copyright 2025 Conductor Authors. + * Copyright 2026 Conductor Authors. *

    * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at @@ -12,16 +12,14 @@ */ package io.orkes.conductor.client.exceptions; -/** - * Base exception for all Agentspan SDK errors. - */ -public class AgentspanException extends RuntimeException { +/** Base exception for Conductor agent SDK errors. */ +public class AgentException extends RuntimeException { - public AgentspanException(String message) { + public AgentException(String message) { super(message); } - public AgentspanException(String message, Throwable cause) { + public AgentException(String message, Throwable cause) { super(message, cause); } } diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/exceptions/SSEUnavailableException.java b/conductor-client/src/main/java/io/orkes/conductor/client/exceptions/SSEUnavailableException.java index b09c845af..75f15219c 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/exceptions/SSEUnavailableException.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/exceptions/SSEUnavailableException.java @@ -18,7 +18,7 @@ * Python SDK. Callers should degrade to status polling rather than treating * the stream as silently empty. */ -public class SSEUnavailableException extends AgentspanException { +public class SSEUnavailableException extends AgentException { public SSEUnavailableException(String message) { super(message); diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/http/OrkesSchedulerClient.java b/conductor-client/src/main/java/io/orkes/conductor/client/http/OrkesSchedulerClient.java index bacbbdaaf..2778e4deb 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/http/OrkesSchedulerClient.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/http/OrkesSchedulerClient.java @@ -64,7 +64,12 @@ public void pauseAllSchedules() { @Override public void pauseSchedule(String name) { - schedulerResource.pauseSchedule(name); + pauseSchedule(name, null); + } + + @Override + public void pauseSchedule(String name, String reason) { + schedulerResource.pauseSchedule(name, reason); } @Override diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/http/SchedulerResource.java b/conductor-client/src/main/java/io/orkes/conductor/client/http/SchedulerResource.java index 2506c0df1..fe9d7db38 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/http/SchedulerResource.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/http/SchedulerResource.java @@ -15,6 +15,7 @@ import java.util.List; import java.util.Map; +import com.netflix.conductor.client.exception.ConductorClientException; import com.netflix.conductor.client.http.ConductorClient; import com.netflix.conductor.client.http.ConductorClientRequest; import com.netflix.conductor.client.http.ConductorClientRequest.Method; @@ -104,13 +105,24 @@ public Map pauseAllSchedules() { } public void pauseSchedule(String name) { - ConductorClientRequest request = ConductorClientRequest.builder() + pauseSchedule(name, null); + } + + public void pauseSchedule(String name, String reason) { + ConductorClientRequest getRequest = ConductorClientRequest.builder() .method(Method.GET) .path("/scheduler/schedules/{name}/pause") .addPathParam("name", name) + .addQueryParam("reason", reason) + .build(); + ConductorClientRequest putRequest = ConductorClientRequest.builder() + .method(Method.PUT) + .path("/scheduler/schedules/{name}/pause") + .addPathParam("name", name) + .addQueryParam("reason", reason) .build(); - client.execute(request); + executeGetThenPutOnMethodNotAllowed(getRequest, putRequest); } public Map requeueAllExecutionRecords() { @@ -138,13 +150,35 @@ public Map resumeAllSchedules() { } public void resumeSchedule(String name) { - ConductorClientRequest request = ConductorClientRequest.builder() + ConductorClientRequest getRequest = ConductorClientRequest.builder() .method(Method.GET) .path("/scheduler/schedules/{name}/resume") .addPathParam("name", name) .build(); + ConductorClientRequest putRequest = ConductorClientRequest.builder() + .method(Method.PUT) + .path("/scheduler/schedules/{name}/resume") + .addPathParam("name", name) + .build(); - client.execute(request); + executeGetThenPutOnMethodNotAllowed(getRequest, putRequest); + } + + /** + * Enterprise scheduler endpoints accept GET while OSS accepts PUT. Retry only + * a method-not-allowed response so application and authentication failures + * retain their original behavior. + */ + private void executeGetThenPutOnMethodNotAllowed( + ConductorClientRequest getRequest, ConductorClientRequest putRequest) { + try { + client.execute(getRequest); + } catch (ConductorClientException e) { + if (e.getStatus() != 405) { + throw e; + } + client.execute(putRequest); + } } public void saveSchedule(SaveScheduleRequest saveScheduleRequest) { diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/SaveScheduleRequest.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/SaveScheduleRequest.java index c1190ce99..b413dbea7 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/SaveScheduleRequest.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/SaveScheduleRequest.java @@ -104,4 +104,9 @@ public SaveScheduleRequest zoneId(String zoneId) { return this; } -} \ No newline at end of file + public SaveScheduleRequest description(String description) { + this.description = description; + return this; + } + +} diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/model/WorkflowSchedule.java b/conductor-client/src/main/java/io/orkes/conductor/client/model/WorkflowSchedule.java index f29e1c958..a12cba8ce 100644 --- a/conductor-client/src/main/java/io/orkes/conductor/client/model/WorkflowSchedule.java +++ b/conductor-client/src/main/java/io/orkes/conductor/client/model/WorkflowSchedule.java @@ -56,6 +56,8 @@ public class WorkflowSchedule { private String description; + private Long nextRunTime; + public WorkflowSchedule createTime(Long createTime) { this.createTime = createTime; return this; @@ -123,4 +125,9 @@ public WorkflowSchedule zoneId(String zoneId) { this.zoneId = zoneId; return this; } -} \ No newline at end of file + + public WorkflowSchedule nextRunTime(Long nextRunTime) { + this.nextRunTime = nextRunTime; + return this; + } +} diff --git a/conductor-client/src/test/java/io/orkes/conductor/client/ApiClientEnvResolutionTest.java b/conductor-client/src/test/java/io/orkes/conductor/client/ApiClientEnvResolutionTest.java index 160658b24..805cc4df0 100644 --- a/conductor-client/src/test/java/io/orkes/conductor/client/ApiClientEnvResolutionTest.java +++ b/conductor-client/src/test/java/io/orkes/conductor/client/ApiClientEnvResolutionTest.java @@ -21,9 +21,9 @@ import static org.junit.jupiter.api.Assertions.assertNull; /** - * Env-based client construction (spec R3): standard Conductor variables win, - * legacy Agentspan names are honored as fallbacks, and the default is the - * Conductor-standard {@code http://localhost:8080/api}. Exercised through the + * Env-based client construction (spec R3): standard Conductor variables configure + * the client, and the default is the Conductor-standard {@code http://localhost:8080/api}. + * Exercised through the * builder's env seam so process env is never mutated. */ class ApiClientEnvResolutionTest { @@ -43,21 +43,12 @@ private static ApiClient fromEnv(Map env) { } @Test - void conductorServerUrlWinsOverAgentspan() { - ApiClient client = fromEnv(Map.of( - "CONDUCTOR_SERVER_URL", "http://conductor-host:9999", - "AGENTSPAN_SERVER_URL", "http://agentspan-host:1111")); + void conductorServerUrlConfiguresClient() { + ApiClient client = fromEnv(Map.of("CONDUCTOR_SERVER_URL", "http://conductor-host:9999")); assertEquals("http://conductor-host:9999/api", client.getBasePath()); } - @Test - void agentspanServerUrlUsedWhenConductorUnset() { - ApiClient client = fromEnv(Map.of("AGENTSPAN_SERVER_URL", "http://agentspan-host:1111")); - - assertEquals("http://agentspan-host:1111/api", client.getBasePath()); - } - @Test void defaultIsConductorStandardLocalhost() { ApiClient client = fromEnv(Map.of()); @@ -69,15 +60,13 @@ void defaultIsConductorStandardLocalhost() { } @Test - void blankConductorUrlFallsThroughToAgentspan() { - ApiClient client = fromEnv(Map.of( - "CONDUCTOR_SERVER_URL", " ", - "AGENTSPAN_SERVER_URL", "http://agentspan-host:1111")); + void blankConductorUrlUsesDefault() { + ApiClient client = fromEnv(Map.of("CONDUCTOR_SERVER_URL", " ")); assertEquals( - "http://agentspan-host:1111/api", + "http://localhost:8080/api", client.getBasePath(), - "an exported-but-blank variable must not clobber the chain"); + "an exported-but-blank variable must not clobber the default"); } @Test @@ -89,13 +78,13 @@ void serverUrlNormalizedToApiSuffix() { } @Test - void agentspanAuthKeysUsedAsFallback() { + void conductorAuthKeysConfigureAuthentication() { ApiClient.ApiClientBuilder builder = builderWithEnv(Map.of( - "AGENTSPAN_AUTH_KEY", "legacy-key", - "AGENTSPAN_AUTH_SECRET", "legacy-secret")); + "CONDUCTOR_AUTH_KEY", "key", + "CONDUCTOR_AUTH_SECRET", "secret")); builder.build(); - assertNotNull(builder.authentication, "legacy Agentspan credentials must configure authentication"); + assertNotNull(builder.authentication, "Conductor credentials must configure authentication"); } @Test diff --git a/conductor-client/src/test/java/io/orkes/conductor/client/exceptions/AgentExceptionsTest.java b/conductor-client/src/test/java/io/orkes/conductor/client/exceptions/AgentExceptionsTest.java index 4ad07fd91..22197b1b8 100644 --- a/conductor-client/src/test/java/io/orkes/conductor/client/exceptions/AgentExceptionsTest.java +++ b/conductor-client/src/test/java/io/orkes/conductor/client/exceptions/AgentExceptionsTest.java @@ -24,7 +24,7 @@ void agentApiCarriesStatusAndBody() { AgentAPIException e = new AgentAPIException(500, "boom"); assertEquals(500, e.getStatusCode()); assertEquals("boom", e.getResponseBody()); - assertInstanceOf(AgentspanException.class, e); + assertInstanceOf(AgentException.class, e); } @Test @@ -32,13 +32,13 @@ void notFoundIsApiExceptionWith404() { AgentNotFoundException e = new AgentNotFoundException(404, "missing"); assertEquals(404, e.getStatusCode()); assertInstanceOf(AgentAPIException.class, e); - assertInstanceOf(AgentspanException.class, e); + assertInstanceOf(AgentException.class, e); } @Test void baseExceptionKeepsMessageAndCause() { Throwable cause = new IllegalStateException("c"); - AgentspanException e = new AgentspanException("m", cause); + AgentException e = new AgentException("m", cause); assertEquals("m", e.getMessage()); assertSame(cause, e.getCause()); } diff --git a/conductor-client/src/test/java/io/orkes/conductor/client/http/SchedulerResourceTest.java b/conductor-client/src/test/java/io/orkes/conductor/client/http/SchedulerResourceTest.java new file mode 100644 index 000000000..d62a19a14 --- /dev/null +++ b/conductor-client/src/test/java/io/orkes/conductor/client/http/SchedulerResourceTest.java @@ -0,0 +1,104 @@ +/* + * Copyright 2026 Conductor Authors. + *

    + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

    + * http://www.apache.org/licenses/LICENSE-2.0 + *

    + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package io.orkes.conductor.client.http; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.orkes.conductor.client.ApiClient; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SchedulerResourceTest { + + private MockWebServer server; + private OrkesSchedulerClient schedulerClient; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + schedulerClient = new OrkesSchedulerClient(ApiClient.builder() + .basePath(server.url("/api").toString()) + .build()); + } + + @AfterEach + void tearDown() throws IOException { + server.shutdown(); + } + + private RecordedRequest takeRequest() throws InterruptedException { + RecordedRequest request = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(request, "expected a request to reach the stub server"); + return request; + } + + @Test + void pauseSchedulePrefersGetAndSendsReason() throws InterruptedException { + server.enqueue(new MockResponse().setResponseCode(204)); + + schedulerClient.pauseSchedule("nightly", "maintenance window"); + + RecordedRequest request = takeRequest(); + assertEquals("GET", request.getMethod()); + assertEquals("/api/scheduler/schedules/nightly/pause?reason=maintenance%20window", request.getPath()); + } + + @Test + void pauseScheduleRetriesWithPutOnlyAfterMethodNotAllowed() throws InterruptedException { + server.enqueue(new MockResponse().setResponseCode(405)); + server.enqueue(new MockResponse().setResponseCode(204)); + + schedulerClient.pauseSchedule("nightly"); + + RecordedRequest get = takeRequest(); + RecordedRequest put = takeRequest(); + assertEquals("GET", get.getMethod()); + assertEquals("PUT", put.getMethod()); + assertEquals("/api/scheduler/schedules/nightly/pause", put.getPath()); + assertNull(put.getRequestUrl().queryParameter("reason")); + } + + @Test + void resumeScheduleUsesTheSameGetThenPutFallback() throws InterruptedException { + server.enqueue(new MockResponse().setResponseCode(405)); + server.enqueue(new MockResponse().setResponseCode(204)); + + schedulerClient.resumeSchedule("nightly"); + + assertEquals("GET", takeRequest().getMethod()); + assertEquals("PUT", takeRequest().getMethod()); + } + + @Test + void pauseScheduleDoesNotRetryNon405Failures() throws InterruptedException { + server.enqueue(new MockResponse().setResponseCode(500)); + + assertThrows(RuntimeException.class, () -> schedulerClient.pauseSchedule("nightly")); + + assertEquals("GET", takeRequest().getMethod()); + assertNull(server.takeRequest(100, TimeUnit.MILLISECONDS)); + } +} diff --git a/conductor-client/src/test/java/io/orkes/conductor/client/model/TestSerDerWorkflowSchedule.java b/conductor-client/src/test/java/io/orkes/conductor/client/model/TestSerDerWorkflowSchedule.java index fe18592ae..bbe7e5d87 100644 --- a/conductor-client/src/test/java/io/orkes/conductor/client/model/TestSerDerWorkflowSchedule.java +++ b/conductor-client/src/test/java/io/orkes/conductor/client/model/TestSerDerWorkflowSchedule.java @@ -51,6 +51,7 @@ public void testSerializationDeserialization() throws Exception { assertNotNull(workflowSchedule.getStartWorkflowRequest()); assertNotNull(workflowSchedule.getUpdatedBy()); assertNotNull(workflowSchedule.getUpdatedTime()); + assertNotNull(workflowSchedule.getNextRunTime()); assertNotNull(workflowSchedule.getZoneId()); // 3. Marshall this POJO to JSON again @@ -62,4 +63,4 @@ public void testSerializationDeserialization() throws Exception { JsonNode deserializedJson = objectMapper.readTree(serializedJson); assertEquals(originalJson, deserializedJson); } -} \ No newline at end of file +} diff --git a/conductor-client/src/test/java/io/orkes/conductor/client/model/agent/AgentModelJsonTest.java b/conductor-client/src/test/java/io/orkes/conductor/client/model/agent/AgentModelJsonTest.java index b949231cc..0a365d88b 100644 --- a/conductor-client/src/test/java/io/orkes/conductor/client/model/agent/AgentModelJsonTest.java +++ b/conductor-client/src/test/java/io/orkes/conductor/client/model/agent/AgentModelJsonTest.java @@ -95,6 +95,7 @@ public void serializesAgentRequestWithDefaultNamesAndExplicitWireExceptions() th .prompt("hello") .sessionId("session-1") .staticPlan(Map.of("step", 1)) + .idempotencyKey("logical-run-123") .timeoutSeconds(30) .build(); @@ -105,9 +106,11 @@ public void serializesAgentRequestWithDefaultNamesAndExplicitWireExceptions() th assertEquals("hello", json.get("prompt").asText()); assertEquals("session-1", json.get("sessionId").asText()); assertEquals(1, json.get("static_plan").get("step").asInt()); + assertEquals("logical-run-123", json.get("idempotencyKey").asText()); assertEquals(30, json.get("timeoutSeconds").asInt()); assertFalse(json.has("staticPlan")); assertFalse(json.has("agentConfig")); + assertFalse(json.get("rawConfig").has("idempotencyKey")); } @Test diff --git a/conductor-client/src/test/resources/ser_deser_json_string.json b/conductor-client/src/test/resources/ser_deser_json_string.json index fe566189f..760097576 100644 --- a/conductor-client/src/test/resources/ser_deser_json_string.json +++ b/conductor-client/src/test/resources/ser_deser_json_string.json @@ -1978,6 +1978,7 @@ "WorkflowSchedule": { "content": { "updatedTime": 123, + "nextRunTime": 123, "paused": true, "updatedBy": "sample_updatedBy", "description": "sample_description", @@ -2212,4 +2213,4 @@ "inherits": ["LLMWorkerInputV2"] } } -} \ No newline at end of file +} diff --git a/docs/agents/README.md b/docs/agents/README.md index 5b61e7732..73c57ec25 100644 --- a/docs/agents/README.md +++ b/docs/agents/README.md @@ -1,12 +1,12 @@ -# Agentspan Java SDK +# Conductor Java Agent SDK -Java SDK for [Agentspan](https://agentspan.ai) — a durable runtime for AI agents, built for Conductor. Build, deploy, and run agents that survive crashes, scale across machines, and pause for human approval. +Java SDK for Conductor — a durable runtime for AI agents. Build, deploy, and run agents that survive crashes, scale across machines, and pause for human approval. ## Requirements - Java 21+ - Maven 3.6+ or Gradle 7+ -- A running Agentspan server +- A running Conductor server ## Installation @@ -76,7 +76,7 @@ Set environment variables: export CONDUCTOR_SERVER_URL=http://localhost:8080/api export CONDUCTOR_AUTH_KEY=your-key export CONDUCTOR_AUTH_SECRET=your-secret -export AGENTSPAN_LLM_MODEL=openai/gpt-4o +export CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o ``` Or configure programmatically. Connection (server URL + auth) is owned by the @@ -98,8 +98,7 @@ AgentRuntime runtime = new AgentRuntime(client, new AgentConfig(100, 5)); > Or just `new AgentRuntime()` / `new AgentRuntime(new AgentConfig(100, 5))` to > build the client from `CONDUCTOR_SERVER_URL` / `CONDUCTOR_AUTH_KEY` / -> `CONDUCTOR_AUTH_SECRET` (the legacy `AGENTSPAN_*` names are honored as -> fallbacks). +> `CONDUCTOR_AUTH_SECRET`. ## Tools diff --git a/docs/agents/agent-client-api.md b/docs/agents/agent-client-api.md index cd33d529e..bd739bbdc 100644 --- a/docs/agents/agent-client-api.md +++ b/docs/agents/agent-client-api.md @@ -1,6 +1,6 @@ # AgentClient — Control-Plane API Reference -`AgentClient` (`io.orkes.conductor.client.AgentClient`, in the `conductor-client` module) is the Java SDK's interface to the agent control-plane (`/api/agent/*`). Strictly scoped to five endpoints — compile, deploy, start, status, respond. Standard Conductor endpoints (`/api/workflow/*`, `/api/tasks`, etc.) are handled by the SDK's own typed clients (`WorkflowClient`, `TaskClient`, `MetadataClient`). Obtain one with `new AgentClient(conductorClient)` or `OrkesClients.getAgentClient()`. +`AgentClient` (`io.orkes.conductor.client.AgentClient`, in the `conductor-client` module) is the Java SDK's interface to the agent control-plane (`/api/agent/*`). Standard Conductor endpoints (`/api/workflow/*`, `/api/tasks`, etc.) remain on the SDK's typed clients (`WorkflowClient`, `TaskClient`, `MetadataClient`). Obtain an agent client with `new OrkesClients(conductorClient).getAgentClient()` or construct `OrkesAgentClient` directly. Every request goes through the shared `ConductorClient`'s native HTTP + auth + serialization layer. No hand-rolled HTTP. `ConductorClientException` is mapped to the typed `AgentAPIException` / `AgentNotFoundException` (`io.orkes.conductor.client.exceptions`). @@ -12,7 +12,13 @@ Every request goes through the shared `ConductorClient`'s native HTTP + auth + s | [`deployAgent`](#deployagent) | `POST /api/agent/deploy` | `AgentRequest` | `StartResponse` | Register workflow def without starting | | [`startAgent`](#startagent) | `POST /api/agent/start` | `AgentRequest` | `StartResponse` | Compile + register + start execution | | [`getAgentStatus`](#getagentstatus) | `GET /api/agent/{id}/status` | path: `executionId` | `AgentStatusResponse` | Poll execution status; includes HITL pending-tool | +| `getExecution` | `GET /api/agent/execution/{id}` | path: `executionId` | `Map` | Fetch the full execution tree | +| `listExecutions` | `GET /api/agent/executions` | query parameter map | `Map` | Search agent executions | | [`respond`](#respond) | `POST /api/agent/{id}/respond` | `RespondBody` | `void` | Resume a paused HITL task | +| [`cancelAgent`](#cancelagent) | `DELETE /api/agent/{id}/cancel` | path: `executionId`; optional `reason` query | `void` | Immediately cancel/terminate an execution | +| [`stopAgent`](#stopagent) | `POST /api/agent/{id}/stop` | path: `executionId` | `void` | Gracefully stop after the current iteration | +| `signalAgent` | `POST /api/agent/{id}/signal` | path: `executionId`; message body | `void` | Inject persistent context | +| `streamSse` | `GET /api/agent/stream/{id}` | path: `executionId`; optional last event ID | `SseClient` | Open the resumable SSE event stream | --- @@ -21,6 +27,11 @@ Every request goes through the shared `ConductorClient`'s native HTTP + auth + s Input to `compileAgent`, `deployAgent`, and `startAgent` (`io.orkes.conductor.client.model.agent.AgentRequest`). A pure transport DTO: the agent definition arrives pre-serialized as a JSON-ready map — domain serialization is owned by `conductor-client-ai` (`AgentRuntime.agentRequest(agent)` calls `AgentConfigSerializer.serialize(agent)` and resolves the `Framework` discriminator before building the request). ```java +// Already-deployed agent — version is optional +AgentRequest.deployedAgent("researcher", 3) + .prompt("Summarize the release risks") + .build() + // Native agent — AgentRuntime passes the serialized agent map AgentRequest.nativeAgent(serializedAgent).build() @@ -28,12 +39,19 @@ AgentRequest.nativeAgent(serializedAgent).build() AgentRequest.frameworkAgent("openai", serializedAgent).build() AgentRequest.frameworkAgent("langchain", serializedAgent).build() +// Framework skill reference instead of rawConfig +AgentRequest.frameworkAgent("skill", null) + .model("anthropic/claude-sonnet-4-6") + .skillRef(Map.of("name", "code-review", "version", 2)) + .build() + // With execution fields (for /start only) AgentRequest.nativeAgent(serializedAgent) .prompt("What is the capital of France?") .sessionId("session-abc") .runId("a1b2c3...") // per-execution domain UUID for stateful agents .staticPlan(plan.toJson()) // pre-serialized map, written as "static_plan" + .idempotencyKey("question-123") .build() ``` @@ -41,16 +59,22 @@ AgentRequest.nativeAgent(serializedAgent) | Factory | JSON emitted | |---|---| +| `deployedAgent(name, version)` | `"name": name, "version": version` (version omitted when null) | | `nativeAgent(config)` | `"agentConfig": config` | | `frameworkAgent(framework, config)` | `"framework": framework, "rawConfig": config` | +| `frameworkAgent(framework, null).skillRef(ref)` | `"framework": framework, "skillRef": ref` | **Field mapping to server `StartRequest`:** | `AgentRequest` field | Java type | JSON key | Server `StartRequest` field | Used by | |---|---|---|---|---| +| `name` | `String` | `"name"` | `name` | deployed agents | +| `version` | `Integer` | `"version"` | `version` | deployed agents (optional) | | `agentConfig` | `Object` (map) | `"agentConfig"` (native path) | `agentConfig` | native agents | | `framework` | `String` | `"framework"` (framework path) | `framework` | framework agents | | `rawConfig` | `Object` (map) | `"rawConfig"` (framework path) | `rawConfig` | framework agents | +| `model` | `String` | `"model"` | `model` | optional model override | +| `skillRef` | `Map` | `"skillRef"` | `skillRef` | framework skill agents | | `prompt` | `String` | `"prompt"` | `prompt` | start only | | `sessionId` | `String` | `"sessionId"` | `sessionId` | start (stateful) | | `runId` | `String` | `"runId"` | `runId` | start (stateful isolation) | @@ -61,7 +85,7 @@ AgentRequest.nativeAgent(serializedAgent) | `credentials` | `List` | `"credentials"` | `credentials` | compile / start | | `timeoutSeconds` | `Integer` | `"timeoutSeconds"` | `timeoutSeconds` | compile / start | -Null fields are never written — the class is annotated `@JsonInclude(NON_NULL)`. +Null fields are never written — the class is annotated `@JsonInclude(NON_NULL)`. The three request forms are mutually exclusive: use deployed `name`/`version`, inline native `agentConfig`, or framework `framework` plus `rawConfig`/`skillRef`. **`Framework` enum** — all seven values map 1-to-1 with the server's normalizer registry: @@ -184,16 +208,39 @@ Used by `AgentRuntime.startAsync(agent, prompt, plan)`. ### Request body — `AgentRequest` +Start an already-deployed agent without resending its definition: + +```java +StartResponse response = client.startAgent( + AgentRequest.deployedAgent("researcher", 3) + .prompt("What changed in the latest release?") + .build()); +``` + +```json +{ + "name": "researcher", + "version": 3, + "prompt": "What changed in the latest release?" +} +``` + +Inline definitions remain supported: + ```json { "agentConfig": { ... }, "prompt": "What is the capital of France?", "sessionId": "session-abc", "runId": "a1b2c3d4e5f6...", - "static_plan": { "steps": [...] } + "static_plan": { "steps": [...] }, + "idempotencyKey": "question-123" } ``` +The idempotency key is optional. Callers that retry the same logical start +should reuse the same stable value; the client does not generate one. + ### Response — `StartResponse` ```json @@ -221,7 +268,7 @@ Used by `AgentHandle.waitForResult()` and `AgentHandle.waitUntilWaiting()`. ### Response — `AgentStatusResponse` ```json -{ "executionId": "...", "status": "COMPLETED", "isComplete": true, "isRunning": false, "output": { ... } } +{ "executionId": "...", "status": "COMPLETED", "startTime": 1710000000000, "endTime": 1710000005000, "isComplete": true, "isRunning": false, "output": { ... } } ``` HITL paused: @@ -235,6 +282,8 @@ HITL paused: |---|---|---|---|---| | `executionId` | `getExecutionId()` | `String` | path param | — | | `status` | `getStatus()` | `String` | `workflow.getStatus().name()` | `RUNNING`, `COMPLETED`, `FAILED`, `TERMINATED`, `TIMED_OUT`, `PAUSED` | +| `startTime` | `getStartTime()` | `Long` | workflow start timestamp | Epoch milliseconds; nullable until supplied by the server | +| `endTime` | `getEndTime()` | `Long` | workflow end timestamp | Epoch milliseconds; nullable while execution is active | | `isComplete` | `isComplete()` | `boolean` | `workflow.getStatus().isTerminal()` | `true` for all terminal statuses | | `isRunning` | `isRunning()` | `boolean` | `status == RUNNING` | — | | `output` | `getOutput()` | `Map` | `workflow.getOutput()` | Only present when `isComplete() == true` | @@ -276,6 +325,34 @@ Used by `AgentHandle.approve()`, `.reject()`, `.respond(Map)` and `AgentStream.a --- +## cancelAgent + +Immediately cancel an agent execution. This is distinct from graceful stopping: cancellation terminates now and may record an operator reason. + +```java +client.cancelAgent(executionId, "Superseded by a newer request"); +``` + +**HTTP:** `DELETE /api/agent/{executionId}/cancel?reason=Superseded%20by%20a%20newer%20request` + +The `reason` query parameter is omitted when the Java argument is `null`, empty, or blank. HTTP 404 maps to `AgentNotFoundException`; other HTTP failures map to `AgentAPIException` through the normal client transport. + +--- + +## stopAgent + +Request a graceful deterministic stop after the current agent iteration finishes. Unlike cancellation, this preserves the current iteration and does not take a reason. + +```java +client.stopAgent(executionId); +``` + +**HTTP:** `POST /api/agent/{executionId}/stop` + +Use `stopAgent` when the current iteration should finish cleanly; use `cancelAgent` when work must terminate immediately. + +--- + ## WorkflowClient usage Raw workflow data (`GET /api/workflow/{id}`) is fetched via the standard Conductor `WorkflowClient` — not `AgentClient`. `AgentClient` owns only `/api/agent/*`. diff --git a/docs/agents/agent-runtime-api.md b/docs/agents/agent-runtime-api.md index f5792c1e4..32374291f 100644 --- a/docs/agents/agent-runtime-api.md +++ b/docs/agents/agent-runtime-api.md @@ -1,6 +1,6 @@ # AgentRuntime — API Reference -`AgentRuntime` is the primary entry point for the Agentspan Java SDK. It manages the connection to the Agentspan server, registers local tool workers, and exposes every operation for running, streaming, deploying, and serving agents. +`AgentRuntime` is the primary entry point for the Conductor Java Agent SDK. It manages the connection to the Conductor server, registers local tool workers, and exposes every operation for running, streaming, deploying, and serving agents. Implements `AutoCloseable` — always use try-with-resources or call `shutdown()` explicitly. @@ -58,22 +58,21 @@ Fully explicit — the canonical constructor all others delegate to. ### Environment variables -Standard Conductor variables win; the legacy Agentspan names are honored as -fallbacks. Invalid or empty values fall back to the default. +Invalid or empty values fall back to the default. | Variable | Default | Description | |---|---|---| -| `CONDUCTOR_SERVER_URL` → `AGENTSPAN_SERVER_URL` | `http://localhost:8080` | Conductor server base URL | -| `CONDUCTOR_AUTH_KEY` → `AGENTSPAN_AUTH_KEY` | _(none)_ | API key (optional) | -| `CONDUCTOR_AUTH_SECRET` → `AGENTSPAN_AUTH_SECRET` | _(none)_ | API secret (optional) | -| `AGENTSPAN_WORKER_POLL_INTERVAL` | `100` | Worker poll interval (ms) | -| `AGENTSPAN_WORKER_THREADS` | `1` | Worker thread count | -| `AGENTSPAN_AUTO_START_WORKERS` | `true` | Register + start workers on `run`/`start`/`stream` (`serve` always starts) | -| `AGENTSPAN_DAEMON_WORKERS` | `true` | SDK-owned background threads (SSE reader, liveness monitor) run as daemons | -| `AGENTSPAN_STREAMING_ENABLED` | `true` | Use SSE for `stream()`; `false` degrades to status polling | -| `AGENTSPAN_LIVENESS_ENABLED` | `true` | Watch stateful runs for worker stalls (`WorkerStallError`) | -| `AGENTSPAN_LIVENESS_STALL_SECONDS` | `30.0` | Seconds a task may sit unpolled before it counts as a stall | -| `AGENTSPAN_LIVENESS_CHECK_INTERVAL_SECONDS` | `10.0` | Seconds between liveness checks | +| `CONDUCTOR_SERVER_URL` | `http://localhost:8080` | Conductor server base URL | +| `CONDUCTOR_AUTH_KEY` | _(none)_ | API key (optional) | +| `CONDUCTOR_AUTH_SECRET` | _(none)_ | API secret (optional) | +| `CONDUCTOR_AGENT_WORKER_POLL_INTERVAL` | `100` | Worker poll interval (ms) | +| `CONDUCTOR_AGENT_WORKER_THREADS` | `1` | Worker thread count | +| `CONDUCTOR_AGENT_AUTO_START_WORKERS` | `true` | Register + start workers on `run`/`start`/`stream` (`serve` always starts) | +| `CONDUCTOR_AGENT_DAEMON_WORKERS` | `true` | SDK-owned background threads (SSE reader, liveness monitor) run as daemons | +| `CONDUCTOR_AGENT_STREAMING_ENABLED` | `true` | Use SSE for `stream()`; `false` degrades to status polling | +| `CONDUCTOR_AGENT_LIVENESS_ENABLED` | `true` | Watch stateful runs for worker stalls (`WorkerStallError`) | +| `CONDUCTOR_AGENT_LIVENESS_STALL_SECONDS` | `30.0` | Seconds a task may sit unpolled before it counts as a stall | +| `CONDUCTOR_AGENT_LIVENESS_CHECK_INTERVAL_SECONDS` | `10.0` | Seconds between liveness checks | --- @@ -82,7 +81,7 @@ fallbacks. Invalid or empty values fall back to the default. Build an `ApiClient` (via its own builder — client construction lives in the client layer, not on the runtime) to pass to the `AgentRuntime(ApiClient)` constructor. The `ApiClient` owns server URL, auth, and HTTP timeouts. ```java -// From environment: CONDUCTOR_SERVER_URL → AGENTSPAN_SERVER_URL → http://localhost:8080/api +// From environment: CONDUCTOR_SERVER_URL → http://localhost:8080/api // (same resolution the no-arg AgentRuntime() constructor uses internally) ApiClient client = ApiClient.builder().useEnvVariables(true).build(); @@ -119,7 +118,7 @@ AgentResult run(Agent agent, String prompt) // For PLAN_EXECUTE strategy — bypasses the planner LLM entirely AgentResult run(Agent agent, String prompt, Plan plan) -// Per-run LLM overrides — only non-null fields override the agent's settings. +// Per-run settings — LLM overrides plus optional execution metadata. // Also available on start()/stream() and every async variant. AgentResult run(Agent agent, String prompt, RunSettings runSettings) ``` @@ -128,9 +127,15 @@ AgentResult run(Agent agent, String prompt, RunSettings runSettings) runtime.run(agent, "Summarize this document", new RunSettings() .model("openai/gpt-4o") .temperature(0.2) - .maxTokens(2048)); + .maxTokens(2048) + .idempotencyKey("summarize-document-123")); ``` +`idempotencyKey` is optional execution metadata. It is sent as a top-level +field on `/api/agent/start`, never inside `agentConfig`. Reuse the same stable +key when retrying one logical execution. The runtime does not generate a key; +null, empty, and whitespace-only values are omitted. + **What happens internally:** 1. Workers for the agent's tools are registered with the Conductor task runner. 2. `POST /api/agent/start` — server compiles, registers, and starts the workflow. @@ -349,21 +354,19 @@ CompletableFuture resumeAsync(String executionId, Agent agent) --- -## schedules +## getSchedulerClient -Access the scheduling API lazily (created on first call, shared thereafter). +Access the shared typed workflow scheduler client. ```java -Schedules schedules = runtime.schedules(); - -schedules.list("my_agent"); // List -schedules.runNow(schedules.get("my_agent-daily")); // trigger immediately (takes ScheduleInfo) -schedules.pause("my_agent-daily"); -schedules.resume("my_agent-daily"); -schedules.delete("my_agent-daily"); +SchedulerClient schedules = runtime.getSchedulerClient(); +List schedulesForAgent = schedules.getAllSchedules("my_agent"); +schedules.pauseSchedule("my_agent-daily", "maintenance"); +schedules.resumeSchedule("my_agent-daily"); +schedules.deleteSchedule("my_agent-daily"); ``` -See [Scheduling concepts](concepts/scheduling.md) for the full `Schedules` API. +See [Scheduling concepts](concepts/scheduling.md) for direct `SaveScheduleRequest` usage. --- @@ -388,13 +391,13 @@ Worker-runner tuning. **Does not hold server URL or auth** — those are on `Api ```java new AgentConfig() // defaults: 100ms poll, 1 thread new AgentConfig(pollMs, threads) // explicit -AgentConfig.fromEnv() // reads AGENTSPAN_WORKER_* env vars +AgentConfig.fromEnv() // reads CONDUCTOR_AGENT_WORKER_* env vars ``` | Parameter | Env var | Default | Description | |---|---|---|---| -| `workerPollIntervalMs` | `AGENTSPAN_WORKER_POLL_INTERVAL` | `100` | How often workers poll for tasks (ms). 100ms is fast for dev; raise to 500–1000ms in production to reduce server load. | -| `workerThreadCount` | `AGENTSPAN_WORKER_THREADS` | `1` | Thread pool size. The actual pool is `max(configured, numWorkerTypes)` so every task type gets at least one thread. | +| `workerPollIntervalMs` | `CONDUCTOR_AGENT_WORKER_POLL_INTERVAL` | `100` | How often workers poll for tasks (ms). 100ms is fast for dev; raise to 500–1000ms in production to reduce server load. | +| `workerThreadCount` | `CONDUCTOR_AGENT_WORKER_THREADS` | `1` | Thread pool size. The actual pool is `max(configured, numWorkerTypes)` so every task type gets at least one thread. | --- diff --git a/docs/agents/agent-schema.json b/docs/agents/agent-schema.json index 420236d0f..41037175b 100644 --- a/docs/agents/agent-schema.json +++ b/docs/agents/agent-schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://agentspan.ai/schemas/agent-config.schema.json", + "$id": "urn:conductor:agent-config-schema", "title": "Conductor Agent AgentConfig", "description": "Canonical wire contract for the agent configuration that SDKs serialize and POST to the server (under the `agentConfig` key of the start/compile request). Mirrors the server-side `AgentConfig` model. Convention: camelCase keys, `@JsonInclude(NON_NULL)` — absent means unset. Recursive: `agents`, `planner`, `fallback`, `router` nest a full AgentConfig.", "type": "object", diff --git a/docs/agents/agent-schema.md b/docs/agents/agent-schema.md index f0797f69f..2bdd57863 100644 --- a/docs/agents/agent-schema.md +++ b/docs/agents/agent-schema.md @@ -18,9 +18,9 @@ is the reconciliation of three sources: | Source | File | |---|---| -| Server model (deserialization target) | `server/conductor-agentspan/src/main/java/dev/agentspan/runtime/model/AgentConfig.java` (+ nested `*Config` models) | +| Server model (deserialization target) | Conductor server `AgentConfig` model (+ nested `*Config` models) | | Java SDK emit | `conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/AgentConfigSerializer.java` | -| Python SDK emit | `sdk/python/src/agentspan/agents/config_serializer.py` | +| Python SDK emit | Python agent SDK `config_serializer.py` | ## Proof of correctness diff --git a/docs/agents/api-reference.md b/docs/agents/api-reference.md index 2c1f08a5d..abd9d1f12 100644 --- a/docs/agents/api-reference.md +++ b/docs/agents/api-reference.md @@ -1,6 +1,6 @@ # API Reference -Complete method signatures for the Agentspan Java SDK public API. +Complete method signatures for the Conductor Java Agent SDK public API. ## AgentRuntime @@ -17,7 +17,7 @@ AgentRuntime(ApiClient client, AgentConfig config) Build the `ApiClient` with its own builder (construction lives in the client layer): `ApiClient.builder().basePath("http://host:8080/api").credentials(key, secret).build()`, or `ApiClient.builder().useEnvVariables(true).build()` for the -`CONDUCTOR_SERVER_URL` → `AGENTSPAN_SERVER_URL` → `http://localhost:8080/api` chain. +`CONDUCTOR_SERVER_URL` → `http://localhost:8080/api` chain. ### Run @@ -49,7 +49,6 @@ CompletableFuture streamAsync(Agent agent, String prompt) ```java CompileResponse plan(Agent agent) // compile only, no run List deploy(Agent... agents) -DeploymentInfo deploy(Agent agent, List schedules) CompletableFuture> deployAsync(Agent... agents) void serve(Agent... agents) // blocks indefinitely ``` @@ -59,7 +58,7 @@ void serve(Agent... agents) // blocks indefi ```java AgentHandle resume(String executionId, Agent agent) CompletableFuture resumeAsync(String executionId, Agent agent) -Schedules schedules() +SchedulerClient getSchedulerClient() ``` ### Lifecycle @@ -71,6 +70,43 @@ void close() // alias for shutdown(); implements AutoCloseable --- +## AgentClient control plane + +Low-level access to `/api/agent/*`. Obtain it from +`new OrkesClients(conductorClient).getAgentClient()`. See the complete +[AgentClient reference](agent-client-api.md) for wire shapes and error mapping. + +```java +CompileResponse compileAgent(AgentRequest request) +StartResponse deployAgent(AgentRequest request) +StartResponse startAgent(AgentRequest request) +AgentStatusResponse getAgentStatus(String executionId) +Map getExecution(String executionId) +Map listExecutions(Map params) +void respond(String executionId, RespondBody body) +void cancelAgent(String executionId, String reason) // immediate DELETE +void stopAgent(String executionId) // graceful POST +void signalAgent(String executionId, String message) +SseClient streamSse(String executionId, String lastEventId) +``` + +`AgentRequest` supports three mutually exclusive definition forms: + +```java +AgentRequest.deployedAgent(String name, Integer version) +AgentRequest.nativeAgent(Object agentConfig) +AgentRequest.frameworkAgent(String framework, Object rawConfig) + +// Additional builder fields +.model(String model) +.skillRef(Map skillRef) +``` + +Null fields are omitted. `AgentStatusResponse.getStartTime()` and `getEndTime()` return nullable +epoch-millisecond timestamps. + +--- + ## Agent.Builder ```java @@ -382,33 +418,19 @@ new OnCondition(String targetAgent, Function,Boolean> predica --- -## Schedules +## SchedulerClient ```java -Schedules schedules = runtime.schedules(); - -schedules.save(Schedule schedule, String agentName) -schedules.get(String wireName) // → ScheduleInfo -schedules.list(String agentName) // → List -schedules.runNow(ScheduleInfo info) // → String executionId -schedules.pause(String wireName) -schedules.pause(String wireName, String reason) -schedules.resume(String wireName) -schedules.delete(String wireName) -schedules.previewNext(String cron, int n) // → List (epoch ms) - -// Schedule.builder() -Schedule.builder() - .name(String) // required - .cron(String) // required; standard 5-field cron - .timezone(String) // default "UTC" - .input(Map) - .description(String) - .paused(boolean) - .catchup(boolean) - .startAt(long) // epoch ms - .endAt(long) // epoch ms - .build() +SchedulerClient schedules = runtime.getSchedulerClient(); + +schedules.saveSchedule(SaveScheduleRequest request) +schedules.getSchedule(String name) // → WorkflowSchedule +schedules.getAllSchedules(String workflowName) // → List +schedules.pauseSchedule(String name) +schedules.pauseSchedule(String name, String reason) +schedules.resumeSchedule(String name) +schedules.deleteSchedule(String name) +schedules.getNextFewSchedules(cron, null, null, 5) // → List (epoch ms) ``` --- @@ -432,7 +454,7 @@ public String fetchIssue(String repo, ToolContext ctx) { Agent.builder().credentials("GITHUB_TOKEN", "JIRA_API_KEY")... // Store the secret once via the CLI: -// agentspan secrets set GITHUB_TOKEN ghp_xxxxx +// conductor secret put GITHUB_TOKEN ``` `ToolContext` also exposes `getSessionId()`, `getExecutionId()`, `getTaskId()`, and a @@ -466,7 +488,7 @@ OpenAIAgent.builder() .build() // Google ADK -Agent AdkBridge.toAgentspan(BaseAgent adkAgent) +Agent AdkBridge.toConductor(BaseAgent adkAgent) Agent.Builder AdkBridge.agentBuilder(BaseAgent adkAgent) // LangChain4j ChatModel @@ -484,7 +506,7 @@ or a LangGraph4j `AgentExecutor.Builder` (the latter two take trailing `@Tool` P ```java new AgentConfig() // defaults: 100ms poll, 1 thread new AgentConfig(int pollIntervalMs, int threads) -AgentConfig.fromEnv() // reads AGENTSPAN_WORKER_* env vars +AgentConfig.fromEnv() // reads CONDUCTOR_AGENT_WORKER_* env vars config.getWorkerPollIntervalMs() config.getWorkerThreadCount() diff --git a/docs/agents/concepts/agents.md b/docs/agents/concepts/agents.md index dc3687390..7790eacad 100644 --- a/docs/agents/concepts/agents.md +++ b/docs/agents/concepts/agents.md @@ -166,7 +166,7 @@ See [Guardrails](guardrails.md). ### Credentials -Declare which secrets the agent's tools require. The SDK fetches them from the Agentspan secrets store at runtime and injects them into tool context. +Declare which secrets the agent's tools require. The SDK receives them from the Conductor secrets store at runtime and injects them into tool context. ```java Agent agent = Agent.builder() @@ -256,7 +256,7 @@ try (AgentRuntime runtime = new AgentRuntime()) { | `deploy(Agent...)` | `List` | Register workflow definitions without running them. | | `serve(Agent...)` | `void` | Long-running worker mode — keeps polling indefinitely. | | `resume(executionId, agent)` | `AgentHandle` | Resume a suspended execution. | -| `schedules()` | `Schedules` | Access the scheduling API. | +| `getSchedulerClient()` | `SchedulerClient` | Access typed workflow-scheduling APIs. | ### AgentResult diff --git a/docs/agents/concepts/deploy-serve-run.md b/docs/agents/concepts/deploy-serve-run.md index 9802f6c64..e66b85c5c 100644 --- a/docs/agents/concepts/deploy-serve-run.md +++ b/docs/agents/concepts/deploy-serve-run.md @@ -30,9 +30,8 @@ workers or start anything. Idempotent — safe to call on every startup. ```java List infos = runtime.deploy(agentA, agentB); -// Deploy + reconcile cron schedules in one call (see Scheduling): -runtime.deploy(agent, List.of( - Schedule.builder().name("daily").cron("0 9 * * *").build())); +// Schedule deployment is explicit through the typed SchedulerClient. +runtime.deploy(agent); ``` ## serve — run the workers @@ -48,6 +47,21 @@ runtime.serve(agentA, agentB); // blocks until the process is killed A typical production split: one process calls `deploy(...)` at release time; one or more worker processes call `serve(...)`; executions are triggered by schedules or API. +An external service can start the deployed definition directly through the low-level control-plane +client without serializing or redeploying it: + +```java +AgentClient agents = new OrkesClients(conductorClient).getAgentClient(); +StartResponse started = agents.startAgent( + AgentRequest.deployedAgent("researcher", 3) + .prompt("Summarize today's incidents") + .build()); +System.out.println(started.getExecutionId()); +``` + +See [AgentClient](../agent-client-api.md#startagent) for deployed, native-inline, and framework +request forms. + ## run — register, start, and wait The all-in-one path for interactive use: register workers, start the execution, and block for the diff --git a/docs/agents/concepts/guardrails.md b/docs/agents/concepts/guardrails.md index c6fc7e736..a3e0da7d4 100644 --- a/docs/agents/concepts/guardrails.md +++ b/docs/agents/concepts/guardrails.md @@ -10,6 +10,31 @@ There are several kinds, each producing a `GuardrailDef`: - `Guardrail.of(name, func)` — shorthand builder for a custom Java function - `Guardrail.external(name)` — reference an existing Conductor worker as a guardrail +## Ownership and worker names + +- A guardrail with `func(...)` is **local**. `AgentRuntime` registers and starts its worker. + Agent-level workers are named `{agentName}_output_guardrail`; tool-level workers are named + `{toolName}_output_guardrail`. Multiple local guardrails at one scope run in declaration order. +- Regex and LLM-as-judge guardrails have no Java function, so they are **server-owned** and do + not start a Java worker. +- `Guardrail.external(name)` is **externally owned**: start a worker for the named task yourself; + the runtime deliberately does not duplicate it. + +Tool-scoped local guardrails work on every tool type, including HTTP, API, MCP, human, media, +RAG, and agent tools: + +```java +ToolDef guardedHttp = HttpTool.builder() + .name("lookup_customer") + .url("https://api.example.com/customers") + .method("GET") + .build() + .withGuardrails(List.of(Guardrail.of("no_pii", content -> + content.contains("ssn") ? GuardrailResult.fail("PII in tool output") : GuardrailResult.pass()) + .onFail(OnFail.RAISE) + .build())); +``` + ## Quick example ```java diff --git a/docs/agents/concepts/multi-agent.md b/docs/agents/concepts/multi-agent.md index d808b31c0..aa137ba82 100644 --- a/docs/agents/concepts/multi-agent.md +++ b/docs/agents/concepts/multi-agent.md @@ -1,6 +1,6 @@ # Multi-Agent -Agentspan has one primitive — `Agent` — and multiple strategies for composing agents together. Pick the strategy that matches your workflow's structure. +Conductor has one agent primitive — `Agent` — and multiple strategies for composing agents together. Pick the strategy that matches your workflow's structure. ## Strategy overview diff --git a/docs/agents/concepts/scheduling.md b/docs/agents/concepts/scheduling.md index 97c0be8ff..4b6169719 100644 --- a/docs/agents/concepts/scheduling.md +++ b/docs/agents/concepts/scheduling.md @@ -1,11 +1,14 @@ # Scheduling -Run agents on a cron schedule. Schedules are stored in Conductor and survive server restarts — no cron daemon or external scheduler needed. +Run agents on a cron schedule. Schedules are stored in Conductor and survive server restarts — no cron daemon or external scheduler needed. Use the SDK's typed `SchedulerClient` directly. ## Deploy an agent with a schedule ```java -import org.conductoross.conductor.ai.schedule.Schedule; +import com.netflix.conductor.common.metadata.workflow.StartWorkflowRequest; + +import io.orkes.conductor.client.SchedulerClient; +import io.orkes.conductor.client.model.SaveScheduleRequest; Agent reportAgent = Agent.builder() .name("daily_report") @@ -13,14 +16,17 @@ Agent reportAgent = Agent.builder() .instructions("Generate a daily sales summary.") .build(); -Schedule daily = Schedule.builder() - .name("daily") - .cron("0 9 * * *") // 9 AM every day - .timezone("America/New_York") - .build(); - try (AgentRuntime runtime = new AgentRuntime()) { - runtime.deploy(reportAgent, List.of(daily)); + runtime.deploy(reportAgent); + + StartWorkflowRequest workflow = new StartWorkflowRequest(); + workflow.setName(reportAgent.getName()); + SchedulerClient schedules = runtime.getSchedulerClient(); + schedules.saveSchedule(new SaveScheduleRequest() + .name("daily_report-daily") + .cronExpression("0 9 * * *") // 9 AM every day + .zoneId("America/New_York") + .startWorkflowRequest(workflow)); } ``` @@ -29,52 +35,44 @@ try (AgentRuntime runtime = new AgentRuntime()) { Pass a fixed prompt or parameters to the scheduled run: ```java -Schedule weeklyDigest = Schedule.builder() - .name("weekly") - .cron("0 8 * * MON") - .input(Map.of("report_type", "weekly", "include_charts", true)) +StartWorkflowRequest workflow = new StartWorkflowRequest(); +workflow.setName("daily_report"); +workflow.setInput(Map.of("report_type", "weekly", "include_charts", true)); + +SaveScheduleRequest weeklyDigest = new SaveScheduleRequest() + .name("daily_report-weekly") + .cronExpression("0 8 * * MON") + .zoneId("UTC") .description("Monday morning executive digest") - .build(); + .startWorkflowRequest(workflow); ``` ## Manage schedules ```java -Schedules schedules = runtime.schedules(); +SchedulerClient schedules = runtime.getSchedulerClient(); // List all schedules for an agent -List all = schedules.list("daily_report"); - -// Get a specific schedule by its wire name (agent-name-schedule-name) -ScheduleInfo info = schedules.get("daily_report-daily"); +List all = schedules.getAllSchedules("daily_report"); -// Trigger immediately (ignores cron timing) — runNow takes the ScheduleInfo -String executionId = schedules.runNow(info); - -// Pause and resume (by wire name) -schedules.pause("daily_report-daily"); -schedules.resume("daily_report-daily"); +// Pause and resume (by wire name). A pause reason is retained by Conductor. +schedules.pauseSchedule("daily_report-daily", "quarter-end freeze"); +schedules.resumeSchedule("daily_report-daily"); // Delete (by wire name) -schedules.delete("daily_report-daily"); +schedules.deleteSchedule("daily_report-daily"); // Preview the next N fire times for a cron expression -List next = schedules.previewNext("0 9 * * *", 5); // epoch millis +List next = schedules.getNextFewSchedules("0 9 * * *", null, null, 5); // epoch millis ``` -## Schedule.builder() options - -| Method | Type | Default | Description | -|---|---|---|---| -| `name(String)` | `String` | **required** | Unique name within the agent. | -| `cron(String)` | `String` | **required** | Standard 5-field cron expression. | -| `timezone(String)` | `String` | `"UTC"` | IANA timezone (e.g. `"Europe/London"`). | -| `input(Map)` | `Map` | `{}` | Fixed input passed to the agent on each run. | -| `description(String)` | `String` | `null` | Human-readable description. | -| `paused(boolean)` | `boolean` | `false` | Create in paused state. | -| `catchup(boolean)` | `boolean` | `false` | Run missed executions after a server downtime. | -| `startAt(long)` | `long` | `null` | Epoch milliseconds — schedule not active before this time. | -| `endAt(long)` | `long` | `null` | Epoch milliseconds — schedule disabled after this time. | +## Native schedule fields + +`SaveScheduleRequest` accepts the schedule name, cron expression, timezone, pause/catchup flags, optional start/end timestamps, description, and a `StartWorkflowRequest`. The `StartWorkflowRequest` identifies the deployed agent workflow and carries its fixed input. + +## Migration from the AI scheduling facade + +`AgentRuntime.schedules()`, `Schedule`, and `ScheduleInfo` are not part of the AI SDK. Obtain the shared client with `runtime.getSchedulerClient()`, build a `SaveScheduleRequest` directly, and use `WorkflowSchedule` for retrieved schedules. Schedule creation, update, and deletion are explicit; deploying an agent does not reconcile schedules. ## Cron syntax diff --git a/docs/agents/concepts/stateful.md b/docs/agents/concepts/stateful.md index a8346de03..34a89bc12 100644 --- a/docs/agents/concepts/stateful.md +++ b/docs/agents/concepts/stateful.md @@ -1,7 +1,7 @@ # Stateful Agents By default each `run()` is independent — the agent has no memory of previous runs. For -conversational or long-lived agents, Agentspan offers three complementary mechanisms. +conversational or long-lived agents, Conductor offers three complementary mechanisms. ## Sessions — multi-turn continuity diff --git a/docs/agents/concepts/termination.md b/docs/agents/concepts/termination.md index d08047f65..82745106c 100644 --- a/docs/agents/concepts/termination.md +++ b/docs/agents/concepts/termination.md @@ -78,3 +78,20 @@ Agent agent = Agent.builder() .termination(either) .build(); ``` + +## Stopping a running execution + +Termination conditions are part of an agent definition. Operator-driven execution control uses +`AgentClient` instead: + +```java +// Graceful: finish the current iteration, then stop. +agentClient.stopAgent(executionId); + +// Immediate: terminate now and record an optional reason. +agentClient.cancelAgent(executionId, "Superseded by a newer run"); +``` + +`stopAgent` sends `POST /agent/{id}/stop`. `cancelAgent` sends +`DELETE /agent/{id}/cancel` and omits the `reason` query parameter when it is null or blank. +See the [AgentClient reference](../agent-client-api.md#cancelagent). diff --git a/docs/agents/concepts/tools.md b/docs/agents/concepts/tools.md index 4922b2ee6..4076f33ba 100644 --- a/docs/agents/concepts/tools.md +++ b/docs/agents/concepts/tools.md @@ -1,6 +1,6 @@ # Tools -Tools give agents the ability to take actions. In Agentspan, each tool invocation runs as a Conductor task — distributed, retryable, and observable in the workflow audit log. +Tools give agents the ability to take actions. In Conductor, each tool invocation runs as a task — distributed, retryable, and observable in the workflow audit log. ## Java method tools (`@Tool`) @@ -90,7 +90,7 @@ Agent agent = Agent.builder() .build(); // Store the secret once via the CLI or API: -// agentspan secrets set GITHUB_TOKEN ghp_xxxxx +// conductor secret put GITHUB_TOKEN ``` The worker fetches each declared secret from the server (via the execution token) before the diff --git a/docs/agents/frameworks/google-adk.md b/docs/agents/frameworks/google-adk.md index 03ca23f67..b2a964d28 100644 --- a/docs/agents/frameworks/google-adk.md +++ b/docs/agents/frameworks/google-adk.md @@ -1,6 +1,6 @@ # Google ADK -Use Google's Agent Development Kit (ADK) agents directly with Agentspan. The `AdkBridge` converts a native `LlmAgent` (or any `BaseAgent`) into an `Agent`, serialising its tools, instructions, and sub-agent graph into the format the server's `GoogleADKNormalizer` understands. +Use Google's Agent Development Kit (ADK) agents directly with Conductor. The `AdkBridge` converts a native `LlmAgent` (or any `BaseAgent`) into an `Agent`, serialising its tools, instructions, and sub-agent graph into the format the server's `GoogleADKNormalizer` understands. ## Dependency @@ -36,7 +36,7 @@ LlmAgent adkAgent = LlmAgent.builder() .build(); // Convert to an Agent -Agent agent = AdkBridge.toAgentspan(adkAgent); +Agent agent = AdkBridge.toConductor(adkAgent); // Run via AgentRuntime try (AgentRuntime runtime = new AgentRuntime()) { @@ -45,9 +45,9 @@ try (AgentRuntime runtime = new AgentRuntime()) { } ``` -## agentBuilder — attach extra Agentspan features +## agentBuilder — attach extra Conductor features -If you want to mix ADK agent structure with Agentspan–only features (guardrails, credentials, callbacks), use `agentBuilder()` which returns an `Agent.Builder` you can continue configuring: +If you want to mix ADK agent structure with Conductor-specific features (guardrails, credentials, callbacks), use `agentBuilder()` which returns an `Agent.Builder` you can continue configuring: ```java import org.conductoross.conductor.ai.guardrail.RegexGuardrail; @@ -66,7 +66,7 @@ Agent agent = AdkBridge.agentBuilder(adkAgent) ## What gets mapped -| ADK concept | Agentspan mapping | +| ADK concept | Conductor mapping | |---|---| | `LlmAgent.name()` | `Agent.name` | | `LlmAgent.model()` | `Agent.model` | @@ -79,4 +79,4 @@ Agent agent = AdkBridge.agentBuilder(adkAgent) | `LoopAgent` / `SequentialAgent` | `Strategy.SEQUENTIAL` | !!! note "Model requirement" - Google ADK agents require a Gemini model (e.g. `gemini-2.0-flash`). Make sure your Agentspan server has a Google AI or Vertex AI provider configured with the appropriate API key. + Google ADK agents require a Gemini model (e.g. `gemini-2.0-flash`). Make sure your Conductor server has a Google AI or Vertex AI provider configured with the appropriate API key. diff --git a/docs/agents/frameworks/langchain4j.md b/docs/agents/frameworks/langchain4j.md index 60d27c74c..8662fe758 100644 --- a/docs/agents/frameworks/langchain4j.md +++ b/docs/agents/frameworks/langchain4j.md @@ -1,6 +1,6 @@ # LangChain4j -Use LangChain4j `@Tool`-annotated POJOs directly with Agentspan. The bridge reflects your annotated methods, builds a JSON Schema from the parameter types, and registers each method as a Conductor worker task. +Use LangChain4j `@Tool`-annotated POJOs directly with Conductor. The bridge reflects your annotated methods, builds a JSON Schema from the parameter types, and registers each method as a Conductor worker task. ## Dependency @@ -57,7 +57,7 @@ boolean isTools = LangChain4jAgent.isLangChain4jTools(new Object()); / ## What gets mapped -| LangChain4j annotation | Agentspan mapping | +| LangChain4j annotation | Conductor mapping | |---|---| | `@Tool("description")` | Tool name = method name; description = annotation value | | `@Tool(name="x", value="desc")` | Tool name = `x`; description = `desc` | diff --git a/docs/agents/frameworks/langgraph4j.md b/docs/agents/frameworks/langgraph4j.md index c811b9c2a..cd85e04c3 100644 --- a/docs/agents/frameworks/langgraph4j.md +++ b/docs/agents/frameworks/langgraph4j.md @@ -1,7 +1,7 @@ # LangGraph4j Run a [LangGraph4j](https://github.com/bsorrentino/langgraph4j) `AgentExecutor` on the durable -Agentspan runtime. Hand the runtime a native `AgentExecutor.Builder` and it recovers the +Conductor agent runtime. Hand the runtime a native `AgentExecutor.Builder` and it recovers the configured `ChatModel` (and system message, if any), then runs the agent server-side. ## Dependency @@ -16,7 +16,7 @@ compileOnly 'org.bsc.langgraph4j:langgraph4j-agent-executor:1.6.0-beta5' ## Usage (drop-in) -The runtime accepts the native `AgentExecutor.Builder` directly — no Agentspan types required. +The runtime accepts the native `AgentExecutor.Builder` directly — no Conductor-specific types required. ```java import dev.langchain4j.model.chat.ChatModel; @@ -25,10 +25,10 @@ import org.bsc.langgraph4j.agentexecutor.AgentExecutor; import org.conductoross.conductor.ai.AgentRuntime; import org.conductoross.conductor.ai.model.AgentResult; -// apiKey is required by the LangChain4j builder but unused — Agentspan runs the +// apiKey is required by the LangChain4j builder but unused — Conductor runs the // LLM call on the server using server-registered credentials. ChatModel model = OpenAiChatModel.builder() - .apiKey("agentspan-server-handles-credentials") + .apiKey("conductor-server-handles-credentials") .modelName("gpt-4o-mini") .build(); diff --git a/docs/agents/frameworks/openai.md b/docs/agents/frameworks/openai.md index 0eeb3d600..ec96b5bbc 100644 --- a/docs/agents/frameworks/openai.md +++ b/docs/agents/frameworks/openai.md @@ -1,6 +1,6 @@ # OpenAI Agents SDK -Use the Agentspan Java SDK with OpenAI Agents SDK-style tool definitions. The `OpenAIAgent` bridge accepts `@Tool`-annotated POJOs and registers them as Conductor worker tasks, routing the agent through the server's `OpenAINormalizer`. +Use the Conductor Java Agent SDK with OpenAI Agents SDK-style tool definitions. The `OpenAIAgent` bridge accepts `@Tool`-annotated POJOs and registers them as Conductor worker tasks, routing the agent through the server's `OpenAINormalizer`. ## Dependency diff --git a/docs/agents/generated/generate.py b/docs/agents/generated/generate.py index 6a94cce92..5ef2f13c4 100644 --- a/docs/agents/generated/generate.py +++ b/docs/agents/generated/generate.py @@ -17,7 +17,7 @@ HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(HERE, "..", "..", "..", "..")) SCHEMA = os.path.join(ROOT, "sdk/java/docs/agent-schema.json") -MODEL_DIR = os.path.join(ROOT, "server/conductor-agentspan/src/main/java/dev/agentspan/runtime/model") +MODEL_DIR = os.environ.get("CONDUCTOR_AGENT_MODEL_DIR", "") schema = json.load(open(SCHEMA)) defs = schema["$defs"] @@ -99,8 +99,11 @@ def gen_java(name, node): except ImportError: print("[b] skipped (pip install jsonschema to run)") -# (c) every server AgentConfig field (root + nested) is present in the schema +# (c) every server AgentConfig field (root + nested) is present in the schema. +# Set CONDUCTOR_AGENT_MODEL_DIR to the server model source directory to enable this check. def server_fields(cls): + if not MODEL_DIR: + return None p = os.path.join(MODEL_DIR, cls + ".java") if not os.path.exists(p): return None src = open(p).read() diff --git a/docs/agents/getting-started.md b/docs/agents/getting-started.md index 0cadd5961..4b1d97760 100644 --- a/docs/agents/getting-started.md +++ b/docs/agents/getting-started.md @@ -4,10 +4,10 @@ - Java 21+ - Gradle 7+ or Maven 3.6+ -- A running Agentspan server — see the [Agentspan repo](https://github.com/agentspan-ai/agentspan) or start one locally: +- A running Conductor server — see the [Conductor repo](https://github.com/conductor-oss/conductor) or start one locally: ```bash -docker run -p 8080:8080 agentspan/server:latest +docker run -p 8080:8080 conductoross/conductor:latest ``` ## Add the dependency @@ -37,7 +37,7 @@ The SDK reads connection settings from environment variables by default: ```bash export CONDUCTOR_SERVER_URL=http://localhost:8080/api export OPENAI_API_KEY= -export AGENTSPAN_LLM_MODEL=openai/gpt-4o-mini +export CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini export CONDUCTOR_AUTH_KEY=your-key # optional export CONDUCTOR_AUTH_SECRET=your-secret # optional ``` diff --git a/docs/agents/index.md b/docs/agents/index.md index dc274ed62..a53f59ab9 100644 --- a/docs/agents/index.md +++ b/docs/agents/index.md @@ -1,6 +1,6 @@ -# Agentspan Java SDK +# Conductor Java Agent SDK -Build long-running, dynamic plan-execute, and event-driven AI agents in Java on [Agentspan](https://agentspan.ai) — a durable runtime built for Conductor. Your agents survive process crashes, run on cron, trigger from events, and execute dynamic plans deterministically — all without managing state yourself. +Build long-running, dynamic plan-execute, and event-driven AI agents in Java on Conductor. Your agents survive process crashes, run on cron, trigger from events, and execute dynamic plans deterministically — all without managing state yourself. ```java Agent agent = Agent.builder() @@ -39,7 +39,7 @@ The docs are organized into five areas: ### c) Framework agents -Run agents authored in another framework on the durable Agentspan runtime. +Run agents authored in another framework on the durable Conductor runtime. - **[OpenAI Agents SDK](frameworks/openai.md)** · **[Google ADK](frameworks/google-adk.md)** · **[LangChain4j](frameworks/langchain4j.md)** · **[LangGraph4j](frameworks/langgraph4j.md)**. @@ -53,7 +53,8 @@ Run agents authored in another framework on the durable Agentspan runtime. - **[Public API summary](api-reference.md)** — every public signature on one page. - **[AgentRuntime](agent-runtime-api.md)** — the entry-point class in detail. -- **[AgentClient](agent-client-api.md)** — the `/api/agent/*` control plane (`io.orkes.conductor.client.AgentClient`, in `conductor-client`). +- **[AgentClient](agent-client-api.md)** — the `/api/agent/*` control plane: start deployed or inline agents, inspect status timestamps, respond, gracefully stop, or immediately cancel. +- **[Control-plane example](../../agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example71AgentControlPlane.java)** — runnable deployed-agent start/status/stop/cancel flow. ## Installation @@ -73,11 +74,11 @@ Run agents authored in another framework on the durable Agentspan runtime. ``` -**Requirements:** Java 21+ · a Agentspan server (see [Getting Started](getting-started.md)). +**Requirements:** Java 21+ · a Conductor server (see [Getting Started](getting-started.md)). ## What makes it different -| Feature | Agentspan | Thread-based SDKs | +| Feature | Conductor | Thread-based SDKs | |---|---|---| | Survives crashes | ✅ Conductor workflow | ❌ State lost | | Tool workers | ✅ Distributed tasks | ❌ In-process only | diff --git a/docs/agents/spring-boot.md b/docs/agents/spring-boot.md index cd69efd5a..0efca836e 100644 --- a/docs/agents/spring-boot.md +++ b/docs/agents/spring-boot.md @@ -32,9 +32,9 @@ conductor.root-uri=http://localhost:8080/api conductor.security.client.key-id=your-key # optional conductor.security.client.secret=your-secret # optional -# Agentspan worker tuning -agentspan.worker-poll-interval-ms=100 -agentspan.worker-thread-count=1 +# Conductor agent worker tuning +conductor.agent.worker-poll-interval-ms=100 +conductor.agent.worker-thread-count=1 ``` ## Inject and use @@ -109,7 +109,7 @@ The catalog scans lazily on first access; only beans whose class declares `@Agen | Bean type | Bean name | Condition | |---|---|---| | `ApiClient` | `orkesConductorClient` | From `conductor-client-spring`; `@ConditionalOnMissingBean` | -| `AgentConfig` | `agentspanConfig` | From `agentspan.*` properties; `@ConditionalOnMissingBean` | +| `AgentConfig` | `conductorAgentConfig` | From `conductor.agent.*` properties; `@ConditionalOnMissingBean` | | `AgentRuntime` | `agentRuntime` | Wires `ApiClient` + `AgentConfig`; `@ConditionalOnMissingBean` | | `AgentCatalog` | `agentCatalog` | Collects `@AgentDef` agents from all beans; `@ConditionalOnMissingBean` | @@ -121,10 +121,10 @@ To connect to multiple servers or use custom TLS: ```java @Configuration -public class MyAgentspanConfig { +public class MyConductorAgentConfig { @Bean - public ApiClient agentspanClient() { + public ApiClient conductorAgentClient() { return ApiClient.builder() .basePath("http://myserver:8080/api") .credentials("key", "secret") @@ -137,7 +137,7 @@ public class MyAgentspanConfig { ```java @Bean -public AgentConfig agentspanConfig() { +public AgentConfig conductorAgentConfig() { return new AgentConfig(500, 4); // 500ms poll, 4 worker threads } ``` diff --git a/docs/design/secret-injection-contract.md b/docs/design/secret-injection-contract.md index 079526f37..efbd1e4f0 100644 --- a/docs/design/secret-injection-contract.md +++ b/docs/design/secret-injection-contract.md @@ -2,7 +2,7 @@ How worker tools receive declared credentials, end to end. This is the wire contract shared with the Python SDK (see the Agent SDK Porting Spec, R6) and -implemented server-side by conductor-oss PR #1255 (agentspan > 0.4.2, +implemented server-side by conductor-oss PR #1255 (Conductor OSS builds with conductor-oss ≥ 3.32.0-rc). ## The contract @@ -44,19 +44,18 @@ delivered fails the task even though `PATH` exists in the environment. ## What this replaced -Earlier versions fetched secrets per call via `POST /workers/secrets` -authenticated with an execution token mined from -`inputData["__agentspan_ctx__"]`. That fetch path (and its transport -exceptions) is deleted (porting spec R12): there is no separate fetch call, no -execution token, and no second token authority. `CredentialNotFoundException` -remains as the public `ToolContext.getCredential` miss signal. +Earlier versions fetched secrets per call via `POST /workers/secrets` using a +task context token. That fetch path (and its transport exceptions) is deleted +(porting spec R12): there is no separate fetch call, no execution token, and +no second token authority. `CredentialNotFoundException` remains as the public +`ToolContext.getCredential` miss signal. ## Server capability | Server | `runtimeMetadata` delivery | |---|---| -| agentspan ≤ 0.4.2 | ✗ (field dropped on registration) | -| agentspan ≥ 0.4.3 | ✓ | +| Conductor OSS before PR #1255 | ✗ (field dropped on registration) | +| Conductor OSS with PR #1255 | ✓ | | conductor-oss ≥ 3.32.0-rc | ✓ (standalone flavor's secret store is env-backed and read-only via the API) | The credential e2e suite probes capability first (register a `TaskDef` with diff --git a/e2e/build.gradle b/e2e/build.gradle index bdbdc6e29..61fd17e26 100644 --- a/e2e/build.gradle +++ b/e2e/build.gradle @@ -1,5 +1,5 @@ -// End-to-end suites for the agent SDK. They need a live agentspan server -// (AGENTSPAN_SERVER_URL) plus server-side LLM credentials, so — like the `tests` +// End-to-end suites for the agent SDK. They need a live Conductor server +// (CONDUCTOR_SERVER_URL) plus server-side LLM credentials, so — like the `tests` // module — every task is gated behind a property: run with -Pe2e. plugins { diff --git a/e2e/src/test/java/BaseTest.java b/e2e/src/test/java/BaseTest.java index 877c74d89..5224165ff 100644 --- a/e2e/src/test/java/BaseTest.java +++ b/e2e/src/test/java/BaseTest.java @@ -39,15 +39,15 @@ */ public abstract class BaseTest { - /** API URL for the Agentspan server (includes /api suffix). */ + /** API URL for the Conductor server (includes /api suffix). */ protected static final String SERVER_URL = - System.getenv().getOrDefault("AGENTSPAN_SERVER_URL", "http://localhost:8080/api"); + System.getenv().getOrDefault("CONDUCTOR_SERVER_URL", "http://localhost:8080/api"); /** Base URL (without /api) for health checks and workflow fetches. */ protected static final String BASE_URL = SERVER_URL.replace("/api", ""); /** LLM model to use in e2e tests. */ - protected static final String MODEL = System.getenv().getOrDefault("AGENTSPAN_LLM_MODEL", "openai/gpt-4o-mini"); + protected static final String MODEL = System.getenv().getOrDefault("CONDUCTOR_AGENT_LLM_MODEL", "openai/gpt-4o-mini"); private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); diff --git a/e2e/src/test/java/Suite11LangChain4j.java b/e2e/src/test/java/Suite11LangChain4j.java index 15e56f9dc..12241af70 100644 --- a/e2e/src/test/java/Suite11LangChain4j.java +++ b/e2e/src/test/java/Suite11LangChain4j.java @@ -33,7 +33,7 @@ * Suite 8: LangChain4j framework integration. * *

    Validates that {@link LangChain4jAgent} correctly bridges LangChain4j - * {@code @Tool}-annotated POJOs to Agentspan agents: + * {@code @Tool}-annotated POJOs to Conductor agents: *

      *
    1. Framework detection — {@link LangChain4jAgent#isLangChain4jTools} works
    2. *
    3. Tool extraction — correct names, descriptions, and JSON Schema
    4. diff --git a/e2e/src/test/java/Suite2ToolCallingCredentials.java b/e2e/src/test/java/Suite2ToolCallingCredentials.java index 4d6bcf184..894ba89c9 100644 --- a/e2e/src/test/java/Suite2ToolCallingCredentials.java +++ b/e2e/src/test/java/Suite2ToolCallingCredentials.java @@ -27,7 +27,7 @@ import org.conductoross.conductor.ai.model.ToolDef; import org.junit.jupiter.api.*; -import io.orkes.conductor.client.exceptions.AgentspanException; +import io.orkes.conductor.client.exceptions.AgentException; import static org.junit.jupiter.api.Assertions.*; @@ -55,7 +55,7 @@ * {@code Task.runtimeMetadata}. This is the test that would catch stamp drift, * fail-open regressions in {@code WorkerManager}, or any future "tool gets the * wrong value" bug. A capability probe skips the suite on servers that drop the - * field (agentspan ≤ 0.4.2) — those failures would be the server's, not the SDK's.

      + * field on servers without Conductor OSS PR #1255 — those failures would be the server's, not the SDK's.

      */ @Tag("e2e") @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @@ -92,7 +92,7 @@ static void setup() { /** * Register a probe TaskDef with {@code runtimeMetadata} and read it back — - * servers without conductor-oss PR #1255 (agentspan ≤ 0.4.2) silently drop + * servers without conductor-oss PR #1255 silently drop * the field, and every wire-delivery assertion below would then fail for a * server reason, not an SDK one. */ @@ -126,8 +126,8 @@ static void assumeRuntimeMetadataCapable() { readBack != null && readBack.getRuntimeMetadata() != null && readBack.getRuntimeMetadata().contains("E2E_PROBE_SECRET"), - "Server does not persist TaskDef.runtimeMetadata (needs agentspan > 0.4.2 / " - + "conductor-oss PR #1255) — skipping credential wire-delivery suite"); + "Server does not persist TaskDef.runtimeMetadata (needs Conductor OSS PR #1255) " + + "— skipping credential wire-delivery suite"); } @AfterAll @@ -285,11 +285,11 @@ private static void putSecret(String name, String value) { // set/update lifecycle steps cannot run (a server-flavor // capability, not an SDK regression). The fail-closed steps // still run everywhere; the full lifecycle runs on the - // writable-store (agentspan) flavor. + // writable-store flavor. Assumptions.assumeFalse( resp.body() != null && resp.body().contains("read-only"), "server secret store is read-only (env-backed) — skipping write-dependent step"); - throw new AgentspanException( + throw new AgentException( "PUT /api/secrets/" + name + " failed: HTTP " + resp.statusCode() + " " + resp.body()); } } catch (org.opentest4j.TestAbortedException skip) { diff --git a/e2e/src/test/java/Suite6PdfTools.java b/e2e/src/test/java/Suite6PdfTools.java index 942041d75..85f28d9fa 100644 --- a/e2e/src/test/java/Suite6PdfTools.java +++ b/e2e/src/test/java/Suite6PdfTools.java @@ -351,7 +351,7 @@ void test_pdf_tool_defaults_propagate_to_plan_config() { @Order(7) @SuppressWarnings("unchecked") void test_pdf_generation_task_completes_and_has_output() { - String sampleMarkdown = "# Agentspan Parity Report\n\n" + String sampleMarkdown = "# Conductor Parity Report\n\n" + "## Overview\n" + "This PDF validates the GENERATE_PDF pipeline end-to-end.\n\n" + "## Numbers\n" diff --git a/e2e/src/test/java/SuiteHttpApi404.java b/e2e/src/test/java/SuiteHttpApi404.java index 523717e94..d71569537 100644 --- a/e2e/src/test/java/SuiteHttpApi404.java +++ b/e2e/src/test/java/SuiteHttpApi404.java @@ -19,7 +19,7 @@ import io.orkes.conductor.client.ApiClient; import io.orkes.conductor.client.exceptions.AgentAPIException; import io.orkes.conductor.client.exceptions.AgentNotFoundException; -import io.orkes.conductor.client.exceptions.AgentspanException; +import io.orkes.conductor.client.exceptions.AgentException; import io.orkes.conductor.client.http.OrkesAgentClient; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -52,7 +52,7 @@ void getStatusOnMissingExecutionIdRaisesAgentNotFoundException() { ex, "404 must surface as AgentNotFoundException, not generic AgentAPIException"); assertInstanceOf( - AgentspanException.class, ex, "AgentNotFoundException must remain catchable as the SDK base type"); + AgentException.class, ex, "AgentNotFoundException must remain catchable as the SDK base type"); assertTrue(ex.getStatusCode() == 404, "Expected statusCode=404, got " + ex.getStatusCode()); } } diff --git a/examples/ai/anthropic-claude/src/test/java/anthropicclaude/workers/ClaudeBuildMessagesWorkerTest.java b/examples/ai/anthropic-claude/src/test/java/anthropicclaude/workers/ClaudeBuildMessagesWorkerTest.java index 8124cdc81..c85b8031b 100644 --- a/examples/ai/anthropic-claude/src/test/java/anthropicclaude/workers/ClaudeBuildMessagesWorkerTest.java +++ b/examples/ai/anthropic-claude/src/test/java/anthropicclaude/workers/ClaudeBuildMessagesWorkerTest.java @@ -25,7 +25,7 @@ void buildsRequestBodyWithCorrectStructure() { Task task = taskWith(new HashMap<>(Map.of( "userMessage", "Audit the auth module", "systemPrompt", "You are a security engineer", - "model", "claude-sonnet-4-20250514", + "model", "claude-sonnet-4-6", "max_tokens", 1024, "temperature", 0.5 ))); @@ -48,7 +48,7 @@ void messagesContainUserRoleWithContent() { Task task = taskWith(new HashMap<>(Map.of( "userMessage", "Hello Claude", "systemPrompt", "Be helpful", - "model", "claude-sonnet-4-20250514", + "model", "claude-sonnet-4-6", "max_tokens", 512, "temperature", 0.7 ))); @@ -68,7 +68,7 @@ void systemPromptIsSeparateFromMessages() { Task task = taskWith(new HashMap<>(Map.of( "userMessage", "Test message", "systemPrompt", "System instructions here", - "model", "claude-sonnet-4-20250514", + "model", "claude-sonnet-4-6", "max_tokens", 256, "temperature", 0.0 ))); @@ -91,7 +91,7 @@ void outputContainsRequestBodyKey() { Task task = taskWith(new HashMap<>(Map.of( "userMessage", "Test", "systemPrompt", "Prompt", - "model", "claude-sonnet-4-20250514", + "model", "claude-sonnet-4-6", "max_tokens", 100, "temperature", 0.5 ))); diff --git a/gradle.properties b/gradle.properties index 42ba350de..aab8a8fbf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=5.1.0 +version=5.1.0-SNAPSHOT From b229a0050b513e508d98f20d05f04f7749e9e952 Mon Sep 17 00:00:00 2001 From: Viren Baraiya Date: Sat, 18 Jul 2026 18:36:08 -0700 Subject: [PATCH 08/14] readme --- .github/workflows/ci.yml | 56 +- CODE_OF_CONDUCT.md | 9 + CONTRIBUTING.md | 39 + README.md | 672 +++--------------- SECURITY.md | 13 + .../examples/Example108PlanExecuteRefs.java | 56 +- .../ai/examples/Example115PlannerContext.java | 64 +- examples/basics/hello-world/run.sh | 86 ++- 8 files changed, 345 insertions(+), 650 deletions(-) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 499ced67c..0997a2f46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,61 @@ concurrency: cancel-in-progress: true jobs: + readme-validation: + runs-on: ubuntu-latest + name: README validation + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Zulu JDK 21 + uses: actions/setup-java@v5 + with: + distribution: "zulu" + java-version: "21" + + - name: Validate README links + uses: lycheeverse/lychee-action@v2 + with: + args: --offline --no-progress README.md + + - name: Reject retired README references + run: | + if rg -n ':examples:run|examples/old|LlmIndexDocument|\.topK\(' README.md; then + echo "README references a retired command, path, or API." + exit 1 + fi + + - name: Compile documented AI example target + run: ./gradlew :agent-examples:compileJava + + - name: Package documented core example + run: | + docker run --rm \ + -v "$PWD/examples/basics/hello-world":/work \ + -v "$HOME/.m2":/root/.m2 \ + -w /work \ + maven:3.9-eclipse-temurin-21 \ + mvn -B package -DskipTests + + - name: Smoke-test documented core quickstart + working-directory: examples/basics/hello-world + env: + CONDUCTOR_PORT: 18080 + CONDUCTOR_UI_PORT: 11234 + run: | + timeout 240 ./run.sh + curl --fail --retry 10 --retry-delay 2 --retry-all-errors http://localhost:18080/health + curl --fail --retry 10 --retry-delay 2 --retry-all-errors http://localhost:11234/ + docker compose ps --status running --services | rg --fixed-strings --line-regexp conductor + + - name: Stop documented core quickstart + if: always() + working-directory: examples/basics/hello-world + run: docker compose down -v --remove-orphans + build: runs-on: ubuntu-latest name: Java Client Build @@ -59,4 +114,3 @@ jobs: - name: Check Tests Status if: steps.tests.outcome == 'failure' run: exit 1 - \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..c4e2ebd96 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,9 @@ +# Code of Conduct + +This repository follows the [Conductor OSS Code of Conduct](https://github.com/conductor-oss/conductor/blob/main/CODE_OF_CONDUCT.md). + +Participate with kindness, respect, professionalism, and a focus on constructive technical discussion. Harassment, discrimination, personal attacks, and other exclusionary behavior are not acceptable in issues, pull requests, community channels, or project events. + +Report conduct concerns privately to [community@orkes.io](mailto:community@orkes.io), as specified by the canonical Conductor OSS policy. Do not report conduct incidents in a public issue. + +The canonical upstream policy governs if this summary and the upstream policy ever differ. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..7bfa1458c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing to the Conductor Java SDK + +Thanks for contributing. This repository contains the Java client, AI agent SDK, Spring integrations, examples, and end-to-end tests. + +All participation is governed by the [Code of Conduct](CODE_OF_CONDUCT.md). + +## Prerequisites + +- Java 21+ +- Docker for examples and integration tests that start Conductor + +## Local checks + +Run formatting before committing: + +```shell +./gradlew spotlessApply +``` + +Run the SDK test suite: + +```shell +./gradlew test jacocoTestReport +``` + +Compile the maintained agent examples when changing their APIs or documentation: + +```shell +./gradlew :agent-examples:compileJava +``` + +## Pull requests + +- Keep changes focused and include tests for behavior changes. +- Update the relevant documentation and examples when public APIs or commands change. +- Do not add secrets, credentials, or private endpoints to source, tests, or documentation. +- Open pull requests against `main` and complete the pull-request template. + +To contribute across the broader project, browse [Conductor OSS contribution opportunities](https://github.com/conductor-oss/conductor/contribute) and follow the [upstream contribution guide](https://github.com/conductor-oss/conductor/blob/main/CONTRIBUTING.md). diff --git a/README.md b/README.md index 92ba2dd2d..ca96b42c9 100644 --- a/README.md +++ b/README.md @@ -2,625 +2,179 @@ [![CI](https://github.com/conductor-oss/conductor-java-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/conductor-oss/conductor-java-sdk/actions/workflows/ci.yml) [![Maven Central](https://img.shields.io/maven-central/v/org.conductoross/conductor-client.svg)](https://search.maven.org/search?q=g:org.conductoross) -[![Java Versions](https://img.shields.io/badge/Java-17%2B-blue)](https://www.oracle.com/java/technologies/downloads/) +[![Java 21+](https://img.shields.io/badge/Java-21%2B-blue)](https://www.oracle.com/java/technologies/downloads/) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) -Java SDK for [Conductor](https://www.conductor-oss.org/) (OSS and Orkes Conductor) — an orchestration platform for building distributed applications, AI agents, and workflow-driven microservices. Define workflows as code, run workers anywhere, and let Conductor handle retries, state management, and observability. - -If you find [Conductor](https://github.com/conductor-oss/conductor) useful, please consider giving it a star on GitHub -- it helps the project grow. - -[![GitHub stars](https://img.shields.io/github/stars/conductor-oss/conductor.svg?style=social&label=Star&maxAge=)](https://GitHub.com/conductor-oss/conductor/) - - -* [Java SDK for Conductor](#java-sdk-for-conductor) - * [Start Conductor server](#start-conductor-server) - * [Install the SDK](#install-the-sdk) - * [60-Second Quickstart](#60-second-quickstart) - * [Comprehensive worker example](#comprehensive-worker-example) - * [Workers](#workers) - * [Monitoring Workers](#monitoring-workers) - * [Workflows](#workflows) - * [File Storage](#file-storage) - * [Troubleshooting](#troubleshooting) - * [AI & LLM Workflows](#ai--llm-workflows) - * [Examples](#examples) - * [API Journey Examples](#api-journey-examples) - * [Documentation](#documentation) - * [Support](#support) - * [Frequently Asked Questions](#frequently-asked-questions) - * [License](#license) - - - -## Install Conductor CLI -```shell -npm install -g @conductor-oss/conductor-cli -``` -> [!TIP] -> Alternatively, you can use a hosted Conductor at https://developer.orkescloud.com/ - -## Start Conductor server - -If you don't already have a Conductor server running, pick one: -```shell -# Use CLI to start the server -- recommended -# use --help for all the options including port override -conductor server start - -# Alternatively, if you prefer to use docker use: -# docker run -p 8080:8080 conductoross/conductor:latest - -# And if you really want, you can install and download binaries to run locally -# curl -sSL https://raw.githubusercontent.com/conductor-oss/conductor/main/conductor_server.sh | sh -``` -Once the server starts, navigate to http://localhost:8080/ for the UI +The Java SDK for [Conductor](https://www.conductor-oss.org/) lets you build durable AI agents and workflow workers. Conductor coordinates retries, state, and observability while your Java code runs wherever you deploy it. -## Install the SDK +**Get involved:** [⭐ Conductor OSS](https://github.com/conductor-oss/conductor) · [Choose a Conductor OSS contribution](https://github.com/conductor-oss/conductor/contribute) · [Contribution guide](https://github.com/conductor-oss/conductor/blob/main/CONTRIBUTING.md) -The SDK requires Java 17+. Add the following dependency to your project: +## Choose your path -**For Gradle:** +| I want to… | Start here | +|---|---| +| Build a durable AI agent with tools and human approval | [Run an AI agent example](#ai-agent-quickstart) | +| Bring an existing Google ADK or LangChain4j agent | [Use framework bridges](#google-adk-and-langchain4j) | +| Build a durable workflow and Java worker | [Run the core hello-world example](#workflow-and-worker-quickstart) | +| Browse all examples | [AI agent guide](docs/agents/index.md) · [Core examples](examples/README.md) | -```gradle -dependencies { - implementation 'org.conductoross:conductor-client:' +## Why Conductor? - // Optionally, you can also add spring module for auto configuration - // implementation 'org.conductoross:conductor-client-spring:' -} -``` +- **Survive process failures:** execution state is durable, so agents and workflows resume from completed work. +- **Build dynamic agent graphs:** define workflow graphs in Java or let an LLM plan them at runtime. Conductor executes plans as durable sub-workflows, so agents can plan, execute, observe, and replan complex work instead of relying on a transient in-process loop. +- **Run tools as distributed tasks:** scale Java workers independently while Conductor manages retries and delivery. +- **Orchestrate long-running work:** combine AI, schedules, events, and human approval without holding application threads open. +- **See every execution:** inspect inputs, outputs, tool calls, retries, and status through one execution model. -**For Maven:** +**See the real graph:** [`Example115PlannerContext`](agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example115PlannerContext.java) has an LLM turn onboarding policy into a KYC → account → email → conditional kickoff graph. Conductor compiles the plan into a durable sub-workflow, pipes outputs between steps, executes it, and the example reads back the generated sub-workflow to print the actual task trace. -```xml - - org.conductoross - conductor-client - VERSION - -``` -*Optionally, you can also add spring module for auto configuration* -```xml - - org.conductoross - conductor-client-spring - VERSION - +```shell +./gradlew :agent-examples:run \ + -PmainClass=org.conductoross.conductor.ai.examples.Example115PlannerContext ``` -**For the latest release version, see https://github.com/conductor-oss/java-sdk/releases** -## 60-Second Quickstart +Prefer to construct the graph in code? [`Example108PlanExecuteRefs`](agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example108PlanExecuteRefs.java) builds a typed `Plan` with dependencies and cross-step output references, then runs it through the same durable sub-workflow execution path. -**Step 1: Create a workflow** +## Requirements and compatibility -Workflows are definitions that reference task types (e.g., a SIMPLE task called `greet`). We'll build a workflow called -`greetings` that runs one task and returns its output. +- Java 21+ +- Docker for the standalone core quickstart, or a running OSS/Orkes Conductor server. +- Maven 3.8+ when running standalone core examples without their launcher script; Gradle is included for this repository's agent examples. -```java -ConductorWorkflow workflow = new ConductorWorkflow<>(executor); -workflow.setName("greetings"); -workflow.setVersion(1); - -SimpleTask greetTask = new SimpleTask("greet", "greet_ref"); -greetTask.input("name", "${workflow.input.name}"); - -workflow.add(greetTask); -workflow.registerWorkflow(true, true); -``` +The CI workflows are the source of truth for the server versions exercised by this SDK. See the [agent E2E matrix](.github/workflows/agent-e2e.yml) for its pinned server version. -**Step 2: Write worker** - -Workers are Java classes that implement the `Worker` interface and poll Conductor for tasks to execute. - -```java -public class GreetWorker implements Worker { - - @Override - public String getTaskDefName() { - return "greet"; - } - - @Override - public TaskResult execute(Task task) { - String name = (String) task.getInputData().get("name"); - TaskResult result = new TaskResult(task); - result.setStatus(TaskResult.Status.COMPLETED); - result.addOutputData("greeting", "Hello, " + name + "!"); - return result; - } -} -``` +## Install the SDK -**Step 3: Run your first workflow app** - -Create a `Main.java` with the following: - -```java -import com.netflix.conductor.client.automator.TaskRunnerConfigurer; -import com.netflix.conductor.client.http.ConductorClient; -import com.netflix.conductor.client.http.TaskClient; -import com.netflix.conductor.client.http.WorkflowClient; -import com.netflix.conductor.sdk.workflow.executor.WorkflowExecutor; - -import java.util.List; -import java.util.Map; - -public class Main { - public static void main(String[] args) { - // Configure the SDK (reads CONDUCTOR_SERVER_URL from env, defaults to localhost:8080) - String serverUrl = System.getenv().getOrDefault("CONDUCTOR_SERVER_URL", "http://localhost:8080/api"); - ConductorClient client = ConductorClient.builder() - .basePath(serverUrl) - .build(); - - // Create workflow executor - WorkflowExecutor executor = new WorkflowExecutor(client); - - // Build and register the workflow - ConductorWorkflow workflow = new ConductorWorkflow<>(executor); - workflow.setName("greetings"); - workflow.setVersion(1); - - SimpleTask greetTask = new SimpleTask("greet", "greet_ref"); - greetTask.input("name", "${workflow.input.name}"); - workflow.add(greetTask); - workflow.registerWorkflow(true, true); - - // Start polling for tasks - TaskClient taskClient = new TaskClient(client); - TaskRunnerConfigurer configurer = new TaskRunnerConfigurer.Builder( - taskClient, - List.of(new GreetWorker()) - ).withThreadCount(10).build(); - configurer.init(); - - // Run the workflow and get the result - WorkflowClient workflowClient = new WorkflowClient(client); - String workflowId = workflowClient.startWorkflow("greetings", 1, "", Map.of("name", "Conductor")); - - System.out.println("Started workflow: " + workflowId); - System.out.println("View execution at: " + serverUrl.replace("/api", "") + "/execution/" + workflowId); - } -} -``` +Replace `` with a published version from [Maven Central](https://search.maven.org/search?q=g:org.conductoross). -Run it: +### AI agents -```shell -./gradlew run -``` - -> ### Using Orkes Conductor / Remote Server? -> Export your authentication credentials as well: -> -> ```shell -> export CONDUCTOR_SERVER_URL="https://your-cluster.orkesconductor.io/api" -> -> # If using Orkes Conductor that requires auth key/secret -> export CONDUCTOR_AUTH_KEY="your-key" -> export CONDUCTOR_AUTH_SECRET="your-secret" -> ``` - -That's it -- you just defined a worker, built a workflow, and executed it. Open the Conductor UI (default: -[http://localhost:8080](http://localhost:8080)) to see the execution. - -## Comprehensive worker example - -See [examples/src/main/java/com/netflix/conductor/sdk/examples/helloworld/](examples/src/main/java/com/netflix/conductor/sdk/examples/helloworld/) for a complete working example with: -- Workflow definition using the SDK -- Worker implementation with annotations -- Workflow execution and monitoring - ---- - -## Workers - -Workers are Java classes that execute Conductor tasks. Implement the `Worker` interface or use the `@WorkerTask` annotation: - -**Using Worker interface:** - -```java -public class MyWorker implements Worker { - - @Override - public String getTaskDefName() { - return "my_task"; - } - - @Override - public TaskResult execute(Task task) { - // Your business logic here - TaskResult result = new TaskResult(task); - result.setStatus(TaskResult.Status.COMPLETED); - result.addOutputData("result", "Task completed successfully"); - return result; - } -} -``` - -**Using @WorkerTask annotation:** - -```java -public class Workers { - - @WorkerTask("greet") - public String greet(@InputParam("name") String name) { - return "Hello, " + name + "!"; - } - - @WorkerTask("process_data") - public Map processData(@InputParam("data") Map data) { - // Process and return data - return Map.of("processed", true, "result", data); - } +```gradle +dependencies { + implementation 'org.conductoross:conductor-client-ai:' } ``` -**Start workers** with `TaskRunnerConfigurer` or `AnnotatedWorkerExecutor`: - -```java -// Option 1: Using TaskRunnerConfigurer -TaskClient taskClient = new TaskClient(client); -TaskRunnerConfigurer configurer = new TaskRunnerConfigurer.Builder( - taskClient, - List.of(new MyWorker(), new AnotherWorker()) -) -.withThreadCount(10) -.build(); -configurer.init(); - -// Option 2: Register dependency-injected @WorkerTask instances -AnnotatedWorkerExecutor executor = new AnnotatedWorkerExecutor(taskClient); -executor.initWorkersFromInstances(List.of(new Workers())); +```xml + + org.conductoross + conductor-client-ai + <VERSION> + ``` -**Worker Design Principles:** - -- Workers should be stateless and idempotent -- Handle failure scenarios gracefully -- Report status back to Conductor -- Complete execution quickly (or use polling for long-running tasks) - -**Worker vs. HTTP Endpoints:** - -| Feature | Worker | HTTP Endpoint | -|---------|--------|---------------| -| Deployment | Embedded in application | Separate service | -| Scalability | Horizontal (add more instances) | Horizontal (add more instances) | -| Latency | Lower (direct polling) | Higher (network overhead) | -| Complexity | Simple | Complex (service mesh, load balancer) | +Google ADK and LangChain4j are optional dependencies; use the versions and setup in the [Google ADK guide](docs/agents/frameworks/google-adk.md) or [LangChain4j guide](docs/agents/frameworks/langchain4j.md). -**Learn more:** -- [Worker SDK Guide](docs/worker_sdk.md) — Complete worker framework documentation -- [Worker Examples](examples/) — Sample worker implementations +### Workflows and workers -## Monitoring Workers - -Enable Prometheus metrics collection for monitoring workers: - -```groovy -// Using conductor-client-metrics module +```gradle dependencies { - implementation 'org.conductoross:conductor-client-metrics:5.1.0' + implementation 'org.conductoross:conductor-client:' } ``` -```java -import com.netflix.conductor.client.metrics.prometheus.PrometheusMetricsCollector; - -PrometheusMetricsCollector metricsCollector = new PrometheusMetricsCollector(); -metricsCollector.startServer(); // http://localhost:9991/metrics - -ConductorClient client = ConductorClient.builder() - .basePath("...") - .withMetricsCollector(metricsCollector) - .build(); - -TaskClient taskClient = new TaskClient(client); -WorkflowClient workflowClient = new WorkflowClient(client); - -TaskRunnerConfigurer configurer = new TaskRunnerConfigurer.Builder(taskClient, workers) - .withThreadCount(10) - .build(); +```xml + + org.conductoross + conductor-client + <VERSION> + ``` -When a `MetricsCollector` is attached to the `ConductorClient`, downstream clients (`TaskClient`, `WorkflowClient`, `TaskRunnerConfigurer`) automatically register themselves as event listeners. - -See [conductor-client-metrics/README.md](conductor-client-metrics/README.md) for the complete metric catalog and setup details. - -## Workflows - -Define workflows in Java using the `ConductorWorkflow` builder: - -```java -ConductorWorkflow workflow = new ConductorWorkflow<>(executor); -workflow.setName("my_workflow"); -workflow.setVersion(1); -workflow.setOwnerEmail("team@example.com"); - -// Add tasks -SimpleTask task1 = new SimpleTask("task1", "task1_ref"); -SimpleTask task2 = new SimpleTask("task2", "task2_ref"); -workflow.add(task1); -workflow.add(task2); - -// Register the workflow -workflow.registerWorkflow(true, true); -``` +### Modules -**Execute workflows:** +| Module | Use it for | +|---|---| +| `conductor-client-ai` | Durable AI agents, tools, guardrails, handoffs, and framework bridges | +| `conductor-client-ai-spring` | Spring auto-configuration for AI agents | +| `conductor-client` | Workflow, task, worker, metadata, and scheduler clients | +| `conductor-client-spring` | Spring auto-configuration for the core client | +| `conductor-client-spring-boot4` | Spring Boot 4 auto-configuration for the core client | +| `conductor-client-metrics` | Prometheus metrics collection | -```java -WorkflowClient workflowClient = new WorkflowClient(client); +## AI agent quickstart -// Synchronous (start and poll for completion) -CompletableFuture future = workflow.execute(input); -Workflow result = future.get(30, TimeUnit.SECONDS); -System.out.println("Output: " + result.getOutput()); +Use this path when your agent needs LLM reasoning, tools, guardrails, handoffs, or human approval. Configure the LLM provider credential on the **Conductor server process** first—setting it only in the client shell is not enough. The [agent getting-started guide](docs/agents/getting-started.md) covers local and remote server setup. -// Asynchronous (returns workflow ID immediately) -String workflowId = workflowClient.startWorkflow("my_workflow", 1, "", Map.of("key", "value")); +```shell +export CONDUCTOR_SERVER_URL=http://localhost:8080/api +export CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini -// Dynamic execution (sends workflow definition with request) -CompletableFuture dynamicRun = workflow.executeDynamic(input); +# Run the maintained basic-agent example from this repository. +./gradlew :agent-examples:run \ + -PmainClass=org.conductoross.conductor.ai.examples.Example01BasicAgent ``` -**Manage running workflows:** - -```java -WorkflowClient workflowClient = new WorkflowClient(client); - -// Get workflow status -Workflow wf = workflowClient.getWorkflow(workflowId, true); -System.out.println("Status: " + wf.getStatus()); - -// Pause, resume, terminate -workflowClient.pauseWorkflow(workflowId); -workflowClient.resumeWorkflow(workflowId); -workflowClient.terminateWorkflow(workflowId, "No longer needed"); - -// Retry and restart failed workflows -workflowClient.retryWorkflow(workflowId); -workflowClient.restartWorkflow(workflowId, false); -``` +Expected outcome: the example prints an `AgentResult` containing the model response. See the [AI agent guide](docs/agents/index.md), [tools guide](docs/agents/concepts/tools.md), and [agent examples](agent-examples/src/main/java/org/conductoross/conductor/ai/examples/) for the next step. -**Learn more:** -- [Workflow SDK Guide](docs/workflow_sdk.md) — Workflow-as-code documentation -- [Workflow Testing](docs/testing_framework.md) — Unit testing workflows +### Google ADK and LangChain4j -## File Storage +Keep using the Java agent framework your team already knows. The SDK bridges native [Google ADK](docs/agents/frameworks/google-adk.md) `BaseAgent` and `LlmAgent` instances into durable Conductor agents, including tools and sub-agent graphs. It also turns existing [LangChain4j](docs/agents/frameworks/langchain4j.md) `@Tool`-annotated POJOs into Conductor worker tools and supports LangChain4j `ChatModel`-based agents. -Use `FileClient` to upload and download workflow-scoped binary payloads. Workflow inputs and outputs carry only opaque `conductor://file/` strings: +Start with the [Google ADK examples](agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/) or the focused [LangChain4j bridge guide](docs/agents/frameworks/langchain4j.md). -```java -FileClient files = new FileClient(client); +## Workflow and worker quickstart -String handle = files.upload( - workflowId, - Path.of("report.pdf"), - new FileUploadOptions().setContentType("application/pdf")); +This maintained example registers a workflow, starts a Java worker, executes the workflow, and prints `Result: PASSED`. -FileMetadata metadata = files.getMetadata(workflowId, handle); -Path local = files.download(workflowId, handle, Path.of("downloads/report.pdf")); +```shell +# The launcher starts Conductor, builds the example with Maven/JDK 21, and runs it. +cd examples/basics/hello-world +./run.sh ``` -Multipart is selected automatically for supported providers. See the [FileClient guide](docs/file-client.md) for every upload/download form and the [Media Transcoder](examples/file-storage/media-transcoder/) for a complete workflow. - -## Troubleshooting +Expected outcome: -**Worker stops polling or crashes:** -- Check network connectivity to Conductor server -- Verify `CONDUCTOR_SERVER_URL` is set correctly -- Ensure sufficient thread pool size for your workload -- Monitor JVM memory and GC pauses - -**Connection refused errors:** -- Verify Conductor server is running: `curl http://localhost:8080/health` -- Check firewall rules if connecting to remote server -- For Orkes Conductor, verify auth credentials are correct - -**Tasks stuck in SCHEDULED state:** -- Ensure workers are polling for the correct task type -- Check that `getTaskDefName()` matches the task name in workflow -- Verify worker thread count is sufficient - -**Workflow execution timeout:** -- Increase workflow timeout in definition -- Check if tasks are completing within expected time -- Monitor Conductor server logs for errors - -**Authentication errors with Orkes Conductor:** -- Verify `CONDUCTOR_AUTH_KEY` and `CONDUCTOR_AUTH_SECRET` are set -- Ensure the application has required permissions -- Check that credentials haven't expired - ---- - -## AI & LLM Workflows - -Conductor supports AI-native workflows including agentic tool calling, RAG pipelines, and multi-agent orchestration. - -**Durable AI Agents** - -The `conductor-client-ai` module is a full agent SDK on top of Conductor: `Agent`, `AgentRuntime`, `@Tool` functions, guardrails, handoffs, and multi-agent strategies, with Spring Boot auto-configuration in `conductor-client-ai-spring` and 150+ runnable examples in `agent-examples`. Start with the [agent docs](docs/agents/index.md), or use the low-level [AgentClient control-plane API](docs/agents/agent-client-api.md) to start deployed agents and stop or cancel executions. - -**Agentic Workflows** - -Build AI agents where LLMs dynamically select and call Java workers as tools. All agentic examples live in [`AgenticExamplesRunner.java`](examples/src/main/java/io/orkes/conductor/sdk/examples/agentic/AgenticExamplesRunner.java) — a single unified runner. - -| Workflow | Description | -|----------|-------------| -| `llm_chat_workflow` | Automated multi-turn Q&A using `LLM_CHAT_COMPLETE` system task | -| `llm_chat_human_in_loop` | Interactive chat with WAIT task pauses for user input | -| `multiagent_chat_demo` | Multi-agent debate with moderator routing between two LLM panelists | -| `function_calling_workflow` | LLM picks which Java worker to call, returns JSON, dispatch worker executes it | -| `mcp_ai_agent` | AI agent using MCP tools (ListMcpTools → LLM plans → CallMcpTool → summarize) | - -**LLM and RAG Workflows** - -| Example | Description | -|---------|-------------| -| [RagWorkflowExample.java](examples/src/main/java/io/orkes/conductor/sdk/examples/agentic/RagWorkflowExample.java) | End-to-end RAG: document indexing, semantic search, answer generation | -| [VectorDbExample.java](examples/src/main/java/io/orkes/conductor/sdk/examples/agentic/VectorDbExample.java) | Vector database operations: text indexing, embedding generation, and semantic search | - -**Using LLM Tasks in Workflows:** - -```java -// Chat completion task (LLM_CHAT_COMPLETE system task) -LlmChatComplete chatTask = new LlmChatComplete("chat_assistant", "chat_ref") - .llmProvider("openai") - .model("gpt-4o-mini") - .messages(List.of( - Map.of("role", "system", "message", "You are a helpful assistant."), - Map.of("role", "user", "message", "${workflow.input.question}") - )) - .temperature(0.7) - .maxTokens(500); - -// Text completion task (LLM_TEXT_COMPLETE system task) -LlmTextComplete textTask = new LlmTextComplete("generate_text", "text_ref") - .llmProvider("openai") - .model("gpt-4o-mini") - .promptName("my-prompt-template") - .temperature(0.7); - -// Document indexing for RAG (LLM_INDEX_DOCUMENT system task) -LlmIndexDocument indexTask = new LlmIndexDocument("index_doc", "index_ref") - .vectorDb("pinecone") - .namespace("my-docs") - .index("knowledge-base") - .embeddingModel("text-embedding-ada-002") - .text("${workflow.input.document}"); - -// Semantic search (LLM_SEARCH_INDEX system task) -LlmSearchIndex searchTask = new LlmSearchIndex("search_docs", "search_ref") - .vectorDb("pinecone") - .namespace("my-docs") - .index("knowledge-base") - .query("${workflow.input.question}") - .topK(5); - -// MCP tool discovery (MCP_LIST_TOOLS system task — Orkes Conductor) -ListMcpTools listTools = new ListMcpTools("discover_tools", "tools_ref") - .mcpServer("http://localhost:3001/mcp"); - -// MCP tool execution (MCP_CALL_TOOL system task — Orkes Conductor) -CallMcpTool callTool = new CallMcpTool("execute_tool", "tool_ref") - .mcpServer("http://localhost:3001/mcp") - .method("${tools_ref.output.result.method}") - .arguments("${tools_ref.output.result.arguments}"); - -workflow.add(chatTask); -workflow.add(textTask); -workflow.add(indexTask); +```text +Status: COMPLETED +Output: {greeting=Hello, Developer! Welcome to Conductor.} +Result: PASSED ``` -Run all agentic examples: +The managed Conductor server remains running after the example completes. Open the UI at [http://localhost:1234](http://localhost:1234), then stop it when finished: ```shell -export CONDUCTOR_SERVER_URL=http://localhost:8080/api -export OPENAI_API_KEY=your-key # or ANTHROPIC_API_KEY - -# Run all examples end-to-end -./gradlew :examples:run --args="--all" - -# Run specific workflow -./gradlew :examples:run --args="--menu" +docker compose down ``` -## Examples +To use an existing server instead, set `CONDUCTOR_SERVER_URL` explicitly before running the launcher. For worker patterns, workflow definitions, and testing, continue with the [core examples catalog](examples/README.md), [worker guide](docs/worker_sdk.md), and [workflow guide](docs/workflow_sdk.md). -See the [Examples Guide](examples/README.md) for the full catalog. Key examples: +## Common tasks -| Example | Description | Run | -|---------|-------------|-----| -| [Hello World](examples/src/main/java/com/netflix/conductor/sdk/examples/helloworld/) | Minimal workflow with worker | `./gradlew :examples:run -PmainClass=com.netflix.conductor.sdk.examples.helloworld.Main` | -| [Workflow Operations](examples/src/main/java/io/orkes/conductor/sdk/examples/workflowops/) | Pause, resume, terminate workflows | `./gradlew :examples:run -PmainClass=io.orkes.conductor.sdk.examples.workflowops.Main` | -| [Shipment Workflow](examples/src/main/java/com/netflix/conductor/sdk/examples/shipment/) | Real-world order processing | `./gradlew :examples:run -PmainClass=com.netflix.conductor.sdk.examples.shipment.Main` | -| [Events](examples/src/main/java/com/netflix/conductor/sdk/examples/events/) | Event-driven workflows | `./gradlew :examples:run -PmainClass=com.netflix.conductor.sdk.examples.events.EventHandlerExample` | -| [FileClient Media Transcoder](examples/file-storage/media-transcoder/) | Path/stream upload, metadata, atomic download, handle passing | See example README | -| [All AI examples](examples/src/main/java/io/orkes/conductor/sdk/examples/agentic/AgenticExamplesRunner.java) | All agentic/LLM workflows | `./gradlew :examples:run --args="--all"` | -| [RAG Workflow](examples/src/main/java/io/orkes/conductor/sdk/examples/agentic/RagWorkflowExample.java) | RAG pipeline (index → search → answer) | `./gradlew :examples:run -PmainClass=io.orkes.conductor.sdk.examples.agentic.RagWorkflowExample` | +| Need | Start with | +|---|---| +| Build Java AI agents | [Agent concepts](docs/agents/concepts/agents.md) | +| Add tools and human approval | [Agent tools](docs/agents/concepts/tools.md) | +| Use another agent framework | [Google ADK](docs/agents/frameworks/google-adk.md) · [LangChain4j](docs/agents/frameworks/langchain4j.md) · [LangGraph4j](docs/agents/frameworks/langgraph4j.md) | +| Deploy, serve, and run agents | [Agent runtime modes](docs/agents/concepts/deploy-serve-run.md) | +| Implement and scale Java workers | [Worker SDK guide](docs/worker_sdk.md) | +| Define workflows in Java | [Workflow SDK guide](docs/workflow_sdk.md) | +| Upload/download workflow-scoped files | [FileClient guide](docs/file-client.md) | +| Test workflows and workers | [Testing framework](docs/testing_framework.md) | +| Expose worker metrics | [Client metrics](conductor-client-metrics/README.md) | +| Configure Spring applications | [Core Spring integration](conductor-client-spring/README.md) · [AI Spring guide](docs/agents/spring-boot.md) | +| Manage schedules | [Typed `SchedulerClient`](conductor-client/src/main/java/io/orkes/conductor/client/SchedulerClient.java) | -## API Journey Examples - -End-to-end examples covering all APIs for each domain: - -| Example | APIs | Run | -|---------|------|-----| -| [Metadata Management](examples/src/main/java/io/orkes/conductor/sdk/examples/MetadataManagement.java) | Task & workflow definitions | `./gradlew :examples:run -PmainClass=io.orkes.conductor.sdk.examples.MetadataManagement` | -| [Workflow Management](examples/src/main/java/io/orkes/conductor/sdk/examples/WorkflowManagement.java) | Start, monitor, control workflows | `./gradlew :examples:run -PmainClass=io.orkes.conductor.sdk.examples.WorkflowManagement` | -| [Authorization Management](examples/src/main/java/io/orkes/conductor/sdk/examples/AuthorizationManagement.java) | Users, groups, permissions | `./gradlew :examples:run -PmainClass=io.orkes.conductor.sdk.examples.AuthorizationManagement` | -| [Scheduler Management](examples/src/main/java/io/orkes/conductor/sdk/examples/SchedulerManagement.java) | Workflow scheduling | `./gradlew :examples:run -PmainClass=io.orkes.conductor.sdk.examples.SchedulerManagement` | - -## Documentation - -| Document | Description | -|----------|-------------| -| [Worker SDK](docs/worker_sdk.md) | Complete worker framework guide | -| [Workflow SDK](docs/workflow_sdk.md) | Workflow-as-code documentation | -| [FileClient](docs/file-client.md) | Workflow-scoped upload, download, metadata, and worker examples | -| [FileClient Design](docs/design/file-client.md) | Transfer adapters, retry ownership, multipart, and security boundaries | -| [Testing Framework](docs/testing_framework.md) | Unit testing workflows and workers | -| [Conductor Client](conductor-client/README.md) | HTTP client library documentation | -| [Client Metrics](conductor-client-metrics/README.md) | Prometheus metrics collection | -| [Spring Integration](conductor-client-spring/README.md) | Spring Boot auto-configuration | -| [AI Agents](docs/agents/index.md) | Durable AI agent SDK (`conductor-client-ai`) guide | -| [Examples](examples/README.md) | Complete examples catalog | - -## Support - -- [Open an issue (SDK)](https://github.com/conductor-oss/conductor-java-sdk/issues) for SDK bugs, questions, and feature requests -- [Open an issue (Conductor server)](https://github.com/conductor-oss/conductor/issues) for Conductor OSS server issues -- [Join the Conductor Slack](https://join.slack.com/t/orkes-conductor/shared_invite/zt-2vdbx239s-Eacdyqya9giNLHfrCavfaA) for community discussion and help -- [Orkes Community Forum](https://community.orkes.io/) for Q&A - -## Frequently Asked Questions - -**Is this the same as Netflix Conductor?** - -Yes. Conductor OSS is the continuation of the original [Netflix Conductor](https://github.com/Netflix/conductor) repository after Netflix contributed the project to the open-source foundation. - -**Is this project actively maintained?** - -Yes. [Orkes](https://orkes.io) is the primary maintainer and offers an enterprise SaaS platform for Conductor across all major cloud providers. - -**Can Conductor scale to handle my workload?** - -Conductor was built at Netflix to handle massive scale and has been battle-tested in production environments processing millions of workflows. It scales horizontally to meet virtually any demand. - -**Does Conductor support durable code execution?** - -Yes. Conductor ensures workflows complete reliably even in the face of infrastructure failures, process crashes, or network issues. - -**Are workflows always asynchronous?** - -No. While Conductor excels at asynchronous orchestration, it also supports synchronous workflow execution when immediate results are required. - -**Do I need to use a Conductor-specific framework?** - -No. Conductor is language and framework agnostic. Use your preferred language and framework -- the [SDKs](https://github.com/conductor-oss/conductor#conductor-sdks) provide native integration for Python, Java, JavaScript, Go, C#, and more. - -**Can I mix workers written in different languages?** - -Yes. A single workflow can have workers written in Python, Java, Go, or any other supported language. Workers communicate through the Conductor server, not directly with each other. - -**What Java versions are supported?** - -Java 17 and above. - -**Should I use Worker interface or @WorkerTask annotation?** - -Use `@WorkerTask` annotation for simpler, cleaner code -- input parameters are automatically mapped and return values become task output. Use the `Worker` interface when you need full control over task execution, access to task metadata, or custom error handling. +## Troubleshooting -**How do I run workers in production?** +| Symptom | Check | +|---|---| +| Connection refused | The server is healthy at `http://localhost:8080/health`; `CONDUCTOR_SERVER_URL` ends in `/api`. | +| Task remains `SCHEDULED` | A worker is polling the exact task type and has enough threads. | +| Authentication failure | `CONDUCTOR_AUTH_KEY` and `CONDUCTOR_AUTH_SECRET` are set for the target server. | +| AI agent cannot call a model | The server—not only the client process—has a configured LLM provider and model. | -Workers are standard Java applications. Deploy them as you would any Java application -- in containers, VMs, or bare metal. Workers poll the Conductor server for tasks, so no inbound ports need to be opened. +## Support and project policies -**How do I test workflows without running a full Conductor server?** +**Contribute upstream:** [Choose a Conductor OSS contribution](https://github.com/conductor-oss/conductor/contribute) · [Read the Conductor OSS contribution guide](https://github.com/conductor-oss/conductor/blob/main/CONTRIBUTING.md) -The SDK provides a test framework that uses Conductor's `POST /api/workflow/test` endpoint to evaluate workflows with mock task outputs. See [Testing Framework](docs/testing_framework.md) for details. +- [SDK issues](https://github.com/conductor-oss/conductor-java-sdk/issues) for Java SDK bugs and feature requests +- [Conductor server issues](https://github.com/conductor-oss/conductor/issues) for OSS server behavior +- [Contributing](CONTRIBUTING.md) for local development, tests, and pull requests +- [Code of Conduct](CODE_OF_CONDUCT.md) for community expectations and conduct reporting +- [Security policy](SECURITY.md) for private vulnerability reporting +- [Conductor Slack](https://join.slack.com/t/orkes-conductor/shared_invite/zt-2vdbx239s-Eacdyqya9giNLHfrCavfaA) and the [Orkes Community Forum](https://community.orkes.io/) for questions ## License -Apache 2.0 +Apache 2.0. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..bd3a4ecf8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Reporting a vulnerability + +Do not report security vulnerabilities in a public issue. Report them through the [Conductor private security advisory channel](https://github.com/conductor-oss/conductor/security/advisories/new). + +Include the affected Java SDK artifact and version, a minimal reproduction, impact, and any mitigation you identified. + +## Supported versions + +Security fixes are applied to the current maintained release line. Please upgrade to the latest published SDK release before reporting behavior that may already be fixed. + +For Conductor server support and vulnerability policy, see the [upstream security policy](https://github.com/conductor-oss/conductor/security/policy). diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example108PlanExecuteRefs.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example108PlanExecuteRefs.java index cccdd8046..0d438e118 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example108PlanExecuteRefs.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example108PlanExecuteRefs.java @@ -12,10 +12,6 @@ */ package org.conductoross.conductor.ai.examples; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -30,6 +26,13 @@ import org.conductoross.conductor.ai.plans.Ref; import org.conductoross.conductor.ai.plans.Step; +import com.netflix.conductor.client.http.WorkflowClient; +import com.netflix.conductor.common.metadata.tasks.Task; +import com.netflix.conductor.common.run.Workflow; + +import io.orkes.conductor.client.ApiClient; +import io.orkes.conductor.client.OrkesClients; + import com.fasterxml.jackson.databind.ObjectMapper; @@ -50,7 +53,7 @@ * to format a final summary. The plan is fully deterministic — no planner * LLM required — because we pass it directly to {@code runtime.run}. * - *

      Run: {@code ./gradlew :examples:run -PmainClass=org.conductoross.conductor.ai.examples.Example108PlanExecuteRefs} + *

      Run: {@code ./gradlew :agent-examples:run -PmainClass=org.conductoross.conductor.ai.examples.Example108PlanExecuteRefs} */ public class Example108PlanExecuteRefs { @@ -162,54 +165,39 @@ public static void main(String[] args) throws Exception { .build()) .build(); - try (AgentRuntime runtime = new AgentRuntime()) { + ApiClient transport = new ApiClient(); + WorkflowClient workflows = new OrkesClients(transport).getWorkflowClient(); + try (AgentRuntime runtime = new AgentRuntime(transport)) { AgentResult result = runtime.run(harness, "demo", plan); System.out.println("status=" + result.getStatus() + " executionId=" + result.getExecutionId()); - showPipelineOutputs(result.getExecutionId()); + showPipelineOutputs(workflows, result.getExecutionId()); } } - @SuppressWarnings("unchecked") - private static void showPipelineOutputs(String executionId) throws Exception { - HttpClient http = HttpClient.newHttpClient(); + private static void showPipelineOutputs(WorkflowClient workflows, String executionId) throws Exception { ObjectMapper mapper = new ObjectMapper(); - Map parent = fetchWorkflow(http, mapper, executionId); + Workflow parent = workflows.getWorkflow(executionId, true); String subId = null; - for (Map t : (List>) parent.getOrDefault("tasks", List.of())) { - String ref = String.valueOf(t.getOrDefault("referenceTaskName", "")); + for (Task task : parent.getTasks()) { + String ref = task.getReferenceTaskName(); if (ref.endsWith("_plan_exec")) { - Map out = (Map) t.get("outputData"); - subId = out == null ? null : (String) out.get("subWorkflowId"); + subId = (String) task.getOutputData().get("subWorkflowId"); break; } } if (subId == null) return; - Map sub = fetchWorkflow(http, mapper, subId); + Workflow sub = workflows.getWorkflow(subId, true); System.out.println("\n── pipeline trace (Ref data flow) ────────────────────────"); - for (Map t : (List>) sub.getOrDefault("tasks", List.of())) { - String name = String.valueOf(t.get("taskDefName")); + for (Task task : sub.getTasks()) { + String name = task.getTaskDefName(); if (name.equals("produce") || name.equals("enrich") || name.equals("report")) { System.out.println("\n" + name + ":"); - System.out.println( - mapper.writerWithDefaultPrettyPrinter().writeValueAsString(t.get("outputData"))); + System.out.println(mapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(task.getOutputData())); } } } - private static final String BASE_URL = - System.getenv().getOrDefault("CONDUCTOR_SERVER_URL", "http://localhost:8080/api") - .replace("/api", ""); - @SuppressWarnings("unchecked") - private static Map fetchWorkflow( - HttpClient http, ObjectMapper mapper, String id) throws Exception { - HttpResponse resp = http.send( - HttpRequest.newBuilder() - .uri(URI.create(BASE_URL + "/api/workflow/" + id + "?includeTasks=true")) - .GET() - .build(), - HttpResponse.BodyHandlers.ofString()); - return mapper.readValue(resp.body(), Map.class); - } } diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example115PlannerContext.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example115PlannerContext.java index 2ba6837c7..510baaa75 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example115PlannerContext.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example115PlannerContext.java @@ -12,10 +12,6 @@ */ package org.conductoross.conductor.ai.examples; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -27,7 +23,12 @@ import org.conductoross.conductor.ai.model.ToolDef; import org.conductoross.conductor.ai.plans.Context; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.netflix.conductor.client.http.WorkflowClient; +import com.netflix.conductor.common.metadata.tasks.Task; +import com.netflix.conductor.common.run.Workflow; + +import io.orkes.conductor.client.ApiClient; +import io.orkes.conductor.client.OrkesClients; /** * 115 — Plan-Execute with {@code plannerContext}: customer onboarding plan. @@ -54,16 +55,12 @@ *

      Mirrors sdk/python/examples/115_plan_execute_planner_context.py and * sdk/typescript/examples/115-plan-execute-planner-context.ts. * - *

      Run: {@code ./gradlew :examples:run -PmainClass=org.conductoross.conductor.ai.examples.Example115PlannerContext} + *

      Run: {@code ./gradlew :agent-examples:run -PmainClass=org.conductoross.conductor.ai.examples.Example115PlannerContext} */ public class Example115PlannerContext { private static final String MODEL = System.getenv().getOrDefault("CONDUCTOR_AGENT_LLM_MODEL", "anthropic/claude-sonnet-4-6"); - private static final String BASE_URL = - System.getenv().getOrDefault("CONDUCTOR_SERVER_URL", "http://localhost:8080/api") - .replace("/api", ""); - public static void main(String[] args) throws Exception { // ── Onboarding tools (deterministic, no external calls) ────── @@ -206,38 +203,26 @@ public static void main(String[] args) throws Exception { String prompt = "Onboard customer cust-001 at tier 'enterprise'. " + "Use customer_id='cust-001' and tier='enterprise' for the tools."; - try (AgentRuntime runtime = new AgentRuntime()) { + ApiClient transport = new ApiClient(); + WorkflowClient workflows = new OrkesClients(transport).getWorkflowClient(); + try (AgentRuntime runtime = new AgentRuntime(transport)) { AgentResult result = runtime.run(harness, prompt); System.out.println("status: " + result.getStatus()); System.out.println("output: " + result.getOutput()); - showExecutedSteps(result.getExecutionId()); + showExecutedSteps(workflows, result.getExecutionId()); } } - private static void showExecutedSteps(String executionId) throws Exception { - ObjectMapper mapper = new ObjectMapper(); - HttpClient client = HttpClient.newHttpClient(); - - HttpRequest parentReq = HttpRequest.newBuilder() - .uri(URI.create(BASE_URL + "/api/workflow/" + executionId + "?includeTasks=true")) - .build(); - HttpResponse parentResp = - client.send(parentReq, HttpResponse.BodyHandlers.ofString()); - @SuppressWarnings("unchecked") - Map parent = mapper.readValue(parentResp.body(), Map.class); + private static void showExecutedSteps(WorkflowClient workflows, String executionId) { + Workflow parent = workflows.getWorkflow(executionId, true); System.out.println("\n=== Executed onboarding plan ==="); - @SuppressWarnings("unchecked") - List> parentTasks = - (List>) parent.getOrDefault("tasks", List.of()); String subId = null; - for (Map t : parentTasks) { - String ref = (String) t.getOrDefault("referenceTaskName", ""); + for (Task task : parent.getTasks()) { + String ref = task.getReferenceTaskName(); if (ref.endsWith("_plan_exec")) { - @SuppressWarnings("unchecked") - Map out = (Map) t.get("outputData"); - if (out != null) subId = (String) out.get("subWorkflowId"); + subId = (String) task.getOutputData().get("subWorkflowId"); break; } } @@ -246,27 +231,18 @@ private static void showExecutedSteps(String executionId) throws Exception { return; } - HttpRequest subReq = HttpRequest.newBuilder() - .uri(URI.create(BASE_URL + "/api/workflow/" + subId + "?includeTasks=true")) - .build(); - HttpResponse subResp = - client.send(subReq, HttpResponse.BodyHandlers.ofString()); - @SuppressWarnings("unchecked") - Map sub = mapper.readValue(subResp.body(), Map.class); - @SuppressWarnings("unchecked") - List> subTasks = - (List>) sub.getOrDefault("tasks", List.of()); + Workflow sub = workflows.getWorkflow(subId, true); java.util.Set expected = java.util.Set.of( "validate_kyc", "create_account", "send_welcome_email", "schedule_kickoff_call"); int count = 0; boolean sawKickoff = false; - for (Map t : subTasks) { - String name = (String) t.getOrDefault("taskDefName", ""); + for (Task task : sub.getTasks()) { + String name = task.getTaskDefName(); if (expected.contains(name)) { count++; if ("schedule_kickoff_call".equals(name)) sawKickoff = true; - System.out.printf(" %-10s %s%n", t.get("status"), name); + System.out.printf(" %-10s %s%n", task.getStatus(), name); } } System.out.println(" " + count + " step(s) executed"); diff --git a/examples/basics/hello-world/run.sh b/examples/basics/hello-world/run.sh index 00b1bd1ab..33aabe98e 100755 --- a/examples/basics/hello-world/run.sh +++ b/examples/basics/hello-world/run.sh @@ -18,28 +18,90 @@ load_repo_env() { load_repo_env - echo "=== Hello World (Java) ===" echo "" -CONDUCTOR_SERVER_URL="${CONDUCTOR_SERVER_URL:-http://localhost:${CONDUCTOR_PORT:-8080}/api}" -HEALTH_URL="${CONDUCTOR_SERVER_URL%/api}/health" +EXPLICIT_SERVER_URL="${CONDUCTOR_SERVER_URL:-}" + +run_with_external_server() { + local health_url="${CONDUCTOR_SERVER_URL%/api}/health" -CONDUCTOR_PORT="${CONDUCTOR_PORT:-$(echo "$CONDUCTOR_SERVER_URL" | sed -n 's|.*://[^:]*:\([0-9]*\).*|\1|p')}" -CONDUCTOR_PORT="${CONDUCTOR_PORT:-8080}" -export CONDUCTOR_PORT + if ! curl -sf "$health_url" > /dev/null 2>&1; then + echo "No healthy Conductor server found at $health_url." >&2 + echo "Fix CONDUCTOR_SERVER_URL, or unset it to let this launcher start Conductor." >&2 + exit 1 + fi -if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then - echo "Conductor is running at $CONDUCTOR_SERVER_URL" + echo "Using the explicitly configured Conductor server at $CONDUCTOR_SERVER_URL" echo "Building and running the example..." echo "" docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work maven:3.9-eclipse-temurin-21 mvn -q package -DskipTests 2>/dev/null || docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work maven:3.9-eclipse-temurin-21 mvn package -DskipTests CONDUCTOR_SERVER_URL="$CONDUCTOR_SERVER_URL" java -jar target/hello-world-1.0.0.jar "$@" +} + +managed_conductor_is_running() { + [ -n "$(docker compose ps --status running --quiet conductor 2>/dev/null)" ] +} + +managed_host_port() { + local container_port="$1" + local binding + binding="$(docker compose port conductor "$container_port" 2>/dev/null)" + echo "${binding##*:}" +} + +wait_for_managed_conductor() { + local attempt + for attempt in {1..60}; do + if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then + return 0 + fi + sleep 1 + done + + echo "Conductor did not become healthy at $HEALTH_URL." >&2 + docker compose logs conductor >&2 + return 1 +} + +if [ -n "$EXPLICIT_SERVER_URL" ]; then + CONDUCTOR_SERVER_URL="$EXPLICIT_SERVER_URL" + export CONDUCTOR_SERVER_URL + run_with_external_server "$@" else - echo "Conductor not found at $HEALTH_URL" - echo "Starting Conductor on port $CONDUCTOR_PORT with Docker Compose..." + CONDUCTOR_PORT="${CONDUCTOR_PORT:-8080}" + CONDUCTOR_UI_PORT="${CONDUCTOR_UI_PORT:-1234}" + CONDUCTOR_SERVER_URL="http://localhost:${CONDUCTOR_PORT}/api" + HEALTH_URL="http://localhost:${CONDUCTOR_PORT}/health" + export CONDUCTOR_PORT CONDUCTOR_UI_PORT CONDUCTOR_SERVER_URL + + if managed_conductor_is_running; then + CONDUCTOR_PORT="$(managed_host_port 8080)" + CONDUCTOR_UI_PORT="$(managed_host_port 5000)" + CONDUCTOR_SERVER_URL="http://localhost:${CONDUCTOR_PORT}/api" + HEALTH_URL="http://localhost:${CONDUCTOR_PORT}/health" + export CONDUCTOR_PORT CONDUCTOR_UI_PORT CONDUCTOR_SERVER_URL + echo "Reusing this example's managed Conductor server at $CONDUCTOR_SERVER_URL" + else + if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then + echo "A Conductor server is already responding at $CONDUCTOR_SERVER_URL," >&2 + echo "but it was not started by this example." >&2 + echo "Set CONDUCTOR_SERVER_URL=$CONDUCTOR_SERVER_URL to reuse it explicitly," >&2 + echo "or choose unused ports, for example:" >&2 + echo " CONDUCTOR_PORT=18080 CONDUCTOR_UI_PORT=11234 ./run.sh" >&2 + exit 1 + fi + + echo "Starting a managed Conductor server on port $CONDUCTOR_PORT..." + docker compose up -d conductor + fi + + wait_for_managed_conductor + echo "Building and running the example..." echo "" - echo "Tip: If port $CONDUCTOR_PORT is taken, run: CONDUCTOR_PORT=9090 ./run.sh" + docker compose run --build --rm hello-world "$@" echo "" - docker compose up --build --abort-on-container-exit + echo "Conductor is still running." + echo "UI: http://localhost:${CONDUCTOR_UI_PORT}" + echo "Stop it with: docker compose down" fi From efcb873775d5dac86acad657592c4e2977417cb2 Mon Sep 17 00:00:00 2001 From: Viren Baraiya Date: Sat, 18 Jul 2026 18:45:19 -0700 Subject: [PATCH 09/14] Update ci.yml --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0997a2f46..77eaba475 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: - name: Reject retired README references run: | - if rg -n ':examples:run|examples/old|LlmIndexDocument|\.topK\(' README.md; then + if grep -En ':examples:run|examples/old|LlmIndexDocument|\.topK\(' README.md; then echo "README references a retired command, path, or API." exit 1 fi @@ -60,9 +60,9 @@ jobs: CONDUCTOR_UI_PORT: 11234 run: | timeout 240 ./run.sh - curl --fail --retry 10 --retry-delay 2 --retry-all-errors http://localhost:18080/health - curl --fail --retry 10 --retry-delay 2 --retry-all-errors http://localhost:11234/ - docker compose ps --status running --services | rg --fixed-strings --line-regexp conductor + curl --fail --silent --show-error --output /dev/null --retry 10 --retry-delay 2 --retry-all-errors http://localhost:18080/health + curl --fail --silent --show-error --output /dev/null --retry 10 --retry-delay 2 --retry-all-errors http://localhost:11234/ + docker compose ps --status running --services | grep -Fx conductor - name: Stop documented core quickstart if: always() From 3cf2fa98375f16a2528fd753c0fd8eb35dc900f0 Mon Sep 17 00:00:00 2001 From: Viren Baraiya Date: Sun, 19 Jul 2026 00:03:21 -0700 Subject: [PATCH 10/14] fixes --- .github/workflows/ci.yml | 44 +- README.md | 33 +- .../ai/examples/Example04HttpAndMcpTools.java | 51 +- .../ai/examples/Example16CredentialsTool.java | 2 +- .../adk/Example18OrderProcessing.java | 8 +- .../conductor/ai/internal/WorkerManager.java | 2 +- .../conductor/ai/model/ToolContext.java | 2 +- .../conductor/ai/tools/McpTool.java | 5 +- .../conductor/ai/tools/ToolsTest.java | 1 + conductor-client-spring-boot4/README.md | 2 +- conductor-client-spring/README.md | 4 +- conductor-client/README.md | 2 +- {docs/design => design}/file-client.md | 2 +- .../secret-injection-contract.md | 2 +- docs/README.md | 27 + docs/agents/README.md | 185 ++----- docs/agents/agent-schema.md | 163 ------ docs/agents/api-reference.md | 513 ------------------ docs/agents/concepts/agents.md | 68 +-- docs/agents/concepts/deploy-serve-run.md | 4 +- docs/agents/concepts/scheduling.md | 16 +- docs/agents/concepts/skills.md | 97 ---- docs/agents/concepts/termination.md | 2 +- docs/agents/concepts/tools.md | 23 +- docs/agents/frameworks/google-adk.md | 11 +- docs/agents/frameworks/langchain4j.md | 13 +- docs/agents/frameworks/langgraph4j.md | 12 +- docs/agents/frameworks/openai.md | 15 +- docs/agents/generated/AgentConfigModel.java | 25 - docs/agents/generated/agent_config.py | 190 ------- docs/agents/generated/generate.py | 130 ----- docs/agents/getting-started.md | 158 ++---- docs/agents/index.md | 87 --- .../agent-definition.md} | 4 +- docs/agents/{ => reference}/agent-schema.json | 2 +- docs/agents/reference/agent-schema.md | 36 ++ docs/agents/reference/api.md | 23 + .../client.md} | 25 +- .../runtime.md} | 23 +- docs/agents/spring-boot.md | 26 +- docs/file-client.md | 7 +- docs/{SchemaClient.md => schema-client.md} | 8 +- docs/server-setup.md | 46 ++ docs/testing_framework.md | 74 --- docs/worker_sdk.md | 143 ----- docs/workers.md | 91 ++++ docs/workflow-testing.md | 65 +++ docs/workflow_sdk.md | 232 -------- docs/workflows.md | 97 ++++ examples/README.md | 9 +- examples/RUNNING.md | 32 +- examples/ai/README.md | 6 +- tools/agent-schema/requirements.txt | 1 + tools/agent-schema/verify.py | 130 +++++ 54 files changed, 858 insertions(+), 2121 deletions(-) rename {docs/design => design}/file-client.md (99%) rename {docs/design => design}/secret-injection-contract.md (98%) create mode 100644 docs/README.md delete mode 100644 docs/agents/agent-schema.md delete mode 100644 docs/agents/api-reference.md delete mode 100644 docs/agents/concepts/skills.md delete mode 100644 docs/agents/generated/AgentConfigModel.java delete mode 100644 docs/agents/generated/agent_config.py delete mode 100644 docs/agents/generated/generate.py delete mode 100644 docs/agents/index.md rename docs/agents/{agent-structure.md => reference/agent-definition.md} (98%) rename docs/agents/{ => reference}/agent-schema.json (99%) create mode 100644 docs/agents/reference/agent-schema.md create mode 100644 docs/agents/reference/api.md rename docs/agents/{agent-client-api.md => reference/client.md} (95%) rename docs/agents/{agent-runtime-api.md => reference/runtime.md} (94%) rename docs/{SchemaClient.md => schema-client.md} (88%) create mode 100644 docs/server-setup.md delete mode 100644 docs/testing_framework.md delete mode 100644 docs/worker_sdk.md create mode 100644 docs/workers.md create mode 100644 docs/workflow-testing.md delete mode 100644 docs/workflow_sdk.md create mode 100644 docs/workflows.md create mode 100644 tools/agent-schema/requirements.txt create mode 100644 tools/agent-schema/verify.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77eaba475..762ea7199 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,9 +14,9 @@ concurrency: cancel-in-progress: true jobs: - readme-validation: + documentation-validation: runs-on: ubuntu-latest - name: README validation + name: Documentation validation timeout-minutes: 15 steps: @@ -29,18 +29,48 @@ jobs: distribution: "zulu" java-version: "21" - - name: Validate README links + - name: Validate documentation links uses: lycheeverse/lychee-action@v2 with: - args: --offline --no-progress README.md + args: --offline --no-progress README.md docs - - name: Reject retired README references + - name: Reject retired documentation references run: | - if grep -En ':examples:run|examples/old|LlmIndexDocument|\.topK\(' README.md; then - echo "README references a retired command, path, or API." + if grep -REn ':examples:run|examples/old|LlmIndexDocument|\.topK\(|runtime\.schedules\(|Schedule\.builder|conductor\.netflix\.com|^=== "|compileOnly .*langchain|compileOnly .*google\.adk|compileOnly .*langgraph|docs/agents/index\.md|docs/agents/agent-(runtime-api|client-api|structure|schema)\.md|docs/agents/api-reference\.md|docs/testing_framework\.md' README.md docs; then + echo "Documentation references a retired command, path, API, or renderer-specific construct." exit 1 fi + - name: Validate documented source links and test-server version + run: | + curl --fail --silent --show-error --location --retry 3 --retry-all-errors --output /dev/null \ + https://github.com/conductor-oss/java-sdk/blob/main/conductor-client/src/main/java/com/netflix/conductor/sdk/workflow/executor/WorkflowExecutor.java + curl --fail --silent --show-error --location --retry 3 --retry-all-errors --output /dev/null \ + https://github.com/conductor-oss/java-sdk/blob/main/conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/testing/WorkflowTestFrameworkTests.java + server_version="$(sed -n 's/.*new WorkflowTestRunner(8080, "\([^"]*\)").*/\1/p' conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/testing/WorkflowTestFrameworkTests.java)" + test -n "$server_version" + grep -Fq "$server_version" docs/workflow-testing.md + + - name: Verify agent configuration schema + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Run agent configuration schema verifier + run: | + python -m pip install --disable-pip-version-check -r tools/agent-schema/requirements.txt + python tools/agent-schema/verify.py + test ! -d docs/agents/generated + test -f docs/agents/reference/agent-schema.json + test ! -f docs/agents/concepts/skills.md + + - name: Verify documented example targets + run: | + test -f examples/basics/hello-world/run.sh + test -f agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example01BasicAgent.java + test -f agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example108PlanExecuteRefs.java + test -f agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example115PlannerContext.java + - name: Compile documented AI example target run: ./gradlew :agent-examples:compileJava diff --git a/README.md b/README.md index ca96b42c9..6604a01c5 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ The Java SDK for [Conductor](https://www.conductor-oss.org/) lets you build dura **Get involved:** [⭐ Conductor OSS](https://github.com/conductor-oss/conductor) · [Choose a Conductor OSS contribution](https://github.com/conductor-oss/conductor/contribute) · [Contribution guide](https://github.com/conductor-oss/conductor/blob/main/CONTRIBUTING.md) +**Using an AI coding agent?** Load [Conductor Skills](https://github.com/conductor-oss/conductor-skills) so it can create, run, and operate Conductor workflows: `npm install -g @conductor-oss/conductor-skills && conductor-skills --all`. + ## Choose your path | I want to… | Start here | @@ -16,7 +18,8 @@ The Java SDK for [Conductor](https://www.conductor-oss.org/) lets you build dura | Build a durable AI agent with tools and human approval | [Run an AI agent example](#ai-agent-quickstart) | | Bring an existing Google ADK or LangChain4j agent | [Use framework bridges](#google-adk-and-langchain4j) | | Build a durable workflow and Java worker | [Run the core hello-world example](#workflow-and-worker-quickstart) | -| Browse all examples | [AI agent guide](docs/agents/index.md) · [Core examples](examples/README.md) | +| Browse all examples | [AI agent guide](docs/agents/README.md) · [Core examples](examples/README.md) | +| Navigate the SDK documentation | [Documentation hub](docs/README.md) | ## Why Conductor? @@ -38,7 +41,7 @@ Prefer to construct the graph in code? [`Example108PlanExecuteRefs`](agent-examp ## Requirements and compatibility - Java 21+ -- Docker for the standalone core quickstart, or a running OSS/Orkes Conductor server. +- A running OSS/Orkes Conductor server. For local development, use the [Conductor CLI](docs/server-setup.md) (`npm install -g @conductor-oss/conductor-cli`; then `conductor server start`). Docker remains available for containerized examples. - Maven 3.8+ when running standalone core examples without their launcher script; Gradle is included for this repository's agent examples. The CI workflows are the source of truth for the server versions exercised by this SDK. See the [agent E2E matrix](.github/workflows/agent-e2e.yml) for its pinned server version. @@ -105,7 +108,7 @@ export CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini -PmainClass=org.conductoross.conductor.ai.examples.Example01BasicAgent ``` -Expected outcome: the example prints an `AgentResult` containing the model response. See the [AI agent guide](docs/agents/index.md), [tools guide](docs/agents/concepts/tools.md), and [agent examples](agent-examples/src/main/java/org/conductoross/conductor/ai/examples/) for the next step. +Expected outcome: the example prints an `AgentResult` containing the model response. See the [AI agent guide](docs/agents/README.md), [tools guide](docs/agents/concepts/tools.md), and [agent examples](agent-examples/src/main/java/org/conductoross/conductor/ai/examples/) for the next step. ### Google ADK and LangChain4j @@ -117,8 +120,14 @@ Start with the [Google ADK examples](agent-examples/src/main/java/org/conductoro This maintained example registers a workflow, starts a Java worker, executes the workflow, and prints `Result: PASSED`. +### Recommended: use a CLI-managed server + ```shell -# The launcher starts Conductor, builds the example with Maven/JDK 21, and runs it. +# Start the local server once (see docs/server-setup.md). +conductor server start +export CONDUCTOR_SERVER_URL=http://localhost:8080/api + +# The launcher reuses the explicitly configured server. cd examples/basics/hello-world ./run.sh ``` @@ -131,13 +140,17 @@ Output: {greeting=Hello, Developer! Welcome to Conductor.} Result: PASSED ``` -The managed Conductor server remains running after the example completes. Open the UI at [http://localhost:1234](http://localhost:1234), then stop it when finished: +Open the CLI-managed server UI at [http://localhost:8080](http://localhost:8080), then stop it when finished: ```shell -docker compose down +conductor server stop ``` -To use an existing server instead, set `CONDUCTOR_SERVER_URL` explicitly before running the launcher. For worker patterns, workflow definitions, and testing, continue with the [core examples catalog](examples/README.md), [worker guide](docs/worker_sdk.md), and [workflow guide](docs/workflow_sdk.md). +### Optional: let the launcher manage Docker + +Leave `CONDUCTOR_SERVER_URL` unset and run the same launcher. It starts the example's Docker Compose server; its UI is [http://localhost:1234](http://localhost:1234). Stop that path with `docker compose down` from `examples/basics/hello-world`. + +For any existing server, set `CONDUCTOR_SERVER_URL` explicitly before running the launcher. For worker patterns, workflow definitions, and testing, continue with the [core examples catalog](examples/README.md), [worker guide](docs/workers.md), and [workflow guide](docs/workflows.md). ## Common tasks @@ -147,10 +160,10 @@ To use an existing server instead, set `CONDUCTOR_SERVER_URL` explicitly before | Add tools and human approval | [Agent tools](docs/agents/concepts/tools.md) | | Use another agent framework | [Google ADK](docs/agents/frameworks/google-adk.md) · [LangChain4j](docs/agents/frameworks/langchain4j.md) · [LangGraph4j](docs/agents/frameworks/langgraph4j.md) | | Deploy, serve, and run agents | [Agent runtime modes](docs/agents/concepts/deploy-serve-run.md) | -| Implement and scale Java workers | [Worker SDK guide](docs/worker_sdk.md) | -| Define workflows in Java | [Workflow SDK guide](docs/workflow_sdk.md) | +| Implement and scale Java workers | [Workers guide](docs/workers.md) | +| Define workflows in Java | [Workflows guide](docs/workflows.md) | | Upload/download workflow-scoped files | [FileClient guide](docs/file-client.md) | -| Test workflows and workers | [Testing framework](docs/testing_framework.md) | +| Test workflows and workers | [Workflow test harness](docs/workflow-testing.md) | | Expose worker metrics | [Client metrics](conductor-client-metrics/README.md) | | Configure Spring applications | [Core Spring integration](conductor-client-spring/README.md) · [AI Spring guide](docs/agents/spring-boot.md) | | Manage schedules | [Typed `SchedulerClient`](conductor-client/src/main/java/io/orkes/conductor/client/SchedulerClient.java) | diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example04HttpAndMcpTools.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example04HttpAndMcpTools.java index a9ff0c28a..fd6cd228c 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example04HttpAndMcpTools.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example04HttpAndMcpTools.java @@ -45,7 +45,7 @@ *

        *
      • Conductor server with LLM support
      • *
      • MCP weather server on http://localhost:3001/mcp
      • - *
      • CONDUCTOR_SERVER_URL=http://localhost:6767
      • + *
      • CONDUCTOR_SERVER_URL=http://localhost:8080/api
      • *
      */ public class Example04HttpAndMcpTools { @@ -62,57 +62,36 @@ public static void main(String[] args) { // Local worker tool List localTools = ToolRegistry.fromInstance(new ReportTools()); - // HTTP tool — executes server-side via Conductor HttpTask - ToolDef weatherApi = HttpTool.builder() - .name("get_current_weather") - .description("Get current weather for a city from the weather API") - .url("http://localhost:3001/mcp") - .method("POST") - .accept("text/event-stream", "application/json") - .contentType("application/json") + // HTTP tool — uses the regular REST endpoint on the same test server, not /mcp. + ToolDef httpWeather = HttpTool.builder() + .name("get_current_weather_http") + .description("Get the current weather for a city through the test server's HTTP API") + .url("http://localhost:3001/api/weather") + .method("GET") .inputSchema(Map.of( "type", "object", - "properties", Map.of( - "jsonrpc", Map.of("type", "string", "const", "2.0"), - "id", Map.of("const", 1), - "method", Map.of("type", "string", "const", "tools/call"), - "params", Map.of( - "type", "object", - "additionalProperties", false, - "properties", Map.of( - "name", Map.of("type", "string", "const", "get_current_weather"), - "arguments", Map.of( - "type", "object", - "additionalProperties", false, - "properties", Map.of("city", Map.of("type", "string")), - "required", List.of("city") - ) - ), - "required", List.of("name", "arguments") - ) - ), - "required", List.of("jsonrpc", "id", "method", "params") + "properties", Map.of("city", Map.of("type", "string")), + "required", List.of("city") )) .build(); - // MCP tool — discovered from MCP server at runtime + // MCP tool — Conductor discovers and calls the weather tools through the MCP protocol. ToolDef mcpWeather = McpTool.builder() - .name("github") - .description("GitHub operations via MCP") + .name("weather_mcp") + .description("Weather tools for retrieving the current weather by city") .serverUrl("http://localhost:3001/mcp") .build(); Agent agent = Agent.builder() .name("api_assistant") .model(Settings.LLM_MODEL) - .instructions("You have access to weather data, GitHub, and report formatting.") - .tools(localTools) - .tools(weatherApi) + .instructions("Use weather_mcp for weather requests.") + .tools(mcpWeather) .maxTokens(102040) .build(); AgentResult result = runtime.run(agent, - "Get the weather in London and format it as a report."); + "Get the weather in London"); result.printResult(); runtime.shutdown(); diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example16CredentialsTool.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example16CredentialsTool.java index a598819db..1ca3c2b0c 100644 --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example16CredentialsTool.java +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example16CredentialsTool.java @@ -42,7 +42,7 @@ * runtime, so unlike Python/.NET/TypeScript there is no env-injection mode. * Tools MUST read declared credentials via {@code ctx.getCredential(name)}; reading * via {@code System.getenv} would only see whatever the JVM inherited from - * the shell at startup. See {@code docs/design/secret-injection-contract.md} §6. + * the shell at startup. See {@code design/secret-injection-contract.md} §6. * *

      Setup (one-time, via CLI): *

      diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example18OrderProcessing.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example18OrderProcessing.java
      index d146fec34..72452fe41 100644
      --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example18OrderProcessing.java
      +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/adk/Example18OrderProcessing.java
      @@ -40,7 +40,13 @@ public class Example18OrderProcessing {
           public static Map searchCatalog(
                   @Schema(name = "query", description = "Search query") String query,
                   @Schema(name = "category", description = "Product category") String category) {
      -        String cat = category == null || category.isEmpty() ? "all" : category;
      +        String cat = category == null ? "all" : category.trim().toLowerCase();
      +        // Models often use broad labels such as "electronics" or "computers". Treat an
      +        // unknown category as an all-catalog search so a valid text query still produces a useful
      +        // observation instead of sending the agent into a retry loop with an empty result.
      +        if (!List.of("all", "laptops", "accessories", "monitors").contains(cat)) {
      +            cat = "all";
      +        }
               List> catalog = List.of(
                   Map.of("sku", "LAP-001", "name", "ProBook Laptop 15\"", "category", "laptops", "price", 1299.99, "stock", 23),
                   Map.of("sku", "LAP-002", "name", "UltraSlim Notebook 13\"", "category", "laptops", "price", 899.99, "stock", 45),
      diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/WorkerManager.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/WorkerManager.java
      index 86d8e3c4e..5f6db8b2d 100644
      --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/WorkerManager.java
      +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/WorkerManager.java
      @@ -408,7 +408,7 @@ TaskResult executeHandler(String taskName, Task task) {
               // (wire-only Task.runtimeMetadata) BEFORE invoking the handler. Fail closed:
               // a declared-but-undelivered name is a terminal failure so Conductor doesn't
               // burn retries on a config problem — ambient process env is NEVER read.
      -        // See docs/design/secret-injection-contract.md.
      +        // See design/secret-injection-contract.md.
               Map resolvedSecrets = Collections.emptyMap();
               List declared = taskCredentials.getOrDefault(taskName, Collections.emptyList());
               if (!declared.isEmpty()) {
      diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/ToolContext.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/ToolContext.java
      index 372a199db..020cd2790 100644
      --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/ToolContext.java
      +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/model/ToolContext.java
      @@ -40,7 +40,7 @@
        * {@code @Tool(credentials = {...})} and resolved by the runtime for this call. The
        * credential map is an immutable per-call snapshot, so it is safe to read from threads
        * the tool spawns — unlike a thread-local, the values remain valid for the lifetime of
      - * this context object. See {@code docs/design/secret-injection-contract.md} for the
      + * this context object. See {@code design/secret-injection-contract.md} for the
        * cross-SDK contract; Java's per-call context mirrors .NET's {@code IToolContext} and
        * Python's contextvars accessor.
        */
      diff --git a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/tools/McpTool.java b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/tools/McpTool.java
      index 052a7f293..e771ff0d8 100644
      --- a/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/tools/McpTool.java
      +++ b/conductor-client-ai/src/main/java/org/conductoross/conductor/ai/tools/McpTool.java
      @@ -111,7 +111,10 @@ public ToolDef build() {
                   }
       
                   Map config = new HashMap<>(additionalConfig);
      -            if (serverUrl != null) config.put("serverUrl", serverUrl);
      +            // AgentSpan's MCP compiler consumes snake_case config keys, matching the
      +            // cross-SDK agent schema. Using serverUrl silently drops the MCP server
      +            // from the compiled workflow.
      +            if (serverUrl != null) config.put("server_url", serverUrl);
                   if (toolName != null) config.put("toolName", toolName);
                   if (!headers.isEmpty()) config.put("headers", headers);
       
      diff --git a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/tools/ToolsTest.java b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/tools/ToolsTest.java
      index 5a775a639..3c174dd99 100644
      --- a/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/tools/ToolsTest.java
      +++ b/conductor-client-ai/src/test/java/org/conductoross/conductor/ai/tools/ToolsTest.java
      @@ -54,6 +54,7 @@ void mcpToolShape() {
                       .build();
               assertEquals("mcp", t.getToolType());
               assertEquals("m", t.getName());
      +        assertEquals("http://mcp", t.getConfig().get("server_url"));
           }
       
           @Test
      diff --git a/conductor-client-spring-boot4/README.md b/conductor-client-spring-boot4/README.md
      index 9ed7e8ec8..f42e01a60 100644
      --- a/conductor-client-spring-boot4/README.md
      +++ b/conductor-client-spring-boot4/README.md
      @@ -9,7 +9,7 @@ For Spring Boot 3 consumers, use `org.conductoross:conductor-client-spring` inst
       ### Prerequisites
       - Java 21 or higher
       - A Spring Boot 4 project
      -- A running Conductor server (local or remote)
      +- A running Conductor server ([start one locally with the CLI](../docs/server-setup.md), or use a remote server)
       
       ### Usage
       
      diff --git a/conductor-client-spring/README.md b/conductor-client-spring/README.md
      index d7ffc57da..973b5f9c4 100644
      --- a/conductor-client-spring/README.md
      +++ b/conductor-client-spring/README.md
      @@ -8,7 +8,7 @@ in Spring-based applications.
       ### Prerequisites
       - Java 17 or higher
       - A Spring boot Project Gradle properly setup with Gradle or Maven
      -- A running Conductor server (local or remote)
      +- A running Conductor server ([start one locally with the CLI](../docs/server-setup.md), or use a remote server)
       
       ### Using Conductor Client Spring
       
      @@ -47,4 +47,4 @@ public class MyApp {
       conductor.client.rootUri=http://localhost:8080/api
       ```
       
      -> **Note:** We are improving the Spring module to make the integration seamless. SEE: [[Java Client v4] Improve Spring module with auto-configuration](https://github.com/conductor-oss/conductor/issues/285)
      \ No newline at end of file
      +> **Note:** We are improving the Spring module to make the integration seamless. SEE: [[Java Client v4] Improve Spring module with auto-configuration](https://github.com/conductor-oss/conductor/issues/285)
      diff --git a/conductor-client/README.md b/conductor-client/README.md
      index f20c90933..60f709155 100644
      --- a/conductor-client/README.md
      +++ b/conductor-client/README.md
      @@ -7,7 +7,7 @@ This module provides the core client library to interact with Conductor through
       ### Prerequisites
       - Java 11 or higher
       - A Gradle or Maven project properly set up
      -- A running Conductor server (local or remote)
      +- A running Conductor server ([start one locally with the CLI](../docs/server-setup.md), or use a remote server)
       
       ### Using Conductor Client
       
      diff --git a/docs/design/file-client.md b/design/file-client.md
      similarity index 99%
      rename from docs/design/file-client.md
      rename to design/file-client.md
      index b11a9cfb3..a6db99000 100644
      --- a/docs/design/file-client.md
      +++ b/design/file-client.md
      @@ -1,4 +1,4 @@
      -# FileClient Design
      +# File client design
       
       ## Decision
       
      diff --git a/docs/design/secret-injection-contract.md b/design/secret-injection-contract.md
      similarity index 98%
      rename from docs/design/secret-injection-contract.md
      rename to design/secret-injection-contract.md
      index efbd1e4f0..29cb7c4c2 100644
      --- a/docs/design/secret-injection-contract.md
      +++ b/design/secret-injection-contract.md
      @@ -1,4 +1,4 @@
      -# Secret Injection Contract (`runtimeMetadata`)
      +# Secret injection contract (`runtimeMetadata`)
       
       How worker tools receive declared credentials, end to end. This is the wire
       contract shared with the Python SDK (see the Agent SDK Porting Spec, R6) and
      diff --git a/docs/README.md b/docs/README.md
      new file mode 100644
      index 000000000..e14d6b77d
      --- /dev/null
      +++ b/docs/README.md
      @@ -0,0 +1,27 @@
      +# Java SDK documentation
      +
      +Use this guide to get a successful first result, then move into the API reference that fits your application.
      +
      +For a local server, start with the [Conductor CLI setup](server-setup.md). Docker is available when a containerized server is required. If you use an AI coding agent, load [Conductor Skills](https://github.com/conductor-oss/conductor-skills) with `npm install -g @conductor-oss/conductor-skills && conductor-skills --all`.
      +
      +## Choose a path
      +
      +| Goal | Start here | What you will prove |
      +|---|---|---|
      +| Build a durable AI agent | [Run your first agent](agents/getting-started.md) | An LLM-backed agent completes through Conductor. |
      +| Build a workflow and Java worker | [Run Hello World](../examples/basics/hello-world/README.md) | A workflow dispatches a `SIMPLE` task to a Java worker. |
      +| Define workflows in Java | [Workflows](workflows.md) | Fluent workflow definitions and system tasks. |
      +| Implement workers | [Workers](workers.md) | Interface and annotation-based workers. |
      +| Test workflows against a local server | [Workflow test harness](workflow-testing.md) | A workflow and its workers execute in a test. |
      +
      +## After your first result
      +
      +- **AI agents:** [agent guide](agents/README.md), [tools](agents/concepts/tools.md), [framework bridges](agents/README.md#framework-bridges), and [scheduling](agents/concepts/scheduling.md).
      +- **Core client:** [Schema client](schema-client.md), [FileClient](file-client.md), and the [examples catalog](../examples/README.md).
      +- **Design contracts:** [file transfer](../design/file-client.md) and [tool credential delivery](../design/secret-injection-contract.md).
      +
      +## Documentation conventions
      +
      +- Replace `` with a published SDK version from [Maven Central](https://search.maven.org/search?q=g:org.conductoross).
      +- A `provider/model` value is an example; the provider credential must be configured on the **Conductor server**, not only in the Java client process.
      +- Runnable commands link to maintained examples. Short Java blocks labeled **Fragment** show one API concept and need surrounding application setup.
      diff --git a/docs/agents/README.md b/docs/agents/README.md
      index 73c57ec25..85f4e36d1 100644
      --- a/docs/agents/README.md
      +++ b/docs/agents/README.md
      @@ -1,171 +1,56 @@
       # Conductor Java Agent SDK
       
      -Java SDK for Conductor — a durable runtime for AI agents. Build, deploy, and run agents that survive crashes, scale across machines, and pause for human approval.
      +Build durable Java AI agents on Conductor. Agents can use local Java tools, wait for people, execute dynamic plans, and recover after a process restart because Conductor persists execution state.
       
      -## Requirements
      +**New here?** Follow [Getting Started](getting-started.md) to configure a server-side LLM provider and run the maintained basic-agent example.
       
      -- Java 21+
      -- Maven 3.6+ or Gradle 7+
      -- A running Conductor server
      +For an AI coding agent that understands Conductor operations, load [Conductor Skills](https://github.com/conductor-oss/conductor-skills): `npm install -g @conductor-oss/conductor-skills && conductor-skills --all`.
       
      -## Installation
      +## Install
       
      -Maven (`pom.xml`):
      -
      -```xml
      -
      -    org.conductoross
      -    conductor-client-ai
      -    5.1.0
      -
      -```
      -
      -Gradle (`build.gradle`):
      +Requirements: Java 21+ and a Conductor server.
       
       ```groovy
      -implementation 'org.conductoross:conductor-client-ai:5.1.0'
      -```
      -
      -### Spring Boot starter
      -
      -For Spring Boot apps, add the auto-configuration starter instead:
      -
      -```xml
      -
      -    org.conductoross
      -    conductor-client-ai-spring
      -    5.1.0
      -
      -```
      -
      -```groovy
      -implementation 'org.conductoross:conductor-client-ai-spring:5.1.0'
      -```
      -
      -## Quick Start
      -
      -```java
      -import org.conductoross.conductor.ai.Agent;
      -import org.conductoross.conductor.ai.AgentRuntime;
      -import org.conductoross.conductor.ai.model.AgentResult;
      -
      -public class Main {
      -    public static void main(String[] args) {
      -        Agent agent = Agent.builder()
      -            .name("assistant")
      -            .model("openai/gpt-4o")
      -            .instructions("You are a helpful assistant.")
      -            .build();
      -
      -        // AgentRuntime is AutoCloseable — try-with-resources shuts down workers cleanly.
      -        try (AgentRuntime runtime = new AgentRuntime()) {
      -            AgentResult result = runtime.run(agent, "What is the capital of France?");
      -            result.printResult();
      -        }
      -    }
      +dependencies {
      +    implementation 'org.conductoross:conductor-client-ai:'
       }
       ```
       
      -> In Spring, inject the auto-configured `AgentRuntime` bean instead of constructing one.
      -
      -## Configuration
      -
      -Set environment variables:
      -
      -```bash
      -export CONDUCTOR_SERVER_URL=http://localhost:8080/api
      -export CONDUCTOR_AUTH_KEY=your-key
      -export CONDUCTOR_AUTH_SECRET=your-secret
      -export CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o
      -```
      -
      -Or configure programmatically. Connection (server URL + auth) is owned by the
      -Conductor `ApiClient`; `AgentConfig` carries only worker-runner tuning:
      -
      -```java
      -import io.orkes.conductor.client.ApiClient;
      -import org.conductoross.conductor.ai.AgentConfig;
      -import org.conductoross.conductor.ai.AgentRuntime;
      -
      -// Build the Conductor client (server URL + optional key/secret auth)…
      -ApiClient client = ApiClient.builder()
      -        .basePath("http://localhost:8080/api")
      -        .credentials("my-key", "my-secret")
      -        .build();
      -// …and pass worker tuning (poll interval ms, worker threads).
      -AgentRuntime runtime = new AgentRuntime(client, new AgentConfig(100, 5));
      -```
      -
      -> Or just `new AgentRuntime()` / `new AgentRuntime(new AgentConfig(100, 5))` to
      -> build the client from `CONDUCTOR_SERVER_URL` / `CONDUCTOR_AUTH_KEY` /
      -> `CONDUCTOR_AUTH_SECRET`.
      +Replace `` with a published version from [Maven Central](https://search.maven.org/search?q=g:org.conductoross).
       
      -## Tools
      +## Start here
       
      -Define tools using the `@Tool` annotation:
      +- **[Getting Started](getting-started.md)** — configure a server and run a maintained agent example.
      +- **[Deploy · Serve · Run · Plan](concepts/deploy-serve-run.md)** — choose the right runtime mode.
      +- **[Scheduling](concepts/scheduling.md)** — schedule deployed agents with the shared typed client.
       
      -```java
      -import org.conductoross.conductor.ai.annotations.Tool;
      -import org.conductoross.conductor.ai.internal.ToolRegistry;
      -
      -public class WeatherTools {
      -    @Tool(name = "get_weather", description = "Get weather for a city")
      -    public String getWeather(String city) {
      -        return "Sunny, 72F in " + city;
      -    }
      -}
      -
      -// Register with agent
      -WeatherTools tools = new WeatherTools();
      -Agent agent = Agent.builder()
      -    .name("weather_agent")
      -    .model("openai/gpt-4o")
      -    .tools(ToolRegistry.fromInstance(tools))
      -    .build();
      -```
      +## Build agents
       
      -## Multi-Agent
      +- **[Agents](concepts/agents.md)** — builder API and `@AgentDef`.
      +- **[Tools](concepts/tools.md)** — Java tools, HTTP/MCP tools, human approval, files, and credentials.
      +- **[Multi-Agent](concepts/multi-agent.md)** — sequential, parallel, handoff, swarm, and plan-execute agents.
      +- **[Guardrails](concepts/guardrails.md)**, **[Termination](concepts/termination.md)**, **[Callbacks](concepts/callbacks.md)**, **[Stateful Agents](concepts/stateful.md)**, **[Streaming & Human-in-the-Loop](concepts/streaming-hitl.md)**, and **[Structured Output](concepts/structured-output.md)**.
       
      -```java
      -Agent researcher = Agent.builder().name("researcher").model("openai/gpt-4o")
      -    .instructions("Research the topic.").build();
      -Agent writer = Agent.builder().name("writer").model("openai/gpt-4o")
      -    .instructions("Write based on research.").build();
      -
      -// Sequential pipeline
      -Agent pipeline = researcher.then(writer);
      -try (AgentRuntime runtime = new AgentRuntime()) {
      -    AgentResult result = runtime.run(pipeline, "Write about AI trends");
      -}
      -```
      -
      -## Streaming
      -
      -```java
      -try (AgentRuntime runtime = new AgentRuntime()) {
      -    AgentStream stream = runtime.stream(agent, "Tell me a story");
      -    for (AgentEvent event : stream) {
      -        System.out.println(event.getType() + ": " + event.getContent());
      -    }
      -    AgentResult result = stream.getResult();
      -}
      -```
      +## Framework bridges
       
      -## Examples
      +- **[Google ADK](frameworks/google-adk.md)** — bridge native ADK agents and sub-agent graphs.
      +- **[LangChain4j](frameworks/langchain4j.md)** — turn existing `@Tool` POJOs and `ChatModel` metadata into Conductor agents.
      +- **[LangGraph4j](frameworks/langgraph4j.md)** — run a native graph builder on the durable runtime.
      +- **[OpenAI Agents SDK style](frameworks/openai.md)** — use familiar tool and handoff shapes in Java.
       
      -See the `examples/` directory for complete working examples:
      +## Operate and inspect
       
      -- `Example01BasicAgent` — Hello world
      -- `Example02Tools` — Tool-using agents
      -- `Example03StructuredOutput` — Typed output
      -- `Example05Handoffs` — Multi-agent handoffs
      -- `Example06SequentialPipeline` — Sequential chains
      -- `Example07ParallelAgents` — Parallel execution
      -- `Example08RouterAgent` — Router pattern
      -- `Example09HumanInTheLoop` — HITL approvals
      -- `Example10Guardrails` — Input/output guardrails
      -- `Example11Streaming` — Event streaming
      +- **[Spring Boot](spring-boot.md)** — auto-configured runtime and `@AgentDef` discovery.
      +- **[Runtime reference](reference/runtime.md)** and **[control-plane reference](reference/client.md)**.
      +- **[API map](reference/api.md)**, [agent-definition fields](reference/agent-definition.md), and [configuration schema](reference/agent-schema.md).
      +- [Control-plane example](../../agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example71AgentControlPlane.java) — deploy, start, inspect, stop, and cancel an agent.
       
      -## License
      +## What Conductor adds
       
      -Apache 2.0 License. See [LICENSE](../../LICENSE).
      +| Capability | Conductor agent runtime |
      +|---|---|
      +| Process failure recovery | Durable workflow state resumes from completed work. |
      +| Java tools | Tools run as independently scalable Conductor worker tasks. |
      +| Long-running work | Human approval, schedules, and events do not hold application threads open. |
      +| Dynamic execution | Plans become durable sub-workflows that can be inspected and retried. |
      +| Observability | Inputs, outputs, tool calls, retries, and status share one execution record. |
      diff --git a/docs/agents/agent-schema.md b/docs/agents/agent-schema.md
      deleted file mode 100644
      index 2bdd57863..000000000
      --- a/docs/agents/agent-schema.md
      +++ /dev/null
      @@ -1,163 +0,0 @@
      -# Agent JSON Schema
      -
      -[`agent-schema.json`](agent-schema.json) is the canonical wire contract for the agent
      -configuration that every SDK serializes and sends to the server. SDKs POST it under the
      -`agentConfig` key of the start/compile request; the server deserializes it into its
      -`AgentConfig` model and compiles it into a Conductor workflow.
      -
      -- **Format:** JSON Schema Draft 2020-12.
      -- **Convention:** camelCase keys; absent = unset (`@JsonInclude(NON_NULL)` server-side).
      -- **Recursive:** `agents`, `planner`, `fallback`, and `router` nest a full agent config (`$ref: "#"`).
      -- **Strictness:** `additionalProperties: false` at the root, so the schema is the *complete*
      -  set of recognized top-level keys.
      -
      -## What the schema is derived from
      -
      -The server's `AgentConfig` is the canonical target — both SDKs serialize *to* it. The schema
      -is the reconciliation of three sources:
      -
      -| Source | File |
      -|---|---|
      -| Server model (deserialization target) | Conductor server `AgentConfig` model (+ nested `*Config` models) |
      -| Java SDK emit | `conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/AgentConfigSerializer.java` |
      -| Python SDK emit | Python agent SDK `config_serializer.py` |
      -
      -## Proof of correctness
      -
      -The schema is correct iff it is **sound** (every key it declares is server-recognized or
      -explicitly tolerated), **complete** (every key either SDK can emit is permitted), and
      -**type-consistent** (declared types match the server field and both SDK emit types). Each was
      -verified in three rounds.
      -
      -### Round 1 — static inventory
      -
      -Every field of the server `AgentConfig` and its nested models (`ToolConfig`, `GuardrailConfig`,
      -`MemoryConfig`, `TerminationConfig`, `HandoffConfig`, `CallbackConfig`, `CodeExecutionConfig`,
      -`CliConfig`, `ThinkingConfig`, `PrefillToolCallConfig`, `OutputTypeConfig`, `WorkerRef`) was
      -inventoried with its JSON key and type, alongside the exact set of keys each SDK serializer
      -emits. The server uses Spring's default Jackson config — **no `FAIL_ON_UNKNOWN_PROPERTIES`** —
      -so unknown keys are ignored, never rejected. The schema is intentionally *stricter* than the
      -server (closed `additionalProperties`) to serve as a precise contract.
      -
      -### Round 2 — discrepancy verification against source
      -
      -Cross-source differences were confirmed by reading the source directly (not summaries):
      -
      -| Item | Finding | Schema decision |
      -|---|---|---|
      -| guardrail `onFail` | Closed enum `retry \| raise \| fix \| human` — Java `OnFail` enum, Python `_VALID_ON_FAIL`, and the server field comment **all agree**. | enum on `onFail` |
      -| `strategy` | Python emits `strategy: null` for a single agent (`config_serializer.py:91`); Java omits it. | `type: ["string","null"]`, enum includes `null` |
      -| `sessionId` | Java emits it **inside** `agentConfig` (`AgentConfigSerializer.java:259`); the server `AgentConfig` has no such field and reads it from the request wrapper. | permitted optional key |
      -| `planSource` | Python emits it in `agentConfig` (`config_serializer.py:240`); the Java SDK sends the static plan via the wrapper's `static_plan`. Server `AgentConfig` has `planSource`. | permitted optional `object` |
      -| `reasoningEffort` | Values `minimal \| low \| medium \| high` (`agent.py:515`). | enum |
      -
      -### Round 3 — empirical conformance
      -
      -A maximal agent was serialized by **each** SDK and validated against the schema with a Draft
      -2020-12 validator, then negative mutations were checked, then the result was compiled on a live
      -server:
      -
      -| Check | Result |
      -|---|---|
      -| Schema is a well-formed Draft 2020-12 schema | ✅ |
      -| **Python** maximal agent (21 keys) validates | ✅ |
      -| **Java** maximal agent (29 keys; exercises `memory`, `gate`, `termination`, `thinkingConfig`, `codeExecution`, `guardrails`, `tools`, `agents`, `handoffs`) validates | ✅ |
      -| Negative (make-fail): unknown key, bad `reasoningEffort`, bad `strategy`, wrong `maxTurns` type, missing `name`, bad guardrail `onFail` — **all rejected** | ✅ |
      -| Java-emitted, schema-valid config compiles on the live server (`POST /agent/compile` → HTTP 200) | ✅ |
      -
      -The negative checks establish that the schema has *teeth* — it is not vacuously permissive — and
      -the live compile establishes that a schema-valid document is genuinely accepted by the server.
      -
      -## Top-level field correspondence
      -
      -| Schema property | Type | Server `AgentConfig` | Java emit | Python emit |
      -|---|---|---|---|---|
      -| `name` | string (required) | ✅ | ✅ | ✅ |
      -| `description` | string | ✅ | — | — (platform-set) |
      -| `model` | string\|null | ✅ | ✅ | ✅ |
      -| `external` | boolean | ✅ | ✅ | ✅ |
      -| `baseUrl` | string | ✅ | ✅ | ✅ |
      -| `instructions` | string\|object\|null | ✅ | ✅ | ✅ |
      -| `introduction` | string | ✅ | ✅ | ✅ |
      -| `tools` | array→`tool` | ✅ | ✅ | ✅ |
      -| `agents` | array→`#` | ✅ | ✅ | ✅ |
      -| `strategy` | string\|null (enum) | ✅ | ✅ | ✅ |
      -| `router` | `#`\|`workerRef` | ✅ | ✅ | ✅ |
      -| `guardrails` | array→`guardrail` | ✅ | ✅ | ✅ |
      -| `maxTurns` | integer | ✅ | ✅ | ✅ |
      -| `maxTokens` | integer | ✅ | ✅ | ✅ |
      -| `temperature` | number | ✅ | ✅ | ✅ |
      -| `timeoutSeconds` | integer | ✅ | ✅ | ✅ |
      -| `reasoningEffort` | string (enum) | ✅ | ✅ | ✅ |
      -| `contextWindowBudget` | integer | ✅ | ✅ | ✅ |
      -| `thinkingConfig` | `thinkingConfig` | ✅ | ✅ | ✅ |
      -| `memory` | `memory` | ✅ | ✅ | ✅ |
      -| `termination` | `termination` | ✅ | ✅ | ✅ |
      -| `outputType` | `outputType` | ✅ | ✅ | ✅ |
      -| `handoffs` | array→`handoff` | ✅ | ✅ | ✅ |
      -| `allowedTransitions` | object | ✅ | ✅ | ✅ |
      -| `callbacks` | array→`callback` | ✅ | ✅ | ✅ |
      -| `gate` | `gate` | ✅ | ✅ | ✅ |
      -| `stopWhen` | `workerRef` | ✅ | ✅ | ✅ |
      -| `enablePlanning` | boolean | ✅ | ✅ | ✅ |
      -| `planner` | `#` | ✅ | ✅ | ✅ |
      -| `fallback` | `#` | ✅ | ✅ | ✅ |
      -| `fallbackMaxTurns` | integer | ✅ | ✅ | ✅ |
      -| `plannerContext` | array→`plannerContextEntry` | ✅ | ✅ | ✅ |
      -| `planSource` | object | ✅ | — (via wrapper) | ✅ |
      -| `synthesize` | boolean | ✅ | ✅ | ✅ |
      -| `stateful` | boolean | — (domain isolation) | ✅ | ✅ |
      -| `sessionId` | string | — (read from wrapper) | ✅ | — (sent in wrapper) |
      -| `includeContents` | string | ✅ | ✅ | ✅ |
      -| `requiredTools` | array | ✅ | ✅ | ✅ |
      -| `prefillTools` | array→`prefillTool` | ✅ | ✅ | ✅ |
      -| `credentials` | array | ✅ | ✅ | ✅ |
      -| `metadata` | object | ✅ | ✅ | ✅ |
      -| `localCodeExecution` | boolean | — (→ `codeExecution`) | builder flag | builder flag |
      -| `codeExecution` | `codeExecution` | ✅ | ✅ | ✅ |
      -| `cliConfig` | `cliConfig` | ✅ | ✅ | ✅ |
      -| `maskedFields` | array | ✅ | ✅ | ✅ |
      -
      -`—` in the server column marks the two keys the server does not model on `AgentConfig`
      -(`stateful` drives runtime domain isolation; `sessionId` is read from the request wrapper). Both
      -are permitted by the schema so that Java's output validates.
      -
      -## Known cross-SDK divergences
      -
      -These are *correct per the schema* (both forms validate) but worth noting:
      -
      -- **Static plan channel.** Python places the static plan in `agentConfig.planSource`; the Java SDK
      -  sends it in the request wrapper as `static_plan`. The server accepts both.
      -- **Session id channel.** Java echoes `sessionId` into `agentConfig` in addition to the wrapper;
      -  Python only sends it in the wrapper. The server reads it from the wrapper.
      -- **Tool retry fields.** The Java serializer may emit `retryCount` / `retryDelaySeconds` /
      -  `retryPolicy` on a tool. These are not in the server `ToolConfig` model, so the `tool` definition
      -  keeps `additionalProperties: true`.
      -- **`cliConfig.workingDir`.** The Java serializer emits `workingDir` inside `cliConfig`; the server
      -  `CliConfig` model has no such field (it is ignored server-side). The schema includes it so Java
      -  output validates.
      -
      -## Reverse verification
      -
      -As an independent check that the schema is *complete* (the forward pass started from inventories,
      -which could omit a field), the schema is reverse-engineered back into models and diffed against the
      -live source by [`generated/generate.py`](generated/generate.py). It emits a Python dataclass
      -([`generated/agent_config.py`](generated/agent_config.py)) and a Java record
      -([`generated/AgentConfigModel.java`](generated/AgentConfigModel.java)) **directly from the schema**,
      -then asserts:
      -
      -| Check | Result |
      -|---|---|
      -| (a) generated dataclass/record fields are field-for-field identical to the schema (root + 16 nested) | ✅ |
      -| (b) a generated instance serializes to schema-valid JSON | ✅ |
      -| (c) every server `AgentConfig` field — root **and** all 13 nested models (`ToolConfig`, `GuardrailConfig`, `TerminationConfig`, …) — is present in the schema | ✅ **0 gaps** |
      -
      -The generated Java record also compiles under `javac`. The only properties the schema carries
      -beyond the server models are the documented SDK-emitted/tolerated extras: `sessionId`, `stateful`,
      -`localCodeExecution` (root) and `cliConfig.workingDir` — nothing was missed.
      -
      -## Scope
      -
      -The schema describes **native** agent configs. Framework-bridged agents (`openai`, `google_adk`,
      -`skill`, …) take a different serialization path and are sent as an opaque `rawConfig` under a
      -`framework` key in the request wrapper; they are out of scope for this schema.
      diff --git a/docs/agents/api-reference.md b/docs/agents/api-reference.md
      deleted file mode 100644
      index abd9d1f12..000000000
      --- a/docs/agents/api-reference.md
      +++ /dev/null
      @@ -1,513 +0,0 @@
      -# API Reference
      -
      -Complete method signatures for the Conductor Java Agent SDK public API.
      -
      -## AgentRuntime
      -
      -The SDK entry point. Thread-safe — share one instance.
      -
      -```java
      -// Constructors
      -AgentRuntime()                                    // client + tuning from env vars
      -AgentRuntime(AgentConfig config)                  // env vars + explicit tuning
      -AgentRuntime(ApiClient client)                    // explicit client
      -AgentRuntime(ApiClient client, AgentConfig config)
      -```
      -
      -Build the `ApiClient` with its own builder (construction lives in the client
      -layer): `ApiClient.builder().basePath("http://host:8080/api").credentials(key, secret).build()`,
      -or `ApiClient.builder().useEnvVariables(true).build()` for the
      -`CONDUCTOR_SERVER_URL` → `http://localhost:8080/api` chain.
      -
      -### Run
      -
      -```java
      -AgentResult  run(Agent agent, String prompt)
      -AgentResult  run(Agent agent, String prompt, Plan plan)
      -
      -CompletableFuture  runAsync(Agent agent, String prompt)
      -CompletableFuture  runAsync(Agent agent, String prompt, Plan plan)
      -```
      -
      -### Start (fire-and-forget)
      -
      -```java
      -AgentHandle  start(Agent agent, String prompt)
      -CompletableFuture  startAsync(Agent agent, String prompt)
      -CompletableFuture  startAsync(Agent agent, String prompt, Plan plan)
      -```
      -
      -### Stream
      -
      -```java
      -AgentStream  stream(Agent agent, String prompt)
      -CompletableFuture  streamAsync(Agent agent, String prompt)
      -```
      -
      -### Deploy / serve
      -
      -```java
      -CompileResponse            plan(Agent agent)                    // compile only, no run
      -List       deploy(Agent... agents)
      -CompletableFuture>  deployAsync(Agent... agents)
      -void                       serve(Agent... agents)               // blocks indefinitely
      -```
      -
      -### Resume / schedule
      -
      -```java
      -AgentHandle                         resume(String executionId, Agent agent)
      -CompletableFuture      resumeAsync(String executionId, Agent agent)
      -SchedulerClient                     getSchedulerClient()
      -```
      -
      -### Lifecycle
      -
      -```java
      -void  shutdown()      // stop workers, release HTTP connections
      -void  close()         // alias for shutdown(); implements AutoCloseable
      -```
      -
      ----
      -
      -## AgentClient control plane
      -
      -Low-level access to `/api/agent/*`. Obtain it from
      -`new OrkesClients(conductorClient).getAgentClient()`. See the complete
      -[AgentClient reference](agent-client-api.md) for wire shapes and error mapping.
      -
      -```java
      -CompileResponse     compileAgent(AgentRequest request)
      -StartResponse       deployAgent(AgentRequest request)
      -StartResponse       startAgent(AgentRequest request)
      -AgentStatusResponse getAgentStatus(String executionId)
      -Map  getExecution(String executionId)
      -Map  listExecutions(Map params)
      -void                respond(String executionId, RespondBody body)
      -void                cancelAgent(String executionId, String reason)  // immediate DELETE
      -void                stopAgent(String executionId)                    // graceful POST
      -void                signalAgent(String executionId, String message)
      -SseClient           streamSse(String executionId, String lastEventId)
      -```
      -
      -`AgentRequest` supports three mutually exclusive definition forms:
      -
      -```java
      -AgentRequest.deployedAgent(String name, Integer version)
      -AgentRequest.nativeAgent(Object agentConfig)
      -AgentRequest.frameworkAgent(String framework, Object rawConfig)
      -
      -// Additional builder fields
      -.model(String model)
      -.skillRef(Map skillRef)
      -```
      -
      -Null fields are omitted. `AgentStatusResponse.getStartTime()` and `getEndTime()` return nullable
      -epoch-millisecond timestamps.
      -
      ----
      -
      -## Agent.Builder
      -
      -```java
      -Agent.builder()
      -    // Identity
      -    .name(String)                          // required
      -    .model(String)                         // required
      -    .instructions(String)
      -    .instructions(Supplier)        // dynamic — re-evaluated on each run submission
      -    .instructionsTemplate(PromptTemplate)
      -    .introduction(String)
      -    .metadata(Map)
      -
      -    // LLM
      -    .maxTurns(int)                         // default 25
      -    .maxTokens(int)
      -    .temperature(double)
      -    .thinkingBudgetTokens(int)             // Anthropic extended thinking
      -    .reasoningEffort(String)               // OpenAI reasoning models: "low"|"medium"|"high"
      -    .contextWindowBudget(int)              // token threshold for proactive condensation
      -    .timeoutSeconds(int)                   // default 0 (server applies its own default)
      -
      -    // Tools
      -    .tools(List)
      -    .tools(ToolDef...)
      -
      -    // Multi-agent
      -    .agents(List)
      -    .agents(Agent...)
      -    .strategy(Strategy)
      -    .router(Agent)
      -    .handoffs(List)
      -    .handoffs(Handoff...)
      -    .allowedTransitions(Map>)
      -
      -    // Termination
      -    .termination(TerminationCondition)
      -
      -    // Guardrails
      -    .guardrails(List)
      -    .guardrails(GuardrailDef...)
      -
      -    // Auth
      -    .credentials(List)
      -    .credentials(String...)
      -
      -    // Code execution
      -    .localCodeExecution(boolean)
      -    .allowedLanguages(List)
      -    .codeExecutionTimeout(int)             // seconds
      -
      -    // Callbacks (intercept the agent loop)
      -    .beforeModelCallback(Function, Map>)
      -    .afterModelCallback(Function, Map>)
      -    .beforeAgentCallback(Function, Map>)
      -    .afterAgentCallback(Function, Map>)
      -    .callbacks(List)
      -    .callbacks(CallbackHandler...)
      -
      -    // Stateful (session isolation)
      -    .sessionId(String)
      -    .stateful(boolean)
      -    .memory(ConversationMemory)            // multi-turn message history
      -
      -    // Privacy
      -    .maskedFields(String...)               // redact fields in history/UI
      -
      -    // Advanced
      -    .outputType(Class)                  // structured output
      -    .fallback(Agent)
      -    .fallbackMaxTurns(int)
      -    .planner(Agent)
      -    .plannerContext(List)
      -    .plannerContext(String...)
      -    .prefillTools(List)
      -    .synthesize(boolean)
      -    .enablePlanning(boolean)
      -    .baseUrl(String)
      -    .gate(TextGate)
      -    .includeContents(String)
      -    .cliConfig(CliConfig)
      -    .requiredTools(String...)
      -    .stopWhen(String)                      // task name to stop on
      -    .allowedCommands(List)
      -    .framework(String)                     // for bridge agents
      -    .frameworkConfig(Map)
      -
      -    .build()
      -```
      -
      ----
      -
      -## @AgentDef annotation
      -
      -Declarative alternative to the builder — annotate a method to define an agent
      -(see [Agents](concepts/agents.md#agentdef-annotation) for attribute details):
      -
      -```java
      -import org.conductoross.conductor.ai.annotations.AgentDef;
      -
      -public class Weather {
      -    @Tool(name = "get_weather", description = "Get weather for a city")
      -    public String getWeather(String city) { return "Sunny, 72F in " + city; }
      -
      -    @AgentDef(model = "openai/gpt-4o")     // @Tool methods attach automatically
      -    public String weatherbot() {
      -        // returned String = instructions; no-arg form is lazy — re-evaluated per run
      -        return "You are a weather assistant. Today is " + LocalDate.now() + ".";
      -    }
      -
      -    @AgentDef(model = "openai/gpt-4o")     // optional Agent.Builder param = full builder API
      -    public void researcher(Agent.Builder builder) {
      -        builder.termination(new MaxMessageTermination(10));
      -    }
      -
      -    @AgentDef                              // return Agent (or Agent.Builder) = full factory
      -    public Agent reviewer() {
      -        return Agent.builder().name("reviewer").model("openai/gpt-4o")
      -                .instructions("Review the draft.").build();
      -    }
      -
      -    @AgentDef(model = "openai/gpt-4o")     // return PromptTemplate = server-side template
      -    public PromptTemplate support() {
      -        return new PromptTemplate("customer-support", Map.of("tone", "friendly"));
      -    }
      -}
      -```
      -
      -```java
      -List agents = Agent.fromInstance(instance);          // resolve all @AgentDef methods
      -Agent agent       = Agent.fromInstance(instance, "name");   // resolve one by name
      -```
      -
      ----
      -
      -## AgentResult
      -
      -```java
      -Object                   getOutput()        // final LLM output (String or structured object)
      - T                    getOutput(Class type)   // deserialize structured output
      -AgentStatus              getStatus()        // COMPLETED | FAILED | TERMINATED | TIMED_OUT
      -String                   getExecutionId()
      -List> getToolCalls()
      -List         getEvents()
      -TokenUsage               getTokenUsage()
      -boolean                  isSuccess()
      -String                   getError()
      -```
      -
      ----
      -
      -## AgentHandle
      -
      -```java
      -String       getExecutionId()
      -AgentResult  waitForResult()                             // blocks; default 600s timeout
      -AgentResult  waitForResult(long timeoutMs, long pollMs)  // explicit timeout
      -boolean      waitUntilWaiting(long timeoutMs)            // wait for HITL pause
      -boolean      isWaiting()                                 // true if a HITL task is paused
      -void         approve()
      -void         approve(String comment)
      -void         reject(String reason)
      -void         respond(Map data)            // arbitrary HITL response (MANUAL strategy)
      -```
      -
      ----
      -
      -## AgentStream
      -
      -`AgentStream` implements `Iterable` and `AutoCloseable`.
      -
      -```java
      -try (AgentStream stream = runtime.stream(agent, prompt)) {
      -    for (AgentEvent event : stream) {
      -        EventType type = event.getType();    // MESSAGE | TOOL_CALL | TOOL_RESULT | …
      -        String content  = event.getContent();
      -        String toolName = event.getToolName();
      -        Map args   = event.getArgs();
      -        String executionId        = event.getExecutionId();
      -    }
      -}
      -
      -// Approve from a stream
      -stream.approve(event);
      -stream.reject(event, "reason");
      -```
      -
      ----
      -
      -## Tool builders
      -
      -```java
      -// @Tool-annotated POJO → list of worker tools
      -ToolRegistry.fromInstance(Object pojo)                 // returns List
      -
      -// Sub-agent as a tool
      -AgentTool.from(Agent agent)
      -AgentTool.from(Agent agent, String description)
      -
      -// HTTP
      -HttpTool.builder().name(String).description(String).url(String).method(String).build()
      -
      -// MCP
      -McpTool.builder().name(String).description(String).serverUrl(String).build()
      -
      -// Human
      -HumanTool.create(String name, String description)
      -HumanTool.create(String name, String description, Map inputSchema)
      -
      -// PDF
      -PdfTool.create()                                          // name "generate_pdf"
      -PdfTool.create(String name, String description)
      -PdfTool.create(String name, String description, Map inputSchema)
      -
      -// Wait for an external message before continuing
      -WaitForMessageTool.create(String name, String description)
      -WaitForMessageTool.create(String name, String description, int batchSize, boolean blocking)
      -
      -// Image / audio / video / PDF generation
      -MediaTools.imageTool(String name, String description, String provider, String model)
      -MediaTools.audioTool(String name, String description, String provider, String model)
      -MediaTools.videoTool(String name, String description, String provider, String model)
      -MediaTools.pdfTool()
      -// (each media factory also has a trailing Map inputSchema overload)
      -
      -// RAG — search and index against a vector DB
      -RagTools.searchTool(String name, String description, String vectorDb, String index,
      -                    String embedProvider, String embedModel, int maxResults)
      -RagTools.indexTool(String name, String description, String vectorDb, String index,
      -                   String embedProvider, String embedModel)
      -// (both have a String namespace overload before the last arg)
      -```
      -
      ----
      -
      -## Guardrails
      -
      -```java
      -RegexGuardrail.builder()
      -    .name(String).position(Position).onFail(OnFail).maxRetries(int)
      -    .patterns(String...).mode(String).message(String).build()      // → GuardrailDef
      -
      -LLMGuardrail.builder()
      -    .name(String).position(Position).onFail(OnFail).maxRetries(int)
      -    .model(String).policy(String).maxTokens(int).build()           // → GuardrailDef
      -
      -GuardrailDef.builder()
      -    .name(String).position(Position).onFail(OnFail).maxRetries(int)
      -    .func(Function).build()                // → GuardrailDef
      -
      -Guardrail.of(String name, Function func)   // → GuardrailDef.Builder
      -Guardrail.external(String name)                                    // → GuardrailDef.Builder
      -
      -// GuardrailResult (return value of a custom func)
      -GuardrailResult.pass()
      -GuardrailResult.fail(String message)
      -GuardrailResult.fix(String fixedOutput)
      -
      -// Enums
      -Position.INPUT | Position.OUTPUT
      -OnFail.RAISE | OnFail.RETRY | OnFail.FIX | OnFail.HUMAN
      -```
      -
      ----
      -
      -## Callbacks
      -
      -```java
      -// Composable handler — override only the hooks you need (all take/return Map)
      -class MyHandler extends CallbackHandler {
      -    Map onAgentStart(Map kwargs)
      -    Map onAgentEnd(Map kwargs)
      -    Map onModelStart(Map kwargs)
      -    Map onModelEnd(Map kwargs)
      -    Map onToolStart(Map kwargs)
      -    Map onToolEnd(Map kwargs)
      -}
      -Agent.builder().callbacks(new MyHandler())
      -```
      -
      ----
      -
      -## Termination conditions
      -
      -```java
      -MaxMessageTermination.of(int maxMessages)
      -StopMessageTermination.of(String stopMessage)
      -TextMentionTermination.of(String text)
      -TextMentionTermination.of(String text, boolean caseSensitive)
      -TokenUsageTermination.ofTotal(int maxTokens)
      -TokenUsageTermination.ofPrompt(int maxTokens)
      -TokenUsageTermination.ofCompletion(int maxTokens)
      -
      -// Compose
      -condition.and(TerminationCondition other)   // stop only when both say stop
      -condition.or(TerminationCondition other)    // stop when either says stop
      -```
      -
      ----
      -
      -## Handoffs
      -
      -```java
      -OnTextMention.of(String text, String targetAgent)
      -OnToolResult.of(String toolName, String targetAgent)
      -OnToolResult.of(String toolName, String targetAgent, String resultContains)
      -new OnCondition(String targetAgent, Function,Boolean> predicate)
      -```
      -
      ----
      -
      -## SchedulerClient
      -
      -```java
      -SchedulerClient schedules = runtime.getSchedulerClient();
      -
      -schedules.saveSchedule(SaveScheduleRequest request)
      -schedules.getSchedule(String name)                    // → WorkflowSchedule
      -schedules.getAllSchedules(String workflowName)        // → List
      -schedules.pauseSchedule(String name)
      -schedules.pauseSchedule(String name, String reason)
      -schedules.resumeSchedule(String name)
      -schedules.deleteSchedule(String name)
      -schedules.getNextFewSchedules(cron, null, null, 5)    // → List (epoch ms)
      -```
      -
      ----
      -
      -## Credentials
      -
      -Declare which secrets a tool needs, then read them off the injected `ToolContext`.
      -There is no static `Credentials` accessor — Java cannot mutate `System.getenv()` at
      -runtime, so values are passed per-call on the context.
      -
      -```java
      -@Tool(name = "fetch_issue", description = "...", credentials = {"GITHUB_TOKEN"})
      -public String fetchIssue(String repo, ToolContext ctx) {
      -    String token  = ctx.getCredential("GITHUB_TOKEN");        // throws if unresolved
      -    String maybe  = ctx.getCredentialOrNull("GITHUB_TOKEN");  // null if unresolved
      -    Map all = ctx.getCredentials();            // immutable snapshot
      -    // ...
      -}
      -
      -// Or declare at the agent level (applies to all of the agent's tools):
      -Agent.builder().credentials("GITHUB_TOKEN", "JIRA_API_KEY")...
      -
      -// Store the secret once via the CLI:
      -// conductor secret put GITHUB_TOKEN 
      -```
      -
      -`ToolContext` also exposes `getSessionId()`, `getExecutionId()`, `getTaskId()`, and a
      -mutable `getState()` map that persists across tool calls within one execution.
      -
      ----
      -
      -## Skill
      -
      -```java
      -Agent Skill.skill(Path path, String model)
      -Agent Skill.skill(Path path, String model, Map agentModels)
      -Map Skill.loadSkills(Path directory, String model)
      -```
      -
      ----
      -
      -## Framework bridges
      -
      -```java
      -// LangChain4j
      -Agent LangChain4jAgent.from(String name, String model, String instructions, Object... tools)
      -boolean LangChain4jAgent.isLangChain4jTools(Object obj)
      -
      -// OpenAI Agents SDK style
      -OpenAIAgent.builder()
      -    .name(String).model(String).instructions(String)
      -    .tools(Object...)        // @Tool-annotated POJOs
      -    .handoffs(Agent...)
      -    .outputType(String)
      -    .build()
      -
      -// Google ADK
      -Agent AdkBridge.toConductor(BaseAgent adkAgent)
      -Agent.Builder AdkBridge.agentBuilder(BaseAgent adkAgent)
      -
      -// LangChain4j ChatModel
      -Agent.Builder LangChainBridge.agentBuilder(String name, ChatModel model, String systemPrompt, Object... tools)
      -```
      -
      -`AgentRuntime` also accepts native framework objects **directly** (drop-in) on `run`/`start`/
      -`stream`/`deploy`/`serve`/`plan`/`resume`: a native ADK `BaseAgent`, a LangChain4j `ChatModel`,
      -or a LangGraph4j `AgentExecutor.Builder` (the latter two take trailing `@Tool` POJOs).
      -
      ----
      -
      -## AgentConfig
      -
      -```java
      -new AgentConfig()                                 // defaults: 100ms poll, 1 thread
      -new AgentConfig(int pollIntervalMs, int threads)
      -AgentConfig.fromEnv()                             // reads CONDUCTOR_AGENT_WORKER_* env vars
      -
      -config.getWorkerPollIntervalMs()
      -config.getWorkerThreadCount()
      -```
      diff --git a/docs/agents/concepts/agents.md b/docs/agents/concepts/agents.md
      index 7790eacad..e3b2a162e 100644
      --- a/docs/agents/concepts/agents.md
      +++ b/docs/agents/concepts/agents.md
      @@ -166,7 +166,7 @@ See [Guardrails](guardrails.md).
       
       ### Credentials
       
      -Declare which secrets the agent's tools require. The SDK receives them from the Conductor secrets store at runtime and injects them into tool context.
      +Declare which secrets the agent's tools require. A capable Conductor server resolves the declared names at poll time and delivers them to the tool's per-call context; plaintext values never enter workflow input or output.
       
       ```java
       Agent agent = Agent.builder()
      @@ -233,73 +233,15 @@ Agent agent = Agent.builder()
       
       ## Running an agent
       
      -### AgentRuntime
      -
      -`AgentRuntime` is the SDK entry point. Use try-with-resources — `close()` stops worker threads and releases HTTP connections.
      +Use `AgentRuntime` as the SDK entry point and close it when the application stops. For a simple request/response flow:
       
       ```java
       try (AgentRuntime runtime = new AgentRuntime()) {
           AgentResult result = runtime.run(agent, "Hello!");
      +    System.out.println(result.getOutput());
       }
       ```
       
      -| Method | Returns | Description |
      -|---|---|---|
      -| `run(agent, prompt)` | `AgentResult` | Blocking — waits for completion. |
      -| `run(agent, prompt, plan)` | `AgentResult` | Blocking with a deterministic plan. |
      -| `runAsync(agent, prompt)` | `CompletableFuture` | Non-blocking. |
      -| `start(agent, prompt)` | `AgentHandle` | Fire-and-forget; returns a handle to poll or approve. |
      -| `startAsync(agent, prompt)` | `CompletableFuture` | Non-blocking start. |
      -| `stream(agent, prompt)` | `AgentStream` | Blocking iterator over events. |
      -| `streamAsync(agent, prompt)` | `CompletableFuture` | Non-blocking stream. |
      -| `plan(agent)` | `CompileResponse` | Compile only — returns `getWorkflowDef()` + `getRequiredWorkers()` without executing. |
      -| `deploy(Agent...)` | `List` | Register workflow definitions without running them. |
      -| `serve(Agent...)` | `void` | Long-running worker mode — keeps polling indefinitely. |
      -| `resume(executionId, agent)` | `AgentHandle` | Resume a suspended execution. |
      -| `getSchedulerClient()` | `SchedulerClient` | Access typed workflow-scheduling APIs. |
      -
      -### AgentResult
      -
      -```java
      -AgentResult result = runtime.run(agent, "prompt");
      -
      -result.getOutput();          // Object — final LLM output (String or structured object)
      -result.getStatus();          // AgentStatus.COMPLETED / FAILED / TERMINATED / TIMED_OUT
      -result.getExecutionId();     // String — Conductor workflow ID
      -result.getToolCalls();       // List> — all tool invocations
      -result.getEvents();          // List — full event log
      -result.getTokenUsage();      // TokenUsage — prompt/completion/total tokens
      -result.isSuccess();          // true if status == COMPLETED
      -result.getError();           // String — error message if failed
      -```
      -
      -### AgentHandle
      -
      -Returned by `start()` — lets you poll, approve, or stream after the fact:
      -
      -```java
      -AgentHandle handle = runtime.start(agent, "prompt");
      -String executionId = handle.getExecutionId();
      -
      -// Poll until done (blocks the calling thread)
      -AgentResult result = handle.waitForResult();
      -
      -// Or approve a human-in-the-loop step
      -handle.approve("Looks good");
      -handle.reject("Not acceptable");
      -```
      -
      -### Concurrency
      -
      -`AgentRuntime` is thread-safe. Share one instance across threads rather than creating one per request.
      +`AgentResult` contains the output, terminal status, execution ID, tool calls, events, token usage, and any error. For background work use `start` and its `AgentHandle`; for incremental output use `stream`. `AgentRuntime` is thread-safe, so share it instead of creating one per request.
       
      -```java
      -// Good — one shared runtime
      -private static final AgentRuntime runtime = new AgentRuntime();
      -
      -// Run multiple agents concurrently
      -List> futures = prompts.stream()
      -    .map(p -> runtime.runAsync(agent, p))
      -    .toList();
      -CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
      -```
      +See [AgentRuntime](../reference/runtime.md) for every overload, deployment and serve modes, scheduling, and lifecycle details. See [Deploy · Serve · Run · Plan](deploy-serve-run.md) to choose an execution mode.
      diff --git a/docs/agents/concepts/deploy-serve-run.md b/docs/agents/concepts/deploy-serve-run.md
      index e66b85c5c..a4d6212b9 100644
      --- a/docs/agents/concepts/deploy-serve-run.md
      +++ b/docs/agents/concepts/deploy-serve-run.md
      @@ -59,7 +59,7 @@ StartResponse started = agents.startAgent(
       System.out.println(started.getExecutionId());
       ```
       
      -See [AgentClient](../agent-client-api.md#startagent) for deployed, native-inline, and framework
      +See [AgentClient](../reference/client.md#startagent) for deployed, native-inline, and framework
       request forms.
       
       ## run — register, start, and wait
      @@ -86,4 +86,4 @@ AgentResult result = handle.waitForResult();
       ```
       
       Every operation has an `…Async` variant returning a `CompletableFuture` (`runAsync`, `startAsync`,
      -`streamAsync`, `deployAsync`, `resumeAsync`). See the [AgentRuntime API reference](../agent-runtime-api.md).
      +`streamAsync`, `deployAsync`, `resumeAsync`). See the [AgentRuntime reference](../reference/runtime.md).
      diff --git a/docs/agents/concepts/scheduling.md b/docs/agents/concepts/scheduling.md
      index 4b6169719..26333ad71 100644
      --- a/docs/agents/concepts/scheduling.md
      +++ b/docs/agents/concepts/scheduling.md
      @@ -24,7 +24,7 @@ try (AgentRuntime runtime = new AgentRuntime()) {
           SchedulerClient schedules = runtime.getSchedulerClient();
           schedules.saveSchedule(new SaveScheduleRequest()
               .name("daily_report-daily")
      -        .cronExpression("0 9 * * *") // 9 AM every day
      +        .cronExpression("0 0 9 * * ?") // 9 AM every day
               .zoneId("America/New_York")
               .startWorkflowRequest(workflow));
       }
      @@ -41,7 +41,7 @@ workflow.setInput(Map.of("report_type", "weekly", "include_charts", true));
       
       SaveScheduleRequest weeklyDigest = new SaveScheduleRequest()
           .name("daily_report-weekly")
      -    .cronExpression("0 8 * * MON")
      +    .cronExpression("0 0 8 ? * MON")
           .zoneId("UTC")
           .description("Monday morning executive digest")
           .startWorkflowRequest(workflow);
      @@ -63,7 +63,7 @@ schedules.resumeSchedule("daily_report-daily");
       schedules.deleteSchedule("daily_report-daily");
       
       // Preview the next N fire times for a cron expression
      -List next = schedules.getNextFewSchedules("0 9 * * *", null, null, 5); // epoch millis
      +List next = schedules.getNextFewSchedules("0 0 9 * * ?", null, null, 5); // epoch millis
       ```
       
       ## Native schedule fields
      @@ -76,11 +76,11 @@ List next = schedules.getNextFewSchedules("0 9 * * *", null, null, 5); //
       
       ## Cron syntax
       
      -Standard 5-field: `minute hour day-of-month month day-of-week`
      +Quartz cron: `second minute hour day-of-month month day-of-week [year]`
       
       ```
      -0 9 * * *         every day at 9:00 AM
      -0 */6 * * *       every 6 hours
      -0 8 * * MON-FRI   weekdays at 8 AM
      -30 17 1 * *       1st of every month at 5:30 PM
      +0 0 9 * * ?         every day at 9:00 AM
      +0 0 */6 * * ?       every 6 hours
      +0 0 8 ? * MON-FRI   weekdays at 8 AM
      +0 30 17 1 * ?       1st of every month at 5:30 PM
       ```
      diff --git a/docs/agents/concepts/skills.md b/docs/agents/concepts/skills.md
      deleted file mode 100644
      index 9d88f8c7c..000000000
      --- a/docs/agents/concepts/skills.md
      +++ /dev/null
      @@ -1,97 +0,0 @@
      -# Skills
      -
      -A Skill is a portable, self-contained agent capability stored as a directory. You write a `SKILL.md` file describing the agent's purpose and workflow; the SDK loads it as a fully-configured `Agent` ready to run.
      -
      -## Skill directory layout
      -
      -```
      -my-skill/
      -├── SKILL.md              # Required — name, description, workflow instructions
      -├── search-agent.md       # Optional — sub-agent definitions
      -├── writer-agent.md
      -└── scripts/              # Optional — scripts the agent can execute
      -    └── process.py
      -```
      -
      -## SKILL.md format
      -
      -```markdown
      ----
      -name: code_review
      -params:
      -  language:
      -    default: java
      ----
      -
      -## Overview
      -Reviews code for bugs, style issues, and security vulnerabilities.
      -
      -## Workflow
      -1. Read the code from the user's message.
      -2. Call the analyse_code tool with the code and language.
      -3. Return a structured review with severity levels.
      -```
      -
      -## Loading a skill
      -
      -```java
      -import org.conductoross.conductor.ai.skill.Skill;
      -import java.nio.file.Paths;
      -
      -// Load a single skill
      -Agent reviewAgent = Skill.skill(Paths.get("skills/code-review"), "openai/gpt-4o");
      -
      -// Override sub-agent models
      -Agent reviewAgent = Skill.skill(
      -    Paths.get("skills/code-review"),
      -    "openai/gpt-4o",
      -    Map.of("search-agent", "anthropic/claude-sonnet-4-6")  // cheaper model for search
      -);
      -
      -// Load all skills from a directory
      -Map allSkills = Skill.loadSkills(Paths.get("skills"), "openai/gpt-4o");
      -
      -// Run a loaded skill
      -try (AgentRuntime runtime = new AgentRuntime()) {
      -    AgentResult result = runtime.run(reviewAgent, "Review this Java code: ...");
      -}
      -```
      -
      -## Sub-agent files (`*-agent.md`)
      -
      -Each `*-agent.md` file defines a sub-agent within the skill:
      -
      -```markdown
      ----
      -name: analyse_code
      -description: Analyse code for issues
      ----
      -
      -You are a code analysis specialist. When given code:
      -1. Check for common bugs and anti-patterns.
      -2. Identify security vulnerabilities.
      -3. Return a JSON list of findings with severity (low/medium/high).
      -```
      -
      -## Error handling
      -
      -```java
      -import org.conductoross.conductor.ai.skill.SkillLoadError;
      -
      -try {
      -    Agent skill = Skill.skill(Paths.get("skills/missing"), "openai/gpt-4o");
      -} catch (SkillLoadError e) {
      -    System.err.println("Failed to load skill: " + e.getMessage());
      -}
      -```
      -
      -## Publishing and sharing skills
      -
      -Skills are plain files — commit them to your repo, share them via Git, or distribute as JARs. The `Skill.skill()` loader accepts any `Path`, including paths inside JARs via `FileSystem`:
      -
      -```java
      -// Load a skill bundled inside a JAR on the classpath
      -try (var fs = FileSystems.newFileSystem(uri, Map.of())) {
      -    Agent bundledSkill = Skill.skill(fs.getPath("/skills/my-skill"), "openai/gpt-4o");
      -}
      -```
      diff --git a/docs/agents/concepts/termination.md b/docs/agents/concepts/termination.md
      index 82745106c..4cdff00ea 100644
      --- a/docs/agents/concepts/termination.md
      +++ b/docs/agents/concepts/termination.md
      @@ -94,4 +94,4 @@ agentClient.cancelAgent(executionId, "Superseded by a newer run");
       
       `stopAgent` sends `POST /agent/{id}/stop`. `cancelAgent` sends
       `DELETE /agent/{id}/cancel` and omits the `reason` query parameter when it is null or blank.
      -See the [AgentClient reference](../agent-client-api.md#cancelagent).
      +See the [AgentClient reference](../reference/client.md#cancelagent).
      diff --git a/docs/agents/concepts/tools.md b/docs/agents/concepts/tools.md
      index 4076f33ba..5fac0a457 100644
      --- a/docs/agents/concepts/tools.md
      +++ b/docs/agents/concepts/tools.md
      @@ -48,14 +48,12 @@ public String createIssue(
       
       ### ToolContext
       
      -Inject `ToolContext` as the last parameter to access execution metadata, shared state, and credentials:
      +Inject `ToolContext` to access shared state and declared credentials:
       
       ```java
       @Tool(name = "send_email", description = "Send an email", credentials = {"SENDGRID_API_KEY"})
       public String sendEmail(String to, String subject, String body, ToolContext ctx) {
      -    String apiKey       = ctx.getCredential("SENDGRID_API_KEY");
      -    String executionId  = ctx.getExecutionId();
      -    String sessionId    = ctx.getSessionId();
      +    String apiKey = ctx.getCredential("SENDGRID_API_KEY");
           // ...
       }
       ```
      @@ -63,6 +61,8 @@ public String sendEmail(String to, String subject, String body, ToolContext ctx)
       `ToolContext.getState()` is a mutable `Map` that persists across tool calls
       within the same execution — use it to pass data between tools without routing it through the LLM.
       
      +Do not rely on `getExecutionId()`, `getSessionId()`, or `getTaskId()` for injected method tools yet: those identity values are not populated by the current runtime. If a tool needs an identifier, declare it as an explicit tool argument.
      +
       ### Credentials in tools
       
       Declare which secrets a tool needs and read them off the `ToolContext`. There is **no** static
      @@ -89,13 +89,15 @@ Agent agent = Agent.builder()
           .tools(ToolRegistry.fromInstance(new GitHubTools()))
           .build();
       
      -// Store the secret once via the CLI or API:
      -// conductor secret put GITHUB_TOKEN 
      +// Configure GITHUB_TOKEN in the Conductor server's secret store.
      +// The setup mechanism depends on the server distribution and its secret provider.
       ```
       
      -The worker fetches each declared secret from the server (via the execution token) before the
      -handler runs; if a declared secret is missing on the server, the task fails terminally before
      -your code executes.
      +At worker registration, the SDK declares the required secret names on task-definition
      +`runtimeMetadata`. A capable Conductor server resolves those names at poll time and sends values
      +only on the wire-only task `runtimeMetadata` map. There is no per-call secret-fetch request or
      +execution token. If a declared secret is missing or the server does not support this capability,
      +the task fails terminally before your code executes. See the [credential delivery contract](../../../design/secret-injection-contract.md).
       
       ---
       
      @@ -156,8 +158,7 @@ Agent agent = Agent.builder()
           .build();
       ```
       
      -!!! warning "Security"
      -    Use `allowedCommands` to restrict which commands the agent can execute. Without a whitelist, the agent can run any command the JVM user has permission to execute.
      +> **Security:** Use `allowedCommands` to restrict which commands the agent can execute. Without a whitelist, the agent can run any command the JVM user has permission to execute.
       
       ---
       
      diff --git a/docs/agents/frameworks/google-adk.md b/docs/agents/frameworks/google-adk.md
      index b2a964d28..7cbc95768 100644
      --- a/docs/agents/frameworks/google-adk.md
      +++ b/docs/agents/frameworks/google-adk.md
      @@ -5,19 +5,23 @@ Use Google's Agent Development Kit (ADK) agents directly with Conductor. The `Ad
       ## Dependency
       
       ```groovy
      -implementation 'org.conductoross:conductor-client-ai:5.1.0'
      -compileOnly 'com.google.adk:google-adk:1.3.0'
      +implementation 'org.conductoross:conductor-client-ai:'
      +implementation 'com.google.adk:google-adk:1.3.0'
       ```
       
      +`1.3.0` is the version exercised by this repository's agent examples. Replace `` with a published SDK version from [Maven Central](https://search.maven.org/search?q=g:org.conductoross).
      +
       ## Usage
       
       ```java
       import com.google.adk.agents.LlmAgent;
       import com.google.adk.tools.Annotations;
       import com.google.adk.tools.FunctionTool;
      +import java.util.Map;
       import org.conductoross.conductor.ai.Agent;
       import org.conductoross.conductor.ai.AgentRuntime;
       import org.conductoross.conductor.ai.frameworks.AdkBridge;
      +import org.conductoross.conductor.ai.model.AgentResult;
       
       // A FunctionTool target — ADK reflects the method and its @Schema params
       public static class WeatherService {
      @@ -78,5 +82,4 @@ Agent agent = AdkBridge.agentBuilder(adkAgent)
       | Sub-agents (`.subAgents()`) | `Agent.agents` |
       | `LoopAgent` / `SequentialAgent` | `Strategy.SEQUENTIAL` |
       
      -!!! note "Model requirement"
      -    Google ADK agents require a Gemini model (e.g. `gemini-2.0-flash`). Make sure your Conductor server has a Google AI or Vertex AI provider configured with the appropriate API key.
      +> **Model requirement:** Google ADK agents require a Gemini model (for example, `gemini-2.0-flash`). Configure the Google AI or Vertex AI credential on the Conductor server.
      diff --git a/docs/agents/frameworks/langchain4j.md b/docs/agents/frameworks/langchain4j.md
      index 8662fe758..0721f953d 100644
      --- a/docs/agents/frameworks/langchain4j.md
      +++ b/docs/agents/frameworks/langchain4j.md
      @@ -5,10 +5,12 @@ Use LangChain4j `@Tool`-annotated POJOs directly with Conductor. The bridge refl
       ## Dependency
       
       ```groovy
      -implementation 'org.conductoross:conductor-client-ai:5.1.0'
      -compileOnly 'dev.langchain4j:langchain4j:1.0.0'
      +implementation 'org.conductoross:conductor-client-ai:'
      +implementation 'dev.langchain4j:langchain4j:1.0.0'
       ```
       
      +`1.0.0` is the version exercised by this repository's agent examples. Replace `` with a published SDK version from [Maven Central](https://search.maven.org/search?q=g:org.conductoross).
      +
       ## Usage
       
       ```java
      @@ -17,6 +19,7 @@ import dev.langchain4j.agent.tool.P;
       import org.conductoross.conductor.ai.Agent;
       import org.conductoross.conductor.ai.AgentRuntime;
       import org.conductoross.conductor.ai.frameworks.LangChain4jAgent;
      +import org.conductoross.conductor.ai.model.AgentResult;
       
       // Your existing LangChain4j tool POJO — no changes needed
       public class CalculatorTools {
      @@ -68,12 +71,16 @@ boolean isTools = LangChain4jAgent.isLangChain4jTools(new Object());           /
       
       For `ChatModel`-based agents (not `@Tool` POJOs):
       
      +**Fragment — `SearchTools` is an application class.** The local `ChatModel` supplies provider and model metadata; Conductor performs the LLM call with the credential configured on the server.
      +
       ```java
       import dev.langchain4j.model.chat.ChatModel;
      +import dev.langchain4j.model.openai.OpenAiChatModel;
       import org.conductoross.conductor.ai.frameworks.LangChainBridge;
      +import org.conductoross.conductor.ai.internal.ToolRegistry;
       
       ChatModel model = OpenAiChatModel.builder()
      -    .apiKey(System.getenv("OPENAI_API_KEY"))
      +    .apiKey("server-configured-credential")
           .modelName("gpt-4o-mini")
           .build();
       
      diff --git a/docs/agents/frameworks/langgraph4j.md b/docs/agents/frameworks/langgraph4j.md
      index cd85e04c3..abdaad5c5 100644
      --- a/docs/agents/frameworks/langgraph4j.md
      +++ b/docs/agents/frameworks/langgraph4j.md
      @@ -7,13 +7,15 @@ configured `ChatModel` (and system message, if any), then runs the agent server-
       ## Dependency
       
       ```groovy
      -implementation 'org.conductoross:conductor-client-ai:5.1.0'
      -compileOnly 'dev.langchain4j:langchain4j:1.0.0'
      -compileOnly 'dev.langchain4j:langchain4j-open-ai:1.0.0'
      -compileOnly 'org.bsc.langgraph4j:langgraph4j-core:1.6.0-beta5'
      -compileOnly 'org.bsc.langgraph4j:langgraph4j-agent-executor:1.6.0-beta5'
      +implementation 'org.conductoross:conductor-client-ai:'
      +implementation 'dev.langchain4j:langchain4j:1.0.0'
      +implementation 'dev.langchain4j:langchain4j-open-ai:1.0.0'
      +implementation 'org.bsc.langgraph4j:langgraph4j-core:1.6.0-beta5'
      +implementation 'org.bsc.langgraph4j:langgraph4j-agent-executor:1.6.0-beta5'
       ```
       
      +These framework versions are exercised by this repository's agent examples. Replace `` with a published SDK version from [Maven Central](https://search.maven.org/search?q=g:org.conductoross).
      +
       ## Usage (drop-in)
       
       The runtime accepts the native `AgentExecutor.Builder` directly — no Conductor-specific types required.
      diff --git a/docs/agents/frameworks/openai.md b/docs/agents/frameworks/openai.md
      index ec96b5bbc..e923a4a10 100644
      --- a/docs/agents/frameworks/openai.md
      +++ b/docs/agents/frameworks/openai.md
      @@ -5,15 +5,17 @@ Use the Conductor Java Agent SDK with OpenAI Agents SDK-style tool definitions.
       ## Dependency
       
       ```groovy
      -implementation 'org.conductoross:conductor-client-ai:5.1.0'
      +implementation 'org.conductoross:conductor-client-ai:'
       ```
       
       The bridge uses the LangChain4j `@Tool` annotation as a practical equivalent of the Python OpenAI Agents SDK `@function_tool` decorator — add it if you need the annotation:
       
       ```groovy
      -compileOnly 'dev.langchain4j:langchain4j:1.0.0'
      +implementation 'dev.langchain4j:langchain4j:1.0.0'
       ```
       
      +`1.0.0` is the version exercised by this repository's agent examples. Replace `` with a published SDK version from [Maven Central](https://search.maven.org/search?q=g:org.conductoross).
      +
       ## Usage
       
       ```java
      @@ -22,6 +24,7 @@ import dev.langchain4j.agent.tool.P;
       import org.conductoross.conductor.ai.Agent;
       import org.conductoross.conductor.ai.AgentRuntime;
       import org.conductoross.conductor.ai.frameworks.OpenAIAgent;
      +import org.conductoross.conductor.ai.model.AgentResult;
       
       public class ShoppingTools {
       
      @@ -38,7 +41,7 @@ public class ShoppingTools {
       
       Agent agent = OpenAIAgent.builder()
           .name("shopping_assistant")
      -    .model("anthropic/claude-sonnet-4-6")
      +    .model("openai/gpt-4o-mini")
           .instructions("Help users find and purchase products.")
           .tools(new ShoppingTools())
           .build();
      @@ -56,13 +59,13 @@ OpenAI Agents SDK-style handoffs let the LLM transfer control to a specialist ag
       ```java
       Agent billingAgent = Agent.builder()
           .name("billing_agent")
      -    .model("anthropic/claude-sonnet-4-6")
      +    .model("openai/gpt-4o-mini")
           .instructions("Handle billing and payment questions.")
           .build();
       
       Agent supportAgent = OpenAIAgent.builder()
           .name("support_agent")
      -    .model("anthropic/claude-sonnet-4-6")
      +    .model("openai/gpt-4o-mini")
           .instructions("Handle general support. Transfer billing issues to the billing agent.")
           .handoffs(billingAgent)                      // adds billing_agent as a handoff target
           .build();
      @@ -73,7 +76,7 @@ Agent supportAgent = OpenAIAgent.builder()
       ```java
       Agent agent = OpenAIAgent.builder()
           .name("classifier")
      -    .model("anthropic/claude-sonnet-4-6")
      +    .model("openai/gpt-4o-mini")
           .instructions("Classify the sentiment of the input.")
           .outputType("SentimentResult")               // server-side structured output type name
           .build();
      diff --git a/docs/agents/generated/AgentConfigModel.java b/docs/agents/generated/AgentConfigModel.java
      deleted file mode 100644
      index 68f2de628..000000000
      --- a/docs/agents/generated/AgentConfigModel.java
      +++ /dev/null
      @@ -1,25 +0,0 @@
      -// AUTO-GENERATED from agent-schema.json by generate.py — do not edit.
      -import java.util.List;
      -import java.util.Map;
      -
      -public final class AgentConfigModel {
      -  private AgentConfigModel() {}
      -
      -  public record AgentConfig(String name, String description, String model, Boolean external, String baseUrl, Object instructions, String introduction, List tools, List agents, String strategy, Object router, List guardrails, Integer maxTurns, Integer maxTokens, Double temperature, Integer timeoutSeconds, String reasoningEffort, Integer contextWindowBudget, ThinkingConfig thinkingConfig, Memory memory, Termination termination, OutputType outputType, List handoffs, Map allowedTransitions, List callbacks, Gate gate, WorkerRef stopWhen, Boolean enablePlanning, AgentConfig planner, AgentConfig fallback, Integer fallbackMaxTurns, List plannerContext, Map planSource, Boolean synthesize, Boolean stateful, String sessionId, String includeContents, List requiredTools, List prefillTools, List credentials, Map metadata, Boolean localCodeExecution, CodeExecution codeExecution, CliConfig cliConfig, List maskedFields) {}
      -  public record PromptTemplate(String type, String name, Map variables, Integer version) {}
      -  public record Tool(String name, String description, Map inputSchema, Map outputSchema, String toolType, Boolean approvalRequired, Boolean stateful, Integer timeoutSeconds, Integer maxCalls, Map config, List guardrails) {}
      -  public record Guardrail(String name, String guardrailType, String position, String onFail, Integer maxRetries, String taskName, List patterns, String mode, String message, String model, String policy, Integer maxTokens) {}
      -  public record Termination(String type, String text, Boolean caseSensitive, String stopMessage, Integer maxMessages, Integer maxTotalTokens, Integer maxPromptTokens, Integer maxCompletionTokens, List conditions) {}
      -  public record Handoff(String type, String target, String toolName, String resultContains, String text, String taskName) {}
      -  public record Callback(String position, String taskName) {}
      -  public record Memory(List messages, Integer maxMessages) {}
      -  public record Message(String role, String message) {}
      -  public record CodeExecution(Boolean enabled, List allowedLanguages, List allowedCommands, Integer timeout) {}
      -  public record CliConfig(Boolean enabled, List allowedCommands, Integer timeout, Boolean allowShell, String workingDir) {}
      -  public record ThinkingConfig(Boolean enabled, Integer budgetTokens) {}
      -  public record PrefillTool(String toolName, Map arguments) {}
      -  public record PlannerContextEntry(String text, String url, Map headers, Boolean required, Integer maxBytes) {}
      -  public record OutputType(Map schema, String className) {}
      -  public record Gate(String type, String text, Boolean caseSensitive, String taskName) {}
      -  public record WorkerRef(String taskName) {}
      -}
      diff --git a/docs/agents/generated/agent_config.py b/docs/agents/generated/agent_config.py
      deleted file mode 100644
      index ad63cf98d..000000000
      --- a/docs/agents/generated/agent_config.py
      +++ /dev/null
      @@ -1,190 +0,0 @@
      -"""AUTO-GENERATED from agent-schema.json by generate.py — do not edit."""
      -from __future__ import annotations
      -from dataclasses import dataclass
      -from typing import Any, Dict, List, Optional
      -
      -
      -@dataclass
      -class AgentConfig:
      -    name: Optional[str] = None
      -    description: Optional[str] = None
      -    model: Optional[str] = None
      -    external: Optional[bool] = None
      -    baseUrl: Optional[str] = None
      -    instructions: Optional[Any] = None
      -    introduction: Optional[str] = None
      -    tools: Optional[List[Tool]] = None
      -    agents: Optional[List[AgentConfig]] = None
      -    strategy: Optional[str] = None
      -    router: Optional[Any] = None
      -    guardrails: Optional[List[Guardrail]] = None
      -    maxTurns: Optional[int] = None
      -    maxTokens: Optional[int] = None
      -    temperature: Optional[float] = None
      -    timeoutSeconds: Optional[int] = None
      -    reasoningEffort: Optional[str] = None
      -    contextWindowBudget: Optional[int] = None
      -    thinkingConfig: Optional[ThinkingConfig] = None
      -    memory: Optional[Memory] = None
      -    termination: Optional[Termination] = None
      -    outputType: Optional[OutputType] = None
      -    handoffs: Optional[List[Handoff]] = None
      -    allowedTransitions: Optional[Dict[str, Any]] = None
      -    callbacks: Optional[List[Callback]] = None
      -    gate: Optional[Gate] = None
      -    stopWhen: Optional[WorkerRef] = None
      -    enablePlanning: Optional[bool] = None
      -    planner: Optional[AgentConfig] = None
      -    fallback: Optional[AgentConfig] = None
      -    fallbackMaxTurns: Optional[int] = None
      -    plannerContext: Optional[List[PlannerContextEntry]] = None
      -    planSource: Optional[Dict[str, Any]] = None
      -    synthesize: Optional[bool] = None
      -    stateful: Optional[bool] = None
      -    sessionId: Optional[str] = None
      -    includeContents: Optional[str] = None
      -    requiredTools: Optional[List[str]] = None
      -    prefillTools: Optional[List[PrefillTool]] = None
      -    credentials: Optional[List[str]] = None
      -    metadata: Optional[Dict[str, Any]] = None
      -    localCodeExecution: Optional[bool] = None
      -    codeExecution: Optional[CodeExecution] = None
      -    cliConfig: Optional[CliConfig] = None
      -    maskedFields: Optional[List[str]] = None
      -
      -
      -@dataclass
      -class PromptTemplate:
      -    type: Optional[str] = None
      -    name: Optional[str] = None
      -    variables: Optional[Dict[str, Any]] = None
      -    version: Optional[int] = None
      -
      -
      -@dataclass
      -class Tool:
      -    name: Optional[str] = None
      -    description: Optional[str] = None
      -    inputSchema: Optional[Dict[str, Any]] = None
      -    outputSchema: Optional[Dict[str, Any]] = None
      -    toolType: Optional[str] = None
      -    approvalRequired: Optional[bool] = None
      -    stateful: Optional[bool] = None
      -    timeoutSeconds: Optional[int] = None
      -    maxCalls: Optional[int] = None
      -    config: Optional[Dict[str, Any]] = None
      -    guardrails: Optional[List[Guardrail]] = None
      -
      -
      -@dataclass
      -class Guardrail:
      -    name: Optional[str] = None
      -    guardrailType: Optional[str] = None
      -    position: Optional[str] = None
      -    onFail: Optional[str] = None
      -    maxRetries: Optional[int] = None
      -    taskName: Optional[str] = None
      -    patterns: Optional[List[str]] = None
      -    mode: Optional[str] = None
      -    message: Optional[str] = None
      -    model: Optional[str] = None
      -    policy: Optional[str] = None
      -    maxTokens: Optional[int] = None
      -
      -
      -@dataclass
      -class Termination:
      -    type: Optional[str] = None
      -    text: Optional[str] = None
      -    caseSensitive: Optional[bool] = None
      -    stopMessage: Optional[str] = None
      -    maxMessages: Optional[int] = None
      -    maxTotalTokens: Optional[int] = None
      -    maxPromptTokens: Optional[int] = None
      -    maxCompletionTokens: Optional[int] = None
      -    conditions: Optional[List[Termination]] = None
      -
      -
      -@dataclass
      -class Handoff:
      -    type: Optional[str] = None
      -    target: Optional[str] = None
      -    toolName: Optional[str] = None
      -    resultContains: Optional[str] = None
      -    text: Optional[str] = None
      -    taskName: Optional[str] = None
      -
      -
      -@dataclass
      -class Callback:
      -    position: Optional[str] = None
      -    taskName: Optional[str] = None
      -
      -
      -@dataclass
      -class Memory:
      -    messages: Optional[List[Message]] = None
      -    maxMessages: Optional[int] = None
      -
      -
      -@dataclass
      -class Message:
      -    role: Optional[str] = None
      -    message: Optional[str] = None
      -
      -
      -@dataclass
      -class CodeExecution:
      -    enabled: Optional[bool] = None
      -    allowedLanguages: Optional[List[str]] = None
      -    allowedCommands: Optional[List[str]] = None
      -    timeout: Optional[int] = None
      -
      -
      -@dataclass
      -class CliConfig:
      -    enabled: Optional[bool] = None
      -    allowedCommands: Optional[List[str]] = None
      -    timeout: Optional[int] = None
      -    allowShell: Optional[bool] = None
      -    workingDir: Optional[str] = None
      -
      -
      -@dataclass
      -class ThinkingConfig:
      -    enabled: Optional[bool] = None
      -    budgetTokens: Optional[int] = None
      -
      -
      -@dataclass
      -class PrefillTool:
      -    toolName: Optional[str] = None
      -    arguments: Optional[Dict[str, Any]] = None
      -
      -
      -@dataclass
      -class PlannerContextEntry:
      -    text: Optional[str] = None
      -    url: Optional[str] = None
      -    headers: Optional[Dict[str, Any]] = None
      -    required: Optional[bool] = None
      -    maxBytes: Optional[int] = None
      -
      -
      -@dataclass
      -class OutputType:
      -    schema: Optional[Dict[str, Any]] = None
      -    className: Optional[str] = None
      -
      -
      -@dataclass
      -class Gate:
      -    type: Optional[str] = None
      -    text: Optional[str] = None
      -    caseSensitive: Optional[bool] = None
      -    taskName: Optional[str] = None
      -
      -
      -@dataclass
      -class WorkerRef:
      -    taskName: Optional[str] = None
      diff --git a/docs/agents/generated/generate.py b/docs/agents/generated/generate.py
      deleted file mode 100644
      index 5ef2f13c4..000000000
      --- a/docs/agents/generated/generate.py
      +++ /dev/null
      @@ -1,130 +0,0 @@
      -#!/usr/bin/env python3
      -"""Reverse-engineer agent-schema.json into a Python dataclass and a Java record,
      -then prove the schema is correct by diffing the generated models against the
      -server's AgentConfig models and validating a generated instance.
      -
      -Run from the repo root:  python3 sdk/java/docs/generated/generate.py
      -Outputs (regenerated each run):
      -  sdk/java/docs/generated/agent_config.py        — Python dataclasses
      -  sdk/java/docs/generated/AgentConfigModel.java  — Java records
      -
      -Exit code 0 iff: (a) generated models are field-for-field identical to the
      -schema, (b) a generated instance validates against the schema, and (c) every
      -server AgentConfig field (root + nested models) is present in the schema.
      -"""
      -import json, os, re, sys, importlib.util, dataclasses
      -
      -HERE = os.path.dirname(os.path.abspath(__file__))
      -ROOT = os.path.abspath(os.path.join(HERE, "..", "..", "..", ".."))
      -SCHEMA = os.path.join(ROOT, "sdk/java/docs/agent-schema.json")
      -MODEL_DIR = os.environ.get("CONDUCTOR_AGENT_MODEL_DIR", "")
      -
      -schema = json.load(open(SCHEMA))
      -defs = schema["$defs"]
      -ROOT_CLS = "AgentConfig"
      -cap = lambda d: d[0].upper() + d[1:]
      -ref_cls = lambda r: ROOT_CLS if r == "#" else cap(r.split("/")[-1])
      -
      -PY = {"string": "str", "integer": "int", "number": "float", "boolean": "bool", "object": "Dict[str, Any]"}
      -JV = {"string": "String", "integer": "Integer", "number": "Double", "boolean": "Boolean", "object": "Map"}
      -
      -def base_type(s, scalar, ref, arr):
      -    if not isinstance(s, dict): return scalar(None)
      -    if "$ref" in s: return ref(s["$ref"])
      -    if "oneOf" in s: return scalar("__any__")
      -    t = s.get("type")
      -    if isinstance(t, list):
      -        nn = [x for x in t if x != "null"]
      -        return base_type({"type": nn[0]} if nn else {}, scalar, ref, arr) if nn else scalar("__any__")
      -    if t == "array": return arr(s.get("items", {}))
      -    return scalar(t)
      -
      -py_type = lambda s: base_type(s, lambda t: ("Any" if t in (None, "__any__") else PY.get(t, "Any")),
      -                              ref_cls, lambda it: f"List[{py_type(it)}]")
      -jv_type = lambda s: base_type(s, lambda t: ("Object" if t in (None, "__any__") else JV.get(t, "Object")),
      -                              ref_cls, lambda it: f"List<{jv_type(it)}>")
      -
      -order = [(ROOT_CLS, schema)] + [(cap(n), d) for n, d in defs.items()]
      -
      -def gen_py(name, node):
      -    p = node.get("properties") or {}
      -    if not p: return f"@dataclass\nclass {name}:\n    pass"
      -    body = "\n".join(f"    {k}: Optional[{py_type(v)}] = None" for k, v in p.items())
      -    return f"@dataclass\nclass {name}:\n{body}"
      -
      -def gen_java(name, node):
      -    p = node.get("properties") or {}
      -    if not p: return f"  public record {name}() {{}}"
      -    comps = ", ".join(f"{jv_type(v)} {k}" for k, v in p.items())
      -    return f"  public record {name}({comps}) {{}}"
      -
      -with open(os.path.join(HERE, "agent_config.py"), "w") as f:
      -    f.write('"""AUTO-GENERATED from agent-schema.json by generate.py — do not edit."""\n')
      -    f.write("from __future__ import annotations\nfrom dataclasses import dataclass\n")
      -    f.write("from typing import Any, Dict, List, Optional\n\n\n")
      -    f.write("\n\n\n".join(gen_py(n, d) for n, d in order) + "\n")
      -
      -with open(os.path.join(HERE, "AgentConfigModel.java"), "w") as f:
      -    f.write("// AUTO-GENERATED from agent-schema.json by generate.py — do not edit.\n")
      -    f.write("import java.util.List;\nimport java.util.Map;\n\npublic final class AgentConfigModel {\n")
      -    f.write("  private AgentConfigModel() {}\n\n")
      -    f.write("\n".join(gen_java(n, d) for n, d in order) + "\n}\n")
      -
      -# ---- proof ----
      -spec = importlib.util.spec_from_file_location("gen_ac", os.path.join(HERE, "agent_config.py"))
      -m = importlib.util.module_from_spec(spec); sys.modules["gen_ac"] = m; spec.loader.exec_module(m)
      -
      -ok = True
      -
      -# (a) generated models are field-for-field identical to the schema
      -for clsname, node in [(ROOT_CLS, schema)] + [(cap(n), defs[n]) for n in defs]:
      -    fields = {x.name for x in dataclasses.fields(getattr(m, clsname))}
      -    props = set((node.get("properties") or {}).keys())
      -    if fields != props:
      -        ok = False; print(f"[a] FAIL {clsname}: {fields ^ props}")
      -print("[a] generated dataclass/record fields ≡ schema (root + %d nested): %s" % (len(defs), "OK" if ok else "FAIL"))
      -
      -# (b) a generated instance validates against the schema
      -try:
      -    from jsonschema import Draft202012Validator
      -    clean = lambda x: ({k: clean(v) for k, v in x.items() if v is not None} if isinstance(x, dict)
      -                       else [clean(v) for v in x] if isinstance(x, list) else x)
      -    inst = m.AgentConfig(name="gen_agent", model="openai/gpt-4o", external=False, maxTurns=5,
      -                         timeoutSeconds=0, reasoningEffort="high",
      -                         memory=m.Memory(messages=[{"role": "user", "message": "hi"}], maxMessages=10),
      -                         gate=m.Gate(type="text_contains", text="STOP", caseSensitive=False))
      -    errs = list(Draft202012Validator(schema).iter_errors(clean(dataclasses.asdict(inst))))
      -    for e in errs: ok = False; print("   VIOLATION:", e.message)
      -    print("[b] generated instance validates against schema:", "OK" if not errs else "FAIL")
      -except ImportError:
      -    print("[b] skipped (pip install jsonschema to run)")
      -
      -# (c) every server AgentConfig field (root + nested) is present in the schema.
      -# Set CONDUCTOR_AGENT_MODEL_DIR to the server model source directory to enable this check.
      -def server_fields(cls):
      -    if not MODEL_DIR:
      -        return None
      -    p = os.path.join(MODEL_DIR, cls + ".java")
      -    if not os.path.exists(p): return None
      -    src = open(p).read()
      -    fields = re.findall(r"private\s+(?:final\s+)?[\w<>,\s\[\].]+?\s+([a-z]\w*)\s*[;=]", src)
      -    jp = dict(re.findall(r'@JsonProperty\("([^"]+)"\)[^;{]*?\s(\w+)\s*[;=]', src))
      -    inv = {v: k for k, v in jp.items()}
      -    return {inv.get(f, f) for f in fields}
      -
      -MAP = {ROOT_CLS: "AgentConfig", "Tool": "ToolConfig", "Guardrail": "GuardrailConfig",
      -       "Memory": "MemoryConfig", "Termination": "TerminationConfig", "Handoff": "HandoffConfig",
      -       "Callback": "CallbackConfig", "CodeExecution": "CodeExecutionConfig", "CliConfig": "CliConfig",
      -       "ThinkingConfig": "ThinkingConfig", "PrefillTool": "PrefillToolCallConfig",
      -       "OutputType": "OutputTypeConfig", "WorkerRef": "WorkerRef"}
      -gaps = {}
      -for clsname, server_cls in MAP.items():
      -    node = schema if clsname == ROOT_CLS else defs[clsname[0].lower() + clsname[1:]]
      -    sf = server_fields(server_cls)
      -    if sf is None: continue
      -    miss = sf - set((node.get("properties") or {}).keys())
      -    if miss: gaps[clsname] = sorted(miss); ok = False
      -print("[c] every server field present in schema:", "OK" if not gaps else f"GAPS {gaps}")
      -
      -print("\nRESULT:", "SCHEMA VERIFIED ✅" if ok else "VERIFICATION FAILED ❌")
      -sys.exit(0 if ok else 1)
      diff --git a/docs/agents/getting-started.md b/docs/agents/getting-started.md
      index 4b1d97760..3f64b7aa6 100644
      --- a/docs/agents/getting-started.md
      +++ b/docs/agents/getting-started.md
      @@ -1,66 +1,67 @@
      -# Getting Started
      +# Run your first durable AI agent
      +
      +This path starts a local Conductor server with an LLM provider credential, then runs the maintained basic-agent example from this repository. The LLM credential belongs to the **server process** because Conductor performs the model call.
       
       ## Prerequisites
       
       - Java 21+
      -- Gradle 7+ or Maven 3.6+
      -- A running Conductor server — see the [Conductor repo](https://github.com/conductor-oss/conductor) or start one locally:
      +- Node.js and npm for the recommended local-server path
      +- An OpenAI API key, or an existing Conductor server that already has an LLM provider configured
      +
      +## 1. Start a server with a provider credential
      +
      +Install the [Conductor CLI](https://github.com/conductor-oss/conductor-cli) with npm, then start the local server. Configure the provider key in the server process environment before starting it:
       
       ```bash
      -docker run -p 8080:8080 conductoross/conductor:latest
      +export OPENAI_API_KEY='set-this-in-your-shell'
      +npm install -g @conductor-oss/conductor-cli
      +conductor server start
       ```
       
      -## Add the dependency
      +Wait until the server is healthy:
       
      -=== "Gradle"
      +```bash
      +curl --fail http://localhost:8080/health
      +```
       
      -    ```groovy
      -    dependencies {
      -        implementation 'org.conductoross:conductor-client-ai:5.1.0'
      -    }
      -    ```
      +If you need a containerized server, use the optional [Docker setup](../server-setup.md#optional-docker). If you use an existing OSS or Orkes server, use its URL and authentication instead. The selected `provider/model` must be configured on that server.
       
      -=== "Maven"
      +## 2. Add the dependency
       
      -    ```xml
      -    
      -        org.conductoross
      -        conductor-client-ai
      -        5.1.0
      -    
      -    ```
      +### Gradle
       
      -## Configure the connection
      +```groovy
      +dependencies {
      +    implementation 'org.conductoross:conductor-client-ai:'
      +}
      +```
       
      -The SDK reads connection settings from environment variables by default:
      +### Maven
       
      -```bash
      -export CONDUCTOR_SERVER_URL=http://localhost:8080/api
      -export OPENAI_API_KEY=
      -export CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini
      -export CONDUCTOR_AUTH_KEY=your-key                 # optional
      -export CONDUCTOR_AUTH_SECRET=your-secret           # optional
      +```xml
      +
      +    org.conductoross
      +    conductor-client-ai
      +    <VERSION>
      +
       ```
       
      -Or construct an `ApiClient` explicitly:
      +Replace `` with a published version from [Maven Central](https://search.maven.org/search?q=g:org.conductoross).
       
      -```java
      -import io.orkes.conductor.client.ApiClient;
      -import org.conductoross.conductor.ai.AgentRuntime;
      +## 3. Run the maintained example
       
      -// No auth (local dev)
      -ApiClient client = ApiClient.builder().basePath("http://localhost:8080/api").build();
      +From this SDK repository, in a second terminal:
       
      -// With key/secret
      -ApiClient client = ApiClient.builder()
      -        .basePath("http://myserver:8080/api")
      -        .credentials("key", "secret")
      -        .build();
      -
      -AgentRuntime runtime = new AgentRuntime(client);
      +```bash
      +export CONDUCTOR_SERVER_URL=http://localhost:8080/api
      +CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini \
      +  ./gradlew :agent-examples:run \
      +  -PmainClass=org.conductoross.conductor.ai.examples.Example01BasicAgent
       ```
       
      -## Run your first agent
      +`CONDUCTOR_AGENT_LLM_MODEL` is read by this repository's examples; it is not an `AgentRuntime` configuration variable. The expected result is an `AgentResult` with a completed status and a response to the capital-of-France question.
      +
      +## 4. Create the same agent in your application
       
       ```java
       import org.conductoross.conductor.ai.Agent;
      @@ -68,74 +69,31 @@ import org.conductoross.conductor.ai.AgentRuntime;
       import org.conductoross.conductor.ai.model.AgentResult;
       
       Agent agent = Agent.builder()
      -    .name("hello_agent")
      -    .model("anthropic/claude-sonnet-4-6")
      -    .instructions("You are a concise assistant. Answer in one sentence.")
      -    .build();
      +        .name("hello_agent")
      +        .model("openai/gpt-4o-mini")
      +        .instructions("You are a concise assistant. Answer in one sentence.")
      +        .build();
       
       try (AgentRuntime runtime = new AgentRuntime()) {
           AgentResult result = runtime.run(agent, "What is 2 + 2?");
           System.out.println(result.getOutput());
      -    // → "2 + 2 equals 4."
       }
       ```
       
      -## Add a tool
      +`AgentRuntime` reads `CONDUCTOR_SERVER_URL`, `CONDUCTOR_AUTH_KEY`, and `CONDUCTOR_AUTH_SECRET`. It normalizes either `http://host:8080` or `http://host:8080/api` to the API endpoint.
       
      -Tools are Java methods wrapped as Conductor worker tasks. The method runs locally in your process; the agent calls it remotely via Conductor.
      +## First troubleshooting steps
       
      -```java
      -import org.conductoross.conductor.ai.internal.ToolRegistry;
      -import org.conductoross.conductor.ai.annotations.Tool;
      -
      -public class WeatherTools {
      -
      -    @Tool(name = "get_weather", description = "Get current weather for a city")
      -    public String getWeather(String city) {
      -        // real implementation would call a weather API
      -        return "Sunny, 22°C in " + city;
      -    }
      -}
      -
      -Agent agent = Agent.builder()
      -    .name("weather_agent")
      -    .model("anthropic/claude-sonnet-4-6")
      -    .instructions("Answer weather questions using the get_weather tool.")
      -    .tools(ToolRegistry.fromInstance(new WeatherTools()))
      -    .build();
      -
      -try (AgentRuntime runtime = new AgentRuntime()) {
      -    AgentResult result = runtime.run(agent, "What's the weather in Tokyo?");
      -    System.out.println(result.getOutput());
      -}
      -```
      -
      -!!! tip "Tool methods run as Conductor tasks"
      -    The `@Tool` method executes in your local JVM, but Conductor manages its lifecycle. If your process restarts mid-run, Conductor re-dispatches the task to the next available worker.
      -
      -## Streaming
      -
      -Use `stream()` to get events as they happen:
      -
      -```java
      -import org.conductoross.conductor.ai.model.AgentStream;
      -import org.conductoross.conductor.ai.model.AgentEvent;
      -import org.conductoross.conductor.ai.enums.EventType;
      -
      -try (AgentRuntime runtime = new AgentRuntime();
      -     AgentStream stream = runtime.stream(agent, "Tell me a story")) {
      -
      -    for (AgentEvent event : stream) {
      -        if (event.getType() == EventType.MESSAGE) {
      -            System.out.print(event.getContent());
      -        }
      -    }
      -}
      -```
      +| Symptom | Check |
      +|---|---|
      +| Connection failure | `curl --fail http://localhost:8080/health` succeeds and `CONDUCTOR_SERVER_URL` points to `/api`. |
      +| Authentication failure | Set the client key and secret for the target server; do not place them in source. |
      +| Model/provider error | The server container or remote server—not only the Java client—has the provider credential and supports the selected model. |
      +| Tool task stays scheduled | Keep the process containing `AgentRuntime` alive so its local tool workers can poll. |
       
       ## Next steps
       
      -- [Concepts → Agents](concepts/agents.md) — full builder API reference
      -- [Concepts → Tools](concepts/tools.md) — tool types: HTTP, MCP, human, CLI
      -- [Concepts → Multi-Agent](concepts/multi-agent.md) — sequential, parallel, handoff, swarm
      -- [Spring Boot](spring-boot.md) — auto-configuration for Spring Boot apps
      +- [Add Java tools](concepts/tools.md)
      +- [Build multi-agent and plan-execute systems](concepts/multi-agent.md)
      +- [Deploy or serve agents](concepts/deploy-serve-run.md)
      +- [Use Google ADK, LangChain4j, LangGraph4j, or OpenAI-style bridges](README.md#framework-bridges)
      diff --git a/docs/agents/index.md b/docs/agents/index.md
      deleted file mode 100644
      index a53f59ab9..000000000
      --- a/docs/agents/index.md
      +++ /dev/null
      @@ -1,87 +0,0 @@
      -# Conductor Java Agent SDK
      -
      -Build long-running, dynamic plan-execute, and event-driven AI agents in Java on Conductor. Your agents survive process crashes, run on cron, trigger from events, and execute dynamic plans deterministically — all without managing state yourself.
      -
      -```java
      -Agent agent = Agent.builder()
      -    .name("assistant")
      -    .model("anthropic/claude-sonnet-4-6")
      -    .instructions("You are a helpful assistant.")
      -    .build();
      -
      -try (AgentRuntime runtime = new AgentRuntime()) {
      -    AgentResult result = runtime.run(agent, "What is the capital of France?");
      -    System.out.println(result.getOutput());
      -}
      -```
      -
      -Namespace: `org.conductoross.conductor.ai`. Requires Java 21+.
      -
      -## Documentation map
      -
      -The docs are organized into five areas:
      -
      -### a) Get started
      -
      -- **[Getting Started](getting-started.md)** — install (Maven/Gradle), set env vars, run your first agent in under 30 seconds.
      -
      -### b) Writing agents
      -
      -- **[Agents](concepts/agents.md)** — the full `Agent.builder()` API, dynamic instructions, `@AgentDef`/`Agent.fromInstance`.
      -- **[Tools](concepts/tools.md)** — `@Tool` + `ToolRegistry.fromInstance`, and built-ins: HTTP, MCP, Human, Media (image/audio/video), PDF, RAG, WaitForMessage, AgentTool.
      -- **[Multi-Agent](concepts/multi-agent.md)** — sequential, parallel, handoff, router, swarm, round-robin, plan-execute.
      -- **[Guardrails](concepts/guardrails.md)** · **[Termination](concepts/termination.md)** — validation and early-exit conditions.
      -- **[Callbacks](concepts/callbacks.md)** — lifecycle hooks (`CallbackHandler`).
      -- **[Streaming & Human-in-the-Loop](concepts/streaming-hitl.md)** — event streams and approval flows.
      -- **[Stateful Agents](concepts/stateful.md)** — sessions, conversation memory, multi-turn.
      -- **[Structured Output](concepts/structured-output.md)** — typed results via `outputType`.
      -- **[Scheduling](concepts/scheduling.md)** · **[Skills](concepts/skills.md)**.
      -
      -### c) Framework agents
      -
      -Run agents authored in another framework on the durable Conductor runtime.
      -
      -- **[OpenAI Agents SDK](frameworks/openai.md)** · **[Google ADK](frameworks/google-adk.md)** · **[LangChain4j](frameworks/langchain4j.md)** · **[LangGraph4j](frameworks/langgraph4j.md)**.
      -
      -### d) Operating agents
      -
      -- **[Deploy · Serve · Run · Plan](concepts/deploy-serve-run.md)** — the four runtime modes.
      -- **[Spring Boot](spring-boot.md)** — auto-configuration and `@AgentDef` bean discovery.
      -- **[Agent Field Reference](agent-structure.md)** · **[Agent JSON Schema](agent-schema.md)** — the wire format.
      -
      -### e) API reference
      -
      -- **[Public API summary](api-reference.md)** — every public signature on one page.
      -- **[AgentRuntime](agent-runtime-api.md)** — the entry-point class in detail.
      -- **[AgentClient](agent-client-api.md)** — the `/api/agent/*` control plane: start deployed or inline agents, inspect status timestamps, respond, gracefully stop, or immediately cancel.
      -- **[Control-plane example](../../agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example71AgentControlPlane.java)** — runnable deployed-agent start/status/stop/cancel flow.
      -
      -## Installation
      -
      -=== "Gradle"
      -
      -    ```groovy
      -    implementation 'org.conductoross:conductor-client-ai:5.1.0'
      -    ```
      -
      -=== "Maven"
      -
      -    ```xml
      -    
      -        org.conductoross
      -        conductor-client-ai
      -        5.1.0
      -    
      -    ```
      -
      -**Requirements:** Java 21+ · a Conductor server (see [Getting Started](getting-started.md)).
      -
      -## What makes it different
      -
      -| Feature | Conductor | Thread-based SDKs |
      -|---|---|---|
      -| Survives crashes | ✅ Conductor workflow | ❌ State lost |
      -| Tool workers | ✅ Distributed tasks | ❌ In-process only |
      -| Long-running | ✅ Days / weeks | ❌ Minutes |
      -| Human-in-the-loop | ✅ Native approval flow | ❌ Polling hacks |
      -| Observability | ✅ Full workflow audit log | ❌ Log scraping |
      diff --git a/docs/agents/agent-structure.md b/docs/agents/reference/agent-definition.md
      similarity index 98%
      rename from docs/agents/agent-structure.md
      rename to docs/agents/reference/agent-definition.md
      index 4173c67a7..c39fca84d 100644
      --- a/docs/agents/agent-structure.md
      +++ b/docs/agents/reference/agent-definition.md
      @@ -1,4 +1,4 @@
      -# Agent — Field Reference
      +# Agent definition fields
       
       `Agent` is the declarative configuration you build with `Agent.builder()`. Each field
       below lists its builder method, the JSON key it serializes to, and any behavior notes.
      @@ -25,7 +25,7 @@ below lists its builder method, the JSON key it serializes to, and any behavior
       | `outputType` | `outputType(Class)` | `outputType` | Structured-output class name. |
       | `handoffs` | `handoffs(Handoff...)` | `handoffs` | SWARM triggers: `OnTextMention`, `OnToolResult`, `OnCondition`. |
       | `allowedTransitions` | `allowedTransitions(Map)` | `allowedTransitions` | SWARM: restricts which agents may transfer to which. |
      -| `credentials` | `credentials(String...)` | `credentials` | Secret names fetched from the secrets store at runtime. |
      +| `credentials` | `credentials(String...)` | `credentials` | Secret names resolved by a capable server and delivered to tool context at runtime. |
       | `requiredTools` | `requiredTools(String...)` | `requiredTools` | Tool names that must be called during the run. |
       | `metadata` | `metadata(Map)` | `metadata` | Arbitrary key-values stored with the workflow definition. |
       | `synthesize` | `synthesize(boolean)` | `synthesize` | Emitted only when `false` (default `true`). |
      diff --git a/docs/agents/agent-schema.json b/docs/agents/reference/agent-schema.json
      similarity index 99%
      rename from docs/agents/agent-schema.json
      rename to docs/agents/reference/agent-schema.json
      index 41037175b..5f46e0da7 100644
      --- a/docs/agents/agent-schema.json
      +++ b/docs/agents/reference/agent-schema.json
      @@ -1,7 +1,7 @@
       {
         "$schema": "https://json-schema.org/draft/2020-12/schema",
         "$id": "urn:conductor:agent-config-schema",
      -  "title": "Conductor Agent AgentConfig",
      +  "title": "Conductor Agent Configuration",
         "description": "Canonical wire contract for the agent configuration that SDKs serialize and POST to the server (under the `agentConfig` key of the start/compile request). Mirrors the server-side `AgentConfig` model. Convention: camelCase keys, `@JsonInclude(NON_NULL)` — absent means unset. Recursive: `agents`, `planner`, `fallback`, `router` nest a full AgentConfig.",
         "type": "object",
         "required": ["name"],
      diff --git a/docs/agents/reference/agent-schema.md b/docs/agents/reference/agent-schema.md
      new file mode 100644
      index 000000000..25a228b38
      --- /dev/null
      +++ b/docs/agents/reference/agent-schema.md
      @@ -0,0 +1,36 @@
      +# Agent configuration schema
      +
      +[`agent-schema.json`](agent-schema.json) is the canonical wire contract for an agent configuration submitted under `agentConfig` on compile and start requests. It uses JSON Schema Draft 2020-12, camel-case keys, and rejects unknown top-level properties. `agents`, `planner`, `fallback`, and `router` can recursively contain an agent configuration.
      +
      +The schema is the precise contract. Use this page to understand its scope; use the JSON file for every field, type, enum, and defaultable value.
      +
      +## Contract scope
      +
      +The schema follows the server `AgentConfig` model and the Java SDK serializer at [`AgentConfigSerializer.java`](../../../conductor-client-ai/src/main/java/org/conductoross/conductor/ai/internal/AgentConfigSerializer.java). It covers identity and model settings, tools, sub-agents and handoffs, guardrails, memory, termination, callbacks, code execution, planning, and framework configuration.
      +
      +Two compatibility details are deliberate:
      +
      +- `sessionId` is permitted in the configuration because the Java SDK emits it there, although the server also reads session identity from the request wrapper.
      +- `planSource` is permitted for SDK compatibility even though static plans can be sent by a request-wrapper field.
      +
      +## Continuous verification
      +
      +[`tools/agent-schema/verify.py`](../../../tools/agent-schema/verify.py) runs in CI. It:
      +
      +1. validates the schema itself with Draft 2020-12;
      +2. generates Python dataclasses and Java records in a temporary directory;
      +3. checks that every generated field exactly matches its schema object; and
      +4. validates representative valid and invalid configuration documents, then compiles the generated Java record source with Java 21.
      +
      +The verifier never writes generated artifacts into `docs/`. It validates this repository’s structural contract; cross-repository server or SDK interoperability still needs the relevant integration test in that repository.
      +
      +## Use it
      +
      +Validate a document locally with the same CI command:
      +
      +```bash
      +python -m pip install -r tools/agent-schema/requirements.txt
      +python tools/agent-schema/verify.py
      +```
      +
      +For the Java builder surface, see [agent definition fields](agent-definition.md). For request envelopes and control-plane responses, see [AgentClient](client.md).
      diff --git a/docs/agents/reference/api.md b/docs/agents/reference/api.md
      new file mode 100644
      index 000000000..6ad60f54d
      --- /dev/null
      +++ b/docs/agents/reference/api.md
      @@ -0,0 +1,23 @@
      +# API map
      +
      +Use this page to choose the detailed reference; it intentionally does not duplicate every signature.
      +
      +| Need | Reference |
      +|---|---|
      +| Run, stream, deploy, serve, resume, or schedule an agent | [AgentRuntime](runtime.md) |
      +| Call `/api/agent/*` directly or inspect request/response shapes | [AgentClient control plane](client.md) |
      +| Build an `Agent` or inspect serialized fields | [Agent definition fields](agent-definition.md) |
      +| Validate the agent configuration contract | [Agent configuration schema](agent-schema.md) |
      +
      +## Primary types
      +
      +`AgentRuntime` is the high-level, thread-safe entry point. Use `run` when the caller waits for an `AgentResult`, `start` when it needs an `AgentHandle`, `stream` for events, and `deploy` or `serve` for long-lived agent definitions. The [runtime reference](runtime.md) contains constructors, overloads, environment variables, and lifecycle behavior.
      +
      +`AgentClient`, from `new OrkesClients(conductorClient).getAgentClient()`, is the lower-level control-plane client. Use it for direct compile, deploy, start, status, response, cancellation, and SSE operations; see the [client reference](client.md).
      +
      +## Related guides
      +
      +- [Choose a runtime mode](../concepts/deploy-serve-run.md)
      +- [Define agents](../concepts/agents.md)
      +- [Add tools](../concepts/tools.md)
      +- [Schedule deployed agents](../concepts/scheduling.md)
      diff --git a/docs/agents/agent-client-api.md b/docs/agents/reference/client.md
      similarity index 95%
      rename from docs/agents/agent-client-api.md
      rename to docs/agents/reference/client.md
      index bd739bbdc..bbbc7b070 100644
      --- a/docs/agents/agent-client-api.md
      +++ b/docs/agents/reference/client.md
      @@ -1,4 +1,4 @@
      -# AgentClient — Control-Plane API Reference
      +# AgentClient control-plane reference
       
       `AgentClient` (`io.orkes.conductor.client.AgentClient`, in the `conductor-client` module) is the Java SDK's interface to the agent control-plane (`/api/agent/*`). Standard Conductor endpoints (`/api/workflow/*`, `/api/tasks`, etc.) remain on the SDK's typed clients (`WorkflowClient`, `TaskClient`, `MetadataClient`). Obtain an agent client with `new OrkesClients(conductorClient).getAgentClient()` or construct `OrkesAgentClient` directly.
       
      @@ -87,22 +87,21 @@ AgentRequest.nativeAgent(serializedAgent)
       
       Null fields are never written — the class is annotated `@JsonInclude(NON_NULL)`. The three request forms are mutually exclusive: use deployed `name`/`version`, inline native `agentConfig`, or framework `framework` plus `rawConfig`/`skillRef`.
       
      -**`Framework` enum** — all seven values map 1-to-1 with the server's normalizer registry:
      +**Framework wire values:**
       
      -| Enum constant | Wire value | Server normalizer |
      -|---|---|---|
      -| `Framework.OPENAI` | `"openai"` | `OpenAINormalizer` |
      -| `Framework.GOOGLE_ADK` | `"google_adk"` | `GoogleADKNormalizer` |
      -| `Framework.LANGCHAIN` | `"langchain"` | `LangChainNormalizer` |
      -| `Framework.LANGGRAPH` | `"langgraph"` | `LangGraphNormalizer` |
      -| `Framework.SKILL` | `"skill"` | `SkillNormalizer` |
      -| `Framework.VERCEL_AI` | `"vercel_ai"` | `VercelAINormalizer` |
      -| `Framework.CLAUDE_AGENT_SDK` | `"claude_agent_sdk"` | `ClaudeAgentSdkNormalizer` |
      +| Enum constant | Wire value |
      +|---|---|
      +| `Framework.OPENAI` | `"openai"` |
      +| `Framework.GOOGLE_ADK` | `"google_adk"` |
      +| `Framework.LANGCHAIN` | `"langchain"` |
      +| `Framework.LANGGRAPH` | `"langgraph"` |
      +| `Framework.SKILL` | `"skill"` |
      +| `Framework.VERCEL_AI` | `"vercel_ai"` |
      +| `Framework.CLAUDE_AGENT_SDK` | `"claude_agent_sdk"` |
       
       `AgentRuntime` resolves `agent.getFramework()` → `Framework` via `Framework.of(String)` (returns `Optional.empty()` for unrecognised strings, routing them through the native path).
       
      -**Structural proof — `static_plan` key:**
      -The server field is `staticPlan` annotated `@JsonProperty("static_plan")`. The SDK's `AgentRequest.staticPlan` field carries the same `@JsonProperty("static_plan")` — both sides agree on the JSON key.
      +`staticPlan` is serialized as `"static_plan"`.
       
       ---
       
      diff --git a/docs/agents/agent-runtime-api.md b/docs/agents/reference/runtime.md
      similarity index 94%
      rename from docs/agents/agent-runtime-api.md
      rename to docs/agents/reference/runtime.md
      index 32374291f..779080c68 100644
      --- a/docs/agents/agent-runtime-api.md
      +++ b/docs/agents/reference/runtime.md
      @@ -1,4 +1,4 @@
      -# AgentRuntime — API Reference
      +# AgentRuntime reference
       
       `AgentRuntime` is the primary entry point for the Conductor Java Agent SDK. It manages the connection to the Conductor server, registers local tool workers, and exposes every operation for running, streaming, deploying, and serving agents.
       
      @@ -29,7 +29,7 @@ try (AgentRuntime runtime = new AgentRuntime()) {
       | [`serve`](#serve) | `void` | Long-running worker mode (blocks) |
       | [`resume`](#resume) | `AgentHandle` | Re-attach to an existing execution |
       | [`resumeAsync`](#resumeasync) | `CompletableFuture` | Async re-attach |
      -| [`schedules`](#schedules) | `Schedules` | Access the scheduling API |
      +| [`getSchedulerClient`](#getschedulerclient) | `SchedulerClient` | Access typed workflow scheduling APIs |
       | [`shutdown`](#shutdown) | `void` | Stop workers and release HTTP connections |
       
       ---
      @@ -62,7 +62,7 @@ Invalid or empty values fall back to the default.
       
       | Variable | Default | Description |
       |---|---|---|
      -| `CONDUCTOR_SERVER_URL` | `http://localhost:8080` | Conductor server base URL |
      +| `CONDUCTOR_SERVER_URL` | `http://localhost:8080/api` | Conductor server base URL |
       | `CONDUCTOR_AUTH_KEY` | _(none)_ | API key (optional) |
       | `CONDUCTOR_AUTH_SECRET` | _(none)_ | API secret (optional) |
       | `CONDUCTOR_AGENT_WORKER_POLL_INTERVAL` | `100` | Worker poll interval (ms) |
      @@ -159,7 +159,7 @@ null, empty, and whitespace-only values are omitted.
       
       ## runAsync
       
      -Non-blocking variant of `run`. Uses the common `ForkJoinPool`.
      +Non-blocking variant of `run`.
       
       ```java
       CompletableFuture runAsync(Agent agent, String prompt)
      @@ -298,9 +298,10 @@ Register workflow definition(s) on the server without starting an execution. Ide
       List infos = runtime.deploy(agentA, agentB);
       infos.forEach(i -> System.out.println(i.getRegisteredName()));
       
      -// With schedules — deploys the agent and reconciles its cron schedules
      -Schedule daily = Schedule.builder().name("daily").cron("0 9 * * *").build();
      -runtime.deploy(agent, List.of(daily));
      +// Scheduling is explicit after deployment:
      +runtime.deploy(agent);
      +SchedulerClient schedules = runtime.getSchedulerClient();
      +// Build a SaveScheduleRequest and call schedules.saveSchedule(request).
       ```
       
       **`DeploymentInfo` fields:** `getRegisteredName()` (server workflow name), `getAgentName()` (SDK agent name).
      @@ -366,13 +367,13 @@ schedules.resumeSchedule("my_agent-daily");
       schedules.deleteSchedule("my_agent-daily");
       ```
       
      -See [Scheduling concepts](concepts/scheduling.md) for direct `SaveScheduleRequest` usage.
      +See [Scheduling concepts](../concepts/scheduling.md) for direct `SaveScheduleRequest` usage.
       
       ---
       
       ## shutdown
       
      -Stop all worker threads, drain in-flight tasks, and release HTTP connections (OkHttp connection pool + dispatcher thread pool).
      +Stop local workers and release runtime-owned HTTP resources.
       
       ```java
       runtime.shutdown();
      @@ -380,7 +381,7 @@ runtime.shutdown();
       runtime.close();   // AutoCloseable — called automatically by try-with-resources
       ```
       
      -Without explicit shutdown, OkHttp's thread pool keeps threads alive for ~60s after the last request. In tests or short-lived processes, always close the runtime.
      +In tests or short-lived processes, always close the runtime.
       
       ---
       
      @@ -413,4 +414,4 @@ private static final AgentRuntime RUNTIME = new AgentRuntime();
       Runtime.getRuntime().addShutdownHook(new Thread(RUNTIME::shutdown));
       ```
       
      -Do not create one `AgentRuntime` per request — each instance owns its own OkHttp connection pool and worker thread pool.
      +Do not create one `AgentRuntime` per request — each instance owns client and worker resources.
      diff --git a/docs/agents/spring-boot.md b/docs/agents/spring-boot.md
      index 0efca836e..598570c35 100644
      --- a/docs/agents/spring-boot.md
      +++ b/docs/agents/spring-boot.md
      @@ -4,21 +4,23 @@ The `conductor-client-ai-spring` module provides Spring Boot auto-configuration.
       
       ## Dependency
       
      -=== "Gradle"
      +### Gradle
       
      -    ```groovy
      -    implementation 'org.conductoross:conductor-client-ai-spring:5.1.0'
      -    ```
      +```groovy
      +implementation 'org.conductoross:conductor-client-ai-spring:'
      +```
      +
      +### Maven
       
      -=== "Maven"
      +```xml
      +
      +    org.conductoross
      +    conductor-client-ai-spring
      +    <VERSION>
      +
      +```
       
      -    ```xml
      -    
      -        org.conductoross
      -        conductor-client-ai-spring
      -        5.1.0
      -    
      -    ```
      +Replace `` with a published version from [Maven Central](https://search.maven.org/search?q=g:org.conductoross).
       
       This pulls in both `conductor-client-ai` and `conductor-client-spring` (which wires the `ApiClient`).
       
      diff --git a/docs/file-client.md b/docs/file-client.md
      index bb7ed7c49..c4ab79728 100644
      --- a/docs/file-client.md
      +++ b/docs/file-client.md
      @@ -2,11 +2,14 @@
       
       `FileClient` uploads, downloads, and inspects workflow-scoped binary files. Workflow data contains only opaque strings such as `conductor://file/`; workers never pass storage URLs or SDK file objects to one another.
       
      -The Conductor server must have file storage enabled and must expose the workflow-scoped File API.
      +The Conductor server must have file storage enabled and must expose the workflow-scoped File API. For local setup, use the [Conductor CLI guide](server-setup.md).
       
       ## Create a client
       
       ```java
      +import com.netflix.conductor.client.http.ConductorClient;
      +import org.conductoross.conductor.client.FileClient;
      +
       ConductorClient conductor = ConductorClient.builder()
               .basePath("http://localhost:8080/api")
               .build();
      @@ -179,4 +182,4 @@ The annotation value must exactly match both the workflow task's `name` and a re
       - Signed requests use a separate raw HTTP client with redirects disabled and no Conductor authentication, cookies, or application interceptors.
       - Unknown server storage types can use the generic adapter only when the supplied signed URL is HTTP(S).
       
      -See [FileClient Design](design/file-client.md) for component boundaries and [Media Transcoder](../examples/file-storage/media-transcoder/) for a complete multi-worker workflow.
      +See [File client design](../design/file-client.md) for component boundaries and [Media Transcoder](../examples/file-storage/media-transcoder/) for a complete multi-worker workflow.
      diff --git a/docs/SchemaClient.md b/docs/schema-client.md
      similarity index 88%
      rename from docs/SchemaClient.md
      rename to docs/schema-client.md
      index a5b29b42c..bdced2807 100644
      --- a/docs/SchemaClient.md
      +++ b/docs/schema-client.md
      @@ -1,7 +1,13 @@
      -# SchemaClient
      +# Schema client
       
       The `SchemaClient` provides operations to manage schemas in Conductor. Schemas define the structure of workflow inputs, outputs, and task data using JSON Schema, Avro, or Protobuf formats.
       
      +## Prerequisites
      +
      +- Java 21+
      +- A running Conductor server with schema APIs enabled ([start one locally with the CLI](server-setup.md), or use an existing server)
      +- `org.conductoross:conductor-client:` from [Maven Central](https://search.maven.org/search?q=g:org.conductoross)
      +
       ## Setup
       
       ```java
      diff --git a/docs/server-setup.md b/docs/server-setup.md
      new file mode 100644
      index 000000000..c7c73b9ca
      --- /dev/null
      +++ b/docs/server-setup.md
      @@ -0,0 +1,46 @@
      +# Start a local Conductor server
      +
      +Use the [Conductor CLI](https://github.com/conductor-oss/conductor-cli) for local development. It is the recommended path because the same CLI also creates workflows, starts executions, inspects status, and manages schedules.
      +
      +Prerequisites: Java 21+ (the CLI runs the local server JAR) and Node.js/npm.
      +
      +## Recommended: CLI
      +
      +Install the CLI with npm, then start the local server:
      +
      +```bash
      +npm install -g @conductor-oss/conductor-cli
      +conductor server start
      +```
      +
      +Verify it and point SDK applications at its API:
      +
      +```bash
      +conductor server status
      +export CONDUCTOR_SERVER_URL=http://localhost:8080/api
      +```
      +
      +Use `conductor server stop` when you are finished. See the [CLI repository](https://github.com/conductor-oss/conductor-cli) for alternative installation methods and server options.
      +
      +## Optional: Docker
      +
      +Use Docker when you need a containerized server or are running an example that includes a `docker-compose.yml`:
      +
      +```bash
      +docker run --rm -p 8080:8080 -p 1234:5000 conductoross/conductor:latest
      +```
      +
      +The API is `http://localhost:8080/api` and the UI is `http://localhost:1234`.
      +
      +## Use an existing server
      +
      +Set `CONDUCTOR_SERVER_URL` to the server API endpoint and, when required, set `CONDUCTOR_AUTH_KEY` and `CONDUCTOR_AUTH_SECRET`. Do not put credentials in source code or workflow input.
      +
      +## Give your coding agent Conductor context
      +
      +[Conductor Skills](https://github.com/conductor-oss/conductor-skills) teaches supported coding agents how to create, run, inspect, and manage Conductor workflows. Install it with npm and load it for detected agents:
      +
      +```bash
      +npm install -g @conductor-oss/conductor-skills
      +conductor-skills --all
      +```
      diff --git a/docs/testing_framework.md b/docs/testing_framework.md
      deleted file mode 100644
      index 601ee2d84..000000000
      --- a/docs/testing_framework.md
      +++ /dev/null
      @@ -1,74 +0,0 @@
      -# Unit Testing Framework for Workflows
      -
      -The framework allows you to test the workflow definitions against a specific version of Conductor server.
      -
      -The unit tests allow the following:
      -1. **Input/Output Wiring**: Ensure the tasks are wired up correctly.
      -2. **Parameter check**: Workflow behavior with missing mandatory parameters is expected (fail if required).
      -3. **Task Failure behavior**: Ensure the task definitions have the right number of retries etc.  
      -   For example, if the task is not idempotent, it does not get retried.
      -4. **Branch Testing**: Given a specific input, ensure the workflow executes a specific branch of the fork/decision.
      -
      -The local test server is self-contained with no additional dependencies required and stores all the data
      -in memory.  Once the test completes, the server is terminated and all the data is wiped out.
      -
      -## Unit Testing Frameworks
      -The unit testing framework is agnostic to the framework you use for testing and can be easily integrated into 
      -JUnit, Spock and other testing frameworks being used.
      -
      -## Setting Up Local Server for Testing​
      -
      -```java
      -//Setup method  code - should be called once per the test lifecycle
      -//e.g. @BeforeClass in JUnit
      -
      -//Download the published conductor server version 3.5.2 
      -//Start the local server at port 8096
      -testRunner = new WorkflowTestRunner(8096, "3.5.2");
      -
      -//Scan the packages for task workers
      -testRunner.init("com.netflix.conductor.testing.workflows");
      -
      -//Get the executor instance used for  loading workflows 
      -executor = testRunner.getWorkflowExecutor();
      -```
      -
      -Clean up method:
      -```java
      -//Clean up method code -- place in a clean up method e.g. @AfterClass in Junit
      -
      -//Shutdown local workers and servers and clean up any local resources in use.
      -testRunner.shutdown();
      -```
      -
      -Loading workflows from JSON files for testing:
      -```java
      -executor.loadTaskDefs("/tasks.json");
      -executor.loadWorkflowDefs("/simple_workflow.json");
      -```
      -
      -## Sample test code that starts a workflow and verifies its execution
      -
      -```java
      -GetInsuranceQuote getQuote = new GetInsuranceQuote();
      -getQuote.setName("personA");
      -getQuote.setAmount(1000000.0);
      -getQuote.setZipCode("10121");
      -
      -// Start the workflow and wait for it to complete
      -CompletableFuture workflowFuture = executor.executeWorkflow("InsuranceQuoteWorkflow", 1, getQuote);
      -
      -//Wait for the workflow execution to complete
      -Workflow workflow = workflowFuture.get();
      -
      -//Assertions
      -assertNotNull(workflow);
      -assertEquals(Workflow.WorkflowStatus.COMPLETED, workflow.getStatus());
      -assertNotNull(workflow.getOutput());
      -assertNotNull(workflow.getTasks());
      -assertFalse(workflow.getTasks().isEmpty());
      -assertTrue(workflow.getTasks().stream().anyMatch(task -> task.getTaskDefName().equals("task_6")));
      -```
      -
      -
      -
      diff --git a/docs/worker_sdk.md b/docs/worker_sdk.md
      deleted file mode 100644
      index 6b91ebff8..000000000
      --- a/docs/worker_sdk.md
      +++ /dev/null
      @@ -1,143 +0,0 @@
      -# Worker SDK
      -Worker SDK makes it easy to write Conductor workers which are strongly typed with specific inputs and outputs.
      -
      -Annotations for the worker methods:
      -
      -* `@WorkerTask` - When annotated, convert a method to a Conductor worker.
      -* `@InputParam` - Name of the input parameter to bind to from the task's input.
      -* `@OutputParam` - Name of the output key of the task's output.
      -
      -Please note inputs and outputs to a task in Conductor are JSON documents.
      -
      -
      -**Examples**
      -
      -Create a worker named `task1` that gets Task as input and produces TaskResult as output.
      -```java
      -@WorkerTask("task1")
      -    public TaskResult task1(Task task) {
      -        task.setStatus(Task.Status.COMPLETED);
      -        return new TaskResult(task);
      -    }
      -```
      -
      -Create a worker named `task2` that takes the `name` as a String input and produces an output `return "Hello, " + name`
      -
      -```java
      -@WorkerTask("task2")
      -public @OutputParam("greetings") String task2(@InputParam("name") String name) {
      -    return "Hello, " + name;
      -}
      -```
      -Example Task Input/Output
      -
      -Input:
      -```json
      -{
      -   "name": "conductor"
      -}
      -```
      -
      -Output:
      -```json
      -{
      -   "greetings": "Hello, conductor"
      -}
      -```
      -A worker that takes complex java type as input and produces the complex output:
      -```java
      -@WorkerTask("get_insurance_quote")
      - public InsuranceQuote getInsuranceQuote(GetInsuranceQuote quoteInput) {
      -     InsuranceQuote quote = new InsuranceQuote();
      -     //Implementation
      -     return quote;
      - }
      -```
      -
      -Example Task Input/Output
      -
      -Input:
      -```json
      -{
      -   "name": "personA",
      -   "zipCode": "10121",
      -   "amount": 1000000
      -}
      -```
      -
      -Output:
      -```json
      -{
      -   "name": "personA",
      -   "quotedPremium": 123.50,
      -   "quotedAmount": 1000000
      -}
      -```
      -
      -## Managing Task Workers
      -Annotated Workers are managed by [WorkflowExecutor](https://github.com/conductor-oss/java-sdk/blob/main/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/executor/WorkflowExecutor.java)
      -
      -### Start Workers
      -To start workers via classpath scanning, use either of the `initWorkers` methods in the `WorkflowExecutor` class.
      -```java
      -WorkflowExecutor executor = new WorkflowExecutor("http://server/api/");
      -// List of packages  (comma separated) to scan for annotated workers.  
      -// Please note, the worker method(s) MUST be public and the class in which they are defined
      -// MUST have a no-args constructor        
      -executor.initWorkers("com.company.package1,com.company.package2");
      -
      -// You may also specify packages as separate variadic arguments rather than comma-separated
      -executor.initWorkers("com.company.package1", "com.company.package2");
      -```
      -
      -To start workers via a specific class (or list of classes) use the `initWorkersFromClasses` method. This will not scan
      -the entire classpath, but only the classes you specify.
      -```java
      -WorkflowExecutor executor = new WorkflowExecutor("http://server/api/");
      -
      -// As above, note that the worker method(s) MUST be public and the class in which they are defined
      -// MUST have a no-args constructor
      -executor.initWorkersFromClasses(List.of(MyCoolWorker.class, SomeOtherWorker.class));
      -```
      -
      -Finally, if you want to manage your workers in a way that requires you to construct them without no-args constructors,
      -you may use the `initWorkersFromInstances` method. This allows you to pass in instances of your worker classes.
      -```java
      -WorkflowExecutor executor = new WorkflowExecutor("http://server/api/");
      -
      -// As above, note that the worker method(s) MUST be public
      -executor.initWorkersFromInstances(List.of(
      -        myCoolWorkerInstance, someOtherWorkerInstance
      -))
      -```
      -
      -
      -### Stop Workers
      -The code fragment to stop workers at shutdown of the application.
      -```java
      -executor.shutdown();
      -```
      -
      -### Unit Testing Workers
      -Workers implemented with the annotations are regular Java methods that can be unit tested with any testing framework.
      -
      -#### Mock Workers for Workflow Testing​
      -Create a mock worker in a different package (e.g., test) and scan for these packages when loading up the workers for integration testing.
      -
      -See [Unit Testing Framework](testing_framework.md) for more details on testing.
      -
      -## Best Practices
      -In a typical production environment, you will have multiple workers across different machines/VMs/pods polling for the same task.
      -As with all Conductor workers, the following best practices apply:
      -
      -1. Workers should be stateless and should not maintain any state on the process they are running.
      -2. Ideally, workers should be idempotent.
      -3. The worker should follow the Single Responsibility Principle and do exactly one thing they are responsible for.
      -4. The worker should not embed any workflow logic - i.e., scheduling another worker, sending a message, etc. The Conductor has features to do this, making it possible to decouple your workflow logic from worker implementation.
      -
      -
      -
      -
      -
      -
      -
      diff --git a/docs/workers.md b/docs/workers.md
      new file mode 100644
      index 000000000..19a61828e
      --- /dev/null
      +++ b/docs/workers.md
      @@ -0,0 +1,91 @@
      +# Workers
      +
      +Workers execute `SIMPLE` tasks that Conductor schedules. Start with the maintained [Hello World](../examples/basics/hello-world/README.md): it registers `greet`, starts a Java worker, executes a workflow, and waits for completion.
      +
      +## Worker contract
      +
      +For every `SIMPLE` task:
      +
      +1. The workflow task `name`, task definition name, and worker task name must match exactly.
      +2. At least one live worker must poll that task name.
      +3. The worker must be idempotent because Conductor may redeliver after a timeout or retry.
      +
      +## Implement `Worker`
      +
      +This is the most direct pattern and is used by Hello World:
      +
      +```java
      +import com.netflix.conductor.client.worker.Worker;
      +import com.netflix.conductor.common.metadata.tasks.Task;
      +import com.netflix.conductor.common.metadata.tasks.TaskResult;
      +
      +public final class GreetWorker implements Worker {
      +    @Override
      +    public String getTaskDefName() {
      +        return "greet";
      +    }
      +
      +    @Override
      +    public TaskResult execute(Task task) {
      +        String name = (String) task.getInputData().getOrDefault("name", "World");
      +        TaskResult result = new TaskResult(task);
      +        result.setStatus(TaskResult.Status.COMPLETED);
      +        result.getOutputData().put("greeting", "Hello, " + name + "!");
      +        return result;
      +    }
      +}
      +```
      +
      +Run workers with `TaskRunnerConfigurer`:
      +
      +```java
      +import java.util.List;
      +
      +import com.netflix.conductor.client.automator.TaskRunnerConfigurer;
      +
      +TaskRunnerConfigurer workers = new TaskRunnerConfigurer.Builder(taskClient, List.of(new GreetWorker()))
      +        .withThreadCount(1)
      +        .build();
      +workers.init();
      +```
      +
      +Call `workers.shutdown()` during application shutdown.
      +
      +## Annotation-based workers
      +
      +For method binding, use `@WorkerTask`, `@InputParam`, and `@OutputParam`:
      +
      +```java
      +import com.netflix.conductor.sdk.workflow.task.InputParam;
      +import com.netflix.conductor.sdk.workflow.task.OutputParam;
      +import com.netflix.conductor.sdk.workflow.task.WorkerTask;
      +
      +public final class GreetingTasks {
      +    @WorkerTask("greet")
      +    public @OutputParam("greeting") String greet(@InputParam("name") String name) {
      +        return "Hello, " + name + "!";
      +    }
      +}
      +```
      +
      +`WorkflowExecutor` can discover public annotated methods from packages, classes, or existing instances. Existing instances preserve constructor injection:
      +
      +```java
      +import java.util.List;
      +
      +import com.netflix.conductor.sdk.workflow.executor.WorkflowExecutor;
      +
      +WorkflowExecutor executor = new WorkflowExecutor("http://localhost:8080/api");
      +executor.initWorkersFromInstances(List.of(new GreetingTasks()));
      +```
      +
      +See the [current WorkflowExecutor source](https://github.com/conductor-oss/java-sdk/blob/main/conductor-client/src/main/java/com/netflix/conductor/sdk/workflow/executor/WorkflowExecutor.java) for all worker-loading options.
      +
      +## Production guidance
      +
      +- Set task-definition retry and timeout policy deliberately; a worker timeout is not a business timeout.
      +- Keep workers stateless and make side effects idempotent with a request key or durable application record.
      +- Use task domains and independent worker deployments when different queues need isolation.
      +- Return `FAILED_WITH_TERMINAL_ERROR` only for non-retryable failures; let transient failures use the configured retry policy.
      +
      +For a local-server workflow test, continue with the [test harness](workflow-testing.md).
      diff --git a/docs/workflow-testing.md b/docs/workflow-testing.md
      new file mode 100644
      index 000000000..a4f29c844
      --- /dev/null
      +++ b/docs/workflow-testing.md
      @@ -0,0 +1,65 @@
      +# Workflow test harness
      +
      +`WorkflowTestRunner` downloads and starts a real Conductor server, then registers annotated workers and workflow metadata against it. It is an integration-test harness, not a mock-only unit-test framework.
      +
      +Use it to verify input/output wiring, retries, dynamic branches, and worker behavior without managing a separate server process.
      +
      +## JUnit setup
      +
      +The maintained SDK test currently exercises Conductor `3.21.23`.
      +
      +```java
      +import com.netflix.conductor.sdk.testing.WorkflowTestRunner;
      +import com.netflix.conductor.sdk.workflow.executor.WorkflowExecutor;
      +import org.junit.jupiter.api.AfterAll;
      +import org.junit.jupiter.api.BeforeAll;
      +
      +class QuoteWorkflowTest {
      +    private static WorkflowTestRunner testRunner;
      +    private static WorkflowExecutor executor;
      +
      +    @BeforeAll
      +    static void startServer() throws Exception {
      +        testRunner = new WorkflowTestRunner(8096, "3.21.23");
      +        testRunner.init("com.example.workflowtests");
      +        executor = testRunner.getWorkflowExecutor();
      +        executor.loadTaskDefs("/tasks.json");
      +        executor.loadWorkflowDefs("/quote_workflow.json");
      +    }
      +
      +    @AfterAll
      +    static void stopServer() {
      +        testRunner.shutdown();
      +    }
      +}
      +```
      +
      +The package passed to `init` must contain public `@WorkerTask` methods. The JSON files are classpath resources.
      +
      +## Execute and assert
      +
      +```java
      +import static org.junit.jupiter.api.Assertions.assertEquals;
      +import static org.junit.jupiter.api.Assertions.assertNotNull;
      +
      +import java.util.Map;
      +
      +import com.netflix.conductor.common.run.Workflow;
      +
      +Workflow workflow = executor.executeWorkflow(
      +        "quote_workflow", 1, Map.of("name", "personA", "amount", 1_000_000))
      +        .get();
      +
      +assertNotNull(workflow);
      +assertEquals(Workflow.WorkflowStatus.COMPLETED, workflow.getStatus());
      +assertNotNull(workflow.getOutput());
      +```
      +
      +The full maintained example is [WorkflowTestFrameworkTests](https://github.com/conductor-oss/java-sdk/blob/main/conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/testing/WorkflowTestFrameworkTests.java).
      +
      +## What to cover
      +
      +- Missing or invalid inputs produce the expected workflow status.
      +- Retryable workers are invoked again and terminal failures are not retried.
      +- `SWITCH`, dynamic-task, and fork/join paths select the intended branch.
      +- Workflow outputs contain the contract your callers depend on.
      diff --git a/docs/workflow_sdk.md b/docs/workflow_sdk.md
      deleted file mode 100644
      index e5772e496..000000000
      --- a/docs/workflow_sdk.md
      +++ /dev/null
      @@ -1,232 +0,0 @@
      -# Workflow SDK
      -Workflow SDK provides fluent API to create workflows with strongly typed interfaces.
      -
      -## APIs
      -### ConductorWorkflow
      -[ConductorWorkflow](https://github.com/conductor-oss/conductor/blob/main/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/def/ConductorWorkflow.java) is the SDK representation of a Conductor workflow.
      -
      -#### Create a `ConductorWorkflow` Instance
      -```java
      -ConductorWorkflow conductorWorkflow = new WorkflowBuilder(executor)
      -    .name("sdk_workflow_example")
      -    .version(1)
      -    .ownerEmail("hello@example.com")
      -    .description("Example Workflow")
      -    .timeoutPolicy(WorkflowDef.TimeoutPolicy.TIME_OUT_WF, 100)
      -    .add(new SimpleTask("calculate_insurance_premium", "calculate_insurance_premium"))
      -    .add(new SimpleTask("send_email", "send_email"))
      -    .build();
      -```
      -### Working with Simple Worker Tasks
      -Use [SimpleTask](https://github.com/conductor-oss/conductor/blob/main/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/SimpleTask.java) to add a simple task to a workflow.
      -
      -Example:
      -```java
      -...
      -builder.add(new SimpleTask("send_email", "send_email"))
      -...
      -```
      -### Wiring Inputs to Task
      -Use `input` methods to configure the inputs to the task.
      -
      -See our doc on [task inputs](https://conductor.netflix.com/how-tos/Tasks/task-inputs.html) for more details.
      -
      -Example
      -```java
      -builder.add(
      -        new SimpleTask("send_email", "send_email")
      -                .input("email", "${workflow.input.email}")
      -                .input("subject", "Your insurance quote for the amount ${generate_quote.output.amount}")
      -);
      -```
      -
      -### Working with Operators
      -Each operator has its own class that can be added to the workflow builder.
      -
      -* [ForkJoin](https://github.com/conductor-oss/conductor/blob/main/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/ForkJoin.java) 
      -* [Wait](https://github.com/conductor-oss/conductor/blob/main/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/Wait.java)
      -* [Switch](https://github.com/conductor-oss/conductor/blob/main/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/Switch.java)
      -* [DynamicFork](https://github.com/conductor-oss/conductor/blob/main/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/DynamicFork.java)
      -* [DoWhile](https://github.com/conductor-oss/conductor/blob/main/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/DoWhile.java)
      -* [Join](https://github.com/conductor-oss/conductor/blob/main/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/Join.java)
      -* [Dynamic](https://github.com/conductor-oss/conductor/blob/main/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/Dynamic.java)
      -* [Terminate](https://github.com/conductor-oss/conductor/blob/main/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/Terminate.java)
      -* [SubWorkflow](https://github.com/conductor-oss/conductor/blob/main/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/SubWorkflow.java)
      -* [SetVariable](https://github.com/conductor-oss/conductor/blob/main/java-sdk/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks/SetVariable.java)
      -
      -### Working with AI/LLM Tasks
      -
      -The SDK provides fluent builders for AI and LLM operations:
      -
      -#### LLM Text Completion
      -Generate text using large language models:
      -
      -```java
      -LlmTextComplete textTask = new LlmTextComplete("generate_text", "text_ref")
      -    .llmProvider("openai")
      -    .model("gpt-4")
      -    .promptName("my-prompt-template")
      -    .promptVariables(Map.of("topic", "${workflow.input.topic}"))
      -    .temperature(0.7)
      -    .maxTokens(500);
      -
      -workflow.add(textTask);
      -```
      -
      -#### LLM Chat Completion
      -Multi-turn conversations with chat models:
      -
      -```java
      -LlmChatComplete chatTask = new LlmChatComplete("chat", "chat_ref")
      -    .llmProvider("openai")
      -    .model("gpt-4")
      -    .messages("${workflow.input.conversation_history}")
      -    .temperature(0.7)
      -    .maxTokens(1000);
      -
      -// With function calling (tools)
      -chatTask.tools(toolDefinitions)
      -    .toolChoice("auto");
      -
      -workflow.add(chatTask);
      -```
      -
      -#### Document Indexing (for RAG)
      -Index documents into a vector database:
      -
      -```java
      -LlmIndexDocument indexTask = new LlmIndexDocument("index_doc", "index_ref")
      -    .vectorDb("pinecone")
      -    .namespace("my-documents")
      -    .index("knowledge-base")
      -    .embeddingModelProvider("openai")
      -    .embeddingModel("text-embedding-ada-002")
      -    .text("${workflow.input.document_text}")
      -    .docId("${workflow.input.doc_id}")
      -    .metadata(Map.of("source", "user-upload"))
      -    .chunkSize(500)
      -    .chunkOverlap(50);
      -
      -workflow.add(indexTask);
      -```
      -
      -#### Semantic Search
      -Search vector database for relevant documents:
      -
      -```java
      -LlmSearchIndex searchTask = new LlmSearchIndex("search_docs", "search_ref")
      -    .vectorDb("pinecone")
      -    .namespace("my-documents")
      -    .index("knowledge-base")
      -    .embeddingModelProvider("openai")
      -    .embeddingModel("text-embedding-ada-002")
      -    .query("${workflow.input.user_question}")
      -    .topK(5);
      -
      -workflow.add(searchTask);
      -```
      -
      -#### Generate Embeddings
      -Convert text to vector embeddings:
      -
      -```java
      -LlmGenerateEmbeddings embedTask = new LlmGenerateEmbeddings("generate_embeddings", "embed_ref")
      -    .llmProvider("openai")
      -    .model("text-embedding-ada-002")
      -    .text("${workflow.input.text}");
      -
      -workflow.add(embedTask);
      -```
      -
      -#### Complete RAG Pipeline Example
      -
      -```java
      -// 1. Search for relevant documents
      -LlmSearchIndex searchTask = new LlmSearchIndex("search", "search_ref")
      -    .vectorDb("pinecone")
      -    .index("knowledge-base")
      -    .query("${workflow.input.question}")
      -    .topK(5);
      -
      -// 2. Generate answer using retrieved context
      -LlmChatComplete answerTask = new LlmChatComplete("answer", "answer_ref")
      -    .llmProvider("openai")
      -    .model("gpt-4")
      -    .promptName("rag-answer-prompt")
      -    .promptVariables(Map.of(
      -        "question", "${workflow.input.question}",
      -        "context", "${search_ref.output.results}"
      -    ))
      -    .temperature(0.3);
      -
      -workflow.add(searchTask);
      -workflow.add(answerTask);
      -```
      -
      -See [AI & LLM Examples](../examples/src/main/java/io/orkes/conductor/sdk/examples/agentic/) for complete working examples.
      -
      -#### Register Workflow with Conductor Server
      -```java
      -//Returns true if the workflow is successfully created
      -//Reasons why this method will return false
      -//1. Network connectivity issue
      -//2. Workflow already exists with the specified name and version 
      -//3. There are missing task definitions
      -boolean registered = workflow.registerWorkflow();
      -```
      -#### Overwrite Existing Workflow Definition​
      -```java
      -boolean registered = workflow.registerWorkflow(true);
      -```
      -
      -#### Overwrite existing workflow definitions & registering any missing task definitions
      -```java
      -boolean registered = workflow.registerWorkflow(true, true);
      -```
      -
      -#### Create `ConductorWorkflow` based on the definition registered on the server
      -
      -```java
      -ConductorWorkflow conductorWorkflow = 
      -                        new ConductorWorkflow(executor)
      -                        .from("sdk_workflow_example", 1);
      -```
      -
      -#### Start Workflow Execution
      -Start the execution of the workflow based on the definition registered on the server. Use the register method to register a workflow on the server before executing.
      -
      -```java
      -
      -//Returns a completable future
      -CompletableFuture execution = conductorWorkflow.execute(input);
      -
      -//Wait for the workflow to complete -- useful if workflow completes within a reasonable amount of time
      -Workflow workflowRun = execution.get();
      -
      -//Get the workflowId
      -String workflowId = workflowRun.getWorkflowId();
      -
      -//Get the status of workflow execution
      -WorkflowStatus status = workflowRun.getStatus();
      -```
      -See [Workflow](https://github.com/conductor-oss/conductor/blob/main/common/src/main/java/com/netflix/conductor/common/run/Workflow.java) for more details on the Workflow object.
      -
      -#### Start Dynamic Workflow Execution
      -Dynamic workflows are executed by specifying the workflow definition along with the execution and do not require registering the workflow on the server before executing.
      -
      -##### Use cases for dynamic workflows
      -1. Each workflow run has a unique workflow definition 
      -2. Workflows are defined based on the user data and cannot be modeled ahead of time statically 
      -
      -```java
      -//1. Use WorkflowBuilder to create ConductorWorkflow.
      -//2. Execute using the definition created by SDK.
      -CompletableFuture execution = conductorWorkflow.executeDynamic(input);
      -
      -```
      -
      -
      -
      -
      -
      -
      diff --git a/docs/workflows.md b/docs/workflows.md
      new file mode 100644
      index 000000000..3764258cd
      --- /dev/null
      +++ b/docs/workflows.md
      @@ -0,0 +1,97 @@
      +# Workflows
      +
      +The fluent Workflow SDK creates typed Conductor workflow definitions in Java. Start with the maintained [workflow and worker Hello World](../examples/basics/hello-world/README.md); it registers a task definition, starts a worker, runs a workflow, and prints `Result: PASSED`.
      +
      +## Prerequisites
      +
      +- Java 21+
      +- A running Conductor server ([start one locally with the CLI](server-setup.md), or use an existing server)
      +- `org.conductoross:conductor-client:` from [Maven Central](https://search.maven.org/search?q=g:org.conductoross)
      +
      +`SIMPLE` tasks require both a registered task definition and a worker polling the exact task name. The Hello World example is the shortest complete reference for that lifecycle.
      +
      +## Build a workflow
      +
      +**Fragment — create this after constructing a `WorkflowExecutor` for your server.**
      +
      +```java
      +import com.netflix.conductor.common.metadata.workflow.WorkflowDef;
      +import com.netflix.conductor.sdk.workflow.def.ConductorWorkflow;
      +import com.netflix.conductor.sdk.workflow.def.WorkflowBuilder;
      +import com.netflix.conductor.sdk.workflow.def.tasks.SimpleTask;
      +import com.netflix.conductor.sdk.workflow.executor.WorkflowExecutor;
      +
      +WorkflowExecutor executor = new WorkflowExecutor("http://localhost:8080/api");
      +
      +ConductorWorkflow> workflow =
      +        new WorkflowBuilder>(executor)
      +                .name("quote_workflow")
      +                .version(1)
      +                .description("Calculate and email an insurance quote")
      +                .ownerEmail("team@example.com")
      +                .timeoutPolicy(WorkflowDef.TimeoutPolicy.TIME_OUT_WF, 300)
      +                .add(
      +                        new SimpleTask("calculate_quote", "calculate_quote"),
      +                        new SimpleTask("send_email", "send_email")
      +                                .input("email", "${workflow.input.email}")
      +                                .input("subject", "Your quote is ${calculate_quote.output.amount}"))
      +                .build();
      +```
      +
      +`ConductorWorkflow` can register a reusable definition, execute an already registered definition, or submit a one-off dynamic definition:
      +
      +```java
      +workflow.registerWorkflow(true, true);             // overwrite and register missing task definitions
      +workflow.execute(java.util.Map.of("email", "user@example.com"));
      +workflow.executeDynamic(java.util.Map.of("email", "user@example.com"));
      +```
      +
      +Use dynamic execution only when each run truly needs a distinct graph; registered definitions are easier to inspect, reuse, and evolve.
      +
      +## System tasks
      +
      +The builder accepts all task classes in `com.netflix.conductor.sdk.workflow.def.tasks`, including `ForkJoin`, `Wait`, `Switch`, `DynamicFork`, `DoWhile`, `Join`, `Dynamic`, `Terminate`, `SubWorkflow`, and `SetVariable`.
      +
      +Read the current source when you need a task-specific builder: [workflow task classes](https://github.com/conductor-oss/java-sdk/tree/main/conductor-client/src/main/java/com/netflix/conductor/sdk/workflow/def/tasks).
      +
      +## AI and vector tasks
      +
      +The fluent SDK exposes current Conductor system-task types. Provider and vector database integrations are configured on the server.
      +
      +**Fragment — index text:**
      +
      +```java
      +import org.conductoross.conductor.sdk.ai.LlmIndexText;
      +
      +LlmIndexText indexText = new LlmIndexText("index_doc", "index_ref")
      +        .vectorDb("knowledge_base")
      +        .namespace("documents")
      +        .index("articles")
      +        .embeddingModelProvider("openai")
      +        .embeddingModel("text-embedding-3-small")
      +        .text("${workflow.input.document}")
      +        .docId("${workflow.input.documentId}");
      +```
      +
      +**Fragment — search the index:**
      +
      +```java
      +import org.conductoross.conductor.sdk.ai.LlmSearchIndex;
      +
      +LlmSearchIndex search = new LlmSearchIndex("search_docs", "search_ref")
      +        .vectorDb("knowledge_base")
      +        .namespace("documents")
      +        .index("articles")
      +        .embeddingModelProvider("openai")
      +        .embeddingModel("text-embedding-3-small")
      +        .query("${workflow.input.question}")
      +        .maxResults(5);
      +```
      +
      +Add either task with `.add(indexText)` or `.add(search)`. The exact embedding model must match the model used to index the data.
      +
      +## Related guides
      +
      +- [Workers](workers.md) — implement and run `SIMPLE` tasks.
      +- [Workflow test harness](workflow-testing.md) — test definitions and workers against a local server.
      +- [Agent SDK](agents/README.md) — build durable LLM agents, tools, and dynamic plans.
      diff --git a/examples/README.md b/examples/README.md
      index d7e508884..ab0799656 100644
      --- a/examples/README.md
      +++ b/examples/README.md
      @@ -5,8 +5,9 @@
       ## Quick Start
       
       ```bash
      -# Start Conductor
      -docker run -d -p 8080:8080 -p 1234:5000 conductoross/conductor:latest
      +# Recommended: install the CLI once, then start Conductor locally.
      +npm install -g @conductor-oss/conductor-cli
      +conductor server start
       
       # Pick any example and run it
       cd examples/basics/hello-world
      @@ -18,7 +19,9 @@ mvn package -DskipTests && java -jar target/hello-world-1.0.0.jar
       
       - Java 21+
       - Maven 3.8+
      -- Conductor server (local Docker or [Orkes Cloud](https://orkes.io))
      +- Conductor server ([local CLI](../docs/server-setup.md), optional Docker, or [Orkes Cloud](https://orkes.io))
      +
      +Use an AI coding agent? Load [Conductor Skills](https://github.com/conductor-oss/conductor-skills) before asking it to work with these examples: `npm install -g @conductor-oss/conductor-skills && conductor-skills --all`.
       
       ## Machine-Readable Metadata
       
      diff --git a/examples/RUNNING.md b/examples/RUNNING.md
      index 483d9abf0..d7de89748 100644
      --- a/examples/RUNNING.md
      +++ b/examples/RUNNING.md
      @@ -6,9 +6,24 @@ Every example in this repository follows the same structure and can be run the s
       
       - **Java 21+**: verify with `java -version`
       - **Maven 3.8+**: verify with `mvn -version`
      -- **Docker**: to run Conductor
      +- **Node.js and npm**: for the recommended local Conductor CLI
       
      -## Option 1: Docker Compose
      +## Option 1: Conductor CLI (recommended)
      +
      +Install the [Conductor CLI](https://github.com/conductor-oss/conductor-cli), start the server, and then run an example:
      +
      +```bash
      +npm install -g @conductor-oss/conductor-cli
      +conductor server start
      +
      +cd examples//
      +mvn package -DskipTests
      +java -jar target/-1.0.0.jar
      +```
      +
      +Stop the server when finished with `conductor server stop`.
      +
      +## Option 2: Docker Compose
       
       Each example includes a `docker-compose.yml` that starts both Conductor and the example:
       
      @@ -19,7 +34,7 @@ docker compose up --build
       
       Conductor UI will be available at `http://localhost:1234`.
       
      -## Option 2: Run Locally
      +## Option 3: Docker container
       
       Start Conductor:
       
      @@ -35,7 +50,7 @@ mvn package -DskipTests
       java -jar target/-1.0.0.jar
       ```
       
      -## Option 3: Launcher Script
      +## Option 4: Launcher Script
       
       ```bash
       cd examples//
      @@ -69,6 +84,15 @@ Check status:
       conductor workflow get-execution 
       ```
       
      +## AI coding agents
      +
      +Install [Conductor Skills](https://github.com/conductor-oss/conductor-skills) to give a supported coding agent Conductor workflow and operations guidance:
      +
      +```bash
      +npm install -g @conductor-oss/conductor-skills
      +conductor-skills --all
      +```
      +
       ## Configuration
       
       | Environment Variable | Default | Description |
      diff --git a/examples/ai/README.md b/examples/ai/README.md
      index bf24a38f8..90508bd6e 100644
      --- a/examples/ai/README.md
      +++ b/examples/ai/README.md
      @@ -10,6 +10,8 @@ These examples demonstrate LLM integration patterns with Conductor workflows: pr
        conductor server start
        ```
       
      +   For a containerized server instead, use the optional Docker setup in [Running Conductor Examples](../RUNNING.md#option-2-docker-compose).
      +
       2. **Java 21+** and **Maven 3.8+**
       
       3. **LLM API key** (see table below). `first-ai-workflow` runs in **demo mode by default** (no API key needed). Other examples require an API key, set the appropriate environment variable before running.
      @@ -49,6 +51,8 @@ CONDUCTOR_OPENAI_API_KEY=sk-... java -jar target/first-ai-workflow-1.0.0.jar
       
       All examples print `Result: PASSED` on success, whether in demo or live mode.
       
      +Use an AI coding agent? Load [Conductor Skills](https://github.com/conductor-oss/conductor-skills) before working with these examples: `npm install -g @conductor-oss/conductor-skills && conductor-skills --all`.
      +
       ---
       
      -> **How to run this example:** See [RUNNING.md](../RUNNING.md) for prerequisites, build commands, Docker setup, and CLI usage.
      +> **How to run this example:** See [RUNNING.md](../RUNNING.md) for CLI-first setup, build commands, and optional Docker setup.
      diff --git a/tools/agent-schema/requirements.txt b/tools/agent-schema/requirements.txt
      new file mode 100644
      index 000000000..12aab9370
      --- /dev/null
      +++ b/tools/agent-schema/requirements.txt
      @@ -0,0 +1 @@
      +jsonschema==4.25.1
      diff --git a/tools/agent-schema/verify.py b/tools/agent-schema/verify.py
      new file mode 100644
      index 000000000..03a6acb72
      --- /dev/null
      +++ b/tools/agent-schema/verify.py
      @@ -0,0 +1,130 @@
      +#!/usr/bin/env python3
      +"""Verify the documented agent configuration schema without creating tracked outputs."""
      +
      +from __future__ import annotations
      +
      +import dataclasses
      +import importlib.util
      +import json
      +import subprocess
      +import sys
      +import tempfile
      +from pathlib import Path
      +from typing import Any
      +
      +from jsonschema import Draft202012Validator
      +
      +
      +ROOT = Path(__file__).resolve().parents[2]
      +SCHEMA_PATH = ROOT / "docs" / "agents" / "reference" / "agent-schema.json"
      +ROOT_CLASS = "AgentConfig"
      +PY_TYPES = {"string": "str", "integer": "int", "number": "float", "boolean": "bool", "object": "dict[str, Any]"}
      +JAVA_TYPES = {"string": "String", "integer": "Integer", "number": "Double", "boolean": "Boolean", "object": "Map"}
      +
      +
      +def class_name(name: str) -> str:
      +    return ROOT_CLASS if name == "#" else name.rsplit("/", 1)[-1][:1].upper() + name.rsplit("/", 1)[-1][1:]
      +
      +
      +def schema_type(node: Any, scalar, array) -> str:
      +    if not isinstance(node, dict):
      +        return scalar(None)
      +    if "$ref" in node:
      +        return class_name(node["$ref"])
      +    if "oneOf" in node or "anyOf" in node:
      +        return scalar(None)
      +    kind = node.get("type")
      +    if isinstance(kind, list):
      +        kind = next((item for item in kind if item != "null"), None)
      +    if kind == "array":
      +        return array(schema_type(node.get("items", {}), scalar, array))
      +    return scalar(kind)
      +
      +
      +def python_type(node: Any) -> str:
      +    return schema_type(node, lambda kind: PY_TYPES.get(kind, "Any"), lambda item: f"list[{item}]")
      +
      +
      +def java_type(node: Any) -> str:
      +    return schema_type(node, lambda kind: JAVA_TYPES.get(kind, "Object"), lambda item: f"List<{item}>")
      +
      +
      +def models(schema: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
      +    return [(ROOT_CLASS, schema)] + [(class_name(name), node) for name, node in schema.get("$defs", {}).items()]
      +
      +
      +def write_python(path: Path, schema: dict[str, Any]) -> None:
      +    parts = [
      +        "from __future__ import annotations",
      +        "from dataclasses import dataclass",
      +        "from typing import Any, Optional",
      +        "",
      +    ]
      +    for name, node in models(schema):
      +        properties = node.get("properties", {})
      +        parts.append("@dataclass")
      +        parts.append(f"class {name}:")
      +        if properties:
      +            parts.extend(f"    {field}: Optional[{python_type(value)}] = None" for field, value in properties.items())
      +        else:
      +            parts.append("    pass")
      +        parts.append("")
      +    path.write_text("\n".join(parts), encoding="utf-8")
      +
      +
      +def write_java(path: Path, schema: dict[str, Any]) -> None:
      +    records = []
      +    for name, node in models(schema):
      +        properties = node.get("properties", {})
      +        components = ", ".join(f"{java_type(value)} {field}" for field, value in properties.items())
      +        records.append(f"    public record {name}({components}) {{}}")
      +    path.write_text(
      +        "import java.util.List;\nimport java.util.Map;\n\n"
      +        "public final class AgentConfigModel {\n"
      +        "    private AgentConfigModel() {}\n\n"
      +        + "\n".join(records)
      +        + "\n}\n",
      +        encoding="utf-8",
      +    )
      +
      +
      +def check_generated_fields(module: Any, schema: dict[str, Any]) -> None:
      +    for name, node in models(schema):
      +        actual = {field.name for field in dataclasses.fields(getattr(module, name))}
      +        expected = set(node.get("properties", {}))
      +        if actual != expected:
      +            raise AssertionError(f"{name} fields differ from schema: {actual ^ expected}")
      +
      +
      +def main() -> None:
      +    schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
      +    Draft202012Validator.check_schema(schema)
      +    validator = Draft202012Validator(schema)
      +
      +    valid = {"name": "schema-verifier", "model": "openai/gpt-4o-mini", "maxTurns": 3}
      +    validator.validate(valid)
      +    invalid_errors = list(validator.iter_errors({"name": "schema-verifier", "unknown": True}))
      +    if not invalid_errors:
      +        raise AssertionError("schema must reject unknown top-level properties")
      +
      +    with tempfile.TemporaryDirectory(prefix="agent-schema-") as temporary_directory:
      +        temporary = Path(temporary_directory)
      +        python_path = temporary / "agent_config.py"
      +        java_path = temporary / "AgentConfigModel.java"
      +        write_python(python_path, schema)
      +        write_java(java_path, schema)
      +
      +        spec = importlib.util.spec_from_file_location("agent_config", python_path)
      +        if spec is None or spec.loader is None:
      +            raise AssertionError("could not load generated Python model")
      +        module = importlib.util.module_from_spec(spec)
      +        sys.modules[spec.name] = module
      +        spec.loader.exec_module(module)
      +        check_generated_fields(module, schema)
      +        subprocess.run(["javac", "--release", "21", "-d", str(temporary / "classes"), str(java_path)], check=True)
      +
      +    print("agent schema: Draft 2020-12, generated field mapping, examples, and Java 21 compilation verified")
      +
      +
      +if __name__ == "__main__":
      +    main()
      
      From 7623303272f8120a09256b2d64a82f77f2a32b1c Mon Sep 17 00:00:00 2001
      From: Viren Baraiya 
      Date: Sun, 19 Jul 2026 23:07:43 -0700
      Subject: [PATCH 11/14] fixes and e2e
      
      ---
       .github/workflows/agent-e2e.yml               |  3 ++
       .../examples/Example17SwarmOrchestration.java |  2 +-
       .../ai/examples/Example58ScatterGather.java   | 30 ++++++++++--
       .../client/http/OrkesAuthentication.java      | 48 +++++++++++++++++--
       .../client/http/TokenRefreshTest.java         | 47 ++++++++++++++++++
       e2e/src/test/java/Suite4McpTools.java         |  6 +--
       6 files changed, 124 insertions(+), 12 deletions(-)
      
      diff --git a/.github/workflows/agent-e2e.yml b/.github/workflows/agent-e2e.yml
      index 532001e84..6f38c9dac 100644
      --- a/.github/workflows/agent-e2e.yml
      +++ b/.github/workflows/agent-e2e.yml
      @@ -25,6 +25,9 @@ env:
       
       jobs:
         agent-e2e:
      +    # Temporarily disabled until a Conductor server release containing the
      +    # required agent-runtime changes is published.
      +    if: ${{ false }}
           runs-on: ubuntu-latest
           timeout-minutes: 45
           env:
      diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example17SwarmOrchestration.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example17SwarmOrchestration.java
      index 9041b9454..f64fad20e 100644
      --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example17SwarmOrchestration.java
      +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example17SwarmOrchestration.java
      @@ -77,7 +77,7 @@ public static void main(String[] args) {
       
               System.out.println("=== Refund Scenario ===");
               AgentResult refundResult = runtime.run(support,
      -            "I bought a product last week and it arrived damaged. I want my money back.");
      +            "I bought a product last week and it arrived damaged. I want my money back. my order number is O123 and product SKU is S124");
               refundResult.printResult();
       
               System.out.println("\n=== Technical Issue Scenario ===");
      diff --git a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example58ScatterGather.java b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example58ScatterGather.java
      index 2c9a616b2..6d618ee9d 100644
      --- a/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example58ScatterGather.java
      +++ b/agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example58ScatterGather.java
      @@ -12,6 +12,7 @@
        */
       package org.conductoross.conductor.ai.examples;
       
      +import java.util.Arrays;
       import java.util.LinkedHashMap;
       import java.util.List;
       import java.util.Map;
      @@ -39,6 +40,29 @@
        */
       public class Example58ScatterGather {
       
      +    private static final String[] countries = new String[]{
      +            "Afghanistan", "Albania", "Algeria", "Andorra", "Angola",
      +            "Argentina", "Armenia", "Australia", "Austria", "Azerbaijan",
      +            "Bahamas", "Bahrain", "Bangladesh", "Barbados", "Belarus",
      +            "Belgium", "Belize", "Benin", "Bhutan", "Bolivia",
      +            "Bosnia and Herzegovina", "Botswana", "Brazil", "Brunei", "Bulgaria",
      +            "Burkina Faso", "Burundi", "Cambodia", "Cameroon", "Canada",
      +            "Chad", "Chile", "China", "Colombia", "Congo",
      +            "Costa Rica", "Croatia", "Cuba", "Cyprus", "Czech Republic",
      +            "Denmark", "Djibouti", "Dominican Republic", "Ecuador", "Egypt",
      +            "El Salvador", "Estonia", "Ethiopia", "Fiji", "Finland",
      +            "France", "Gabon", "Georgia", "Germany", "Ghana",
      +            "Greece", "Guatemala", "Guinea", "Haiti", "Honduras",
      +            "Hungary", "Iceland", "India", "Indonesia", "Iran",
      +            "Iraq", "Ireland", "Israel", "Italy", "Jamaica",
      +            "Japan", "Jordan", "Kazakhstan", "Kenya", "Kuwait",
      +            "Laos", "Latvia", "Lebanon", "Libya", "Lithuania",
      +            "Luxembourg", "Madagascar", "Malaysia", "Mali", "Malta",
      +            "Mexico", "Mongolia", "Morocco", "Mozambique", "Myanmar",
      +            "Nepal", "Netherlands", "New Zealand", "Nigeria", "North Korea",
      +            "Norway", "Oman", "Pakistan", "Panama", "Paraguay",
      +            };
      +
           static class ResearchTools {
               @Tool(name = "search_knowledge_base",
                     description = "Search the knowledge base for information on a topic")
      @@ -63,7 +87,7 @@ public static void main(String[] args) {
       
               Agent researcher = Agent.builder()
                   .name("researcher")
      -            .model("anthropic/claude-sonnet-4-6")
      +            .model("anthropic/claude-sonnet-5")
                   .instructions(
                       "You are a country analyst. You will be given the name of a country. "
                       + "Use the search_knowledge_base tool ONCE to research that country, then "
      @@ -96,7 +120,7 @@ public static void main(String[] args) {
               String workerName = researcher.getName();
               String instructions =
                   "You are a scatter-gather coordinator. Your job is to:\n"
      -            + "1. Decompose the input into N independent sub-problems\n"
      +            + "1. Decompose the input into independent sub-problems ONE for each input\n"
                   + "2. Call the '" + workerName + "' tool MULTIPLE TIMES IN PARALLEL — once per sub-problem, "
                   + "each with a clear, self-contained prompt\n"
                   + "3. After all results return, synthesize them into a unified answer\n\n"
      @@ -114,7 +138,7 @@ public static void main(String[] args) {
                   .build();
       
               AgentResult result = runtime.run(coordinator,
      -            "Create a comprehensive profile for each of the 100 countries listed.");
      +            "Create a comprehensive profile for each of the 100 countries listed. " + Arrays.toString(countries));
               result.printResult();
       
               runtime.shutdown();
      diff --git a/conductor-client/src/main/java/io/orkes/conductor/client/http/OrkesAuthentication.java b/conductor-client/src/main/java/io/orkes/conductor/client/http/OrkesAuthentication.java
      index 27f9182f6..d9e7acf13 100644
      --- a/conductor-client/src/main/java/io/orkes/conductor/client/http/OrkesAuthentication.java
      +++ b/conductor-client/src/main/java/io/orkes/conductor/client/http/OrkesAuthentication.java
      @@ -48,6 +48,7 @@ public class OrkesAuthentication implements HeaderSupplier {
           // Guarded by refreshLock.
           private int tokenRefreshFailures = 0;
           private long lastTokenRefreshAttempt = 0;
      +    private volatile boolean authenticationDisabled;
       
           private TokenResource tokenResource;
       
      @@ -95,18 +96,28 @@ public void init(ConductorClient client) {
       
           @Override
           public Map get(String method, String path) {
      -        if ("/token".equalsIgnoreCase(path)) {
      +        if (authenticationDisabled || "/token".equalsIgnoreCase(path)) {
                   return Map.of();
               }
       
      -        return Map.of("X-Authorization", getToken());
      +        String token = getToken();
      +        return token == null ? Map.of() : Map.of("X-Authorization", token);
           }
       
           public String getToken() {
      +        if (authenticationDisabled) {
      +            return null;
      +        }
      +
               try {
                   return tokenCache.get(TOKEN_CACHE_KEY, this::refreshToken);
               } catch (ExecutionException e) {
                   return null;
      +        } catch (RuntimeException e) {
      +            if (authenticationDisabled) {
      +                return null;
      +            }
      +            throw e;
               }
           }
       
      @@ -120,13 +131,24 @@ public String getToken() {
            */
           public String refreshIfStale(String staleToken) {
               synchronized (refreshLock) {
      +            if (authenticationDisabled) {
      +                return null;
      +            }
      +
                   String current = tokenCache.getIfPresent(TOKEN_CACHE_KEY);
                   if (current != null && !current.equals(staleToken)) {
                       return current; // another thread already rotated it
                   }
      -            String fresh = refreshToken();
      -            tokenCache.put(TOKEN_CACHE_KEY, fresh); // resets TTL and unblocks others
      -            return fresh;
      +            try {
      +                String fresh = refreshToken();
      +                tokenCache.put(TOKEN_CACHE_KEY, fresh); // resets TTL and unblocks others
      +                return fresh;
      +            } catch (RuntimeException e) {
      +                if (authenticationDisabled) {
      +                    return null;
      +                }
      +                throw e;
      +            }
               }
           }
       
      @@ -171,6 +193,12 @@ private String refreshToken() {
                       tokenRefreshFailures = 0;
                       return response.getToken();
                   } catch (RuntimeException e) {
      +                if (isUnsupportedTokenEndpoint(e)) {
      +                    authenticationDisabled = true;
      +                    tokenRefreshFailures = 0;
      +                    LOGGER.warn("Token endpoint is unavailable (404); disabling key/secret authentication for this client");
      +                    throw e;
      +                }
                       tokenRefreshFailures++;
                       LOGGER.error("Failed to refresh authentication token (attempt {}): {}",
                               tokenRefreshFailures, e.getMessage());
      @@ -178,4 +206,14 @@ private String refreshToken() {
                   }
               }
           }
      +
      +    private boolean isUnsupportedTokenEndpoint(Throwable error) {
      +        for (Throwable cause = error; cause != null; cause = cause.getCause()) {
      +            if (cause instanceof ConductorClientException
      +                    && ((ConductorClientException) cause).getStatus() == 404) {
      +                return true;
      +            }
      +        }
      +        return false;
      +    }
       }
      diff --git a/conductor-client/src/test/java/io/orkes/conductor/client/http/TokenRefreshTest.java b/conductor-client/src/test/java/io/orkes/conductor/client/http/TokenRefreshTest.java
      index 7d0c15154..61fd08655 100644
      --- a/conductor-client/src/test/java/io/orkes/conductor/client/http/TokenRefreshTest.java
      +++ b/conductor-client/src/test/java/io/orkes/conductor/client/http/TokenRefreshTest.java
      @@ -36,6 +36,7 @@
       import okhttp3.mockwebserver.RecordedRequest;
       
       import static org.junit.jupiter.api.Assertions.assertEquals;
      +import static org.junit.jupiter.api.Assertions.assertNull;
       import static org.junit.jupiter.api.Assertions.assertThrows;
       import static org.junit.jupiter.api.Assertions.assertTrue;
       
      @@ -199,6 +200,34 @@ public MockResponse dispatch(@NotNull RecordedRequest request) {
               }
           }
       
      +    /**
      +     * Models an OSS/no-auth server where the Orkes token-mint endpoint is not
      +     * available, but regular API endpoints work without credentials.
      +     */
      +    private static class NoAuthServerDispatcher extends Dispatcher {
      +        final AtomicInteger tokenAttempts = new AtomicInteger(0);
      +        final AtomicReference businessAuthorizationHeader = new AtomicReference<>();
      +
      +        @NotNull
      +        @Override
      +        public MockResponse dispatch(@NotNull RecordedRequest request) {
      +            String path = request.getPath() == null ? "" : request.getPath();
      +            if (path.endsWith("/token") && "POST".equals(request.getMethod())) {
      +                tokenAttempts.incrementAndGet();
      +                return new MockResponse()
      +                        .setResponseCode(404)
      +                        .setHeader("Content-Type", "application/json")
      +                        .setBody("{\"message\":\"No static resource api/token.\"}");
      +            }
      +
      +            businessAuthorizationHeader.set(request.getHeader(AUTH_HEADER));
      +            return new MockResponse()
      +                    .setResponseCode(200)
      +                    .setHeader("Content-Type", "text/plain")
      +                    .setBody("ok");
      +        }
      +    }
      +
           @Test
           public void refreshesAndContinuesAfterExpiry() throws IOException {
               server = new MockWebServer();
      @@ -336,6 +365,24 @@ public void emptyBodyBubblesUpWithoutRefresh() throws IOException {
                       "401 with an empty body must bubble up without minting a new token");
           }
       
      +    @Test
      +    public void disablesAuthenticationWhenTokenEndpointIsNotFound() throws IOException {
      +        server = new MockWebServer();
      +        NoAuthServerDispatcher dispatcher = new NoAuthServerDispatcher();
      +        server.setDispatcher(dispatcher);
      +        server.start();
      +
      +        ApiClient client = buildClient();
      +
      +        ConductorClientResponse response = callBusinessEndpoint(client);
      +
      +        assertEquals(200, response.getStatusCode());
      +        assertEquals("ok", response.getData());
      +        assertEquals(1, dispatcher.tokenAttempts.get(), "the unsupported token endpoint must be probed once");
      +        assertNull(dispatcher.businessAuthorizationHeader.get(),
      +                "fallback requests must not include X-Authorization");
      +    }
      +
           @Test
           public void backoffThrottlesRepeatedTokenMintFailures() throws IOException {
               server = new MockWebServer();
      diff --git a/e2e/src/test/java/Suite4McpTools.java b/e2e/src/test/java/Suite4McpTools.java
      index 86286b133..d296728db 100644
      --- a/e2e/src/test/java/Suite4McpTools.java
      +++ b/e2e/src/test/java/Suite4McpTools.java
      @@ -182,11 +182,11 @@ void test_mcp_tool_serializes_to_plan() {
                               + ". COUNTERFACTUAL: if serializer drops it, server won't treat it as MCP.");
       
               Map config = (Map) tool.get("config");
      -        assertNotNull(config, "MCP tool plan must carry config (serverUrl, headers). Got null config.");
      +        assertNotNull(config, "MCP tool plan must carry config (server_url, headers). Got null config.");
               assertEquals(
                       "http://localhost:9999/mcp",
      -                config.get("serverUrl"),
      -                "config.serverUrl should round-trip. Got: " + config.get("serverUrl"));
      +                config.get("server_url"),
      +                "config.server_url should round-trip. Got: " + config.get("server_url"));
       
               Map headers = (Map) config.get("headers");
               assertNotNull(headers, "config.headers should be present.");
      
      From c2b08b24a8bdeea92706f408ab2454f97893b3d9 Mon Sep 17 00:00:00 2001
      From: Viren Baraiya 
      Date: Sun, 19 Jul 2026 23:28:22 -0700
      Subject: [PATCH 12/14] Update README.md
      
      ---
       README.md | 6 +++++-
       1 file changed, 5 insertions(+), 1 deletion(-)
      
      diff --git a/README.md b/README.md
      index 6604a01c5..37d5d2726 100644
      --- a/README.md
      +++ b/README.md
      @@ -9,7 +9,11 @@ The Java SDK for [Conductor](https://www.conductor-oss.org/) lets you build dura
       
       **Get involved:** [⭐ Conductor OSS](https://github.com/conductor-oss/conductor) · [Choose a Conductor OSS contribution](https://github.com/conductor-oss/conductor/contribute) · [Contribution guide](https://github.com/conductor-oss/conductor/blob/main/CONTRIBUTING.md)
       
      -**Using an AI coding agent?** Load [Conductor Skills](https://github.com/conductor-oss/conductor-skills) so it can create, run, and operate Conductor workflows: `npm install -g @conductor-oss/conductor-skills && conductor-skills --all`.
      +**Using an AI coding agent?** Load [Conductor Skills](https://github.com/conductor-oss/conductor-skills) so it can create, run, and operate Conductor workflows:
      +
      +```shell
      +npm install -g @conductor-oss/conductor-skills && conductor-skills --all
      +```
       
       ## Choose your path
       
      
      From 9b2f9766a5b633d7e697a0f557fcb27f226a29df Mon Sep 17 00:00:00 2001
      From: Viren Baraiya 
      Date: Sun, 19 Jul 2026 23:56:40 -0700
      Subject: [PATCH 13/14] documentation updates
      
      ---
       README.md                                     |  9 +--
       conductor-client-spring-boot4/README.md       | 56 ++++++++++++-----
       conductor-client-spring/README.md             | 62 ++++++++++---------
       .../testing/WorkflowTestFrameworkTests.java   | 24 +++----
       docs/README.md                                | 53 +++++++++++-----
       docs/agents/reference/client.md               | 43 ++++++++++++-
       docs/api-map.md                               | 21 +++++++
       docs/compatibility.md                         | 27 ++++++++
       docs/connection-authentication.md             | 38 ++++++++++++
       docs/core-quickstart.md                       | 53 ++++++++++++++++
       docs/debugging.md                             | 22 +++++++
       docs/deployment-scaling.md                    | 25 ++++++++
       docs/documentation-standard.md                | 11 ++++
       docs/examples.md                              | 12 ++++
       docs/observability.md                         | 21 +++++++
       docs/reliability.md                           | 24 +++++++
       docs/schedules-events.md                      | 36 +++++++++++
       docs/security.md                              | 25 ++++++++
       docs/spring-boot.md                           | 14 +++++
       docs/upgrading.md                             | 23 +++++++
       docs/workflow-lifecycle.md                    | 34 ++++++++++
       docs/workflow-testing.md                      |  4 +-
       22 files changed, 554 insertions(+), 83 deletions(-)
       create mode 100644 docs/api-map.md
       create mode 100644 docs/compatibility.md
       create mode 100644 docs/connection-authentication.md
       create mode 100644 docs/core-quickstart.md
       create mode 100644 docs/debugging.md
       create mode 100644 docs/deployment-scaling.md
       create mode 100644 docs/documentation-standard.md
       create mode 100644 docs/examples.md
       create mode 100644 docs/observability.md
       create mode 100644 docs/reliability.md
       create mode 100644 docs/schedules-events.md
       create mode 100644 docs/security.md
       create mode 100644 docs/spring-boot.md
       create mode 100644 docs/upgrading.md
       create mode 100644 docs/workflow-lifecycle.md
      
      diff --git a/README.md b/README.md
      index 37d5d2726..ad846f382 100644
      --- a/README.md
      +++ b/README.md
      @@ -164,13 +164,14 @@ For any existing server, set `CONDUCTOR_SERVER_URL` explicitly before running th
       | Add tools and human approval | [Agent tools](docs/agents/concepts/tools.md) |
       | Use another agent framework | [Google ADK](docs/agents/frameworks/google-adk.md) · [LangChain4j](docs/agents/frameworks/langchain4j.md) · [LangGraph4j](docs/agents/frameworks/langgraph4j.md) |
       | Deploy, serve, and run agents | [Agent runtime modes](docs/agents/concepts/deploy-serve-run.md) |
      -| Implement and scale Java workers | [Workers guide](docs/workers.md) |
      -| Define workflows in Java | [Workflows guide](docs/workflows.md) |
      +| Implement and scale Java workers | [Workers guide](docs/workers.md) · [reliability](docs/reliability.md) |
      +| Define and evolve workflows | [Workflows guide](docs/workflows.md) · [lifecycle/versioning](docs/workflow-lifecycle.md) |
       | Upload/download workflow-scoped files | [FileClient guide](docs/file-client.md) |
       | Test workflows and workers | [Workflow test harness](docs/workflow-testing.md) |
       | Expose worker metrics | [Client metrics](conductor-client-metrics/README.md) |
      -| Configure Spring applications | [Core Spring integration](conductor-client-spring/README.md) · [AI Spring guide](docs/agents/spring-boot.md) |
      -| Manage schedules | [Typed `SchedulerClient`](conductor-client/src/main/java/io/orkes/conductor/client/SchedulerClient.java) |
      +| Configure Spring applications | [Boot 3](conductor-client-spring/README.md) · [Boot 4](conductor-client-spring-boot4/README.md) · [AI Spring guide](docs/agents/spring-boot.md) |
      +| Manage schedules and events | [Schedules/events guide](docs/schedules-events.md) |
      +| Find typed clients and Javadocs | [Core API map](docs/api-map.md) |
       
       ## Troubleshooting
       
      diff --git a/conductor-client-spring-boot4/README.md b/conductor-client-spring-boot4/README.md
      index f42e01a60..7ecce3727 100644
      --- a/conductor-client-spring-boot4/README.md
      +++ b/conductor-client-spring-boot4/README.md
      @@ -1,28 +1,54 @@
      -# Conductor Client Spring (Spring Boot 4)
      +# Conductor Client Spring for Spring Boot 4
       
      -Provides Spring Boot 4 / Spring Framework 7 auto-configurations for the Conductor client and SDK.
      +Spring Boot 4 / Spring Framework 7 auto-configuration for the Conductor core client, workflow SDK, and Java workers.
       
      -For Spring Boot 3 consumers, use `org.conductoross:conductor-client-spring` instead.
      +**Prerequisites:** Java 21+, Spring Boot 4, and a running [OSS or Orkes Conductor server](../docs/connection-authentication.md). For Spring Boot 3, use [the Boot 3 module](../conductor-client-spring/README.md).
       
      -## Getting Started
      +## Install
       
      -### Prerequisites
      -- Java 21 or higher
      -- A Spring Boot 4 project
      -- A running Conductor server ([start one locally with the CLI](../docs/server-setup.md), or use a remote server)
      -
      -### Usage
      -
      -Add the dependency:
      +Use the published version from [Maven Central](https://search.maven.org/artifact/org.conductoross/conductor-client-spring-boot4).
       
       ```groovy
      -implementation 'org.conductoross:conductor-client-spring-boot4:'
      +implementation 'org.conductoross:conductor-client-spring-boot4:'
       ```
       
      -The auto-configurations are registered via `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` and discovered automatically by `@SpringBootApplication`. No `@ComponentScan` is required.
      +```xml
      +
      +    org.conductoross
      +    conductor-client-spring-boot4
      +    <VERSION>
      +
      +```
       
      -Configure the client:
      +## Configure
       
       ```properties
       conductor.client.root-uri=http://localhost:8080/api
      +conductor.client.verifying-ssl=true
       ```
      +
      +For Orkes, inject the endpoint and credentials with your platform secret manager; see [connection and authentication](../docs/connection-authentication.md). Never commit credentials to an application configuration file.
      +
      +## What auto-configuration provides
      +
      +The module is registered in `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`. A normal `@SpringBootApplication` discovers it automatically; no manual `@ComponentScan` is needed.
      +
      +When `conductor.client.root-uri` or `conductor.client.base-path` is configured, it provides a `ConductorClient`, `TaskClient`, `WorkflowClient`, `WorkflowExecutor`, `AnnotatedWorkerExecutor`, and a `TaskRunnerConfigurer` whose lifecycle is managed by Spring. `Worker` beans and public `@WorkerTask` methods on `@Component` beans are registered for polling.
      +
      +```java
      +@Component
      +class GreetingTasks {
      +    @WorkerTask("greet")
      +    public @OutputParam("greeting") String greet(@InputParam("name") String name) {
      +        return "Hello, " + name;
      +    }
      +}
      +```
      +
      +**Expected result:** a registered `greet` task is polled by the Spring-managed worker and completes. Set `conductor.worker.greet.threadCount` to control its worker concurrency, and make external effects idempotent.
      +
      +## Migration from Boot 3
      +
      +Switch only the dependency artifact from `conductor-client-spring` to `conductor-client-spring-boot4`; the `conductor.client.*` configuration and worker annotations are intentionally the same. Verify Java 21 and your Boot 4 dependency set first. If you supplied custom client/runner beans, retain them: `@ConditionalOnMissingBean` keeps your implementations in control.
      +
      +Next: [Spring integration selector](../docs/spring-boot.md), [workers](../docs/workers.md), and [deployment/scaling](../docs/deployment-scaling.md).
      diff --git a/conductor-client-spring/README.md b/conductor-client-spring/README.md
      index 973b5f9c4..023b860e0 100644
      --- a/conductor-client-spring/README.md
      +++ b/conductor-client-spring/README.md
      @@ -1,50 +1,56 @@
      -# Conductor Client Spring
      +# Conductor Client Spring for Spring Boot 3
       
      -Provides Spring framework configurations, simplifying the use of Conductor client 
      -in Spring-based applications.
      +Spring Boot 3 auto-configuration for the Conductor core client, workflow SDK, and Java workers.
       
      -## Getting Started
      +**Prerequisites:** Java 21+, Spring Boot 3, and a running [OSS or Orkes Conductor server](../docs/connection-authentication.md). For Spring Boot 4, use [the Boot 4 module](../conductor-client-spring-boot4/README.md).
       
      -### Prerequisites
      -- Java 17 or higher
      -- A Spring boot Project Gradle properly setup with Gradle or Maven
      -- A running Conductor server ([start one locally with the CLI](../docs/server-setup.md), or use a remote server)
      +## Install
       
      -### Using Conductor Client Spring
      +Use the published version from [Maven Central](https://search.maven.org/artifact/org.conductoross/conductor-client-spring).
       
      -1. **Add `conductor-client-spring` dependency to your project**
      -
      -For Gradle:
       ```groovy
      -implementation 'org.conductoross:conductor-client-spring:4.0.0'
      +implementation 'org.conductoross:conductor-client-spring:'
       ```
       
      -For Maven:
       ```xml
       
           org.conductoross
           conductor-client-spring
      -    4.0.0
      +    <VERSION>
       
       ```
       
      -2. Add `com.netflix.conductor` to the component scan packages, e.g.:
      +## Configure
       
      -```java
      -import org.springframework.boot.autoconfigure.SpringBootApplication;;
      -import org.springframework.context.annotation.ComponentScan;
      +```properties
      +conductor.client.root-uri=http://localhost:8080/api
      +conductor.client.verifying-ssl=true
      +```
      +
      +For Orkes, provide the endpoint and credentials through your deployment's environment/secret manager; see [connection and authentication](../docs/connection-authentication.md). Never store auth credentials in `application.properties` committed to source control.
      +
      +## What auto-configuration provides
      +
      +With `@SpringBootApplication`, the module discovers its auto-configurations automatically. It creates a `ConductorClient`, `TaskClient`, `WorkflowClient`, `WorkflowExecutor`, `AnnotatedWorkerExecutor`, and a managed `TaskRunnerConfigurer` when the required beans are available. No `@ComponentScan` for `com.netflix.conductor` is required.
       
      -@SpringBootApplication
      -@ComponentScan(basePackages = {"com.netflix.conductor"})
      -public class MyApp {
      -    
      +Spring discovers `Worker` beans and `@Component` beans with public `@WorkerTask` methods. The task runner starts with the application and calls `shutdown()` during graceful Spring shutdown.
      +
      +```java
      +@Component
      +class GreetingTasks {
      +    @WorkerTask("greet")
      +    public @OutputParam("greeting") String greet(@InputParam("name") String name) {
      +        return "Hello, " + name;
      +    }
       }
       ```
       
      -3. Configure the client in `application.properties`
      +**Expected result:** when a registered workflow reaches `greet`, the managed worker polls it and completes the task. Set per-task worker thread counts with `conductor.worker..threadCount`; keep side effects idempotent.
       
      -```properties
      -conductor.client.rootUri=http://localhost:8080/api
      -```
      +## Customization and migration
      +
      +Define your own `ConductorClient`, `TaskClient`, `WorkflowClient`, `WorkflowExecutor`, or `TaskRunnerConfigurer` bean to replace the corresponding default. Use `conductor.client.base-path` as an alternative to `root-uri`; `root-uri` wins when both are present.
      +
      +Older documentation showed Java 17 and a fixed `4.0.0` coordinate. This module now follows the Java 21 SDK baseline and uses `` from Maven Central. It also uses Boot auto-configuration, so remove legacy manual component scanning unless it serves your own application components.
       
      -> **Note:** We are improving the Spring module to make the integration seamless. SEE: [[Java Client v4] Improve Spring module with auto-configuration](https://github.com/conductor-oss/conductor/issues/285)
      +Next: [Spring integration selector](../docs/spring-boot.md), [workers](../docs/workers.md), and [reliability](../docs/reliability.md).
      diff --git a/conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/testing/WorkflowTestFrameworkTests.java b/conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/testing/WorkflowTestFrameworkTests.java
      index 84ee209d5..7aad07fe4 100644
      --- a/conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/testing/WorkflowTestFrameworkTests.java
      +++ b/conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/testing/WorkflowTestFrameworkTests.java
      @@ -76,7 +76,10 @@ public void testDynamicTaskExecuted() throws Exception {
               Workflow workflow = executor.executeWorkflow("Decision_TaskExample", 1, input).get();
       
               assertNotNull(workflow);
      -        assertEquals(Workflow.WorkflowStatus.COMPLETED, workflow.getStatus());
      +        assertEquals(
      +                Workflow.WorkflowStatus.COMPLETED,
      +                workflow.getStatus(),
      +                () -> "workflow failed: " + workflow.getReasonForIncompletion() + "; tasks: " + workflow.getTasks());
               assertNotNull(workflow.getOutput());
               assertNotNull(workflow.getTasks());
               assertFalse(workflow.getTasks().isEmpty());
      @@ -84,11 +87,9 @@ public void testDynamicTaskExecuted() throws Exception {
                       workflow.getTasks().stream()
                               .anyMatch(task -> task.getTaskDefName().equals("task_6")));
       
      -        // task_2's implementation fails at the first try, so we should have to instances of task_2
      -        // execution
      -        // 2 executions of task_2 should be present
      +        // The dynamic task resolves task_2 from workflow input and runs it once.
               assertEquals(
      -                2,
      +                1,
                       workflow.getTasks().stream()
                               .filter(task -> task.getTaskDefName().equals("task_2"))
                               .count());
      @@ -97,11 +98,8 @@ public void testDynamicTaskExecuted() throws Exception {
                               .filter(task -> task.getTaskDefName().equals("task_2"))
                               .collect(Collectors.toList());
               assertNotNull(task2Executions);
      -        assertEquals(2, task2Executions.size());
      -
      -        // First instance would have failed and second succeeded.
      -        assertEquals(Task.Status.FAILED, task2Executions.get(0).getStatus());
      -        assertEquals(Task.Status.COMPLETED, task2Executions.get(1).getStatus());
      +        assertEquals(1, task2Executions.size());
      +        assertEquals(Task.Status.COMPLETED, task2Executions.get(0).getStatus());
       
               // task10's output
               assertEquals(100, workflow.getOutput().get("c"));
      @@ -133,12 +131,6 @@ public Map task1(Task1Input input) {
       
           @WorkerTask("task_2")
           public TaskResult task2(Task task) {
      -        if (task.getRetryCount() < 1) {
      -            task.setStatus(Task.Status.FAILED);
      -            task.setReasonForIncompletion("try again");
      -            return new TaskResult(task);
      -        }
      -
               task.setStatus(Task.Status.COMPLETED);
               return new TaskResult(task);
           }
      diff --git a/docs/README.md b/docs/README.md
      index e14d6b77d..8c68682b1 100644
      --- a/docs/README.md
      +++ b/docs/README.md
      @@ -1,27 +1,48 @@
       # Java SDK documentation
       
      -Use this guide to get a successful first result, then move into the API reference that fits your application.
      +Build durable workflow workers and AI agents with Conductor. This repository documents both **OSS** and **Orkes** in place; pages call out capabilities that need Orkes.
       
      -For a local server, start with the [Conductor CLI setup](server-setup.md). Docker is available when a containerized server is required. If you use an AI coding agent, load [Conductor Skills](https://github.com/conductor-oss/conductor-skills) with `npm install -g @conductor-oss/conductor-skills && conductor-skills --all`.
      +## Start here
       
      -## Choose a path
      -
      -| Goal | Start here | What you will prove |
      +| Goal | Guide | Expected result |
       |---|---|---|
      -| Build a durable AI agent | [Run your first agent](agents/getting-started.md) | An LLM-backed agent completes through Conductor. |
      -| Build a workflow and Java worker | [Run Hello World](../examples/basics/hello-world/README.md) | A workflow dispatches a `SIMPLE` task to a Java worker. |
      -| Define workflows in Java | [Workflows](workflows.md) | Fluent workflow definitions and system tasks. |
      -| Implement workers | [Workers](workers.md) | Interface and annotation-based workers. |
      -| Test workflows against a local server | [Workflow test harness](workflow-testing.md) | A workflow and its workers execute in a test. |
      +| Connect to a server | [Server setup](server-setup.md) and [connection/authentication](connection-authentication.md) | A local or remote API endpoint accepts SDK requests. |
      +| Build a workflow and Java worker | [Core quickstart](core-quickstart.md) | Hello World prints `Result: PASSED`. |
      +| Build an AI agent | [Agent quickstart](agents/getting-started.md) | An LLM-backed agent completes through Conductor. |
      +
      +## Build
      +
      +- [Workflows](workflows.md) and [workflow lifecycle](workflow-lifecycle.md)
      +- [Workers](workers.md), [workflow testing](workflow-testing.md), [files](file-client.md), and [schemas](schema-client.md)
      +- [Schedules and events](schedules-events.md)
      +- [AI agents](agents/README.md), [tools](agents/concepts/tools.md), and [agent framework bridges](agents/README.md#framework-bridges)
      +- [Curated examples](examples.md); the [complete catalog](../examples/README.md) remains machine-generated.
      +
      +## Operate
      +
      +- [Reliability: timeouts, retries, idempotency, and domains](reliability.md)
      +- [Security and secrets](security.md)
      +- [Deployment, scaling, and graceful shutdown](deployment-scaling.md)
      +- [Metrics and logging](observability.md)
      +- [Debugging incidents](debugging.md)
      +
      +## Integrate
      +
      +- [Spring Boot integration selector](spring-boot.md)
      +- [Spring Boot 3 module](../conductor-client-spring/README.md)
      +- [Spring Boot 4 module](../conductor-client-spring-boot4/README.md)
      +- [Google ADK](agents/frameworks/google-adk.md), [LangChain4j](agents/frameworks/langchain4j.md), and [LangGraph4j](agents/frameworks/langgraph4j.md)
       
      -## After your first result
      +## Reference and upgrades
       
      -- **AI agents:** [agent guide](agents/README.md), [tools](agents/concepts/tools.md), [framework bridges](agents/README.md#framework-bridges), and [scheduling](agents/concepts/scheduling.md).
      -- **Core client:** [Schema client](schema-client.md), [FileClient](file-client.md), and the [examples catalog](../examples/README.md).
      -- **Design contracts:** [file transfer](../design/file-client.md) and [tool credential delivery](../design/secret-injection-contract.md).
      +- [API map and generated Javadocs](api-map.md)
      +- [Compatibility matrix](compatibility.md)
      +- [Upgrade guide](upgrading.md)
      +- [Agent client control plane](agents/reference/client.md), [agent runtime](agents/reference/runtime.md), and [agent schema](agents/reference/agent-schema.md)
       
       ## Documentation conventions
       
       - Replace `` with a published SDK version from [Maven Central](https://search.maven.org/search?q=g:org.conductoross).
      -- A `provider/model` value is an example; the provider credential must be configured on the **Conductor server**, not only in the Java client process.
      -- Runnable commands link to maintained examples. Short Java blocks labeled **Fragment** show one API concept and need surrounding application setup.
      +- Runnable commands link to maintained examples and state an expected result. Short Java blocks labeled **Fragment** need surrounding application setup and link to a complete path.
      +- Provider credentials belong on the **Conductor server**, not only in the Java client process. Never place secrets in workflow input or source control.
      +- Primary guide authors follow the [documentation standard](documentation-standard.md).
      diff --git a/docs/agents/reference/client.md b/docs/agents/reference/client.md
      index bbbc7b070..41bf45e39 100644
      --- a/docs/agents/reference/client.md
      +++ b/docs/agents/reference/client.md
      @@ -17,8 +17,8 @@ Every request goes through the shared `ConductorClient`'s native HTTP + auth + s
       | [`respond`](#respond) | `POST /api/agent/{id}/respond` | `RespondBody` | `void` | Resume a paused HITL task |
       | [`cancelAgent`](#cancelagent) | `DELETE /api/agent/{id}/cancel` | path: `executionId`; optional `reason` query | `void` | Immediately cancel/terminate an execution |
       | [`stopAgent`](#stopagent) | `POST /api/agent/{id}/stop` | path: `executionId` | `void` | Gracefully stop after the current iteration |
      -| `signalAgent` | `POST /api/agent/{id}/signal` | path: `executionId`; message body | `void` | Inject persistent context |
      -| `streamSse` | `GET /api/agent/stream/{id}` | path: `executionId`; optional last event ID | `SseClient` | Open the resumable SSE event stream |
      +| [`signalAgent`](#signalagent) | `POST /api/agent/{id}/signal` | path: `executionId`; message body | `void` | Inject persistent context |
      +| [`streamSse`](#streamsse) | `GET /api/agent/stream/{id}` | path: `executionId`; optional last event ID | `SseClient` | Open the resumable SSE event stream |
       
       ---
       
      @@ -352,6 +352,45 @@ Use `stopAgent` when the current iteration should finish cleanly; use `cancelAge
       
       ---
       
      +## signalAgent
      +
      +Inject a durable text message into a running agent's context. This is an agent-control-plane signal, not a response to a pending human approval; use [`respond`](#respond) for a HUMAN task.
      +
      +**Fragment — obtain `client` as shown in the [control-plane overview](#agentclient-control-plane-reference).**
      +
      +```java
      +client.signalAgent(executionId, "Customer supplied order number 12345");
      +```
      +
      +**HTTP:** `POST /api/agent/{executionId}/signal`
      +
      +The body is the message accepted by the agent control plane. The method returns `void`; `AgentNotFoundException` represents a missing execution and `AgentAPIException` represents another server failure. Do not put credentials or sensitive raw customer data in a signal.
      +
      +---
      +
      +## streamSse
      +
      +Open the resumable server-sent event stream for an agent execution. `AgentRuntime.stream(...)` uses this method; direct users must close the returned `SseClient`.
      +
      +**Fragment — obtain `client` as shown in the [control-plane overview](#agentclient-control-plane-reference).**
      +
      +```java
      +import java.util.Map;
      +
      +try (SseClient stream = client.streamSse(executionId, null)) {
      +    Map event;
      +    while ((event = stream.nextEvent()) != null) {
      +        System.out.println(event);
      +    }
      +}
      +```
      +
      +**HTTP:** `GET /api/agent/stream/{executionId}`
      +
      +Pass the last received event ID as the second argument to resume after a reconnect. The client reconnects mid-stream with `Last-Event-ID`; if the server rejects streaming altogether it throws `SSEUnavailableException`. Fall back to `getAgentStatus` polling in that case. Treat streamed content as potentially sensitive and redact it before logging.
      +
      +---
      +
       ## WorkflowClient usage
       
       Raw workflow data (`GET /api/workflow/{id}`) is fetched via the standard Conductor `WorkflowClient` — not `AgentClient`. `AgentClient` owns only `/api/agent/*`.
      diff --git a/docs/api-map.md b/docs/api-map.md
      new file mode 100644
      index 000000000..6ffa2fffe
      --- /dev/null
      +++ b/docs/api-map.md
      @@ -0,0 +1,21 @@
      +# Core API map
      +
      +**Audience:** developers choosing the typed Java client.  
      +**Reference policy:** generated Javadocs are canonical for signatures; source links are useful for snapshot development.
      +
      +| Need | Type | Module | Route |
      +|---|---|---|---|
      +| Configure HTTP/auth transport | `ConductorClient` / `ApiClient` | core | [Javadocs](https://javadoc.io/doc/org.conductoross/conductor-client) |
      +| Start, query, pause, resume workflows | `WorkflowClient` | core | [source](../conductor-client/src/main/java/com/netflix/conductor/client/http/WorkflowClient.java) |
      +| Poll and update tasks | `TaskClient` | core | [source](../conductor-client/src/main/java/com/netflix/conductor/client/http/TaskClient.java) |
      +| Register task and workflow metadata | `MetadataClient` | core | [source](../conductor-client/src/main/java/com/netflix/conductor/client/http/MetadataClient.java) |
      +| Create schedules | `SchedulerClient` | core/Orkes clients | [source](../conductor-client/src/main/java/io/orkes/conductor/client/SchedulerClient.java) |
      +| Manage schemas | `SchemaClient` | core/Orkes clients | [source](../conductor-client/src/main/java/io/orkes/conductor/client/SchemaClient.java) |
      +| Transfer workflow-scoped files | `FileClient` | core | [guide](file-client.md) |
      +| Emit SDK metrics | metrics integration | metrics | [README](../conductor-client-metrics/README.md) |
      +| Compile, deploy, start, signal, and stream agents | `AgentClient` | core + AI | [reference](agents/reference/client.md) |
      +| Build/serve/run agents | `AgentRuntime` | AI | [reference](agents/reference/runtime.md) |
      +
      +`OrkesClients` assembles Orkes-specific typed clients from the shared transport. Use [connection and authentication](connection-authentication.md) before constructing any client.
      +
      +Next: [workflows](workflows.md), [agents](agents/README.md), and [compatibility](compatibility.md).
      diff --git a/docs/compatibility.md b/docs/compatibility.md
      new file mode 100644
      index 000000000..b7f84fc6b
      --- /dev/null
      +++ b/docs/compatibility.md
      @@ -0,0 +1,27 @@
      +# Compatibility matrix
      +
      +**Audience:** teams selecting modules and runtime baselines.  
      +**Last verified:** repository build and module configuration.
      +
      +| Area | Supported baseline | Notes |
      +|---|---|---|
      +| Java SDK | Java 21+ | This repository compiles and tests with Java 21. |
      +| OSS Conductor | Supported server deployment | Use the CI server matrix as the exercised compatibility baseline; test your target server during an upgrade. |
      +| Orkes | Supported tenant API | Set the tenant API endpoint and credentials; availability of enterprise features is tenant-specific. |
      +| Spring Boot 3 | Java 17+ / Spring Boot 3 | Use `conductor-client-spring`; the SDK itself remains Java 21+. |
      +| Spring Boot 4 | Java 21+ / Spring Boot 4 | Use `conductor-client-spring-boot4`. |
      +
      +## Published modules
      +
      +| Module | Purpose | Maven Central | Javadocs |
      +|---|---|---|---|
      +| `conductor-client` | Core workflow, task, metadata, file, and scheduler clients | [artifact](https://search.maven.org/artifact/org.conductoross/conductor-client) | [Javadocs](https://javadoc.io/doc/org.conductoross/conductor-client) |
      +| `conductor-client-ai` | Agent runtime, definitions, tools, and bridges | [artifact](https://search.maven.org/artifact/org.conductoross/conductor-client-ai) | [Javadocs](https://javadoc.io/doc/org.conductoross/conductor-client-ai) |
      +| `conductor-client-ai-spring` | Spring support for agents | [artifact](https://search.maven.org/search?q=g:org.conductoross%20AND%20a:conductor-client-ai-spring) | Source and generated artifact Javadocs when published |
      +| `conductor-client-spring` | Spring Boot 3 core-client auto-configuration | [artifact](https://search.maven.org/artifact/org.conductoross/conductor-client-spring) | [Javadocs](https://javadoc.io/doc/org.conductoross/conductor-client-spring) |
      +| `conductor-client-spring-boot4` | Spring Boot 4 core-client auto-configuration | [artifact](https://search.maven.org/artifact/org.conductoross/conductor-client-spring-boot4) | [Javadocs](https://javadoc.io/doc/org.conductoross/conductor-client-spring-boot4) |
      +| `conductor-client-metrics` | SDK metrics integration | [artifact](https://search.maven.org/artifact/org.conductoross/conductor-client-metrics) | [Javadocs](https://javadoc.io/doc/org.conductoross/conductor-client-metrics) |
      +
      +Use `` from [Maven Central](https://search.maven.org/search?q=g:org.conductoross), never a README-pinned release. Generated Javadocs are the signature reference; linked source remains the fallback for snapshot work.
      +
      +Next: [Spring integration](spring-boot.md), [API map](api-map.md), and [upgrading](upgrading.md).
      diff --git a/docs/connection-authentication.md b/docs/connection-authentication.md
      new file mode 100644
      index 000000000..4b120f160
      --- /dev/null
      +++ b/docs/connection-authentication.md
      @@ -0,0 +1,38 @@
      +# Connection and authentication
      +
      +**Audience:** SDK users connecting an application to OSS or Orkes.  
      +**Security:** keep credentials in environment variables or your platform secret store; never in workflow input, source, or logs.
      +
      +## OSS
      +
      +For local development, follow [server setup](server-setup.md), then point the application at the API endpoint:
      +
      +```bash
      +export CONDUCTOR_SERVER_URL=http://localhost:8080/api
      +```
      +
      +For a remote OSS deployment, set the same variable to its HTTPS API endpoint. Confirm it includes `/api`.
      +
      +## Orkes
      +
      +Set the server URL and credentials in the environment used to launch the application:
      +
      +```bash
      +export CONDUCTOR_SERVER_URL=https://your-tenant.orkesconductor.com/api
      +export CONDUCTOR_AUTH_KEY=your-key-id
      +export CONDUCTOR_AUTH_SECRET=your-secret-value
      +```
      +
      +Use your deployment platform's secret injection mechanism for the last two values. Rotate credentials through that platform and restart workers safely after rotation.
      +
      +## Verify before debugging code
      +
      +Use the CLI against the same endpoint and credentials:
      +
      +```bash
      +conductor workflow list
      +```
      +
      +Expected result: the server returns workflow definitions or an empty list. A `401` or `403` is an authorization problem, while a connection error normally means a URL, network, TLS, or proxy problem.
      +
      +Next: [core quickstart](core-quickstart.md) or [agent quickstart](agents/getting-started.md).
      diff --git a/docs/core-quickstart.md b/docs/core-quickstart.md
      new file mode 100644
      index 000000000..378cf7185
      --- /dev/null
      +++ b/docs/core-quickstart.md
      @@ -0,0 +1,53 @@
      +# Core workflow and worker quickstart
      +
      +**Audience:** Java developers building their first Conductor workflow worker.  
      +**Works with:** OSS and Orkes.  
      +**Prerequisites:** Java 21+, Docker or the Conductor CLI, and a shell.
      +
      +This is the CI-smoke-tested Hello World path. It registers a task definition and workflow, starts a Java worker, executes the workflow, and shuts the worker down.
      +
      +## Run it
      +
      +Start a local server once:
      +
      +```bash
      +conductor server start
      +export CONDUCTOR_SERVER_URL=http://localhost:8080/api
      +```
      +
      +Run the checked-in example:
      +
      +```bash
      +cd examples/basics/hello-world
      +./run.sh
      +```
      +
      +Expected output:
      +
      +```text
      +Status: COMPLETED
      +Output: {greeting=Hello, Developer! Welcome to Conductor.}
      +Result: PASSED
      +```
      +
      +Open the execution in the local UI at `http://localhost:8080`. When you are finished, stop the local server:
      +
      +```bash
      +conductor server stop
      +```
      +
      +If `CONDUCTOR_SERVER_URL` is unset, `run.sh` instead starts the example's Docker Compose server. Stop that server with `docker compose down` in this directory.
      +
      +## What it proves
      +
      +- A `SIMPLE` task definition, workflow task name, and Java worker task name match.
      +- A live worker polls the task and returns output.
      +- The workflow result is retrieved and checked.
      +
      +## Common failures
      +
      +- `SCHEDULED` task: the worker is not polling the exact task name; see [workers](workers.md).
      +- Connection failure: verify the endpoint ends in `/api`; see [connection and authentication](connection-authentication.md).
      +- Repeated side effect: make the worker idempotent before using the pattern in production; see [reliability](reliability.md).
      +
      +Next, define a real [workflow](workflows.md), make the worker [reliable and scalable](reliability.md), and add an integration [test](workflow-testing.md).
      diff --git a/docs/debugging.md b/docs/debugging.md
      new file mode 100644
      index 000000000..a5d8c86bf
      --- /dev/null
      +++ b/docs/debugging.md
      @@ -0,0 +1,22 @@
      +# Debugging incidents
      +
      +**Audience:** on-call engineers and workflow owners.  
      +**Works with:** OSS and Orkes.
      +
      +Start with an execution ID, not a code guess. Inspect the workflow status, failed task, reason for incompletion, retry count, input/output shape, and worker logs for the same task ID.
      +
      +## Fast triage
      +
      +| Symptom | Likely cause | First check |
      +|---|---|---|
      +| Task stays `SCHEDULED` | No matching worker, wrong domain, or no task definition | worker task name/domain and queue age |
      +| Task times out | worker crash, blocked dependency, or timeout too low | worker logs, response timeout, downstream latency |
      +| Task repeatedly fails | transient failure misclassified or non-idempotent side effect | reason, retry policy, idempotency key |
      +| Agent waits forever | approval/tool response was never sent | agent status and pending HITL task |
      +| `401`/`403` | credentials or tenant permissions | endpoint and injected credential identity |
      +
      +## Gather safe evidence
      +
      +Capture IDs, timestamps, status, reason, retry count, and redacted configuration. Do not paste auth headers, raw secrets, or customer payloads into tickets. If a definition changed recently, compare the execution's workflow version before rolling back.
      +
      +Next: [reliability](reliability.md), [connection and authentication](connection-authentication.md), and [agent client reference](agents/reference/client.md).
      diff --git a/docs/deployment-scaling.md b/docs/deployment-scaling.md
      new file mode 100644
      index 000000000..aa799c487
      --- /dev/null
      +++ b/docs/deployment-scaling.md
      @@ -0,0 +1,25 @@
      +# Deployment, scaling, and graceful shutdown
      +
      +**Audience:** teams operating Java workers or agents in containers, VMs, or Spring applications.  
      +**Works with:** OSS and Orkes.
      +
      +Scale workers from queue depth, task latency, and external dependency limits—not CPU alone. Keep each worker process stateless; Conductor persists workflow state.
      +
      +## Worker lifecycle
      +
      +Start polling only after configuration, credentials, and downstream dependencies are healthy. On shutdown, stop accepting new work, allow in-flight tasks to report a terminal result within the termination grace period, then call the runner's `shutdown()` method. Kubernetes readiness should represent the ability to poll and execute tasks, not merely an open HTTP port.
      +
      +## Capacity controls
      +
      +- Use independent deployments or task domains for workloads with different limits.
      +- Cap worker thread counts to protect databases and third-party APIs.
      +- Set server-side task rate and concurrency limits where appropriate.
      +- Scale from sustained queue backlog and age, then verify retries are not masking an outage.
      +
      +## Agent runtime
      +
      +`AgentRuntime.serve(...)` prepares local tool workers; deploy it as you would a worker process. Separate model/tool credentials by deployment and never give a broad production credential to an example tool.
      +
      +Expected result: replacing one worker instance does not lose execution state; unfinished tasks are redelivered according to timeout policy.
      +
      +Next: [observability](observability.md), [reliability](reliability.md), and [Spring integration](spring-boot.md).
      diff --git a/docs/documentation-standard.md b/docs/documentation-standard.md
      new file mode 100644
      index 000000000..0647f3374
      --- /dev/null
      +++ b/docs/documentation-standard.md
      @@ -0,0 +1,11 @@
      +# Documentation standard
      +
      +Every primary guide must include:
      +
      +- Audience and prerequisites.
      +- An explicit OSS/Orkes capability label when behavior differs.
      +- A security note when credentials, user data, tools, or external side effects are involved.
      +- Runnable commands or a clear **Fragment** label linked to a complete example/reference.
      +- Expected result, common failure modes, cleanup where resources are started, and next-step links.
      +
      +Keep examples repository-native and use `` plus Maven Central rather than stale version literals. Generated Javadocs are the signature source of truth. CI validates internal links, curated paths, and representative compile/smoke-test paths.
      diff --git a/docs/examples.md b/docs/examples.md
      new file mode 100644
      index 000000000..a873f58a6
      --- /dev/null
      +++ b/docs/examples.md
      @@ -0,0 +1,12 @@
      +# Recommended examples
      +
      +The [examples catalog](../examples/README.md) is the complete machine-generated catalog. These paths are the maintained starting points; do not infer equal support from every catalog entry.
      +
      +| Path | Tag | Prerequisites and command | Expected outcome | Cleanup |
      +|---|---|---|---|---|
      +| [Hello World](../examples/basics/hello-world/README.md) | Start here | Java 21 and a local server; `cd examples/basics/hello-world && ./run.sh` | `Result: PASSED` | `conductor server stop`, or `docker compose down` for the Docker path |
      +| [Basic agent](../agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example01BasicAgent.java) | Start here | Server-side provider credentials; `./gradlew :agent-examples:run -PmainClass=org.conductoross.conductor.ai.examples.Example01BasicAgent` | Prints an `AgentResult` | Stop the local server/workers |
      +| [Plan and execute](../agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example108PlanExecuteRefs.java) | Production pattern | Same agent setup | Runs a durable plan with output references | Stop the local server/workers |
      +| [Planner context](../agent-examples/src/main/java/org/conductoross/conductor/ai/examples/Example115PlannerContext.java) | Reference only | Same agent setup and provider access | Prints the generated task trace | Stop the local server/workers |
      +
      +Credentials must be configured on the Conductor server. Do not run reference-only examples against production credentials without reviewing their tools and side effects.
      diff --git a/docs/observability.md b/docs/observability.md
      new file mode 100644
      index 000000000..694e9eb7b
      --- /dev/null
      +++ b/docs/observability.md
      @@ -0,0 +1,21 @@
      +# Metrics and logging
      +
      +**Audience:** operators diagnosing throughput, latency, errors, and cost.  
      +**Works with:** OSS and Orkes.
      +
      +Use the Conductor UI/API for execution-level state and the SDK metrics module for client-side telemetry. Link logs, traces, and business records with workflow ID, task ID, correlation ID, and a redacted business identifier.
      +
      +## What to measure
      +
      +- Workflow starts, completions, failures, and end-to-end latency.
      +- Task queue age, poll rate, execution latency, timeout count, and retry count.
      +- Worker thread saturation and downstream dependency error rate.
      +- Agent model tokens, tool calls, time spent waiting for human input, and terminal status.
      +
      +The [`conductor-client-metrics`](../conductor-client-metrics/README.md) module provides the SDK-side metrics integration. Do not label metrics with unbounded IDs, prompts, user email addresses, or secrets.
      +
      +## Logs
      +
      +Log state transitions and failure reasons at the worker boundary. Preserve the workflow and task IDs so an operator can open the exact execution. Redact request bodies by default and log only the fields necessary to reproduce a problem.
      +
      +Next: [debugging incidents](debugging.md) and [deployment and scaling](deployment-scaling.md).
      diff --git a/docs/reliability.md b/docs/reliability.md
      new file mode 100644
      index 000000000..f2d6b539a
      --- /dev/null
      +++ b/docs/reliability.md
      @@ -0,0 +1,24 @@
      +# Reliability: timeouts, retries, idempotency, and domains
      +
      +**Audience:** production workflow and worker owners.  
      +**Works with:** OSS and Orkes.
      +
      +Conductor delivers tasks at least once. A worker must tolerate retries, timeouts, and process replacement without duplicating business effects.
      +
      +## Set the three task timeouts
      +
      +Set `pollTimeoutSeconds` to detect a task no worker claims, `responseTimeoutSeconds` to recover a task after a worker disappears, and `timeoutSeconds` to bound total execution. Set a workflow timeout as a separate guardrail. Choose values from observed latency plus realistic failure recovery, not defaults.
      +
      +## Retry only safe work
      +
      +Configure retry count, backoff, and delay on task definitions. Use `FAILED_WITH_TERMINAL_ERROR` for invalid input or an irreversible business rejection; allow transient network or provider failures to use the retry policy. Design each external action with an idempotency key or a durable application record before enabling retries.
      +
      +## Isolate workers with domains
      +
      +Use task domains for dedicated worker pools, regions, tenants, or high-risk integrations. A domain is a routing boundary, not an authorization boundary; still use scoped credentials and network controls.
      +
      +## Verify
      +
      +Exercise success, transient failure, terminal failure, duplicate delivery, and timeout paths in an integration test. The maintained [workflow test harness](workflow-testing.md) runs against a real local server.
      +
      +Next: [deployment and scaling](deployment-scaling.md), [security](security.md), and [debugging](debugging.md).
      diff --git a/docs/schedules-events.md b/docs/schedules-events.md
      new file mode 100644
      index 000000000..da8106bef
      --- /dev/null
      +++ b/docs/schedules-events.md
      @@ -0,0 +1,36 @@
      +# Schedules and events
      +
      +**Audience:** teams starting workflows from time-based or event-driven triggers.  
      +**Works with:** schedules work in OSS and Orkes; event infrastructure depends on server configuration.
      +
      +Use the typed `SchedulerClient` for schedule metadata and the workflow `EVENT` task or event handlers for asynchronous events. The Java API map links each client and its canonical Javadocs.
      +
      +## Schedule safely
      +
      +Use a stable correlation identifier derived from the time window so a retry or double delivery cannot create duplicate business work. Keep the scheduled workflow idempotent and make its input small: pass object references, not large files.
      +
      +**Fragment — create the client from `OrkesClients`; the [API map](api-map.md) links the complete signature reference.**
      +
      +```java
      +StartWorkflowRequest start = new StartWorkflowRequest()
      +        .withName("billing")
      +        .withVersion(2)
      +        .withCorrelationId("billing-2026-07-19")
      +        .withInput(Map.of("billingDate", "2026-07-19"));
      +
      +SaveScheduleRequest schedule = new SaveScheduleRequest()
      +        .name("daily-billing")
      +        .cronExpression("0 0 2 * * ?")
      +        .zoneId("UTC")
      +        .startWorkflowRequest(start);
      +
      +schedulerClient.saveSchedule(schedule);
      +```
      +
      +Expected result: the schedule is visible through the server API/UI and produces a workflow execution at the configured time.
      +
      +## Event-driven work
      +
      +Use an `EVENT` task to publish from a workflow, or configure an event handler to start a workflow. Put event payload validation at the workflow boundary and include an idempotency key from the producer.
      +
      +Next: [workflow lifecycle](workflow-lifecycle.md), [reliability](reliability.md), and [debugging](debugging.md).
      diff --git a/docs/security.md b/docs/security.md
      new file mode 100644
      index 000000000..161a4ddb9
      --- /dev/null
      +++ b/docs/security.md
      @@ -0,0 +1,25 @@
      +# Security and secrets
      +
      +**Audience:** production SDK and agent owners.  
      +**Security note:** workflow input, task input, execution output, and logs may be visible to operators. Do not treat them as secret stores.
      +
      +## Credentials
      +
      +Store server credentials in environment variables or a workload secret manager. Use `CONDUCTOR_AUTH_KEY` and `CONDUCTOR_AUTH_SECRET` where required. Restrict each deployment to the smallest server permissions and rotate credentials without writing their values to source control.
      +
      +## Workflow and worker data
      +
      +- Pass references to blobs rather than large or sensitive payloads.
      +- Redact personal data in logs and metrics.
      +- Use application-owned idempotency keys for external writes.
      +- Validate input at the workflow boundary before invoking tools or workers.
      +
      +## Agent tools
      +
      +Treat every tool as an API exposed to a model. Define narrow input schemas, authorize in the worker, validate arguments again at execution, and keep sensitive credentials server-side. Provider credentials must be available to the Conductor server for server-executed agent/model work. See [agent tools](agents/concepts/tools.md) and the [credential delivery contract](../design/secret-injection-contract.md).
      +
      +## OSS and Orkes
      +
      +Both need secret handling outside workflow input. Orkes-specific secret and integration capabilities are server features; confirm their availability with your tenant and use the platform's secret references rather than copying values into definitions.
      +
      +Next: [connection and authentication](connection-authentication.md), [reliability](reliability.md), and [observability](observability.md).
      diff --git a/docs/spring-boot.md b/docs/spring-boot.md
      new file mode 100644
      index 000000000..2b6eb59f4
      --- /dev/null
      +++ b/docs/spring-boot.md
      @@ -0,0 +1,14 @@
      +# Spring Boot integration selector
      +
      +**Audience:** Spring applications that run Conductor clients or workers.  
      +**Works with:** OSS and Orkes.
      +
      +| Your application | Module | Java baseline | Guide |
      +|---|---|---|---|
      +| Spring Boot 3 | `conductor-client-spring` | Java 17+ for Boot; Java 21+ for this SDK | [Boot 3](../conductor-client-spring/README.md) |
      +| Spring Boot 4 | `conductor-client-spring-boot4` | Java 21+ | [Boot 4](../conductor-client-spring-boot4/README.md) |
      +| AI agents in Spring | `conductor-client-ai-spring` | match your Spring application | [agent Spring guide](agents/spring-boot.md) |
      +
      +Both core modules use Spring Boot auto-configuration. Add the matching dependency, configure `conductor.client.root-uri`, and let `@SpringBootApplication` discover the client/worker configuration. Do not add manual component scanning for Conductor packages unless your application has a separate reason.
      +
      +Expected result: Spring creates the configured client beans and starts annotated worker beans through the managed task runner. See each module README for properties, custom beans, worker discovery, and migration details.
      diff --git a/docs/upgrading.md b/docs/upgrading.md
      new file mode 100644
      index 000000000..587095cdb
      --- /dev/null
      +++ b/docs/upgrading.md
      @@ -0,0 +1,23 @@
      +# Upgrade the Java SDK safely
      +
      +**Audience:** teams upgrading the SDK, Java baseline, or workflow definitions.  
      +**Works with:** OSS and Orkes.
      +
      +## Before the upgrade
      +
      +1. Select a published `` from [Maven Central](https://search.maven.org/search?q=g:org.conductoross) and read its release notes.
      +2. Confirm Java 21 for SDK applications and select the matching Spring module: [Boot 3](../conductor-client-spring/README.md) or [Boot 4](../conductor-client-spring-boot4/README.md).
      +3. Compile and run your worker and agent test suites against the target server version.
      +4. Review deprecated APIs in the generated Javadocs and migrate before removing the old version.
      +
      +## Roll out safely
      +
      +Deploy new workers before definitions that require their task types. Keep worker implementations backward compatible while old executions are in flight. For breaking workflow changes, publish a new workflow version, route new starts to it, and drain old executions before removing the old version.
      +
      +Do not change task names, output keys, tool contracts, or credential scopes in place unless all callers and in-flight executions remain compatible.
      +
      +## Verify and roll back
      +
      +Monitor workflow completion, retries, queue age, and error reasons after rollout. A rollback should restore a compatible worker/definition pair; never delete definitions needed by active executions.
      +
      +Next: [workflow lifecycle](workflow-lifecycle.md), [compatibility](compatibility.md), and [debugging](debugging.md).
      diff --git a/docs/workflow-lifecycle.md b/docs/workflow-lifecycle.md
      new file mode 100644
      index 000000000..29c2a0e3f
      --- /dev/null
      +++ b/docs/workflow-lifecycle.md
      @@ -0,0 +1,34 @@
      +# Workflow lifecycle and versioning
      +
      +**Audience:** teams publishing workflow definitions.  
      +**Works with:** OSS and Orkes.
      +
      +Register definitions deliberately and treat their inputs and outputs as an API. Use [workflows](workflows.md) for the fluent builder surface.
      +
      +## Safe evolution
      +
      +1. Give every workflow a description, owner, timeout policy, and stable task reference names.
      +2. Keep existing input and output keys compatible. Additive changes are safest.
      +3. Publish a new workflow version for breaking graph or output changes; pin callers and sub-workflows to the intended version.
      +4. Leave the old version available while executions and callers still use it, then retire it after their retention window.
      +
      +Do not mutate a running workflow's semantics in place: an execution can be between durable tasks while the definition changes.
      +
      +## Register, run, inspect
      +
      +**Fragment — use the fully runnable [Hello World](core-quickstart.md) for client construction and worker lifecycle.**
      +
      +```java
      +workflow.registerWorkflow(true, true);
      +String workflowId = workflow.execute(Map.of("customerId", "c-123"));
      +```
      +
      +Expected result: the definition is visible in the Conductor UI and the execution has a durable workflow ID. Query the status through `WorkflowClient` or the UI before changing a definition.
      +
      +## Failure modes
      +
      +- Missing task definition or worker: execution stops at a `SCHEDULED` task.
      +- Output rename: downstream tasks or callers see missing values.
      +- Unbounded work: a workflow timeout is absent or too permissive.
      +
      +Next: [reliability](reliability.md), [schedules and events](schedules-events.md), and [upgrading](upgrading.md).
      diff --git a/docs/workflow-testing.md b/docs/workflow-testing.md
      index a4f29c844..e4fa58435 100644
      --- a/docs/workflow-testing.md
      +++ b/docs/workflow-testing.md
      @@ -2,7 +2,7 @@
       
       `WorkflowTestRunner` downloads and starts a real Conductor server, then registers annotated workers and workflow metadata against it. It is an integration-test harness, not a mock-only unit-test framework.
       
      -Use it to verify input/output wiring, retries, dynamic branches, and worker behavior without managing a separate server process.
      +Use it to verify input/output wiring, dynamic branches, and worker behavior without managing a separate server process. Test retry policy separately with a concrete `SIMPLE` task definition; older server versions do not apply the resolved worker task's retry policy through a `DYNAMIC` wrapper.
       
       ## JUnit setup
       
      @@ -55,7 +55,7 @@ assertEquals(Workflow.WorkflowStatus.COMPLETED, workflow.getStatus());
       assertNotNull(workflow.getOutput());
       ```
       
      -The full maintained example is [WorkflowTestFrameworkTests](https://github.com/conductor-oss/java-sdk/blob/main/conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/testing/WorkflowTestFrameworkTests.java).
      +The full maintained example is [WorkflowTestFrameworkTests](https://github.com/conductor-oss/java-sdk/blob/main/conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/testing/WorkflowTestFrameworkTests.java). Its dynamic-task path and missing-input failure path are run in this repository's test suite.
       
       ## What to cover
       
      
      From a38f432b7e679ad3dcba10ec7908520717439595 Mon Sep 17 00:00:00 2001
      From: Viren Baraiya 
      Date: Mon, 20 Jul 2026 19:36:30 -0700
      Subject: [PATCH 14/14] docs
      
      ---
       .gitignore                         |  1 +
       README.md                          | 67 +++++++++++++++++--------
       conductor-client-metrics/README.md |  4 +-
       conductor-client/README.md         | 10 ++--
       design/api_client_enhacements.md   |  2 +-
       docs/agents/getting-started.md     |  2 +-
       docs/server-setup.md               | 28 ++++++++---
       examples/README.md                 | 80 +++++++++++++++---------------
       8 files changed, 119 insertions(+), 75 deletions(-)
      
      diff --git a/.gitignore b/.gitignore
      index 09d0e1893..a8dbb7a7b 100644
      --- a/.gitignore
      +++ b/.gitignore
      @@ -12,3 +12,4 @@ bin/
       *.db
       *.db-shm
       *.db-wal
      +/design/openspec
      diff --git a/README.md b/README.md
      index ad846f382..580147832 100644
      --- a/README.md
      +++ b/README.md
      @@ -9,7 +9,7 @@ The Java SDK for [Conductor](https://www.conductor-oss.org/) lets you build dura
       
       **Get involved:** [⭐ Conductor OSS](https://github.com/conductor-oss/conductor) · [Choose a Conductor OSS contribution](https://github.com/conductor-oss/conductor/contribute) · [Contribution guide](https://github.com/conductor-oss/conductor/blob/main/CONTRIBUTING.md)
       
      -**Using an AI coding agent?** Load [Conductor Skills](https://github.com/conductor-oss/conductor-skills) so it can create, run, and operate Conductor workflows:
      +**Using an AI coding agent?** Load [Conductor Skills](https://github.com/conductor-oss/conductor-skills) so it can create, run, and operate Conductor Workflows and Agents:
       
       ```shell
       npm install -g @conductor-oss/conductor-skills && conductor-skills --all
      @@ -25,6 +25,44 @@ npm install -g @conductor-oss/conductor-skills && conductor-skills --all
       | Browse all examples | [AI agent guide](docs/agents/README.md) · [Core examples](examples/README.md) |
       | Navigate the SDK documentation | [Documentation hub](docs/README.md) |
       
      +## Choose your Conductor server
      +
      +Connect to a server before following either quickstart. Use the hosted Developer Edition by default, or run Conductor locally when you need a self-managed development environment.
      +
      +### Recommended: Orkes Developer Edition
      +
      +[Orkes Developer Edition](https://developer.orkescloud.com/) is the default hosted option. Create an application and access key in the Developer Edition UI, then configure this SDK with its API endpoint. Keep the key and secret out of source control.
      +
      +```shell
      +export CONDUCTOR_SERVER_URL=https://developer.orkescloud.com/api
      +export CONDUCTOR_AUTH_KEY=
      +export CONDUCTOR_AUTH_SECRET=
      +```
      +
      +For another hosted or self-managed remote cluster, use that cluster's `/api` URL and its application credentials instead. See [server setup](docs/server-setup.md) for details.
      +
      +### Local alternative: Conductor CLI
      +
      +The CLI is the preferred local-server path. It needs Java 21+ and Node.js/npm.
      +
      +```shell
      +npm install -g @conductor-oss/conductor-cli
      +conductor server start
      +conductor server status
      +export CONDUCTOR_SERVER_URL=http://localhost:8080/api
      +```
      +
      +### Docker fallback
      +
      +Use Docker when you need a containerized local server instead of the CLI:
      +
      +```shell
      +docker run --rm -p 8080:8080 -p 1234:5000 conductoross/conductor:latest
      +export CONDUCTOR_SERVER_URL=http://localhost:8080/api
      +```
      +
      +The Docker server UI is available at [http://localhost:1234](http://localhost:1234). See [server setup](docs/server-setup.md) for full local, remote, and authentication guidance.
      +
       ## Why Conductor?
       
       - **Survive process failures:** execution state is durable, so agents and workflows resume from completed work.
      @@ -45,7 +83,8 @@ Prefer to construct the graph in code? [`Example108PlanExecuteRefs`](agent-examp
       ## Requirements and compatibility
       
       - Java 21+
      -- A running OSS/Orkes Conductor server. For local development, use the [Conductor CLI](docs/server-setup.md) (`npm install -g @conductor-oss/conductor-cli`; then `conductor server start`). Docker remains available for containerized examples.
      +- A running OSS/Orkes Conductor server selected in [Choose your Conductor server](#choose-your-conductor-server).
      +- Docker when using the `examples/basics/hello-world/run.sh` launcher; it builds and runs the example in containers even when the server itself is remote or CLI-managed.
       - Maven 3.8+ when running standalone core examples without their launcher script; Gradle is included for this repository's agent examples.
       
       The CI workflows are the source of truth for the server versions exercised by this SDK. See the [agent E2E matrix](.github/workflows/agent-e2e.yml) for its pinned server version.
      @@ -101,10 +140,9 @@ dependencies {
       
       ## AI agent quickstart
       
      -Use this path when your agent needs LLM reasoning, tools, guardrails, handoffs, or human approval. Configure the LLM provider credential on the **Conductor server process** first—setting it only in the client shell is not enough. The [agent getting-started guide](docs/agents/getting-started.md) covers local and remote server setup.
      +Use this path when your agent needs LLM reasoning, tools, guardrails, handoffs, or human approval. Select a server above first. For a local CLI server, configure the LLM provider credential in the server environment **before** starting it. For Developer Edition, configure the provider integration in the hosted cluster. The [agent getting-started guide](docs/agents/getting-started.md) covers both paths.
       
       ```shell
      -export CONDUCTOR_SERVER_URL=http://localhost:8080/api
       export CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini
       
       # Run the maintained basic-agent example from this repository.
      @@ -122,16 +160,9 @@ Start with the [Google ADK examples](agent-examples/src/main/java/org/conductoro
       
       ## Workflow and worker quickstart
       
      -This maintained example registers a workflow, starts a Java worker, executes the workflow, and prints `Result: PASSED`.
      -
      -### Recommended: use a CLI-managed server
      +With a server selected above, this maintained example registers a workflow, starts a Java worker, executes the workflow, and prints `Result: PASSED`.
       
       ```shell
      -# Start the local server once (see docs/server-setup.md).
      -conductor server start
      -export CONDUCTOR_SERVER_URL=http://localhost:8080/api
      -
      -# The launcher reuses the explicitly configured server.
       cd examples/basics/hello-world
       ./run.sh
       ```
      @@ -144,17 +175,9 @@ Output: {greeting=Hello, Developer! Welcome to Conductor.}
       Result: PASSED
       ```
       
      -Open the CLI-managed server UI at [http://localhost:8080](http://localhost:8080), then stop it when finished:
      -
      -```shell
      -conductor server stop
      -```
      -
      -### Optional: let the launcher manage Docker
      -
      -Leave `CONDUCTOR_SERVER_URL` unset and run the same launcher. It starts the example's Docker Compose server; its UI is [http://localhost:1234](http://localhost:1234). Stop that path with `docker compose down` from `examples/basics/hello-world`.
      +The launcher requires Docker to build and run the example. With `CONDUCTOR_SERVER_URL` set, it reuses the selected remote, CLI-managed, or Docker server. If you leave it unset, the launcher starts its own Docker Compose server; stop that server with `docker compose down` from `examples/basics/hello-world`.
       
      -For any existing server, set `CONDUCTOR_SERVER_URL` explicitly before running the launcher. For worker patterns, workflow definitions, and testing, continue with the [core examples catalog](examples/README.md), [worker guide](docs/workers.md), and [workflow guide](docs/workflows.md).
      +For worker patterns, workflow definitions, and testing, continue with the [core examples catalog](examples/README.md), [worker guide](docs/workers.md), and [workflow guide](docs/workflows.md).
       
       ## Common tasks
       
      diff --git a/conductor-client-metrics/README.md b/conductor-client-metrics/README.md
      index 9ef6e767a..f23958d8a 100644
      --- a/conductor-client-metrics/README.md
      +++ b/conductor-client-metrics/README.md
      @@ -6,9 +6,11 @@ The `conductor-client-metrics` module provides Prometheus metrics for Java SDK c
       
       Add the metrics module to the worker application:
       
      +Use the published version from [Maven Central](https://search.maven.org/artifact/org.conductoross/conductor-client-metrics).
      +
       ```groovy
       dependencies {
      -    implementation 'org.conductoross:conductor-client-metrics:5.1.0'
      +    implementation 'org.conductoross:conductor-client-metrics:'
       }
       ```
       
      diff --git a/conductor-client/README.md b/conductor-client/README.md
      index 60f709155..f711c1c4d 100644
      --- a/conductor-client/README.md
      +++ b/conductor-client/README.md
      @@ -5,17 +5,19 @@ This module provides the core client library to interact with Conductor through
       ## Getting Started
       
       ### Prerequisites
      -- Java 11 or higher
      +- Java 21 or higher
       - A Gradle or Maven project properly set up
       - A running Conductor server ([start one locally with the CLI](../docs/server-setup.md), or use a remote server)
       
       ### Using Conductor Client
       
      +Use the published version from [Maven Central](https://search.maven.org/artifact/org.conductoross/conductor-client).
      +
       1. **Add `conductor-client` dependency to your project**
       
       For Gradle:
       ```groovy
      -implementation 'org.conductoross:conductor-client:4.0.0'
      +implementation 'org.conductoross:conductor-client:'
       ```
       
       For Maven:
      @@ -23,7 +25,7 @@ For Maven:
       
           org.conductoross
           conductor-client
      -    4.0.0
      +    <VERSION>
       
       ```
       
      @@ -89,7 +91,7 @@ public class HelloWorker implements Worker {
       }
       ```
       
      -> **Note:** The full code for the above examples can be found [here](../examples/src/main/java/com/netflix/conductor/gettingstarted).
      +> **Note:** For a maintained end-to-end worker example, see [Hello World](../examples/basics/hello-world/).
       
       ## File uploads and downloads
       
      diff --git a/design/api_client_enhacements.md b/design/api_client_enhacements.md
      index 1a0771e20..3eb2dcbc6 100644
      --- a/design/api_client_enhacements.md
      +++ b/design/api_client_enhacements.md
      @@ -970,5 +970,5 @@ This design document provides a language-agnostic blueprint for implementing a C
       The design works well for stable environments with properly configured token TTLs. For production deployments in varied network conditions, implementing the proposed enhancements (retry logic, circuit breaker, observability) is recommended.
       
       For language-specific implementations, refer to:
      -- [Requirements](#requirements) section for functional and non-functional requirements
      +- [Current Limitations](#current-limitations) and [Proposed Improvements](#proposed-improvements) for the design rationale and implementation requirements
       - [Java SDK Implementation Reference](#java-sdk-implementation-reference) for concrete examples
      diff --git a/docs/agents/getting-started.md b/docs/agents/getting-started.md
      index 3f64b7aa6..50e0ff247 100644
      --- a/docs/agents/getting-started.md
      +++ b/docs/agents/getting-started.md
      @@ -24,7 +24,7 @@ Wait until the server is healthy:
       curl --fail http://localhost:8080/health
       ```
       
      -If you need a containerized server, use the optional [Docker setup](../server-setup.md#optional-docker). If you use an existing OSS or Orkes server, use its URL and authentication instead. The selected `provider/model` must be configured on that server.
      +If you need a containerized server, use the [Docker fallback](../server-setup.md#docker-fallback). If you use an existing OSS or Orkes server, use its URL and authentication instead. The selected `provider/model` must be configured on that server.
       
       ## 2. Add the dependency
       
      diff --git a/docs/server-setup.md b/docs/server-setup.md
      index c7c73b9ca..2b0192e94 100644
      --- a/docs/server-setup.md
      +++ b/docs/server-setup.md
      @@ -1,10 +1,26 @@
      -# Start a local Conductor server
      +# Connect the SDK to a Conductor server
       
      -Use the [Conductor CLI](https://github.com/conductor-oss/conductor-cli) for local development. It is the recommended path because the same CLI also creates workflows, starts executions, inspects status, and manages schedules.
      +Choose the hosted Orkes Developer Edition by default, or run Conductor locally for a self-managed development environment.
      +
      +## Recommended default: Orkes Developer Edition
      +
      +Sign in to [Orkes Developer Edition](https://developer.orkescloud.com/), create an application and access key, then configure the SDK with the hosted API endpoint. Do not commit the key or secret to source control.
      +
      +```bash
      +export CONDUCTOR_SERVER_URL=https://developer.orkescloud.com/api
      +export CONDUCTOR_AUTH_KEY=
      +export CONDUCTOR_AUTH_SECRET=
      +```
      +
      +For AI agents, configure the LLM provider integration in the hosted cluster before running an agent. See the [agent getting-started guide](agents/getting-started.md).
      +
      +## Run locally
      +
      +Use the [Conductor CLI](https://github.com/conductor-oss/conductor-cli) when you want a local server. The CLI also creates workflows, starts executions, inspects status, and manages schedules.
       
       Prerequisites: Java 21+ (the CLI runs the local server JAR) and Node.js/npm.
       
      -## Recommended: CLI
      +### Preferred local path: CLI
       
       Install the CLI with npm, then start the local server:
       
      @@ -22,7 +38,7 @@ export CONDUCTOR_SERVER_URL=http://localhost:8080/api
       
       Use `conductor server stop` when you are finished. See the [CLI repository](https://github.com/conductor-oss/conductor-cli) for alternative installation methods and server options.
       
      -## Optional: Docker
      +### Docker fallback
       
       Use Docker when you need a containerized server or are running an example that includes a `docker-compose.yml`:
       
      @@ -32,9 +48,9 @@ docker run --rm -p 8080:8080 -p 1234:5000 conductoross/conductor:latest
       
       The API is `http://localhost:8080/api` and the UI is `http://localhost:1234`.
       
      -## Use an existing server
      +## Use another remote server
       
      -Set `CONDUCTOR_SERVER_URL` to the server API endpoint and, when required, set `CONDUCTOR_AUTH_KEY` and `CONDUCTOR_AUTH_SECRET`. Do not put credentials in source code or workflow input.
      +Set `CONDUCTOR_SERVER_URL` to the server's `/api` endpoint and, when required, set `CONDUCTOR_AUTH_KEY` and `CONDUCTOR_AUTH_SECRET`. Do not put credentials in source code or workflow input.
       
       ## Give your coding agent Conductor context
       
      diff --git a/examples/README.md b/examples/README.md
      index ab0799656..004901498 100644
      --- a/examples/README.md
      +++ b/examples/README.md
      @@ -249,36 +249,36 @@ See [`manifest.json`](manifest.json) for per-example metadata: category, workflo
       
       | Example | Description |
       |---------|-------------|
      -| [api-test-generation](crm/api-test-generation/) | A Java Conductor workflow that automatically generates API tests from an OpenAPI specification. pa... |
      -| [bug-triage](crm/bug-triage/) | A Java Conductor workflow that automatically triages bug reports. parsing the report text, classif... |
      +| [api-test-generation](devops/api-test-generation/) | A Java Conductor workflow that automatically generates API tests from an OpenAPI specification. pa... |
      +| [bug-triage](devops/bug-triage/) | A Java Conductor workflow that automatically triages bug reports. parsing the report text, classif... |
       | [campaign-automation](crm/campaign-automation/) | A Java Conductor workflow that runs a complete marketing campaign lifecycle. designing the campaig... |
      -| [chatbot-orchestration](crm/chatbot-orchestration/) | A Java Conductor workflow that processes a chatbot conversation turn. receiving the user message w... |
      -| [code-generation](crm/code-generation/) | A Java Conductor workflow that generates code from natural language requirements. parsing requirem... |
      -| [code-review-ai](crm/code-review-ai/) | A Java Conductor workflow that reviews pull requests automatically. parsing the diff to extract ch... |
      -| [commit-analysis](crm/commit-analysis/) | A Java Conductor workflow that analyzes a repository's commit history. parsing commits from a bran... |
      +| [chatbot-orchestration](ai/chatbot-orchestration/) | A Java Conductor workflow that processes a chatbot conversation turn. receiving the user message w... |
      +| [code-generation](ai-generation/code-generation/) | A Java Conductor workflow that generates code from natural language requirements. parsing requirem... |
      +| [code-review-ai](ai-generation/code-review-ai/) | A Java Conductor workflow that reviews pull requests automatically. parsing the diff to extract ch... |
      +| [commit-analysis](devops/commit-analysis/) | A Java Conductor workflow that analyzes a repository's commit history. parsing commits from a bran... |
       | [customer-journey](crm/customer-journey/) | A Java Conductor workflow that maps a customer's journey from first contact to conversion. trackin... |
      -| [deployment-ai](crm/deployment-ai/) | A Java Conductor workflow that makes deployment decisions intelligently. analyzing code changes in... |
      -| [document-qa](crm/document-qa/) | A Java Conductor workflow that answers questions about documents. ingesting a document from a URL,... |
      -| [documentation-ai](crm/documentation-ai/) | A Java Conductor workflow that generates documentation from source code. analyzing a repository to... |
      +| [deployment-ai](ai-generation/deployment-ai/) | A Java Conductor workflow that makes deployment decisions intelligently. analyzing code changes in... |
      +| [document-qa](ai/document-qa/) | A Java Conductor workflow that answers questions about documents. ingesting a document from a URL,... |
      +| [documentation-ai](ai-generation/documentation-ai/) | A Java Conductor workflow that generates documentation from source code. analyzing a repository to... |
       | [drip-campaign](crm/drip-campaign/) | A Java Conductor workflow that runs a drip email campaign for a contact. enrolling them in a campa... |
      -| [event-management](crm/event-management/) | A Java Conductor workflow that manages an event lifecycle. planning the event with venue and sched... |
      -| [helpdesk-routing](crm/helpdesk-routing/) | A Java Conductor workflow that routes helpdesk tickets to the right support tier. classifying the ... |
      -| [incident-ai](crm/incident-ai/) | A Java Conductor workflow that handles production incidents end-to-end. detecting an anomaly from ... |
      -| [knowledge-base-sync](crm/knowledge-base-sync/) | A Java Conductor workflow that keeps a knowledge base in sync with a source. crawling the source U... |
      +| [event-management](events/event-management/) | A Java Conductor workflow that manages an event lifecycle. planning the event with venue and sched... |
      +| [helpdesk-routing](human-in-loop/helpdesk-routing/) | A Java Conductor workflow that routes helpdesk tickets to the right support tier. classifying the ... |
      +| [incident-ai](ai-generation/incident-ai/) | A Java Conductor workflow that handles production incidents end-to-end. detecting an anomaly from ... |
      +| [knowledge-base-sync](ai/knowledge-base-sync/) | A Java Conductor workflow that keeps a knowledge base in sync with a source. crawling the source U... |
       | [lead-nurturing](crm/lead-nurturing/) | A Java Conductor workflow that nurtures a lead through a personalized outreach sequence. segmentin... |
       | [lead-scoring](crm/lead-scoring/) | Your top rep just spent three weeks nurturing a lead who was never going to buy: meanwhile, a VP of ... |
      -| [monitoring-ai](crm/monitoring-ai/) | A Java Conductor workflow that provides intelligent monitoring. collecting system metrics from a s... |
      -| [named-entity-extraction](crm/named-entity-extraction/) | A Java Conductor workflow that extracts named entities from text. tokenizing the input into words,... |
      -| [pr-review-ai](crm/pr-review-ai/) | A Java Conductor workflow that automates pull request reviews. fetching the diff from the reposito... |
      -| [question-answering](crm/question-answering/) | A Java Conductor workflow that answers natural language questions from a knowledge base. parsing t... |
      -| [release-notes-ai](crm/release-notes-ai/) | A Java Conductor workflow that generates release notes automatically. collecting commits between t... |
      -| [sentiment-analysis](crm/sentiment-analysis/) | A Java Conductor workflow that analyzes sentiment in customer text. preprocessing the input (clean... |
      -| [summarization-pipeline](crm/summarization-pipeline/) | A Java Conductor workflow that summarizes long documents. extracting logical sections from the inp... |
      -| [test-generation](crm/test-generation/) | A Java Conductor workflow that automatically generates unit tests from source code. analyzing the ... |
      -| [text-classification](crm/text-classification/) | A Java Conductor workflow that classifies text into categories. preprocessing the input, extractin... |
      -| [ticket-management](crm/ticket-management/) | A Java Conductor workflow that manages the full lifecycle of a support ticket. creating the ticket... |
      -| [voice-bot](crm/voice-bot/) | A Java Conductor workflow that powers a voice-based conversational bot. transcribing caller audio ... |
      -| [webinar-registration](crm/webinar-registration/) | A Java Conductor workflow that manages the end-to-end webinar registration experience. registering... |
      +| [monitoring-ai](ai-generation/monitoring-ai/) | A Java Conductor workflow that provides intelligent monitoring. collecting system metrics from a s... |
      +| [named-entity-extraction](data/named-entity-extraction/) | A Java Conductor workflow that extracts named entities from text. tokenizing the input into words,... |
      +| [pr-review-ai](ai-generation/pr-review-ai/) | A Java Conductor workflow that automates pull request reviews. fetching the diff from the reposito... |
      +| [question-answering](ai/question-answering/) | A Java Conductor workflow that answers natural language questions from a knowledge base. parsing t... |
      +| [release-notes-ai](ai-generation/release-notes-ai/) | A Java Conductor workflow that generates release notes automatically. collecting commits between t... |
      +| [sentiment-analysis](data/sentiment-analysis/) | A Java Conductor workflow that analyzes sentiment in customer text. preprocessing the input (clean... |
      +| [summarization-pipeline](data/summarization-pipeline/) | A Java Conductor workflow that summarizes long documents. extracting logical sections from the inp... |
      +| [test-generation](devops/test-generation/) | A Java Conductor workflow that automatically generates unit tests from source code. analyzing the ... |
      +| [text-classification](data/text-classification/) | A Java Conductor workflow that classifies text into categories. preprocessing the input, extractin... |
      +| [ticket-management](human-in-loop/ticket-management/) | A Java Conductor workflow that manages the full lifecycle of a support ticket. creating the ticket... |
      +| [voice-bot](ai/voice-bot/) | A Java Conductor workflow that powers a voice-based conversational bot. transcribing caller audio ... |
      +| [webinar-registration](events/webinar-registration/) | A Java Conductor workflow that manages the end-to-end webinar registration experience. registering... |
       
       ## Data (44)
       
      @@ -295,13 +295,13 @@ See [`manifest.json`](manifest.json) for per-example metadata: category, workflo
       | [data-catalog](data/data-catalog/) | A Java Conductor workflow example for building a data catalog. discovering data assets across schema... |
       | [data-compression](data/data-compression/) | A Java Conductor workflow example for intelligent data compression. analyzing data characteristics t... |
       | [data-dedup](data/data-dedup/) | A Java Conductor workflow example for data deduplication: loading records, computing dedup keys from... |
      -| [data-encryption](data/data-encryption/) | A Java Conductor workflow example for field-level data encryption. generating an encryption key for ... |
      +| [data-encryption](security/data-encryption/) | A Java Conductor workflow example for field-level data encryption. generating an encryption key for ... |
       | [data-enrichment](data/data-enrichment/) | Marketing hands you a spreadsheet of 10,000 leads. Each row has a name, an email, and a zip code. Sa... |
       | [data-export](data/data-export/) | A Java Conductor workflow example for data export: querying a data source, then exporting the result... |
       | [data-lake-ingestion](data/data-lake-ingestion/) | A Java Conductor workflow example for data lake ingestion: validating incoming records against a sch... |
       | [data-lineage](data/data-lineage/) | A Java Conductor workflow example for data lineage tracking: registering the data source origin, app... |
      -| [data-masking](data/data-masking/) | A Java Conductor workflow example for data masking: loading records, detecting PII fields (SSNs, ema... |
      -| [data-migration](data/data-migration/) | A Java Conductor workflow example for database-to-database data migration. extracting records from a... |
      +| [data-masking](security/data-masking/) | A Java Conductor workflow example for data masking: loading records, detecting PII fields (SSNs, ema... |
      +| [data-migration](microservices/data-migration/) | A Java Conductor workflow example for database-to-database data migration. extracting records from a... |
       | [data-partitioning](data/data-partitioning/) | A Java Conductor workflow example for data partitioning. splitting a dataset into two partitions bas... |
       | [data-quality-checks](data/data-quality-checks/) | The executive dashboard shows 15% revenue growth this quarter. The CEO quotes it in the board meetin... |
       | [data-reconciliation](data/data-reconciliation/) | A Java Conductor workflow example for data reconciliation. fetching records from two independent sou... |
      @@ -346,7 +346,7 @@ See [`manifest.json`](manifest.json) for per-example metadata: category, workflo
       | [ci-cd-pipeline](devops/ci-cd-pipeline/) | Someone pushed to main. Seven CI jobs kicked off in three different systems. The unit tests passed, ... |
       | [commit-analysis](devops/commit-analysis/) | A Java Conductor workflow that analyzes a repository's commit history. parsing commits from a bran... |
       | [compliance-scanning](devops/compliance-scanning/) | Orchestrates infrastructure compliance scanning using [Conductor](https://github.com/conductor-oss/c... |
      -| [container-orchestration](devops/container-orchestration/) | Orchestrates a container build-scan-deploy pipeline using [Conductor](https://github.com/conductor-o... |
      +| [container-orchestration](advanced/container-orchestration/) | Orchestrates a container build-scan-deploy pipeline using [Conductor](https://github.com/conductor-o... |
       | [cost-optimization](devops/cost-optimization/) | Orchestrates cloud cost optimization using [Conductor](https://github.com/conductor-oss/conductor). ... |
       | [custom-metrics](devops/custom-metrics/) | Automates custom metrics pipelines using [Conductor](https://github.com/conductor-oss/conductor). Th... |
       | [database-backup](devops/database-backup/) | The production disk died on a Tuesday. The team pulled up the backup schedule and discovered the las... |
      @@ -439,7 +439,7 @@ See [`manifest.json`](manifest.json) for per-example metadata: category, workflo
       | [event-choreography](events/event-choreography/) | Choreography pattern: services communicate through events with no central orchestrator. Each service... |
       | [event-correlation](events/event-correlation/) | Event Correlation. init correlation session, fork to receive order/payment/shipping events in para... |
       | [event-dedup](events/event-dedup/) | Event deduplication workflow. computes a hash of the event payload, checks if the event has been s... |
      -| [event-driven-microservices](events/event-driven-microservices/) | Event-driven microservices workflow: order_service -> emit_order_created -> payment_service -> emit_... |
      +| [event-driven-microservices](microservices/event-driven-microservices/) | Event-driven microservices workflow: order_service -> emit_order_created -> payment_service -> emit_... |
       | [event-driven-saga](events/event-driven-saga/) | A customer places an order. Your service creates the order record, charges their credit card, and th... |
       | [event-driven-workflow](events/event-driven-workflow/) | Event-driven workflow that receives events, classifies them by type, and routes to the appropriate h... |
       | [event-fanout](events/event-fanout/) | Event fan-out workflow that receives an event, fans out to analytics, storage, and notification proc... |
      @@ -454,7 +454,7 @@ See [`manifest.json`](manifest.json) for per-example metadata: category, workflo
       | [event-replay-testing](events/event-replay-testing/) | Event Replay Testing. loads recorded events, sets up a sandbox environment, replays each event in ... |
       | [event-routing](events/event-routing/) | An order-cancellation event lands in the user-profile handler. The handler doesn't know what to do w... |
       | [event-schema-validation](events/event-schema-validation/) | Event Schema Validation. validate an incoming event against a named schema, then route valid event... |
      -| [event-sourcing](events/event-sourcing/) | Event Sourcing. load event log, append new event, rebuild aggregate state, and snapshot for a bank... |
      +| [event-sourcing](microservices/event-sourcing/) | Event Sourcing. load event log, append new event, rebuild aggregate state, and snapshot for a bank... |
       | [event-split](events/event-split/) | Splits a composite event into multiple sub-events for parallel processing using FORK_JOIN. Uses [Con... |
       | [event-transformation](events/event-transformation/) | Event Transformation Pipeline. parse raw events, enrich with context, map to CloudEvents schema, a... |
       | [event-ttl](events/event-ttl/) | Event TTL workflow that checks if an event has expired, processes it if still valid, or logs it if t... |
      @@ -609,7 +609,7 @@ See [`manifest.json`](manifest.json) for per-example metadata: category, workflo
       | [multi-level-approval](human-in-loop/multi-level-approval/) | A Java Conductor workflow example for sequential multi-level approval. routing a request through M... |
       | [multi-tenant-approval](human-in-loop/multi-tenant-approval/) | A Java Conductor workflow example for multi-tenant SaaS approval routing. loading each tenant's ap... |
       | [quality-gate](human-in-loop/quality-gate/) | A Java Conductor workflow example for deployment quality gates. running an automated test suite (4... |
      -| [sla-monitoring](human-in-loop/sla-monitoring/) | A Java Conductor workflow example for measuring human approval response times against SLA targets . ... |
      +| [sla-monitoring](devops/sla-monitoring/) | A Java Conductor workflow example for measuring human approval response times against SLA targets . ... |
       | [slack-approval](human-in-loop/slack-approval/) | A Java Conductor workflow example for Slack-native approvals. submitting a request, posting a Slac... |
       | [ticket-management](human-in-loop/ticket-management/) | A Java Conductor workflow that manages the full lifecycle of a support ticket. creating the ticket... |
       | [training-data-labeling](human-in-loop/training-data-labeling/) | A Java Conductor workflow example for ML training data quality. preparing a labeling batch, using ... |
      @@ -854,7 +854,7 @@ See [`manifest.json`](manifest.json) for per-example metadata: category, workflo
       |---------|-------------|
       | [alerting-pipeline](scheduling/alerting-pipeline/) | A Java Conductor workflow example for building an alerting pipeline. evaluating metric rules again... |
       | [anomaly-detection](scheduling/anomaly-detection/) | Request latency on your checkout service crept from 120ms to 450ms over six hours. Nobody noticed be... |
      -| [apm-workflow](scheduling/apm-workflow/) | A Java Conductor workflow example for application performance monitoring (APM). collecting distrib... |
      +| [apm-workflow](devops/apm-workflow/) | A Java Conductor workflow example for application performance monitoring (APM). collecting distrib... |
       | [batch-scheduling](scheduling/batch-scheduling/) | Every night at 2 AM, four cron jobs fire simultaneously: the ETL import, the report generator, the d... |
       | [calendar-integration](scheduling/calendar-integration/) | A Java Conductor workflow example for calendar integration. fetching events from a calendar, compa... |
       | [capacity-monitoring](scheduling/capacity-monitoring/) | A Java Conductor workflow example for capacity monitoring. measuring current resource utilization ... |
      @@ -862,21 +862,21 @@ See [`manifest.json`](manifest.json) for per-example metadata: category, workflo
       | [compliance-monitoring](scheduling/compliance-monitoring/) | A Java Conductor workflow example for compliance monitoring. scanning infrastructure resources, ev... |
       | [cost-monitoring](scheduling/cost-monitoring/) | A Java Conductor workflow example for cloud cost monitoring. collecting billing data across accoun... |
       | [cron-job-orchestration](scheduling/cron-job-orchestration/) | The nightly data export runs at 2 AM. It creates 4 GB of temp files in `/tmp`, writes results to S3,... |
      -| [custom-metrics](scheduling/custom-metrics/) | A Java Conductor workflow example for custom metrics. defining business-specific metrics, collecti... |
      +| [custom-metrics](devops/custom-metrics/) | A Java Conductor workflow example for custom metrics. defining business-specific metrics, collecti... |
       | [deadline-management](scheduling/deadline-management/) | The SOC 2 compliance filing was due Friday. On Monday morning, the auditor emails asking where it is... |
       | [dependency-mapping](scheduling/dependency-mapping/) | A Java Conductor workflow example for mapping service dependencies. discovering services in an env... |
       | [distributed-logging](scheduling/distributed-logging/) | A Java Conductor workflow example for distributed logging. collecting logs from multiple services ... |
       | [health-dashboard](scheduling/health-dashboard/) | A Java Conductor workflow example for building a health dashboard. checking the health of API serv... |
      -| [log-aggregation](scheduling/log-aggregation/) | A Java Conductor workflow example for log aggregation. collecting logs from multiple sources, pars... |
      +| [log-aggregation](devops/log-aggregation/) | A Java Conductor workflow example for log aggregation. collecting logs from multiple sources, pars... |
       | [maintenance-windows](scheduling/maintenance-windows/) | A Java Conductor workflow example for maintenance window management. checking whether the current ... |
      -| [metrics-collection](scheduling/metrics-collection/) | A Java Conductor workflow example for metrics collection. gathering infrastructure metrics (CPU, m... |
      +| [metrics-collection](devops/metrics-collection/) | A Java Conductor workflow example for metrics collection. gathering infrastructure metrics (CPU, m... |
       | [performance-profiling](scheduling/performance-profiling/) | A Java Conductor workflow example for performance profiling. instrumenting a service, collecting C... |
      -| [predictive-monitoring](scheduling/predictive-monitoring/) | A Java Conductor workflow example for predictive monitoring. collecting historical metric data, tr... |
      +| [predictive-monitoring](devops/predictive-monitoring/) | A Java Conductor workflow example for predictive monitoring. collecting historical metric data, tr... |
       | [recurring-billing](scheduling/recurring-billing/) | A Java Conductor workflow example for recurring billing. generating invoices on a recurring schedu... |
       | [root-cause-analysis](scheduling/root-cause-analysis/) | A Java Conductor workflow example for automated root cause analysis. detecting an issue, collectin... |
       | [scheduled-reports](scheduling/scheduled-reports/) | A Java Conductor workflow example for scheduled report generation. querying data sources, formatti... |
       | [sla-scheduling](scheduling/sla-scheduling/) | A Java Conductor workflow example for SLA-aware scheduling. prioritizing tickets by SLA urgency, e... |
      -| [threshold-alerting](scheduling/threshold-alerting/) | A Java Conductor workflow example for threshold alerting. checking a metric against warning and cr... |
      +| [threshold-alerting](devops/threshold-alerting/) | A Java Conductor workflow example for threshold alerting. checking a metric against warning and cr... |
       | [time-based-triggers](scheduling/time-based-triggers/) | A Java Conductor workflow example for time-based triggering. checking the current time and routing... |
       | [timezone-handling](scheduling/timezone-handling/) | A Java Conductor workflow example for timezone handling. detecting a user's timezone, converting r... |
       | [trace-collection](scheduling/trace-collection/) | A Java Conductor workflow example for distributed trace collection. instrumenting services, collec... |
      @@ -961,7 +961,7 @@ See [`manifest.json`](manifest.json) for per-example metadata: category, workflo
       | [idempotent-start](task-patterns/idempotent-start/) | Idempotent start demo. demonstrates correlationId-based dedup and search-based idempotency. Uses [... |
       | [inline-tasks](task-patterns/inline-tasks/) | Demonstrates INLINE tasks. JavaScript that runs on the Conductor server with no workers. Uses [Condu... |
       | [jq-transform-advanced](task-patterns/jq-transform-advanced/) | Advanced JQ data transformations. flatten orders, aggregate by customer, classify into tiers. Uses... |
      -| [map-reduce](task-patterns/map-reduce/) | MapReduce Pattern. Splits log files into parallel analysis tasks using FORK_JOIN_DYNAMIC, then aggre... |
      +| [map-reduce](advanced/map-reduce/) | MapReduce Pattern. Splits log files into parallel analysis tasks using FORK_JOIN_DYNAMIC, then aggre... |
       | [nested-sub-workflows](task-patterns/nested-sub-workflows/) | Three-level nested order processing. order fulfillment (Level 1) delegates to a payment sub-workfl... |
       | [nested-switch](task-patterns/nested-switch/) | Multi-level decision tree using nested SWITCH tasks with value-param. Uses [Conductor](https://githu... |
       | [passing-output-to-input](task-patterns/passing-output-to-input/) | Shows all the ways to pass data between tasks. Uses [Conductor](https://github.com/conductor-oss/conductor) to orchestrate independent services as workers.com/conductor-oss/con... |
      @@ -980,7 +980,7 @@ See [`manifest.json`](manifest.json) for per-example metadata: category, workflo
       | [task-definitions](task-patterns/task-definitions/) | Task definitions test. runs td_fast_task to verify task definition configuration. Uses [Conductor]... |
       | [task-domains](task-patterns/task-domains/) | Task Domains demo. route tasks to specific worker groups using domains. Uses [Conductor](https://g... |
       | [task-input-templates](task-patterns/task-input-templates/) | Shows reusable parameter mapping patterns. Uses [Conductor](https://github.com/conductor-oss/conductor) to orchestrate independent services as workers.com/conductor-oss/conduct... |
      -| [task-priority](task-patterns/task-priority/) | Workflow priority demo. priority levels 0-99 (higher = more important). Uses [Conductor](https://g... |
      +| [task-priority](advanced/task-priority/) | Workflow priority demo. priority levels 0-99 (higher = more important). Uses [Conductor](https://g... |
       | [terminate-task](task-patterns/terminate-task/) | Early exit with TERMINATE based on validation. Uses [Conductor](https://github.com/conductor-oss/conductor) to orchestrate independent services as workers.com/conductor-oss/con... |
       | [wait-for-event](task-patterns/wait-for-event/) | WAIT task demo. pauses a workflow durably until an external system sends a signal (approval, webhook... |
       | [workflow-archival](task-patterns/workflow-archival/) | Archival demo workflow. single task for demonstrating cleanup policies. Uses [Conductor](https://g... |