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