1use std::sync::Arc;
20
21use crate::{AgentTool, AgentToolResult, ToolContext};
22use async_trait::async_trait;
23use serde_json::{Value, json};
24
25use crate::issues::{FileIssueStore, Issue, IssueError, IssueFilter, IssuePatch, Priority, Status};
26
27#[derive(Debug, Clone)]
29pub struct IssueTool {
30 store: Arc<FileIssueStore>,
31}
32
33impl IssueTool {
34 pub fn new(store: FileIssueStore) -> Self {
36 Self {
37 store: Arc::new(store),
38 }
39 }
40}
41
42#[async_trait]
43impl AgentTool for IssueTool {
44 fn name(&self) -> &str {
45 "issue"
46 }
47
48 fn label(&self) -> &str {
49 "Issue"
50 }
51
52 fn description(&self) -> &str {
53 "Manage local issues stored as markdown files in `.oxicode/issues/`. \
54 Before editing, call `start` to claim the issue — this prevents other \
55 agents/sessions from concurrently working on the same issue. Always \
56 call `list` first to see existing issues and avoid duplicates. \
57 Use `release` to give up a claim, or `close` to finish the work. \
58 For `update`: every field is optional — omit to keep, provide to replace; \
59 `labels: []` clears all labels (omit to keep). Prefer the dedicated \
60 `close`/`reopen`/`start`/`release` actions over `update { status }`. \
61 To resume a closed issue, call `reopen`, then `start`. Concurrent edits \
62 are auto-reconciled (up to 4 retries), so a stale `content_hash` from \
63 an earlier `read` still succeeds."
64 }
65
66 fn parameters_schema(&self) -> Value {
67 json!({
68 "type": "object",
69 "properties": {
70 "action": {
71 "type": "string",
72 "enum": ["list", "read", "create", "update", "reopen", "start", "release", "close", "link_session"],
73 "description": "Issue operation. For `update`, every field is optional — omit to keep, provide to replace. Concurrent edits are auto-reconciled (up to 4 retries)."
74 },
75 "id": {"type": "integer", "description": "Issue id (for read/update/reopen/start/release/close/link_session)."},
76 "title": {"type": "string", "description": "create: required. update: replaces the title. Max 512 chars."},
77 "body": {"type": "string", "description": "create: optional (defaults empty). update: replaces the body. Max 256 KiB."},
78 "priority": {"type": "string", "enum": ["low", "medium", "high", "critical"], "description": "create/update: new priority. list: filter to this priority."},
79 "labels": {"type": "array", "items": {"type": "string"}, "description": "create/update: REPLACES labels entirely. Omit to keep; pass [] to clear all. Max 32 labels, 64 chars each."},
80 "status": {"type": "string", "enum": ["open", "closed"], "description": "list: filter by status. update: new status (prefer the `close`/`reopen` actions for clarity)."},
81 "label": {"type": "string", "description": "list: filter to issues with this label."},
82 "text": {"type": "string", "description": "list: case-insensitive substring filter on the title."},
83 "content_hash": {"type": "string", "description": "Hash from the last `read`. ADVISORY: the tool auto re-reads and retries on conflict, so a stale hash still succeeds."},
84 "github": {"type": "object", "readOnly": true, "description": "READ-ONLY. Populated by GitHub sync (Phase 6); cannot be set via this tool."}
85 },
86 "required": ["action"]
87 })
88 }
89
90 fn essential(&self) -> bool {
91 false
92 }
93
94 async fn execute(
95 &self,
96 _tool_call_id: &str,
97 params: Value,
98 _signal: Option<tokio::sync::oneshot::Receiver<()>>,
99 ctx: &ToolContext,
100 ) -> Result<AgentToolResult, String> {
101 let action = match params.get("action").and_then(|v| v.as_str()) {
102 Some(a) => a.to_string(),
103 None => return Ok(AgentToolResult::error("missing required field: action")),
104 };
105
106 if let Err(e) = validate_size(¶ms, &action) {
108 return Ok(AgentToolResult::error(e));
109 }
110
111 let session = ctx.session_id.clone().unwrap_or_default();
112 let result: Result<String, String> = match action.as_str() {
113 "list" => self.list(params),
114 "read" => self.read(params).await,
115 "create" => self.create(params, &session).await,
116 "update" => self.update(params, &session).await,
117 "start" => self.start(params, &session).await,
118 "release" => self.release(params, &session).await,
119 "close" => self.close(params, &session).await,
120 "reopen" => self.reopen(params).await,
121 "link_session" => self.link_session(params, &session).await,
122 other => Err(format!("unknown action: {other}")),
123 };
124
125 Ok(match result {
126 Ok(text) => AgentToolResult::success(text),
127 Err(e) => AgentToolResult::error(e),
128 })
129 }
130}
131
132impl IssueTool {
133 fn list(&self, params: Value) -> Result<String, String> {
134 let status = parse_status_opt(params.get("status"))?;
135 let priority = parse_priority_opt(params.get("priority"))?;
136 let label = params
137 .get("label")
138 .and_then(|v| v.as_str())
139 .map(String::from);
140 let text = params
141 .get("text")
142 .and_then(|v| v.as_str())
143 .map(String::from);
144 let filter = IssueFilter {
145 status,
146 priority,
147 label,
148 assigned_to_session: None,
149 text,
150 };
151 let issues = self.store.list(&filter).map_err(|e| e.to_string())?;
152 if issues.is_empty() {
153 return Ok("no issues match the filter".to_string());
154 }
155 Ok(issues
156 .iter()
157 .map(format_issue_line)
158 .collect::<Vec<_>>()
159 .join("\n"))
160 }
161
162 async fn read(&self, params: Value) -> Result<String, String> {
163 let id = require_u32(params.get("id"), "id")?;
164 self.store
165 .read(id)
166 .map(|(issue, hash)| format_issue_full(&issue, &hash, &self.store.issues_dir()))
167 .map_err(|e| e.to_string())
168 }
169
170 async fn create(&self, params: Value, session: &str) -> Result<String, String> {
171 let title = require_string(params.get("title"), "title")?;
172 let body = params
173 .get("body")
174 .and_then(|v| v.as_str())
175 .unwrap_or("")
176 .to_string();
177 let priority = parse_priority_opt(params.get("priority"))?.unwrap_or(Priority::Medium);
178 let labels = parse_labels(params.get("labels"))?;
179 let session_opt = if session.is_empty() {
180 None
181 } else {
182 Some(session)
183 };
184 let issue = self
185 .store
186 .create(title, body, priority, labels, session_opt)
187 .map_err(|e| e.to_string())?;
188 Ok(format!(
189 "created issue #{}: {}",
190 issue.meta.id, issue.meta.title
191 ))
192 }
193
194 async fn update(&self, params: Value, session: &str) -> Result<String, String> {
195 let id = require_u32(params.get("id"), "id")?;
196 let agent_hash = hash_param(params.get("content_hash"));
197 let patch = IssuePatch {
200 title: params
201 .get("title")
202 .and_then(|v| v.as_str())
203 .map(String::from),
204 body: params
205 .get("body")
206 .and_then(|v| v.as_str())
207 .map(String::from),
208 status: parse_status_opt(params.get("status"))?,
209 priority: parse_priority_opt(params.get("priority"))?,
210 labels: params
211 .get("labels")
212 .map(|v| parse_labels(Some(v)))
213 .transpose()?,
214 };
215 let caller = if session.is_empty() {
216 None
217 } else {
218 Some(session.to_string())
219 };
220 let store = self.store.clone();
221 cas_retry(&store, id, agent_hash, |hash| {
222 let store = store.clone();
223 let patch = patch.clone();
224 let caller = caller.clone();
225 async move { store.apply_patch(id, patch, caller, hash).await }
226 })
227 .await
228 .map(|issue| format!("updated issue #{}", issue.meta.id))
229 .map_err(|e| e.to_string())
230 }
231
232 async fn start(&self, params: Value, session: &str) -> Result<String, String> {
233 let id = require_u32(params.get("id"), "id")?;
234 if session.is_empty() {
235 return Err("cannot start: no active session id in context".to_string());
236 }
237 let agent_hash = hash_param(params.get("content_hash"));
238 let store = self.store.clone();
239 let session = session.to_string();
240 cas_retry(&store, id, agent_hash, |hash| {
241 let store = store.clone();
242 let session = session.clone();
243 async move { store.start(id, &session, hash).await }
244 })
245 .await
246 .map(|issue| format!("assigned issue #{} to session {}", issue.meta.id, session))
247 .map_err(|e| e.to_string())
248 }
249
250 async fn release(&self, params: Value, session: &str) -> Result<String, String> {
251 let id = require_u32(params.get("id"), "id")?;
252 if session.is_empty() {
253 return Err("cannot release: no active session id in context".to_string());
254 }
255 let agent_hash = hash_param(params.get("content_hash"));
256 let store = self.store.clone();
257 let session = session.to_string();
258 cas_retry(&store, id, agent_hash, |hash| {
259 let store = store.clone();
260 let session = session.clone();
261 async move { store.release(id, &session, hash).await }
262 })
263 .await
264 .map(|_| format!("released issue #{id}"))
265 .map_err(|e| e.to_string())
266 }
267
268 async fn close(&self, params: Value, session: &str) -> Result<String, String> {
269 let id = require_u32(params.get("id"), "id")?;
270 if session.is_empty() {
271 return Err("cannot close: no active session id in context".to_string());
272 }
273 let agent_hash = hash_param(params.get("content_hash"));
274 let store = self.store.clone();
275 let session = session.to_string();
276 cas_retry(&store, id, agent_hash, |hash| {
277 let store = store.clone();
278 let session = session.clone();
279 async move { store.close(id, &session, hash).await }
280 })
281 .await
282 .map(|issue| format!("closed issue #{}: {}", issue.meta.id, issue.meta.title))
283 .map_err(|e| e.to_string())
284 }
285
286 async fn reopen(&self, params: Value) -> Result<String, String> {
287 let id = require_u32(params.get("id"), "id")?;
288 let agent_hash = hash_param(params.get("content_hash"));
289 let store = self.store.clone();
290 cas_retry(&store, id, agent_hash, |hash| {
291 let store = store.clone();
292 async move { store.reopen(id, hash).await }
293 })
294 .await
295 .map(|issue| format!("reopened issue #{}: {}", issue.meta.id, issue.meta.title))
296 .map_err(|e| e.to_string())
297 }
298
299 async fn link_session(&self, params: Value, session: &str) -> Result<String, String> {
300 let id = require_u32(params.get("id"), "id")?;
301 if session.is_empty() {
302 return Err("cannot link_session: no active session id in context".to_string());
303 }
304 let agent_hash = hash_param(params.get("content_hash"));
305 let store = self.store.clone();
306 let session = session.to_string();
307 cas_retry(&store, id, agent_hash, |hash| {
308 let store = store.clone();
309 let session = session.clone();
310 async move { store.link_session(id, &session, hash).await }
311 })
312 .await
313 .map(|_| format!("linked session to issue #{id}"))
314 .map_err(|e| e.to_string())
315 }
316}
317
318pub fn format_issue_line(i: &Issue) -> String {
324 let lock = if i.meta.assigned_to.is_some() {
325 "▣"
326 } else {
327 " "
328 };
329 let assignee = i.meta.assigned_to.as_ref().map(|a| {
330 format!(
331 " (assigned: {} since {})",
332 short_session(&a.session),
333 a.acquired_at.format("%m-%d %H:%M")
334 )
335 });
336 format!(
337 "#{:<4} [{}] {:8} {}{} {}{}",
338 i.meta.id,
339 i.meta.status,
340 i.meta.priority,
341 lock,
342 i.meta.title,
343 i.meta.labels.join(","),
344 assignee.unwrap_or_default(),
345 )
346}
347
348pub fn format_issue_full(i: &Issue, hash: &str, issues_dir: &std::path::Path) -> String {
354 let mut s = format_issue_line(i);
355 s.push('\n');
356 s.push_str(&format!(" id: {}\n", i.meta.id));
357 s.push_str(&format!(" created: {}\n", i.meta.created_at));
358 s.push_str(&format!(" updated: {}\n", i.meta.updated_at));
359 if let Some(c) = i.meta.closed_at {
360 s.push_str(&format!(" closed: {}\n", c));
361 }
362 s.push_str(&format!(" sessions: {:?}\n", i.meta.sessions));
363 if let Some(a) = &i.meta.assigned_to {
364 s.push_str(&format!(
365 " assigned: {} (since {})\n",
366 short_session(&a.session),
367 a.acquired_at.format("%Y-%m-%d %H:%M")
368 ));
369 if let Some(o) = crate::issues::liveness::read_owner_info(issues_dir, &a.session) {
370 let holder = if o.host.is_empty() {
371 format!("pid {}", o.pid)
372 } else {
373 format!("pid {} on {}", o.pid, o.host)
374 };
375 s.push_str(&format!(
376 " lock: {holder} (cwd: {}, since {})\n",
377 o.cwd,
378 chrono::DateTime::from_timestamp(o.started as i64, 0)
379 .map(|t| t.format("%Y-%m-%d %H:%M").to_string())
380 .unwrap_or_else(|| o.started.to_string())
381 ));
382 }
383 }
384 s.push_str(&format!(" content_hash: {}\n", hash));
385 s.push('\n');
386 s.push_str(&i.body);
387 s
388}
389
390fn short_session(s: &str) -> String {
391 if s.len() <= 8 {
392 s.to_string()
393 } else {
394 format!("{}…", &s[..8])
395 }
396}
397
398const MAX_CAS_ATTEMPTS: u32 = 4;
407
408pub async fn cas_retry<T, F, Fut>(
415 store: &FileIssueStore,
416 id: u32,
417 agent_hash: Option<String>,
418 mut op: F,
419) -> Result<T, IssueError>
420where
421 F: FnMut(Option<String>) -> Fut,
422 Fut: Future<Output = Result<T, IssueError>> + Send,
423 T: Send,
424{
425 let mut hash = agent_hash;
426 for attempt in 0..MAX_CAS_ATTEMPTS {
427 match op(hash.clone()).await {
428 Ok(v) => return Ok(v),
429 Err(IssueError::Conflict { .. }) if attempt + 1 < MAX_CAS_ATTEMPTS => {
430 tracing::debug!(
431 id,
432 attempt = attempt + 1,
433 "issue CAS conflict, re-reading fresh hash"
434 );
435 hash = store.read(id).ok().map(|(_, h)| h);
436 continue;
437 }
438 Err(e) => return Err(e),
439 }
440 }
441 Err(IssueError::Conflict { id })
442}
443
444fn hash_param(v: Option<&Value>) -> Option<String> {
446 v.and_then(|x| x.as_str())
447 .filter(|s| !s.is_empty())
448 .map(String::from)
449}
450
451const MAX_TITLE_LEN: usize = 512;
455const MAX_BODY_LEN: usize = 256 * 1024;
457const MAX_LABELS: usize = 32;
459const MAX_LABEL_LEN: usize = 64;
461
462fn validate_size(params: &Value, action: &str) -> Result<(), String> {
469 if !matches!(action, "create" | "update") {
470 return Ok(());
471 }
472 if let Some(t) = params.get("title").and_then(|v| v.as_str())
473 && t.chars().count() > MAX_TITLE_LEN
474 {
475 return Err(format!("title too long (max {MAX_TITLE_LEN} chars)"));
476 }
477 if let Some(b) = params.get("body").and_then(|v| v.as_str())
478 && b.len() > MAX_BODY_LEN
479 {
480 return Err(format!("body too large (max {MAX_BODY_LEN} bytes)"));
481 }
482 if let Some(l) = params.get("labels").and_then(|v| v.as_array()) {
483 if l.len() > MAX_LABELS {
484 return Err(format!("too many labels (max {MAX_LABELS})"));
485 }
486 for item in l {
487 if item.as_str().map(|s| s.chars().count()).unwrap_or(0) > MAX_LABEL_LEN {
488 return Err(format!("label too long (max {MAX_LABEL_LEN} chars)"));
489 }
490 }
491 }
492 Ok(())
493}
494
495fn require_string(v: Option<&Value>, name: &str) -> Result<String, String> {
496 v.and_then(|x| x.as_str())
497 .map(String::from)
498 .ok_or_else(|| format!("missing required field: {name}"))
499}
500
501fn require_u32(v: Option<&Value>, name: &str) -> Result<u32, String> {
502 v.and_then(|x| x.as_u64())
503 .and_then(|n| u32::try_from(n).ok())
504 .ok_or_else(|| format!("missing or invalid field: {name}"))
505}
506
507fn parse_status_opt(v: Option<&Value>) -> Result<Option<Status>, String> {
508 let Some(v) = v else { return Ok(None) };
509 let s = v
510 .as_str()
511 .ok_or_else(|| "status must be a string".to_string())?;
512 match s {
513 "open" => Ok(Some(Status::Open)),
514 "closed" => Ok(Some(Status::Closed)),
515 other => Err(format!("invalid status: {other}")),
516 }
517}
518
519fn parse_priority_opt(v: Option<&Value>) -> Result<Option<Priority>, String> {
520 let Some(v) = v else { return Ok(None) };
521 let s = v
522 .as_str()
523 .ok_or_else(|| "priority must be a string".to_string())?;
524 match s {
525 "low" => Ok(Some(Priority::Low)),
526 "medium" => Ok(Some(Priority::Medium)),
527 "high" => Ok(Some(Priority::High)),
528 "critical" => Ok(Some(Priority::Critical)),
529 other => Err(format!("invalid priority: {other}")),
530 }
531}
532
533fn parse_labels(v: Option<&Value>) -> Result<Vec<String>, String> {
534 let Some(v) = v else { return Ok(vec![]) };
535 let arr = v
536 .as_array()
537 .ok_or_else(|| "labels must be an array of strings".to_string())?;
538 let mut out = Vec::with_capacity(arr.len());
539 for item in arr {
540 let s = item
541 .as_str()
542 .ok_or_else(|| "labels must be an array of strings".to_string())?;
543 out.push(s.to_string());
544 }
545 Ok(out)
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
553
554 fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
555 let tmp = tempfile::tempdir().unwrap();
556 let dir = tmp.path().join(".oxicode").join("issues");
557 std::fs::create_dir_all(&dir).unwrap();
558 (tmp, FileIssueStore::open(dir).unwrap())
559 }
560
561 #[tokio::test]
562 async fn cas_retry_recovers_from_stale_hash() {
563 let (_tmp, store) = tmp_store();
567 store
568 .create("T".into(), "b".into(), Priority::Low, vec![], None)
569 .unwrap();
570 let id = 1;
571
572 let result: Result<Issue, _> = cas_retry(
573 &store,
574 id,
575 Some("deadbeefdeadbeef".to_string()), |hash| {
577 let store = store.clone();
578 async move {
579 store
580 .apply_patch(
581 id,
582 IssuePatch {
583 title: Some("Patched".into()),
584 ..Default::default()
585 },
586 None,
587 hash,
588 )
589 .await
590 }
591 },
592 )
593 .await;
594 let issue = result.expect("cas_retry should recover from a stale hash");
595 assert_eq!(issue.meta.title, "Patched");
596 }
597
598 #[tokio::test]
599 async fn cas_retry_gives_up_after_bound() {
600 let (_tmp, store) = tmp_store();
603 store
604 .create("T".into(), "b".into(), Priority::Low, vec![], None)
605 .unwrap();
606 let id = 1;
607
608 let result: Result<Issue, _> = cas_retry(&store, id, None, |_hash| async move {
609 Err(IssueError::Conflict { id })
610 })
611 .await;
612 assert!(
613 matches!(result, Err(IssueError::Conflict { id: 1 })),
614 "must give up with Conflict after the bound, got: {result:?}"
615 );
616 }
617
618 #[test]
621 fn validate_size_passes_small_payload() {
622 let p = json!({"title": "ok", "body": "short", "labels": ["a", "b"]});
623 assert!(validate_size(&p, "create").is_ok());
624 assert!(validate_size(&p, "update").is_ok());
625 }
626
627 #[test]
628 fn validate_size_skips_non_text_actions() {
629 let p = json!({"body": "x".repeat(300_000)});
631 assert!(validate_size(&p, "list").is_ok());
632 assert!(validate_size(&p, "start").is_ok());
633 }
634
635 #[test]
636 fn validate_size_rejects_oversize_body() {
637 let p = json!({"body": "x".repeat(MAX_BODY_LEN + 1)});
638 let err = validate_size(&p, "create").unwrap_err();
639 assert!(err.contains("body too large"), "got: {err}");
640 }
641 #[tokio::test]
642 async fn format_issue_line_shows_assignee_since() {
643 let (_tmp, store) = tmp_store();
644 store
645 .create("T".into(), "b".into(), Priority::Low, vec![], None)
646 .unwrap();
647 store.start(1, "proc-1-abc", None).await.unwrap();
648 let (issue, _) = store.read(1).unwrap();
649 let s = format_issue_line(&issue);
650 assert!(s.contains("since"), "line must show assignment age: {s}");
651 }
652
653 #[tokio::test]
654 async fn format_issue_full_shows_flock_holder_provenance() {
655 let (_tmp, store) = tmp_store();
656 store
657 .create("T".into(), "b".into(), Priority::Low, vec![], None)
658 .unwrap();
659 store.start(1, "tui-1-abcdef", None).await.unwrap();
660 let _guard = crate::issues::liveness::acquire(&store.issues_dir(), "tui-1-abcdef").unwrap();
661 let (issue, hash) = store.read(1).unwrap();
662 let s = format_issue_full(&issue, &hash, &store.issues_dir());
663 assert!(
664 s.contains("since"),
665 "full view must show assignment age: {s}"
666 );
667 assert!(
668 s.contains(&format!("pid {}", std::process::id())),
669 "full view must show the flock holder pid: {s}"
670 );
671 }
672}