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#[derive(serde::Deserialize)]
23struct TaskboardConfig {
24 storage_path: PathBuf,
25 #[serde(default)]
26 lease_ttl_secs: Option<u64>,
27}
28
29fn create_taskboard_from_config(config: &str) -> TaskboardPlugin {
33 if config.is_empty() {
34 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
45const DEFAULT_LEASE_TTL_SECS: u64 = 600;
47
48pub struct TaskboardPlugin {
55 board: Arc<Mutex<Vec<LiveTask>>>,
57 storage_path: PathBuf,
59 lease_ttl: Duration,
61}
62
63impl TaskboardPlugin {
64 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 pub fn taskboard_path_from_chat(chat_path: &std::path::Path) -> PathBuf {
78 chat_path.with_extension("taskboard")
79 }
80
81 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 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 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 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 #[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 unsafe { room_plugin_destroy(plugin_ptr) };
397 }
398
399 #[test]
400 fn abi_destroy_null_is_safe() {
401 unsafe { room_plugin_destroy(std::ptr::null_mut()) };
403 }
404
405 #[test]
408 fn sweep_expired_multiple_simultaneous_and_skips_finished() {
409 let (plugin, _tmp) = make_plugin();
410
411 {
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 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 lt_finished.lease_start = Some(stale);
454 board.push(lt_finished);
455 }
456
457 let expired = plugin.sweep_expired();
458
459 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 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}