obelisk 0.37.2

Deterministic workflow engine
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
use clap::Parser;
use concepts::{
    ComponentType, ExecutionId, FunctionFqn, FunctionFqnParseError,
    prefixed_ulid::{DeploymentId, ExecutionIdDerived},
};

/// Deployment TOML section names, used as the key in the deployment TOML file.
#[derive(
    Debug, Clone, Copy, strum::Display, strum::EnumString, serde::Serialize, serde::Deserialize,
)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub(crate) enum TomlComponentType {
    ActivityWasm,
    ActivityStub,
    ActivityExternal,
    ActivityJs,
    WorkflowWasm,
    WorkflowJs,
    WebhookEndpointWasm,
    WebhookEndpointJs,
    Cron,
}
use std::{path::PathBuf, str::FromStr};

fn parse_oci_reference(s: &str) -> Result<oci_client::Reference, String> {
    let s = s
        .strip_prefix("oci://")
        .ok_or_else(|| format!("OCI reference must start with `oci://`, got: {s}"))?;
    oci_client::Reference::from_str(s).map_err(|e| e.to_string())
}

/// A deployment source: either a path to a TOML file or an existing deployment ID.
#[derive(Debug, Clone)]
pub(crate) enum DeploymentSource {
    File(PathBuf),
    Id(DeploymentId),
}

impl FromStr for DeploymentSource {
    type Err = std::convert::Infallible;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if let Ok(id) = s.parse::<DeploymentId>() {
            Ok(DeploymentSource::Id(id))
        } else {
            Ok(DeploymentSource::File(PathBuf::from(s)))
        }
    }
}

pub(crate) mod shadow {
    pub(crate) const PKG_VERSION: &str = env!("PKG_VERSION");
}

#[derive(Parser, Debug)]
#[clap(name = "obelisk")]
#[command
(
    version = const_format::formatcp!("{}", shadow::PKG_VERSION),
about = "Obelisk: deterministic workflow engine", disable_version_flag = true, disable_help_subcommand = true)]
pub(crate) struct Args {
    #[command(subcommand)]
    pub(crate) command: Subcommand,

    /// Print version
    #[arg(short, long, action = clap::ArgAction::Version)]
    version: Option<bool>,
}

#[derive(Debug, clap::Subcommand)]
pub(crate) enum Subcommand {
    /// Run or verify the Obelisk server.
    #[command(subcommand)]
    Server(Server),
    /// Submit, inspect, stub, or cancel executions against a running server.
    #[command(subcommand)]
    Execution(Execution),
    /// Inspect components or add/push them to an OCI registry.
    #[command(subcommand)]
    Component(Component),
    /// Manage deployments.
    #[command(subcommand)]
    Deployment(Deployment),
    /// Generate configuration files and WIT artifacts.
    #[command(subcommand)]
    Generate(Generate),
}

#[derive(Debug, clap::Subcommand)]
pub(crate) enum Deployment {
    /// Upload a deployment TOML as a new deployment; print the new deployment ID.
    Submit {
        /// Path to the deployment TOML file.
        #[arg(
            value_name = "PATH",
            required_unless_present = "empty",
            conflicts_with = "empty"
        )]
        file: Option<PathBuf>,
        /// Submit an empty deployment with no components.
        #[arg(long)]
        empty: bool,
        /// Verify all environment variables before persisting.
        #[arg(long)]
        verify: bool,
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
    },
    /// Submit (if a file is given) and enqueue for next server restart.
    Enqueue {
        /// Path to a deployment TOML file, or an existing deployment ID.
        #[arg(
            value_name = "PATH|ID",
            required_unless_present = "empty",
            conflicts_with = "empty"
        )]
        source: Option<DeploymentSource>,
        /// Enqueue an empty deployment with no components.
        #[arg(long)]
        empty: bool,
        /// Verify all environment variables before enqueuing.
        #[arg(long)]
        verify: bool,
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
    },
    /// Submit (if a file is given) and hot-redeploy immediately. Fails if not possible.
    Apply {
        /// Path to a deployment TOML file, or an existing deployment ID.
        #[arg(
            value_name = "PATH|ID",
            required_unless_present = "empty",
            conflicts_with = "empty"
        )]
        source: Option<DeploymentSource>,
        /// Apply an empty deployment with no components.
        #[arg(long)]
        empty: bool,
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
    },
    /// List recent deployments.
    List {
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
    },
    /// Show the full configuration of a deployment.
    Show {
        /// Deployment ID
        #[arg(value_name = "ID")]
        id: String,
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
    },
}

