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.
Prerequisite: declare the params firstInstances only make sense for a workflow that declares its inputs. Before you can bind params, thesync_fileworkflow must declare them with#[workflow(params(...))](Python:@cloaca.workflow_params(...)) — see Declare and validate workflow inputs. That declaration is what produces thedeclaredslots the builder validates against below.
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:
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_atcannot 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.