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