1use std::path::Path;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::Mutex;
6use std::time::Duration;
7
8use serde_json::Value;
9use vissue_control::client::Client;
10use vissue_control::rpc::{
11 ClaimParams, Error as RpcError, IdParams, InitializeResult, IssueListParams, IssueListResult,
12 MutResult as WireMut, NoteParams, Notification, RelatedParams, Request, SearchParams,
13 TreeParams, UpdateParams, CONFLICT, CYCLE, INVALID_STATE, NOT_FOUND,
14};
15use vissue_control::{InitializeParams, PROTOCOL_VERSION};
16use vissue_core::config::Layout;
17use vissue_core::error::Error;
18use vissue_core::views::{
19 AgendaRow, ClaimRow, Excerpt, IssueDetail, ListQuery, RelatedHit, SearchHit, TreeNode,
20};
21
22use crate::backend::{BackendKind, BoardBackend, ListPage, MutResult, SinceGate, UpdateReq};
23
24pub struct ControlBackend {
26 layout: Layout,
27 identity: String,
28 client: Mutex<Client>,
29 generation: AtomicU64,
30 revision: AtomicU64,
31 page_revision: AtomicU64,
33 since: SinceGate,
34 last_query: Mutex<Option<ListQuery>>,
35 last_since: Mutex<Option<Option<u64>>>,
36}
37
38impl ControlBackend {
39 pub fn connect(path: &Path, layout: &Layout, agent: &str) -> Result<Self, ControlAttachError> {
42 Self::connect_as(path, layout, agent, "vissue-tui")
43 }
44
45 pub fn connect_as(
47 path: &Path,
48 layout: &Layout,
49 agent: &str,
50 client: &str,
51 ) -> Result<Self, ControlAttachError> {
52 let mut client_conn = Client::connect(path).map_err(ControlAttachError::Rpc)?;
53 let params = InitializeParams {
54 protocol_version: PROTOCOL_VERSION,
55 client: client.into(),
56 agent: agent.to_string(),
57 };
58 let value = client_conn
59 .request_typed(&Request::Initialize(params))
60 .map_err(ControlAttachError::Rpc)?;
61 let init: InitializeResult = serde_json::from_value(value)
62 .map_err(|e| ControlAttachError::Rpc(RpcError::Json(e)))?;
63 if !roots_match(layout, &init.root, &init.prefix) {
64 return Err(ControlAttachError::Mismatch {
65 want_root: layout.root().display().to_string(),
66 want_prefix: layout.prefix().to_string(),
67 got_root: init.root,
68 got_prefix: init.prefix,
69 });
70 }
71 Ok(Self {
72 layout: layout.clone(),
73 identity: init.identity,
74 client: Mutex::new(client_conn),
75 generation: AtomicU64::new(init.generation),
76 revision: AtomicU64::new(init.revision),
77 page_revision: AtomicU64::new(0),
78 since: SinceGate::after_attach(),
79 last_query: Mutex::new(None),
80 last_since: Mutex::new(None),
81 })
82 }
83
84 fn call(&self, req: &Request) -> Result<Value, Error> {
85 let mut client = self.client.lock().expect("control client");
86 client.request_typed(req).map_err(map_rpc)
87 }
88
89 fn list_params(&self, q: ListQuery) -> IssueListParams {
90 let mut last_query = self.last_query.lock().expect("query");
93 let same = last_query.as_ref() == Some(&q);
94 *last_query = Some(q.clone());
95 drop(last_query);
96 let page = self.page_revision.load(Ordering::SeqCst);
97 let since = if same {
98 self.since.next(page)
99 } else {
100 self.since.invalidate();
101 let _ = self.since.next(page);
102 None
103 };
104 *self.last_since.lock().expect("since") = Some(since);
105 IssueListParams {
106 project: q.project,
107 state: q.state,
108 ready: if q.ready { Some(true) } else { None },
109 query: q.query,
110 limit: q.limit,
111 offset: q.offset,
112 since_revision: since,
113 }
114 }
115
116 fn apply_list(&self, result: IssueListResult) -> ListPage {
117 if !result.unchanged {
118 self.page_revision.store(result.revision, Ordering::SeqCst);
119 self.revision.store(result.revision, Ordering::SeqCst);
120 self.generation.store(result.generation, Ordering::SeqCst);
121 }
122 ListPage {
123 issues: result.issues,
124 total: result.total,
125 matched: result.matched,
126 revision: result.revision,
127 generation: result.generation,
128 unchanged: result.unchanged,
129 }
130 }
131
132 fn apply_mut(&self, wire: WireMut) -> MutResult {
133 self.revision.store(wire.revision, Ordering::SeqCst);
134 self.generation.store(wire.generation, Ordering::SeqCst);
135 MutResult {
136 ok: wire.ok,
137 report: wire.report,
138 issue: wire.issue,
139 revision: wire.revision,
140 generation: wire.generation,
141 }
142 }
143}
144
145fn roots_match(layout: &Layout, root: &str, prefix: &str) -> bool {
146 let want_root = layout.root().display().to_string();
147 (root == want_root || Path::new(root) == layout.root()) && prefix == layout.prefix()
148}
149
150#[derive(Debug)]
152pub enum ControlAttachError {
153 Rpc(RpcError),
154 Mismatch {
155 want_root: String,
156 want_prefix: String,
157 got_root: String,
158 got_prefix: String,
159 },
160}
161
162impl std::fmt::Display for ControlAttachError {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 match self {
165 Self::Rpc(err) => write!(f, "{err}"),
166 Self::Mismatch {
167 want_root,
168 want_prefix,
169 got_root,
170 got_prefix,
171 } => write!(
172 f,
173 "serve root/prefix mismatch: want {want_root} {want_prefix}, got {got_root} {got_prefix}"
174 ),
175 }
176 }
177}
178
179impl std::error::Error for ControlAttachError {}
180
181fn map_rpc(err: RpcError) -> Error {
182 match err {
183 RpcError::Rpc(rpc) => match rpc.code {
184 NOT_FOUND => Error::IssueNotFound {
185 id: rpc
186 .data
187 .as_ref()
188 .and_then(|d| d.get("id"))
189 .and_then(Value::as_str)
190 .unwrap_or("")
191 .to_string(),
192 },
193 CONFLICT => Error::ClaimConflict {
194 id: rpc
195 .data
196 .as_ref()
197 .and_then(|d| d.get("id"))
198 .and_then(Value::as_str)
199 .unwrap_or("")
200 .to_string(),
201 holder: rpc
202 .data
203 .as_ref()
204 .and_then(|d| d.get("holder"))
205 .and_then(Value::as_str)
206 .unwrap_or("")
207 .to_string(),
208 claimed_at: None,
209 },
210 CYCLE => Error::BlockerCycle {
211 blocker: rpc
212 .data
213 .as_ref()
214 .and_then(|d| d.get("block"))
215 .and_then(Value::as_str)
216 .unwrap_or("")
217 .to_string(),
218 issue: rpc
219 .data
220 .as_ref()
221 .and_then(|d| d.get("id"))
222 .and_then(Value::as_str)
223 .unwrap_or("")
224 .to_string(),
225 },
226 INVALID_STATE => Error::InvalidState {
227 id: rpc
228 .data
229 .as_ref()
230 .and_then(|d| d.get("id"))
231 .and_then(Value::as_str)
232 .unwrap_or("")
233 .to_string(),
234 state: rpc
235 .data
236 .as_ref()
237 .and_then(|d| d.get("state"))
238 .and_then(Value::as_str)
239 .unwrap_or("")
240 .to_string(),
241 },
242 _ => Error::Other(anyhow::anyhow!("{}", rpc.message)),
243 },
244 other => Error::Other(anyhow::anyhow!("{other}")),
245 }
246}
247
248fn decode<T: serde::de::DeserializeOwned>(value: Value) -> Result<T, Error> {
249 serde_json::from_value(value).map_err(|e| Error::Other(e.into()))
250}
251
252impl BoardBackend for ControlBackend {
253 fn layout(&self) -> &Layout {
254 &self.layout
255 }
256
257 fn generation(&self) -> u64 {
258 self.generation.load(Ordering::SeqCst)
259 }
260
261 fn revision(&self) -> u64 {
262 self.revision.load(Ordering::SeqCst)
263 }
264
265 fn live(&self) -> BackendKind {
266 BackendKind::Control
267 }
268
269 fn identity(&self) -> &str {
270 &self.identity
271 }
272
273 fn list(&self, q: ListQuery) -> Result<ListPage, Error> {
274 let params = self.list_params(q);
275 let value = self.call(&Request::IssueList(params))?;
276 Ok(self.apply_list(decode(value)?))
277 }
278
279 fn ready(&self, project: Option<&str>) -> Result<ListPage, Error> {
280 let params = self.list_params(ListQuery {
281 project: project.map(str::to_string),
282 ready: true,
283 ..ListQuery::default()
284 });
285 let value = self.call(&Request::IssueReady(params))?;
286 Ok(self.apply_list(decode(value)?))
287 }
288
289 fn get(&self, id: &str) -> Result<IssueDetail, Error> {
290 let value = self.call(&Request::IssueGet(IdParams { id: id.to_string() }))?;
291 let row: vissue_control::rpc::IssueGetResult = decode(value)?;
292 Ok(row.issue)
293 }
294
295 fn excerpt(&self, id: &str) -> Result<Excerpt, Error> {
296 let value = self.call(&Request::IssueExcerpt(IdParams { id: id.to_string() }))?;
297 decode(value)
298 }
299
300 fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>, Error> {
301 let value = self.call(&Request::IssueSearch(SearchParams {
302 query: query.to_string(),
303 limit: Some(limit),
304 }))?;
305 decode(value)
306 }
307
308 fn claims(&self, holder: Option<&str>, project: Option<&str>) -> Result<Vec<ClaimRow>, Error> {
309 let value = self.call(&Request::IssueClaims(vissue_control::rpc::ClaimsParams {
310 holder: holder.map(str::to_string),
311 project: project.map(str::to_string),
312 }))?;
313 decode(value)
314 }
315
316 fn agenda(&self, days: i64, project: Option<&str>) -> Result<Vec<AgendaRow>, Error> {
317 let value = self.call(&Request::IssueAgenda(vissue_control::rpc::AgendaParams {
318 days: Some(days),
319 project: project.map(str::to_string),
320 }))?;
321 decode(value)
322 }
323
324 fn tree(&self, id: &str) -> Result<TreeNode, Error> {
325 let value = self.call(&Request::IssueTree(TreeParams {
326 id: id.to_string(),
327 format: Some("nodes".into()),
328 }))?;
329 match decode::<vissue_control::rpc::TreeResult>(value)? {
330 vissue_control::rpc::TreeResult::Nodes(node) => Ok(node),
331 vissue_control::rpc::TreeResult::Text { text } => Err(Error::Other(anyhow::anyhow!(
332 "serve returned tree text, not nodes: {text}"
333 ))),
334 }
335 }
336
337 fn related(&self, id: &str, depth: usize, limit: usize) -> Result<Vec<RelatedHit>, Error> {
338 let value = self.call(&Request::IssueRelated(RelatedParams {
339 id: id.to_string(),
340 depth: Some(depth),
341 limit: Some(limit),
342 }))?;
343 decode(value)
344 }
345
346 fn projects(&self) -> Result<Vec<String>, Error> {
347 let value = self.call(&Request::ProjectList)?;
348 let row: vissue_control::rpc::ProjectListResult = decode(value)?;
349 Ok(row.projects)
350 }
351
352 fn claim(&self, id: &str, force: bool) -> Result<MutResult, Error> {
353 let value = self.call(&Request::IssueClaim(ClaimParams {
354 id: id.to_string(),
355 force,
356 agent: None,
357 }))?;
358 Ok(self.apply_mut(decode(value)?))
359 }
360
361 fn note(&self, id: &str, text: &str) -> Result<MutResult, Error> {
362 let value = self.call(&Request::IssueNote(NoteParams {
363 id: id.to_string(),
364 text: text.to_string(),
365 }))?;
366 Ok(self.apply_mut(decode(value)?))
367 }
368
369 fn update(&self, req: UpdateReq) -> Result<MutResult, Error> {
370 let value = self.call(&Request::IssueUpdate(UpdateParams {
371 id: req.id,
372 state: req.state,
373 priority: req.priority.map(|c| c.to_string()),
374 block: req.block,
375 unblock: req.unblock,
376 agent: None,
377 }))?;
378 Ok(self.apply_mut(decode(value)?))
379 }
380
381 fn open(&self, id: &str) -> Result<IssueDetail, Error> {
382 let value = self.call(&Request::IssueOpen(IdParams { id: id.to_string() }))?;
383 let row: vissue_control::rpc::IssueGetResult = decode(value)?;
384 Ok(row.issue)
385 }
386
387 fn wait(&self, last: u64, timeout_ms: u64) -> Result<u64, Error> {
388 let mut client = self.client.lock().expect("control client");
389 match client.wait_notification(Duration::from_millis(timeout_ms.max(1))) {
390 Ok(Notification::VaultChanged(changed)) => {
391 self.revision.store(changed.revision, Ordering::SeqCst);
392 self.generation.store(changed.generation, Ordering::SeqCst);
393 Ok(changed.revision)
394 }
395 Ok(_) => Ok(self.revision.load(Ordering::SeqCst)),
396 Err(_) => Ok(last),
397 }
398 }
399
400 fn last_since_revision(&self) -> Option<Option<u64>> {
401 *self.last_since.lock().expect("since")
402 }
403
404 fn invalidate_since(&self) {
405 self.since.invalidate();
406 *self.last_query.lock().expect("query") = None;
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use crate::backend::{BoardBackend, UpdateReq};
414 use serde_json::json;
415 use std::io::{BufReader, Write};
416 use std::os::unix::net::UnixListener;
417 use std::sync::{Arc, Mutex};
418 use std::thread;
419 use vissue_control::frame::{read_message, write_message};
420 use vissue_control::rpc::JsonRpcRequest;
421 use vissue_core::views::ListQuery;
422
423 #[test]
424 fn after_initialize_the_next_list_omits_since_revision() {
425 let dir = tempfile::tempdir().unwrap();
426 let sock = dir.path().join("control.sock");
427 let layout = Layout::new(dir.path().join("vault"), "Software");
428 let seen = Arc::new(Mutex::new(Vec::new()));
429 let seen_cb = Arc::clone(&seen);
430 let root = layout.root().display().to_string();
431 let listener = UnixListener::bind(&sock).unwrap();
432 thread::spawn(move || {
433 let (stream, _) = listener.accept().unwrap();
434 let mut reader = BufReader::new(stream.try_clone().unwrap());
435 let mut writer = stream;
436 while let Ok((payload, framing)) = read_message(&mut reader) {
437 let req: JsonRpcRequest = serde_json::from_slice(&payload).unwrap();
438 let body = if req.method == "initialize" {
439 json!({
440 "jsonrpc": "2.0",
441 "id": req.id,
442 "result": {
443 "protocolVersion": 1,
444 "capabilities": [],
445 "root": root,
446 "prefix": "Software",
447 "generation": 9,
448 "revision": 41,
449 "identity": "tui"
450 }
451 })
452 } else {
453 let since = req
454 .params
455 .as_ref()
456 .and_then(|p| p.get("since_revision"))
457 .cloned();
458 seen_cb.lock().unwrap().push(since);
459 json!({
460 "jsonrpc": "2.0",
461 "id": req.id,
462 "result": {
463 "issues": [],
464 "total": 0,
465 "matched": 0,
466 "revision": 41,
467 "generation": 9,
468 "unchanged": false
469 }
470 })
471 };
472 write_message(&mut writer, &serde_json::to_vec(&body).unwrap(), framing).unwrap();
473 writer.flush().unwrap();
474 }
475 });
476
477 let backend = ControlBackend::connect(&sock, &layout, "tui").unwrap();
478 assert_eq!(backend.revision(), 41);
479 assert_eq!(backend.live(), BackendKind::Control);
480 backend.ready(None).unwrap();
481 assert_eq!(backend.last_since_revision(), Some(None));
482 backend.ready(None).unwrap();
483 assert_eq!(backend.last_since_revision(), Some(Some(41)));
484 backend.list(ListQuery::default()).unwrap();
485 assert_eq!(backend.last_since_revision(), Some(None));
486 let seen = seen.lock().unwrap();
487 assert_eq!(seen.len(), 3);
488 assert_eq!(seen[0], None);
489 assert_eq!(seen[1], Some(json!(41)));
490 assert_eq!(seen[2], None);
491 }
492
493 fn serve_methods(path: &std::path::Path, root: String) {
494 let listener = UnixListener::bind(path).unwrap();
495 thread::spawn(move || {
496 let (stream, _) = listener.accept().unwrap();
497 let mut reader = BufReader::new(stream.try_clone().unwrap());
498 let mut writer = stream;
499 while let Ok((payload, framing)) = read_message(&mut reader) {
500 let req: JsonRpcRequest = serde_json::from_slice(&payload).unwrap();
501 let result = match req.method.as_str() {
502 "initialize" => json!({
503 "protocolVersion":1,"capabilities":[],"root":root,
504 "prefix":"Software","generation":2,"revision":3,"identity":"tui"
505 }),
506 "issue/get" | "issue/show" | "issue/open" => json!({
507 "id":"atlas-1a2b","project":"atlas","title":"t","state":"TODO",
508 "priority":"B","properties":{},"org_tags":[],"tags":[],
509 "blocked_by":[],"parent":null,"claimed_by":null,"claimed_at":null,
510 "file":"f","line_start":1,"line_end":2,"revision":3
511 }),
512 "issue/excerpt" => json!({
513 "id":"atlas-1a2b","file":"f","line_start":1,"line_end":2,
514 "text":"body","suppressed":false
515 }),
516 "issue/search" | "issue/claims" | "issue/agenda" | "issue/related" => {
517 json!([])
518 }
519 "issue/tree" => json!({
520 "id":"atlas-1a2b","state":"TODO","title":"t",
521 "children":[],"blocked_by":[]
522 }),
523 "project/list" => json!({"projects":["atlas"],"revision":3}),
524 "issue/claim" | "issue/note" | "issue/update" => json!({
525 "ok":true,"report":"ok","issue":null,"revision":4,"generation":3
526 }),
527 "issue/list" | "issue/ready" => json!({
528 "issues":[],"total":0,"matched":0,"revision":3,
529 "generation":2,"unchanged":false
530 }),
531 other => panic!("unexpected {other}"),
532 };
533 let body = json!({"jsonrpc":"2.0","id":req.id,"result":result});
534 write_message(&mut writer, &serde_json::to_vec(&body).unwrap(), framing).unwrap();
535 writer.flush().unwrap();
536 }
537 });
538 }
539
540 #[test]
541 fn control_verbs_roundtrip() {
542 let dir = tempfile::tempdir().unwrap();
543 let sock = dir.path().join("control.sock");
544 let layout = Layout::new(dir.path().join("vault"), "Software");
545 serve_methods(&sock, layout.root().display().to_string());
546 let backend = ControlBackend::connect(&sock, &layout, "tui").unwrap();
547 assert_eq!(backend.get("atlas-1a2b").unwrap().id, "atlas-1a2b");
548 assert_eq!(backend.excerpt("atlas-1a2b").unwrap().text, "body");
549 assert!(backend.search("x", 5).unwrap().is_empty());
550 assert!(backend.claims(None, None).unwrap().is_empty());
551 assert!(backend.agenda(14, None).unwrap().is_empty());
552 assert_eq!(backend.tree("atlas-1a2b").unwrap().id, "atlas-1a2b");
553 assert!(backend.related("atlas-1a2b", 2, 5).unwrap().is_empty());
554 assert_eq!(backend.projects().unwrap(), ["atlas"]);
555 assert!(backend.claim("atlas-1a2b", false).unwrap().ok);
556 assert!(backend.note("atlas-1a2b", "hi").unwrap().ok);
557 assert!(
558 backend
559 .update(UpdateReq {
560 id: "atlas-1a2b".into(),
561 state: Some("STARTED".into()),
562 ..UpdateReq::default()
563 })
564 .unwrap()
565 .ok
566 );
567 assert_eq!(backend.open("atlas-1a2b").unwrap().id, "atlas-1a2b");
568 assert_eq!(backend.wait(3, 5).unwrap(), 3);
569 }
570
571 #[test]
572 fn after_claim_next_list_sends_page_revision_not_head() {
573 let dir = tempfile::tempdir().unwrap();
574 let sock = dir.path().join("control.sock");
575 let layout = Layout::new(dir.path().join("vault"), "Software");
576 let seen = Arc::new(Mutex::new(Vec::new()));
577 let seen_cb = Arc::clone(&seen);
578 let root = layout.root().display().to_string();
579 let listener = UnixListener::bind(&sock).unwrap();
580 thread::spawn(move || {
581 let (stream, _) = listener.accept().unwrap();
582 let mut reader = BufReader::new(stream.try_clone().unwrap());
583 let mut writer = stream;
584 while let Ok((payload, framing)) = read_message(&mut reader) {
585 let req: JsonRpcRequest = serde_json::from_slice(&payload).unwrap();
586 let result = match req.method.as_str() {
587 "initialize" => json!({
588 "protocolVersion":1,"capabilities":[],"root":root,
589 "prefix":"Software","generation":2,"revision":10,"identity":"tui"
590 }),
591 "issue/ready" | "issue/list" => {
592 let since = req
593 .params
594 .as_ref()
595 .and_then(|p| p.get("since_revision"))
596 .cloned();
597 seen_cb.lock().unwrap().push(since);
598 json!({
599 "issues":[{
600 "id":"atlas-2c3d","state":"TODO","priority":"B",
601 "title":"Emit a summary table","project":"atlas",
602 "blocked_by":[],"claimed_by":null,"claimed_at":null
603 }],
604 "total":1,"matched":1,"revision":10,
605 "generation":2,"unchanged":false
606 })
607 }
608 "issue/claim" => json!({
609 "ok":true,"report":"claimed","issue":null,
610 "revision":11,"generation":3
611 }),
612 other => panic!("unexpected {other}"),
613 };
614 let body = json!({"jsonrpc":"2.0","id":req.id,"result":result});
615 write_message(&mut writer, &serde_json::to_vec(&body).unwrap(), framing).unwrap();
616 writer.flush().unwrap();
617 }
618 });
619
620 let backend = ControlBackend::connect(&sock, &layout, "tui").unwrap();
621 let page = backend.ready(None).unwrap();
622 assert_eq!(page.issues[0].id, "atlas-2c3d");
623 assert_eq!(backend.last_since_revision(), Some(None));
624 assert!(backend.claim("atlas-2c3d", false).unwrap().ok);
625 assert_eq!(backend.revision(), 11);
626 backend.ready(None).unwrap();
627 assert_eq!(backend.last_since_revision(), Some(Some(10)));
628 let seen = seen.lock().unwrap();
629 assert_eq!(seen[0], None);
630 assert_eq!(seen[1], Some(json!(10)));
631 }
632
633 #[test]
634 fn root_mismatch_refuses_the_socket() {
635 let dir = tempfile::tempdir().unwrap();
636 let sock = dir.path().join("control.sock");
637 let layout = Layout::new(dir.path().join("vault"), "Software");
638 let listener = UnixListener::bind(&sock).unwrap();
639 thread::spawn(move || {
640 let (stream, _) = listener.accept().unwrap();
641 let mut reader = BufReader::new(stream.try_clone().unwrap());
642 let mut writer = stream;
643 let (payload, framing) = read_message(&mut reader).unwrap();
644 let req: JsonRpcRequest = serde_json::from_slice(&payload).unwrap();
645 let body = json!({
646 "jsonrpc": "2.0",
647 "id": req.id,
648 "result": {
649 "protocolVersion": 1,
650 "capabilities": [],
651 "root": "/other/vault",
652 "prefix": "Software",
653 "generation": 1,
654 "revision": 1,
655 "identity": "tui"
656 }
657 });
658 write_message(&mut writer, &serde_json::to_vec(&body).unwrap(), framing).unwrap();
659 writer.flush().unwrap();
660 });
661 let err = match ControlBackend::connect(&sock, &layout, "tui") {
662 Ok(_) => panic!("expected root mismatch"),
663 Err(err) => err,
664 };
665 match err {
666 ControlAttachError::Mismatch { got_root, .. } => {
667 assert_eq!(got_root, "/other/vault");
668 }
669 other => panic!("{other:?}"),
670 }
671 }
672}