INNORIX
Transfer BuilderTransfer FinderDevelopersResourcesCustomers
Start Free
INNORIX

LET FILES
MOVE THEMSELVES

INNORIX provides enterprise file infrastructure for moving and automating files across every system and environment.
Trusted by more than 5,000 enterprise and government agencies.

START HERE

  • Build the Transfer You Need
  • Find the Transfer You Need

POPULAR TRANSFERS

  • Sync Team Folders
  • Send Large Files to Clients
  • Explore Files Across Systems
  • Migrate FTP, SFTP, SCP & rsync
  • Add Transfer to Any App
  • Add Web Upload & Download
  • Build AI & Data Workflows
  • Browse All Transfers→

DEVELOPERS

  • Developer Center
  • Examples
  • API Quickstart
  • Developer Guide
  • API Reference
  • GitHub

RESOURCES

  • Resource Center
  • Product Guide
  • Integrations
  • Deploy & Manage
  • Help Center

CUSTOMERS

  • Government
  • Public Sector
  • Manufacturing
  • Engineering
  • Finance
  • Distribution
  • IT/Telecom
  • Media
  • Healthcare
  • Education

PLANS

  • Pricing

COMPANY

About Us

OTHER INNORIX PRODUCT

Al.bert — Smart Traffic AI

GLOBAL OFFICES

  • New York, USA
  • Seoul, South Korea
  • Ho Chi Minh City, Vietnam
  • View Office Locations→

(C)2026 INNORIX. All rights reserved.

  • Security
  • Status
  • Terms
  • Privacy
  • Cookies
  1. Guide
  2. Automating the End-to-End Workflow for AI Training Data and Model Files

Automating the End-to-End Workflow for AI Training Data and Model Files

Automate data movement across AI and model workflows, from collection and processing through result delivery and retry handling.

IT EngineersDevelopers
  • Automating the End-to-End Workflow for AI Training Data and Model Files
  • Deploy AI Model Files to Multiple Edge Devices
  • Securely Importing and Exporting Files in Air-Gapped and Network-Segmented Environments
  • Simultaneously Distributing Approved Files to Multiple Locations
  • Transfer Files from Azure Blob to an On-Premises Server
  • Automatically Transferring DB Dumps, Backup, and Archive Files to Remote Storage
  • Centrally Collecting Files from Branches, Factories, and Edge Devices
  • Deploy CI/CD Build Artifacts to Multiple Servers
  • Move Files Between Different Cloud Storage Services
  • Transfer Files Between S3 Buckets in Different AWS Accounts
  • Provide Customer-Specific File Workspaces
  • Provide Customers with Large-File Download Links That Expire
  • Let Customers Upload Large Files Through a Browser
  • Send Large Files Directly to a Customer Workspace
  • Store Database Backup Files in Object Storage
  • Receive File Transfer Failure and Recovery Alerts in Datadog
  • Add File Transfer Capabilities to Web, App, and Business Systems
  • Run Validation, Conversion, and Follow-Up Tasks After File Arrival
  • Migrate Large Volumes of Files from NAS and File Servers to the Cloud
  • Convert FTP Batch Jobs to Managed File Flows
  • Transfer Directly from Google Cloud Storage to Amazon S3
  • Create a File Transfer Status Dashboard in Grafana
  • Automatically Verify File Integrity Using Hash Values
  • Automatically Transfer New Files to a Designated Destination
  • Send Result Files from Kubernetes to Object Storage
  • Automatically Transfer Large Files and Build Artifacts Outside Git
  • Automatically Deliver Files That Are Difficult to Manage with Git
  • Collect Logs and Diagnostic Files from Distributed Servers into a Central Location
  • Automatically Transfer Media Sources and Processing Results Step by Step
  • Scan Received Files with Microsoft Defender and Perform Follow-up Processing
  • Automate Multi-Step File Transfers in a Single Workflow
  • Automatically Transfer Media Sources and Processing Results Step by Step
  • Automatically Route Partner Files to Internal Business Systems
  • Exchange Files Regularly with Partners and Supply Chain Systems
  • Automating Recurring File Transfers Daily and Weekly
  • Automatically Resume File Transfers After a Network Interruption
  • Convert rsync Jobs to Managed File Flows
  • Schedule Recurring File Transfers
  • Distribute Software and Firmware to Multiple Devices and Review Results
  • Distributing Software Packages to Multiple Servers and Branches
  • Automatically Apply Team Folder Changes Across Multiple Devices
  • Automatically Reflect a Team Work Folder Across Multiple PCs
  • Find and Transfer Files Across Multiple Devices from One Place
  • Add Large File Upload and Download to Your Website

Getting Started#

Core Concept#

Automatically send data and model files to the systems that need them

In AI workflows, a wide range of files—including raw data, preprocessing results, training data, model files, and inference results—are used across multiple systems and environments.

By connecting where each file is created with the system that uses it, you can send data to the next processing environment as soon as it is ready, then route generated models and result files to the locations where they are needed.

Data Source
     │
     ▼
Data Processing
     │
     ▼
AI Training
     │
     ▼
Model Output
     │
     ▼
Result Storage

With this setup, AI data and model files can be managed according to the processing environment at each stage, with each file automatically flowing into the next task as soon as it is ready.

Workflow Flow#

Connecting the workflow from data collection through processing and result utilization

Collect data generated across multiple systems and send it to the AI processing environment where it is needed.

Once data processing and training are complete, send the generated models and result files to the next system or storage environment for downstream use.

Data Generation
     │
     ▼
Data Collection
     │
     ▼
Preprocessing · AI Processing
     │
     ▼
Model Generation
     │
     ├──────────────┐
     ▼              ▼
Model Storage        Result Delivery
     │              │
     └──────┬───────┘
            ▼
        Next Task

Each stage can use the files and processing results generated by the previous stage as inputs for the next stage.

Automation Benefits#

Reduce repetitive large-file movement and management at every stage

As AI workloads grow in data volume and processing stages, the work required to prepare files and send them to the systems that need them grows as well.

By configuring the next task to run automatically based on file creation and processing status, you can connect each stage's file flow in a defined sequence.

Category Manual File Management Workflow Automation
Data Collection Check files on each system Collect data from multiple systems into a single workflow
Processing Environment Prepare files for the next system Automatically send files to the required system based on processing conditions
Model Management Check generated model files Route models to the designated environment after generation
Result Utilization Prepare processing results for the next task Automatically connect result files to the next task

