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 room_protocol::EventType;
10
11use crate::plugin::{
12    BoxFuture, CommandContext, CommandInfo, ParamSchema, ParamType, Plugin, PluginResult,
13};
14
15/// Default lease TTL in seconds (10 minutes).
16const DEFAULT_LEASE_TTL_SECS: u64 = 600;
17
18/// Unified task lifecycle plugin with lease-based expiry.
19///
20/// Manages a board of tasks that agents can post, claim, plan, get approved,
21/// update, release, and finish. Claimed tasks have a configurable lease TTL —
22/// if not renewed via `/taskboard update` or `/taskboard plan`, they auto-
23/// release back to open status (lazy sweep on access).
24pub struct TaskboardPlugin {
25    /// In-memory task board with lease timers.
26    board: Arc<Mutex<Vec<LiveTask>>>,
27    /// Path to the NDJSON persistence file.
28    storage_path: PathBuf,
29    /// Lease TTL duration.
30    lease_ttl: Duration,
31}
32
33impl TaskboardPlugin {
34    /// Create a new taskboard plugin, loading existing tasks from disk.
35    pub fn new(storage_path: PathBuf, lease_ttl_secs: Option<u64>) -> Self {
36        let ttl = lease_ttl_secs.unwrap_or(DEFAULT_LEASE_TTL_SECS);
37        let tasks = task::load_tasks(&storage_path);
38        let live_tasks: Vec<LiveTask> = tasks.into_iter().map(LiveTask::new).collect();
39        Self {
40            board: Arc::new(Mutex::new(live_tasks)),
41            storage_path,
42            lease_ttl: Duration::from_secs(ttl),
43        }
44    }
45
46    /// Derive the `.taskboard` file path from a `.chat` file path.
47    pub fn taskboard_path_from_chat(chat_path: &std::path::Path) -> PathBuf {
48        chat_path.with_extension("taskboard")
49    }
50
51    /// Returns the command info for the TUI command palette without needing
52    /// an instantiated plugin. Used by `all_known_commands()`.
53    pub fn default_commands() -> Vec<CommandInfo> {
54        vec![CommandInfo {
55            name: "taskboard".to_owned(),
56            description:
57                "Manage task lifecycle — post, list, show, claim, assign, plan, approve, update, release, finish, cancel"
58                    .to_owned(),
59            usage: "/taskboard <action> [args...]".to_owned(),
60            params: vec![
61                ParamSchema {
62                    name: "action".to_owned(),
63                    param_type: ParamType::Choice(vec![
64                        "post".to_owned(),
65                        "list".to_owned(),
66                        "show".to_owned(),
67                        "claim".to_owned(),
68                        "assign".to_owned(),
69                        "plan".to_owned(),
70                        "approve".to_owned(),
71                        "update".to_owned(),
72                        "release".to_owned(),
73                        "finish".to_owned(),
74                        "cancel".to_owned(),
75                    ]),
76                    required: true,
77                    description: "Subcommand".to_owned(),
78                },
79                ParamSchema {
80                    name: "args".to_owned(),
81                    param_type: ParamType::Text,
82                    required: false,
83                    description: "Task ID or description".to_owned(),
84                },
85            ],
86        }]
87    }
88
89    /// Sweep expired leases (lazy — called before reads).
90    fn sweep_expired(&self) -> Vec<String> {
91        let mut board = self.board.lock().unwrap();
92        let ttl = self.lease_ttl.as_secs();
93        let mut expired_ids = Vec::new();
94        for lt in board.iter_mut() {
95            if lt.is_expired(ttl)
96                && matches!(
97                    lt.task.status,
98                    TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
99                )
100            {
101                let prev_assignee = lt.task.assigned_to.clone().unwrap_or_default();
102                expired_ids.push(format!("{} (was {})", lt.task.id, prev_assignee));
103                lt.expire();
104            }
105        }
106        if !expired_ids.is_empty() {
107            let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
108            let _ = task::save_tasks(&self.storage_path, &tasks);
109        }
110        expired_ids
111    }
112
113    fn handle_post(&self, ctx: &CommandContext) -> (String, bool) {
114        let description = ctx.params[1..].join(" ");
115        if description.is_empty() {
116            return ("usage: /taskboard post <description>".to_owned(), false);
117        }
118        let mut board = self.board.lock().unwrap();
119        let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
120        let id = next_id(&tasks);
121        let task = Task {
122            id: id.clone(),
123            description: description.clone(),
124            status: TaskStatus::Open,
125            posted_by: ctx.sender.clone(),
126            assigned_to: None,
127            posted_at: chrono::Utc::now(),
128            claimed_at: None,
129            plan: None,
130            approved_by: None,
131            approved_at: None,
132            updated_at: None,
133            notes: None,
134        };
135        board.push(LiveTask::new(task.clone()));
136        let all_tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
137        let _ = task::save_tasks(&self.storage_path, &all_tasks);
138        (format!("task {id} posted: {description}"), true)
139    }
140
141    fn handle_list(&self) -> String {
142        let expired = self.sweep_expired();
143        let board = self.board.lock().unwrap();
144        if board.is_empty() {
145            return "taskboard is empty".to_owned();
146        }
147        let mut lines = Vec::new();
148        if !expired.is_empty() {
149            lines.push(format!("expired: {}", expired.join(", ")));
150        }
151        lines.push(format!(
152            "{:<8} {:<10} {:<12} {:<12} {}",
153            "ID", "STATUS", "ASSIGNEE", "ELAPSED", "DESCRIPTION"
154        ));
155        for lt in board.iter() {
156            let elapsed = match lt.lease_start {
157                Some(start) => {
158                    let secs = start.elapsed().as_secs();
159                    if secs < 60 {
160                        format!("{secs}s")
161                    } else {
162                        format!("{}m", secs / 60)
163                    }
164                }
165                None => "-".to_owned(),
166            };
167            let assignee = lt.task.assigned_to.as_deref().unwrap_or("-").to_owned();
168            let desc = if lt.task.description.len() > 40 {
169                format!("{}...", &lt.task.description[..37])
170            } else {
171                lt.task.description.clone()
172            };
173            lines.push(format!(
174                "{:<8} {:<10} {:<12} {:<12} {}",
175                lt.task.id, lt.task.status, assignee, elapsed, desc
176            ));
177        }
178        lines.join("\n")
179    }
180
181    fn handle_claim(&self, ctx: &CommandContext) -> (String, bool) {
182        let task_id = match ctx.params.get(1) {
183            Some(id) => id,
184            None => return ("usage: /taskboard claim <task-id>".to_owned(), false),
185        };
186        self.sweep_expired();
187        let mut board = self.board.lock().unwrap();
188        let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
189            Some(lt) => lt,
190            None => return (format!("task {task_id} not found"), false),
191        };
192        if lt.task.status != TaskStatus::Open {
193            return (
194                format!(
195                    "task {task_id} is {} (must be open to claim)",
196                    lt.task.status
197                ),
198                false,
199            );
200        }
201        lt.task.status = TaskStatus::Claimed;
202        lt.task.assigned_to = Some(ctx.sender.clone());
203        lt.task.claimed_at = Some(chrono::Utc::now());
204        lt.renew_lease();
205        let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
206        let _ = task::save_tasks(&self.storage_path, &tasks);
207        (
208            format!(
209                "task {task_id} claimed by {} — submit plan with /taskboard plan {task_id} <plan>",
210                ctx.sender
211            ),
212            true,
213        )
214    }
215
216    fn handle_plan(&self, ctx: &CommandContext) -> (String, bool) {
217        let task_id = match ctx.params.get(1) {
218            Some(id) => id,
219            None => {
220                return (
221                    "usage: /taskboard plan <task-id> <plan text>".to_owned(),
222                    false,
223                )
224            }
225        };
226        let plan_text = ctx.params[2..].join(" ");
227        if plan_text.is_empty() {
228            return (
229                "usage: /taskboard plan <task-id> <plan text>".to_owned(),
230                false,
231            );
232        }
233        self.sweep_expired();
234        let mut board = self.board.lock().unwrap();
235        let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
236            Some(lt) => lt,
237            None => return (format!("task {task_id} not found"), false),
238        };
239        if !matches!(lt.task.status, TaskStatus::Claimed | TaskStatus::Planned) {
240            return (
241                format!(
242                    "task {task_id} is {} (must be claimed to submit plan)",
243                    lt.task.status
244                ),
245                false,
246            );
247        }
248        if lt.task.assigned_to.as_deref() != Some(&ctx.sender) {
249            return (format!("task {task_id} is assigned to someone else"), false);
250        }
251        lt.task.status = TaskStatus::Planned;
252        lt.task.plan = Some(plan_text.clone());
253        lt.renew_lease();
254        let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
255        let _ = task::save_tasks(&self.storage_path, &tasks);
256        (
257            format!("task {task_id} plan submitted — awaiting approval\nplan: {plan_text}"),
258            true,
259        )
260    }
261
262    fn handle_approve(&self, ctx: &CommandContext) -> (String, bool) {
263        let task_id = match ctx.params.get(1) {
264            Some(id) => id,
265            None => return ("usage: /taskboard approve <task-id>".to_owned(), false),
266        };
267        self.sweep_expired();
268        let mut board = self.board.lock().unwrap();
269        let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
270            Some(lt) => lt,
271            None => return (format!("task {task_id} not found"), false),
272        };
273        if lt.task.status != TaskStatus::Planned {
274            return (
275                format!(
276                    "task {task_id} is {} (must be planned to approve)",
277                    lt.task.status
278                ),
279                false,
280            );
281        }
282        // Poster or host can approve.
283        let is_poster = lt.task.posted_by == ctx.sender;
284        let is_host = ctx.metadata.host.as_deref() == Some(&ctx.sender);
285        if !is_poster && !is_host {
286            return ("only the task poster or host can approve".to_owned(), false);
287        }
288        lt.task.status = TaskStatus::Approved;
289        lt.task.approved_by = Some(ctx.sender.clone());
290        lt.task.approved_at = Some(chrono::Utc::now());
291        lt.renew_lease();
292        let assignee = lt
293            .task
294            .assigned_to
295            .as_deref()
296            .unwrap_or("unknown")
297            .to_owned();
298        let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
299        let _ = task::save_tasks(&self.storage_path, &tasks);
300        (
301            format!(
302                "task {task_id} approved by {} — @{assignee} proceed with implementation",
303                ctx.sender
304            ),
305            true,
306        )
307    }
308
309    fn handle_update(&self, ctx: &CommandContext) -> (String, bool) {
310        let task_id = match ctx.params.get(1) {
311            Some(id) => id,
312            None => {
313                return (
314                    "usage: /taskboard update <task-id> [notes]".to_owned(),
315                    false,
316                )
317            }
318        };
319        let notes = if ctx.params.len() > 2 {
320            Some(ctx.params[2..].join(" "))
321        } else {
322            None
323        };
324        self.sweep_expired();
325        let mut board = self.board.lock().unwrap();
326        let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
327            Some(lt) => lt,
328            None => return (format!("task {task_id} not found"), false),
329        };
330        if !matches!(
331            lt.task.status,
332            TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
333        ) {
334            return (
335                format!(
336                    "task {task_id} is {} (must be claimed/planned/approved to update)",
337                    lt.task.status
338                ),
339                false,
340            );
341        }
342        if lt.task.assigned_to.as_deref() != Some(&ctx.sender) {
343            return (format!("task {task_id} is assigned to someone else"), false);
344        }
345        let mut warning = String::new();
346        if lt.task.status != TaskStatus::Approved {
347            warning = format!(" [warning: task is {} — not yet approved]", lt.task.status);
348        }
349        if let Some(n) = notes {
350            lt.task.notes = Some(n);
351        }
352        lt.renew_lease();
353        let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
354        let _ = task::save_tasks(&self.storage_path, &tasks);
355        (
356            format!("task {task_id} updated, lease renewed{warning}"),
357            true,
358        )
359    }
360
361    fn handle_release(&self, ctx: &CommandContext) -> (String, bool) {
362        let task_id = match ctx.params.get(1) {
363            Some(id) => id,
364            None => return ("usage: /taskboard release <task-id>".to_owned(), false),
365        };
366        self.sweep_expired();
367        let mut board = self.board.lock().unwrap();
368        let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
369            Some(lt) => lt,
370            None => return (format!("task {task_id} not found"), false),
371        };
372        if !matches!(
373            lt.task.status,
374            TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
375        ) {
376            return (
377                format!(
378                    "task {task_id} is {} (must be claimed/planned/approved to release)",
379                    lt.task.status
380                ),
381                false,
382            );
383        }
384        // Allow owner or host to release.
385        if lt.task.assigned_to.as_deref() != Some(&ctx.sender)
386            && ctx.metadata.host.as_deref() != Some(&ctx.sender)
387        {
388            return (
389                format!("task {task_id} can only be released by the assignee or host"),
390                false,
391            );
392        }
393        let prev = lt.task.assigned_to.clone().unwrap_or_default();
394        lt.task.status = TaskStatus::Open;
395        lt.task.assigned_to = None;
396        lt.task.claimed_at = None;
397        lt.task.plan = None;
398        lt.task.approved_by = None;
399        lt.task.approved_at = None;
400        lt.lease_start = None;
401        let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
402        let _ = task::save_tasks(&self.storage_path, &tasks);
403        (
404            format!("task {task_id} released by {prev} — back to open"),
405            true,
406        )
407    }
408
409    fn handle_assign(&self, ctx: &CommandContext) -> (String, bool) {
410        let task_id = match ctx.params.get(1) {
411            Some(id) => id,
412            None => {
413                return (
414                    "usage: /taskboard assign <task-id> <username>".to_owned(),
415                    false,
416                )
417            }
418        };
419        let target_user = match ctx.params.get(2) {
420            Some(u) => u,
421            None => {
422                return (
423                    "usage: /taskboard assign <task-id> <username>".to_owned(),
424                    false,
425                )
426            }
427        };
428        self.sweep_expired();
429        let mut board = self.board.lock().unwrap();
430        let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
431            Some(lt) => lt,
432            None => return (format!("task {task_id} not found"), false),
433        };
434        if lt.task.status != TaskStatus::Open {
435            return (
436                format!(
437                    "task {task_id} is {} (must be open to assign)",
438                    lt.task.status
439                ),
440                false,
441            );
442        }
443        // Only poster or host can assign.
444        let is_poster = lt.task.posted_by == ctx.sender;
445        let is_host = ctx.metadata.host.as_deref() == Some(&ctx.sender);
446        if !is_poster && !is_host {
447            return ("only the task poster or host can assign".to_owned(), false);
448        }
449        lt.task.status = TaskStatus::Claimed;
450        lt.task.assigned_to = Some(target_user.clone());
451        lt.task.claimed_at = Some(chrono::Utc::now());
452        lt.renew_lease();
453        let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
454        let _ = task::save_tasks(&self.storage_path, &tasks);
455        (
456            format!("task {task_id} assigned to {target_user} by {}", ctx.sender),
457            true,
458        )
459    }
460
461    fn handle_show(&self, ctx: &CommandContext) -> String {
462        let task_id = match ctx.params.get(1) {
463            Some(id) => id,
464            None => return "usage: /taskboard show <task-id>".to_owned(),
465        };
466        self.sweep_expired();
467        let board = self.board.lock().unwrap();
468        let lt = match board.iter().find(|lt| lt.task.id == *task_id) {
469            Some(lt) => lt,
470            None => return format!("task {task_id} not found"),
471        };
472        let t = &lt.task;
473        let assignee = t.assigned_to.as_deref().unwrap_or("-");
474        let plan = t.plan.as_deref().unwrap_or("-");
475        let approved_by = t.approved_by.as_deref().unwrap_or("-");
476        let notes = t.notes.as_deref().unwrap_or("-");
477        let elapsed = match lt.lease_start {
478            Some(start) => {
479                let secs = start.elapsed().as_secs();
480                if secs < 60 {
481                    format!("{secs}s")
482                } else {
483                    format!("{}m", secs / 60)
484                }
485            }
486            None => "-".to_owned(),
487        };
488        format!(
489            "task {}\n  status:      {}\n  description: {}\n  posted by:   {}\n  assigned to: {}\n  plan:        {}\n  approved by: {}\n  notes:       {}\n  lease:       {}",
490            t.id, t.status, t.description, t.posted_by, assignee, plan, approved_by, notes, elapsed
491        )
492    }
493
494    fn handle_finish(&self, ctx: &CommandContext) -> (String, bool) {
495        let task_id = match ctx.params.get(1) {
496            Some(id) => id,
497            None => return ("usage: /taskboard finish <task-id>".to_owned(), false),
498        };
499        self.sweep_expired();
500        let mut board = self.board.lock().unwrap();
501        let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
502            Some(lt) => lt,
503            None => return (format!("task {task_id} not found"), false),
504        };
505        if !matches!(
506            lt.task.status,
507            TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
508        ) {
509            return (
510                format!(
511                    "task {task_id} is {} (must be claimed/planned/approved to finish)",
512                    lt.task.status
513                ),
514                false,
515            );
516        }
517        if lt.task.assigned_to.as_deref() != Some(&ctx.sender) {
518            return (
519                format!("task {task_id} can only be finished by the assignee"),
520                false,
521            );
522        }
523        lt.task.status = TaskStatus::Finished;
524        lt.lease_start = None;
525        lt.task.updated_at = Some(chrono::Utc::now());
526        let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
527        let _ = task::save_tasks(&self.storage_path, &tasks);
528        (format!("task {task_id} finished by {}", ctx.sender), true)
529    }
530
531    fn handle_cancel(&self, ctx: &CommandContext) -> (String, bool) {
532        let task_id = match ctx.params.get(1) {
533            Some(id) => id,
534            None => {
535                return (
536                    "usage: /taskboard cancel <task-id> [reason]".to_owned(),
537                    false,
538                )
539            }
540        };
541        self.sweep_expired();
542        let mut board = self.board.lock().unwrap();
543        let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
544            Some(lt) => lt,
545            None => return (format!("task {task_id} not found"), false),
546        };
547        if matches!(lt.task.status, TaskStatus::Finished | TaskStatus::Cancelled) {
548            return (
549                format!("task {task_id} is {} (cannot cancel)", lt.task.status),
550                false,
551            );
552        }
553        // Permission: poster, assignee, or host can cancel.
554        let is_poster = lt.task.posted_by == ctx.sender;
555        let is_assignee = lt.task.assigned_to.as_deref() == Some(&ctx.sender);
556        let is_host = ctx.metadata.host.as_deref() == Some(&ctx.sender);
557        if !is_poster && !is_assignee && !is_host {
558            return (
559                format!("task {task_id} can only be cancelled by the poster, assignee, or host"),
560                false,
561            );
562        }
563        lt.task.status = TaskStatus::Cancelled;
564        lt.lease_start = None;
565        let reason: String = ctx
566            .params
567            .iter()
568            .skip(2)
569            .cloned()
570            .collect::<Vec<_>>()
571            .join(" ");
572        lt.task.notes = Some(if reason.is_empty() {
573            format!("cancelled by {}", ctx.sender)
574        } else {
575            format!("cancelled by {}: {reason}", ctx.sender)
576        });
577        lt.task.updated_at = Some(chrono::Utc::now());
578        let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
579        let _ = task::save_tasks(&self.storage_path, &tasks);
580        let msg = if reason.is_empty() {
581            format!("task {task_id} cancelled by {}", ctx.sender)
582        } else {
583            format!("task {task_id} cancelled by {} — {reason}", ctx.sender)
584        };
585        (msg, true)
586    }
587}
588
589impl Plugin for TaskboardPlugin {
590    fn name(&self) -> &str {
591        "taskboard"
592    }
593
594    fn commands(&self) -> Vec<CommandInfo> {
595        Self::default_commands()
596    }
597
598    fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
599        Box::pin(async move {
600            let action = ctx.params.first().map(String::as_str).unwrap_or("");
601            let (result, broadcast) = match action {
602                "post" => self.handle_post(&ctx),
603                "list" => (self.handle_list(), false),
604                "claim" => self.handle_claim(&ctx),
605                "assign" => self.handle_assign(&ctx),
606                "plan" => self.handle_plan(&ctx),
607                "approve" => self.handle_approve(&ctx),
608                "show" => (self.handle_show(&ctx), false),
609                "update" => self.handle_update(&ctx),
610                "release" => self.handle_release(&ctx),
611                "finish" => self.handle_finish(&ctx),
612                "cancel" => self.handle_cancel(&ctx),
613                "" => ("usage: /taskboard <post|list|show|claim|assign|plan|approve|update|release|finish|cancel> [args...]".to_owned(), false),
614                other => (format!("unknown action: {other}. use: post, list, show, claim, assign, plan, approve, update, release, finish, cancel"), false),
615            };
616            if broadcast {
617                // Emit a typed event alongside the system broadcast.
618                let event_type = match action {
619                    "post" => Some(EventType::TaskPosted),
620                    "claim" => Some(EventType::TaskClaimed),
621                    "assign" => Some(EventType::TaskAssigned),
622                    "plan" => Some(EventType::TaskPlanned),
623                    "approve" => Some(EventType::TaskApproved),
624                    "update" => Some(EventType::TaskUpdated),
625                    "release" => Some(EventType::TaskReleased),
626                    "finish" => Some(EventType::TaskFinished),
627                    "cancel" => Some(EventType::TaskCancelled),
628                    _ => None,
629                };
630                if let Some(et) = event_type {
631                    let _ = ctx.writer.emit_event(et, &result, None).await;
632                }
633                Ok(PluginResult::Broadcast(result))
634            } else {
635                Ok(PluginResult::Reply(result))
636            }
637        })
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    fn make_plugin() -> (TaskboardPlugin, tempfile::NamedTempFile) {
646        let tmp = tempfile::NamedTempFile::new().unwrap();
647        let plugin = TaskboardPlugin::new(tmp.path().to_path_buf(), Some(600));
648        (plugin, tmp)
649    }
650
651    #[test]
652    fn plugin_name() {
653        let (plugin, _tmp) = make_plugin();
654        assert_eq!(plugin.name(), "taskboard");
655    }
656
657    #[test]
658    fn plugin_commands() {
659        let (plugin, _tmp) = make_plugin();
660        let cmds = plugin.commands();
661        assert_eq!(cmds.len(), 1);
662        assert_eq!(cmds[0].name, "taskboard");
663        assert_eq!(cmds[0].params.len(), 2);
664        if let ParamType::Choice(ref choices) = cmds[0].params[0].param_type {
665            assert!(choices.contains(&"post".to_owned()));
666            assert!(choices.contains(&"approve".to_owned()));
667            assert!(choices.contains(&"assign".to_owned()));
668            assert_eq!(choices.len(), 11);
669        } else {
670            panic!("expected Choice param type");
671        }
672    }
673
674    #[test]
675    fn handle_post_creates_task() {
676        let (plugin, _tmp) = make_plugin();
677        let ctx = test_ctx("alice", &["post", "fix the bug"]);
678        let (result, broadcast) = plugin.handle_post(&ctx);
679        assert!(result.contains("tb-001"));
680        assert!(result.contains("fix the bug"));
681        assert!(broadcast);
682        let board = plugin.board.lock().unwrap();
683        assert_eq!(board.len(), 1);
684        assert_eq!(board[0].task.status, TaskStatus::Open);
685    }
686
687    #[test]
688    fn handle_post_empty_description() {
689        let (plugin, _tmp) = make_plugin();
690        let ctx = test_ctx("alice", &["post"]);
691        let (result, broadcast) = plugin.handle_post(&ctx);
692        assert!(result.contains("usage"));
693        assert!(!broadcast);
694    }
695
696    #[test]
697    fn handle_claim_and_plan_flow() {
698        let (plugin, _tmp) = make_plugin();
699        // Post a task.
700        plugin.handle_post(&test_ctx("ba", &["post", "implement feature"]));
701        // Claim it.
702        let (result, broadcast) = plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
703        assert!(result.contains("claimed by agent"));
704        assert!(broadcast);
705        // Submit plan.
706        let (result, broadcast) = plugin.handle_plan(&test_ctx(
707            "agent",
708            &["plan", "tb-001", "add struct, write tests"],
709        ));
710        assert!(result.contains("plan submitted"));
711        assert!(result.contains("plan: add struct, write tests"));
712        assert!(broadcast);
713        let board = plugin.board.lock().unwrap();
714        assert_eq!(board[0].task.status, TaskStatus::Planned);
715        assert_eq!(
716            board[0].task.plan.as_deref(),
717            Some("add struct, write tests")
718        );
719    }
720
721    #[test]
722    fn handle_approve_by_poster() {
723        let (plugin, _tmp) = make_plugin();
724        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
725        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
726        plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "my plan"]));
727        // Poster (ba) can approve without being host.
728        let (result, broadcast) =
729            plugin.handle_approve(&test_ctx_with_host("ba", &["approve", "tb-001"], None));
730        assert!(result.contains("approved"));
731        assert!(broadcast);
732        let board = plugin.board.lock().unwrap();
733        assert_eq!(board[0].task.status, TaskStatus::Approved);
734    }
735
736    #[test]
737    fn handle_approve_by_host() {
738        let (plugin, _tmp) = make_plugin();
739        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
740        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
741        plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "my plan"]));
742        // Host can approve even if not the poster.
743        let (result, broadcast) = plugin.handle_approve(&test_ctx_with_host(
744            "joao",
745            &["approve", "tb-001"],
746            Some("joao"),
747        ));
748        assert!(result.contains("approved"));
749        assert!(broadcast);
750    }
751
752    #[test]
753    fn handle_approve_rejected_for_non_poster_non_host() {
754        let (plugin, _tmp) = make_plugin();
755        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
756        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
757        plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "my plan"]));
758        // Random user (not poster, not host) cannot approve.
759        let (result, broadcast) = plugin.handle_approve(&test_ctx_with_host(
760            "random",
761            &["approve", "tb-001"],
762            Some("joao"),
763        ));
764        assert!(result.contains("only the task poster or host"));
765        assert!(!broadcast);
766    }
767
768    #[test]
769    fn handle_update_renews_lease() {
770        let (plugin, _tmp) = make_plugin();
771        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
772        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
773        let (result, broadcast) =
774            plugin.handle_update(&test_ctx("agent", &["update", "tb-001", "progress note"]));
775        assert!(result.contains("lease renewed"));
776        assert!(result.contains("warning")); // not approved yet
777        assert!(broadcast);
778        let board = plugin.board.lock().unwrap();
779        assert_eq!(board[0].task.notes.as_deref(), Some("progress note"));
780    }
781
782    #[test]
783    fn handle_update_no_warning_when_approved() {
784        let (plugin, _tmp) = make_plugin();
785        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
786        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
787        plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "plan"]));
788        plugin.handle_approve(&test_ctx_with_host(
789            "ba",
790            &["approve", "tb-001"],
791            Some("ba"),
792        ));
793        let (result, broadcast) = plugin.handle_update(&test_ctx("agent", &["update", "tb-001"]));
794        assert!(result.contains("lease renewed"));
795        assert!(!result.contains("warning"));
796        assert!(broadcast);
797    }
798
799    #[test]
800    fn handle_release_back_to_open() {
801        let (plugin, _tmp) = make_plugin();
802        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
803        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
804        let (result, broadcast) = plugin.handle_release(&test_ctx("agent", &["release", "tb-001"]));
805        assert!(result.contains("released"));
806        assert!(broadcast);
807        let board = plugin.board.lock().unwrap();
808        assert_eq!(board[0].task.status, TaskStatus::Open);
809        assert!(board[0].task.assigned_to.is_none());
810    }
811
812    #[test]
813    fn handle_finish() {
814        let (plugin, _tmp) = make_plugin();
815        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
816        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
817        let (result, broadcast) = plugin.handle_finish(&test_ctx("agent", &["finish", "tb-001"]));
818        assert!(result.contains("finished"));
819        assert!(broadcast);
820        let board = plugin.board.lock().unwrap();
821        assert_eq!(board[0].task.status, TaskStatus::Finished);
822    }
823
824    #[test]
825    fn handle_claim_wrong_status() {
826        let (plugin, _tmp) = make_plugin();
827        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
828        plugin.handle_claim(&test_ctx("a", &["claim", "tb-001"]));
829        let (result, broadcast) = plugin.handle_claim(&test_ctx("b", &["claim", "tb-001"]));
830        assert!(result.contains("must be open"));
831        assert!(!broadcast);
832    }
833
834    #[test]
835    fn handle_plan_wrong_user() {
836        let (plugin, _tmp) = make_plugin();
837        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
838        plugin.handle_claim(&test_ctx("agent-a", &["claim", "tb-001"]));
839        let (result, broadcast) =
840            plugin.handle_plan(&test_ctx("agent-b", &["plan", "tb-001", "my plan"]));
841        assert!(result.contains("assigned to someone else"));
842        assert!(!broadcast);
843    }
844
845    #[test]
846    fn handle_list_shows_tasks() {
847        let (plugin, _tmp) = make_plugin();
848        plugin.handle_post(&test_ctx("ba", &["post", "first task"]));
849        plugin.handle_post(&test_ctx("ba", &["post", "second task"]));
850        let result = plugin.handle_list();
851        assert!(result.contains("tb-001"));
852        assert!(result.contains("tb-002"));
853        assert!(result.contains("first task"));
854    }
855
856    #[test]
857    fn handle_list_empty() {
858        let (plugin, _tmp) = make_plugin();
859        let result = plugin.handle_list();
860        assert_eq!(result, "taskboard is empty");
861    }
862
863    #[test]
864    fn handle_show_displays_full_detail() {
865        let (plugin, _tmp) = make_plugin();
866        plugin.handle_post(&test_ctx("ba", &["post", "build the feature"]));
867        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
868        plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "add struct, tests"]));
869        let result = plugin.handle_show(&test_ctx("anyone", &["show", "tb-001"]));
870        assert!(result.contains("tb-001"));
871        assert!(result.contains("planned"));
872        assert!(result.contains("build the feature"));
873        assert!(result.contains("agent"));
874        assert!(result.contains("add struct, tests"));
875        assert!(result.contains("ba")); // posted by
876    }
877
878    #[test]
879    fn handle_show_not_found() {
880        let (plugin, _tmp) = make_plugin();
881        let result = plugin.handle_show(&test_ctx("a", &["show", "tb-999"]));
882        assert!(result.contains("not found"));
883    }
884
885    #[test]
886    fn handle_show_no_args() {
887        let (plugin, _tmp) = make_plugin();
888        let result = plugin.handle_show(&test_ctx("a", &["show"]));
889        assert!(result.contains("usage"));
890    }
891
892    #[test]
893    fn handle_not_found() {
894        let (plugin, _tmp) = make_plugin();
895        let (result, broadcast) = plugin.handle_claim(&test_ctx("a", &["claim", "tb-999"]));
896        assert!(result.contains("not found"));
897        assert!(!broadcast);
898    }
899
900    #[test]
901    fn persistence_survives_reload() {
902        let tmp = tempfile::NamedTempFile::new().unwrap();
903        let path = tmp.path().to_path_buf();
904        {
905            let plugin = TaskboardPlugin::new(path.clone(), Some(600));
906            plugin.handle_post(&test_ctx("ba", &["post", "persistent task"]));
907            plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
908        }
909        // Reload from disk.
910        let plugin2 = TaskboardPlugin::new(path, Some(600));
911        let board = plugin2.board.lock().unwrap();
912        assert_eq!(board.len(), 1);
913        assert_eq!(board[0].task.id, "tb-001");
914        assert_eq!(board[0].task.status, TaskStatus::Claimed);
915    }
916
917    #[test]
918    fn lease_expiry_on_list() {
919        let (plugin, _tmp) = make_plugin();
920        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
921        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
922        // Force lease to the past.
923        {
924            let mut board = plugin.board.lock().unwrap();
925            board[0].lease_start =
926                Some(std::time::Instant::now() - std::time::Duration::from_secs(700));
927        }
928        let result = plugin.handle_list();
929        assert!(result.contains("expired"));
930        let board = plugin.board.lock().unwrap();
931        assert_eq!(board[0].task.status, TaskStatus::Open);
932    }
933
934    #[test]
935    fn full_lifecycle() {
936        let (plugin, _tmp) = make_plugin();
937        // post → claim → plan → approve → update → finish
938        plugin.handle_post(&test_ctx("ba", &["post", "implement #42"]));
939        plugin.handle_claim(&test_ctx("saphire", &["claim", "tb-001"]));
940        plugin.handle_plan(&test_ctx(
941            "saphire",
942            &["plan", "tb-001", "add Foo, write tests"],
943        ));
944        plugin.handle_approve(&test_ctx_with_host(
945            "ba",
946            &["approve", "tb-001"],
947            Some("ba"),
948        ));
949        plugin.handle_update(&test_ctx("saphire", &["update", "tb-001", "tests passing"]));
950        plugin.handle_finish(&test_ctx("saphire", &["finish", "tb-001"]));
951        let board = plugin.board.lock().unwrap();
952        assert_eq!(board[0].task.status, TaskStatus::Finished);
953    }
954
955    #[test]
956    fn taskboard_path_from_chat_replaces_extension() {
957        let chat = PathBuf::from("/data/room-dev.chat");
958        let tb = TaskboardPlugin::taskboard_path_from_chat(&chat);
959        assert_eq!(tb, PathBuf::from("/data/room-dev.taskboard"));
960    }
961
962    #[test]
963    fn default_commands_matches_commands() {
964        let (plugin, _tmp) = make_plugin();
965        let default = TaskboardPlugin::default_commands();
966        let instance = plugin.commands();
967        assert_eq!(default.len(), instance.len());
968        assert_eq!(default[0].name, instance[0].name);
969        assert_eq!(default[0].params.len(), instance[0].params.len());
970    }
971
972    #[test]
973    fn handle_assign_happy_path() {
974        let (plugin, _tmp) = make_plugin();
975        plugin.handle_post(&test_ctx("ba", &["post", "implement feature"]));
976        let (result, broadcast) = plugin.handle_assign(&test_ctx_with_host(
977            "ba",
978            &["assign", "tb-001", "agent"],
979            None,
980        ));
981        assert!(result.contains("assigned to agent"));
982        assert!(result.contains("by ba"));
983        assert!(broadcast);
984        let board = plugin.board.lock().unwrap();
985        assert_eq!(board[0].task.status, TaskStatus::Claimed);
986        assert_eq!(board[0].task.assigned_to.as_deref(), Some("agent"));
987    }
988
989    #[test]
990    fn handle_assign_by_host() {
991        let (plugin, _tmp) = make_plugin();
992        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
993        // Host (joao) can assign even though ba posted it.
994        let (result, broadcast) = plugin.handle_assign(&test_ctx_with_host(
995            "joao",
996            &["assign", "tb-001", "saphire"],
997            Some("joao"),
998        ));
999        assert!(result.contains("assigned to saphire"));
1000        assert!(result.contains("by joao"));
1001        assert!(broadcast);
1002    }
1003
1004    #[test]
1005    fn handle_assign_rejected_non_poster_non_host() {
1006        let (plugin, _tmp) = make_plugin();
1007        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
1008        let (result, broadcast) = plugin.handle_assign(&test_ctx_with_host(
1009            "random",
1010            &["assign", "tb-001", "agent"],
1011            Some("joao"),
1012        ));
1013        assert!(result.contains("only the task poster or host"));
1014        assert!(!broadcast);
1015    }
1016
1017    #[test]
1018    fn handle_assign_wrong_status() {
1019        let (plugin, _tmp) = make_plugin();
1020        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
1021        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
1022        // Task is already claimed — assign should fail.
1023        let (result, broadcast) =
1024            plugin.handle_assign(&test_ctx("ba", &["assign", "tb-001", "other"]));
1025        assert!(result.contains("must be open to assign"));
1026        assert!(!broadcast);
1027    }
1028
1029    #[test]
1030    fn handle_assign_not_found() {
1031        let (plugin, _tmp) = make_plugin();
1032        let (result, broadcast) =
1033            plugin.handle_assign(&test_ctx("ba", &["assign", "tb-999", "agent"]));
1034        assert!(result.contains("not found"));
1035        assert!(!broadcast);
1036    }
1037
1038    #[test]
1039    fn handle_assign_missing_args() {
1040        let (plugin, _tmp) = make_plugin();
1041        // No task ID.
1042        let (result, broadcast) = plugin.handle_assign(&test_ctx("ba", &["assign"]));
1043        assert!(result.contains("usage"));
1044        assert!(!broadcast);
1045        // No username.
1046        let (result, broadcast) = plugin.handle_assign(&test_ctx("ba", &["assign", "tb-001"]));
1047        assert!(result.contains("usage"));
1048        assert!(!broadcast);
1049    }
1050
1051    #[test]
1052    fn handle_assign_then_plan_and_finish() {
1053        let (plugin, _tmp) = make_plugin();
1054        plugin.handle_post(&test_ctx("ba", &["post", "implement #502"]));
1055        // Assign to agent.
1056        plugin.handle_assign(&test_ctx("ba", &["assign", "tb-001", "agent"]));
1057        // Agent can submit plan on assigned task.
1058        let (result, broadcast) = plugin.handle_plan(&test_ctx(
1059            "agent",
1060            &["plan", "tb-001", "add handler and tests"],
1061        ));
1062        assert!(result.contains("plan submitted"));
1063        assert!(broadcast);
1064        // Approve and finish.
1065        plugin.handle_approve(&test_ctx_with_host(
1066            "ba",
1067            &["approve", "tb-001"],
1068            Some("ba"),
1069        ));
1070        let (result, broadcast) = plugin.handle_finish(&test_ctx("agent", &["finish", "tb-001"]));
1071        assert!(result.contains("finished"));
1072        assert!(broadcast);
1073    }
1074
1075    #[test]
1076    fn handle_cancel_by_poster() {
1077        let (plugin, _tmp) = make_plugin();
1078        plugin.handle_post(&test_ctx("ba", &["post", "obsolete task"]));
1079        let (result, broadcast) =
1080            plugin.handle_cancel(&test_ctx("ba", &["cancel", "tb-001", "no longer needed"]));
1081        assert!(result.contains("cancelled by ba"));
1082        assert!(result.contains("no longer needed"));
1083        assert!(broadcast);
1084        let board = plugin.board.lock().unwrap();
1085        assert_eq!(board[0].task.status, TaskStatus::Cancelled);
1086        assert!(board[0]
1087            .task
1088            .notes
1089            .as_deref()
1090            .unwrap()
1091            .contains("no longer needed"));
1092        assert!(board[0].lease_start.is_none());
1093    }
1094
1095    #[test]
1096    fn handle_cancel_by_assignee() {
1097        let (plugin, _tmp) = make_plugin();
1098        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
1099        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
1100        let (result, broadcast) = plugin.handle_cancel(&test_ctx("agent", &["cancel", "tb-001"]));
1101        assert!(result.contains("cancelled by agent"));
1102        assert!(broadcast);
1103        let board = plugin.board.lock().unwrap();
1104        assert_eq!(board[0].task.status, TaskStatus::Cancelled);
1105        assert!(board[0]
1106            .task
1107            .notes
1108            .as_deref()
1109            .unwrap()
1110            .contains("cancelled by agent"));
1111    }
1112
1113    #[test]
1114    fn handle_cancel_by_host() {
1115        let (plugin, _tmp) = make_plugin();
1116        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
1117        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
1118        // Host (joao) can cancel even if not poster or assignee.
1119        let (result, broadcast) = plugin.handle_cancel(&test_ctx_with_host(
1120            "joao",
1121            &["cancel", "tb-001", "scope changed"],
1122            Some("joao"),
1123        ));
1124        assert!(result.contains("cancelled by joao"));
1125        assert!(result.contains("scope changed"));
1126        assert!(broadcast);
1127    }
1128
1129    #[test]
1130    fn handle_cancel_finished_rejected() {
1131        let (plugin, _tmp) = make_plugin();
1132        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
1133        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
1134        plugin.handle_finish(&test_ctx("agent", &["finish", "tb-001"]));
1135        let (result, broadcast) = plugin.handle_cancel(&test_ctx("ba", &["cancel", "tb-001"]));
1136        assert!(result.contains("cannot cancel"));
1137        assert!(result.contains("finished"));
1138        assert!(!broadcast);
1139    }
1140
1141    #[test]
1142    fn handle_cancel_unauthorized_rejected() {
1143        let (plugin, _tmp) = make_plugin();
1144        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
1145        plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
1146        // Random user who is neither poster, assignee, nor host.
1147        let (result, broadcast) = plugin.handle_cancel(&test_ctx_with_host(
1148            "random",
1149            &["cancel", "tb-001"],
1150            Some("joao"),
1151        ));
1152        assert!(result.contains("poster, assignee, or host"));
1153        assert!(!broadcast);
1154    }
1155
1156    #[test]
1157    fn handle_cancel_no_reason() {
1158        let (plugin, _tmp) = make_plugin();
1159        plugin.handle_post(&test_ctx("ba", &["post", "task"]));
1160        let (result, broadcast) = plugin.handle_cancel(&test_ctx("ba", &["cancel", "tb-001"]));
1161        assert!(result.contains("cancelled by ba"));
1162        assert!(!result.contains("—")); // no reason separator
1163        assert!(broadcast);
1164        let board = plugin.board.lock().unwrap();
1165        assert_eq!(board[0].task.notes.as_deref(), Some("cancelled by ba"));
1166    }
1167
1168    // ── Test helpers ────────────────────────────────────────────────────────
1169
1170    fn test_ctx(sender: &str, params: &[&str]) -> CommandContext {
1171        test_ctx_with_host(sender, params, None)
1172    }
1173
1174    fn test_ctx_with_host(sender: &str, params: &[&str], host: Option<&str>) -> CommandContext {
1175        use std::collections::HashMap;
1176        use std::sync::atomic::AtomicU64;
1177
1178        use crate::plugin::{ChatWriter, RoomMetadata, UserInfo};
1179
1180        let clients = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
1181        let chat_path = Arc::new(PathBuf::from("/dev/null"));
1182        let room_id = Arc::new("test-room".to_owned());
1183        let seq_counter = Arc::new(AtomicU64::new(0));
1184        let writer = ChatWriter::new(&clients, &chat_path, &room_id, &seq_counter, "taskboard");
1185
1186        CommandContext {
1187            command: "taskboard".to_owned(),
1188            params: params.iter().map(|s| s.to_string()).collect(),
1189            sender: sender.to_owned(),
1190            room_id: "test-room".to_owned(),
1191            message_id: "msg-001".to_owned(),
1192            timestamp: chrono::Utc::now(),
1193            history: crate::plugin::HistoryReader::new(std::path::Path::new("/dev/null"), sender),
1194            writer,
1195            metadata: RoomMetadata {
1196                online_users: vec![UserInfo {
1197                    username: sender.to_owned(),
1198                    status: String::new(),
1199                }],
1200                host: host.map(|h| h.to_owned()),
1201                message_count: 0,
1202            },
1203            available_commands: vec![],
1204        }
1205    }
1206}