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