This lets you define the flow of data, models, and result files stage by stage and manage the entire AI workflow as a single file workflow.

IT Engineer#

Configure an automated workflow for AI data and model files

Data Collection#

Bring data from multiple systems into a single workflow

First, connect the systems where data for AI processing is generated or stored to the workflow.

Set each system's file paths and collection targets to bring data from multiple locations into a single processing flow for use in the next stage.

Source A ──┐
           │
Source B ──┼──→ Data Collection
           │
Source C ──┘
                  │
                  ▼
             AI Processing

[Product UI: Flow Canvas screen connecting multiple Source systems and data folders to a single data collection task]

If needed, set conditions such as file paths, names, and extensions to collect only the data required for AI processing.

Processing Connection#

Send collected data to the AI processing systems that need it

Once the collected data is ready, connect it to the AI processing systems that perform the next tasks, such as preprocessing, training, or inference.

Specify the systems and file paths used at each processing stage, and configure the output from one stage to become the input for the next system.

Collected Data
       │
       ▼
Preprocessing
       │
       ▼
Training Server
       │
       ▼
Inference / Analysis

With this setup, the files required at each AI processing stage can be automatically sent to the prepared working environment.

Job Conditions#

Start the next task based on data and processing status

Each workflow stage can be configured to start the next task when a defined condition is met, such as file creation, completion of data collection, or completion of processing.

For example, you can start preprocessing once data has been collected from multiple systems, then continue to the training task as soon as the preprocessing results are generated.

Data Ready
    │
    ▼
Collection Complete
    │
    ▼
Start Processing
    │
    ▼
Processing Complete
    │
    ▼
Start Training

By linking job conditions, you can run the workflow in sequence from data collection through AI processing and model generation based on the processing status at each stage.

Result Transfer#

Send generated models and processing results to the systems that need them

Once training and processing are complete, send the generated model files and result data to the next environment.

Connect the locations where files will be used—such as model storage, inference systems, validation environments, and business systems—and send each output to the appropriate destination.

AI Training
     │
     ▼
Model Generated
     │
 ┌───┴───────────┐
 ▼               ▼
Model Storage  Inference Server
                     │
                     ▼
                Result Output
                     │
                     ▼
               Target System

When a model or result file is used across multiple environments, you can connect destination-specific file flows and automatically extend the workflow to every required working location.

Run Management#

Track processing status stage by stage, from data to models and results

For each workflow run, you can view the overall flow together with the status of each stage.

You can track processed files and progress step by step across data collection, preprocessing, AI processing, model generation, and result transfer.

AI Workflow
     │
     ├── Data Collection   ✓
     │
     ├── Processing        ✓
     │
     ├── Model Training    ●
     │
     └── Result Transfer   ○

Key items to check include:

Check Item Details
Stage The workflow stage currently running
Source The system and file location from which data was collected
Files Number of processed files and total size
Progress Processing progress for each stage
Target Destination for model and result files
Status Execution result for the overall workflow and each task

This lets you manage the status of a specific stage alongside the overall AI file workflow.

Exception Handling#

Check transfer and processing status, then rerun the stages that need attention

When a stage requires additional investigation during execution, review the data status, system connections, and file transfer results using the task details and execution history.

You can review each stage separately from data collection through AI processing and result transfer, then rerun the required task.

Workflow Run
      │
      ▼
Stage Status
      │
 ┌────┴─────┐
 ▼          ▼
Completed   Check Required
                │
                ▼
           Run Details
                │
       ┌────────┼────────┐
       ▼        ▼        ▼
     Data     Device    Transfer
                │
                ▼
           Retry Stage
                │
                ▼
          Result Check

Developer#

Deploy datasets to multiple training nodes and collect node-specific results on a storage system

Integration Setup#

Prepare shared API call code and path conventions

import os
import requests

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com").rstrip("/")
TOKEN = os.environ["INNORIX_ACCESS_TOKEN"]
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")   # optional; falls back to the current workspace

STATUS_COMPLETE = 2
TERMINAL = {2, 4, 5, 9, 99}          # complete / error / cancelled / partial / failed
NOT_SUCCEEDED = {4, 5, 9, 99}


def api(method, path, body=None, params=None):
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {TOKEN}",
    }

    if WORKSPACE_ID:
        headers["x-workspace-id"] = WORKSPACE_ID

    response = requests.request(
        method, BASE_URL + path,
        headers=headers, json=body, params=params, timeout=30,
    )

    payload = response.json() if response.content else {}

    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")

    return payload.get("data")


def is_terminal(detail):
    return detail.get("isTerminal", detail.get("status") in TERMINAL)
// InnorixClient.java
public static final String BASE_URL =
        env("INNORIX_BASE_URL", "https://app.innorix.com").replaceAll("/+
quot;
, ""); public static final String WORKSPACE_ID = env("INNORIX_WORKSPACE_ID", null); public static final int STATUS_COMPLETE = 2; // States the transfer no longer moves out of public static final Set<Integer> TERMINAL = Set.of(2, 4, 5, 9, 99); // Terminal states that are not a full success public static final Set<Integer> NOT_SUCCEEDED = Set.of(4, 5, 9, 99); private HttpRequest.Builder headers(HttpRequest.Builder builder) { builder.header("Content-Type", "application/json") .header("Authorization", "Bearer " + session.accessToken()); // When omitted the account's current workspace is used. if (workspaceId != null) builder.header("x-workspace-id", workspaceId); return builder; } /** Unwraps and returns data from the response. Throws ApiError on failure. */ public Object api(String method, String path, Object body, Map<String, Object> params) { Resp response = request(method, path, body, params); Object payload = null; try { payload = Json.parse(response.text()); } catch (RuntimeException ignored) { payload = null; } if (!response.ok()) { Map<String, Object> map = Json.asObj(payload); String message = Json.str(map, "message", Json.str(map, "error", "unknown error")); throw new ApiError(response.status, message, map); } return Json.get(payload, "data"); } /** Use the server flag when present, otherwise fall back to the status code. */ public static boolean isTerminal(Map<String, Object> record) { Boolean flag = Json.boolOrNull(record, "isTerminal"); if (flag != null) return flag; Integer status = Json.intOrNull(record, "status"); return status != null && TERMINAL.contains(status); }
// innorix-client.js
const BASE_URL = (process.env.INNORIX_BASE_URL
  || "https://app.innorix.com").replace(/\/+$/, "");
