Skip to main content

room_plugin_taskboard/
lib.rs

1mod handlers;
2pub mod task;
3
4use std::path::PathBuf;
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7
8use task::{next_id, LiveTask, Task, TaskStatus};
9
10use room_protocol::plugin::{
11    BoxFuture, CommandContext, CommandInfo, ParamSchema, ParamType, Plugin, PluginResult,
12};
13use room_protocol::EventType;
14
15// ── C ABI entry points for cdylib loading ─────────────────────────────────
16
17/// JSON configuration for the taskboard plugin when loaded dynamically.
18///
19/// `lease_ttl_secs` defaults to `0` (expiry disabled). Set a positive value
20/// to enable automatic lease expiry (e.g. `600` for 10 minutes).
21///
22/// ```json
23/// { "storage_path": "/path/to/room.taskboard", "lease_ttl_secs": 0 }
24/// ```
25#[derive(serde::Deserialize)]
26struct TaskboardConfig {
27    storage_path: PathBuf,
28    #[serde(default)]
29    lease_ttl_secs: Option<u64>,
30}
31
32/// Create a [`TaskboardPlugin`] from a JSON config string.
33///
34/// Falls back to a temp-file default if config is empty (for testing).
35fn create_taskboard_from_config(config: &str) -> TaskboardPlugin {
36    if config.is_empty() {
37        // Default: use a temp path — useful for tests, not for production.
38        TaskboardPlugin::new(PathBuf::from("/tmp/room-default.taskboard"), None)
39    } else {
40        let cfg: TaskboardConfig =
41            serde_json::from_str(config).expect("invalid taskboard plugin config JSON");
42        TaskboardPlugin::new(cfg.storage_path, cfg.lease_ttl_secs)
43    }
44}
45
46room_protocol::declare_plugin!("taskboard", create_taskboard_from_config);
47
48/// Default lease TTL in seconds. `0` means lease expiry is disabled (no
49/// auto-release). Set a non-zero value via config to enable time-based expiry.
50const DEFAULT_LEASE_TTL_SECS: u64 = 0;
51
52/// Unified task lifecycle plugin with optional lease-based expiry.
53///
54/// Manages a board of tasks that agents can post, claim, plan, get approved,
55/// update, release, and finish. When `lease_ttl_secs` is non-zero, claimed
56/// tasks auto-release back to open status if not renewed via `/taskboard
57/// update` or `/taskboard plan` within the TTL (lazy sweep on access). When
58/// zero (the default), leases are tracked but never expire.
59pub struct TaskboardPlugin {
60    /// In-memory task board with lease timers.
61    board: Arc<Mutex<Vec<LiveTask>>>,
62    /// Path to the NDJSON persistence file.
63    storage_path: PathBuf,
64    /// Lease TTL duration.
65    lease_ttl: Duration,
66}
67
68impl TaskboardPlugin {
69    /// Create a new taskboard plugin, loading existing tasks from disk.
70    pub fn new(storage_path: PathBuf, lease_ttl_secs: Option<u64>) -> Self {
71        let ttl = lease_ttl_secs.unwrap_or(DEFAULT_LEASE_TTL_SECS);
72        let tasks = task::load_tasks(&storage_path);
73        let live_tasks: Vec<LiveTask> = tasks.into_iter().map(LiveTask::new).collect();
74        Self {
75            board: Arc::new(Mutex::new(live_tasks)),
76            storage_path,
77            lease_ttl: Duration::from_secs(ttl),
78        }
79    }
80
81    /// Derive the `.taskboard` file path from a `.chat` file path.
82    pub fn taskboard_path_from_chat(chat_path: &std::path::Path) -> PathBuf {
83        chat_path.with_extension("taskboard")
84    }
85
86    /// Returns the command info for the TUI command palette without needing
87    /// an instantiated plugin. Used by `all_known_commands()`.
88    pub fn default_commands() -> Vec<CommandInfo> {
89        vec![CommandInfo {
90            name: "taskboard".to_owned(),
91            description:
92                "Manage task lifecycle — post, list, history, mine, qa-queue, show, claim, assign, plan, approve, update, request_review, review_claim, reject, release, finish, cancel"
93                    .to_owned(),
94            usage: "/taskboard <action> [args...]".to_owned(),
95            params: vec![
96                ParamSchema {
97                    name: "action".to_owned(),
98                    param_type: ParamType::Choice(vec![
99                        "post".to_owned(),
100                        "list".to_owned(),
101                        "history".to_owned(),
102                        "mine".to_owned(),
103                        "qa-queue".to_owned(),
104                        "show".to_owned(),
105                        "claim".to_owned(),
106                        "assign".to_owned(),
107                        "plan".to_owned(),
108                        "approve".to_owned(),
109                        "update".to_owned(),
110                        "request_review".to_owned(),
111                        "review_claim".to_owned(),
112                        "reject".to_owned(),
113                        "release".to_owned(),
114                        "finish".to_owned(),
115                        "cancel".to_owned(),
116                    ]),
117                    required: true,
118                    description: "Subcommand".to_owned(),
119                },
120                ParamSchema {
121                    name: "args".to_owned(),
122                    param_type: ParamType::Text,
123                    required: false,
124                    description: "Task ID or description".to_owned(),
125                },
126            ],
127            subcommands: taskboard_subcommands(),
128        }]
129    }
130
131    /// Sweep expired leases (lazy — called before reads).
132    ///
133    /// Returns immediately when the TTL is zero (expiry disabled).
134    fn sweep_expired(&self) -> Vec<String> {
135        let ttl = self.lease_ttl.as_secs();
136        if ttl == 0 {
137            return Vec::new();
138        }
139        let mut board = self.board.lock().unwrap();
140        let mut expired_ids = Vec::new();
141        for lt in board.iter_mut() {
142            if lt.is_expired(ttl)
143                && matches!(
144                    lt.task.status,
145                    TaskStatus::Claimed
146                        | TaskStatus::Planned
147                        | TaskStatus::InProgress
148                        | TaskStatus::ReviewClaimed
149                )
150            {
151                let prev_assignee = lt.task.assigned_to.clone().unwrap_or_default();
152                expired_ids.push(format!("{} (was {})", lt.task.id, prev_assignee));
153                lt.expire();
154            }
155        }
156        if !expired_ids.is_empty() {
157            let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
158            let _ = task::save_tasks(&self.storage_path, &tasks);
159        }
160        expired_ids
161    }
162}
163
164/// Build the per-subcommand help schemas for `/help taskboard <sub>`.
165fn taskboard_subcommands() -> Vec<CommandInfo> {
166    vec![
167        CommandInfo {
168            name: "post".to_owned(),
169            description: "Create a new task on the board. Anyone can post.".to_owned(),
170            usage: "/taskboard post <description>".to_owned(),
171            params: vec![ParamSchema {
172                name: "description".to_owned(),
173                param_type: ParamType::Text,
174                required: true,
175                description: "Task description".to_owned(),
176            }],
177            subcommands: vec![],
178        },
179        CommandInfo {
180            name: "list".to_owned(),
181            description: "Show all active tasks. Use 'list all' to include finished/cancelled.".to_owned(),
182            usage: "/taskboard list [all]".to_owned(),
183            params: vec![ParamSchema {
184                name: "all".to_owned(),
185                param_type: ParamType::Choice(vec!["all".to_owned()]),
186                required: false,
187                description: "Include finished and cancelled tasks".to_owned(),
188            }],
189            subcommands: vec![],
190        },
191        CommandInfo {
192            name: "show".to_owned(),
193            description: "Show full detail for a single task.".to_owned(),
194            usage: "/taskboard show <task-id>".to_owned(),
195            params: vec![ParamSchema {
196                name: "task-id".to_owned(),
197                param_type: ParamType::Text,
198                required: true,
199                description: "Task ID (e.g. tb-001)".to_owned(),
200            }],
201            subcommands: vec![],
202        },
203        CommandInfo {
204            name: "claim".to_owned(),
205            description: "Claim an open task. Starts the lease timer. Task must be in Open status.".to_owned(),
206            usage: "/taskboard claim <task-id>".to_owned(),
207            params: vec![ParamSchema {
208                name: "task-id".to_owned(),
209                param_type: ParamType::Text,
210                required: true,
211                description: "Task ID (e.g. tb-001)".to_owned(),
212            }],
213            subcommands: vec![],
214        },
215        CommandInfo {
216            name: "assign".to_owned(),
217            description: "Assign an open task to a specific user. Only the poster or host can assign.".to_owned(),
218            usage: "/taskboard assign <task-id> <username>".to_owned(),
219            params: vec![
220                ParamSchema {
221                    name: "task-id".to_owned(),
222                    param_type: ParamType::Text,
223                    required: true,
224                    description: "Task ID (e.g. tb-001)".to_owned(),
225                },
226                ParamSchema {
227                    name: "username".to_owned(),
228                    param_type: ParamType::Username,
229                    required: true,
230                    description: "User to assign the task to".to_owned(),
231                },
232            ],
233            subcommands: vec![],
234        },
235        CommandInfo {
236            name: "plan".to_owned(),
237            description: "Submit an implementation plan. Task must be in Claimed status and you must be the assignee. Wait for approval before implementing.".to_owned(),
238            usage: "/taskboard plan <task-id> <plan-text>".to_owned(),
239            params: vec![
240                ParamSchema {
241                    name: "task-id".to_owned(),
242                    param_type: ParamType::Text,
243                    required: true,
244                    description: "Task ID (e.g. tb-001)".to_owned(),
245                },
246                ParamSchema {
247                    name: "plan-text".to_owned(),
248                    param_type: ParamType::Text,
249                    required: true,
250                    description: "Your implementation plan".to_owned(),
251                },
252            ],
253            subcommands: vec![],
254        },
255        CommandInfo {
256            name: "approve".to_owned(),
257            description: "Approve a planned task so the assignee can proceed. Only the poster or host can approve.".to_owned(),
258            usage: "/taskboard approve <task-id>".to_owned(),
259            params: vec![ParamSchema {
260                name: "task-id".to_owned(),
261                param_type: ParamType::Text,
262                required: true,
263                description: "Task ID (e.g. tb-001)".to_owned(),
264            }],
265            subcommands: vec![],
266        },
267        CommandInfo {
268            name: "update".to_owned(),
269            description: "Renew the lease timer and optionally add progress notes. Only the assignee can update.".to_owned(),
270            usage: "/taskboard update <task-id> [notes]".to_owned(),
271            params: vec![
272                ParamSchema {
273                    name: "task-id".to_owned(),
274                    param_type: ParamType::Text,
275                    required: true,
276                    description: "Task ID (e.g. tb-001)".to_owned(),
277                },
278                ParamSchema {
279                    name: "notes".to_owned(),
280                    param_type: ParamType::Text,
281                    required: false,
282                    description: "Progress notes".to_owned(),
283                },
284            ],
285            subcommands: vec![],
286        },
287        CommandInfo {
288            name: "review".to_owned(),
289            description: "Mark a task as ready for review. Only the assignee can request review.".to_owned(),
290            usage: "/taskboard review <task-id>".to_owned(),
291            params: vec![ParamSchema {
292                name: "task-id".to_owned(),
293                param_type: ParamType::Text,
294                required: true,
295                description: "Task ID (e.g. tb-001)".to_owned(),
296            }],
297            subcommands: vec![],
298        },
299        CommandInfo {
300            name: "release".to_owned(),
301            description: "Release a task back to Open status. The assignee or host can release.".to_owned(),
302            usage: "/taskboard release <task-id>".to_owned(),
303            params: vec![ParamSchema {
304                name: "task-id".to_owned(),
305                param_type: ParamType::Text,
306                required: true,
307                description: "Task ID (e.g. tb-001)".to_owned(),
308            }],
309            subcommands: vec![],
310        },
311        CommandInfo {
312            name: "finish".to_owned(),
313            description: "Mark a task as finished. Only the assignee can finish.".to_owned(),
314            usage: "/taskboard finish <task-id>".to_owned(),
315            params: vec![ParamSchema {
316                name: "task-id".to_owned(),
317                param_type: ParamType::Text,
318                required: true,
319                description: "Task ID (e.g. tb-001)".to_owned(),
320            }],
321            subcommands: vec![],
322        },
323        CommandInfo {
324            name: "cancel".to_owned(),
325            description: "Cancel a task with an optional reason. The poster, assignee, or host can cancel.".to_owned(),
326            usage: "/taskboard cancel <task-id> [reason]".to_owned(),
327            params: vec![
328                ParamSchema {
329                    name: "task-id".to_owned(),
330                    param_type: ParamType::Text,
331                    required: true,
332                    description: "Task ID (e.g. tb-001)".to_owned(),
333                },
334                ParamSchema {
335                    name: "reason".to_owned(),
336                    param_type: ParamType::Text,
337                    required: false,
338                    description: "Cancellation reason".to_owned(),
339                },
340            ],
341            subcommands: vec![],
342        },
343        CommandInfo {
344            name: "mine".to_owned(),
345            description: "Show only tasks assigned to you.".to_owned(),
346            usage: "/taskboard mine".to_owned(),
347            params: vec![],
348            subcommands: vec![],
349        },
350    ]
351}
352
353impl Plugin for TaskboardPlugin {
354    fn name(&self) -> &str {
355        "taskboard"
356    }
357
358    fn version(&self) -> &str {
359        env!("CARGO_PKG_VERSION")
360    }
361
362    fn commands(&self) -> Vec<CommandInfo> {
363        Self::default_commands()
364    }
365
366    fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
367        Box::pin(async move {
368            let action = ctx.params.first().map(String::as_str).unwrap_or("");
369            let (result, broadcast) = match action {
370                "post" => self.handle_post(&ctx),
371                "list" => {
372                    let show_all = ctx.params.get(1).map(|s| s.as_str()) == Some("all");
373                    (self.handle_list(show_all), false)
374                }
375                "history" => (self.handle_history(), false),
376                "mine" => (self.handle_mine(&ctx.sender), false),
377                "qa-queue" => (self.handle_qa_queue(), false),
378                "claim" => self.handle_claim(&ctx),
379                "assign" => self.handle_assign(&ctx),
380                "plan" => self.handle_plan(&ctx),
381                "approve" => self.handle_approve(&ctx),
382                "show" => (self.handle_show(&ctx), false),
383                "update" => self.handle_update(&ctx),
384                "release" => self.handle_release(&ctx),
385                "request_review" => self.handle_request_review(&ctx),
386                "review_claim" => self.handle_review_claim(&ctx),
387                "reject" => self.handle_reject(&ctx),
388                "finish" => self.handle_finish(&ctx),
389                "cancel" => self.handle_cancel(&ctx),
390                "" => ("usage: /taskboard <post|list|history|mine|qa-queue|show|claim|assign|plan|approve|update|request_review|review_claim|reject|release|finish|cancel> [args...]".to_owned(), false),
391                other => (format!("unknown action: {other}. use: post, list, history, mine, qa-queue, show, claim, assign, plan, approve, update, request_review, review_claim, reject, release, finish, cancel"), false),
392            };
393            if broadcast {
394                // Emit a typed event alongside the system broadcast.
395                let event_type = match action {
396                    "post" => Some(EventType::TaskPosted),
397                    "claim" => Some(EventType::TaskClaimed),
398                    "assign" => Some(EventType::TaskAssigned),
399                    "plan" => Some(EventType::TaskPlanned),
400                    "approve" => Some(EventType::TaskApproved),
401                    "update" => Some(EventType::TaskUpdated),
402                    "request_review" => Some(EventType::ReviewRequested),
403                    "review_claim" => Some(EventType::ReviewClaimed),
404                    "reject" => Some(EventType::TaskRejected),
405                    "release" => Some(EventType::TaskReleased),
406                    "finish" => Some(EventType::TaskFinished),
407                    "cancel" => Some(EventType::TaskCancelled),
408                    _ => None,
409                };
410                if let Some(et) = event_type {
411                    let _ = ctx.writer.emit_event(et, &result, None).await;
412                }
413                Ok(PluginResult::Broadcast(result, None))
414            } else {
415                Ok(PluginResult::Reply(result, None))
416            }
417        })
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    fn make_plugin() -> (TaskboardPlugin, tempfile::NamedTempFile) {
426        let tmp = tempfile::NamedTempFile::new().unwrap();
427        let plugin = TaskboardPlugin::new(tmp.path().to_path_buf(), Some(600));
428        (plugin, tmp)
429    }
430
431    #[test]
432    fn plugin_name() {
433        let (plugin, _tmp) = make_plugin();
434        assert_eq!(plugin.name(), "taskboard");
435    }
436
437    #[test]
438    fn plugin_version_matches_crate() {
439        let (plugin, _tmp) = make_plugin();
440        assert_eq!(plugin.version(), env!("CARGO_PKG_VERSION"));
441    }
442
443    #[test]
444    fn plugin_api_version_is_current() {
445        let (plugin, _tmp) = make_plugin();
446        assert_eq!(
447            plugin.api_version(),
448            room_protocol::plugin::PLUGIN_API_VERSION
449        );
450    }
451
452    #[test]
453    fn plugin_min_protocol_is_compatible() {
454        let (plugin, _tmp) = make_plugin();
455        // Default min_protocol is "0.0.0", which is always satisfied.
456        assert_eq!(plugin.min_protocol(), "0.0.0");
457    }
458
459    #[test]
460    fn plugin_commands() {
461        let (plugin, _tmp) = make_plugin();
462        let cmds = plugin.commands();
463        assert_eq!(cmds.len(), 1);
464        assert_eq!(cmds[0].name, "taskboard");
465        assert_eq!(cmds[0].params.len(), 2);
466        if let ParamType::Choice(ref choices) = cmds[0].params[0].param_type {
467            assert!(choices.contains(&"post".to_owned()));
468            assert!(choices.contains(&"approve".to_owned()));
469            assert!(choices.contains(&"assign".to_owned()));
470            assert_eq!(choices.len(), 17);
471        } else {
472            panic!("expected Choice param type");
473        }
474    }
475
476    #[test]
477    fn plugin_commands_has_subcommands() {
478        let (plugin, _tmp) = make_plugin();
479        let cmds = plugin.commands();
480        assert_eq!(cmds[0].subcommands.len(), 13, "should have 13 subcommands");
481        let names: Vec<&str> = cmds[0]
482            .subcommands
483            .iter()
484            .map(|s| s.name.as_str())
485            .collect();
486        assert!(names.contains(&"post"));
487        assert!(names.contains(&"plan"));
488        assert!(names.contains(&"claim"));
489        assert!(names.contains(&"cancel"));
490    }
491
492    #[test]
493    fn taskboard_path_from_chat_replaces_extension() {
494        let chat = PathBuf::from("/data/room-dev.chat");
495        let tb = TaskboardPlugin::taskboard_path_from_chat(&chat);
496        assert_eq!(tb, PathBuf::from("/data/room-dev.taskboard"));
497    }
498
499    #[test]
500    fn default_commands_matches_commands() {
501        let (plugin, _tmp) = make_plugin();
502        let default = TaskboardPlugin::default_commands();
503        let instance = plugin.commands();
504        assert_eq!(default.len(), instance.len());
505        assert_eq!(default[0].name, instance[0].name);
506        assert_eq!(default[0].params.len(), instance[0].params.len());
507    }
508
509    fn seed_task(plugin: &TaskboardPlugin, id: &str, status: TaskStatus) {
510        let mut board = plugin.board.lock().unwrap();
511        let t = task::Task {
512            id: id.to_owned(),
513            description: format!("task {id}"),
514            status,
515            posted_by: "alice".to_owned(),
516            assigned_to: if status != TaskStatus::Open {
517                Some("bob".to_owned())
518            } else {
519                None
520            },
521            posted_at: chrono::Utc::now(),
522            claimed_at: None,
523            plan: None,
524            approved_by: None,
525            approved_at: None,
526            updated_at: None,
527            notes: None,
528            team: None,
529            reviewer: None,
530        };
531        board.push(LiveTask::new(t));
532    }
533
534    #[test]
535    fn handle_list_filters_terminal_tasks() {
536        let (plugin, _tmp) = make_plugin();
537        seed_task(&plugin, "tb-001", TaskStatus::Open);
538        seed_task(&plugin, "tb-002", TaskStatus::Claimed);
539        seed_task(&plugin, "tb-003", TaskStatus::Finished);
540        seed_task(&plugin, "tb-004", TaskStatus::Cancelled);
541
542        let output = plugin.handle_list(false);
543        assert!(output.contains("tb-001"), "open task should appear");
544        assert!(output.contains("tb-002"), "claimed task should appear");
545        assert!(!output.contains("tb-003"), "finished task should be hidden");
546        assert!(
547            !output.contains("tb-004"),
548            "cancelled task should be hidden"
549        );
550    }
551
552    #[test]
553    fn handle_list_all_shows_everything() {
554        let (plugin, _tmp) = make_plugin();
555        seed_task(&plugin, "tb-001", TaskStatus::Open);
556        seed_task(&plugin, "tb-002", TaskStatus::Finished);
557        seed_task(&plugin, "tb-003", TaskStatus::Cancelled);
558
559        let output = plugin.handle_list(true);
560        assert!(output.contains("tb-001"), "open task should appear");
561        assert!(
562            output.contains("tb-002"),
563            "finished task should appear with all"
564        );
565        assert!(
566            output.contains("tb-003"),
567            "cancelled task should appear with all"
568        );
569    }
570
571    #[test]
572    fn handle_list_empty_after_filter() {
573        let (plugin, _tmp) = make_plugin();
574        seed_task(&plugin, "tb-001", TaskStatus::Finished);
575        seed_task(&plugin, "tb-002", TaskStatus::Cancelled);
576
577        let output = plugin.handle_list(false);
578        assert!(
579            output.contains("no active tasks"),
580            "should show helpful empty message, got: {output}"
581        );
582        assert!(
583            output.contains("/taskboard list all"),
584            "should hint at 'list all' command"
585        );
586    }
587
588    // ── history tests ────────────────────────────────────────────────────
589
590    #[test]
591    fn handle_history_shows_finished_and_cancelled() {
592        let (plugin, _tmp) = make_plugin();
593        seed_task(&plugin, "tb-001", TaskStatus::Open);
594        seed_task(&plugin, "tb-002", TaskStatus::Claimed);
595        seed_task(&plugin, "tb-003", TaskStatus::Finished);
596        seed_task(&plugin, "tb-004", TaskStatus::Cancelled);
597
598        let output = plugin.handle_history();
599        assert!(!output.contains("tb-001"), "open task should not appear");
600        assert!(!output.contains("tb-002"), "claimed task should not appear");
601        assert!(output.contains("tb-003"), "finished task should appear");
602        assert!(output.contains("tb-004"), "cancelled task should appear");
603    }
604
605    #[test]
606    fn handle_history_empty_when_no_completed() {
607        let (plugin, _tmp) = make_plugin();
608        seed_task(&plugin, "tb-001", TaskStatus::Open);
609        seed_task(&plugin, "tb-002", TaskStatus::Claimed);
610
611        let output = plugin.handle_history();
612        assert!(
613            output.contains("no completed tasks"),
614            "should show empty message, got: {output}"
615        );
616    }
617
618    #[test]
619    fn handle_history_shows_assignee() {
620        let (plugin, _tmp) = make_plugin();
621        seed_task(&plugin, "tb-001", TaskStatus::Finished);
622
623        let output = plugin.handle_history();
624        assert!(output.contains("bob"), "should show assignee");
625        assert!(output.contains("ASSIGNEE"), "should have header");
626    }
627
628    // ── ABI entry point tests ────────────────────────────────────────────
629
630    #[test]
631    fn abi_declaration_matches_plugin() {
632        let decl = &ROOM_PLUGIN_DECLARATION;
633        assert_eq!(decl.api_version, room_protocol::plugin::PLUGIN_API_VERSION);
634        unsafe {
635            assert_eq!(decl.name().unwrap(), "taskboard");
636            assert_eq!(decl.version().unwrap(), env!("CARGO_PKG_VERSION"));
637            assert_eq!(decl.min_protocol().unwrap(), "0.0.0");
638        }
639    }
640
641    #[test]
642    fn abi_create_with_empty_config() {
643        let plugin_ptr = unsafe { room_plugin_create(std::ptr::null(), 0) };
644        assert!(!plugin_ptr.is_null());
645        let plugin = unsafe { Box::from_raw(plugin_ptr) };
646        assert_eq!(plugin.name(), "taskboard");
647        assert_eq!(plugin.version(), env!("CARGO_PKG_VERSION"));
648    }
649
650    #[test]
651    fn abi_create_with_json_config() {
652        let tmp = tempfile::NamedTempFile::new().unwrap();
653        let config = format!(
654            r#"{{"storage_path":"{}","lease_ttl_secs":300}}"#,
655            tmp.path().display()
656        );
657        let plugin_ptr = unsafe { room_plugin_create(config.as_ptr(), config.len()) };
658        assert!(!plugin_ptr.is_null());
659        let plugin = unsafe { Box::from_raw(plugin_ptr) };
660        assert_eq!(plugin.name(), "taskboard");
661    }
662
663    #[test]
664    fn abi_destroy_frees_plugin() {
665        let plugin_ptr = unsafe { room_plugin_create(std::ptr::null(), 0) };
666        assert!(!plugin_ptr.is_null());
667        // Should not panic or double-free.
668        unsafe { room_plugin_destroy(plugin_ptr) };
669    }
670
671    #[test]
672    fn abi_destroy_null_is_safe() {
673        // Passing null to destroy must be a no-op.
674        unsafe { room_plugin_destroy(std::ptr::null_mut()) };
675    }
676
677    /// Multiple tasks with expired leases must all be swept in a single call,
678    /// and Finished tasks must be left untouched even with a stale lease.
679    #[test]
680    fn sweep_expired_multiple_simultaneous_and_skips_finished() {
681        let (plugin, _tmp) = make_plugin();
682
683        // Seed 4 tasks: 3 Claimed (will expire) + 1 Finished (must survive).
684        {
685            let mut board = plugin.board.lock().unwrap();
686            let stale = std::time::Instant::now() - std::time::Duration::from_secs(700);
687            for i in 1..=3 {
688                let t = task::Task {
689                    id: format!("tb-{i:03}"),
690                    description: format!("expiry test {i}"),
691                    status: TaskStatus::Claimed,
692                    posted_by: "alice".to_owned(),
693                    assigned_to: Some(format!("agent-{i}")),
694                    posted_at: chrono::Utc::now(),
695                    claimed_at: Some(chrono::Utc::now()),
696                    plan: None,
697                    approved_by: None,
698                    approved_at: None,
699                    updated_at: None,
700                    notes: None,
701                    team: None,
702                    reviewer: None,
703                };
704                let mut lt = LiveTask::new(t);
705                lt.lease_start = Some(stale);
706                board.push(lt);
707            }
708            // Finished task with a stale lease — must NOT be swept.
709            let finished = task::Task {
710                id: "tb-004".to_owned(),
711                description: "finished task".to_owned(),
712                status: TaskStatus::Finished,
713                posted_by: "alice".to_owned(),
714                assigned_to: Some("bob".to_owned()),
715                posted_at: chrono::Utc::now(),
716                claimed_at: Some(chrono::Utc::now()),
717                plan: Some("done".to_owned()),
718                approved_by: None,
719                approved_at: None,
720                updated_at: None,
721                notes: None,
722                team: None,
723                reviewer: None,
724            };
725            let mut lt_finished = LiveTask::new(finished);
726            // Manually inject stale lease to simulate edge case.
727            lt_finished.lease_start = Some(stale);
728            board.push(lt_finished);
729        }
730
731        let expired = plugin.sweep_expired();
732
733        // All 3 Claimed tasks should have expired.
734        assert_eq!(
735            expired.len(),
736            3,
737            "expected 3 expired tasks, got {expired:?}"
738        );
739        for id in &expired {
740            assert!(!id.contains("tb-004"), "Finished task must not be swept");
741        }
742
743        // Verify board state after sweep.
744        let board = plugin.board.lock().unwrap();
745        for lt in board.iter() {
746            if lt.task.id == "tb-004" {
747                assert_eq!(lt.task.status, TaskStatus::Finished);
748                assert_eq!(lt.task.assigned_to.as_deref(), Some("bob"));
749                assert_eq!(lt.task.plan.as_deref(), Some("done"));
750            } else {
751                assert_eq!(lt.task.status, TaskStatus::Open);
752                assert!(lt.task.assigned_to.is_none());
753                assert!(lt.lease_start.is_none());
754            }
755        }
756    }
757
758    /// When lease_ttl_secs is 0 (the default), sweep_expired must be a no-op
759    /// even when tasks have stale lease timestamps.
760    #[test]
761    fn sweep_expired_disabled_when_ttl_zero() {
762        let tmp = tempfile::NamedTempFile::new().unwrap();
763        let plugin = TaskboardPlugin::new(tmp.path().to_path_buf(), None); // default = 0
764
765        {
766            let mut board = plugin.board.lock().unwrap();
767            let stale = std::time::Instant::now() - std::time::Duration::from_secs(9999);
768            let t = task::Task {
769                id: "tb-001".to_owned(),
770                description: "should not expire".to_owned(),
771                status: TaskStatus::Claimed,
772                posted_by: "alice".to_owned(),
773                assigned_to: Some("bob".to_owned()),
774                posted_at: chrono::Utc::now(),
775                claimed_at: Some(chrono::Utc::now()),
776                plan: None,
777                approved_by: None,
778                approved_at: None,
779                updated_at: None,
780                notes: None,
781                team: None,
782                reviewer: None,
783            };
784            let mut lt = LiveTask::new(t);
785            lt.lease_start = Some(stale);
786            board.push(lt);
787        }
788
789        let expired = plugin.sweep_expired();
790        assert!(expired.is_empty(), "TTL=0 must disable expiry");
791
792        let board = plugin.board.lock().unwrap();
793        assert_eq!(board[0].task.status, TaskStatus::Claimed);
794        assert_eq!(board[0].task.assigned_to.as_deref(), Some("bob"));
795    }
796
797    // ── handle_mine tests ────────────────────────────────────────────────
798
799    #[test]
800    fn handle_mine_returns_only_assigned_tasks() {
801        let (plugin, _tmp) = make_plugin();
802        seed_task(&plugin, "tb-001", TaskStatus::Open);
803        seed_task(&plugin, "tb-002", TaskStatus::Claimed); // assigned to "bob"
804        seed_task(&plugin, "tb-003", TaskStatus::InProgress); // assigned to "bob"
805
806        // Seed one task assigned to "alice".
807        {
808            let mut board = plugin.board.lock().unwrap();
809            let t = task::Task {
810                id: "tb-004".to_owned(),
811                description: "alice task".to_owned(),
812                status: TaskStatus::Claimed,
813                posted_by: "manager".to_owned(),
814                assigned_to: Some("alice".to_owned()),
815                posted_at: chrono::Utc::now(),
816                claimed_at: None,
817                plan: None,
818                approved_by: None,
819                approved_at: None,
820                updated_at: None,
821                notes: None,
822                team: None,
823                reviewer: None,
824            };
825            board.push(LiveTask::new(t));
826        }
827
828        let output = plugin.handle_mine("bob");
829        assert!(
830            output.contains("tb-002"),
831            "bob's claimed task should appear"
832        );
833        assert!(
834            output.contains("tb-003"),
835            "bob's approved task should appear"
836        );
837        assert!(
838            !output.contains("tb-001"),
839            "unassigned task should not appear"
840        );
841        assert!(
842            !output.contains("tb-004"),
843            "alice's task should not appear for bob"
844        );
845    }
846
847    #[test]
848    fn handle_mine_empty_when_no_tasks_assigned() {
849        let (plugin, _tmp) = make_plugin();
850        seed_task(&plugin, "tb-001", TaskStatus::Open);
851        seed_task(&plugin, "tb-002", TaskStatus::Claimed); // assigned to "bob"
852
853        let output = plugin.handle_mine("alice");
854        assert!(
855            output.contains("no tasks assigned to alice"),
856            "should show empty message, got: {output}"
857        );
858    }
859
860    #[test]
861    fn handle_mine_includes_finished_tasks() {
862        let (plugin, _tmp) = make_plugin();
863        seed_task(&plugin, "tb-001", TaskStatus::Finished); // assigned to "bob"
864
865        let output = plugin.handle_mine("bob");
866        assert!(
867            output.contains("tb-001"),
868            "finished tasks should appear in mine"
869        );
870    }
871
872    // ── qa-queue tests ──────────────────────────────────────────────────
873
874    #[test]
875    fn handle_qa_queue_returns_only_awaiting_review() {
876        let (plugin, _tmp) = make_plugin();
877        seed_task(&plugin, "tb-001", TaskStatus::Open);
878        seed_task(&plugin, "tb-002", TaskStatus::Claimed);
879        seed_task(&plugin, "tb-003", TaskStatus::AwaitingReview);
880        seed_task(&plugin, "tb-004", TaskStatus::Finished);
881
882        let output = plugin.handle_qa_queue();
883        assert!(
884            output.contains("tb-003"),
885            "AwaitingReview task should appear"
886        );
887        assert!(!output.contains("tb-001"), "open task should not appear");
888        assert!(!output.contains("tb-002"), "claimed task should not appear");
889        assert!(
890            !output.contains("tb-004"),
891            "finished task should not appear"
892        );
893    }
894
895    #[test]
896    fn handle_qa_queue_empty_when_none_awaiting() {
897        let (plugin, _tmp) = make_plugin();
898        seed_task(&plugin, "tb-001", TaskStatus::Open);
899        seed_task(&plugin, "tb-002", TaskStatus::Claimed);
900        seed_task(&plugin, "tb-003", TaskStatus::Finished);
901
902        let output = plugin.handle_qa_queue();
903        assert!(
904            output.contains("no tasks awaiting review"),
905            "should show empty message, got: {output}"
906        );
907    }
908
909    #[test]
910    fn handle_qa_queue_shows_assignee_and_header() {
911        let (plugin, _tmp) = make_plugin();
912        seed_task(&plugin, "tb-001", TaskStatus::AwaitingReview);
913
914        let output = plugin.handle_qa_queue();
915        assert!(output.contains("bob"), "should show assignee");
916        assert!(output.contains("ASSIGNEE"), "should have header");
917        assert!(output.contains("ID"), "should have ID header");
918    }
919}