#[derive(Debug, clap::Subcommand)]
pub(crate) enum Generate {
    /// Generate the server configuration (server.toml) schema in JSON schema format.
    #[cfg(debug_assertions)]
    ServerConfigSchema {
        /// Filename to write the schema to, defaults to <stdout>.
        output: Option<PathBuf>,
    },
    /// Generate the deployment configuration (deployment.toml) schema in JSON schema format.
    #[cfg(debug_assertions)]
    DeploymentSchema {
        /// Filename to write the schema to, defaults to <stdout>.
        output: Option<PathBuf>,
    },
    /// Generate the canonical deployment schema (stored in the database) in JSON schema format.
    #[cfg(debug_assertions)]
    DeploymentCanonicalSchema {
        /// Filename to write the schema to, defaults to <stdout>.
        output: Option<PathBuf>,
    },
    /// Generate the database storage schema in JSON schema format.
    #[cfg(debug_assertions)]
    DbSchema {
        /// Filename to write the schema to, defaults to <stdout>.
        output: Option<PathBuf>,
    },
    /// Generate the `OpenAPI` schema in JSON format.
    #[cfg(debug_assertions)]
    OpenApiSchema {
        /// Filename to write the schema to, defaults to <stdout>.
        output: Option<PathBuf>,
    },
    /// Generate extension WIT files that are automatically implemented by Obelisk
    /// based on the exported interfaces of the component (e.g. `-schedule`, `-await-next` variants).
    WitExtensions {
        /// Overwrite existing files in the output directory.
        #[arg(long, short)]
        force: bool,
        /// Component type this WIT is for. One of `workflow`, `activity`, `activity_stub`, `webhook_endpoint`.
        component_type: ComponentType,
        /// Path to the `wit` folder containing the target world and, if present, a `deps` subfolder.
        input_wit_directory: PathBuf,
        /// Directory where folders and WIT files will be written to.
        output_directory: PathBuf,
    },
    /// Generate Obelisk's built-in support WIT files (host interfaces) for the given component type.
    WitSupport {
        /// Component type whose host interfaces to emit. One of `workflow`, `activity`, `activity_stub`, `webhook_endpoint`.
        component_type: ComponentType,
        /// Directory where folders and WIT files will be written to.
        output_directory: PathBuf,
        /// Overwrite existing files in the output directory.
        #[arg(long, short)]
        overwrite: bool,
    },
    /// Generate WIT dependency folder based on activities and workflows found in the deployment TOML.
    WitDeps {
        /// Path to the deployment TOML file.
        #[arg(long, short)]
        deployment: PathBuf,
        /// Directory where folders and WIT files will be written to.
        output_directory: PathBuf,
        /// Overwrite existing files.
        #[arg(long, short)]
        overwrite: bool,
    },
    /// Generate a default server.toml.
    ServerConfig {
        /// Filename to write the TOML to, defaults to `server.toml`.
        output: Option<PathBuf>,
        /// Overwrite existing file.
        #[arg(long, short)]
        overwrite: bool,
    },
    /// Generate a default deployment.toml.
    Deployment {
        /// Filename to write the TOML to, defaults to `deployment.toml`.
        output: Option<PathBuf>,
        /// Overwrite existing file.
        #[arg(long, short)]
        overwrite: bool,
    },
    /// Generate a fresh random execution ID and print it to stdout.
    ExecutionId,
}

#[derive(Debug, clap::Subcommand)]
pub(crate) enum Server {
    /// Start the Obelisk server.
    Run {
        /// Delete the sqlite database directory before starting. Destroys all execution history.
        #[arg(long)]
        clean_sqlite_directory: bool,
        /// Delete both the codegen cache and the OCI image cache before starting.
        #[arg(long)]
        clean_cache: bool,
        /// Delete only the codegen cache before starting; OCI image cache is kept.
        #[arg(long)]
        clean_codegen_cache: bool,
        /// Path to the server configuration file (server.toml). If omitted, built-in defaults are used.
        #[arg(long)]
        server_config: Option<PathBuf>,
        /// Path to the deployment TOML file. If provided, the deployment is inserted and activated on startup,
        /// overriding any existing Enqueued or Active deployment in the database.
        #[arg(short, long, conflicts_with = "deployment_empty")]
        deployment: Option<PathBuf>,
        /// Start with an empty deployment, ignoring any Enqueued or Active deployment in the database.
        /// Useful for recovering from a faulty deployment: start empty, then push a new deployment or switch to existing via gRPC.
        #[arg(long)]
        deployment_empty: bool,
        /// Do not fail startup when a component's imports/exports fail type checking against the current deployment.
        #[arg(long, short)]
        suppress_type_checking_errors: bool,
    },
    /// Read the configuration, compile the components, verify their imports and exit without starting the server.
    Verify {
        /// Delete both the codegen cache and the OCI image cache before verifying.
        #[arg(long)]
        clean_cache: bool,
        /// Delete only the codegen cache before verifying; OCI image cache is kept.
        #[arg(long)]
        clean_codegen_cache: bool,
        /// Path to the server configuration file (server.toml). If omitted, built-in defaults are used.
        #[arg(long)]
        server_config: Option<PathBuf>,
        /// Path to the deployment TOML file. If omitted, the database's Enqueued deployment is used,
        /// falling back to the Active deployment. Errors if neither is found.
        #[arg(short, long)]
        deployment: Option<PathBuf>,
        /// Skip the check that every environment variable referenced by the deployment is set.
        #[arg(long, short)]
        ignore_missing_env_vars: bool,
        /// Do not fail when a component's imports/exports fail type checking against the deployment.
        #[arg(long, short)]
        suppress_type_checking_errors: bool,
        /// Skip opening the sqlite database and validating its schema.
        #[arg(long)]
        skip_db: bool,
    },
}