const TOKEN = process.env.INNORIX_ACCESS_TOKEN;
const WORKSPACE_ID = process.env.INNORIX_WORKSPACE_ID || null;

export const STATUS_COMPLETE = 2;
export const TERMINAL = new Set([2, 4, 5, 9, 99]);       // complete / error / cancelled / partial / failed
export const NOT_SUCCEEDED = new Set([4, 5, 9, 99]);

export async function api(method, path, body = null, params = null) {
  const url = new URL(BASE_URL + path);

  if (params) {
    for (const [key, value] of Object.entries(params)) {
      if (value === undefined || value === null) continue;
      url.searchParams.set(key, String(value));
    }
  }

  const headers = {
    "Content-Type": "application/json",
    Authorization: `Bearer ${TOKEN}`,
  };

  // When omitted the account's current workspace is used.
  if (WORKSPACE_ID) headers["x-workspace-id"] = WORKSPACE_ID;

  const response = await fetch(url, {
    method,
    headers,
    body: body === null ? undefined : JSON.stringify(body),
    signal: AbortSignal.timeout(30_000),
  });

  const payload = await response.json().catch(() => ({}));

  if (!response.ok) {
    throw new Error(payload.message || `HTTP ${response.status}`);
  }

  return payload.data;
}

export function isTerminal(detail) {
  return detail.isTerminal !== undefined
    ? detail.isTerminal
    : TERMINAL.has(detail.status);
}
// InnorixClient.cs
public static readonly string BaseUrl =
    Env("INNORIX_BASE_URL", "https://app.innorix.com").TrimEnd('/');
public static readonly string WorkspaceIdFromEnv = Env("INNORIX_WORKSPACE_ID", null);

public const int StatusComplete = 2;
/// <summary>States the transfer no longer moves out of</summary>
public static readonly HashSet<int> Terminal = new HashSet<int> { 2, 4, 5, 9, 99 };
/// <summary>Terminal states that are not a full success</summary>
public static readonly HashSet<int> NotSucceeded = new HashSet<int> { 4, 5, 9, 99 };

// Applied on every request
request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Session.AccessToken);
// When omitted the account's current workspace is used.
if (WorkspaceId != null) request.Headers.TryAddWithoutValidation("x-workspace-id", WorkspaceId);

public async Task<JsonNode> ApiAsync(string method, string path, JsonNode body = null,
    IDictionary<string, object> parameters = null)
{
    Resp response = await RequestAsync(method, path, body, parameters).ConfigureAwait(false);
    JsonNode payload = null;

    try
    {
        payload = J.Parse(response.Text());
    }
    catch (Exception)
    {
        payload = null;
    }

    if (!response.Ok)
    {
        JsonObject map = J.AsObj(payload);
        string message = J.Str(map, "message", J.Str(map, "error", "unknown error"));
        throw new ApiError(response.Status, message, map);
    }

    return J.Get(payload, "data");
}

/// <summary>Use the server flag when present, otherwise fall back to the status code.</summary>
public static bool IsTerminal(JsonObject record)
{
    bool? flag = J.BoolOrNull(record, "isTerminal");
    if (flag != null) return flag.Value;

    int? status = J.IntOrNull(record, "status");
    return status != null && Terminal.Contains(status.Value);
}
import base64
import time


def encode_path(device_id, raw_path):
    normalized = str(raw_path or "").replace("\\", "/")
    token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
    return f"{device_id}_ino_{token}"


def now_iso():
    return time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())
public static String encodePath(String deviceId, String rawPath) {
    String normalized = (rawPath == null ? "" : rawPath).replace("\\", "/");
    return deviceId + "_ino_"
            + Base64.getEncoder().encodeToString(normalized.getBytes(StandardCharsets.UTF_8));
}

public static String nowIso() {
    return Instant.now().truncatedTo(ChronoUnit.SECONDS).toString().replace("Z", ".000Z");
}
export function encodePath(deviceId, rawPath) {
  const normalized = String(rawPath ?? "").replace(/\\/g, "/");
  const token = Buffer.from(normalized, "utf8").toString("base64");
  return `${deviceId}_ino_${token}`;
}

export function nowIso() {
  return new Date().toISOString().replace(/\.\d{3}Z$/, ".000Z");
}
public static string EncodePath(string deviceId, string rawPath)
{
    string normalized = (rawPath ?? "").Replace("\\", "/");
    return deviceId + "_ino_" + Convert.ToBase64String(Encoding.UTF8.GetBytes(normalized));
}

public static string NowIso()
{
    return DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'.000Z'",
        System.Globalization.CultureInfo.InvariantCulture);
}

Transfer status is determined using the values below. There are five terminal states, and the value representing success is Complete (2).

Status Value Meaning Terminal
2 Complete Yes
4 Error Yes
5 Cancelled Yes
9 Partially Complete Yes
99 Failed Yes
1 · 6 · 12 · 13 Started · Transferring · Synchronizing · Receiving No

Path Rules#

Include the dataset version and run identifier in the path

As runs accumulate, it becomes difficult to tell which result came from which dataset. By standardizing path rules in functions, you can avoid assembling path strings throughout the code.

DATA_ROOT = "/data"
WORK_ROOT = "/work"
ARCHIVE_ROOT = "/archive"


# source dataset on the storage device
def dataset_path(dataset, version):
    return f"{DATA_ROOT}/datasets/{dataset}/{version}"


# path the training job reads on the node
def node_input_path(dataset, version, run_id):
    return f"{WORK_ROOT}/{run_id}/input/{dataset}/{version}"


# path the node writes its results to
def node_output_path(run_id):
    return f"{WORK_ROOT}/{run_id}/output"


# archive path where results are collected
def archive_path(run_id):
    return f"{ARCHIVE_ROOT}/runs/{run_id}"


run_id = f"r-{time.strftime('%Y%m%d-%H%M%S')}"
static final String DATA_ROOT = "/data";
static final String WORK_ROOT = "/work";
static final String ARCHIVE_ROOT = "/archive";

// source dataset on the storage device
String datasetPath(String dataset, String version) {
    return DATA_ROOT + "/datasets/" + dataset + "/" + version;
}

// path the training job reads on the node
String nodeInputPath(String dataset, String version, String runId) {
    return WORK_ROOT + "/" + runId + "/input/" + dataset + "/" + version;
}

