Skip to main content

oxicode_agent/tools/
issue.rs

1//! `issue` agent tool — agent-driven local issue management.
2//!
3//! Implements [`oxicode_agent::AgentTool`] against the [`FileIssueStore`]. One
4//! tool with an `action` discriminator (matches the pattern used by the
5//! `github` tool, see `oxicode-agent/src/tools/github.rs`).
6//!
7//! Actions: `list`, `read`, `create`, `update`, `start`, `release`, `close`,
8//! `link_session`. See [`AgentTool::parameters_schema`] for the exact JSON
9//! schema each action accepts.
10//!
11//! Design notes:
12//! - The tool holds an `Arc<FileIssueStore>` so it can be cheaply cloned when
13//!   the tool is registered in the live `ToolRegistry` (mirrors how
14//!   `McpTool`/`WasmTool` hold their managers).
15//! - The "current session id" used for assignment / session-linking is taken
16//!   from `ToolContext.session_id`. The agent loop fills this in from the
17//!   active session.
18
19use 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/// The `issue` tool. One registration, multiple actions.
28#[derive(Debug, Clone)]
29pub struct IssueTool {
30    store: Arc<FileIssueStore>,
31}
32
33impl IssueTool {
34    /// Construct a new `issue` tool backed by `store`.
35    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        // Guard the disk before dispatch: reject oversize payloads early (#5).
107        if let Err(e) = validate_size(&params, &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))
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        // IssuePatch makes absent vs [] unambiguous: labels absent → None (keep),
198        // labels: [] → Some(vec![]) (clear). Resolves #3.
199        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
318// ── Formatting helpers (shared with the CLI subcommand in Phase 1.5) ─────
319
320/// Render an issue as a single summary line (id, status, priority, lock,
321/// title, labels, assignee). Used by both the agent tool and the CLI.
322pub fn format_issue_line(i: &Issue) -> String {
323    let lock = if i.meta.assigned_to.is_some() {
324        "▣"
325    } else {
326        " "
327    };
328    let assignee = i
329        .meta
330        .assigned_to
331        .as_ref()
332        .map(|a| format!(" (assigned: {})", short_session(&a.session)))
333        .unwrap_or_default();
334    format!(
335        "#{:<4} [{}] {:8} {}{} {}{}",
336        i.meta.id,
337        i.meta.status,
338        i.meta.priority,
339        lock,
340        i.meta.title,
341        i.meta.labels.join(","),
342        assignee,
343    )
344}
345
346/// Render a full issue view (summary line + meta + body). Used by both the
347/// agent tool and the CLI.
348pub fn format_issue_full(i: &Issue, hash: &str) -> String {
349    let mut s = format_issue_line(i);
350    s.push('\n');
351    s.push_str(&format!("  id: {}\n", i.meta.id));
352    s.push_str(&format!("  created: {}\n", i.meta.created_at));
353    s.push_str(&format!("  updated: {}\n", i.meta.updated_at));
354    if let Some(c) = i.meta.closed_at {
355        s.push_str(&format!("  closed: {}\n", c));
356    }
357    s.push_str(&format!("  sessions: {:?}\n", i.meta.sessions));
358    if let Some(a) = &i.meta.assigned_to {
359        s.push_str(&format!(
360            "  assigned: {} (since {})\n",
361            short_session(&a.session),
362            a.acquired_at
363        ));
364    }
365    s.push_str(&format!("  content_hash: {}\n", hash));
366    s.push('\n');
367    s.push_str(&i.body);
368    s
369}
370
371fn short_session(s: &str) -> String {
372    if s.len() <= 8 {
373        s.to_string()
374    } else {
375        format!("{}…", &s[..8])
376    }
377}
378
379// ── Param parsers (centralized so each action doesn't repeat) ────────────
380
381/// Maximum CAS attempts before giving up (#2). The first attempt uses the
382/// agent's `content_hash` (fast path); on each [`IssueError::Conflict`] we
383/// re-read a fresh hash and retry. So a stale hash from the agent still
384/// succeeds as long as contention resolves within the bound. See
385/// `docs/designs/2026-06-17-issue-system-hardening.md` §1.4 for why the hash
386/// gate is *required* for safe automatic recovery.
387const MAX_CAS_ATTEMPTS: u32 = 4;
388
389/// Run an issue store mutation under bounded compare-and-set retry.
390///
391/// The store is deliberately strict: it returns raw [`IssueError::Conflict`]
392/// and never retries on its own (principle: strictness in the store, recovery
393/// in the tool). This helper owns the recovery — re-reading a fresh hash after
394/// each conflict and retrying up to [`MAX_CAS_ATTEMPTS`] times.
395pub async fn cas_retry<T, F, Fut>(
396    store: &FileIssueStore,
397    id: u32,
398    agent_hash: Option<String>,
399    mut op: F,
400) -> Result<T, IssueError>
401where
402    F: FnMut(Option<String>) -> Fut,
403    Fut: Future<Output = Result<T, IssueError>> + Send,
404    T: Send,
405{
406    let mut hash = agent_hash;
407    for attempt in 0..MAX_CAS_ATTEMPTS {
408        match op(hash.clone()).await {
409            Ok(v) => return Ok(v),
410            Err(IssueError::Conflict { .. }) if attempt + 1 < MAX_CAS_ATTEMPTS => {
411                tracing::debug!(
412                    id,
413                    attempt = attempt + 1,
414                    "issue CAS conflict, re-reading fresh hash"
415                );
416                hash = store.read(id).ok().map(|(_, h)| h);
417                continue;
418            }
419            Err(e) => return Err(e),
420        }
421    }
422    Err(IssueError::Conflict { id })
423}
424
425/// Extract a non-empty `content_hash` from params (absent/empty → `None`).
426fn hash_param(v: Option<&Value>) -> Option<String> {
427    v.and_then(|x| x.as_str())
428        .filter(|s| !s.is_empty())
429        .map(String::from)
430}
431
432// ── Size limits (#5) — early rejection to prevent disk fill / oversized docs ──
433
434/// Maximum title length, in characters.
435const MAX_TITLE_LEN: usize = 512;
436/// Maximum body length, in bytes (256 KiB).
437const MAX_BODY_LEN: usize = 256 * 1024;
438/// Maximum number of labels per issue.
439const MAX_LABELS: usize = 32;
440/// Maximum length of a single label, in characters.
441const MAX_LABEL_LEN: usize = 64;
442
443/// Reject oversized `create`/`update` payloads before they touch the store.
444///
445/// Size is enforced only for the actions that accept free-form text
446/// (`create`, `update`); read-only actions (`list`/`read`) are unaffected.
447/// `title`/`label` length is measured in `char`s (grapheme-safe enough for a
448/// bound); `body` in bytes (the on-disk cost).
449fn validate_size(params: &Value, action: &str) -> Result<(), String> {
450    if !matches!(action, "create" | "update") {
451        return Ok(());
452    }
453    if let Some(t) = params.get("title").and_then(|v| v.as_str())
454        && t.chars().count() > MAX_TITLE_LEN
455    {
456        return Err(format!("title too long (max {MAX_TITLE_LEN} chars)"));
457    }
458    if let Some(b) = params.get("body").and_then(|v| v.as_str())
459        && b.len() > MAX_BODY_LEN
460    {
461        return Err(format!("body too large (max {MAX_BODY_LEN} bytes)"));
462    }
463    if let Some(l) = params.get("labels").and_then(|v| v.as_array()) {
464        if l.len() > MAX_LABELS {
465            return Err(format!("too many labels (max {MAX_LABELS})"));
466        }
467        for item in l {
468            if item.as_str().map(|s| s.chars().count()).unwrap_or(0) > MAX_LABEL_LEN {
469                return Err(format!("label too long (max {MAX_LABEL_LEN} chars)"));
470            }
471        }
472    }
473    Ok(())
474}
475
476fn require_string(v: Option<&Value>, name: &str) -> Result<String, String> {
477    v.and_then(|x| x.as_str())
478        .map(String::from)
479        .ok_or_else(|| format!("missing required field: {name}"))
480}
481
482fn require_u32(v: Option<&Value>, name: &str) -> Result<u32, String> {
483    v.and_then(|x| x.as_u64())
484        .and_then(|n| u32::try_from(n).ok())
485        .ok_or_else(|| format!("missing or invalid field: {name}"))
486}
487
488fn parse_status_opt(v: Option<&Value>) -> Result<Option<Status>, String> {
489    let Some(v) = v else { return Ok(None) };
490    let s = v
491        .as_str()
492        .ok_or_else(|| "status must be a string".to_string())?;
493    match s {
494        "open" => Ok(Some(Status::Open)),
495        "closed" => Ok(Some(Status::Closed)),
496        other => Err(format!("invalid status: {other}")),
497    }
498}
499
500fn parse_priority_opt(v: Option<&Value>) -> Result<Option<Priority>, String> {
501    let Some(v) = v else { return Ok(None) };
502    let s = v
503        .as_str()
504        .ok_or_else(|| "priority must be a string".to_string())?;
505    match s {
506        "low" => Ok(Some(Priority::Low)),
507        "medium" => Ok(Some(Priority::Medium)),
508        "high" => Ok(Some(Priority::High)),
509        "critical" => Ok(Some(Priority::Critical)),
510        other => Err(format!("invalid priority: {other}")),
511    }
512}
513
514fn parse_labels(v: Option<&Value>) -> Result<Vec<String>, String> {
515    let Some(v) = v else { return Ok(vec![]) };
516    let arr = v
517        .as_array()
518        .ok_or_else(|| "labels must be an array of strings".to_string())?;
519    let mut out = Vec::with_capacity(arr.len());
520    for item in arr {
521        let s = item
522            .as_str()
523            .ok_or_else(|| "labels must be an array of strings".to_string())?;
524        out.push(s.to_string());
525    }
526    Ok(out)
527}
528
529#[cfg(test)]
530mod tests {
531    //! Phase 2 coverage for the tool-layer CAS retry helper (#2). The store
532    //! is strict (raw Conflict, no retry); `cas_retry` owns recovery.
533    use super::*;
534
535    fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
536        let tmp = tempfile::tempdir().unwrap();
537        let dir = tmp.path().join(".oxicode").join("issues");
538        std::fs::create_dir_all(&dir).unwrap();
539        (tmp, FileIssueStore::open(dir).unwrap())
540    }
541
542    #[tokio::test]
543    async fn cas_retry_recovers_from_stale_hash() {
544        // The agent passes a stale/wrong content_hash. First attempt conflicts;
545        // cas_retry re-reads a fresh hash and retries → succeeds. This is the
546        // whole point of #2: a stale hash is advisory, not fatal.
547        let (_tmp, store) = tmp_store();
548        store
549            .create("T".into(), "b".into(), Priority::Low, vec![], None)
550            .unwrap();
551        let id = 1;
552
553        let result: Result<Issue, _> = cas_retry(
554            &store,
555            id,
556            Some("deadbeefdeadbeef".to_string()), // deliberately wrong
557            |hash| {
558                let store = store.clone();
559                async move {
560                    store
561                        .apply_patch(
562                            id,
563                            IssuePatch {
564                                title: Some("Patched".into()),
565                                ..Default::default()
566                            },
567                            None,
568                            hash,
569                        )
570                        .await
571                }
572            },
573        )
574        .await;
575        let issue = result.expect("cas_retry should recover from a stale hash");
576        assert_eq!(issue.meta.title, "Patched");
577    }
578
579    #[tokio::test]
580    async fn cas_retry_gives_up_after_bound() {
581        // If contention never resolves, cas_retry surfaces Conflict after
582        // MAX_CAS_ATTEMPTS — it never loops forever.
583        let (_tmp, store) = tmp_store();
584        store
585            .create("T".into(), "b".into(), Priority::Low, vec![], None)
586            .unwrap();
587        let id = 1;
588
589        let result: Result<Issue, _> = cas_retry(&store, id, None, |_hash| async move {
590            Err(IssueError::Conflict { id })
591        })
592        .await;
593        assert!(
594            matches!(result, Err(IssueError::Conflict { id: 1 })),
595            "must give up with Conflict after the bound, got: {result:?}"
596        );
597    }
598
599    // ── Phase 3 coverage: size limits (#5) ──
600
601    #[test]
602    fn validate_size_passes_small_payload() {
603        let p = json!({"title": "ok", "body": "short", "labels": ["a", "b"]});
604        assert!(validate_size(&p, "create").is_ok());
605        assert!(validate_size(&p, "update").is_ok());
606    }
607
608    #[test]
609    fn validate_size_skips_non_text_actions() {
610        // list/read/start/etc. never hit the size gate even with huge values.
611        let p = json!({"body": "x".repeat(300_000)});
612        assert!(validate_size(&p, "list").is_ok());
613        assert!(validate_size(&p, "start").is_ok());
614    }
615
616    #[test]
617    fn validate_size_rejects_oversize_body() {
618        let p = json!({"body": "x".repeat(MAX_BODY_LEN + 1)});
619        let err = validate_size(&p, "create").unwrap_err();
620        assert!(err.contains("body too large"), "got: {err}");
621    }
622
623    #[test]
624    fn validate_size_rejects_oversize_title() {
625        let p = json!({"title": "x".repeat(MAX_TITLE_LEN + 1)});
626        let err = validate_size(&p, "update").unwrap_err();
627        assert!(err.contains("title too long"), "got: {err}");
628    }
629
630    #[test]
631    fn validate_size_rejects_too_many_labels() {
632        let labels: Vec<&str> = (0..(MAX_LABELS + 1)).map(|_| "l").collect();
633        let p = json!({"labels": labels});
634        let err = validate_size(&p, "create").unwrap_err();
635        assert!(err.contains("too many labels"), "got: {err}");
636    }
637
638    #[test]
639    fn validate_size_rejects_long_label() {
640        let p = json!({"labels": ["x".repeat(MAX_LABEL_LEN + 1)]});
641        let err = validate_size(&p, "create").unwrap_err();
642        assert!(err.contains("label too long"), "got: {err}");
643    }
644}