#[derive(Debug, clap::Subcommand)]
pub(crate) enum Component {
    /// List components.
    List {
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
        /// Show component imports
        #[arg(short, long)]
        imports: bool,
        /// Show auto-generated export extensions
        #[arg(short, long)]
        extensions: bool,
    },
    /// Push a WASM component to an OCI registry with deployment metadata.
    Push {
        /// Component name in the input deployment TOML
        #[arg(required(true))]
        component_name: String,
        /// OCI reference with `oci://` prefix. Example: `oci://docker.io/repo/image:tag`
        #[arg(required(true), value_parser = parse_oci_reference)]
        oci: oci_client::Reference,
        /// Path to the input deployment TOML file.
        #[arg(long, short, required = true)]
        deployment: PathBuf,
    },
    /// Add a component to the deployment TOML configuration file from an OCI reference.
    Add {
        /// OCI reference with `oci://` prefix. Example: `oci://docker.io/repo/image:tag`
        #[arg(required(true), value_parser = parse_oci_reference)]
        location: oci_client::Reference,
        /// Component name in the target deployment TOML
        #[arg(required(true))]
        component_name: String,
        /// Path to the target deployment TOML file.
        #[arg(long, short, required = true)]
        deployment: PathBuf,
        /// Pin the location with the manifest digest (e.g. `image:tag@sha256:...`).
        #[arg(long)]
        locked: bool,
    },
}

#[derive(Debug, Clone)]
pub enum FunctionFqnOrShort {
    Ffqn(FunctionFqn),
    Short {
        ifc_name: String,
        function_name: String,
    }, // starts with `.../` prefix
}
impl FromStr for FunctionFqnOrShort {
    type Err = FunctionFqnParseError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        const PREFIX: &str = ".../";

        if let Some(rest) = input.strip_prefix(PREFIX) {
            let Some((ifc_name, fn_name)) = rest.split_once('.') else {
                return Err(FunctionFqnParseError::DelimiterNotFound(input.to_string()));
            };
            // Ensure exactly two parts
            if fn_name.contains('.') {
                return Err(FunctionFqnParseError::DelimiterFoundInFunctionName(
                    input.to_string(),
                ));
            }

            Ok(FunctionFqnOrShort::Short {
                ifc_name: ifc_name.to_string(),
                function_name: fn_name.to_string(),
            })
        } else {
            Ok(FunctionFqnOrShort::Ffqn(FunctionFqn::from_str(input)?))
        }
    }
}

#[derive(Debug, clap::Subcommand)]
pub(crate) enum Execution {
    /// Submit a new execution and optionally follow its status stream until it finishes.
    Submit {
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
        /// Use this explicit execution ID instead of having the server assign one. Useful for idempotency. See `obelisk generate execution-id`.
        #[arg(short, long)]
        execution_id: Option<ExecutionId>,
        /// Function to invoke, either as a fully qualified name (`ns:pkg/ifc.fn`)
        /// or shortened to `.../ifc.fn` when the interface name is unambiguous.
        #[arg(value_name = "function")]
        ffqn: FunctionFqnOrShort,
        /// Follow the stream of events until the execution finishes.
        #[arg(short, long)]
        follow: bool,
        /// Do not attempt to reconnect on connection error while following the status stream.
        #[arg(long, requires = "follow")]
        no_reconnect: bool,
        /// Output events as JSON in Web API format instead of human-readable text.
        #[arg(short, long)]
        json: bool,
        /// Accepted Parameter Formats:
        ///
        /// - JSON array string, e.g. '["first", "second", null, 1]'
        ///
        /// - File reference prefixed with @, e.g. @file.json (file must contain a valid JSON array)
        ///
        /// - Multiple arguments after --, e.g. -- '"first"' @secondparam.json null 1
        ///
        /// - For functions with no parameters: [] (JSON array variant) or no arguments.
        #[arg(name = "parameters")]
        params: Vec<String>,
    },
    /// Write a return value or an execution error to an already created stubbed execution,
    /// unblocking any parent workflow that is awaiting it.
    Stub(Stub),
    /// Get the current state of an execution, optionally following the event stream.
    Get {
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
        /// Follow the status stream until the execution finishes.
        #[arg(short, long)]
        follow: bool,
        /// Execution ID to look up.
        execution_id: ExecutionId,
        /// Do not attempt to reconnect on connection error while following the status stream.
        #[arg(long, requires = "follow")]
        no_reconnect: bool,
    },
    /// Request cancellation of a running activity or pending delay.
    Cancel(CancelCommand),
}

