diff --git a/conductor-client-spring/build.gradle b/conductor-client-spring/build.gradle
index bb8fc1847..f5d02f10e 100644
--- a/conductor-client-spring/build.gradle
+++ b/conductor-client-spring/build.gradle
@@ -20,6 +20,8 @@ repositories {
dependencies {
api project(":conductor-client")
implementation 'org.springframework.boot:spring-boot-starter:3.5.13'
+
+ testImplementation 'org.springframework.boot:spring-boot-starter-test:3.3.13'
}
java {
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 81c2ec474..301005cf4 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
@@ -18,10 +18,13 @@
import java.util.Optional;
import org.apache.commons.lang3.StringUtils;
+import org.conductoross.conductor.client.FileClient;
+import org.conductoross.conductor.client.FileClientProperties;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureOrder;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.core.Ordered;
@@ -122,4 +125,18 @@ public WorkflowExecutor workflowExecutor(ConductorClient client, AnnotatedWorker
public WorkflowClient workflowClient(ConductorClient client) {
return new WorkflowClient(client);
}
+
+ @Bean
+ @ConfigurationProperties("conductor.file-client")
+ @ConditionalOnMissingBean
+ public FileClientProperties fileClientProperties() {
+ return new FileClientProperties();
+ }
+
+ @Bean
+ @ConditionalOnBean(ConductorClient.class)
+ @ConditionalOnMissingBean
+ public FileClient fileClient(ConductorClient client, FileClientProperties fileClientProperties) {
+ return new FileClient(client, fileClientProperties, null);
+ }
}
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 9eb138fc8..1f8d9992e 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
@@ -13,6 +13,7 @@
package io.orkes.conductor.client.spring;
import org.apache.commons.lang3.StringUtils;
+import org.conductoross.conductor.client.FileClient;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -128,4 +129,10 @@ public SecretClient orkesSecretClient(OrkesClients clients) {
return clients.getSecretClient();
}
+ @Bean
+ @ConditionalOnBean(ApiClient.class)
+ @ConditionalOnMissingBean
+ public FileClient fileClient(ApiClient client) {
+ return new FileClient(client);
+ }
}
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
new file mode 100644
index 000000000..0a1c10342
--- /dev/null
+++ b/conductor-client-spring/src/test/java/com/netflix/conductor/client/spring/ConductorClientAutoConfigurationTest.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 com.netflix.conductor.client.spring;
+
+import org.conductoross.conductor.client.FileClient;
+import org.conductoross.conductor.client.FileClientProperties;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ConductorClientAutoConfigurationTest {
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(ConductorClientAutoConfiguration.class));
+
+ @Test
+ void zeroConfigYieldsWorkingFileClientBean() {
+ contextRunner
+ .withPropertyValues("conductor.client.base-path=http://localhost:8080/api")
+ .run(context -> {
+ assertThat(context).hasSingleBean(FileClient.class);
+ FileClientProperties properties = context.getBean(FileClientProperties.class);
+ assertThat(properties.getLocalCacheDirectory())
+ .startsWith(System.getProperty("java.io.tmpdir"));
+ });
+ }
+
+ @Test
+ void cacheDirectoryOverrideIsRespected() {
+ contextRunner
+ .withPropertyValues(
+ "conductor.client.base-path=http://localhost:8080/api",
+ "conductor.file-client.local-cache-directory=/tmp/custom-cache")
+ .run(context -> {
+ FileClientProperties properties = context.getBean(FileClientProperties.class);
+ assertThat(properties.getLocalCacheDirectory()).isEqualTo("/tmp/custom-cache");
+ });
+ }
+
+}
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 7d19050f0..a4c7a20a2 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,6 +14,7 @@
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;
@@ -33,6 +34,11 @@
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;
@@ -51,6 +57,7 @@
import com.netflix.conductor.common.metadata.tasks.Task;
import com.netflix.conductor.common.metadata.tasks.TaskResult;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Stopwatch;
import com.google.common.util.concurrent.Uninterruptibles;
@@ -73,6 +80,7 @@ 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;
@@ -87,9 +95,11 @@ class TaskRunner {
int taskPollTimeout,
List pollFilters,
EventDispatcher eventDispatcher,
- boolean useVirtualThreads) {
+ boolean useVirtualThreads,
+ FileClient fileClient) {
this.worker = worker;
this.taskClient = taskClient;
+ this.fileClient = fileClient;
this.updateRetryCount = updateRetryCount;
this.taskPollTimeout = taskPollTimeout;
this.pollingIntervalInMillis = worker.getPollingInterval();
@@ -346,6 +356,11 @@ 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 ){
@@ -368,6 +383,7 @@ 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());
@@ -453,6 +469,26 @@ 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 7c174b2dc..5bb21bcf6 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,6 +21,7 @@
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;
@@ -54,6 +55,7 @@ public class TaskRunnerConfigurer {
private final List pollFilters;
private final EventDispatcher eventDispatcher;
private final boolean useVirtualThreads;
+ private final FileClient fileClient;
/**
* @see TaskRunnerConfigurer.Builder
@@ -75,6 +77,7 @@ private TaskRunnerConfigurer(TaskRunnerConfigurer.Builder builder) {
this.pollFilters = builder.pollFilters;
this.eventDispatcher = builder.eventDispatcher;
this.useVirtualThreads = builder.useVirtualThreads;
+ this.fileClient = builder.fileClient;
builder.workers.forEach(this.workers::add);
taskRunners = new LinkedList<>();
}
@@ -173,7 +176,8 @@ private void startWorker(Worker worker) {
taskPollTimeout,
pollFilters,
eventDispatcher,
- useVirtualThreads);
+ useVirtualThreads,
+ fileClient);
// 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.
@@ -206,6 +210,7 @@ public static class Builder {
private final List pollFilters = new LinkedList<>();
private final EventDispatcher eventDispatcher = new EventDispatcher<>();
private boolean useVirtualThreads;
+ private FileClient fileClient;
public Builder(TaskClient taskClient, Iterable workers) {
Preconditions.checkNotNull(taskClient, "TaskClient cannot be null");
@@ -352,5 +357,10 @@ public Builder withUseVirtualThreads(boolean useVirtualThreads) {
this.useVirtualThreads = 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 10438a435..72537366e 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,10 +18,16 @@
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
@@ -179,6 +185,27 @@ public boolean isRetriable() {
private long firstStartTime;
+ @JsonIgnore
+ private transient WorkflowFileClient workflowFileClient;
+
+ @JsonIgnore
+ public FileUploader getFileUploader() { return workflowFileClient; }
+
+ public void setWorkflowFileClient(WorkflowFileClient workflowFileClient) {
+ this.workflowFileClient = workflowFileClient;
+ }
+
+ public FileHandler getInputFileHandler(String 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 275a0ceac..7339a0ab5 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
@@ -12,6 +12,7 @@
*/
package com.netflix.conductor.sdk.workflow.executor.task;
+import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
@@ -20,6 +21,11 @@
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;
@@ -31,7 +37,9 @@
import com.netflix.conductor.sdk.workflow.task.OutputParam;
import com.netflix.conductor.sdk.workflow.task.WorkflowInstanceIdInputParam;
+import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
@@ -111,9 +119,9 @@ private Object[] getInvocationParameters(Task task) {
Parameter[] parameters = workerMethod.getParameters();
if (parameterTypes.length == 1 && parameterTypes[0].equals(Task.class)) {
- return new Object[] {task};
+ return new Object[]{task};
} else if (parameterTypes.length == 1 && parameterTypes[0].equals(Map.class)) {
- return new Object[] {task.getInputData()};
+ return new Object[]{task.getInputData()};
}
return getParameters(task, parameterTypes, parameters);
@@ -124,7 +132,7 @@ private Object[] getParameters(Task task, Class>[] parameterTypes, Parameter[]
Object[] values = new Object[parameterTypes.length];
for (int i = 0; i < parameterTypes.length; i++) {
Annotation[] paramAnnotation = parameterAnnotations[i];
- if(containsWorkflowInstanceIdInputParamAnnotation(paramAnnotation)) {
+ if (containsWorkflowInstanceIdInputParamAnnotation(paramAnnotation)) {
validateParameterForWorkflowInstanceId(parameters[i]);
values[i] = task.getWorkflowInstanceId();
} else if (paramAnnotation.length > 0) {
@@ -132,7 +140,7 @@ private Object[] getParameters(Task task, Class>[] parameterTypes, Parameter[]
Class> parameterType = parameterTypes[i];
values[i] = getInputValue(task, parameterType, type, paramAnnotation);
} else {
- values[i] = om.convertValue(task.getInputData(), parameterTypes[i]);
+ values[i] = convertWithContext(task.getInputData(), parameterTypes[i], task);
}
}
@@ -146,7 +154,7 @@ private boolean containsWorkflowInstanceIdInputParamAnnotation(Annotation[] anno
}
private void validateParameterForWorkflowInstanceId(Parameter parameter) {
- if(!parameter.getType().equals(String.class)) {
+ if (!parameter.getType().equals(String.class)) {
throw new IllegalArgumentException(
"Parameter " + parameter + " is annotated with " + WorkflowInstanceIdInputParam.class.getSimpleName() +
" but is not of type " + String.class.getSimpleName() + ".");
@@ -158,7 +166,7 @@ private Object getInputValue(
InputParam ip = findInputParamAnnotation(paramAnnotation);
if (ip == null) {
- return om.convertValue(task.getInputData(), parameterType);
+ return convertWithContext(task.getInputData(), parameterType, task);
}
final String name = ip.value();
@@ -167,6 +175,16 @@ 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) {
@@ -174,7 +192,7 @@ private Object getInputValue(
Class> typeOfParameter = (Class>) parameterizedType.getActualTypeArguments()[0];
List parameterizedList = new ArrayList<>();
for (Object item : list) {
- parameterizedList.add(om.convertValue(item, typeOfParameter));
+ parameterizedList.add(convertWithContext(item, typeOfParameter, task));
}
return parameterizedList;
@@ -182,7 +200,20 @@ private Object getInputValue(
return list;
}
} else {
- return om.convertValue(value, parameterType);
+ return convertWithContext(value, parameterType, task);
+ }
+ }
+
+ private Object convertWithContext(Object source, Class> targetType, Task task) {
+ 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);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to bind task input to " + targetType.getName(), e);
}
}
@@ -201,6 +232,15 @@ 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/io/orkes/conductor/client/OrkesClients.java b/conductor-client/src/main/java/io/orkes/conductor/client/OrkesClients.java
index 700f652b8..dc8d9e5e2 100644
--- a/conductor-client/src/main/java/io/orkes/conductor/client/OrkesClients.java
+++ b/conductor-client/src/main/java/io/orkes/conductor/client/OrkesClients.java
@@ -12,6 +12,8 @@
*/
package io.orkes.conductor.client;
+import org.conductoross.conductor.client.FileClient;
+
import com.netflix.conductor.client.http.ConductorClient;
import io.orkes.conductor.client.http.*;
@@ -75,4 +77,8 @@ public EnvironmentClient getEnvironmentClient() {
public OrkesSchemaClient getSchemaClient() {
return new OrkesSchemaClient(client);
}
+
+ public FileClient getFileClient() {
+ return new FileClient(client);
+ }
}
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
new file mode 100644
index 000000000..a0acfe7c6
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/client/FileClient.java
@@ -0,0 +1,370 @@
+/*
+ * 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.InputStream;
+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.List;
+import java.util.Map;
+
+import org.conductoross.conductor.client.model.file.FileDownloadUrlResponse;
+import org.conductoross.conductor.client.model.file.FileHandle;
+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.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 com.fasterxml.jackson.core.type.TypeReference;
+
+/**
+ * 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}.
+ *
+ *
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}.
+ */
+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<>() {
+ };
+
+ private final ConductorClient client;
+ private final FileClientProperties properties;
+ private final Map fileStorageBackendsByStorageType;
+
+ /**
+ * 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());
+ }
+
+ /**
+ * Full-args constructor.
+ *
+ * @param properties {@code null} to use defaults
+ * @param fileStorageBackendsByStorageType {@code null} to use the built-in backends
+ */
+ public FileClient(ConductorClient client, FileClientProperties properties, Map fileStorageBackendsByStorageType) {
+ this.client = client;
+ this.properties = properties == null ? new FileClientProperties() : properties;
+ this.fileStorageBackendsByStorageType = fileStorageBackendsByStorageType == null ? createDefaultFileStorageBackends() : fileStorageBackendsByStorageType;
+ }
+
+ // --- FileUploader (developer-facing) ---
+
+ /**
+ * 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");
+ }
+ try {
+ fillDefaults(options, localFile);
+ long fileSize = Files.size(localFile);
+ FileUploadRequest request = FileHandlerConverter.toFileUploadRequest(workflowId, options);
+
+ FileUploadResponse response = createFileOnServer(request);
+
+ FileStorageBackend storageBackend = fileStorageBackendsByStorageType.get(response.getStorageType());
+ if (storageBackend == null) {
+ throw new FileStorageException("Server uses " + response.getStorageType()
+ + " but SDK only supports: " + fileStorageBackendsByStorageType.keySet());
+ }
+
+ if (options.isMultipart() && storageBackend.hasMultipartSupport()) {
+ uploadMultipart(response.getFileHandleId(), response.getStorageType(), localFile);
+ } else {
+ storageBackend.upload(response.getUploadUrl(), localFile);
+ confirmUpload(response.getFileHandleId());
+ }
+
+ return FileHandlerConverter.toManagedFileHandler(response, new WorkflowFileClient(this, workflowId), localFile, fileSize);
+ } catch (IOException e) {
+ throw new FileStorageException("Upload failed for: " + localFile, e);
+ }
+ }
+
+ /**
+ * Buffers the stream to a temp file, then delegates to {@link #upload(String, Path, FileUploadOptions)}.
+ */
+ public FileHandler upload(String workflowId, InputStream inputStream, FileUploadOptions options) {
+ try {
+ Path temp = Files.createTempFile("conductor-upload-", ".tmp");
+ Files.copy(inputStream, temp, StandardCopyOption.REPLACE_EXISTING);
+ return upload(workflowId, temp, options);
+ } catch (IOException e) {
+ throw new FileStorageException("Failed to write InputStream to temp file", e);
+ }
+ }
+
+ public FileHandler upload(String workflowId, Path localFile) {
+ return upload(workflowId, localFile, new FileUploadOptions());
+ }
+
+ public FileHandler upload(String workflowId, InputStream inputStream) {
+ return upload(workflowId, inputStream, new FileUploadOptions());
+ }
+
+ /**
+ * 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());
+ }
+ if (options.getContentType() == null) {
+ options.setContentType("application/octet-stream");
+ }
+ if (options.getTaskId() == null) {
+ TaskContext ctx = TaskContext.get();
+ if (ctx != null) {
+ options.setTaskId(ctx.getTaskId());
+ }
+ }
+ }
+
+ // --- SDK-internal (public for cross-package access, not developer-facing) ---
+
+ /**
+ * 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);
+ }
+ fileStorageBackendsByStorageType.get(storageType).download(urlResponse.getDownloadUrl(), destination);
+ }
+
+ /**
+ * Fetches the {@link FileHandle} metadata via {@code GET /api/files/{fileId}}.
+ */
+ public FileHandle getMetadata(String fileHandleId) {
+ ConductorClientRequest request = ConductorClientRequest.builder()
+ .method(Method.GET)
+ .path("/files/{fileId}")
+ .addPathParam("fileId", FileHandler.toFileId(fileHandleId))
+ .build();
+ return client.execute(request, FILE_HANDLE_TYPE).getData();
+ }
+
+ public int getRetryCount() {
+ return properties.getRetryCount();
+ }
+
+ public String getCacheDirectory() {
+ return properties.getLocalCacheDirectory();
+ }
+
+ /**
+ * 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))
+ .build();
+ client.execute(request, FILE_UPLOAD_COMPLETE_RESPONSE_TYPE);
+ }
+
+ // --- Private ---
+
+ private FileUploadResponse createFileOnServer(FileUploadRequest uploadRequest) {
+ ConductorClientRequest request = ConductorClientRequest.builder()
+ .method(Method.POST)
+ .path("/files")
+ .body(uploadRequest)
+ .build();
+ return client.execute(request, FILE_UPLOAD_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))
+ .build();
+ return client.execute(request, FILE_DOWNLOAD_URL_RESPONSE_TYPE).getData();
+ }
+
+ private void uploadMultipart(String fileHandleId, StorageType storageType, Path localFile) {
+ 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);
+
+ List partETags = new ArrayList<>();
+
+ for (int part = 1; part <= totalParts; part++) {
+ long offset = (long) (part - 1) * partSize;
+ long length = Math.min(partSize, fileSize - offset);
+
+ String url = getPartUploadUrl(fileHandleId, uploadId, part);
+ String etag = fileStorageBackendsByStorageType.get(storageType).uploadPart(url, localFile, offset, length);
+ partETags.add(etag);
+ }
+
+ completeMultipartUpload(fileHandleId, uploadId, partETags);
+ } catch (IOException e) {
+ throw new FileStorageException("Multipart upload failed for: " + fileHandleId, e);
+ }
+ }
+
+ 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 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 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 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())
+ );
+ }
+
+ public static Builder builder(ConductorClient client) {
+ return new Builder(client);
+ }
+
+ /**
+ * 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 final ConductorClient client;
+ private FileClientProperties properties;
+ private final Map backends = new EnumMap<>(StorageType.class);
+
+ private Builder(ConductorClient client) {
+ this.client = client;
+ }
+
+ public Builder properties(FileClientProperties properties) {
+ this.properties = properties;
+ return this;
+ }
+
+ public Builder addStorageBackend(FileStorageBackend backend) {
+ this.backends.put(backend.getStorageType(), backend);
+ return this;
+ }
+
+ public FileClient build() {
+ Map resolved = new EnumMap<>(createDefaultFileStorageBackends());
+ resolved.putAll(backends);
+ return new FileClient(client, properties, Map.copyOf(resolved));
+ }
+ }
+}
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
new file mode 100644
index 000000000..32685a75e
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/client/FileClientProperties.java
@@ -0,0 +1,64 @@
+/*
+ * 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.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. */
+ 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). */
+ private long multipartPartSize = 10L * 1024 * 1024;
+
+ public int getRetryCount() {
+ return retryCount;
+ }
+
+ 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;
+ }
+
+ public void setMultipartPartSize(long multipartPartSize) {
+ this.multipartPartSize = multipartPartSize;
+ }
+
+ private static String getTempDirectory() {
+ return System.getProperty("java.io.tmpdir");
+ }
+}
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
new file mode 100644
index 000000000..9e74cefcc
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileDownloadUrlResponse.java
@@ -0,0 +1,35 @@
+/*
+ * 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.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}.
+ */
+public class FileDownloadUrlResponse {
+
+ /** Prefixed handle: {@code conductor://file/}. */
+ private String fileHandleId;
+ private String downloadUrl;
+ /** Epoch millis. */
+ private long expiresAt;
+
+ public String getFileHandleId() { return fileHandleId; }
+ public void setFileHandleId(String fileHandleId) { this.fileHandleId = fileHandleId; }
+
+ public String getDownloadUrl() { return downloadUrl; }
+ public void setDownloadUrl(String downloadUrl) { this.downloadUrl = downloadUrl; }
+
+ public long getExpiresAt() { return expiresAt; }
+ public void setExpiresAt(long expiresAt) { this.expiresAt = expiresAt; }
+}
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/FileHandle.java
new file mode 100644
index 000000000..b29dcbc91
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileHandle.java
@@ -0,0 +1,72 @@
+/*
+ * 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.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 {
+
+ /** 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 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 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
new file mode 100644
index 000000000..16069de77
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadCompleteResponse.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.model.file;
+
+/**
+ * Response to {@code POST /api/files/{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).
+ */
+public class FileUploadCompleteResponse {
+
+ /** Prefixed handle: {@code conductor://file/}. */
+ private String fileHandleId;
+ private FileUploadStatus uploadStatus;
+ /** Content hash from the storage provider; {@code null} for local backend. */
+ private String contentHash;
+
+ public String getFileHandleId() { return fileHandleId; }
+ public void setFileHandleId(String fileHandleId) { this.fileHandleId = fileHandleId; }
+
+ public FileUploadStatus getUploadStatus() { return uploadStatus; }
+ public void setUploadStatus(FileUploadStatus uploadStatus) { this.uploadStatus = uploadStatus; }
+
+ public String getContentHash() { return contentHash; }
+ public void setContentHash(String contentHash) { this.contentHash = contentHash; }
+}
diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadRequest.java b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadRequest.java
new file mode 100644
index 000000000..3f4bdf7f6
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadRequest.java
@@ -0,0 +1,37 @@
+/*
+ * 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.model.file;
+
+/**
+ * Payload for {@code POST /api/files}. Describes the file the client intends to upload and
+ * optionally the owning {@code workflowId} / {@code taskId}.
+ */
+public class FileUploadRequest {
+
+ private String fileName;
+ private String contentType;
+ private String workflowId;
+ private String taskId;
+
+ 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 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; }
+}
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
new file mode 100644
index 000000000..5bc93a965
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadResponse.java
@@ -0,0 +1,57 @@
+/*
+ * 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.model.file;
+
+/**
+ * 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}.
+ */
+public class FileUploadResponse {
+
+ /** Prefixed handle: {@code conductor://file/}. */
+ private String fileHandleId;
+ private String fileName;
+ private String contentType;
+ private StorageType storageType;
+ private FileUploadStatus uploadStatus;
+ private String uploadUrl;
+ /** Epoch millis. */
+ private long uploadUrlExpiresAt;
+ /** Epoch millis. */
+ private long createdAt;
+
+ 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 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; }
+}
diff --git a/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadStatus.java b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadStatus.java
new file mode 100644
index 000000000..b111138e2
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadStatus.java
@@ -0,0 +1,28 @@
+/*
+ * 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.model.file;
+
+/** Server-authoritative upload lifecycle state. Persisted on the server and mirrored in DTOs. */
+public enum FileUploadStatus {
+ /** Reserved for future use (e.g. pre-initialized records); not entered by the current flow. */
+ PENDING,
+ /** Record created; byte transfer not yet confirmed by {@code POST /upload-complete}. */
+ UPLOADING,
+ /** Terminal success state — content verified present on the storage backend. */
+ UPLOADED,
+ /**
+ * Terminal failure — set by the background audit when an {@link #UPLOADING} record remains
+ * stale past the configured threshold.
+ */
+ FAILED
+}
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
new file mode 100644
index 000000000..925b5c8f1
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/FileUploadUrlResponse.java
@@ -0,0 +1,35 @@
+/*
+ * 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.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.
+ */
+public class FileUploadUrlResponse {
+
+ /** Prefixed handle: {@code conductor://file/}. */
+ private String fileHandleId;
+ private String uploadUrl;
+ /** Epoch millis. */
+ private long expiresAt;
+
+ public String getFileHandleId() { return fileHandleId; }
+ public void setFileHandleId(String fileHandleId) { this.fileHandleId = fileHandleId; }
+
+ public String getUploadUrl() { return uploadUrl; }
+ public void setUploadUrl(String uploadUrl) { this.uploadUrl = uploadUrl; }
+
+ public long getExpiresAt() { return expiresAt; }
+ public void setExpiresAt(long expiresAt) { this.expiresAt = expiresAt; }
+}
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
new file mode 100644
index 000000000..dab2b5828
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/MultipartCompleteRequest.java
@@ -0,0 +1,30 @@
+/*
+ * 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.model.file;
+
+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.
+ */
+public class MultipartCompleteRequest {
+
+ private List partETags;
+
+ public List getPartETags() { return partETags; }
+ public void setPartETags(List partETags) { this.partETags = partETags; }
+}
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
new file mode 100644
index 000000000..e54a3dc27
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/MultipartInitResponse.java
@@ -0,0 +1,28 @@
+/*
+ * 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.model.file;
+
+/** Response to {@code POST /api/files/{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). */
+ private String uploadId;
+
+ public String getFileHandleId() { return fileHandleId; }
+ public void setFileHandleId(String fileHandleId) { this.fileHandleId = fileHandleId; }
+
+ public String getUploadId() { return uploadId; }
+ public void setUploadId(String uploadId) { this.uploadId = uploadId; }
+}
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/model/file/StorageType.java
new file mode 100644
index 000000000..824de6718
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/client/model/file/StorageType.java
@@ -0,0 +1,29 @@
+/*
+ * 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.model.file;
+
+/**
+ * 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
+}
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
new file mode 100644
index 000000000..21e987916
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/client/storage/AzureFileStorageBackend.java
@@ -0,0 +1,110 @@
+/*
+ * 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
new file mode 100644
index 000000000..fc0c05589
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/client/storage/GcsFileStorageBackend.java
@@ -0,0 +1,96 @@
+/*
+ * 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
new file mode 100644
index 000000000..eb00706f5
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/client/storage/HttpBodies.java
@@ -0,0 +1,69 @@
+/*
+ * 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
new file mode 100644
index 000000000..81c078bcb
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/client/storage/LocalFileStorageBackend.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.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
new file mode 100644
index 000000000..66b8602bd
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/client/storage/S3FileStorageBackend.java
@@ -0,0 +1,105 @@
+/*
+ * 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/FileDownloadStatus.java b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileDownloadStatus.java
new file mode 100644
index 000000000..b998c10d6
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileDownloadStatus.java
@@ -0,0 +1,28 @@
+/*
+ * 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;
+
+/**
+ * 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
+}
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
new file mode 100644
index 000000000..c4b99b658
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandler.java
@@ -0,0 +1,132 @@
+/*
+ * 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
new file mode 100644
index 000000000..12a7de5a1
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandlerConverter.java
@@ -0,0 +1,47 @@
+/*
+ * 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
new file mode 100644
index 000000000..7f7792842
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandlerDeserializer.java
@@ -0,0 +1,50 @@
+/*
+ * 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
new file mode 100644
index 000000000..7fd9d880b
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileHandlerSerializer.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.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
new file mode 100644
index 000000000..e9b0ec089
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileStorageBackend.java
@@ -0,0 +1,71 @@
+/*
+ * 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
new file mode 100644
index 000000000..916488228
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileStorageException.java
@@ -0,0 +1,29 @@
+/*
+ * 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;
+
+/**
+ * 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.
+ */
+public class FileStorageException extends RuntimeException {
+
+ public FileStorageException(String message) {
+ super(message);
+ }
+
+ public FileStorageException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
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
new file mode 100644
index 000000000..cb3ced83a
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileUploadOptions.java
@@ -0,0 +1,75 @@
+/*
+ * 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;
+
+/**
+ * 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.
+ *
+ *
{@code
+ * FileUploadOptions options = new FileUploadOptions()
+ * .setContentType("application/pdf")
+ * .setMultipart(true);
+ * FileHandler handler = fileUploader.upload(workflowId, path, options);
+ * }
+ */
+public class FileUploadOptions {
+
+ private String taskId;
+ private String fileName;
+ private String contentType;
+ private boolean multipart;
+
+ public String getTaskId() {
+ return taskId;
+ }
+
+ public FileUploadOptions setTaskId(String taskId) {
+ this.taskId = taskId;
+ return this;
+ }
+
+ public String getFileName() {
+ return fileName;
+ }
+
+ public FileUploadOptions setFileName(String fileName) {
+ this.fileName = fileName;
+ return this;
+ }
+
+ public String getContentType() {
+ return contentType;
+ }
+
+ public FileUploadOptions setContentType(String contentType) {
+ this.contentType = 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
new file mode 100644
index 000000000..775cfe981
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/FileUploader.java
@@ -0,0 +1,43 @@
+/*
+ * 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
new file mode 100644
index 000000000..bead95385
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/LocalFileHandler.java
@@ -0,0 +1,67 @@
+/*
+ * 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
new file mode 100644
index 000000000..1d6d6c3d6
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/ManagedFileHandler.java
@@ -0,0 +1,154 @@
+/*
+ * 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
new file mode 100644
index 000000000..2cae44b13
--- /dev/null
+++ b/conductor-client/src/main/java/org/conductoross/conductor/sdk/file/WorkflowFileClient.java
@@ -0,0 +1,67 @@
+/*
+ * 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/TaskRunnerFileStorageTest.java b/conductor-client/src/test/java/com/netflix/conductor/client/automator/TaskRunnerFileStorageTest.java
new file mode 100644
index 000000000..3b152ef8d
--- /dev/null
+++ b/conductor-client/src/test/java/com/netflix/conductor/client/automator/TaskRunnerFileStorageTest.java
@@ -0,0 +1,171 @@
+/*
+ * 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
new file mode 100644
index 000000000..b09f1480d
--- /dev/null
+++ b/conductor-client/src/test/java/com/netflix/conductor/common/metadata/tasks/TaskGetInputFileHandlerTest.java
@@ -0,0 +1,87 @@
+/*
+ * 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
new file mode 100644
index 000000000..2a309d0be
--- /dev/null
+++ b/conductor-client/src/test/java/com/netflix/conductor/sdk/workflow/executor/task/AnnotatedWorkerFileStorageTest.java
@@ -0,0 +1,341 @@
+/*
+ * 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
new file mode 100644
index 000000000..a5295d67b
--- /dev/null
+++ b/conductor-client/src/test/java/org/conductoross/conductor/client/FileClientPropertiesTest.java
@@ -0,0 +1,28 @@
+/*
+ * 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.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FileClientPropertiesTest {
+
+ @Test
+ void defaultCacheDirectoryIsUnderJavaTmpDir() {
+ String expected = Path.of(System.getProperty("java.io.tmpdir")).toString();
+ assertTrue(new FileClientProperties().getLocalCacheDirectory().startsWith(expected));
+ }
+}
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
new file mode 100644
index 000000000..ca453d982
--- /dev/null
+++ b/conductor-client/src/test/java/org/conductoross/conductor/client/FileClientTest.java
@@ -0,0 +1,356 @@
+/*
+ * 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.ByteArrayInputStream;
+import java.io.InputStream;
+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.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.FileUploadCompleteResponse;
+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.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.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.ArgumentCaptor;
+
+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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+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.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;
+
+ @TempDir
+ Path tempDir;
+
+ private ConductorClient conductorClient;
+ private StubFileStorageBackend backend;
+ private final List stubs = new ArrayList<>();
+
+ @BeforeEach
+ void setUp() {
+ 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());
+ });
+ }
+
+ @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");
+ }
+
+ @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);
+ }
+
+ @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");
+ }
+
+ @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"));
+ }
+
+ @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)));
+ }
+
+ @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();
+
+ FileClient client = clientWithBackends(Map.of(StorageType.LOCAL, backend));
+
+ Path dest = tempDir.resolve("out/remote.bin");
+ client.download("wf-1", HANDLE_ID, StorageType.LOCAL, dest);
+
+ assertTrue(Files.exists(dest));
+ verifyPathCalled("/files/{workflowId}/{fileId}/download-url", RAW_ID);
+ }
+
+ @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));
+
+ FileHandle got = client.getMetadata(HANDLE_ID);
+ assertEquals("r.pdf", got.getFileName());
+ verifyPathCalled("/files/{fileId}", RAW_ID);
+ }
+
+ @Test
+ void confirmUploadSendsStrippedFileId() {
+ stubConfirmUpload();
+ FileClient client = clientWithBackends(Map.of(StorageType.LOCAL, backend));
+
+ client.confirmUpload(HANDLE_ID);
+
+ verifyPathCalled("/files/{fileId}/upload-complete", RAW_ID);
+ }
+
+ @Test
+ void propertiesDefaultsAppliedWhenNull() {
+ FileClient client = new FileClient(conductorClient, null, null);
+
+ assertEquals(3, client.getRetryCount());
+ assertNotNull(client.getCacheDirectory());
+ }
+
+ @Test
+ void uploadThrowsWhenWorkflowIdNull() throws Exception {
+ Path source = writeFile("wf.bin", new byte[] {1});
+ FileClient client = clientWithBackends(Map.of(StorageType.LOCAL, backend));
+
+ FileStorageException ex = assertThrows(
+ FileStorageException.class, () -> client.upload(null, source));
+ assertTrue(ex.getMessage().contains("workflowId"));
+ }
+
+ @Test
+ void builderCustomBackendOverridesBuiltIn() throws Exception {
+ Path source = writeFile("overridden.txt", "x".getBytes());
+ stubCreateFile(StorageType.LOCAL);
+ stubConfirmUpload();
+
+ StubFileStorageBackend override = new StubFileStorageBackend();
+ FileClient client = FileClient.builder(conductorClient)
+ .addStorageBackend(override)
+ .build();
+
+ client.upload("wf-1", source);
+
+ assertNotNull(override.getUploaded(UPLOAD_URL),
+ "override backend should have received the upload");
+ }
+
+ // --- helpers ---
+
+ private Path writeFile(String name, byte[] bytes) throws Exception {
+ return Files.write(tempDir.resolve(name), bytes);
+ }
+
+ private FileClient clientWithBackends(Map backends) {
+ return new FileClient(conductorClient, new FileClientProperties(), backends);
+ }
+
+ private FileClient.Builder clientBuilder() {
+ return FileClient.builder(conductorClient);
+ }
+
+ private void stubCreateFile(StorageType storageType) {
+ FileUploadResponse response = new FileUploadResponse();
+ response.setFileHandleId(HANDLE_ID);
+ response.setStorageType(storageType);
+ response.setUploadStatus(FileUploadStatus.UPLOADING);
+ response.setUploadUrl(UPLOAD_URL);
+ registerStub(req -> "/files".equals(req.getPath()), response);
+ }
+
+ private void stubConfirmUpload() {
+ registerStub(req -> "/files/{fileId}/upload-complete".equals(req.getPath()),
+ 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 stubMultipartInit() {
+ MultipartInitResponse init = new MultipartInitResponse();
+ init.setFileHandleId(HANDLE_ID);
+ init.setUploadId("up-1");
+ registerStub(req -> "/files/{fileId}/multipart".equals(req.getPath()), init);
+
+ 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 void stubMultipartComplete() {
+ registerStub(
+ req -> "/files/{fileId}/multipart/{uploadId}/complete".equals(req.getPath()),
+ new FileUploadCompleteResponse());
+ }
+
+ private void registerStub(Predicate matcher, Object data) {
+ stubs.add(new Stub(matcher, data));
+ }
+
+ 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 void verifyPathNotCalled(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);
+ }
+
+ 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 record Stub(Predicate matcher, Object data) {}
+
+ // 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();
+
+ MultipartBackend(StorageType type) { this.type = type; }
+
+ @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();
+ }
+ @Override public boolean hasMultipartSupport() { return true; }
+ }
+}
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
new file mode 100644
index 000000000..0c0c691ff
--- /dev/null
+++ b/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/FileHandlerDeserializerTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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
new file mode 100644
index 000000000..227fd50a9
--- /dev/null
+++ b/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/FileHandlerSerializationTest.java
@@ -0,0 +1,114 @@
+/*
+ * 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
new file mode 100644
index 000000000..1fee77164
--- /dev/null
+++ b/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/FileHandlerTest.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 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
new file mode 100644
index 000000000..69533010a
--- /dev/null
+++ b/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/LocalFileHandlerTest.java
@@ -0,0 +1,84 @@
+/*
+ * 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
new file mode 100644
index 000000000..6ec22f5ce
--- /dev/null
+++ b/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/ManagedFileHandlerTest.java
@@ -0,0 +1,190 @@
+/*
+ * 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
new file mode 100644
index 000000000..5e600d108
--- /dev/null
+++ b/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/StubFileStorageBackend.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 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
new file mode 100644
index 000000000..6b9c4f91e
--- /dev/null
+++ b/conductor-client/src/test/java/org/conductoross/conductor/sdk/file/storage/LocalFileStorageBackendTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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/examples/file-storage/media-transcoder/.gitignore b/examples/file-storage/media-transcoder/.gitignore
new file mode 100644
index 000000000..92322c4e0
--- /dev/null
+++ b/examples/file-storage/media-transcoder/.gitignore
@@ -0,0 +1,2 @@
+.idea/
+target/
diff --git a/examples/file-storage/media-transcoder/Dockerfile b/examples/file-storage/media-transcoder/Dockerfile
new file mode 100644
index 000000000..73f302b67
--- /dev/null
+++ b/examples/file-storage/media-transcoder/Dockerfile
@@ -0,0 +1,12 @@
+FROM azul/zulu-openjdk-debian:21
+
+WORKDIR /app
+COPY target/media-transcoder-1.0.0.jar /app/app.jar
+COPY src/main/resources/data /app/data
+
+ENV CONDUCTOR_SERVER_URL=http://conductor-server:8080/api
+ENV AWS_S3_ENDPOINT=http://minio:9000
+ENV AWS_ACCESS_KEY_ID=minioadmin
+ENV AWS_SECRET_ACCESS_KEY=minioadmin
+
+CMD ["java", "-jar", "/app/app.jar"]
diff --git a/examples/file-storage/media-transcoder/devspace.yaml b/examples/file-storage/media-transcoder/devspace.yaml
new file mode 100644
index 000000000..76d0cecd1
--- /dev/null
+++ b/examples/file-storage/media-transcoder/devspace.yaml
@@ -0,0 +1,160 @@
+version: v2beta1
+name: conductor-file-storage-e2e
+
+vars:
+ CONDUCTOR_SERVER_URL:
+ default: "http://localhost:8080/api"
+ MINIO_ENDPOINT:
+ default: "http://localhost:9000"
+ MINIO_CONSOLE:
+ default: "http://localhost:9001"
+ MINIO_USER:
+ default: "minioadmin"
+ MINIO_PASSWORD:
+ default: "minioadmin"
+ S3_BUCKET:
+ default: "conductor-files"
+ S3_REGION:
+ default: "us-east-1"
+ NAMESPACE:
+ default: "conductor-file-storage"
+ CONDUCTOR_WORKTREE:
+ default: "../file-storage-conductor"
+ RUNS:
+ default: "3"
+
+images:
+ media-transcoder:
+ image: media-transcoder
+ dockerfile: examples/file-storage/media-transcoder/Dockerfile
+ context: examples/file-storage/media-transcoder
+
+deployments:
+ minio:
+ kubectl:
+ manifests:
+ - ${CONDUCTOR_WORKTREE}/deploy/k8s/namespace.yaml
+ - ${CONDUCTOR_WORKTREE}/deploy/k8s/minio.yaml
+ - ${CONDUCTOR_WORKTREE}/deploy/k8s/minio-bucket-init.yaml
+
+ conductor-server:
+ kubectl:
+ manifests:
+ - ${CONDUCTOR_WORKTREE}/deploy/k8s/postgres.yaml
+ - ${CONDUCTOR_WORKTREE}/deploy/k8s/conductor-config.yaml
+ - ${CONDUCTOR_WORKTREE}/deploy/k8s/conductor-server.yaml
+
+ media-transcoder:
+ kubectl:
+ manifests:
+ - examples/file-storage/media-transcoder/k8s/deployment.yaml
+
+dev:
+ media-transcoder:
+ imageSelector: media-transcoder
+ sync:
+ - path: ./examples/file-storage/media-transcoder/src
+ containerPath: /app/src
+ ports:
+ - port: "5005:5005"
+ terminal: {}
+
+commands:
+ test-local:
+ description: "Deploy MinIO, start Conductor locally, run media-transcoder N times"
+ command: |
+ set -e
+ PASS=0; FAIL=0
+
+ echo "=== Deploy MinIO ==="
+ devspace deploy --deployments minio
+ kubectl -n ${NAMESPACE} wait --for=condition=ready pod -l app=minio --timeout=60s
+ kubectl -n ${NAMESPACE} wait --for=condition=complete job/minio-bucket-init --timeout=60s 2>/dev/null || true
+ kubectl -n ${NAMESPACE} port-forward svc/minio 9000:9000 9001:9001 > /dev/null 2>&1 &
+ PF_PID=$!
+ sleep 2
+ echo "MinIO ready at ${MINIO_ENDPOINT}"
+
+ echo ""
+ echo "=== Build + Start Conductor ==="
+ cd ${CONDUCTOR_WORKTREE}
+ ./gradlew build -x test -x spotlessCheck -x shadowJar --no-daemon -q
+ AWS_ACCESS_KEY_ID=${MINIO_USER} AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} \
+ nohup ./gradlew :conductor-server:bootRun \
+ --args="--conductor.file-storage.enabled=true \
+ --conductor.file-storage.type=s3 \
+ --conductor.file-storage.s3.bucket-name=${S3_BUCKET} \
+ --conductor.file-storage.s3.region=${S3_REGION} \
+ --conductor.file-storage.s3.endpoint=${MINIO_ENDPOINT}" \
+ --no-daemon > /tmp/conductor-server.log 2>&1 &
+ SERVER_PID=$!
+ cd -
+
+ echo "Waiting for health..."
+ for i in $(seq 1 90); do
+ curl -sf http://localhost:8080/health > /dev/null 2>&1 && echo "Conductor UP" && break
+ [ "$i" -eq 90 ] && echo "ERROR: Conductor did not start" && tail -10 /tmp/conductor-server.log && kill $PF_PID $SERVER_PID 2>/dev/null && exit 1
+ sleep 2
+ done
+
+ echo ""
+ echo "=== Run media-transcoder (${RUNS} times) ==="
+ for run in $(seq 1 ${RUNS}); do
+ echo ""
+ echo "--- Run $run of ${RUNS} ---"
+ LOG="/tmp/media-transcoder-run${run}.log"
+ CONDUCTOR_SERVER_URL=${CONDUCTOR_SERVER_URL} \
+ AWS_S3_ENDPOINT=${MINIO_ENDPOINT} \
+ AWS_ACCESS_KEY_ID=${MINIO_USER} \
+ AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} \
+ mvn -f examples/file-storage/media-transcoder/pom.xml -q exec:java -Dexec.mainClass=io.conductor.example.mediatranscoder.MediaTranscoderApp > "$LOG" 2>&1 || true
+
+ if grep -q "Workflow COMPLETED!" "$LOG"; then
+ echo "Run $run: PASS"
+ grep "Output:" "$LOG" | tail -1
+ PASS=$((PASS + 1))
+ else
+ echo "Run $run: FAIL"
+ grep -a "FAILED\|Exception" "$LOG" | grep -v "warning:\|already exists\|Gradle\|deprecated" | tail -3
+ FAIL=$((FAIL + 1))
+ fi
+ done
+
+ echo ""
+ echo "=== Cleanup ==="
+ kill $SERVER_PID 2>/dev/null && echo "Stopped Conductor" || true
+ kill $PF_PID 2>/dev/null && echo "Stopped port-forward" || true
+
+ echo ""
+ echo "==============================="
+ echo " Results: $PASS PASS / $FAIL FAIL (out of ${RUNS})"
+ echo "==============================="
+ [ "$FAIL" -gt 0 ] && exit 1 || exit 0
+
+ deploy-minio:
+ description: "Deploy MinIO only + port-forward"
+ command: |
+ devspace deploy --deployments minio
+ kubectl -n ${NAMESPACE} wait --for=condition=ready pod -l app=minio --timeout=60s
+ kubectl -n ${NAMESPACE} wait --for=condition=complete job/minio-bucket-init --timeout=60s 2>/dev/null || true
+ echo "MinIO S3: ${MINIO_ENDPOINT}"
+ echo "MinIO Console: ${MINIO_CONSOLE} (${MINIO_USER}/${MINIO_PASSWORD})"
+ kubectl -n ${NAMESPACE} port-forward svc/minio 9000:9000 9001:9001
+
+ teardown:
+ description: "Delete namespace and all resources"
+ command: |
+ echo "Deleting namespace ${NAMESPACE}..."
+ kubectl delete namespace ${NAMESPACE} --ignore-not-found
+
+profiles:
+ - name: local
+ description: "MinIO on K8s, Conductor + media-transcoder run locally"
+ patches:
+ - op: remove
+ path: deployments.conductor-server
+ - op: remove
+ path: deployments.media-transcoder
+
+ - name: full-k8s
+ description: "Everything on K8s: MinIO + Conductor + media-transcoder"
diff --git a/examples/file-storage/media-transcoder/k8s/deployment.yaml b/examples/file-storage/media-transcoder/k8s/deployment.yaml
new file mode 100644
index 000000000..bf3c2c255
--- /dev/null
+++ b/examples/file-storage/media-transcoder/k8s/deployment.yaml
@@ -0,0 +1,32 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: media-transcoder
+ namespace: conductor-file-storage
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: media-transcoder
+ template:
+ metadata:
+ labels:
+ app: media-transcoder
+ spec:
+ initContainers:
+ - name: wait-for-conductor
+ image: busybox
+ command: ["sh", "-c", "until wget -qO- http://conductor-server:8080/health; do sleep 3; done"]
+ containers:
+ - name: media-transcoder
+ image: media-transcoder
+ imagePullPolicy: IfNotPresent
+ env:
+ - name: CONDUCTOR_SERVER_URL
+ value: "http://conductor-server:8080/api"
+ - name: AWS_S3_ENDPOINT
+ value: "http://minio:9000"
+ - name: AWS_ACCESS_KEY_ID
+ value: "minioadmin"
+ - name: AWS_SECRET_ACCESS_KEY
+ value: "minioadmin"
diff --git a/examples/file-storage/media-transcoder/pom.xml b/examples/file-storage/media-transcoder/pom.xml
new file mode 100644
index 000000000..98b4d497b
--- /dev/null
+++ b/examples/file-storage/media-transcoder/pom.xml
@@ -0,0 +1,59 @@
+
+
+ 4.0.0
+
+ io.orkes.examples
+ media-transcoder
+ 1.0.0
+ jar
+
+
+ 21
+ 21
+ UTF-8
+
+
+
+
+ org.conductoross
+ conductor-client
+ 4.2.0
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.17.1
+
+
+ org.slf4j
+ slf4j-simple
+ 2.0.12
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.5.1
+
+
+ package
+ shade
+
+
+
+ io.conductor.example.mediatranscoder.MediaTranscoderApp
+
+
+ false
+
+
+
+
+
+
+
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
new file mode 100644
index 000000000..cafdd0408
--- /dev/null
+++ b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/MediaTranscoderApp.java
@@ -0,0 +1,119 @@
+/*
+ * 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.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.Map;
+
+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.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 io.conductor.example.mediatranscoder.workers.ManifestWorker;
+import io.conductor.example.mediatranscoder.workers.ThumbnailWorker;
+import io.conductor.example.mediatranscoder.workers.TranscodeWorker;
+import io.conductor.example.mediatranscoder.workers.UploadPrimaryVideoWorker;
+import org.conductoross.conductor.client.FileClient;
+
+public class MediaTranscoderApp {
+
+ public static void main(String[] args) throws Exception {
+ String serverUrl = System.getenv().getOrDefault(
+ "CONDUCTOR_SERVER_URL", "http://localhost:8080/api");
+
+ System.out.println("Connecting to Conductor at: " + serverUrl);
+
+ ConductorClient client = ConductorClient.builder()
+ .basePath(serverUrl)
+ .build();
+
+ TaskClient taskClient = new TaskClient(client);
+ WorkflowClient workflowClient = new WorkflowClient(client);
+ MetadataClient metadataClient = new MetadataClient(client);
+
+ FileClient fileClient = new FileClient(client);
+
+ // 1. Register (or update) the workflow definition.
+ 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();
+ AnnotatedWorker transcodeWorker = new AnnotatedWorker(
+ "transcode_video",
+ TranscodeWorker.class.getMethod("transcode", TranscodeWorker.TranscodeInput.class),
+ transcodeBean);
+
+ List workers = List.of(
+ new UploadPrimaryVideoWorker(),
+ transcodeWorker,
+ new ThumbnailWorker(),
+ new ManifestWorker());
+
+ TaskRunnerConfigurer configurer = new TaskRunnerConfigurer.Builder(taskClient, workers)
+ .withFileClient(fileClient)
+ .withThreadCount(4)
+ .build();
+ configurer.init();
+ System.out.println("Workers started: upload_primary_video, transcode_video, extract_thumbnail, create_manifest");
+
+ // 3. Start workflow — no inputs; upload_primary_video publishes primaryVideo.
+ StartWorkflowRequest request = new StartWorkflowRequest();
+ request.setName(workflowDef.getName());
+ request.setVersion(workflowDef.getVersion());
+ request.setInput(Map.of());
+
+ String workflowId = workflowClient.startWorkflow(request);
+ System.out.println("Workflow started: " + workflowId);
+
+ // 5. Poll for completion
+ System.out.println("Waiting for workflow to complete...");
+ for (int i = 0; i < 30; i++) {
+ Thread.sleep(2000);
+ Workflow workflow = workflowClient.getWorkflow(workflowId, true);
+ System.out.println(" Status: " + workflow.getStatus());
+ if (workflow.getStatus().isTerminal()) {
+ System.out.println("Workflow " + workflow.getStatus() + "!");
+ System.out.println("Output: " + workflow.getOutput());
+ configurer.shutdown();
+ System.exit(workflow.getStatus().isSuccessful() ? 0 : 1);
+ }
+ }
+
+ System.err.println("Workflow did not complete in 60s");
+ configurer.shutdown();
+ System.exit(1);
+ }
+
+ public static WorkflowDef register(MetadataClient metadataClient) throws IOException {
+ try (InputStream is = MediaTranscoderApp.class.getResourceAsStream("/workflow/media_transcode.json")) {
+ WorkflowDef def = new ObjectMapper().readValue(is, WorkflowDef.class);
+ metadataClient.updateWorkflowDefs(List.of(def));
+ System.out.println("Registered workflow: " + def.getName() + " v" + def.getVersion());
+ return def;
+ }
+ }
+}
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
new file mode 100644
index 000000000..f22794e49
--- /dev/null
+++ b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ManifestWorker.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 io.conductor.example.mediatranscoder.workers;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+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.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}";
+
+ @Override
+ public String getTaskDefName() {
+ return "create_manifest";
+ }
+
+ @Override
+ public TaskResult execute(Task task) {
+ TaskResult result = new TaskResult(task);
+ FileHandler video = task.getInputFileHandler("transcoded_video");
+ FileHandler thumb = task.getInputFileHandler("thumbnail");
+
+ try {
+ System.out.println("[create_manifest] Inputs:");
+ describe(video);
+ describe(thumb);
+
+ String manifest = String.format(
+ STRING.formatted(),
+ video.getFileHandleId(), video.getFileName(), video.getFileSize(),
+ thumb.getFileHandleId(), thumb.getFileName(), thumb.getFileSize());
+
+ Path 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());
+ result.setStatus(TaskResult.Status.COMPLETED);
+
+ System.out.println("[create_manifest] Uploaded: " + uploaded.getFileHandleId());
+ System.out.println("[create_manifest] Content: " + manifest);
+ } catch (Exception e) {
+ result.setStatus(TaskResult.Status.FAILED);
+ result.setReasonForIncompletion(e.getMessage());
+ e.printStackTrace();
+ }
+ 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("=================");
+ }
+}
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
new file mode 100644
index 000000000..67a96d587
--- /dev/null
+++ b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/ThumbnailWorker.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 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.sdk.file.FileUploadOptions;
+import org.conductoross.conductor.sdk.file.FileUploader;
+
+public class ThumbnailWorker implements Worker {
+
+ @Override
+ public String getTaskDefName() { return "extract_thumbnail"; }
+
+ @Override
+ public TaskResult execute(Task task) {
+ TaskResult result = new TaskResult(task);
+ FileHandler primary = task.getInputFileHandler("primary_video");
+
+ try (InputStream in = primary.getInputStream()) {
+ Path 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());
+ result.setStatus(TaskResult.Status.COMPLETED);
+
+ System.out.println("[extract_thumbnail] Uploaded: " + uploaded.getFileHandleId());
+ } catch (Exception e) {
+ result.setStatus(TaskResult.Status.FAILED);
+ result.setReasonForIncompletion(e.getMessage());
+ e.printStackTrace();
+ }
+ return result;
+ }
+}
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
new file mode 100644
index 000000000..fe7861461
--- /dev/null
+++ b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/TranscodeWorker.java
@@ -0,0 +1,42 @@
+/*
+ * 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.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;
+
+public class TranscodeWorker {
+
+ public static class TranscodeInput {
+ public FileHandler primary_video;
+ public String resolution;
+ }
+
+ @WorkerTask("transcode_video")
+ public @OutputParam("output_file") FileHandler transcode(TranscodeInput input) throws IOException {
+ Path transcoded = Files.createTempFile("transcoded-" + input.resolution + "-", ".mp4");
+ try (InputStream in = input.primary_video.getInputStream()) {
+ Files.write(transcoded, ("TRANSCODED:" + input.resolution + "\n").getBytes());
+ Files.write(transcoded, in.readAllBytes(), StandardOpenOption.APPEND);
+ }
+ 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
new file mode 100644
index 000000000..f93c3060f
--- /dev/null
+++ b/examples/file-storage/media-transcoder/src/main/java/io/conductor/example/mediatranscoder/workers/UploadPrimaryVideoWorker.java
@@ -0,0 +1,57 @@
+/*
+ * 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.workers;
+
+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 org.conductoross.conductor.sdk.file.FileHandler;
+import org.conductoross.conductor.sdk.file.FileUploadOptions;
+
+public class UploadPrimaryVideoWorker implements Worker {
+
+ private static final String RESOURCE_PATH = "/data/primary_video.mov";
+
+ @Override
+ public String getTaskDefName() {
+ return "upload_primary_video";
+ }
+
+ @Override
+ public TaskResult execute(Task task) {
+ TaskResult result = new TaskResult(task);
+
+ try (InputStream in = UploadPrimaryVideoWorker.class.getResourceAsStream(RESOURCE_PATH)) {
+ if (in == null) {
+ throw new IllegalStateException("Primary video not found on classpath at " + RESOURCE_PATH);
+ }
+
+ FileUploadOptions options = new FileUploadOptions()
+ .setFileName("primary_video.mov")
+ .setContentType("video/quicktime");
+ FileHandler uploaded = task.getFileUploader().upload(in, options);
+
+ result.getOutputData().put("primary_video", uploaded);
+ result.setStatus(TaskResult.Status.COMPLETED);
+
+ System.out.println("[upload_primary_video] Uploaded: " + uploaded.getFileHandleId());
+ } catch (Exception e) {
+ result.setStatus(TaskResult.Status.FAILED);
+ result.setReasonForIncompletion(e.getMessage());
+ e.printStackTrace();
+ }
+ return result;
+ }
+}
diff --git a/examples/file-storage/media-transcoder/src/main/resources/data/primary_video.mov b/examples/file-storage/media-transcoder/src/main/resources/data/primary_video.mov
new file mode 100644
index 000000000..cbdd5bac5
--- /dev/null
+++ b/examples/file-storage/media-transcoder/src/main/resources/data/primary_video.mov
@@ -0,0 +1 @@
+FAKE_MASTER_VIDEO_DATA_FOR_TESTING
diff --git a/examples/file-storage/media-transcoder/src/main/resources/workflow/media_transcode.json b/examples/file-storage/media-transcoder/src/main/resources/workflow/media_transcode.json
new file mode 100644
index 000000000..7061b5aa9
--- /dev/null
+++ b/examples/file-storage/media-transcoder/src/main/resources/workflow/media_transcode.json
@@ -0,0 +1,62 @@
+{
+ "name": "media_transcode",
+ "version": 1,
+ "tasks": [
+ {
+ "name": "upload_primary_video",
+ "taskReferenceName": "upload_primary_video_ref",
+ "type": "SIMPLE",
+ "inputParameters": {}
+ },
+ {
+ "name": "fork_processing",
+ "taskReferenceName": "fork_ref",
+ "type": "FORK_JOIN",
+ "forkTasks": [
+ [
+ {
+ "name": "transcode_video",
+ "taskReferenceName": "transcode_ref",
+ "type": "SIMPLE",
+ "inputParameters": {
+ "primary_video": "${upload_primary_video_ref.output.primary_video}",
+ "resolution": "720p"
+ }
+ }
+ ],
+ [
+ {
+ "name": "extract_thumbnail",
+ "taskReferenceName": "thumbnail_ref",
+ "type": "SIMPLE",
+ "inputParameters": {
+ "primary_video": "${upload_primary_video_ref.output.primary_video}"
+ }
+ }
+ ]
+ ]
+ },
+ {
+ "name": "join_processing",
+ "taskReferenceName": "join_ref",
+ "type": "JOIN",
+ "joinOn": ["transcode_ref", "thumbnail_ref"]
+ },
+ {
+ "name": "create_manifest",
+ "taskReferenceName": "manifest_ref",
+ "type": "SIMPLE",
+ "inputParameters": {
+ "transcoded_video": "${transcode_ref.output.output_file}",
+ "thumbnail": "${thumbnail_ref.output.output_file}"
+ }
+ }
+ ],
+ "inputParameters": [],
+ "outputParameters": {
+ "primary_video": "${upload_primary_video_ref.output.primary_video}",
+ "manifest": "${manifest_ref.output.output_file}",
+ "transcoded_video": "${transcode_ref.output.output_file}",
+ "thumbnail": "${thumbnail_ref.output.output_file}"
+ }
+}
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
new file mode 100644
index 000000000..e64386a38
--- /dev/null
+++ b/tests/src/test/java/io/orkes/conductor/client/filestorage/FileStorageIntegrationTest.java
@@ -0,0 +1,210 @@
+/*
+ * 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.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.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;
+import com.netflix.conductor.sdk.workflow.def.tasks.Wait;
+import com.netflix.conductor.sdk.workflow.executor.WorkflowExecutor;
+
+import io.orkes.conductor.client.http.OrkesMetadataClient;
+import io.orkes.conductor.client.http.OrkesWorkflowClient;
+import io.orkes.conductor.client.util.ClientTestUtil;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@Disabled
+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;
+
+ private static FileClient fileClient;
+ private static OrkesWorkflowClient workflowClient;
+ private static OrkesMetadataClient metadataClient;
+ private static String workflowId;
+
+ @BeforeAll
+ static void setUp() {
+ fileClient = ClientTestUtil.getOrkesClients().getFileClient();
+ workflowClient = ClientTestUtil.getOrkesClients().getWorkflowClient();
+ 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);
+ assertNotNull(workflowId);
+ }
+
+ @AfterAll
+ static void cleanUp() {
+ try {
+ workflowClient.terminateWorkflow(workflowId, "integration test cleanup");
+ } catch (Exception ignored) {
+ }
+ try {
+ metadataClient.unregisterWorkflowDef(WAIT_WF_NAME, 1);
+ } catch (Exception ignored) {
+ }
+ }
+
+ @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,
+ new FileUploadOptions().setContentType("text/plain"));
+
+ assertNotNull(handler.getFileHandleId());
+ assertTrue(handler.getFileHandleId().startsWith(FileHandler.PREFIX));
+ assertEquals("hello.txt", handler.getFileName());
+ assertEquals("text/plain", handler.getContentType());
+ }
+
+ @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));
+ }
+
+ @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,
+ new FileUploadOptions().setContentType("application/octet-stream"));
+
+ 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());
+ }
+
+ @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);
+
+ 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"));
+ }
+
+ private static Path writeFile(String name, String content) throws Exception {
+ Path file = tempDir.resolve(name);
+ Files.writeString(file, content);
+ return file;
+ }
+}