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/// Default lease TTL in seconds (10 minutes).
16const DEFAULT_LEASE_TTL_SECS: u64 = 600;
17
18/// Unified task lifecycle plugin with lease-based expiry.
19///
20/// Manages a board of tasks that agents can post, claim, plan, get approved,
21/// update, release, and finish. Claimed tasks have a configurable lease TTL —
22/// if not renewed via `/taskboard update` or `/taskboard plan`, they auto-
23/// release back to open status (lazy sweep on access).
24pub struct TaskboardPlugin {
25    /// In-memory task board with lease timers.
26    board: Arc<Mutex<Vec<LiveTask>>>,
27    /// Path to the NDJSON persistence file.
28    storage_path: PathBuf,
29    /// Lease TTL duration.
30    lease_ttl: Duration,
31}
32
33impl TaskboardPlugin {
34    /// Create a new taskboard plugin, loading existing tasks from disk.
35    pub fn new(storage_path: PathBuf, lease_ttl_secs: Option<u64>) -> Self {
36        let ttl = lease_ttl_secs.unwrap_or(DEFAULT_LEASE_TTL_SECS);
37        let tasks = task::load_tasks(&storage_path);
38        let live_tasks: Vec<LiveTask> = tasks.into_iter().map(LiveTask::new).collect();
39        Self {
40            board: Arc::new(Mutex::new(live_tasks)),
41            storage_path,
42            lease_ttl: Duration::from_secs(ttl),
43        }
44    }
45
46    /// Derive the `.taskboard` file path from a `.chat` file path.
47    pub fn taskboard_path_from_chat(chat_path: &std::path::Path) -> PathBuf {
48        chat_path.with_extension("taskboard")
49    }
50
51    /// Returns the command info for the TUI command palette without needing
52    /// an instantiated plugin. Used by `all_known_commands()`.
53    pub fn default_commands() -> Vec<CommandInfo> {
54        vec![CommandInfo {
55            name: "taskboard".to_owned(),
56            description:
57                "Manage task lifecycle — post, list, show, claim, assign, plan, approve, update, review, release, finish, cancel"
58                    .to_owned(),
59            usage: "/taskboard <action> [args...]".to_owned(),
60            params: vec![
61                ParamSchema {
62                    name: "action".to_owned(),
63                    param_type: ParamType::Choice(vec![
64                        "post".to_owned(),
65                        "list".to_owned(),
66                        "show".to_owned(),
67                        "claim".to_owned(),
68                        "assign".to_owned(),
69                        "plan".to_owned(),
70                        "approve".to_owned(),
71                        "update".to_owned(),
72                        "review".to_owned(),
73                        "release".to_owned(),
74                        "finish".to_owned(),
75                        "cancel".to_owned(),
76                    ]),
77                    required: true,
78                    description: "Subcommand".to_owned(),
79                },
80                ParamSchema {
81                    name: "args".to_owned(),
82                    param_type: ParamType::Text,
83                    required: false,
84                    description: "Task ID or description".to_owned(),
85                },
86            ],
87        }]
88    }
89
90    /// Sweep expired leases (lazy — called before reads).
91    fn sweep_expired(&self) -> Vec<String> {
92        let mut board = self.board.lock().unwrap();
93        let ttl = self.lease_ttl.as_secs();
94        let mut expired_ids = Vec::new();
95        for lt in board.iter_mut() {
96            if lt.is_expired(ttl)
97                && matches!(
98                    lt.task.status,
99                    TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
100                )
101            {
102                let prev_assignee = lt.task.assigned_to.clone().unwrap_or_default();
103                expired_ids.push(format!("{} (was {})", lt.task.id, prev_assignee));
104                lt.expire();
105            }
106        }
107        if !expired_ids.is_empty() {
108            let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
109            let _ = task::save_tasks(&self.storage_path, &tasks);
110        }
111        expired_ids
112    }
113}
114
115impl Plugin for TaskboardPlugin {
116    fn name(&self) -> &str {
117        "taskboard"
118    }
119
120    fn version(&self) -> &str {
121        env!("CARGO_PKG_VERSION")
122    }
123
124    fn commands(&self) -> Vec<CommandInfo> {
125        Self::default_commands()
126    }
127
128    fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
129        Box::pin(async move {
130            let action = ctx.params.first().map(String::as_str).unwrap_or("");
131            let (result, broadcast) = match action {
132                "post" => self.handle_post(&ctx),
133                "list" => {
134                    let show_all = ctx.params.get(1).map(|s| s.as_str()) == Some("all");
135                    (self.handle_list(show_all), false)
136                }
137                "claim" => self.handle_claim(&ctx),
138                "assign" => self.handle_assign(&ctx),
139                "plan" => self.handle_plan(&ctx),
140                "approve" => self.handle_approve(&ctx),
141                "show" => (self.handle_show(&ctx), false),
142                "update" => self.handle_update(&ctx),
143                "release" => self.handle_release(&ctx),
144                "review" => self.handle_review(&ctx),
145                "finish" => self.handle_finish(&ctx),
146                "cancel" => self.handle_cancel(&ctx),
147                "" => ("usage: /taskboard <post|list|show|claim|assign|plan|approve|update|review|release|finish|cancel> [args...]".to_owned(), false),
148                other => (format!("unknown action: {other}. use: post, list, show, claim, assign, plan, approve, update, review, release, finish, cancel"), false),
149            };
150            if broadcast {
151                // Emit a typed event alongside the system broadcast.
152                let event_type = match action {
153                    "post" => Some(EventType::TaskPosted),
154                    "claim" => Some(EventType::TaskClaimed),
155                    "assign" => Some(EventType::TaskAssigned),
156                    "plan" => Some(EventType::TaskPlanned),
157                    "approve" => Some(EventType::TaskApproved),
158                    "update" => Some(EventType::TaskUpdated),
159                    "review" => Some(EventType::ReviewRequested),
160                    "release" => Some(EventType::TaskReleased),
161                    "finish" => Some(EventType::TaskFinished),
162                    "cancel" => Some(EventType::TaskCancelled),
163                    _ => None,
164                };
165                if let Some(et) = event_type {
166                    let _ = ctx.writer.emit_event(et, &result, None).await;
167                }
168                Ok(PluginResult::Broadcast(result, None))
169            } else {
170                Ok(PluginResult::Reply(result, None))
171            }
172        })
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    fn make_plugin() -> (TaskboardPlugin, tempfile::NamedTempFile) {
181        let tmp = tempfile::NamedTempFile::new().unwrap();
182        let plugin = TaskboardPlugin::new(tmp.path().to_path_buf(), Some(600));
183        (plugin, tmp)
184    }
185
186    #[test]
187    fn plugin_name() {
188        let (plugin, _tmp) = make_plugin();
189        assert_eq!(plugin.name(), "taskboard");
190    }
191
192    #[test]
193    fn plugin_version_matches_crate() {
194        let (plugin, _tmp) = make_plugin();
195        assert_eq!(plugin.version(), env!("CARGO_PKG_VERSION"));
196    }
197
198    #[test]
199    fn plugin_api_version_is_current() {
200        let (plugin, _tmp) = make_plugin();
201        assert_eq!(
202            plugin.api_version(),
203            room_protocol::plugin::PLUGIN_API_VERSION
204        );
205    }
206
207    #[test]
208    fn plugin_min_protocol_is_compatible() {
209        let (plugin, _tmp) = make_plugin();
210        // Default min_protocol is "0.0.0", which is always satisfied.
211        assert_eq!(plugin.min_protocol(), "0.0.0");
212    }
213
214    #[test]
215    fn plugin_commands() {
216        let (plugin, _tmp) = make_plugin();
217        let cmds = plugin.commands();
218        assert_eq!(cmds.len(), 1);
219        assert_eq!(cmds[0].name, "taskboard");
220        assert_eq!(cmds[0].params.len(), 2);
221        if let ParamType::Choice(ref choices) = cmds[0].params[0].param_type {
222            assert!(choices.contains(&"post".to_owned()));
223            assert!(choices.contains(&"approve".to_owned()));
224            assert!(choices.contains(&"assign".to_owned()));
225            assert_eq!(choices.len(), 12);
226        } else {
227            panic!("expected Choice param type");
228        }
229    }
230
231    #[test]
232    fn taskboard_path_from_chat_replaces_extension() {
233        let chat = PathBuf::from("/data/room-dev.chat");
234        let tb = TaskboardPlugin::taskboard_path_from_chat(&chat);
235        assert_eq!(tb, PathBuf::from("/data/room-dev.taskboard"));
236    }
237
238    #[test]
239    fn default_commands_matches_commands() {
240        let (plugin, _tmp) = make_plugin();
241        let default = TaskboardPlugin::default_commands();
242        let instance = plugin.commands();
243        assert_eq!(default.len(), instance.len());
244        assert_eq!(default[0].name, instance[0].name);
245        assert_eq!(default[0].params.len(), instance[0].params.len());
246    }
247
248    fn seed_task(plugin: &TaskboardPlugin, id: &str, status: TaskStatus) {
249        let mut board = plugin.board.lock().unwrap();
250        let t = task::Task {
251            id: id.to_owned(),
252            description: format!("task {id}"),
253            status,
254            posted_by: "alice".to_owned(),
255            assigned_to: if status != TaskStatus::Open {
256                Some("bob".to_owned())
257            } else {
258                None
259            },
260            posted_at: chrono::Utc::now(),
261            claimed_at: None,
262            plan: None,
263            approved_by: None,
264            approved_at: None,
265            updated_at: None,
266            notes: None,
267            team: None,
268        };
269        board.push(LiveTask::new(t));
270    }
271
272    #[test]
273    fn handle_list_filters_terminal_tasks() {
274        let (plugin, _tmp) = make_plugin();
275        seed_task(&plugin, "tb-001", TaskStatus::Open);
276        seed_task(&plugin, "tb-002", TaskStatus::Claimed);
277        seed_task(&plugin, "tb-003", TaskStatus::Finished);
278        seed_task(&plugin, "tb-004", TaskStatus::Cancelled);
279
280        let output = plugin.handle_list(false);
281        assert!(output.contains("tb-001"), "open task should appear");
282        assert!(output.contains("tb-002"), "claimed task should appear");
283        assert!(!output.contains("tb-003"), "finished task should be hidden");
284        assert!(
285            !output.contains("tb-004"),
286            "cancelled task should be hidden"
287        );
288    }
289
290    #[test]
291    fn handle_list_all_shows_everything() {
292        let (plugin, _tmp) = make_plugin();
293        seed_task(&plugin, "tb-001", TaskStatus::Open);
294        seed_task(&plugin, "tb-002", TaskStatus::Finished);
295        seed_task(&plugin, "tb-003", TaskStatus::Cancelled);
296
297        let output = plugin.handle_list(true);
298        assert!(output.contains("tb-001"), "open task should appear");
299        assert!(
300            output.contains("tb-002"),
301            "finished task should appear with all"
302        );
303        assert!(
304            output.contains("tb-003"),
305            "cancelled task should appear with all"
306        );
307    }
308
309    #[test]
310    fn handle_list_empty_after_filter() {
311        let (plugin, _tmp) = make_plugin();
312        seed_task(&plugin, "tb-001", TaskStatus::Finished);
313        seed_task(&plugin, "tb-002", TaskStatus::Cancelled);
314
315        let output = plugin.handle_list(false);
316        assert!(
317            output.contains("no active tasks"),
318            "should show helpful empty message, got: {output}"
319        );
320        assert!(
321            output.contains("/taskboard list all"),
322            "should hint at 'list all' command"
323        );
324    }
325
326    /// Multiple tasks with expired leases must all be swept in a single call,
327    /// and Finished tasks must be left untouched even with a stale lease.
328    #[test]
329    fn sweep_expired_multiple_simultaneous_and_skips_finished() {
330        let (plugin, _tmp) = make_plugin();
331
332        // Seed 4 tasks: 3 Claimed (will expire) + 1 Finished (must survive).
333        {
334            let mut board = plugin.board.lock().unwrap();
335            let stale = std::time::Instant::now() - std::time::Duration::from_secs(700);
336            for i in 1..=3 {
337                let t = task::Task {
338                    id: format!("tb-{i:03}"),
339                    description: format!("expiry test {i}"),
340                    status: TaskStatus::Claimed,
341                    posted_by: "alice".to_owned(),
342                    assigned_to: Some(format!("agent-{i}")),
343                    posted_at: chrono::Utc::now(),
344                    claimed_at: Some(chrono::Utc::now()),
345                    plan: None,
346                    approved_by: None,
347                    approved_at: None,
348                    updated_at: None,
349                    notes: None,
350                    team: None,
351                };
352                let mut lt = LiveTask::new(t);
353                lt.lease_start = Some(stale);
354                board.push(lt);
355            }
356            // Finished task with a stale lease — must NOT be swept.
357            let finished = task::Task {
358                id: "tb-004".to_owned(),
359                description: "finished task".to_owned(),
360                status: TaskStatus::Finished,
361                posted_by: "alice".to_owned(),
362                assigned_to: Some("bob".to_owned()),
363                posted_at: chrono::Utc::now(),
364                claimed_at: Some(chrono::Utc::now()),
365                plan: Some("done".to_owned()),
366                approved_by: None,
367                approved_at: None,
368                updated_at: None,
369                notes: None,
370                team: None,
371            };
372            let mut lt_finished = LiveTask::new(finished);
373            // Manually inject stale lease to simulate edge case.
374            lt_finished.lease_start = Some(stale);
375            board.push(lt_finished);
376        }
377
378        let expired = plugin.sweep_expired();
379
380        // All 3 Claimed tasks should have expired.
381        assert_eq!(
382            expired.len(),
383            3,
384            "expected 3 expired tasks, got {expired:?}"
385        );
386        for id in &expired {
387            assert!(!id.contains("tb-004"), "Finished task must not be swept");
388        }
389
390        // Verify board state after sweep.
391        let board = plugin.board.lock().unwrap();
392        for lt in board.iter() {
393            if lt.task.id == "tb-004" {
394                assert_eq!(lt.task.status, TaskStatus::Finished);
395                assert_eq!(lt.task.assigned_to.as_deref(), Some("bob"));
396                assert_eq!(lt.task.plan.as_deref(), Some("done"));
397            } else {
398                assert_eq!(lt.task.status, TaskStatus::Open);
399                assert!(lt.task.assigned_to.is_none());
400                assert!(lt.lease_start.is_none());
401            }
402        }
403    }
404}