Skip to main content

agent_exec/
notify.rs

1//! Implementation of the `notify` sub-commands.
2
3use anyhow::Result;
4
5use crate::jobstore::{JobDir, resolve_root};
6use crate::schema::{
7    NotificationConfig, NotifySetData, OutputMatchConfig, OutputMatchStream, OutputMatchType,
8    Response,
9};
10
11/// Options for `notify set`.
12pub struct NotifySetOpts<'a> {
13    /// Job identifier.
14    pub job_id: &'a str,
15    /// Override for jobs root directory.
16    pub root: Option<&'a str>,
17    /// Shell command string to store as notify_command (completion notification).
18    pub command: Option<String>,
19    /// Pattern to match against output lines.
20    pub output_pattern: Option<String>,
21    /// Match type: "contains" or "regex".
22    pub output_match_type: Option<String>,
23    /// Stream selector: "stdout", "stderr", or "either".
24    pub output_stream: Option<String>,
25    /// Shell command string for output-match command sink.
26    pub output_command: Option<String>,
27    /// File path for output-match NDJSON file sink.
28    pub output_file: Option<String>,
29}
30
31/// Execute `notify set`: update persisted notification configuration for an existing job.
32///
33/// This is a metadata-only operation: it rewrites meta.json.notification and
34/// preserves unspecified fields. It does not execute any sink or trigger delivery.
35pub fn set(opts: NotifySetOpts) -> Result<()> {
36    let root = resolve_root(opts.root);
37    let job_dir = JobDir::open(&root, opts.job_id)?;
38
39    let mut meta = job_dir.read_meta()?;
40
41    // Preserve existing completion notification fields.
42    let existing_notify_command = meta
43        .notification
44        .as_ref()
45        .and_then(|n| n.notify_command.clone());
46    let existing_notify_file = meta
47        .notification
48        .as_ref()
49        .and_then(|n| n.notify_file.clone());
50    let existing_on_output_match = meta
51        .notification
52        .as_ref()
53        .and_then(|n| n.on_output_match.clone());
54
55    // Update completion notify_command if provided.
56    let new_notify_command = opts.command.or(existing_notify_command);
57
58    // Build updated output-match config.
59    let new_on_output_match = build_output_match_config(
60        opts.output_pattern,
61        opts.output_match_type,
62        opts.output_stream,
63        opts.output_command,
64        opts.output_file,
65        existing_on_output_match,
66    );
67
68    // Only write notification block if something is configured.
69    let has_anything = new_notify_command.is_some()
70        || existing_notify_file.is_some()
71        || new_on_output_match.is_some();
72
73    meta.notification = if has_anything {
74        Some(NotificationConfig {
75            notify_command: new_notify_command,
76            notify_file: existing_notify_file,
77            on_output_match: new_on_output_match,
78        })
79    } else {
80        None
81    };
82
83    job_dir.write_meta_atomic(&meta)?;
84
85    let notification = meta.notification.unwrap_or(NotificationConfig {
86        notify_command: None,
87        notify_file: None,
88        on_output_match: None,
89    });
90    let response = Response::new(
91        "notify.set",
92        NotifySetData {
93            job_id: opts.job_id.to_string(),
94            notification,
95        },
96    );
97    response.print();
98    Ok(())
99}
100
101/// Build an updated `OutputMatchConfig` by merging provided options with existing config.
102///
103/// - If `output_pattern` is provided, a new config is created from scratch using
104///   provided values (with defaults for unspecified fields).
105/// - If no new pattern is provided but other output-match fields are provided,
106///   they overlay the existing config.
107/// - If nothing is provided and there's no existing config, returns `None`.
108pub fn build_output_match_config(
109    output_pattern: Option<String>,
110    output_match_type: Option<String>,
111    output_stream: Option<String>,
112    output_command: Option<String>,
113    output_file: Option<String>,
114    existing: Option<OutputMatchConfig>,
115) -> Option<OutputMatchConfig> {
116    let has_new_input = output_pattern.is_some()
117        || output_match_type.is_some()
118        || output_stream.is_some()
119        || output_command.is_some()
120        || output_file.is_some();
121
122    if !has_new_input {
123        return existing;
124    }
125
126    // Start from existing config or defaults.
127    let base = existing.unwrap_or_else(|| OutputMatchConfig {
128        pattern: String::new(),
129        match_type: OutputMatchType::default(),
130        stream: OutputMatchStream::default(),
131        command: None,
132        file: None,
133    });
134
135    let pattern = output_pattern.unwrap_or(base.pattern);
136
137    let match_type = match output_match_type.as_deref() {
138        Some("regex") => OutputMatchType::Regex,
139        Some("contains") => OutputMatchType::Contains,
140        _ => base.match_type,
141    };
142
143    let stream = match output_stream.as_deref() {
144        Some("stdout") => OutputMatchStream::Stdout,
145        Some("stderr") => OutputMatchStream::Stderr,
146        Some("either") => OutputMatchStream::Either,
147        _ => base.stream,
148    };
149
150    // For command/file sinks: if provided, replace; otherwise preserve existing.
151    let command = output_command.or(base.command);
152    let file = output_file.or(base.file);
153
154    // Only produce a config if there's a non-empty pattern.
155    if pattern.is_empty() {
156        return None;
157    }
158
159    Some(OutputMatchConfig {
160        pattern,
161        match_type,
162        stream,
163        command,
164        file,
165    })
166}