// path the node writes its results to
String nodeOutputPath(String runId) {
    return WORK_ROOT + "/" + runId + "/output";
}

// archive path where results are collected
String archivePath(String runId) {
    return ARCHIVE_ROOT + "/runs/" + runId;
}

String runId = "r-" + DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")
        .withZone(ZoneOffset.UTC).format(Instant.now());
const DATA_ROOT = "/data";
const WORK_ROOT = "/work";
const ARCHIVE_ROOT = "/archive";

// source dataset on the storage device
const datasetPath = (dataset, version) => `${DATA_ROOT}/datasets/${dataset}/${version}`;

// path the training job reads on the node
const nodeInputPath = (dataset, version, runId) =>
  `${WORK_ROOT}/${runId}/input/${dataset}/${version}`;

// path the node writes its results to
const nodeOutputPath = (runId) => `${WORK_ROOT}/${runId}/output`;

// archive path where results are collected
const archivePath = (runId) => `${ARCHIVE_ROOT}/runs/${runId}`;

const runId = `r-${new Date().toISOString().replace(/[-:T]/g, "").slice(0, 15)}`;
const string DataRoot = "/data";
const string WorkRoot = "/work";
const string ArchiveRoot = "/archive";

// source dataset on the storage device
string DatasetPath(string dataset, string version) =>
    
quot;{DataRoot}/datasets/{dataset}/{version}"
; // path the training job reads on the node string NodeInputPath(string dataset, string version, string runId) =>
quot;{WorkRoot}/{runId}/input/{dataset}/{version}"
; // path the node writes its results to string NodeOutputPath(string runId) =>
quot;{WorkRoot}/{runId}/output"
; // archive path where results are collected string ArchivePath(string runId) =>
quot;{ArchiveRoot}/runs/{runId}"
; string runId =
quot;r-{DateTime.UtcNow:yyyyMMdd-HHmmss}"
;

Putting the run identifier at the beginning of the path makes it easy to delete or move an entire run at once. Using only the date causes two runs on the same day to get mixed together, while using only the dataset name makes it difficult to distinguish results after a version change.

Data Deployment#

Send a dataset to multiple nodes and confirm that every node has received it

One transfer targets one destination. If there are multiple nodes, there are multiple transfers.

def deploy(storage, nodes, dataset, version, run_id):
    source_path = dataset_path(dataset, version)
    target_path = node_input_path(dataset, version, run_id)

    return {
        node: api("POST", "/api/transfers/manual", {
            "sourceDevice": storage,
            "targetDevice": node,
            "targetPath": target_path,
            "sourcePaths": [source_path],
            "sendAllFolder": True,
            "checkIntegrity": True,
            "transferOptions": {"target-action": "overwrite"},
        })["monitorId"]
        for node in nodes
    }
Map<String, String> deploy(String storage, List<String> nodes,
                           String dataset, String version, String runId) {
    String sourcePath = datasetPath(dataset, version);
    String targetPath = nodeInputPath(dataset, version, runId);
    Map<String, String> transfers = new LinkedHashMap<>();

    for (String node : nodes) {
        Map<String, Object> transfer = client.apiObj("POST", "/api/transfers/manual", Json.newObj(
                "sourceDevice", storage,
                "targetDevice", node,
                "targetPath", targetPath,
                "sourcePaths", List.of(sourcePath),
                "sendAllFolder", true,
                "checkIntegrity", true,
                "transferOptions", Json.newObj("target-action", "overwrite")));
        transfers.put(node, Json.str(transfer, "monitorId"));
    }

    return transfers;
}
async function deploy(storage, nodes, dataset, version, runId) {
  const sourcePath = datasetPath(dataset, version);
  const targetPath = nodeInputPath(dataset, version, runId);
  const transfers = {};

  for (const node of nodes) {
    const transfer = await client.api("POST", "/api/transfers/manual", {
      sourceDevice: storage,
      targetDevice: node,
      targetPath,
      sourcePaths: [sourcePath],
      sendAllFolder: true,
      checkIntegrity: true,
      transferOptions: { "target-action": "overwrite" },
    });
    transfers[node] = transfer.monitorId;
  }

  return transfers;
}
async Task<Dictionary<string, string>> DeployAsync(string storage,
    IEnumerable<string> nodes, string dataset, string version, string runId)
{
    string sourcePath = DatasetPath(dataset, version);
    string targetPath = NodeInputPath(dataset, version, runId);
    var transfers = new Dictionary<string, string>();

    foreach (string node in nodes)
    {
        JsonObject transfer = await client.ApiObjAsync("POST", "/api/transfers/manual",
            new JsonObject
            {
                ["sourceDevice"] = storage,
                ["targetDevice"] = node,
                ["targetPath"] = targetPath,
                ["sourcePaths"] = new JsonArray { sourcePath },
                ["sendAllFolder"] = true,
                ["checkIntegrity"] = true,
                ["transferOptions"] = new JsonObject { ["target-action"] = "overwrite" },
            });
        transfers[node] = J.Str(transfer, "monitorId");
    }

    return transfers;
}
def wait(monitor_id, timeout=3600, interval=3):
    deadline = time.time() + timeout

    while time.time() < deadline:
        detail = api("GET", f"/api/transfers/{monitor_id}")

        if is_terminal(detail):
            return detail

        time.sleep(interval)

    raise TimeoutError(monitor_id)


def failed_files(monitor_id):
    result = api("GET", f"/api/transfers/{monitor_id}/files", params={
        "state": "any", "size": 500,
    }) or {}

    return [r for r in (result.get("children") or [])
            if r.get("status") in NOT_SUCCEEDED]


def retry_failed(monitor_id):
    rows = failed_files(monitor_id)

    if not rows:
        return 0

    api("POST", f"/api/transfers/{monitor_id}/retry", {
        "filesRetry": [
            {"filePath": r["sourceFilePath"], "isDir": bool(r.get("isFolder"))}
            for r in rows
        ]
    })

    return len(rows)
public Map<String, Object> wait(String monitorId, int timeoutSeconds, int intervalSeconds) {
    long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;

    while (System.currentTimeMillis() < deadline) {
        Map<String, Object> detail = apiObj("GET", "/api/transfers/" + monitorId);

        if (isTerminal(detail)) return detail;

        sleep(intervalSeconds * 1000L);
    }

    throw new ApiError(0, "timeout waiting for " + monitorId);
}

