1pub mod task;
2
3use std::path::PathBuf;
4use std::sync::{Arc, Mutex};
5use std::time::Duration;
6
7use task::{next_id, LiveTask, Task, TaskStatus};
8
9use room_protocol::EventType;
10
11use crate::plugin::{
12 BoxFuture, CommandContext, CommandInfo, ParamSchema, ParamType, Plugin, PluginResult,
13};
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, 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 "release".to_owned(),
73 "finish".to_owned(),
74 "cancel".to_owned(),
75 ]),
76 required: true,
77 description: "Subcommand".to_owned(),
78 },
79 ParamSchema {
80 name: "args".to_owned(),
81 param_type: ParamType::Text,
82 required: false,
83 description: "Task ID or description".to_owned(),
84 },
85 ],
86 }]
87 }
88
89 fn sweep_expired(&self) -> Vec<String> {
91 let mut board = self.board.lock().unwrap();
92 let ttl = self.lease_ttl.as_secs();
93 let mut expired_ids = Vec::new();
94 for lt in board.iter_mut() {
95 if lt.is_expired(ttl)
96 && matches!(
97 lt.task.status,
98 TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
99 )
100 {
101 let prev_assignee = lt.task.assigned_to.clone().unwrap_or_default();
102 expired_ids.push(format!("{} (was {})", lt.task.id, prev_assignee));
103 lt.expire();
104 }
105 }
106 if !expired_ids.is_empty() {
107 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
108 let _ = task::save_tasks(&self.storage_path, &tasks);
109 }
110 expired_ids
111 }
112
113 fn handle_post(&self, ctx: &CommandContext) -> (String, bool) {
114 let description = ctx.params[1..].join(" ");
115 if description.is_empty() {
116 return ("usage: /taskboard post <description>".to_owned(), false);
117 }
118 let mut board = self.board.lock().unwrap();
119 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
120 let id = next_id(&tasks);
121 let task = Task {
122 id: id.clone(),
123 description: description.clone(),
124 status: TaskStatus::Open,
125 posted_by: ctx.sender.clone(),
126 assigned_to: None,
127 posted_at: chrono::Utc::now(),
128 claimed_at: None,
129 plan: None,
130 approved_by: None,
131 approved_at: None,
132 updated_at: None,
133 notes: None,
134 };
135 board.push(LiveTask::new(task.clone()));
136 let all_tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
137 let _ = task::save_tasks(&self.storage_path, &all_tasks);
138 (format!("task {id} posted: {description}"), true)
139 }
140
141 fn handle_list(&self) -> String {
142 let expired = self.sweep_expired();
143 let board = self.board.lock().unwrap();
144 if board.is_empty() {
145 return "taskboard is empty".to_owned();
146 }
147 let mut lines = Vec::new();
148 if !expired.is_empty() {
149 lines.push(format!("expired: {}", expired.join(", ")));
150 }
151 lines.push(format!(
152 "{:<8} {:<10} {:<12} {:<12} {}",
153 "ID", "STATUS", "ASSIGNEE", "ELAPSED", "DESCRIPTION"
154 ));
155 for lt in board.iter() {
156 let elapsed = match lt.lease_start {
157 Some(start) => {
158 let secs = start.elapsed().as_secs();
159 if secs < 60 {
160 format!("{secs}s")
161 } else {
162 format!("{}m", secs / 60)
163 }
164 }
165 None => "-".to_owned(),
166 };
167 let assignee = lt.task.assigned_to.as_deref().unwrap_or("-").to_owned();
168 let desc = if lt.task.description.len() > 40 {
169 format!("{}...", <.task.description[..37])
170 } else {
171 lt.task.description.clone()
172 };
173 lines.push(format!(
174 "{:<8} {:<10} {:<12} {:<12} {}",
175 lt.task.id, lt.task.status, assignee, elapsed, desc
176 ));
177 }
178 lines.join("\n")
179 }
180
181 fn handle_claim(&self, ctx: &CommandContext) -> (String, bool) {
182 let task_id = match ctx.params.get(1) {
183 Some(id) => id,
184 None => return ("usage: /taskboard claim <task-id>".to_owned(), false),
185 };
186 self.sweep_expired();
187 let mut board = self.board.lock().unwrap();
188 let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
189 Some(lt) => lt,
190 None => return (format!("task {task_id} not found"), false),
191 };
192 if lt.task.status != TaskStatus::Open {
193 return (
194 format!(
195 "task {task_id} is {} (must be open to claim)",
196 lt.task.status
197 ),
198 false,
199 );
200 }
201 lt.task.status = TaskStatus::Claimed;
202 lt.task.assigned_to = Some(ctx.sender.clone());
203 lt.task.claimed_at = Some(chrono::Utc::now());
204 lt.renew_lease();
205 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
206 let _ = task::save_tasks(&self.storage_path, &tasks);
207 (
208 format!(
209 "task {task_id} claimed by {} — submit plan with /taskboard plan {task_id} <plan>",
210 ctx.sender
211 ),
212 true,
213 )
214 }
215
216 fn handle_plan(&self, ctx: &CommandContext) -> (String, bool) {
217 let task_id = match ctx.params.get(1) {
218 Some(id) => id,
219 None => {
220 return (
221 "usage: /taskboard plan <task-id> <plan text>".to_owned(),
222 false,
223 )
224 }
225 };
226 let plan_text = ctx.params[2..].join(" ");
227 if plan_text.is_empty() {
228 return (
229 "usage: /taskboard plan <task-id> <plan text>".to_owned(),
230 false,
231 );
232 }
233 self.sweep_expired();
234 let mut board = self.board.lock().unwrap();
235 let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
236 Some(lt) => lt,
237 None => return (format!("task {task_id} not found"), false),
238 };
239 if !matches!(lt.task.status, TaskStatus::Claimed | TaskStatus::Planned) {
240 return (
241 format!(
242 "task {task_id} is {} (must be claimed to submit plan)",
243 lt.task.status
244 ),
245 false,
246 );
247 }
248 if lt.task.assigned_to.as_deref() != Some(&ctx.sender) {
249 return (format!("task {task_id} is assigned to someone else"), false);
250 }
251 lt.task.status = TaskStatus::Planned;
252 lt.task.plan = Some(plan_text.clone());
253 lt.renew_lease();
254 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
255 let _ = task::save_tasks(&self.storage_path, &tasks);
256 (
257 format!("task {task_id} plan submitted — awaiting approval\nplan: {plan_text}"),
258 true,
259 )
260 }
261
262 fn handle_approve(&self, ctx: &CommandContext) -> (String, bool) {
263 let task_id = match ctx.params.get(1) {
264 Some(id) => id,
265 None => return ("usage: /taskboard approve <task-id>".to_owned(), false),
266 };
267 self.sweep_expired();
268 let mut board = self.board.lock().unwrap();
269 let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
270 Some(lt) => lt,
271 None => return (format!("task {task_id} not found"), false),
272 };
273 if lt.task.status != TaskStatus::Planned {
274 return (
275 format!(
276 "task {task_id} is {} (must be planned to approve)",
277 lt.task.status
278 ),
279 false,
280 );
281 }
282 let is_poster = lt.task.posted_by == ctx.sender;
284 let is_host = ctx.metadata.host.as_deref() == Some(&ctx.sender);
285 if !is_poster && !is_host {
286 return ("only the task poster or host can approve".to_owned(), false);
287 }
288 lt.task.status = TaskStatus::Approved;
289 lt.task.approved_by = Some(ctx.sender.clone());
290 lt.task.approved_at = Some(chrono::Utc::now());
291 lt.renew_lease();
292 let assignee = lt
293 .task
294 .assigned_to
295 .as_deref()
296 .unwrap_or("unknown")
297 .to_owned();
298 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
299 let _ = task::save_tasks(&self.storage_path, &tasks);
300 (
301 format!(
302 "task {task_id} approved by {} — @{assignee} proceed with implementation",
303 ctx.sender
304 ),
305 true,
306 )
307 }
308
309 fn handle_update(&self, ctx: &CommandContext) -> (String, bool) {
310 let task_id = match ctx.params.get(1) {
311 Some(id) => id,
312 None => {
313 return (
314 "usage: /taskboard update <task-id> [notes]".to_owned(),
315 false,
316 )
317 }
318 };
319 let notes = if ctx.params.len() > 2 {
320 Some(ctx.params[2..].join(" "))
321 } else {
322 None
323 };
324 self.sweep_expired();
325 let mut board = self.board.lock().unwrap();
326 let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
327 Some(lt) => lt,
328 None => return (format!("task {task_id} not found"), false),
329 };
330 if !matches!(
331 lt.task.status,
332 TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
333 ) {
334 return (
335 format!(
336 "task {task_id} is {} (must be claimed/planned/approved to update)",
337 lt.task.status
338 ),
339 false,
340 );
341 }
342 if lt.task.assigned_to.as_deref() != Some(&ctx.sender) {
343 return (format!("task {task_id} is assigned to someone else"), false);
344 }
345 let mut warning = String::new();
346 if lt.task.status != TaskStatus::Approved {
347 warning = format!(" [warning: task is {} — not yet approved]", lt.task.status);
348 }
349 if let Some(n) = notes {
350 lt.task.notes = Some(n);
351 }
352 lt.renew_lease();
353 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
354 let _ = task::save_tasks(&self.storage_path, &tasks);
355 (
356 format!("task {task_id} updated, lease renewed{warning}"),
357 true,
358 )
359 }
360
361 fn handle_release(&self, ctx: &CommandContext) -> (String, bool) {
362 let task_id = match ctx.params.get(1) {
363 Some(id) => id,
364 None => return ("usage: /taskboard release <task-id>".to_owned(), false),
365 };
366 self.sweep_expired();
367 let mut board = self.board.lock().unwrap();
368 let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
369 Some(lt) => lt,
370 None => return (format!("task {task_id} not found"), false),
371 };
372 if !matches!(
373 lt.task.status,
374 TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
375 ) {
376 return (
377 format!(
378 "task {task_id} is {} (must be claimed/planned/approved to release)",
379 lt.task.status
380 ),
381 false,
382 );
383 }
384 if lt.task.assigned_to.as_deref() != Some(&ctx.sender)
386 && ctx.metadata.host.as_deref() != Some(&ctx.sender)
387 {
388 return (
389 format!("task {task_id} can only be released by the assignee or host"),
390 false,
391 );
392 }
393 let prev = lt.task.assigned_to.clone().unwrap_or_default();
394 lt.task.status = TaskStatus::Open;
395 lt.task.assigned_to = None;
396 lt.task.claimed_at = None;
397 lt.task.plan = None;
398 lt.task.approved_by = None;
399 lt.task.approved_at = None;
400 lt.lease_start = None;
401 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
402 let _ = task::save_tasks(&self.storage_path, &tasks);
403 (
404 format!("task {task_id} released by {prev} — back to open"),
405 true,
406 )
407 }
408
409 fn handle_assign(&self, ctx: &CommandContext) -> (String, bool) {
410 let task_id = match ctx.params.get(1) {
411 Some(id) => id,
412 None => {
413 return (
414 "usage: /taskboard assign <task-id> <username>".to_owned(),
415 false,
416 )
417 }
418 };
419 let target_user = match ctx.params.get(2) {
420 Some(u) => u,
421 None => {
422 return (
423 "usage: /taskboard assign <task-id> <username>".to_owned(),
424 false,
425 )
426 }
427 };
428 self.sweep_expired();
429 let mut board = self.board.lock().unwrap();
430 let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
431 Some(lt) => lt,
432 None => return (format!("task {task_id} not found"), false),
433 };
434 if lt.task.status != TaskStatus::Open {
435 return (
436 format!(
437 "task {task_id} is {} (must be open to assign)",
438 lt.task.status
439 ),
440 false,
441 );
442 }
443 let is_poster = lt.task.posted_by == ctx.sender;
445 let is_host = ctx.metadata.host.as_deref() == Some(&ctx.sender);
446 if !is_poster && !is_host {
447 return ("only the task poster or host can assign".to_owned(), false);
448 }
449 lt.task.status = TaskStatus::Claimed;
450 lt.task.assigned_to = Some(target_user.clone());
451 lt.task.claimed_at = Some(chrono::Utc::now());
452 lt.renew_lease();
453 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
454 let _ = task::save_tasks(&self.storage_path, &tasks);
455 (
456 format!("task {task_id} assigned to {target_user} by {}", ctx.sender),
457 true,
458 )
459 }
460
461 fn handle_show(&self, ctx: &CommandContext) -> String {
462 let task_id = match ctx.params.get(1) {
463 Some(id) => id,
464 None => return "usage: /taskboard show <task-id>".to_owned(),
465 };
466 self.sweep_expired();
467 let board = self.board.lock().unwrap();
468 let lt = match board.iter().find(|lt| lt.task.id == *task_id) {
469 Some(lt) => lt,
470 None => return format!("task {task_id} not found"),
471 };
472 let t = <.task;
473 let assignee = t.assigned_to.as_deref().unwrap_or("-");
474 let plan = t.plan.as_deref().unwrap_or("-");
475 let approved_by = t.approved_by.as_deref().unwrap_or("-");
476 let notes = t.notes.as_deref().unwrap_or("-");
477 let elapsed = match lt.lease_start {
478 Some(start) => {
479 let secs = start.elapsed().as_secs();
480 if secs < 60 {
481 format!("{secs}s")
482 } else {
483 format!("{}m", secs / 60)
484 }
485 }
486 None => "-".to_owned(),
487 };
488 format!(
489 "task {}\n status: {}\n description: {}\n posted by: {}\n assigned to: {}\n plan: {}\n approved by: {}\n notes: {}\n lease: {}",
490 t.id, t.status, t.description, t.posted_by, assignee, plan, approved_by, notes, elapsed
491 )
492 }
493
494 fn handle_finish(&self, ctx: &CommandContext) -> (String, bool) {
495 let task_id = match ctx.params.get(1) {
496 Some(id) => id,
497 None => return ("usage: /taskboard finish <task-id>".to_owned(), false),
498 };
499 self.sweep_expired();
500 let mut board = self.board.lock().unwrap();
501 let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
502 Some(lt) => lt,
503 None => return (format!("task {task_id} not found"), false),
504 };
505 if !matches!(
506 lt.task.status,
507 TaskStatus::Claimed | TaskStatus::Planned | TaskStatus::Approved
508 ) {
509 return (
510 format!(
511 "task {task_id} is {} (must be claimed/planned/approved to finish)",
512 lt.task.status
513 ),
514 false,
515 );
516 }
517 if lt.task.assigned_to.as_deref() != Some(&ctx.sender) {
518 return (
519 format!("task {task_id} can only be finished by the assignee"),
520 false,
521 );
522 }
523 lt.task.status = TaskStatus::Finished;
524 lt.lease_start = None;
525 lt.task.updated_at = Some(chrono::Utc::now());
526 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
527 let _ = task::save_tasks(&self.storage_path, &tasks);
528 (format!("task {task_id} finished by {}", ctx.sender), true)
529 }
530
531 fn handle_cancel(&self, ctx: &CommandContext) -> (String, bool) {
532 let task_id = match ctx.params.get(1) {
533 Some(id) => id,
534 None => {
535 return (
536 "usage: /taskboard cancel <task-id> [reason]".to_owned(),
537 false,
538 )
539 }
540 };
541 self.sweep_expired();
542 let mut board = self.board.lock().unwrap();
543 let lt = match board.iter_mut().find(|lt| lt.task.id == *task_id) {
544 Some(lt) => lt,
545 None => return (format!("task {task_id} not found"), false),
546 };
547 if matches!(lt.task.status, TaskStatus::Finished | TaskStatus::Cancelled) {
548 return (
549 format!("task {task_id} is {} (cannot cancel)", lt.task.status),
550 false,
551 );
552 }
553 let is_poster = lt.task.posted_by == ctx.sender;
555 let is_assignee = lt.task.assigned_to.as_deref() == Some(&ctx.sender);
556 let is_host = ctx.metadata.host.as_deref() == Some(&ctx.sender);
557 if !is_poster && !is_assignee && !is_host {
558 return (
559 format!("task {task_id} can only be cancelled by the poster, assignee, or host"),
560 false,
561 );
562 }
563 lt.task.status = TaskStatus::Cancelled;
564 lt.lease_start = None;
565 let reason: String = ctx
566 .params
567 .iter()
568 .skip(2)
569 .cloned()
570 .collect::<Vec<_>>()
571 .join(" ");
572 lt.task.notes = Some(if reason.is_empty() {
573 format!("cancelled by {}", ctx.sender)
574 } else {
575 format!("cancelled by {}: {reason}", ctx.sender)
576 });
577 lt.task.updated_at = Some(chrono::Utc::now());
578 let tasks: Vec<Task> = board.iter().map(|lt| lt.task.clone()).collect();
579 let _ = task::save_tasks(&self.storage_path, &tasks);
580 let msg = if reason.is_empty() {
581 format!("task {task_id} cancelled by {}", ctx.sender)
582 } else {
583 format!("task {task_id} cancelled by {} — {reason}", ctx.sender)
584 };
585 (msg, true)
586 }
587}
588
589impl Plugin for TaskboardPlugin {
590 fn name(&self) -> &str {
591 "taskboard"
592 }
593
594 fn commands(&self) -> Vec<CommandInfo> {
595 Self::default_commands()
596 }
597
598 fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
599 Box::pin(async move {
600 let action = ctx.params.first().map(String::as_str).unwrap_or("");
601 let (result, broadcast) = match action {
602 "post" => self.handle_post(&ctx),
603 "list" => (self.handle_list(), false),
604 "claim" => self.handle_claim(&ctx),
605 "assign" => self.handle_assign(&ctx),
606 "plan" => self.handle_plan(&ctx),
607 "approve" => self.handle_approve(&ctx),
608 "show" => (self.handle_show(&ctx), false),
609 "update" => self.handle_update(&ctx),
610 "release" => self.handle_release(&ctx),
611 "finish" => self.handle_finish(&ctx),
612 "cancel" => self.handle_cancel(&ctx),
613 "" => ("usage: /taskboard <post|list|show|claim|assign|plan|approve|update|release|finish|cancel> [args...]".to_owned(), false),
614 other => (format!("unknown action: {other}. use: post, list, show, claim, assign, plan, approve, update, release, finish, cancel"), false),
615 };
616 if broadcast {
617 let event_type = match action {
619 "post" => Some(EventType::TaskPosted),
620 "claim" => Some(EventType::TaskClaimed),
621 "assign" => Some(EventType::TaskAssigned),
622 "plan" => Some(EventType::TaskPlanned),
623 "approve" => Some(EventType::TaskApproved),
624 "update" => Some(EventType::TaskUpdated),
625 "release" => Some(EventType::TaskReleased),
626 "finish" => Some(EventType::TaskFinished),
627 "cancel" => Some(EventType::TaskCancelled),
628 _ => None,
629 };
630 if let Some(et) = event_type {
631 let _ = ctx.writer.emit_event(et, &result, None).await;
632 }
633 Ok(PluginResult::Broadcast(result))
634 } else {
635 Ok(PluginResult::Reply(result))
636 }
637 })
638 }
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 fn make_plugin() -> (TaskboardPlugin, tempfile::NamedTempFile) {
646 let tmp = tempfile::NamedTempFile::new().unwrap();
647 let plugin = TaskboardPlugin::new(tmp.path().to_path_buf(), Some(600));
648 (plugin, tmp)
649 }
650
651 #[test]
652 fn plugin_name() {
653 let (plugin, _tmp) = make_plugin();
654 assert_eq!(plugin.name(), "taskboard");
655 }
656
657 #[test]
658 fn plugin_commands() {
659 let (plugin, _tmp) = make_plugin();
660 let cmds = plugin.commands();
661 assert_eq!(cmds.len(), 1);
662 assert_eq!(cmds[0].name, "taskboard");
663 assert_eq!(cmds[0].params.len(), 2);
664 if let ParamType::Choice(ref choices) = cmds[0].params[0].param_type {
665 assert!(choices.contains(&"post".to_owned()));
666 assert!(choices.contains(&"approve".to_owned()));
667 assert!(choices.contains(&"assign".to_owned()));
668 assert_eq!(choices.len(), 11);
669 } else {
670 panic!("expected Choice param type");
671 }
672 }
673
674 #[test]
675 fn handle_post_creates_task() {
676 let (plugin, _tmp) = make_plugin();
677 let ctx = test_ctx("alice", &["post", "fix the bug"]);
678 let (result, broadcast) = plugin.handle_post(&ctx);
679 assert!(result.contains("tb-001"));
680 assert!(result.contains("fix the bug"));
681 assert!(broadcast);
682 let board = plugin.board.lock().unwrap();
683 assert_eq!(board.len(), 1);
684 assert_eq!(board[0].task.status, TaskStatus::Open);
685 }
686
687 #[test]
688 fn handle_post_empty_description() {
689 let (plugin, _tmp) = make_plugin();
690 let ctx = test_ctx("alice", &["post"]);
691 let (result, broadcast) = plugin.handle_post(&ctx);
692 assert!(result.contains("usage"));
693 assert!(!broadcast);
694 }
695
696 #[test]
697 fn handle_claim_and_plan_flow() {
698 let (plugin, _tmp) = make_plugin();
699 plugin.handle_post(&test_ctx("ba", &["post", "implement feature"]));
701 let (result, broadcast) = plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
703 assert!(result.contains("claimed by agent"));
704 assert!(broadcast);
705 let (result, broadcast) = plugin.handle_plan(&test_ctx(
707 "agent",
708 &["plan", "tb-001", "add struct, write tests"],
709 ));
710 assert!(result.contains("plan submitted"));
711 assert!(result.contains("plan: add struct, write tests"));
712 assert!(broadcast);
713 let board = plugin.board.lock().unwrap();
714 assert_eq!(board[0].task.status, TaskStatus::Planned);
715 assert_eq!(
716 board[0].task.plan.as_deref(),
717 Some("add struct, write tests")
718 );
719 }
720
721 #[test]
722 fn handle_approve_by_poster() {
723 let (plugin, _tmp) = make_plugin();
724 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
725 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
726 plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "my plan"]));
727 let (result, broadcast) =
729 plugin.handle_approve(&test_ctx_with_host("ba", &["approve", "tb-001"], None));
730 assert!(result.contains("approved"));
731 assert!(broadcast);
732 let board = plugin.board.lock().unwrap();
733 assert_eq!(board[0].task.status, TaskStatus::Approved);
734 }
735
736 #[test]
737 fn handle_approve_by_host() {
738 let (plugin, _tmp) = make_plugin();
739 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
740 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
741 plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "my plan"]));
742 let (result, broadcast) = plugin.handle_approve(&test_ctx_with_host(
744 "joao",
745 &["approve", "tb-001"],
746 Some("joao"),
747 ));
748 assert!(result.contains("approved"));
749 assert!(broadcast);
750 }
751
752 #[test]
753 fn handle_approve_rejected_for_non_poster_non_host() {
754 let (plugin, _tmp) = make_plugin();
755 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
756 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
757 plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "my plan"]));
758 let (result, broadcast) = plugin.handle_approve(&test_ctx_with_host(
760 "random",
761 &["approve", "tb-001"],
762 Some("joao"),
763 ));
764 assert!(result.contains("only the task poster or host"));
765 assert!(!broadcast);
766 }
767
768 #[test]
769 fn handle_update_renews_lease() {
770 let (plugin, _tmp) = make_plugin();
771 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
772 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
773 let (result, broadcast) =
774 plugin.handle_update(&test_ctx("agent", &["update", "tb-001", "progress note"]));
775 assert!(result.contains("lease renewed"));
776 assert!(result.contains("warning")); assert!(broadcast);
778 let board = plugin.board.lock().unwrap();
779 assert_eq!(board[0].task.notes.as_deref(), Some("progress note"));
780 }
781
782 #[test]
783 fn handle_update_no_warning_when_approved() {
784 let (plugin, _tmp) = make_plugin();
785 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
786 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
787 plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "plan"]));
788 plugin.handle_approve(&test_ctx_with_host(
789 "ba",
790 &["approve", "tb-001"],
791 Some("ba"),
792 ));
793 let (result, broadcast) = plugin.handle_update(&test_ctx("agent", &["update", "tb-001"]));
794 assert!(result.contains("lease renewed"));
795 assert!(!result.contains("warning"));
796 assert!(broadcast);
797 }
798
799 #[test]
800 fn handle_release_back_to_open() {
801 let (plugin, _tmp) = make_plugin();
802 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
803 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
804 let (result, broadcast) = plugin.handle_release(&test_ctx("agent", &["release", "tb-001"]));
805 assert!(result.contains("released"));
806 assert!(broadcast);
807 let board = plugin.board.lock().unwrap();
808 assert_eq!(board[0].task.status, TaskStatus::Open);
809 assert!(board[0].task.assigned_to.is_none());
810 }
811
812 #[test]
813 fn handle_finish() {
814 let (plugin, _tmp) = make_plugin();
815 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
816 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
817 let (result, broadcast) = plugin.handle_finish(&test_ctx("agent", &["finish", "tb-001"]));
818 assert!(result.contains("finished"));
819 assert!(broadcast);
820 let board = plugin.board.lock().unwrap();
821 assert_eq!(board[0].task.status, TaskStatus::Finished);
822 }
823
824 #[test]
825 fn handle_claim_wrong_status() {
826 let (plugin, _tmp) = make_plugin();
827 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
828 plugin.handle_claim(&test_ctx("a", &["claim", "tb-001"]));
829 let (result, broadcast) = plugin.handle_claim(&test_ctx("b", &["claim", "tb-001"]));
830 assert!(result.contains("must be open"));
831 assert!(!broadcast);
832 }
833
834 #[test]
835 fn handle_plan_wrong_user() {
836 let (plugin, _tmp) = make_plugin();
837 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
838 plugin.handle_claim(&test_ctx("agent-a", &["claim", "tb-001"]));
839 let (result, broadcast) =
840 plugin.handle_plan(&test_ctx("agent-b", &["plan", "tb-001", "my plan"]));
841 assert!(result.contains("assigned to someone else"));
842 assert!(!broadcast);
843 }
844
845 #[test]
846 fn handle_list_shows_tasks() {
847 let (plugin, _tmp) = make_plugin();
848 plugin.handle_post(&test_ctx("ba", &["post", "first task"]));
849 plugin.handle_post(&test_ctx("ba", &["post", "second task"]));
850 let result = plugin.handle_list();
851 assert!(result.contains("tb-001"));
852 assert!(result.contains("tb-002"));
853 assert!(result.contains("first task"));
854 }
855
856 #[test]
857 fn handle_list_empty() {
858 let (plugin, _tmp) = make_plugin();
859 let result = plugin.handle_list();
860 assert_eq!(result, "taskboard is empty");
861 }
862
863 #[test]
864 fn handle_show_displays_full_detail() {
865 let (plugin, _tmp) = make_plugin();
866 plugin.handle_post(&test_ctx("ba", &["post", "build the feature"]));
867 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
868 plugin.handle_plan(&test_ctx("agent", &["plan", "tb-001", "add struct, tests"]));
869 let result = plugin.handle_show(&test_ctx("anyone", &["show", "tb-001"]));
870 assert!(result.contains("tb-001"));
871 assert!(result.contains("planned"));
872 assert!(result.contains("build the feature"));
873 assert!(result.contains("agent"));
874 assert!(result.contains("add struct, tests"));
875 assert!(result.contains("ba")); }
877
878 #[test]
879 fn handle_show_not_found() {
880 let (plugin, _tmp) = make_plugin();
881 let result = plugin.handle_show(&test_ctx("a", &["show", "tb-999"]));
882 assert!(result.contains("not found"));
883 }
884
885 #[test]
886 fn handle_show_no_args() {
887 let (plugin, _tmp) = make_plugin();
888 let result = plugin.handle_show(&test_ctx("a", &["show"]));
889 assert!(result.contains("usage"));
890 }
891
892 #[test]
893 fn handle_not_found() {
894 let (plugin, _tmp) = make_plugin();
895 let (result, broadcast) = plugin.handle_claim(&test_ctx("a", &["claim", "tb-999"]));
896 assert!(result.contains("not found"));
897 assert!(!broadcast);
898 }
899
900 #[test]
901 fn persistence_survives_reload() {
902 let tmp = tempfile::NamedTempFile::new().unwrap();
903 let path = tmp.path().to_path_buf();
904 {
905 let plugin = TaskboardPlugin::new(path.clone(), Some(600));
906 plugin.handle_post(&test_ctx("ba", &["post", "persistent task"]));
907 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
908 }
909 let plugin2 = TaskboardPlugin::new(path, Some(600));
911 let board = plugin2.board.lock().unwrap();
912 assert_eq!(board.len(), 1);
913 assert_eq!(board[0].task.id, "tb-001");
914 assert_eq!(board[0].task.status, TaskStatus::Claimed);
915 }
916
917 #[test]
918 fn lease_expiry_on_list() {
919 let (plugin, _tmp) = make_plugin();
920 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
921 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
922 {
924 let mut board = plugin.board.lock().unwrap();
925 board[0].lease_start =
926 Some(std::time::Instant::now() - std::time::Duration::from_secs(700));
927 }
928 let result = plugin.handle_list();
929 assert!(result.contains("expired"));
930 let board = plugin.board.lock().unwrap();
931 assert_eq!(board[0].task.status, TaskStatus::Open);
932 }
933
934 #[test]
935 fn full_lifecycle() {
936 let (plugin, _tmp) = make_plugin();
937 plugin.handle_post(&test_ctx("ba", &["post", "implement #42"]));
939 plugin.handle_claim(&test_ctx("saphire", &["claim", "tb-001"]));
940 plugin.handle_plan(&test_ctx(
941 "saphire",
942 &["plan", "tb-001", "add Foo, write tests"],
943 ));
944 plugin.handle_approve(&test_ctx_with_host(
945 "ba",
946 &["approve", "tb-001"],
947 Some("ba"),
948 ));
949 plugin.handle_update(&test_ctx("saphire", &["update", "tb-001", "tests passing"]));
950 plugin.handle_finish(&test_ctx("saphire", &["finish", "tb-001"]));
951 let board = plugin.board.lock().unwrap();
952 assert_eq!(board[0].task.status, TaskStatus::Finished);
953 }
954
955 #[test]
956 fn taskboard_path_from_chat_replaces_extension() {
957 let chat = PathBuf::from("/data/room-dev.chat");
958 let tb = TaskboardPlugin::taskboard_path_from_chat(&chat);
959 assert_eq!(tb, PathBuf::from("/data/room-dev.taskboard"));
960 }
961
962 #[test]
963 fn default_commands_matches_commands() {
964 let (plugin, _tmp) = make_plugin();
965 let default = TaskboardPlugin::default_commands();
966 let instance = plugin.commands();
967 assert_eq!(default.len(), instance.len());
968 assert_eq!(default[0].name, instance[0].name);
969 assert_eq!(default[0].params.len(), instance[0].params.len());
970 }
971
972 #[test]
973 fn handle_assign_happy_path() {
974 let (plugin, _tmp) = make_plugin();
975 plugin.handle_post(&test_ctx("ba", &["post", "implement feature"]));
976 let (result, broadcast) = plugin.handle_assign(&test_ctx_with_host(
977 "ba",
978 &["assign", "tb-001", "agent"],
979 None,
980 ));
981 assert!(result.contains("assigned to agent"));
982 assert!(result.contains("by ba"));
983 assert!(broadcast);
984 let board = plugin.board.lock().unwrap();
985 assert_eq!(board[0].task.status, TaskStatus::Claimed);
986 assert_eq!(board[0].task.assigned_to.as_deref(), Some("agent"));
987 }
988
989 #[test]
990 fn handle_assign_by_host() {
991 let (plugin, _tmp) = make_plugin();
992 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
993 let (result, broadcast) = plugin.handle_assign(&test_ctx_with_host(
995 "joao",
996 &["assign", "tb-001", "saphire"],
997 Some("joao"),
998 ));
999 assert!(result.contains("assigned to saphire"));
1000 assert!(result.contains("by joao"));
1001 assert!(broadcast);
1002 }
1003
1004 #[test]
1005 fn handle_assign_rejected_non_poster_non_host() {
1006 let (plugin, _tmp) = make_plugin();
1007 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
1008 let (result, broadcast) = plugin.handle_assign(&test_ctx_with_host(
1009 "random",
1010 &["assign", "tb-001", "agent"],
1011 Some("joao"),
1012 ));
1013 assert!(result.contains("only the task poster or host"));
1014 assert!(!broadcast);
1015 }
1016
1017 #[test]
1018 fn handle_assign_wrong_status() {
1019 let (plugin, _tmp) = make_plugin();
1020 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
1021 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
1022 let (result, broadcast) =
1024 plugin.handle_assign(&test_ctx("ba", &["assign", "tb-001", "other"]));
1025 assert!(result.contains("must be open to assign"));
1026 assert!(!broadcast);
1027 }
1028
1029 #[test]
1030 fn handle_assign_not_found() {
1031 let (plugin, _tmp) = make_plugin();
1032 let (result, broadcast) =
1033 plugin.handle_assign(&test_ctx("ba", &["assign", "tb-999", "agent"]));
1034 assert!(result.contains("not found"));
1035 assert!(!broadcast);
1036 }
1037
1038 #[test]
1039 fn handle_assign_missing_args() {
1040 let (plugin, _tmp) = make_plugin();
1041 let (result, broadcast) = plugin.handle_assign(&test_ctx("ba", &["assign"]));
1043 assert!(result.contains("usage"));
1044 assert!(!broadcast);
1045 let (result, broadcast) = plugin.handle_assign(&test_ctx("ba", &["assign", "tb-001"]));
1047 assert!(result.contains("usage"));
1048 assert!(!broadcast);
1049 }
1050
1051 #[test]
1052 fn handle_assign_then_plan_and_finish() {
1053 let (plugin, _tmp) = make_plugin();
1054 plugin.handle_post(&test_ctx("ba", &["post", "implement #502"]));
1055 plugin.handle_assign(&test_ctx("ba", &["assign", "tb-001", "agent"]));
1057 let (result, broadcast) = plugin.handle_plan(&test_ctx(
1059 "agent",
1060 &["plan", "tb-001", "add handler and tests"],
1061 ));
1062 assert!(result.contains("plan submitted"));
1063 assert!(broadcast);
1064 plugin.handle_approve(&test_ctx_with_host(
1066 "ba",
1067 &["approve", "tb-001"],
1068 Some("ba"),
1069 ));
1070 let (result, broadcast) = plugin.handle_finish(&test_ctx("agent", &["finish", "tb-001"]));
1071 assert!(result.contains("finished"));
1072 assert!(broadcast);
1073 }
1074
1075 #[test]
1076 fn handle_cancel_by_poster() {
1077 let (plugin, _tmp) = make_plugin();
1078 plugin.handle_post(&test_ctx("ba", &["post", "obsolete task"]));
1079 let (result, broadcast) =
1080 plugin.handle_cancel(&test_ctx("ba", &["cancel", "tb-001", "no longer needed"]));
1081 assert!(result.contains("cancelled by ba"));
1082 assert!(result.contains("no longer needed"));
1083 assert!(broadcast);
1084 let board = plugin.board.lock().unwrap();
1085 assert_eq!(board[0].task.status, TaskStatus::Cancelled);
1086 assert!(board[0]
1087 .task
1088 .notes
1089 .as_deref()
1090 .unwrap()
1091 .contains("no longer needed"));
1092 assert!(board[0].lease_start.is_none());
1093 }
1094
1095 #[test]
1096 fn handle_cancel_by_assignee() {
1097 let (plugin, _tmp) = make_plugin();
1098 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
1099 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
1100 let (result, broadcast) = plugin.handle_cancel(&test_ctx("agent", &["cancel", "tb-001"]));
1101 assert!(result.contains("cancelled by agent"));
1102 assert!(broadcast);
1103 let board = plugin.board.lock().unwrap();
1104 assert_eq!(board[0].task.status, TaskStatus::Cancelled);
1105 assert!(board[0]
1106 .task
1107 .notes
1108 .as_deref()
1109 .unwrap()
1110 .contains("cancelled by agent"));
1111 }
1112
1113 #[test]
1114 fn handle_cancel_by_host() {
1115 let (plugin, _tmp) = make_plugin();
1116 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
1117 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
1118 let (result, broadcast) = plugin.handle_cancel(&test_ctx_with_host(
1120 "joao",
1121 &["cancel", "tb-001", "scope changed"],
1122 Some("joao"),
1123 ));
1124 assert!(result.contains("cancelled by joao"));
1125 assert!(result.contains("scope changed"));
1126 assert!(broadcast);
1127 }
1128
1129 #[test]
1130 fn handle_cancel_finished_rejected() {
1131 let (plugin, _tmp) = make_plugin();
1132 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
1133 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
1134 plugin.handle_finish(&test_ctx("agent", &["finish", "tb-001"]));
1135 let (result, broadcast) = plugin.handle_cancel(&test_ctx("ba", &["cancel", "tb-001"]));
1136 assert!(result.contains("cannot cancel"));
1137 assert!(result.contains("finished"));
1138 assert!(!broadcast);
1139 }
1140
1141 #[test]
1142 fn handle_cancel_unauthorized_rejected() {
1143 let (plugin, _tmp) = make_plugin();
1144 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
1145 plugin.handle_claim(&test_ctx("agent", &["claim", "tb-001"]));
1146 let (result, broadcast) = plugin.handle_cancel(&test_ctx_with_host(
1148 "random",
1149 &["cancel", "tb-001"],
1150 Some("joao"),
1151 ));
1152 assert!(result.contains("poster, assignee, or host"));
1153 assert!(!broadcast);
1154 }
1155
1156 #[test]
1157 fn handle_cancel_no_reason() {
1158 let (plugin, _tmp) = make_plugin();
1159 plugin.handle_post(&test_ctx("ba", &["post", "task"]));
1160 let (result, broadcast) = plugin.handle_cancel(&test_ctx("ba", &["cancel", "tb-001"]));
1161 assert!(result.contains("cancelled by ba"));
1162 assert!(!result.contains("—")); assert!(broadcast);
1164 let board = plugin.board.lock().unwrap();
1165 assert_eq!(board[0].task.notes.as_deref(), Some("cancelled by ba"));
1166 }
1167
1168 fn test_ctx(sender: &str, params: &[&str]) -> CommandContext {
1171 test_ctx_with_host(sender, params, None)
1172 }
1173
1174 fn test_ctx_with_host(sender: &str, params: &[&str], host: Option<&str>) -> CommandContext {
1175 use std::collections::HashMap;
1176 use std::sync::atomic::AtomicU64;
1177
1178 use crate::plugin::{ChatWriter, RoomMetadata, UserInfo};
1179
1180 let clients = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
1181 let chat_path = Arc::new(PathBuf::from("/dev/null"));
1182 let room_id = Arc::new("test-room".to_owned());
1183 let seq_counter = Arc::new(AtomicU64::new(0));
1184 let writer = ChatWriter::new(&clients, &chat_path, &room_id, &seq_counter, "taskboard");
1185
1186 CommandContext {
1187 command: "taskboard".to_owned(),
1188 params: params.iter().map(|s| s.to_string()).collect(),
1189 sender: sender.to_owned(),
1190 room_id: "test-room".to_owned(),
1191 message_id: "msg-001".to_owned(),
1192 timestamp: chrono::Utc::now(),
1193 history: crate::plugin::HistoryReader::new(std::path::Path::new("/dev/null"), sender),
1194 writer,
1195 metadata: RoomMetadata {
1196 online_users: vec![UserInfo {
1197 username: sender.to_owned(),
1198 status: String::new(),
1199 }],
1200 host: host.map(|h| h.to_owned()),
1201 message_count: 0,
1202 },
1203 available_commands: vec![],
1204 }
1205 }
1206}