1use 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#[derive(Debug)]
20pub enum Reply {
21 Answer(Answer),
23 Text(Option<String>),
25}
26
27#[derive(Debug)]
28struct Waiting {
29 id: u64,
30 reply: mpsc::Sender<Reply>,
31 event: Value,
32}
33
34#[derive(Debug, Clone, serde::Serialize)]
36pub struct JobView {
37 pub id: u64,
39 pub command: String,
41 pub running: bool,
43 pub result: Option<Value>,
45}
46
47#[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 #[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 #[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 #[must_use]
85 pub fn job(&self) -> Option<JobView> {
86 self.job.lock().ok().and_then(|j| j.clone())
87 }
88
89 #[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 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 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 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 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#[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 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}