Skip to content

Workflow Instances

A workflow that declares params(...) advertises its configurable surface. A workflow instance binds values to those params and gives the result a human name and a schedule — so one workflow template becomes many independent, operable schedules:

sync_file (template, declares: source, dst, mode = "copy")
├── sync_prod source=/prod dst=/backup/prod 0 * * * *
├── sync_staging source=/staging dst=/backup/staging 0 3 * * *
└── sync_archive source=/archive dst=/cold 0 4 * * 0

An instance is data, not code: a serializable value of (workflow name + fully-resolved params). Defaults are snapshotted when the instance is built — a registered instance never silently changes behavior when the workflow’s defaults change; re-register to adopt new defaults.

use cloacina::workflow_instance::WorkflowInstance;
use cloacina::input_interface::{schema_for, InputSlot};
// `declared` is the workflow's declared input slots — the `Vec<InputSlot>` the
// `#[workflow(params(...))]` macro emits for `sync_file`. It's the same schema
// the execute API validates against. Shown inline here so this example is
// self-contained; in practice you read it from the registered workflow rather
// than hand-writing it:
let declared = vec![
InputSlot::required("source", schema_for::<String>()),
InputSlot::required("dst", schema_for::<String>()),
InputSlot::optional("mode", schema_for::<String>(), Some(serde_json::json!("copy"))),
];
let instance = WorkflowInstance::builder("sync_file")
.param("source", "/prod")?
.param("dst", "/backup/prod")?
.build(&declared)?; // validates: unknown / missing-required / reserved names
// and snapshots defaults (mode = "copy")
// Register under a human name, on its own cron schedule:
runner
.register_cron_workflow_instance(&instance, "sync_prod", "0 * * * *", "UTC")
.await?;
// Lifecycle by name (resolves to the schedule row underneath):
let row = runner.get_workflow_instance("sync_file", "sync_prod").await?;
runner.unregister_workflow_instance("sync_file", "sync_prod").await?;

Instance names are unique per workflow (a second sync_prod registration fails); different names stamp out independent copies.

params = cloaca.Context({"source": "/prod", "dst": "/backup/prod"})
runner.register_workflow_instance(
"sync_file", "sync_prod", "0 * * * *", "UTC", params
)

The Rust and Python forms above are the embedded runner’s API. On a server deployment, instances are managed with the instance noun — no embedding required:

Terminal window
cloacinactl instance create sync_file sync_prod \
--param source=/prod --param dst=/backup/prod --cron "0 * * * *"
cloacinactl instance list sync_file
cloacinactl instance inspect sync_file sync_prod
cloacinactl instance delete sync_file sync_prod

--param is repeatable. A value that parses as JSON binds as that type (max_files=500 → the number 500), and anything else binds as a string (mode=copy). --params <file.json> supplies a base object that explicit --param flags override, so a shared configuration can be reused with a couple of per-instance tweaks. --timezone sets the cron timezone (default UTC) and --disabled creates the schedule switched off.

Values are validated against the workflow’s declared params(...) when the instance is created, with the same check that validates a per-run context — a missing required param is rejected up front rather than failing at every fire. A duplicate instance name for the same workflow is a 409.

--cron is optional. Without it the instance is created unscheduled: a durable named binding that never fires on its own.

The equivalent REST surface is:

Method Path
POST /v1/tenants/{tenant}/workflows/{name}/instances
GET /v1/tenants/{tenant}/workflows/{name}/instances
GET /v1/tenants/{tenant}/workflows/{name}/instances/{instance}
DELETE /v1/tenants/{tenant}/workflows/{name}/instances/{instance}

and the same four operations exist on the Rust, Python and TypeScript clients as create_instance / list_instances / get_instance / delete_instance.

Instances are tenant-scoped: an instance created in one tenant is invisible to another, and a cross-tenant request simply 404s.

Because an instance is a schedule row underneath, the existing schedule controls apply to it — cloacinactl trigger pause <workflow> will pause it. There is no separate instance pause verb today.

At every fire (cron or trigger), the instance’s stored params are merged into the run’s context as flat top-level keys — exactly the shape a manual execute with a validated context produces, so tasks read them identically in both cases:

  • The scheduler’s reserved keys always win: scheduled_time, schedule_id, schedule_timezone, schedule_expression, trigger_name, triggered_at cannot be overridden (or spoofed) by a binding.
  • For trigger fires, bound instance params override same-named keys in the trigger-produced payload.

Anonymous schedules (registered via register_cron_workflow) are unaffected — they carry no params and behave exactly as before.

  • Not a closure. You can bind a path, a mode, an ID — serializable data only. The same workflow may run in-process, from a packaged .so, or on a remote fleet agent; bound params travel with the run via the context.
  • Not a workflow version. Params are instance data; they don’t change the workflow’s content hash.