Skip to main content

speccoder_web/
jobs.rs

1//! What the host runs for the page: one job at a time — a planning skill, a
2//! run, a planning commit, a decision on a task. A job's progress, and the
3//! calls and questions that need the person, go to the page as events; the
4//! page's answers come back here and reach the waiting job. Nothing about a
5//! run changes: it is journaled, decided and committed as from the CLI.
6
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
9use std::sync::{Arc, Mutex, mpsc};
10
11use serde_json::{Value, json};
12use speccoder_core::actions::{Runnable, Via};
13use speccoder_core::{CommandResult, CommandStatus};
14use speccoder_runtime::service::approvals::{Answer, Approver, Presentation, Who};
15use speccoder_runtime::service::progress::{Event, Progress};
16use tokio::sync::broadcast;
17
18/// What a waiting job is answered with.
19#[derive(Debug)]
20pub enum Reply {
21    /// A decision on a call.
22    Answer(Answer),
23    /// A question's answer (`None`: skipped).
24    Text(Option<String>),
25}
26
27#[derive(Debug)]
28struct Waiting {
29    id: u64,
30    reply: mpsc::Sender<Reply>,
31    event: Value,
32}
33
34/// The job now, as the page sees it.
35#[derive(Debug, Clone, serde::Serialize)]
36pub struct JobView {
37    /// Its number.
38    pub id: u64,
39    /// The command it stands for.
40    pub command: String,
41    /// Still working.
42    pub running: bool,
43    /// Its result envelope, once done.
44    pub result: Option<Value>,
45}
46
47/// The host's jobs and the page's event stream.
48#[derive(Debug)]
49pub struct Hub {
50    root: crate::Root,
51    /// Signs the person's own commands this host sends, and checks them.
52    pub issuer: Arc<crate::fixes::Issuer>,
53    events: broadcast::Sender<String>,
54    next: AtomicU64,
55    job: Mutex<Option<JobView>>,
56    waiting: Mutex<Option<Waiting>>,
57    cancel: Mutex<Option<Arc<AtomicBool>>>,
58}
59
60impl Hub {
61    /// A hub for the project `root` is (or will be, once bound), signing
62    /// with `issuer`.
63    #[must_use]
64    pub fn new(root: crate::Root, issuer: Arc<crate::fixes::Issuer>) -> Arc<Self> {
65        let (events, _) = broadcast::channel(1024);
66        Arc::new(Self {
67            root,
68            issuer,
69            events,
70            next: AtomicU64::new(1),
71            job: Mutex::new(None),
72            waiting: Mutex::new(None),
73            cancel: Mutex::new(None),
74        })
75    }
76
77    /// Every event from now on, as JSON text.
78    #[must_use]
79    pub fn subscribe(&self) -> broadcast::Receiver<String> {
80        self.events.subscribe()
81    }
82
83    fn send(&self, v: &Value) {
84        let _ = self.events.send(v.to_string());
85    }
86
87    /// The job, if one ran since the host started.
88    #[must_use]
89    pub fn job(&self) -> Option<JobView> {
90        self.job.lock().ok().and_then(|j| j.clone())
91    }
92
93    /// What waits for the person now (an `ask` or `question` event).
94    #[must_use]
95    pub fn waiting(&self) -> Option<Value> {
96        self.waiting
97            .lock()
98            .ok()
99            .and_then(|w| w.as_ref().map(|w| w.event.clone()))
100    }
101
102    /// Answers what waits; `Err` when nothing does, or something else does.
103    pub fn answer(&self, id: u64, reply: Reply) -> Result<(), String> {
104        let w = self
105            .waiting
106            .lock()
107            .map_err(|_| "the host is broken".to_owned())?
108            .take();
109        match w {
110            Some(w) if w.id == id => {
111                let _ = w.reply.send(reply);
112                self.send(&json!({ "type": "answered", "id": id }));
113                Ok(())
114            }
115            Some(w) => {
116                let other = w.id;
117                if let Ok(mut slot) = self.waiting.lock() {
118                    *slot = Some(w);
119                }
120                Err(format!("{other} waits, not {id}"))
121            }
122            None => Err("nothing waits for an answer".to_owned()),
123        }
124    }
125
126    /// Asks the running job to stop before its next task; a call waiting for
127    /// the person is answered with "stop" (it keeps waiting, resumably).
128    pub fn stop(&self) {
129        if let Some(c) = self.cancel.lock().ok().and_then(|c| c.clone()) {
130            c.store(true, Ordering::SeqCst);
131        }
132        let who = Who::local_in(&self.root.get(), Via::Web);
133        if let Some(w) = self.waiting.lock().ok().and_then(|mut w| w.take()) {
134            let _ = w.reply.send(match w.event["type"].as_str() {
135                Some("question") => Reply::Text(None),
136                _ => Reply::Answer(Answer::stop(Some(who))),
137            });
138        }
139        self.send(&json!({ "type": "stopping" }));
140    }
141
142    fn wait_for(&self, mut event: Value) -> Option<Reply> {
143        let id = self.next.fetch_add(1, Ordering::SeqCst);
144        event["id"] = json!(id);
145        let (tx, rx) = mpsc::channel();
146        if let Ok(mut w) = self.waiting.lock() {
147            *w = Some(Waiting {
148                id,
149                reply: tx,
150                event: event.clone(),
151            });
152        }
153        self.send(&event);
154        rx.recv().ok()
155    }
156
157    /// Runs `f` while no job runs and none can start — for a short change
158    /// to the checkout (a commit): the check and the change are one step.
159    ///
160    /// # Errors
161    ///
162    /// A job is working.
163    pub fn exclusive<T>(&self, f: impl FnOnce() -> T) -> Result<T, String> {
164        let job = self
165            .job
166            .lock()
167            .map_err(|_| "the host is broken".to_owned())?;
168        if job.as_ref().is_some_and(|j| j.running) {
169            return Err("a job is working; do this when it ends".to_owned());
170        }
171        let out = f();
172        drop(job);
173        Ok(out)
174    }
175
176    /// Takes the job slot for `command`: its number and cancel flag;
177    /// `Err` when a job is already working.
178    fn begin(&self, command: &str) -> Result<(u64, Arc<AtomicBool>), String> {
179        let id = self.next.fetch_add(1, Ordering::SeqCst);
180        {
181            let mut job = self
182                .job
183                .lock()
184                .map_err(|_| "the host is broken".to_owned())?;
185            if job.as_ref().is_some_and(|j| j.running) {
186                return Err("a job is working; stop it or wait for it".to_owned());
187            }
188            *job = Some(JobView {
189                id,
190                command: command.to_owned(),
191                running: true,
192                result: None,
193            });
194        }
195        let cancel = Arc::new(AtomicBool::new(false));
196        if let Ok(mut c) = self.cancel.lock() {
197            *c = Some(Arc::clone(&cancel));
198        }
199        self.send(&json!({ "type": "started", "id": id, "command": command }));
200        Ok((id, cancel))
201    }
202
203    /// Ends job `id` with `result`, its command steps signed for `root`.
204    fn finish(&self, id: u64, root: &Path, result: &CommandResult) {
205        let value = self.issuer.signing(root.to_path_buf(), || {
206            serde_json::to_value(result).unwrap_or(Value::Null)
207        });
208        self.finish_value(id, value);
209    }
210
211    /// Ends job `id` with the result `value`, as it is.
212    fn finish_value(&self, id: u64, value: Value) {
213        if let Ok(mut job) = self.job.lock()
214            && let Some(j) = job.as_mut()
215        {
216            j.running = false;
217            j.result = Some(value.clone());
218        }
219        if let Ok(mut c) = self.cancel.lock() {
220            *c = None;
221        }
222        self.send(&json!({ "type": "done", "id": id, "result": value }));
223    }
224
225    /// Starts `run` (with `input` for actions that take one) for `feature`,
226    /// in `root` (the bound project's, or a folder of a host not bound yet);
227    /// `Err` when a job is already working.
228    pub fn start(
229        self: &Arc<Self>,
230        run: Runnable,
231        input: Option<String>,
232        feature: Option<String>,
233        command: String,
234        root: PathBuf,
235    ) -> Result<u64, String> {
236        let (id, cancel) = self.begin(&command)?;
237        let hub = Arc::clone(self);
238        std::thread::spawn(move || {
239            let peer: Arc<Peer> = Arc::new(Peer {
240                hub: Arc::clone(&hub),
241                who: Who::local_in(&root, Via::Web),
242            });
243            // What the job builds (an action's step among it) is signed
244            // for the host's project.
245            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
246                hub.issuer.signing(hub.root.get(), || {
247                    execute(
248                        &root,
249                        feature.as_deref(),
250                        &run,
251                        input.as_deref(),
252                        &peer,
253                        cancel,
254                    )
255                })
256            }))
257            .unwrap_or_else(|_| {
258                CommandResult::new("job", CommandStatus::Error, "the job stopped unexpectedly")
259            });
260            let result = speccoder_runtime::service::next::attach(result, feature.as_deref());
261            // A host started outside a project binds to the one this job
262            // made, cloned or opened: once, then the page reloads into it.
263            if let Some(bound) = crate::welcome::bind_after(&hub.root, &run, &result) {
264                hub.send(&json!({ "type": "bound", "root": bound.display().to_string() }));
265            }
266            hub.finish(id, &hub.root.get(), &result);
267        });
268        Ok(id)
269    }
270
271    /// Runs the person's own command — argvs this host issued, checked by
272    /// the caller — in `root`, as a job: its output lines go to the page
273    /// (`output` events), Stop ends it, and it ends in its result.
274    pub fn start_fix(
275        self: &Arc<Self>,
276        argvs: Vec<Vec<String>>,
277        root: PathBuf,
278    ) -> Result<u64, String> {
279        use speccoder_runtime::service::person;
280        let (id, cancel) = self.begin(&person::shown(&argvs))?;
281        let hub = Arc::clone(self);
282        std::thread::spawn(move || {
283            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
284                person::run(&root, &argvs, person::TIMEOUT, &cancel, &|stream, line| {
285                    hub.send(&json!({ "type": "output", "id": id, "stream": stream.as_str(), "line": line }));
286                })
287            }))
288            .unwrap_or_else(|_| {
289                CommandResult::new("fix", CommandStatus::Error, "the command stopped unexpectedly")
290            });
291            hub.finish(id, &root, &result);
292        });
293        Ok(id)
294    }
295
296    /// Runs one of speccoder's own commands that has no action of its own
297    /// (`speccoder run --ignore-checklists`) — `args` issued by this host,
298    /// checked by the caller — as a job of speccoder's: no time limit, Stop
299    /// asks it to stop, its run shows as any run does (the project's live
300    /// state), and its own result is the job's.
301    pub fn start_speccoder(
302        self: &Arc<Self>,
303        args: Vec<String>,
304        root: PathBuf,
305    ) -> Result<u64, String> {
306        use speccoder_runtime::service::person;
307        let mut shown = vec!["speccoder".to_owned()];
308        shown.extend(args.iter().cloned());
309        let (id, cancel) = self.begin(&person::shown(&[shown]))?;
310        let hub = Arc::clone(self);
311        std::thread::spawn(move || {
312            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
313                person::speccoder(&root, &args, &cancel, &|stream, line| {
314                    hub.send(&json!({ "type": "output", "id": id, "stream": stream.as_str(), "line": line }));
315                })
316            }))
317            .unwrap_or_else(|_| Err("it stopped unexpectedly".to_owned()));
318            match result {
319                // Its own result, from its own process: passed on, unsigned.
320                Ok(v) => hub.finish_value(id, v),
321                Err(e) => hub.finish(
322                    id,
323                    &root,
324                    &CommandResult::new(
325                        "job",
326                        CommandStatus::Error,
327                        &format!("the command ended without a result: {e}"),
328                    ),
329                ),
330            }
331        });
332        Ok(id)
333    }
334}
335
336/// The job's side: its progress sink and the person it asks.
337#[derive(Debug)]
338struct Peer {
339    hub: Arc<Hub>,
340    who: Who,
341}
342
343impl Progress for Peer {
344    fn event(&self, event: &Event) {
345        self.hub
346            .send(&json!({ "type": "progress", "event": event }));
347    }
348}
349
350impl Approver for Peer {
351    fn ask(&self, p: &Presentation) -> Answer {
352        match self
353            .hub
354            .wait_for(json!({ "type": "ask", "presentation": p }))
355        {
356            Some(Reply::Answer(mut a)) => {
357                a.by = Some(self.who.clone());
358                a
359            }
360            _ => Answer::stop(Some(self.who.clone())),
361        }
362    }
363
364    fn answer(&self, question: &str) -> Option<String> {
365        match self
366            .hub
367            .wait_for(json!({ "type": "question", "question": question }))
368        {
369            Some(Reply::Text(t)) => t,
370            _ => None,
371        }
372    }
373
374    fn via(&self) -> Via {
375        Via::Web
376    }
377}
378
379fn execute(
380    root: &Path,
381    feature: Option<&str>,
382    run: &Runnable,
383    input: Option<&str>,
384    peer: &Arc<Peer>,
385    cancel: Arc<AtomicBool>,
386) -> CommandResult {
387    use speccoder_runtime::service::actions::{Dispatch, Interface, Offer, offered, perform};
388    if offered(Interface::WebJobs, run.kind()) == Offer::Refuse {
389        return Interface::WebJobs.refusal();
390    }
391    let options = speccoder_runtime::service::RunOptions {
392        approver: Some(Arc::clone(peer) as Arc<dyn Approver>),
393        progress: Some(Arc::clone(peer) as Arc<dyn Progress>),
394        cancel: Some(cancel),
395        ..speccoder_runtime::service::RunOptions::default()
396    };
397    perform(
398        root,
399        feature,
400        Dispatch {
401            run,
402            input,
403            via: Via::Web,
404            options,
405        },
406    )
407}