Skip to main content

agent_exec/
create.rs

1//! Implementation of the `create` sub-command.
2//!
3//! `create` persists a full job definition without launching the supervisor or
4//! child process.  The job is left in `created` state so that `start` can
5//! launch it later.
6
7use anyhow::{Context, Result};
8use tracing::info;
9use ulid::Ulid;
10
11use crate::jobstore::{JobDir, resolve_root};
12use crate::run::{mask_env_vars, pre_create_log_files, resolve_effective_cwd};
13use crate::schema::{CreateData, JobMeta, JobMetaJob, Response};
14use crate::tag::dedup_tags;
15
16/// Options for the `create` sub-command.
17///
18/// # Definition-time option alignment rule
19///
20/// Every definition-time option accepted here MUST also be accepted by `run` (and vice versa),
21/// since both commands write the same persisted job definition to `meta.json`. When adding a
22/// new persisted metadata field, wire it through both `create` and `run` unless the spec
23/// explicitly documents it as launch-only (e.g. snapshot timing, tail sizing, --wait).
24#[derive(Debug)]
25pub struct CreateOpts<'a> {
26    /// Command and arguments to execute when `start` is called.
27    pub command: Vec<String>,
28    /// Override for jobs root directory.
29    pub root: Option<&'a str>,
30    /// Timeout in milliseconds; 0 = no timeout.
31    pub timeout_ms: u64,
32    /// Milliseconds after SIGTERM before SIGKILL; 0 = immediate SIGKILL.
33    pub kill_after_ms: u64,
34    /// Working directory for the command.
35    pub cwd: Option<&'a str>,
36    /// Environment variables as KEY=VALUE strings (persisted as durable config).
37    pub env_vars: Vec<String>,
38    /// Paths to env files (persisted as file-path references, read at start time).
39    pub env_files: Vec<String>,
40    /// Whether to inherit the current process environment at start time (default: true).
41    pub inherit_env: bool,
42    /// Keys to mask in JSON output (values replaced with "***").
43    pub mask: Vec<String>,
44    /// Interval (ms) for state.json updated_at refresh; 0 = disabled.
45    pub progress_every_ms: u64,
46    /// Shell command string for command notification sink.
47    pub notify_command: Option<String>,
48    /// File path for NDJSON notification sink.
49    pub notify_file: Option<String>,
50    /// Resolved shell wrapper argv (e.g. ["sh", "-lc"]).
51    pub shell_wrapper: Vec<String>,
52    /// User-defined tags for this job (deduplicated preserving first-seen order).
53    pub tags: Vec<String>,
54    /// Pattern to match against output lines (output-match notification).
55    pub output_pattern: Option<String>,
56    /// Match type for output-match: "contains" or "regex".
57    pub output_match_type: Option<String>,
58    /// Stream selector: "stdout", "stderr", or "either".
59    pub output_stream: Option<String>,
60    /// Shell command string for output-match command sink.
61    pub output_command: Option<String>,
62    /// File path for output-match NDJSON file sink.
63    pub output_file: Option<String>,
64}
65
66/// Execute `create`: persist job definition and return JSON.
67pub fn execute(opts: CreateOpts) -> Result<()> {
68    if opts.command.is_empty() {
69        anyhow::bail!("no command specified for create");
70    }
71
72    let root = resolve_root(opts.root);
73    std::fs::create_dir_all(&root)
74        .with_context(|| format!("create jobs root {}", root.display()))?;
75
76    let job_id = Ulid::new().to_string();
77    let created_at = crate::run::now_rfc3339_pub();
78
79    let env_keys: Vec<String> = opts
80        .env_vars
81        .iter()
82        .map(|kv| kv.split('=').next().unwrap_or(kv.as_str()).to_string())
83        .collect();
84
85    let masked_env_vars = mask_env_vars(&opts.env_vars, &opts.mask);
86
87    let effective_cwd = resolve_effective_cwd(opts.cwd);
88
89    // Build output-match config from definition-time options (same logic as `notify set`).
90    let on_output_match = crate::notify::build_output_match_config(
91        opts.output_pattern,
92        opts.output_match_type,
93        opts.output_stream,
94        opts.output_command,
95        opts.output_file,
96        None,
97    );
98
99    let notification =
100        if opts.notify_command.is_some() || opts.notify_file.is_some() || on_output_match.is_some()
101        {
102            Some(crate::schema::NotificationConfig {
103                notify_command: opts.notify_command.clone(),
104                notify_file: opts.notify_file.clone(),
105                on_output_match,
106            })
107        } else {
108            None
109        };
110
111    // Validate and deduplicate tags (preserving first-seen order).
112    let tags = dedup_tags(opts.tags)?;
113
114    let meta = JobMeta {
115        job: JobMetaJob { id: job_id.clone() },
116        schema_version: crate::schema::SCHEMA_VERSION.to_string(),
117        command: opts.command.clone(),
118        created_at: created_at.clone(),
119        root: root.display().to_string(),
120        env_keys,
121        env_vars: masked_env_vars,
122        // Persist actual (unmasked) env vars for runtime use by `start`.
123        // --mask only affects display/metadata views; the real values are needed
124        // so `start` can apply them to the child process environment.
125        env_vars_runtime: opts.env_vars.clone(),
126        mask: opts.mask.clone(),
127        cwd: Some(effective_cwd),
128        notification,
129        tags,
130        // Execution-definition fields persisted for `start`.
131        inherit_env: opts.inherit_env,
132        env_files: opts.env_files.clone(),
133        timeout_ms: opts.timeout_ms,
134        kill_after_ms: opts.kill_after_ms,
135        progress_every_ms: opts.progress_every_ms,
136        shell_wrapper: Some(opts.shell_wrapper.clone()),
137    };
138
139    let job_dir = JobDir::create(&root, &job_id, &meta)?;
140    info!(job_id = %job_id, "created job directory (created state)");
141
142    // Pre-create empty log files.
143    pre_create_log_files(&job_dir)?;
144
145    // Write state.json with `created` status — no process spawned.
146    job_dir.init_state_created()?;
147
148    let stdout_log_path = job_dir.stdout_path().display().to_string();
149    let stderr_log_path = job_dir.stderr_path().display().to_string();
150
151    Response::new(
152        "create",
153        CreateData {
154            job_id,
155            state: "created".to_string(),
156            stdout_log_path,
157            stderr_log_path,
158        },
159    )
160    .print();
161
162    Ok(())
163}