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;
13use speccoder_core::{CommandResult, CommandStatus};
14use speccoder_runtime::approvals::{Answer, Approver, Presentation, Who};
15use speccoder_runtime::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: PathBuf,
51    events: broadcast::Sender<String>,
52    next: AtomicU64,
53    job: Mutex<Option<JobView>>,
54    waiting: Mutex<Option<Waiting>>,
55    cancel: Mutex<Option<Arc<AtomicBool>>>,
56}
57
58impl Hub {
59    /// A hub for the project at `root`.
60    #[must_use]
61    pub fn new(root: &Path) -> Arc<Self> {
62        let (events, _) = broadcast::channel(1024);
63        Arc::new(Self {
64            root: root.to_path_buf(),
65            events,
66            next: AtomicU64::new(1),
67            job: Mutex::new(None),
68            waiting: Mutex::new(None),
69            cancel: Mutex::new(None),
70        })
71    }
72
73    /// Every event from now on, as JSON text.
74    #[must_use]
75    pub fn subscribe(&self) -> broadcast::Receiver<String> {
76        self.events.subscribe()
77    }
78
79    fn send(&self, v: &Value) {
80        let _ = self.events.send(v.to_string());
81    }
82
83    /// The job, if one ran since the host started.
84    #[must_use]
85    pub fn job(&self) -> Option<JobView> {
86        self.job.lock().ok().and_then(|j| j.clone())
87    }
88
89    /// What waits for the person now (an `ask` or `question` event).
90    #[must_use]
91    pub fn waiting(&self) -> Option<Value> {
92        self.waiting
93            .lock()
94            .ok()
95            .and_then(|w| w.as_ref().map(|w| w.event.clone()))
96    }
97
98    /// Answers what waits; `Err` when nothing does, or something else does.
99    pub fn answer(&self, id: u64, reply: Reply) -> Result<(), String> {
100        let w = self
101            .waiting
102            .lock()
103            .map_err(|_| "the host is broken".to_owned())?
104            .take();
105        match w {
106            Some(w) if w.id == id => {
107                let _ = w.reply.send(reply);
108                self.send(&json!({ "type": "answered", "id": id }));
109                Ok(())
110            }
111            Some(w) => {
112                let other = w.id;
113                if let Ok(mut slot) = self.waiting.lock() {
114                    *slot = Some(w);
115                }
116                Err(format!("{other} waits, not {id}"))
117            }
118            None => Err("nothing waits for an answer".to_owned()),
119        }
120    }
121
122    /// Asks the running job to stop before its next task; a call waiting for
123    /// the person is answered with "stop" (it keeps waiting, resumably).
124    pub fn stop(&self) {
125        if let Some(c) = self.cancel.lock().ok().and_then(|c| c.clone()) {
126            c.store(true, Ordering::SeqCst);
127        }
128        let who = Who::local_in(&self.root, "web");
129        if let Some(w) = self.waiting.lock().ok().and_then(|mut w| w.take()) {
130            let _ = w.reply.send(match w.event["type"].as_str() {
131                Some("question") => Reply::Text(None),
132                _ => Reply::Answer(Answer::stop(Some(who))),
133            });
134        }
135        self.send(&json!({ "type": "stopping" }));
136    }
137
138    fn wait_for(&self, mut event: Value) -> Option<Reply> {
139        let id = self.next.fetch_add(1, Ordering::SeqCst);
140        event["id"] = json!(id);
141        let (tx, rx) = mpsc::channel();
142        if let Ok(mut w) = self.waiting.lock() {
143            *w = Some(Waiting {
144                id,
145                reply: tx,
146                event: event.clone(),
147            });
148        }
149        self.send(&event);
150        rx.recv().ok()
151    }
152
153    /// Runs `f` while no job runs and none can start — for a short change
154    /// to the checkout (a commit): the check and the change are one step.
155    ///
156    /// # Errors
157    ///
158    /// A job is working.
159    pub fn exclusive<T>(&self, f: impl FnOnce() -> T) -> Result<T, String> {
160        let job = self
161            .job
162            .lock()
163            .map_err(|_| "the host is broken".to_owned())?;
164        if job.as_ref().is_some_and(|j| j.running) {
165            return Err("a job is working; do this when it ends".to_owned());
166        }
167        let out = f();
168        drop(job);
169        Ok(out)
170    }
171
172    /// Starts `run` (with `input` for actions that take one) for `feature`;
173    /// `Err` when a job is already working.
174    pub fn start(
175        self: &Arc<Self>,
176        run: Runnable,
177        input: Option<String>,
178        feature: Option<String>,
179        command: String,
180    ) -> Result<u64, String> {
181        let id = self.next.fetch_add(1, Ordering::SeqCst);
182        {
183            let mut job = self
184                .job
185                .lock()
186                .map_err(|_| "the host is broken".to_owned())?;
187            if job.as_ref().is_some_and(|j| j.running) {
188                return Err("a job is working; stop it or wait for it".to_owned());
189            }
190            *job = Some(JobView {
191                id,
192                command: command.clone(),
193                running: true,
194                result: None,
195            });
196        }
197        let cancel = Arc::new(AtomicBool::new(false));
198        if let Ok(mut c) = self.cancel.lock() {
199            *c = Some(Arc::clone(&cancel));
200        }
201        self.send(&json!({ "type": "started", "id": id, "command": command }));
202        let hub = Arc::clone(self);
203        std::thread::spawn(move || {
204            let peer: Arc<Peer> = Arc::new(Peer {
205                hub: Arc::clone(&hub),
206                who: Who::local_in(&hub.root, "web"),
207            });
208            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
209                execute(
210                    &hub.root,
211                    feature.as_deref(),
212                    &run,
213                    input.as_deref(),
214                    &peer,
215                    cancel,
216                )
217            }))
218            .unwrap_or_else(|_| {
219                CommandResult::new("job", CommandStatus::Error, "the job stopped unexpectedly")
220            });
221            let result = speccoder_runtime::next::attach(result, feature.as_deref());
222            let value = serde_json::to_value(&result).unwrap_or(Value::Null);
223            if let Ok(mut job) = hub.job.lock()
224                && let Some(j) = job.as_mut()
225            {
226                j.running = false;
227                j.result = Some(value.clone());
228            }
229            if let Ok(mut c) = hub.cancel.lock() {
230                *c = None;
231            }
232            hub.send(&json!({ "type": "done", "id": id, "result": value }));
233        });
234        Ok(id)
235    }
236}
237
238/// The job's side: its progress sink and the person it asks.
239#[derive(Debug)]
240struct Peer {
241    hub: Arc<Hub>,
242    who: Who,
243}
244
245impl Progress for Peer {
246    fn event(&self, event: &Event) {
247        self.hub
248            .send(&json!({ "type": "progress", "event": event }));
249    }
250}
251
252impl Approver for Peer {
253    fn ask(&self, p: &Presentation) -> Answer {
254        match self
255            .hub
256            .wait_for(json!({ "type": "ask", "presentation": p }))
257        {
258            Some(Reply::Answer(mut a)) => {
259                a.by = Some(self.who.clone());
260                a
261            }
262            _ => Answer::stop(Some(self.who.clone())),
263        }
264    }
265
266    fn answer(&self, question: &str) -> Option<String> {
267        match self
268            .hub
269            .wait_for(json!({ "type": "question", "question": question }))
270        {
271            Some(Reply::Text(t)) => t,
272            _ => None,
273        }
274    }
275
276    fn via(&self) -> String {
277        "web".to_owned()
278    }
279}
280
281fn execute(
282    root: &Path,
283    feature: Option<&str>,
284    run: &Runnable,
285    input: Option<&str>,
286    peer: &Arc<Peer>,
287    cancel: Arc<AtomicBool>,
288) -> CommandResult {
289    use speccoder_runtime::approvals::{Choice, Verb, decide};
290    let needs = |what: &str| {
291        CommandResult::new(
292            "job",
293            CommandStatus::UsageError,
294            &format!("{what} is needed"),
295        )
296    };
297    let state = speccoder_runtime::worktree::state_dir();
298    let options = speccoder_runtime::RunOptions {
299        approver: Some(Arc::clone(peer) as Arc<dyn Approver>),
300        progress: Some(Arc::clone(peer) as Arc<dyn Progress>),
301        cancel: Some(cancel),
302        ..speccoder_runtime::RunOptions::default()
303    };
304    let text = input.map(str::trim).filter(|t| !t.is_empty());
305    match run {
306        Runnable::Skill { name, arguments } => {
307            let args = text.map_or_else(|| arguments.clone(), str::to_owned);
308            speccoder_runtime::hosting::run(root, feature, name, &args, &options)
309        }
310        Runnable::Run { task: Some(t) } => speccoder_runtime::run(root, feature, t, &options),
311        Runnable::Run { task: None } => {
312            speccoder_runtime::run_feature(root, feature, &options, false)
313        }
314        Runnable::Converge => speccoder_runtime::run_feature(root, feature, &options, true),
315        Runnable::Commit { paths } => {
316            speccoder_runtime::hosting::commit_planning(root, feature, paths)
317        }
318        Runnable::Approve { task } => {
319            decide(root, feature, Verb::Decide(Choice::approve(task, text)))
320        }
321        Runnable::Reject { task } => match text {
322            Some(why) => decide(root, feature, Verb::Decide(Choice::reject(task, why))),
323            None => needs("a reason"),
324        },
325        Runnable::Accept { task } => match (text, &state) {
326            (Some(why), Some(s)) => {
327                speccoder_runtime::controls::accept_in(root, feature, task, why, s, false)
328            }
329            (None, _) => needs("a reason"),
330            (_, None) => {
331                CommandResult::new("job", CommandStatus::ConfigError, "no state directory")
332            }
333        },
334        Runnable::Skip { task } => match text {
335            Some(why) => speccoder_runtime::controls::skip(root, feature, task, Some(why)),
336            None => needs("a reason"),
337        },
338        Runnable::Nudge { task } => match (text, &state) {
339            (Some(note), Some(s)) => {
340                speccoder_runtime::controls::nudge_with(root, feature, task, Some(note), s)
341            }
342            (None, _) => needs("a note"),
343            (_, None) => {
344                CommandResult::new("job", CommandStatus::ConfigError, "no state directory")
345            }
346        },
347        Runnable::QuickChange {
348            request,
349            checks,
350            run,
351        } => speccoder_runtime::quick::change(root, request, checks, *run, "web", &options),
352        Runnable::IssueComment { issue, assign } => speccoder_runtime::github::comment(
353            &speccoder_runtime::github::Ctx::from_env(),
354            root,
355            feature,
356            &issue.to_string(),
357            *assign,
358        ),
359        Runnable::Review { ids, verdict } => match (verdict.as_str(), ids.first(), text) {
360            ("answer", Some(q), Some(t)) => speccoder_runtime::review::answer(root, feature, q, t),
361            ("edit" | "edit-approve", Some(id), Some(t)) => speccoder_runtime::review::edit(
362                root,
363                feature,
364                id,
365                t,
366                verdict == "edit-approve",
367                "web",
368            ),
369            ("answer" | "edit" | "edit-approve", _, _) => needs("the words"),
370            _ => speccoder_runtime::review::decide(root, feature, ids, verdict, text, "web"),
371        },
372        Runnable::Rule { task } => match (text, &state) {
373            (Some(why), Some(s)) => {
374                speccoder_runtime::controls::rule(root, feature, task, why, None, s)
375            }
376            (None, _) => needs("a reason"),
377            (_, None) => {
378                CommandResult::new("job", CommandStatus::ConfigError, "no state directory")
379            }
380        },
381        Runnable::Feature => match text {
382            Some(name) => speccoder_runtime::current::switch(root, name),
383            None => needs("a feature"),
384        },
385        // The page has confirmed it (the action's `confirm`): push and open.
386        Runnable::PullRequest => speccoder_runtime::handoff::pr(
387            root,
388            feature,
389            &speccoder_runtime::handoff::PrOptions {
390                remote: "origin",
391                base: None,
392                open: true,
393                force: false,
394                github: &speccoder_runtime::github::Ctx::from_env(),
395            },
396        ),
397    }
398}