Skip to main content

room_cli/plugin/taskboard/
mod.rs

1pub mod task;
2
3use std::path::PathBuf;
4use std::sync::{Arc, Mutex};
5use std::time::Duration;
6
7use task::{next_id, LiveTask, Task, TaskStatus};
8
9use crate::plugin::{
10    BoxFuture, CommandContext, CommandInfo, ParamSchema, ParamType, Plugin, PluginResult,
11};
12
13/// Default lease TTL in seconds (10 minutes).
14const DEFAULT_LEASE_TTL_SECS: u64 = 600;
15
16/// Unified task lifecycle plugin with lease-based expiry.
17///
18/// Manages a board of tasks that agents can post, claim, plan, get approved,
19/// update, release, and finish. Claimed tasks have a configurable lease TTL —
20/// if not renewed via `/taskboard update` or `/taskboard plan`, they auto-
21/// release back to open status (lazy sweep on access).
22pub struct TaskboardPlugin {
23    /// In-memory task board with lease timers.
24    board: Arc<Mutex<Vec<LiveTask>>>,
25    /// Path to the NDJSON persistence file.
26    storage_path: PathBuf,
27    /// Lease TTL duration.
28    lease_ttl: Duration,
29}
30
31impl TaskboardPlugin {
32    /// Create a new taskboard plugin, loading existing tasks from disk.
33    pub fn new(storage_path: PathBuf, lease_ttl_secs: Option<u64>) -> Self {
34        let ttl = lease_ttl_secs.unwrap_or(DEFAULT_LEASE_TTL_SECS);
35        let tasks = task::load_tasks(&storage_path);
36        let live_tasks: Vec<LiveTask> = tasks.into_iter().map(LiveTask::new).collect();
37        Self {
38            board: Arc::new(Mutex::new(live_tasks)),
39            storage_path,
40            lease_ttl: Duration::from_secs(ttl),
41        }
42    }
43
44    /// Derive the `.taskboard` file path from a `.chat` file path.
45    pub fn taskboard_path_from_chat(chat_path: &std::path::Path) -> PathBuf {
46        chat_path.with_extension("taskboard")
47    }
48
49    /// Returns the command info for the TUI command palette without needing
50    /// an instantiated plugin. Used by `all_known_commands()`.
51    pub fn default_commands() -> Vec<CommandInfo> {
52        vec![CommandInfo {
53            name: "taskboard".to_owned(),
54            description:
55                "Manage task lifecycle — post, list, show, claim, plan, approve, update, release, finish"
56                    .to_owned(),
57            usage: "/taskboard <action> [args...]".to_owned(),
58            params: vec![
59                ParamSchema {
60                    name: "action".to_owned(),
61                    param_type: ParamType::Choice(vec![
62                        "post".to_owned(),
63                        "list".to_owned(),
64                        "show".to_owned(),
65                        "claim".to_owned(),
66                        "plan".to_owned(),
67                        "approve".to_owned(),
68                        "update".to_owned(),
69                        "release".to_owned(),
70                        "finish".to_owned(),
71                    ]),
72                    required: true,
73                    description: "Subcommand".to_owned(),
74                },
75                ParamSchema {
76                    name: "args".to_owned(),
77                    param_type: ParamType::Text,
78                    required: false,
79                    description: "Task ID or description".to_owned(),
80                },
81            ],
82        }]
83    }
84
85    /// Sweep expired leases (lazy — called before reads).
86    fn sweep_expired(&self) -> Vec<String> {
87        let mut board = self.board.lock().unwrap();
88        let ttl = self.lease_ttl.as_secs();
89        let mut expired_ids = Vec::new();
90        for lt in board.iter_mut() {
91            if lt.is_expired(ttl)
92                && matches!(
93                    lt.task.status,
94                    TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
95                )
96            {
97                let prev_assignee = lt.task.assigned_to.clone().unwrap_or_default();
98                expired_ids.push(format!("{} (was {})", lt.task.id, prev_assignee));
99                lt.expire();
100            }
101        }
102        if !expired_ids.is_empty() {
103            let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
104            let _ = task::save_tasks(&self.storage_path, &tasks);
105        }
106        expired_ids
107    }
108
109    fn handle_post(&self, ctx: &CommandContext) -> (String, bool) {
110        let description = ctx.params[1..].join(" ");
111        if description.is_empty() {
112            return ("usage: /taskboard post <description>".to_owned(), false);
113        }
114        let mut board = self.board.lock().unwrap();
115        let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
116        let id = next_id(&tasks);
117        let task = Task {
118            id: id.clone(),
119            description: description.clone(),
120            status: TaskStatus::Open,
121            posted_by: ctx.sender.clone(),
122            assigned_to: None,
123            posted_at: chrono::Utc::now(),
124            claimed_at: None,
125            plan: None,
126            approved_by: None,
127            approved_at: None,
128            updated_at: None,
129            notes: None,
130        };
131        board.push(LiveTask::new(task.clone()));
132        let all_tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
133        let _ = task::save_tasks(&self.storage_path, &all_tasks);
134        (format!("task {id} posted: {description}"), true)
135    }
136
137    fn handle_list(&self) -> String {
138        let expired = self.sweep_expired();
139        let board = self.board.lock().unwrap();
140        if board.is_empty() {
141            return "taskboard is empty".to_owned();
142        }
143        let mut lines = Vec::new();
144        if !expired.is_empty() {
145            lines.push(format!("expired: {}", expired.join(", ")));
146        }
147        lines.push(format!(
148            "{:<8} {:<10} {:<12} {:<12} {}",
149            "ID", "STATUS", "ASSIGNEE", "ELAPSED", "DESCRIPTION"
150        ));
151        for lt in board.iter() {
152            let elapsed = match lt.lease_start {
153                Some(start) => {
154                    let secs = start.elapsed().as_secs();
155                    if secs < 60 {
156                        format!("{secs}s")
157                    } else {
158                        format!("{}m", secs / 60)
159                    }
160                }
161                None => "-".to_owned(),
162            };
163            let assignee = lt.task.assigned_to.as_deref().unwrap_or("-").to_owned();
164            let desc = if lt.task.description.len() > 40 {
165                format!("{}...", &lt.task.description[..37])
166            } else {
167                lt.task.description.clone()
168            };
169            lines.push(format!(
170                "{:<8} {:<10} {:<12} {:<12} {}",
171                lt.task.id, lt.task.status, assignee, elapsed, desc
172            ));
173        }
174        lines.join("\n")
175    }
176
177    fn handle_claim(&self, ctx: &CommandContext) -> (String, bool) {
178        let task_id = match ctx.params.get(1) {
179            Some(id) => id,
180            None => return ("usage: /taskboard claim <task-id>".to_owned(), false),
181        };
182        self.sweep_expired();
183        let mut board = self.board.lock().unwrap();
184        let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
185            Some(lt) => lt,
186            None => return (format!("task {task_id} not found"), false),
187        };
188        if lt.task.status != TaskStatus::Open {
189            return (
190                format!(
191                    "task {task_id} is {} (must be open to claim)",
192                    lt.task.status
193                ),
194                false,
195            );
196        }
197        lt.task.status = TaskStatus::Claimed;
198        lt.task.assigned_to = Some(ctx.sender.clone());
199        lt.task.claimed_at = Some(chrono::Utc::now());
200        lt.renew_lease();
201        let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
202        let _ = task::save_tasks(&self.storage_path, &tasks);
203        (
204            format!(
205                "task {task_id} claimed by {} — submit plan with /taskboard plan {task_id} <plan>",
206                ctx.sender
207            ),
208            true,
209        )
210    }
211
212    fn handle_plan(&self, ctx: &CommandContext) -> (String, bool) {
213        let task_id = match ctx.params.get(1) {
214            Some(id) => id,
215            None => {
216                return (
217                    "usage: /taskboard plan <task-id> <plan text>".to_owned(),
218                    false,
219                )
220            }
221        };
222        let plan_text = ctx.params[2..].join(" ");
223        if plan_text.is_empty() {
224            return (
225                "usage: /taskboard plan <task-id> <plan text>".to_owned(),
226                false,
227            );
228        }
229        self.sweep_expired();
230        let mut board = self.board.lock().unwrap();
231        let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
232            Some(lt) => lt,
233            None => return (format!("task {task_id} not found"), false),
234        };
235        if !matches!(lt.task.status, TaskStatus::Claimed | TaskStatus::Planned) {
236            return (
237                format!(
238                    "task {task_id} is {} (must be claimed to submit plan)",
239                    lt.task.status
240                ),
241                false,
242            );
243        }
244        if lt.task.assigned_to.as_deref() != Some(&ctx.sender) {
245            return (format!("task {task_id} is assigned to someone else"), false);
246        }
247        lt.task.status = TaskStatus::Planned;
248        lt.task.plan = Some(plan_text.clone());
249        lt.renew_lease();
250        let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
251        let _ = task::save_tasks(&self.storage_path, &tasks);
252        (
253            format!("task {task_id} plan submitted — awaiting approval\nplan: {plan_text}"),
254            true,
255        )
256    }
257
258    fn handle_approve(&self, ctx: &CommandContext) -> (String, bool) {
259        let task_id = match ctx.params.get(1) {
260            Some(id) => id,
261            None => return ("usage: /taskboard approve <task-id>".to_owned(), false),
262        };
263        self.sweep_expired();
264        let mut board = self.board.lock().unwrap();
265        let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
266            Some(lt) => lt,
267            None => return (format!("task {task_id} not found"), false),
268        };
269        if lt.task.status != TaskStatus::Planned {
270            return (
271                format!(
272                    "task {task_id} is {} (must be planned to approve)",
273                    lt.task.status
274                ),
275                false,
276            );
277        }
278        // Poster or host can approve.
279        let is_poster = lt.task.posted_by == ctx.sender;
280        let is_host = ctx.metadata.host.as_deref() == Some(&ctx.sender);
281        if !is_poster && !is_host {
282            return ("only the task poster or host can approve".to_owned(), false);
283        }
284        lt.task.status = TaskStatus::Approved;
285        lt.task.approved_by = Some(ctx.sender.clone());
286        lt.task.approved_at = Some(chrono::Utc::now());
287        lt.renew_lease();
288        let assignee = lt
289            .task
290            .assigned_to
291            .as_deref()
292            .unwrap_or("unknown")
293            .to_owned();
294        let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
295        let _ = task::save_tasks(&self.storage_path, &tasks);
296        (
297            format!(
298                "task {task_id} approved by {} — @{assignee} proceed with implementation",
299                ctx.sender
300            ),
301            true,
302        )
303    }
304
305    fn handle_update(&self, ctx: &CommandContext) -> (String, bool) {
306        let task_id = match ctx.params.get(1) {
307            Some(id) => id,
308            None => {
309                return (
310                    "usage: /taskboard update <task-id> [notes]".to_owned(),
311                    false,
312                )
313            }
314        };
315        let notes = if ctx.params.len() > 2 {
316            Some(ctx.params[2..].join(" "))
317        } else {
318            None
319        };
320        self.sweep_expired();
321        let mut board = self.board.lock().unwrap();
322        let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
323            Some(lt) => lt,
324            None => return (format!("task {task_id} not found"), false),
325        };
326        if !matches!(
327            lt.task.status,
328            TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
329        ) {
330            return (
331                format!(
332                    "task {task_id} is {} (must be claimed/planned/approved to update)",
333                    lt.task.status
334                ),
335                false,
336            );
337        }
338        if lt.task.assigned_to.as_deref() != Some(&ctx.sender) {
339            return (format!("task {task_id} is assigned to someone else"), false);
340        }
341        let mut warning = String::new();
342        if lt.task.status != TaskStatus::Approved {
343            warning = format!(" [warning: task is {} — not yet approved]", lt.task.status);
344        }
345        if let Some(n) = notes {
346            lt.task.notes = Some(n);
347        }
348        lt.renew_lease();
349        let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
350        let _ = task::save_tasks(&self.storage_path, &tasks);
351        (
352            format!("task {task_id} updated, lease renewed{warning}"),
353            true,
354        )
355    }
356
357    fn handle_release(&self, ctx: &CommandContext) -> (String, bool) {
358        let task_id = match ctx.params.get(1) {
359            Some(id) => id,
360            None => return ("usage: /taskboard release <task-id>".to_owned(), false),
361        };
362        self.sweep_expired();
363        let mut board = self.board.lock().unwrap();
364        let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
365            Some(lt) => lt,
366            None => return (format!("task {task_id} not found"), false),
367        };
368        if !matches!(
369            lt.task.status,
370            TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
371        ) {
372            return (
373                format!(
374                    "task {task_id} is {} (must be claimed/planned/approved to release)",
375                    lt.task.status
376                ),
377                false,
378            );
379        }
380        // Allow owner or host to release.
381        if lt.task.assigned_to.as_deref() != Some(&ctx.sender)
382            && ctx.metadata.host.as_deref() != Some(&ctx.sender)
383        {
384            return (
385                format!("task {task_id} can only be released by the assignee or host"),
386                false,
387            );
388        }
389        let prev = lt.task.assigned_to.clone().unwrap_or_default();
390        lt.task.status = TaskStatus::Open;
391        lt.task.assigned_to = None;
392        lt.task.claimed_at = None;
393        lt.task.plan = None;
394        lt.task.approved_by = None;
395        lt.task.approved_at = None;
396        lt.lease_start = None;
397        let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
398        let _ = task::save_tasks(&self.storage_path, &tasks);
399        (
400            format!("task {task_id} released by {prev} — back to open"),
401            true,
402        )
403    }
404
405    fn handle_show(&self, ctx: &CommandContext) -> String {
406        let task_id = match ctx.params.get(1) {
407            Some(id) => id,
408            None => return "usage: /taskboard show <task-id>".to_owned(),
409        };
410        self.sweep_expired();
411        let board = self.board.lock().unwrap();
412        let lt = match board.iter().find(|lt| lt.task.id == *task_id) {
413            Some(lt) => lt,
414            None => return format!("task {task_id} not found"),
415        };
416        let t = &lt.task;
417        let assignee = t.assigned_to.as_deref().unwrap_or("-");
418        let plan = t.plan.as_deref().unwrap_or("-");
419        let approved_by = t.approved_by.as_deref().unwrap_or("-");
420        let notes = t.notes.as_deref().unwrap_or("-");
421        let elapsed = match lt.lease_start {
422            Some(start) => {
423                let secs = start.elapsed().as_secs();
424                if secs < 60 {
425                    format!("{secs}s")
426                } else {
427                    format!("{}m", secs / 60)
428                }
429            }
430            None => "-".to_owned(),
431        };
432        format!(
433            "task {}\n  status:      {}\n  description: {}\n  posted by:   {}\n  assigned to: {}\n  plan:        {}\n  approved by: {}\n  notes:       {}\n  lease:       {}",
434            t.id, t.status, t.description, t.posted_by, assignee, plan, approved_by, notes, elapsed
435        )
436    }
437
438    fn handle_finish(&self, ctx: &CommandContext) -> (String, bool) {
439        let task_id = match ctx.params.get(1) {
440            Some(id) => id,
441            None => return ("usage: /taskboard finish <task-id>".to_owned(), false),
442        };
443        self.sweep_expired();
444        let mut board = self.board.lock().unwrap();
445        let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
446            Some(lt) => lt,
447            None => return (format!("task {task_id} not found"), false),
448        };
449        if !matches!(
450            lt.task.status,
451            TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
452        ) {
453            return (
454                format!(
455                    "task {task_id} is {} (must be claimed/planned/approved to finish)",
456                    lt.task.status
457                ),
458                false,
459            );
460        }
461        if lt.task.assigned_to.as_deref() != Some(&ctx.sender) {
462            return (
463                format!("task {task_id} can only be finished by the assignee"),
464                false,
465            );
466        }
467        lt.task.status = TaskStatus::Finished;
468        lt.lease_start = None;
469        lt.task.updated_at = Some(chrono::Utc::now());
470        let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
471        let _ = task::save_tasks(&self.storage_path, &tasks);
472        (format!("task {task_id} finished by {}", ctx.sender), true)
473    }
474}
475
476impl Plugin for TaskboardPlugin {
477    fn name(&self) -> &str {
478        "taskboard"
479    }
480
481    fn commands(&self) -> Vec<CommandInfo> {
482        Self::default_commands()
483    }
484
485    fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
486        Box::pin(async move {
487            let action = ctx.params.first().map(String::as_str).unwrap_or("");
488            let (result, broadcast) = match action {
489                "post" => self.handle_post(&ctx),
490                "list" => (self.handle_list(), false),
491                "claim" => self.handle_claim(&ctx),
492                "plan" => self.handle_plan(&ctx),
493                "approve" => self.handle_approve(&ctx),
494                "show" => (self.handle_show(&ctx), false),
495                "update" => self.handle_update(&ctx),
496                "release" => self.handle_release(&ctx),
497                "finish" => self.handle_finish(&ctx),
498                "" => ("usage: /taskboard <post|list|show|claim|plan|approve|update|release|finish> [args...]".to_owned(), false),
499                other => (format!("unknown action: {other}. use: post, list, show, claim, plan, approve, update, release, finish"), false),
500            };
501            if broadcast {
502                Ok(PluginResult::Broadcast(result))
503            } else {
504                Ok(PluginResult::Reply(result))
505            }
506        })
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    fn make_plugin() -> (TaskboardPlugin, tempfile::NamedTempFile) {
515        let tmp = tempfile::NamedTempFile::new().unwrap();
516        let plugin = TaskboardPlugin::new(tmp.path().to_path_buf(), Some(600));
517        (plugin, tmp)
518    }
519
520    #[test]
521    fn plugin_name() {
522        let (plugin, _tmp) = make_plugin();
523        assert_eq!(plugin.name(), "taskboard");
524    }
525
526    #[test]
527    fn plugin_commands() {
528        let (plugin, _tmp) = make_plugin();
529        let cmds = plugin.commands();
530        assert_eq!(cmds.len(), 1);
531        assert_eq!(cmds[0].name, "taskboard");
532        assert_eq!(cmds[0].params.len(), 2);
533        if let ParamType::Choice(ref choices) = cmds[0].params[0].param_type {
534            assert!(choices.contains(&"post".to_owned()));
535            assert!(choices.contains(&"approve".to_owned()));
536            assert_eq!(choices.len(), 9);
537        } else {
538            panic!("expected Choice param type");
539        }
540    }
541
542    #[test]
543    fn handle_post_creates_task() {
544        let (plugin, _tmp) = make_plugin();
545        let ctx = test_ctx("alice", &["post", "fix the bug"]);
546        let (result, broadcast) = plugin.handle_post(&ctx);
547        assert!(result.contains("tb-001"));
548        assert!(result.contains("fix the bug"));
549        assert!(broadcast);
550        let board = plugin.board.lock().unwrap();
551        assert_eq!(board.len(), 1);
552        assert_eq!(board[0].task.status, TaskStatus::Open);
553    }
554
555    #[test]
556    fn handle_post_empty_description() {
557        let (plugin, _tmp) = make_plugin();
558        let ctx = test_ctx("alice", &["post"]);
559        let (result, broadcast) = plugin.handle_post(&ctx);
560        assert!(result.contains("usage"));
561        assert!(!broadcast);
562    }
563
564    #[test]
565    fn handle_claim_and_plan_flow() {
566        let (plugin, _tmp) = make_plugin();
567        // Post a task.
568        plugin.handle_post(&test_ctx("ba", &["post", "implement feature"]));
569        // Claim it.
570        let (result, broadcast) = plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
571        assert!(result.contains("claimed by agent"));
572        assert!(broadcast);
573        // Submit plan.
574        let (result, broadcast) = plugin.handle_plan(&test_ctx(
575            "agent",
576            &["plan", "tb-001", "add struct, write tests"],
577        ));
578        assert!(result.contains("plan submitted"));
579        assert!(result.contains("plan: add struct, write tests"));
580        assert!(broadcast);
581        let board = plugin.board.lock().unwrap();
582        assert_eq!(board[0].task.status, TaskStatus::Planned);
583        assert_eq!(
584            board[0].task.plan.as_deref(),
585            Some("add struct, write tests")
586        );
587    }
588
589    #[test]
590    fn handle_approve_by_poster() {
591        let (plugin, _tmp) = make_plugin();
592        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
593        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
594        plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "my plan"]));
595        // Poster (ba) can approve without being host.
596        let (result, broadcast) =
597            plugin.handle_approve(&test_ctx_with_host("ba", &["approve", "tb-001"], None));
598        assert!(result.contains("approved"));
599        assert!(broadcast);
600        let board = plugin.board.lock().unwrap();
601        assert_eq!(board[0].task.status, TaskStatus::Approved);
602    }
603
604    #[test]
605    fn handle_approve_by_host() {
606        let (plugin, _tmp) = make_plugin();
607        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
608        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
609        plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "my plan"]));
610        // Host can approve even if not the poster.
611        let (result, broadcast) = plugin.handle_approve(&test_ctx_with_host(
612            "joao",
613            &["approve", "tb-001"],
614            Some("joao"),
615        ));
616        assert!(result.contains("approved"));
617        assert!(broadcast);
618    }
619
620    #[test]
621    fn handle_approve_rejected_for_non_poster_non_host() {
622        let (plugin, _tmp) = make_plugin();
623        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
624        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
625        plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "my plan"]));
626        // Random user (not poster, not host) cannot approve.
627        let (result, broadcast) = plugin.handle_approve(&test_ctx_with_host(
628            "random",
629            &["approve", "tb-001"],
630            Some("joao"),
631        ));
632        assert!(result.contains("only the task poster or host"));
633        assert!(!broadcast);
634    }
635
636    #[test]
637    fn handle_update_renews_lease() {
638        let (plugin, _tmp) = make_plugin();
639        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
640        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
641        let (result, broadcast) =
642            plugin.handle_update(&test_ctx("agent", &["update", "tb-001", "progress note"]));
643        assert!(result.contains("lease renewed"));
644        assert!(result.contains("warning")); // not approved yet
645        assert!(broadcast);
646        let board = plugin.board.lock().unwrap();
647        assert_eq!(board[0].task.notes.as_deref(), Some("progress note"));
648    }
649
650    #[test]
651    fn handle_update_no_warning_when_approved() {
652        let (plugin, _tmp) = make_plugin();
653        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
654        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
655        plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "plan"]));
656        plugin.handle_approve(&test_ctx_with_host(
657            "ba",
658            &["approve", "tb-001"],
659            Some("ba"),
660        ));
661        let (result, broadcast) = plugin.handle_update(&test_ctx("agent", &["update", "tb-001"]));
662        assert!(result.contains("lease renewed"));
663        assert!(!result.contains("warning"));
664        assert!(broadcast);
665    }
666
667    #[test]
668    fn handle_release_back_to_open() {
669        let (plugin, _tmp) = make_plugin();
670        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
671        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
672        let (result, broadcast) = plugin.handle_release(&test_ctx("agent", &["release", "tb-001"]));
673        assert!(result.contains("released"));
674        assert!(broadcast);
675        let board = plugin.board.lock().unwrap();
676        assert_eq!(board[0].task.status, TaskStatus::Open);
677        assert!(board[0].task.assigned_to.is_none());
678    }
679
680    #[test]
681    fn handle_finish() {
682        let (plugin, _tmp) = make_plugin();
683        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
684        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
685        let (result, broadcast) = plugin.handle_finish(&test_ctx("agent", &["finish", "tb-001"]));
686        assert!(result.contains("finished"));
687        assert!(broadcast);
688        let board = plugin.board.lock().unwrap();
689        assert_eq!(board[0].task.status, TaskStatus::Finished);
690    }
691
692    #[test]
693    fn handle_claim_wrong_status() {
694        let (plugin, _tmp) = make_plugin();
695        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
696        plugin.handle_claim(&test_ctx("a", &["claim", "tb-001"]));
697        let (result, broadcast) = plugin.handle_claim(&test_ctx("b", &["claim", "tb-001"]));
698        assert!(result.contains("must be open"));
699        assert!(!broadcast);
700    }
701
702    #[test]
703    fn handle_plan_wrong_user() {
704        let (plugin, _tmp) = make_plugin();
705        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
706        plugin.handle_claim(&test_ctx("agent-a", &["claim", "tb-001"]));
707        let (result, broadcast) =
708            plugin.handle_plan(&test_ctx("agent-b", &["plan", "tb-001", "my plan"]));
709        assert!(result.contains("assigned to someone else"));
710        assert!(!broadcast);
711    }
712
713    #[test]
714    fn handle_list_shows_tasks() {
715        let (plugin, _tmp) = make_plugin();
716        plugin.handle_post(&test_ctx("ba", &["post", "first task"]));
717        plugin.handle_post(&test_ctx("ba", &["post", "second task"]));
718        let result = plugin.handle_list();
719        assert!(result.contains("tb-001"));
720        assert!(result.contains("tb-002"));
721        assert!(result.contains("first task"));
722    }
723
724    #[test]
725    fn handle_list_empty() {
726        let (plugin, _tmp) = make_plugin();
727        let result = plugin.handle_list();
728        assert_eq!(result, "taskboard is empty");
729    }
730
731    #[test]
732    fn handle_show_displays_full_detail() {
733        let (plugin, _tmp) = make_plugin();
734        plugin.handle_post(&test_ctx("ba", &["post", "build the feature"]));
735        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
736        plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "add struct, tests"]));
737        let result = plugin.handle_show(&test_ctx("anyone", &["show", "tb-001"]));
738        assert!(result.contains("tb-001"));
739        assert!(result.contains("planned"));
740        assert!(result.contains("build the feature"));
741        assert!(result.contains("agent"));
742        assert!(result.contains("add struct, tests"));
743        assert!(result.contains("ba")); // posted by
744    }
745
746    #[test]
747    fn handle_show_not_found() {
748        let (plugin, _tmp) = make_plugin();
749        let result = plugin.handle_show(&test_ctx("a", &["show", "tb-999"]));
750        assert!(result.contains("not found"));
751    }
752
753    #[test]
754    fn handle_show_no_args() {
755        let (plugin, _tmp) = make_plugin();
756        let result = plugin.handle_show(&test_ctx("a", &["show"]));
757        assert!(result.contains("usage"));
758    }
759
760    #[test]
761    fn handle_not_found() {
762        let (plugin, _tmp) = make_plugin();
763        let (result, broadcast) = plugin.handle_claim(&test_ctx("a", &["claim", "tb-999"]));
764        assert!(result.contains("not found"));
765        assert!(!broadcast);
766    }
767
768    #[test]
769    fn persistence_survives_reload() {
770        let tmp = tempfile::NamedTempFile::new().unwrap();
771        let path = tmp.path().to_path_buf();
772        {
773            let plugin = TaskboardPlugin::new(path.clone(), Some(600));
774            plugin.handle_post(&test_ctx("ba", &["post", "persistent task"]));
775            plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
776        }
777        // Reload from disk.
778        let plugin2 = TaskboardPlugin::new(path, Some(600));
779        let board = plugin2.board.lock().unwrap();
780        assert_eq!(board.len(), 1);
781        assert_eq!(board[0].task.id, "tb-001");
782        assert_eq!(board[0].task.status, TaskStatus::Claimed);
783    }
784
785    #[test]
786    fn lease_expiry_on_list() {
787        let (plugin, _tmp) = make_plugin();
788        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
789        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
790        // Force lease to the past.
791        {
792            let mut board = plugin.board.lock().unwrap();
793            board[0].lease_start =
794                Some(std::time::Instant::now() - std::time::Duration::from_secs(700));
795        }
796        let result = plugin.handle_list();
797        assert!(result.contains("expired"));
798        let board = plugin.board.lock().unwrap();
799        assert_eq!(board[0].task.status, TaskStatus::Open);
800    }
801
802    #[test]
803    fn full_lifecycle() {
804        let (plugin, _tmp) = make_plugin();
805        // post → claim → plan → approve → update → finish
806        plugin.handle_post(&test_ctx("ba", &["post", "implement #42"]));
807        plugin.handle_claim(&test_ctx("saphire", &["claim", "tb-001"]));
808        plugin.handle_plan(&test_ctx(
809            "saphire",
810            &["plan", "tb-001", "add Foo, write tests"],
811        ));
812        plugin.handle_approve(&test_ctx_with_host(
813            "ba",
814            &["approve", "tb-001"],
815            Some("ba"),
816        ));
817        plugin.handle_update(&test_ctx("saphire", &["update", "tb-001", "tests passing"]));
818        plugin.handle_finish(&test_ctx("saphire", &["finish", "tb-001"]));
819        let board = plugin.board.lock().unwrap();
820        assert_eq!(board[0].task.status, TaskStatus::Finished);
821    }
822
823    #[test]
824    fn taskboard_path_from_chat_replaces_extension() {
825        let chat = PathBuf::from("/data/room-dev.chat");
826        let tb = TaskboardPlugin::taskboard_path_from_chat(&chat);
827        assert_eq!(tb, PathBuf::from("/data/room-dev.taskboard"));
828    }
829
830    #[test]
831    fn default_commands_matches_commands() {
832        let (plugin, _tmp) = make_plugin();
833        let default = TaskboardPlugin::default_commands();
834        let instance = plugin.commands();
835        assert_eq!(default.len(), instance.len());
836        assert_eq!(default[0].name, instance[0].name);
837        assert_eq!(default[0].params.len(), instance[0].params.len());
838    }
839
840    // ── Test helpers ────────────────────────────────────────────────────────
841
842    fn test_ctx(sender: &str, params: &[&str]) -> CommandContext {
843        test_ctx_with_host(sender, params, None)
844    }
845
846    fn test_ctx_with_host(sender: &str, params: &[&str], host: Option<&str>) -> CommandContext {
847        use std::collections::HashMap;
848        use std::sync::atomic::AtomicU64;
849
850        use crate::plugin::{ChatWriter, RoomMetadata, UserInfo};
851
852        let clients = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
853        let chat_path = Arc::new(PathBuf::from("/dev/null"));
854        let room_id = Arc::new("test-room".to_owned());
855        let seq_counter = Arc::new(AtomicU64::new(0));
856        let writer = ChatWriter::new(&clients, &chat_path, &room_id, &seq_counter, "taskboard");
857
858        CommandContext {
859            command: "taskboard".to_owned(),
860            params: params.iter().map(|s| s.to_string()).collect(),
861            sender: sender.to_owned(),
862            room_id: "test-room".to_owned(),
863            message_id: "msg-001".to_owned(),
864            timestamp: chrono::Utc::now(),
865            history: crate::plugin::HistoryReader::new(std::path::Path::new("/dev/null"), sender),
866            writer,
867            metadata: RoomMetadata {
868                online_users: vec![UserInfo {
869                    username: sender.to_owned(),
870                    status: String::new(),
871                }],
872                host: host.map(|h| h.to_owned()),
873                message_count: 0,
874            },
875            available_commands: vec![],
876        }
877    }
878}