public List<Map<String, Object>> failedFiles(String monitorId) {
    List<Map<String, Object>> failed = new ArrayList<>();

    for (Map<String, Object> row : transferFiles(monitorId)) {
        Integer status = Json.intOrNull(row, "status");
        if (status != null && NOT_SUCCEEDED.contains(status)) failed.add(row);
    }

    return failed;
}

public int retryFailed(String monitorId) {
    List<Map<String, Object>> rows = failedFiles(monitorId);
    if (rows.isEmpty()) return 0;

    List<Object> filesRetry = new ArrayList<>();

    for (Map<String, Object> row : rows) {
        String path = Json.str(row, "sourceFilePath");
        if (path == null) continue;
        filesRetry.add(Json.newObj("filePath", path, "isDir", Json.bool(row, "isFolder", false)));
    }

    api("POST", "/api/transfers/" + monitorId + "/retry", Json.newObj("filesRetry", filesRetry));

    return rows.size();
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export async function wait(monitorId, { timeout = 3600, interval = 3 } = {}) {
  const deadline = Date.now() + timeout * 1000;

  while (Date.now() < deadline) {
    const detail = await api("GET", `/api/transfers/${monitorId}`);

    if (isTerminal(detail)) return detail;

    await sleep(interval * 1000);
  }

  throw new Error(`timeout waiting for ${monitorId}`);
}

export async function failedFiles(monitorId) {
  const result = (await api("GET", `/api/transfers/${monitorId}/files`, null, {
    state: "any", size: 500,
  })) || {};

  return (result.children || []).filter((row) => NOT_SUCCEEDED.has(row.status));
}

export async function retryFailed(monitorId) {
  const rows = await failedFiles(monitorId);

  if (rows.length === 0) return 0;

  await api("POST", `/api/transfers/${monitorId}/retry`, {
    filesRetry: rows
      .filter((row) => row.sourceFilePath)
      .map((row) => ({ filePath: row.sourceFilePath, isDir: Boolean(row.isFolder) })),
  });

  return rows.length;
}
public async Task<JsonObject> WaitAsync(string monitorId, int timeoutSeconds = 3600,
    int intervalSeconds = 3)
{
    long deadline = Environment.TickCount64 + timeoutSeconds * 1000L;

    while (Environment.TickCount64 < deadline)
    {
        JsonObject detail = await ApiObjAsync("GET", "/api/transfers/" + monitorId)
            .ConfigureAwait(false);

        if (IsTerminal(detail)) return detail;

        await Task.Delay(intervalSeconds * 1000).ConfigureAwait(false);
    }

    throw new ApiError(0, "timeout waiting for " + monitorId);
}

public async Task<List<JsonObject>> FailedFilesAsync(string monitorId)
{
    List<JsonObject> rows = await TransferFilesAsync(monitorId).ConfigureAwait(false);

    return rows.Where(row =>
    {
        int? status = J.IntOrNull(row, "status");
        return status != null && NotSucceeded.Contains(status.Value);
    }).ToList();
}

/// <summary>Only callable once the transfer has reached a terminal state.</summary>
public async Task<int> RetryFailedAsync(string monitorId)
{
    List<JsonObject> rows = await FailedFilesAsync(monitorId).ConfigureAwait(false);
    if (rows.Count == 0) return 0;

    var filesRetry = new JsonArray();

    foreach (JsonObject row in rows)
    {
        string path = J.Str(row, "sourceFilePath");
        if (path == null) continue;

        filesRetry.Add(new JsonObject
        {
            ["filePath"] = path,
            ["isDir"] = J.Bool(row, "isFolder", false),
        });
    }

    await ApiAsync("POST", "/api/transfers/" + monitorId + "/retry",
        new JsonObject { ["filesRetry"] = filesRetry }).ConfigureAwait(false);

    return rows.Count;
}

Do not start training if even one node has not received the data. Check every node instead of stopping at the first failure so you can determine the correct retry scope.

transfers = deploy(STORAGE, NODES, "imagenet", "v3", run_id)
results = {node: wait(mid) for node, mid in transfers.items()}

incomplete = [n for n, d in results.items() if d["status"] != STATUS_COMPLETE]

if incomplete:
    raise RuntimeError(f"nodes not fully delivered: {', '.join(incomplete)}")
Map<String, String> transfers = deploy(STORAGE, NODES, "imagenet", "v3", runId);
Map<String, Map<String, Object>> results = new LinkedHashMap<>();
for (var entry : transfers.entrySet()) {
    results.put(entry.getKey(), client.await(entry.getValue(), 7200, null));
}

List<String> incomplete = new ArrayList<>();
results.forEach((node, detail) -> {
    if (Json.intOr(detail, "status", -1) != InnorixClient.STATUS_COMPLETE) {
        incomplete.add(node);
    }
});

if (!incomplete.isEmpty()) {
        throw new RuntimeException("Nodes not fully delivered: " + String.join(", ", incomplete));
}
const transfers = await deploy(STORAGE, NODES, "imagenet", "v3", runId);
const results = {};
for (const [node, mid] of Object.entries(transfers)) {
  results[node] = await wait(mid);
}

const incomplete = Object.entries(results)
  .filter(([, d]) => d.status !== STATUS_COMPLETE)
  .map(([node]) => node);

if (incomplete.length) {
  throw new Error(`Nodes not fully delivered: ${incomplete.join(", ")}`);
}
Dictionary<string, string> transfers = await DeployAsync(
    STORAGE, NODES, "imagenet", "v3", runId);
var results = new Dictionary<string, JsonObject>();
foreach (var (node, mid) in transfers)
{
    results[node] = await client.WaitAsync(mid, 7200);
}

var incomplete = results
    .Where(kv => J.IntOrNull(kv.Value, "status") != InnorixClient.StatusComplete)
    .Select(kv => kv.Key).ToList();

if (incomplete.Count > 0)
{
    throw new Exception(
quot;Nodes not fully delivered: {string.Join(", ", incomplete)}"
); }

Use overwrite as the arrival policy. With numbering, copies accumulate on the nodes, making it unclear which copy the training code should read.

Delta Transfer#

Send only the data that changed since the last deployment

monitor_id = api("POST", "/api/transfers/manual", {
    "sourceDevice": STORAGE,
    "targetDevice": node,
    "targetPath": node_input_path("imagenet", "v3", run_id),
    "sourcePaths": [dataset_path("imagenet", "v3")],
    "sendAllFolder": True,
    "incremental": True,
    "transferOptions": {"target-action": "overwrite"},
})["monitorId"]

detail = wait(monitor_id)
print(f"transferred {detail['fileCount']} files, {detail['totalSize']} bytes")
Map<String, Object> transfer = client.apiObj("POST", "/api/transfers/manual", Json.newObj(
        "sourceDevice", STORAGE,
        "targetDevice", node,
        "targetPath", nodeInputPath("imagenet", "v3", runId),
        "sourcePaths", List.of(datasetPath("imagenet", "v3")),
        "sendAllFolder", true,
        "incremental", true,
        "transferOptions", Json.newObj("target-action", "overwrite")));
String monitorId = Json.str(transfer, "monitorId");

Map<String, Object> detail = client.await(monitorId, 7200, null);
System.out.printf("Transferred %s files, %s bytes%n",
        Json.str(detail, "fileCount"), Json.str(detail, "totalSize"));
const transfer = await client.api("POST", "/api/transfers/manual", {
  sourceDevice: STORAGE,
  targetDevice: node,
  targetPath: nodeInputPath("imagenet", "v3", runId),
  sourcePaths: [datasetPath("imagenet", "v3")],
  sendAllFolder: true,
  incremental: true,
  transferOptions: { "target-action": "overwrite" },
});
const monitorId = transfer.monitorId;

const detail = await wait(monitorId);
console.log(`Transferred ${detail.fileCount} files, ${detail.totalSize} bytes`);
JsonObject transfer = await client.ApiObjAsync("POST", "/api/transfers/manual",
    new JsonObject
    {
        ["sourceDevice"] = STORAGE,
        ["targetDevice"] = node,
        ["targetPath"] = NodeInputPath("imagenet", "v3", runId),
        ["sourcePaths"] = new JsonArray { DatasetPath("imagenet", "v3") },
        ["sendAllFolder"] = true,
        ["incremental"] = true,
        ["transferOptions"] = new JsonObject { ["target-action"] = "overwrite" },
    });
string monitorId = J.Str(transfer, "monitorId");

JsonObject detail = await client.WaitAsync(monitorId, 7200);
Console.WriteLine(
quot;Transferred {J.Str(detail, "fileCount")} files, "
+
quot;{J.Str(detail, "totalSize")} bytes"
);

It is disabled by default. The node's agent calculates the changes and transfers only files that were added or modified since the last deployment.

Always use overwrite for incremental transfers. With numbering, changed files accumulate with numbered copies, and the training code may continue reading the old files.

Integrity Verification#

Verify that the transferred data matches the original

Integrity verification has two steps. Start verification with POST /api/transfers/{monitorId}/verification, then poll the result with GET while the server processes it.

def verify(monitor_id, timeout=1800, interval=10):
    api("POST", f"/api/transfers/{monitor_id}/verification", {})

    deadline = time.time() + timeout

    while time.time() < deadline:
        result = api("GET", f"/api/transfers/{monitor_id}/verification") or {}

        if result.get("verified"):
            return result

        time.sleep(interval)

    raise TimeoutError(f"verification: {monitor_id}")


result = verify(monitor_id)

if result["sourceFileCount"] != result["targetFileCount"]:
    raise RuntimeError("file counts differ - part of the transfer is missing")

if not result["checksumMatched"]:
    for row in result.get("mismatchedFiles") or []:
        print("mismatch:", row)
Map<String, Object> result = client.verify(monitorId, 1800, 10);

if (!Json.str(result, "sourceFileCount").equals(Json.str(result, "targetFileCount"))) {
    throw new RuntimeException("File counts differ. Part of the transfer is missing.");
}

if (!Json.bool(result, "checksumMatched", false)) {
    for (Object row : Json.arrOf(result, "mismatchedFiles")) {
        System.out.println("Mismatch: " + row);
    }
}
const result = await verify(monitorId);

if (result.sourceFileCount !== result.targetFileCount) {
  throw new Error("File counts differ. Part of the transfer is missing.");
}

if (!result.checksumMatched) {
  for (const row of result.mismatchedFiles || []) {
    console.log("Mismatch:", row);
  }
}
JsonObject result = await client.VerifyAsync(monitorId);

if (J.Str(result, "sourceFileCount") != J.Str(result, "targetFileCount"))
{
    throw new Exception("File counts differ. Part of the transfer is missing.");
}

if (!J.Bool(result, "checksumMatched", false))
{
    foreach (JsonNode row in J.ArrOf(result, "mismatchedFiles"))
    {
        Console.WriteLine(
quot;Mismatch: {row}"
); } }
Response Item Description
checksumAlgorithm Checksum algorithm used
sourceFileCount · targetFileCount Number of files at the source and target
checksumMatched Whether the checksums match
mismatchedCount · mismatchedFiles Number and list of mismatched files

If the file counts differ, the transfer is incomplete. If the counts match but there are mismatches, the contents were corrupted. The former can be resolved by retransmission; the latter requires finding the cause.

Result Collection#

Collect node-specific outputs on a storage system without collisions

Every node uses the same filename, such as model.pt. If everything is collected into one path, the files will overwrite one another.

def collect(nodes, archive, run_id):
    transfers = {}

    for node in nodes:
        target_path = f"{archive_path(run_id)}/{node}"

        transfers[node] = api("POST", "/api/transfers/manual", {
            "sourceDevice": node,
            "targetDevice": archive,
            "targetPath": target_path,
            "sourcePaths": [node_output_path(run_id)],
            "sendAllFolder": True,
            "checkIntegrity": True,
            "transferOptions": {"target-action": "overwrite"},
        })["monitorId"]

    return transfers
Map<String, String> collect(List<String> nodes, String archive, String runId) {
    Map<String, String> transfers = new LinkedHashMap<>();

    for (String node : nodes) {
        String targetPath = archivePath(runId) + "/" + node;

        Map<String, Object> transfer = client.apiObj("POST", "/api/transfers/manual", Json.newObj(
                "sourceDevice", node,
                "targetDevice", archive,
                "targetPath", targetPath,
                "sourcePaths", List.of(nodeOutputPath(runId)),
                "sendAllFolder", true,
                "checkIntegrity", true,
                "transferOptions", Json.newObj("target-action", "overwrite")));
        transfers.put(node, Json.str(transfer, "monitorId"));
    }

    return transfers;
}
async function collect(nodes, archive, runId) {
  const transfers = {};

  for (const node of nodes) {
    const targetPath = `${archivePath(runId)}/${node}`;

    const transfer = await client.api("POST", "/api/transfers/manual", {
      sourceDevice: node,
      targetDevice: archive,
      targetPath,
      sourcePaths: [nodeOutputPath(runId)],
      sendAllFolder: true,
      checkIntegrity: true,
      transferOptions: { "target-action": "overwrite" },
    });
    transfers[node] = transfer.monitorId;
  }

  return transfers;
}
async Task<Dictionary<string, string>> CollectAsync(
    IEnumerable<string> nodes, string archive, string runId)
{
    var transfers = new Dictionary<string, string>();

    foreach (string node in nodes)
    {
        string targetPath = 
quot;{ArchivePath(runId)}/{node}"
; JsonObject transfer = await client.ApiObjAsync("POST", "/api/transfers/manual", new JsonObject { ["sourceDevice"] = node, ["targetDevice"] = archive, ["targetPath"] = targetPath, ["sourcePaths"] = new JsonArray { NodeOutputPath(runId) }, ["sendAllFolder"] = true, ["checkIntegrity"] = true, ["transferOptions"] = new JsonObject { ["target-action"] = "overwrite" }, }); transfers[node] = J.Str(transfer, "monitorId"); } return transfers; }
/archive/runs/r-20260901-0200/
    dev-gpu-01/
        model.pt
        metrics.json
    dev-gpu-02/
        model.pt
        metrics.json

Including the node identifier in the destination path is the simplest approach. If you avoid collisions with numbering, you cannot tell which node model_1.pt came from.

Collect metrics and logs along with the model files. The cause of a failed training run is recorded in the logs, but those logs will be deleted when the node is reclaimed.

Connect Deployment and Collection#

Automatically start collection when deployment finishes

The code above waits for the client to finish before calling the next step. If training takes several hours, the process must stay alive the entire time, and collection will not happen if it dies.

import uuid

flow_id = str(uuid.uuid4())


def build_step(name, source, source_path, target, target_path,
               step, trigger_id=None, webhook=None):
    schedule = {
        "type": "none",
        "startDateType": "now",
        "startDate": now_iso(),
        "timezone": "Asia/Seoul",
    }

    if trigger_id:
        schedule["triggerAutomation"] = {"value": trigger_id}

    body = {
        "name": name,
        "flowName": name,
        "flowId": flow_id,
        "transferType": "normal",
        "timezone": "Asia/Seoul",
        "step": step,
        "isUpcoming": False,
        "details": [
            {
                "senderId": source,
                "receiverId": target,
                "sourceItem": [
                    {
                        "hash": encode_path(source, source_path),
                        "filePath": source_path,
                        "isDir": True,
                    }
                ],
                "targetPath": encode_path(target, target_path),
                "step": step,
                "transferOptions": {
                    "noSchedule": False,
                    "target-action": "overwrite",
                    "send-fileoption": {},
                },
            }
        ],
        "schedules": [schedule],
    }

    if webhook:
        body["processors"] = [{
            "category": "run",
            "type": "http",
            "config": {"url": webhook, "method": "POST"},
        }]

    return body


deploy_id = api("POST", "/api/automations", build_step(
    f"deploy imagenet:v3", STORAGE, dataset_path("imagenet", "v3"),
    node, node_input_path("imagenet", "v3", run_id),
    step=1, webhook=TRAIN_HOOK))["automationId"]

collect_id = api("POST", "/api/automations", build_step(
    f"collect {run_id}", node, node_output_path(run_id),
    ARCHIVE, f"{archive_path(run_id)}/{node}",
    step=2, trigger_id=deploy_id))["automationId"]
String flowId = UUID.randomUUID().toString();

Map<String, Object> buildStep(String name, String source, String sourcePath,
                              String target, String targetPath, int step,
                              String triggerId, String webhook) {
    Map<String, Object> schedule = new LinkedHashMap<>(Json.newObj(
            "type", "none", "startDateType", "now",
            "startDate", InnorixClient.nowIso(), "timezone", "Asia/Seoul"));

    if (triggerId != null) {
        schedule.put("triggerAutomation", Json.newObj("value", triggerId));
    }

    Map<String, Object> detail = Json.newObj(
            "senderId", source, "receiverId", target,
            "sourceItem", List.of(Json.newObj(
                    "hash", InnorixClient.encodePath(source, sourcePath),
                    "filePath", sourcePath, "isDir", true)),
            "targetPath", InnorixClient.encodePath(target, targetPath),
            "step", step,
            "transferOptions", Json.newObj(
                    "noSchedule", false, "target-action", "overwrite",
                    "send-fileoption", Json.newObj()));

    Map<String, Object> body = new LinkedHashMap<>(Json.newObj(
            "name", name, "flowName", name, "flowId", flowId,
            "transferType", "normal", "timezone", "Asia/Seoul",
            "step", step, "isUpcoming", false,
            "details", List.of(detail),
            "schedules", List.of(schedule)));

    if (webhook != null) {
        body.put("processors", List.of(Json.newObj(
                "category", "run", "type", "http",
                "config", Json.newObj("url", webhook, "method", "POST"))));
    }

    return body;
}
import { randomUUID } from "node:crypto";

const flowId = randomUUID();

function buildStep(name, source, sourcePath, target, targetPath, {
  step, triggerId = null, webhook = null,
}) {
  const schedule = {
    type: "none", startDateType: "now",
    startDate: nowIso(), timezone: "Asia/Seoul",
  };

  if (triggerId) schedule.triggerAutomation = { value: triggerId };

  const body = {
    name, flowName: name, flowId,
    transferType: "normal", timezone: "Asia/Seoul",
    step, isUpcoming: false,
    details: [{
      senderId: source, receiverId: target,
      sourceItem: [{
        hash: encodePath(source, sourcePath),
        filePath: sourcePath, isDir: true,
      }],
      targetPath: encodePath(target, targetPath),
      step,
      transferOptions: {
        noSchedule: false, "target-action": "overwrite",
        "send-fileoption": {},
      },
    }],
    schedules: [schedule],
  };

  if (webhook) {
    body.processors = [{
      category: "run", type: "http",
      config: { url: webhook, method: "POST" },
    }];
  }

  return body;
}
string flowId = Guid.NewGuid().ToString();

JsonObject BuildStep(string name, string source, string sourcePath,
    string target, string targetPath, int step,
    string triggerId = null, string webhook = null)
{
    var schedule = new JsonObject
    {
        ["type"] = "none", ["startDateType"] = "now",
        ["startDate"] = InnorixClient.NowIso(), ["timezone"] = "Asia/Seoul",
    };

    if (triggerId != null)
        schedule["triggerAutomation"] = new JsonObject { ["value"] = triggerId };

    var detail = new JsonObject
    {
        ["senderId"] = source, ["receiverId"] = target,
        ["sourceItem"] = new JsonArray
        {
            new JsonObject
            {
                ["hash"] = InnorixClient.EncodePath(source, sourcePath),
                ["filePath"] = sourcePath, ["isDir"] = true,
            },
        },
        ["targetPath"] = InnorixClient.EncodePath(target, targetPath),
        ["step"] = step,
        ["transferOptions"] = new JsonObject
        {
            ["noSchedule"] = false, ["target-action"] = "overwrite",
            ["send-fileoption"] = new JsonObject(),
        },
    };

    var body = new JsonObject
    {
        ["name"] = name, ["flowName"] = name, ["flowId"] = flowId,
        ["transferType"] = "normal", ["timezone"] = "Asia/Seoul",
        ["step"] = step, ["isUpcoming"] = false,
        ["details"] = new JsonArray { detail },
        ["schedules"] = new JsonArray { schedule },
    };

    if (webhook != null)
    {
        body["processors"] = new JsonArray
        {
            new JsonObject
            {
                ["category"] = "run", ["type"] = "http",
                ["config"] = new JsonObject { ["url"] = webhook, ["method"] = "POST" },
            },
        };
    }

    return body;
}

There are four items that must be handled correctly in the automation request.

Item How to Specify It
isUpcoming Must be false. The server default true ignores the schedule in the request and replaces it with a five-minute one-time schedule. For a step with triggerAutomation, the server forces this to false, so it only needs to be specified explicitly on the first step without a trigger.
step Include it at both the top level and in details. It identifies the hop position within the flow.
sourceItem Include both hash (path token) and filePath (plain-text path).
syncType Include it inside transferOptions. 1 is one-way and 2 is bidirectional.

All four items can be omitted and registration will still succeed, but runtime behavior will differ. If a recurring schedule runs only once and then stops, check isUpcoming first.

Once the two requests have been sent, the application's work is done. Save each step's identifier with the execution record so you can check its status later.

Training Invocation and Retry#

Start training when the data arrives and retransmit only failed files

The invocation occurs after the transfer completes, and the endpoint that receives the request handles it as follows.

def on_train_hook(payload):
    monitor_id = payload.get("monitorId")

    if monitor_id:
        detail = wait(monitor_id)

        if detail["status"] != STATUS_COMPLETE:
            return abort_run(payload)

    start_training(payload)
void onTrainHook(Map<String, Object> payload) throws Exception {
    String monitorId = Json.str(payload, "monitorId");

    if (monitorId != null) {
        Map<String, Object> detail = client.await(monitorId, 7200, null);
        if (Json.intOr(detail, "status", -1) != InnorixClient.STATUS_COMPLETE) {
            abortRun(payload);
            return;
        }
    }

    startTraining(payload);
}
async function onTrainHook(payload) {
  const monitorId = payload.monitorId;

  if (monitorId) {
    const detail = await wait(monitorId);
    if (detail.status !== STATUS_COMPLETE) return abortRun(payload);
  }

  return startTraining(payload);
}
async Task OnTrainHookAsync(JsonObject payload)
{
    string monitorId = J.Str(payload, "monitorId");

    if (monitorId != null)
    {
        JsonObject detail = await client.WaitAsync(monitorId, 7200);
        if (J.Int(detail, "status", -1) != InnorixClient.StatusComplete)
        {
            await AbortRunAsync(payload);
            return;
        }
    }

    await StartTrainingAsync(payload);
}

Check results for each run in the execution history.

runs = api("GET", f"/api/automations/{deploy_id}/executions") or []
latest = runs[0] if runs else None

if latest and latest["status"] != STATUS_COMPLETE:
    print("deploy failed:", latest["monitorId"],
          "retried", retry_failed(latest["monitorId"]), "files")
List<Map<String, Object>> runs = client.executions(deployId);
Map<String, Object> latest = runs.isEmpty() ? null : runs.get(0);

if (latest != null
        && Json.intOr(latest, "status", -1) != InnorixClient.STATUS_COMPLETE) {
    int count = client.retryFailed(Json.str(latest, "monitorId"));
    System.out.println("Deployment failed: " + Json.str(latest, "monitorId")
            + " retried " + count + " files");
}
const runs = (await client.executions(deployId)) || [];
const latest = runs[0] || null;

if (latest && latest.status !== STATUS_COMPLETE) {
  const count = await client.retryFailed(latest.monitorId);
  console.log("Deployment failed:", latest.monitorId, "retried", count, "files");
}
List<JsonObject> runs = J.AsList(await client.ExecutionsAsync(deployId));
JsonObject latest = runs.Count > 0 ? runs[0] : null;

if (latest != null
    && J.IntOrNull(latest, "status") != InnorixClient.StatusComplete)
{
    int count = await client.RetryFailedAsync(J.Str(latest, "monitorId"));
    Console.WriteLine(
quot;Deployment failed: {J.Str(latest, "monitorId")} retried {count} files"
); }

When the dataset is large, retransmitting everything because of a few failed files is a major waste.

Check Item Details
Path Dataset version and run identifier
Deployment Whether delivery to each node is complete
Verification Whether file counts and checksums match
Collection Destination path for each node's results
Flow Run and status at each stage
Retransmission Failed files and processing results
NextDeploy AI Model Files to Multiple Edge Devices

On this page

  • Getting Started
  • Core Concept
  • Workflow Flow
  • Automation Benefits
  • IT Engineer
  • Data Collection
  • Processing Connection
  • Job Conditions
  • Result Transfer
  • Run Management
  • Exception Handling
  • Developer
  • Integration Setup
  • Path Rules
  • Data Deployment
  • Delta Transfer
  • Integrity Verification
  • Result Collection
  • Connect Deployment and Collection
  • Training Invocation and Retry