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, show, claim, assign, plan, approve, update, review, 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                        "show".to_owned(),
97                        "claim".to_owned(),
98                        "assign".to_owned(),
99                        "plan".to_owned(),
100                        "approve".to_owned(),
101                        "update".to_owned(),
102                        "review".to_owned(),
103                        "release".to_owned(),
104                        "finish".to_owned(),
105                        "cancel".to_owned(),
106                    ]),
107                    required: true,
108                    description: "Subcommand".to_owned(),
109                },
110                ParamSchema {
111                    name: "args".to_owned(),
112                    param_type: ParamType::Text,
113                    required: false,
114                    description: "Task ID or description".to_owned(),
115                },
116            ],
117        }]
118    }
119
120    /// Sweep expired leases (lazy — called before reads).
121    fn sweep_expired(&self) -> Vec<String> {
122        let mut board = self.board.lock().unwrap();
123        let ttl = self.lease_ttl.as_secs();
124        let mut expired_ids = Vec::new();
125        for lt in board.iter_mut() {
126            if lt.is_expired(ttl)
127                && matches!(
128                    lt.task.status,
129                    TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
130                )
131            {
132                let prev_assignee = lt.task.assigned_to.clone().unwrap_or_default();
133                expired_ids.push(format!("{} (was {})", lt.task.id, prev_assignee));
134                lt.expire();
135            }
136        }
137        if !expired_ids.is_empty() {
138            let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
139            let _ = task::save_tasks(&self.storage_path, &tasks);
140        }
141        expired_ids
142    }
143}
144
145impl Plugin for TaskboardPlugin {
146    fn name(&self) -> &str {
147        "taskboard"
148    }
149
150    fn version(&self) -> &str {
151        env!("CARGO_PKG_VERSION")
152    }
153
154    fn commands(&self) -> Vec<CommandInfo> {
155        Self::default_commands()
156    }
157
158    fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
159        Box::pin(async move {
160            let action = ctx.params.first().map(String::as_str).unwrap_or("");
161            let (result, broadcast) = match action {
162                "post" => self.handle_post(&ctx),
163                "list" => {
164                    let show_all = ctx.params.get(1).map(|s| s.as_str()) == Some("all");
165                    (self.handle_list(show_all), false)
166                }
167                "claim" => self.handle_claim(&ctx),
168                "assign" => self.handle_assign(&ctx),
169                "plan" => self.handle_plan(&ctx),
170                "approve" => self.handle_approve(&ctx),
171                "show" => (self.handle_show(&ctx), false),
172                "update" => self.handle_update(&ctx),
173                "release" => self.handle_release(&ctx),
174                "review" => self.handle_review(&ctx),
175                "finish" => self.handle_finish(&ctx),
176                "cancel" => self.handle_cancel(&ctx),
177                "" => ("usage: /taskboard <post|list|show|claim|assign|plan|approve|update|review|release|finish|cancel> [args...]".to_owned(), false),
178                other => (format!("unknown action: {other}. use: post, list, show, claim, assign, plan, approve, update, review, release, finish, cancel"), false),
179            };
180            if broadcast {
181                // Emit a typed event alongside the system broadcast.
182                let event_type = match action {
183                    "post" => Some(EventType::TaskPosted),
184                    "claim" => Some(EventType::TaskClaimed),
185                    "assign" => Some(EventType::TaskAssigned),
186                    "plan" => Some(EventType::TaskPlanned),
187                    "approve" => Some(EventType::TaskApproved),
188                    "update" => Some(EventType::TaskUpdated),
189                    "review" => Some(EventType::ReviewRequested),
190                    "release" => Some(EventType::TaskReleased),
191                    "finish" => Some(EventType::TaskFinished),
192                    "cancel" => Some(EventType::TaskCancelled),
193                    _ => None,
194                };
195                if let Some(et) = event_type {
196                    let _ = ctx.writer.emit_event(et, &result, None).await;
197                }
198                Ok(PluginResult::Broadcast(result, None))
199            } else {
200                Ok(PluginResult::Reply(result, None))
201            }
202        })
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    fn make_plugin() -> (TaskboardPlugin, tempfile::NamedTempFile) {
211        let tmp = tempfile::NamedTempFile::new().unwrap();
212        let plugin = TaskboardPlugin::new(tmp.path().to_path_buf(), Some(600));
213        (plugin, tmp)
214    }
215
216    #[test]
217    fn plugin_name() {
218        let (plugin, _tmp) = make_plugin();
219        assert_eq!(plugin.name(), "taskboard");
220    }
221
222    #[test]
223    fn plugin_version_matches_crate() {
224        let (plugin, _tmp) = make_plugin();
225        assert_eq!(plugin.version(), env!("CARGO_PKG_VERSION"));
226    }
227
228    #[test]
229    fn plugin_api_version_is_current() {
230        let (plugin, _tmp) = make_plugin();
231        assert_eq!(
232            plugin.api_version(),
233            room_protocol::plugin::PLUGIN_API_VERSION
234        );
235    }
236
237    #[test]
238    fn plugin_min_protocol_is_compatible() {
239        let (plugin, _tmp) = make_plugin();
240        // Default min_protocol is "0.0.0", which is always satisfied.
241        assert_eq!(plugin.min_protocol(), "0.0.0");
242    }
243
244    #[test]
245    fn plugin_commands() {
246        let (plugin, _tmp) = make_plugin();
247        let cmds = plugin.commands();
248        assert_eq!(cmds.len(), 1);
249        assert_eq!(cmds[0].name, "taskboard");
250        assert_eq!(cmds[0].params.len(), 2);
251        if let ParamType::Choice(ref choices) = cmds[0].params[0].param_type {
252            assert!(choices.contains(&"post".to_owned()));
253            assert!(choices.contains(&"approve".to_owned()));
254            assert!(choices.contains(&"assign".to_owned()));
255            assert_eq!(choices.len(), 12);
256        } else {
257            panic!("expected Choice param type");
258        }
259    }
260
261    #[test]
262    fn taskboard_path_from_chat_replaces_extension() {
263        let chat = PathBuf::from("/data/room-dev.chat");
264        let tb = TaskboardPlugin::taskboard_path_from_chat(&chat);
265        assert_eq!(tb, PathBuf::from("/data/room-dev.taskboard"));
266    }
267
268    #[test]
269    fn default_commands_matches_commands() {
270        let (plugin, _tmp) = make_plugin();
271        let default = TaskboardPlugin::default_commands();
272        let instance = plugin.commands();
273        assert_eq!(default.len(), instance.len());
274        assert_eq!(default[0].name, instance[0].name);
275        assert_eq!(default[0].params.len(), instance[0].params.len());
276    }
277
278    fn seed_task(plugin: &TaskboardPlugin, id: &str, status: TaskStatus) {
279        let mut board = plugin.board.lock().unwrap();
280        let t = task::Task {
281            id: id.to_owned(),
282            description: format!("task {id}"),
283            status,
284            posted_by: "alice".to_owned(),
285            assigned_to: if status != TaskStatus::Open {
286                Some("bob".to_owned())
287            } else {
288                None
289            },
290            posted_at: chrono::Utc::now(),
291            claimed_at: None,
292            plan: None,
293            approved_by: None,
294            approved_at: None,
295            updated_at: None,
296            notes: None,
297            team: None,
298        };
299        board.push(LiveTask::new(t));
300    }
301
302    #[test]
303    fn handle_list_filters_terminal_tasks() {
304        let (plugin, _tmp) = make_plugin();
305        seed_task(&plugin, "tb-001", TaskStatus::Open);
306        seed_task(&plugin, "tb-002", TaskStatus::Claimed);
307        seed_task(&plugin, "tb-003", TaskStatus::Finished);
308        seed_task(&plugin, "tb-004", TaskStatus::Cancelled);
309
310        let output = plugin.handle_list(false);
311        assert!(output.contains("tb-001"), "open task should appear");
312        assert!(output.contains("tb-002"), "claimed task should appear");
313        assert!(!output.contains("tb-003"), "finished task should be hidden");
314        assert!(
315            !output.contains("tb-004"),
316            "cancelled task should be hidden"
317        );
318    }
319
320    #[test]
321    fn handle_list_all_shows_everything() {
322        let (plugin, _tmp) = make_plugin();
323        seed_task(&plugin, "tb-001", TaskStatus::Open);
324        seed_task(&plugin, "tb-002", TaskStatus::Finished);
325        seed_task(&plugin, "tb-003", TaskStatus::Cancelled);
326
327        let output = plugin.handle_list(true);
328        assert!(output.contains("tb-001"), "open task should appear");
329        assert!(
330            output.contains("tb-002"),
331            "finished task should appear with all"
332        );
333        assert!(
334            output.contains("tb-003"),
335            "cancelled task should appear with all"
336        );
337    }
338
339    #[test]
340    fn handle_list_empty_after_filter() {
341        let (plugin, _tmp) = make_plugin();
342        seed_task(&plugin, "tb-001", TaskStatus::Finished);
343        seed_task(&plugin, "tb-002", TaskStatus::Cancelled);
344
345        let output = plugin.handle_list(false);
346        assert!(
347            output.contains("no active tasks"),
348            "should show helpful empty message, got: {output}"
349        );
350        assert!(
351            output.contains("/taskboard list all"),
352            "should hint at 'list all' command"
353        );
354    }
355
356    // ── ABI entry point tests ────────────────────────────────────────────
357
358    #[test]
359    fn abi_declaration_matches_plugin() {
360        let decl = &ROOM_PLUGIN_DECLARATION;
361        assert_eq!(decl.api_version, room_protocol::plugin::PLUGIN_API_VERSION);
362        unsafe {
363            assert_eq!(decl.name().unwrap(), "taskboard");
364            assert_eq!(decl.version().unwrap(), env!("CARGO_PKG_VERSION"));
365            assert_eq!(decl.min_protocol().unwrap(), "0.0.0");
366        }
367    }
368
369    #[test]
370    fn abi_create_with_empty_config() {
371        let plugin_ptr = unsafe { room_plugin_create(std::ptr::null(), 0) };
372        assert!(!plugin_ptr.is_null());
373        let plugin = unsafe { Box::from_raw(plugin_ptr) };
374        assert_eq!(plugin.name(), "taskboard");
375        assert_eq!(plugin.version(), env!("CARGO_PKG_VERSION"));
376    }
377
378    #[test]
379    fn abi_create_with_json_config() {
380        let tmp = tempfile::NamedTempFile::new().unwrap();
381        let config = format!(
382            r#"{{"storage_path":"{}","lease_ttl_secs":300}}"#,
383            tmp.path().display()
384        );
385        let plugin_ptr = unsafe { room_plugin_create(config.as_ptr(), config.len()) };
386        assert!(!plugin_ptr.is_null());
387        let plugin = unsafe { Box::from_raw(plugin_ptr) };
388        assert_eq!(plugin.name(), "taskboard");
389    }
390
391    #[test]
392    fn abi_destroy_frees_plugin() {
393        let plugin_ptr = unsafe { room_plugin_create(std::ptr::null(), 0) };
394        assert!(!plugin_ptr.is_null());
395        // Should not panic or double-free.
396        unsafe { room_plugin_destroy(plugin_ptr) };
397    }
398
399    #[test]
400    fn abi_destroy_null_is_safe() {
401        // Passing null to destroy must be a no-op.
402        unsafe { room_plugin_destroy(std::ptr::null_mut()) };
403    }
404
405    /// Multiple tasks with expired leases must all be swept in a single call,
406    /// and Finished tasks must be left untouched even with a stale lease.
407    #[test]
408    fn sweep_expired_multiple_simultaneous_and_skips_finished() {
409        let (plugin, _tmp) = make_plugin();
410
411        // Seed 4 tasks: 3 Claimed (will expire) + 1 Finished (must survive).
412        {
413            let mut board = plugin.board.lock().unwrap();
414            let stale = std::time::Instant::now() - std::time::Duration::from_secs(700);
415            for i in 1..=3 {
416                let t = task::Task {
417                    id: format!("tb-{i:03}"),
418                    description: format!("expiry test {i}"),
419                    status: TaskStatus::Claimed,
420                    posted_by: "alice".to_owned(),
421                    assigned_to: Some(format!("agent-{i}")),
422                    posted_at: chrono::Utc::now(),
423                    claimed_at: Some(chrono::Utc::now()),
424                    plan: None,
425                    approved_by: None,
426                    approved_at: None,
427                    updated_at: None,
428                    notes: None,
429                    team: None,
430                };
431                let mut lt = LiveTask::new(t);
432                lt.lease_start = Some(stale);
433                board.push(lt);
434            }
435            // Finished task with a stale lease — must NOT be swept.
436            let finished = task::Task {
437                id: "tb-004".to_owned(),
438                description: "finished task".to_owned(),
439                status: TaskStatus::Finished,
440                posted_by: "alice".to_owned(),
441                assigned_to: Some("bob".to_owned()),
442                posted_at: chrono::Utc::now(),
443                claimed_at: Some(chrono::Utc::now()),
444                plan: Some("done".to_owned()),
445                approved_by: None,
446                approved_at: None,
447                updated_at: None,
448                notes: None,
449                team: None,
450            };
451            let mut lt_finished = LiveTask::new(finished);
452            // Manually inject stale lease to simulate edge case.
453            lt_finished.lease_start = Some(stale);
454            board.push(lt_finished);
455        }
456
457        let expired = plugin.sweep_expired();
458
459        // All 3 Claimed tasks should have expired.
460        assert_eq!(
461            expired.len(),
462            3,
463            "expected 3 expired tasks, got {expired:?}"
464        );
465        for id in &expired {
466            assert!(!id.contains("tb-004"), "Finished task must not be swept");
467        }
468
469        // Verify board state after sweep.
470        let board = plugin.board.lock().unwrap();
471        for lt in board.iter() {
472            if lt.task.id == "tb-004" {
473                assert_eq!(lt.task.status, TaskStatus::Finished);
474                assert_eq!(lt.task.assigned_to.as_deref(), Some("bob"));
475                assert_eq!(lt.task.plan.as_deref(), Some("done"));
476            } else {
477                assert_eq!(lt.task.status, TaskStatus::Open);
478                assert!(lt.task.assigned_to.is_none());
479                assert!(lt.lease_start.is_none());
480            }
481        }
482    }
483}