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    /// A progress sink onto the page's event stream, for work that is not
88    /// a job (an Ask turn).
89    #[must_use]
90    pub fn progress(self: &Arc<Self>) -> Arc<dyn Progress> {
91        Arc::new(Stream(Arc::clone(self)))
92    }
93
94    /// The job, if one ran since the host started.
95    #[must_use]
96    pub fn job(&self) -> Option<JobView> {
97        self.job.lock().ok().and_then(|j| j.clone())
98    }
99
100    /// What waits for the person now (an `ask` or `question` event).
101    #[must_use]
102    pub fn waiting(&self) -> Option<Value> {
103        self.waiting
104            .lock()
105            .ok()
106            .and_then(|w| w.as_ref().map(|w| w.event.clone()))
107    }
108
109    /// Answers what waits; `Err` when nothing does, or something else does.
110    pub fn answer(&self, id: u64, reply: Reply) -> Result<(), String> {
111        let w = self
112            .waiting
113            .lock()
114            .map_err(|_| "the host is broken".to_owned())?
115            .take();
116        match w {
117            Some(w) if w.id == id => {
118                let _ = w.reply.send(reply);
119                self.send(&json!({ "type": "answered", "id": id }));
120                Ok(())
121            }
122            Some(w) => {
123                let other = w.id;
124                if let Ok(mut slot) = self.waiting.lock() {
125                    *slot = Some(w);
126                }
127                Err(format!("{other} waits, not {id}"))
128            }
129            None => Err("nothing waits for an answer".to_owned()),
130        }
131    }
132
133    /// Asks the running job to stop before its next task; a call waiting for
134    /// the person is answered with "stop" (it keeps waiting, resumably).
135    pub fn stop(&self) {
136        if let Some(c) = self.cancel.lock().ok().and_then(|c| c.clone()) {
137            c.store(true, Ordering::SeqCst);
138        }
139        let who = Who::local_in(&self.root.get(), Via::Web);
140        if let Some(w) = self.waiting.lock().ok().and_then(|mut w| w.take()) {
141            let _ = w.reply.send(match w.event["type"].as_str() {
142                Some("question") => Reply::Text(None),
143                _ => Reply::Answer(Answer::stop(Some(who))),
144            });
145        }
146        self.send(&json!({ "type": "stopping" }));
147    }
148
149    fn wait_for(&self, mut event: Value) -> Option<Reply> {
150        let id = self.next.fetch_add(1, Ordering::SeqCst);
151        event["id"] = json!(id);
152        let (tx, rx) = mpsc::channel();
153        if let Ok(mut w) = self.waiting.lock() {
154            *w = Some(Waiting {
155                id,
156                reply: tx,
157                event: event.clone(),
158            });
159        }
160        self.send(&event);
161        rx.recv().ok()
162    }
163
164    /// Runs `f` while no job runs and none can start — for a short change
165    /// to the checkout (a commit): the check and the change are one step.
166    ///
167    /// # Errors
168    ///
169    /// A job is working.
170    pub fn exclusive<T>(&self, f: impl FnOnce() -> T) -> Result<T, String> {
171        let job = self
172            .job
173            .lock()
174            .map_err(|_| "the host is broken".to_owned())?;
175        if job.as_ref().is_some_and(|j| j.running) {
176            return Err("a job is working; do this when it ends".to_owned());
177        }
178        let out = f();
179        drop(job);
180        Ok(out)
181    }
182
183    /// Takes the job slot for `command`: its number and cancel flag;
184    /// `Err` when a job is already working.
185    fn begin(&self, command: &str) -> Result<(u64, Arc<AtomicBool>), String> {
186        let id = self.next.fetch_add(1, Ordering::SeqCst);
187        {
188            let mut job = self
189                .job
190                .lock()
191                .map_err(|_| "the host is broken".to_owned())?;
192            if job.as_ref().is_some_and(|j| j.running) {
193                return Err("a job is working; stop it or wait for it".to_owned());
194            }
195            *job = Some(JobView {
196                id,
197                command: command.to_owned(),
198                running: true,
199                result: None,
200            });
201        }
202        let cancel = Arc::new(AtomicBool::new(false));
203        if let Ok(mut c) = self.cancel.lock() {
204            *c = Some(Arc::clone(&cancel));
205        }
206        self.send(&json!({ "type": "started", "id": id, "command": command }));
207        Ok((id, cancel))
208    }
209
210    /// Ends job `id` with `result`, its command steps signed for `root`.
211    fn finish(&self, id: u64, root: &Path, result: &CommandResult) {
212        let value = self.issuer.signing(root.to_path_buf(), || {
213            serde_json::to_value(result).unwrap_or(Value::Null)
214        });
215        self.finish_value(id, value);
216    }
217
218    /// Ends job `id` with the result `value`, as it is.
219    fn finish_value(&self, id: u64, value: Value) {
220        if let Ok(mut job) = self.job.lock()
221            && let Some(j) = job.as_mut()
222        {
223            j.running = false;
224            j.result = Some(value.clone());
225        }
226        if let Ok(mut c) = self.cancel.lock() {
227            *c = None;
228        }
229        self.send(&json!({ "type": "done", "id": id, "result": value }));
230    }
231
232    /// Starts `run` (with `input` for actions that take one) for `feature`,
233    /// in `root` (the bound project's, or a folder of a host not bound yet);
234    /// `Err` when a job is already working.
235    pub fn start(
236        self: &Arc<Self>,
237        run: Runnable,
238        input: Option<String>,
239        feature: Option<String>,
240        command: String,
241        root: PathBuf,
242    ) -> Result<u64, String> {
243        let (id, cancel) = self.begin(&command)?;
244        let hub = Arc::clone(self);
245        std::thread::spawn(move || {
246            let peer: Arc<Peer> = Arc::new(Peer {
247                hub: Arc::clone(&hub),
248                who: Who::local_in(&root, Via::Web),
249            });
250            // What the job builds (an action's step among it) is signed
251            // for the host's project.
252            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
253                hub.issuer.signing(hub.root.get(), || {
254                    execute(
255                        &root,
256                        feature.as_deref(),
257                        &run,
258                        input.as_deref(),
259                        &peer,
260                        cancel,
261                    )
262                })
263            }))
264            .unwrap_or_else(|_| {
265                CommandResult::new("job", CommandStatus::Error, "the job stopped unexpectedly")
266            });
267            let result = speccoder_runtime::service::next::attach(result, feature.as_deref());
268            // A host started outside a project binds to the one this job
269            // made, cloned or opened: once, then the page reloads into it.
270            if let Some(bound) = crate::welcome::bind_after(&hub.root, &run, &result) {
271                hub.send(&json!({ "type": "bound", "root": bound.display().to_string() }));
272            }
273            hub.finish(id, &hub.root.get(), &result);
274        });
275        Ok(id)
276    }
277
278    /// Runs the person's own command — argvs this host issued, checked by
279    /// the caller — in `root`, as a job: its output lines go to the page
280    /// (`output` events), Stop ends it, and it ends in its result.
281    pub fn start_fix(
282        self: &Arc<Self>,
283        argvs: Vec<Vec<String>>,
284        root: PathBuf,
285    ) -> Result<u64, String> {
286        use speccoder_runtime::service::person;
287        let (id, cancel) = self.begin(&person::shown(&argvs))?;
288        let hub = Arc::clone(self);
289        std::thread::spawn(move || {
290            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
291                person::run(&root, &argvs, person::TIMEOUT, &cancel, &|stream, line| {
292                    hub.send(&json!({ "type": "output", "id": id, "stream": stream.as_str(), "line": line }));
293                })
294            }))
295            .unwrap_or_else(|_| {
296                CommandResult::new("fix", CommandStatus::Error, "the command stopped unexpectedly")
297            });
298            hub.finish(id, &root, &result);
299        });
300        Ok(id)
301    }
302
303    /// Runs one of speccoder's own commands that has no action of its own
304    /// (`speccoder run --ignore-checklists`) — `args` issued by this host,
305    /// checked by the caller — as a job of speccoder's: no time limit, Stop
306    /// asks it to stop, its run shows as any run does (the project's live
307    /// state), and its own result is the job's.
308    pub fn start_speccoder(
309        self: &Arc<Self>,
310        args: Vec<String>,
311        root: PathBuf,
312    ) -> Result<u64, String> {
313        use speccoder_runtime::service::person;
314        let mut shown = vec!["speccoder".to_owned()];
315        shown.extend(args.iter().cloned());
316        let (id, cancel) = self.begin(&person::shown(&[shown]))?;
317        let hub = Arc::clone(self);
318        std::thread::spawn(move || {
319            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
320                person::speccoder(&root, &args, &cancel, &|stream, line| {
321                    hub.send(&json!({ "type": "output", "id": id, "stream": stream.as_str(), "line": line }));
322                })
323            }))
324            .unwrap_or_else(|_| Err("it stopped unexpectedly".to_owned()));
325            match result {
326                // Its own result, from its own process: passed on, unsigned.
327                Ok(v) => hub.finish_value(id, v),
328                Err(e) => hub.finish(
329                    id,
330                    &root,
331                    &CommandResult::new(
332                        "job",
333                        CommandStatus::Error,
334                        &format!("the command ended without a result: {e}"),
335                    ),
336                ),
337            }
338        });
339        Ok(id)
340    }
341}
342
343/// Progress onto the page's stream.
344#[derive(Debug)]
345struct Stream(Arc<Hub>);
346
347impl Progress for Stream {
348    fn event(&self, event: &Event) {
349        self.0.send(&json!({ "type": "progress", "event": event }));
350    }
351}
352
353/// The job's side: its progress sink and the person it asks.
354#[derive(Debug)]
355struct Peer {
356    hub: Arc<Hub>,
357    who: Who,
358}
359
360impl Progress for Peer {
361    fn event(&self, event: &Event) {
362        self.hub
363            .send(&json!({ "type": "progress", "event": event }));
364    }
365}
366
367impl Approver for Peer {
368    fn ask(&self, p: &Presentation) -> Answer {
369        match self
370            .hub
371            .wait_for(json!({ "type": "ask", "presentation": p }))
372        {
373            Some(Reply::Answer(mut a)) => {
374                a.by = Some(self.who.clone());
375                a
376            }
377            _ => Answer::stop(Some(self.who.clone())),
378        }
379    }
380
381    fn answer(&self, question: &str) -> Option<String> {
382        match self
383            .hub
384            .wait_for(json!({ "type": "question", "question": question }))
385        {
386            Some(Reply::Text(t)) => t,
387            _ => None,
388        }
389    }
390
391    fn via(&self) -> Via {
392        Via::Web
393    }
394}
395
396fn execute(
397    root: &Path,
398    feature: Option<&str>,
399    run: &Runnable,
400    input: Option<&str>,
401    peer: &Arc<Peer>,
402    cancel: Arc<AtomicBool>,
403) -> CommandResult {
404    use speccoder_runtime::service::actions::{Dispatch, Interface, Offer, offered, perform};
405    if offered(Interface::WebJobs, run.kind()) == Offer::Refuse {
406        return Interface::WebJobs.refusal();
407    }
408    let options = speccoder_runtime::service::RunOptions {
409        approver: Some(Arc::clone(peer) as Arc<dyn Approver>),
410        progress: Some(Arc::clone(peer) as Arc<dyn Progress>),
411        cancel: Some(cancel),
412        ..speccoder_runtime::service::RunOptions::default()
413    };
414    perform(
415        root,
416        feature,
417        Dispatch {
418            run,
419            input,
420            via: Via::Web,
421            options,
422        },
423    )
424}