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
15const DEFAULT_LEASE_TTL_SECS: u64 = 600;
17
18pub struct TaskboardPlugin {
25 board: Arc<Mutex<Vec<LiveTask>>>,
27 storage_path: PathBuf,
29 lease_ttl: Duration,
31}
32
33impl TaskboardPlugin {
34 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 pub fn taskboard_path_from_chat(chat_path: &std::path::Path) -> PathBuf {
48 chat_path.with_extension("taskboard")
49 }
50
51 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 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 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 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 #[test]
329 fn sweep_expired_multiple_simultaneous_and_skips_finished() {
330 let (plugin, _tmp) = make_plugin();
331
332 {
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 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 lt_finished.lease_start = Some(stale);
375 board.push(lt_finished);
376 }
377
378 let expired = plugin.sweep_expired();
379
380 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 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}