pub(crate) mod params {
    use clap::error::ErrorKind;
    use serde_json::Value;

    pub(crate) fn parse_params(params: Vec<String>) -> Result<Vec<serde_json::Value>, clap::Error> {
        if params.is_empty() {
            Ok(vec![]) // no params, does not matter if `--` was present.
        } else if params.len() == 1 && !dashdash() {
            let mut params = params;
            let json_array = params.pop().expect("checked that len == 1");
            // Single JSON Array, or a `@`-prefixed file containing the array.
            let json_array = if let Some(file_path) = json_array.strip_prefix('@') {
                std::fs::read_to_string(file_path).map_err(|err| {
                    clap::Error::raw(
                        ErrorKind::Io,
                        format!(
                            "parameter parsing failed: failed to read file '{file_path}': {err}"
                        ),
                    )
                })?
            } else {
                json_array
            };
            let json_value = serde_json::from_str(&json_array).map_err(|err| {
                clap::Error::raw(
                    ErrorKind::ValueValidation,
                    format!("Invalid JSON array for parameters: {err}"),
                )
            })?;
            let Value::Array(params) = json_value else {
                return Err(clap::Error::raw(
                    ErrorKind::ValueValidation,
                    "Parameter provided as JSON must be a JSON array.",
                ));
            };
            Ok(params)
        } else {
            // Fallback to raw arguments. Each argument is interpreted as a JSON value or a file starting with `@` that contains the JSON.
            let mut parsed_params: Vec<Value> = Vec::new();
            for (idx, arg) in params.into_iter().enumerate() {
                let arg = if let Some(file_path) = arg.strip_prefix('@') {
                    std::fs::read_to_string(file_path).map_err(|err| {
                        clap::Error::raw(
                            ErrorKind::Io,
                            format!(
                                "{}-th parameter parsing failed: failed to read file '{file_path}': {err}",
                                idx + 1
                            ),
                        )
                    })?
                } else {
                    arg
                };
                let json = serde_json::from_str(&arg).map_err(|err| {
                    clap::Error::raw(
                        ErrorKind::ValueValidation,
                        format!(
                            "cannot parse {}-th parameter `{arg}` as JSON -  {err}",
                            idx + 1
                        ),
                    )
                })?;
                parsed_params.push(json);
            }
            Ok(parsed_params)
        }
    }

    fn dashdash() -> bool {
        // Ambigous: Either the single JSON Array representing all parameters,
        // OR `-- "first-and-only-json-param"`
        let mut rev_arg_iter = std::env::args().rev();
        rev_arg_iter.next().expect("last arg must be present");
        let maybe_separator = rev_arg_iter.next().expect("last-1 arg must be present");
        maybe_separator == "--"
    }
}

#[derive(Debug, clap::Args)]
#[command()]
pub(crate) struct Stub {
    /// Address of the obelisk server
    #[arg(short, long, default_value = "http://127.0.0.1:5005")]
    pub(crate) api_url: String,
    /// Execution ID of the stub execution waiting for its return value.
    #[arg(value_name = "EXECUTION_ID")]
    pub(crate) execution_id: ExecutionIdDerived,

    /// Stub a return value encoded as JSON
    #[arg(value_name = "RETURN_VAL")]
    pub(crate) return_value: String,
}

#[derive(Debug, clap::Args)]
#[command()]
#[expect(clippy::doc_markdown)]
pub(crate) struct CancelCommand {
    /// Address of the obelisk server
    #[arg(short, long, default_value = "http://127.0.0.1:5005")]
    pub(crate) api_url: String,
    /// Execution id of an activity (E_01...) or a delay request (Delay_01...)
    #[arg(value_name = "ID")]
    pub(crate) id: String,
}