1use std::path::Path;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use mana::commands::agents::{agents_file_path, load_agents};
6use mana::commands::logs::find_all_logs;
7use mana::commands::next::ScoredUnit;
8use mana::commands::run::{NativeRunParams, RunSummary, RunTarget, RunUnitStatus, RunView};
9use mana::stream::StreamEvent;
10use mana_core::ops::claim::ClaimParams;
11use mana_core::unit::{OnFailAction, UnitType};
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14
15use super::{truncate_head, Tool, ToolContext, ToolOutput, ToolUpdate};
16use crate::error::Result;
17use crate::ui::{NotifyLevel, WidgetContent};
18const MAX_OUTPUT_LINES: usize = 2000;
19const MAX_OUTPUT_BYTES: usize = 50 * 1024;
20const MAX_STORED_RUN_EVENTS: usize = 64;
21const MAX_PERSISTED_RUN_LOG_LINES: usize = 50;
22const FINISHED_RUN_TTL_MS: u128 = 60 * 60 * 1000;
23
24fn find_mana_dir(cwd: &Path) -> std::result::Result<std::path::PathBuf, String> {
25 mana_core::discovery::find_mana_dir(cwd).map_err(|e| e.to_string())
26}
27
28fn resolve_mana_dir(
29 cwd: &Path,
30 params: &serde_json::Value,
31) -> std::result::Result<std::path::PathBuf, String> {
32 let scope = params
33 .get("scope")
34 .and_then(|v| v.as_str())
35 .or_else(|| params.get("mana_scope").and_then(|v| v.as_str()))
36 .unwrap_or("auto");
37
38 if let Some(explicit) = params
39 .get("path")
40 .and_then(|v| v.as_str())
41 .or_else(|| params.get("mana_dir").and_then(|v| v.as_str()))
42 {
43 let path = Path::new(explicit);
44 let resolved = if path.is_absolute() {
45 path.to_path_buf()
46 } else {
47 cwd.join(path)
48 };
49 return Ok(
50 if resolved.file_name().and_then(|name| name.to_str()) == Some(".mana") {
51 resolved
52 } else {
53 resolved.join(".mana")
54 },
55 );
56 }
57
58 match scope {
59 "auto" | "project" => find_mana_dir(cwd),
60 "root" => mana_core::discovery::find_outermost_mana_dir(cwd).map_err(|e| e.to_string()),
61 other => Err(format!(
62 "Unknown mana scope '{other}'. Use auto, project, or root."
63 )),
64 }
65}
66
67fn json_output(value: &impl serde::Serialize) -> ToolOutput {
68 match serde_json::to_string_pretty(value) {
69 Ok(json) => ToolOutput {
70 content: vec![imp_llm::ContentBlock::Text { text: json }],
71 details: serde_json::to_value(value).unwrap_or(serde_json::Value::Null),
72 is_error: false,
73 },
74 Err(e) => ToolOutput::error(format!("Failed to serialize: {e}")),
75 }
76}
77
78fn send_update(ctx: &ToolContext, text: impl Into<String>, details: serde_json::Value) {
79 let _ = ctx.update_tx.try_send(ToolUpdate {
80 content: vec![imp_llm::ContentBlock::Text { text: text.into() }],
81 details,
82 });
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86struct NativeRunParamsView {
87 target: serde_json::Value,
88 jobs: u32,
89 dry_run: bool,
90 loop_mode: bool,
91 keep_going: bool,
92 timeout: u32,
93 idle_timeout: u32,
94 review: bool,
95}
96
97impl From<&NativeRunParams> for NativeRunParamsView {
98 fn from(args: &NativeRunParams) -> Self {
99 let target = match &args.target {
100 RunTarget::AllReady => json!({"kind": "all_ready"}),
101 RunTarget::Unit(id) => json!({"kind": "unit", "id": id}),
102 RunTarget::Explicit(ids) => json!({"kind": "explicit", "ids": ids}),
103 };
104 Self {
105 target,
106 jobs: args.jobs,
107 dry_run: args.dry_run,
108 loop_mode: args.loop_mode,
109 keep_going: args.keep_going,
110 timeout: args.timeout,
111 idle_timeout: args.idle_timeout,
112 review: args.review,
113 }
114 }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118struct NativeRunState {
119 run_id: String,
120 scope: String,
121 background: bool,
122 status: String,
123 error: Option<String>,
124 started_at_ms: u128,
125 finished_at_ms: Option<u128>,
126 args: NativeRunParamsView,
127 runtime: Option<serde_json::Value>,
128 summary: RunSummary,
129 units: Vec<RunUnitStatus>,
130 log_lines: Vec<String>,
131 event_count: usize,
132}
133
134impl NativeRunState {
135 fn new(run_id: String, scope: String, background: bool, args: &NativeRunParams) -> Self {
136 Self {
137 run_id,
138 scope,
139 background,
140 status: "starting".to_string(),
141 error: None,
142 started_at_ms: unix_time_ms(),
143 finished_at_ms: None,
144 args: NativeRunParamsView::from(args),
145 runtime: None,
146 summary: RunSummary {
147 total_units: 0,
148 total_rounds: 0,
149 total_closed: 0,
150 total_failed: 0,
151 total_abandoned: 0,
152 total_awaiting_verify: 0,
153 total_skipped: 0,
154 duration_secs: 0,
155 },
156 units: Vec::new(),
157 log_lines: Vec::new(),
158 event_count: 0,
159 }
160 }
161
162 fn apply_event(&mut self, event: &StreamEvent) {
163 self.event_count += 1;
164 if let Some(line) = stream_event_line(event) {
165 self.log_lines.push(line);
166 trim_log_lines(&mut self.log_lines, MAX_STORED_RUN_EVENTS);
167 }
168
169 match event {
170 StreamEvent::RunStart {
171 total_units,
172 total_rounds,
173 units,
174 runtime,
175 ..
176 } => {
177 self.status = "running".to_string();
178 self.summary.total_units = *total_units;
179 self.summary.total_rounds = *total_rounds;
180 self.runtime = runtime
181 .as_ref()
182 .and_then(|value| serde_json::to_value(value).ok());
183 self.units = units
184 .iter()
185 .map(|info| RunUnitStatus {
186 id: info.id.clone(),
187 title: info.title.clone(),
188 status: "queued".to_string(),
189 round: Some(info.round),
190 agent: None,
191 model: None,
192 duration_secs: None,
193 tool_count: None,
194 turns: None,
195 failure_summary: None,
196 error: None,
197 })
198 .collect();
199 self.units.sort_by(|a, b| a.id.cmp(&b.id));
200 }
201 StreamEvent::RunPlan {
202 total_units,
203 runtime,
204 ..
205 } => {
206 self.status = "running".to_string();
207 self.summary.total_units = (*total_units).max(self.summary.total_units);
208 if runtime.is_some() {
209 self.runtime = runtime
210 .as_ref()
211 .and_then(|value| serde_json::to_value(value).ok());
212 }
213 }
214 StreamEvent::RoundStart { total_rounds, .. } => {
215 self.status = "running".to_string();
216 self.summary.total_rounds = (*total_rounds).max(self.summary.total_rounds);
217 }
218 StreamEvent::UnitReady { id, title, .. } => {
219 let unit = ensure_unit_status(&mut self.units, id, title);
220 unit.status = "queued".to_string();
221 }
222 StreamEvent::UnitStart {
223 id, title, round, ..
224 } => {
225 self.status = "running".to_string();
226 let unit = ensure_unit_status(&mut self.units, id, title);
227 unit.title = title.clone();
228 unit.round = Some(*round);
229 unit.status = "running".to_string();
230 }
231 StreamEvent::UnitDone {
232 id,
233 success,
234 duration_secs,
235 error,
236 tool_count,
237 turns,
238 failure_summary,
239 ..
240 } => {
241 let unit = ensure_unit_status(&mut self.units, id, id);
242 unit.status = if *success { "done" } else { "failed" }.to_string();
243 unit.duration_secs = Some(*duration_secs);
244 unit.tool_count = *tool_count;
245 unit.turns = *turns;
246 unit.failure_summary = failure_summary.clone();
247 unit.error = error.clone();
248 }
249 StreamEvent::BatchVerify { passed, failed, .. } => {
250 for id in passed {
251 let unit = ensure_unit_status(&mut self.units, id, id);
252 unit.status = "done".to_string();
253 }
254 for id in failed {
255 let unit = ensure_unit_status(&mut self.units, id, id);
256 unit.status = "failed".to_string();
257 }
258 }
259 StreamEvent::RunEnd {
260 total_closed,
261 total_failed,
262 total_abandoned,
263 total_awaiting_verify,
264 total_skipped,
265 duration_secs,
266 ..
267 } => {
268 self.summary.total_closed = *total_closed;
269 self.summary.total_failed = *total_failed;
270 self.summary.total_abandoned = *total_abandoned;
271 self.summary.total_awaiting_verify = *total_awaiting_verify;
272 self.summary.total_skipped = *total_skipped;
273 self.summary.duration_secs = *duration_secs;
274 self.status = "finished".to_string();
275 self.finished_at_ms = Some(unix_time_ms());
276 }
277 StreamEvent::DryRun { runtime, .. } => {
278 self.status = "finished".to_string();
279 if runtime.is_some() {
280 self.runtime = runtime
281 .as_ref()
282 .and_then(|value| serde_json::to_value(value).ok());
283 }
284 self.finished_at_ms = Some(unix_time_ms());
285 }
286 StreamEvent::Error { message } => {
287 self.status = "failed".to_string();
288 self.error = Some(message.clone());
289 self.finished_at_ms = Some(unix_time_ms());
290 }
291 _ => {}
292 }
293 }
294
295 fn finish_with_view(&mut self, view: &RunView) {
296 self.summary = view.summary.clone();
297 self.units = view.units.clone();
298 self.runtime = view
299 .runtime
300 .as_ref()
301 .and_then(|value| serde_json::to_value(value).ok());
302 self.status = "finished".to_string();
303 self.error = None;
304 self.finished_at_ms = Some(unix_time_ms());
305 }
306
307 fn fail(&mut self, error: String) {
308 self.status = "failed".to_string();
309 self.error = Some(error.clone());
310 self.finished_at_ms = Some(unix_time_ms());
311 self.log_lines.push(error);
312 trim_log_lines(&mut self.log_lines, MAX_STORED_RUN_EVENTS);
313 }
314
315 fn persisted(&self) -> Self {
316 let mut state = self.clone();
317 trim_log_lines(&mut state.log_lines, MAX_PERSISTED_RUN_LOG_LINES);
318 state
319 }
320}
321
322#[derive(Debug, Clone, Default, Serialize, Deserialize)]
323struct ManaRunStore {
324 next_id: u64,
325 runs: Vec<NativeRunState>,
326}
327
328impl ManaRunStore {
329 fn start_run(&mut self, scope: String, background: bool, args: &NativeRunParams) -> String {
330 self.next_id += 1;
331 let run_id = format!("run-{}", self.next_id);
332 self.runs
333 .push(NativeRunState::new(run_id.clone(), scope, background, args));
334 self.trim_history();
335 run_id
336 }
337
338 fn persist(&self) {
339 let path = run_state_file();
340 let persisted = Self {
341 next_id: self.next_id,
342 runs: self.runs.iter().map(NativeRunState::persisted).collect(),
343 };
344 if let Ok(json) = serde_json::to_string_pretty(&persisted) {
345 let _ = std::fs::write(path, json);
346 }
347 }
348
349 fn load_persisted() -> Self {
350 let path = run_state_file();
351 if !path.exists() {
352 return Self::default();
353 }
354
355 let Ok(contents) = std::fs::read_to_string(path) else {
356 return Self::default();
357 };
358 if contents.trim().is_empty() {
359 return Self::default();
360 }
361
362 let Ok(mut store) = serde_json::from_str::<Self>(&contents) else {
363 return Self::default();
364 };
365
366 store.discard_expired_finished_runs();
367 store.trim_history();
368 store
369 }
370
371 fn discard_expired_finished_runs(&mut self) {
372 let cutoff = unix_time_ms().saturating_sub(FINISHED_RUN_TTL_MS);
373 self.runs.retain(|run| match run.finished_at_ms {
374 Some(finished_at_ms) => finished_at_ms >= cutoff,
375 None => true,
376 });
377 }
378
379 fn update_with_event(&mut self, run_id: &str, event: &StreamEvent) {
380 if let Some(run) = self.runs.iter_mut().find(|run| run.run_id == run_id) {
381 run.apply_event(event);
382 }
383 }
384
385 fn finish_run(&mut self, run_id: &str, view: &RunView) {
386 if let Some(run) = self.runs.iter_mut().find(|run| run.run_id == run_id) {
387 run.finish_with_view(view);
388 }
389 self.trim_history();
390 }
391
392 fn fail_run(&mut self, run_id: &str, error: String) {
393 if let Some(run) = self.runs.iter_mut().find(|run| run.run_id == run_id) {
394 run.fail(error);
395 }
396 self.trim_history();
397 }
398
399 fn snapshot(&self, run_id: Option<&str>) -> Option<NativeRunState> {
400 if let Some(run_id) = run_id {
401 return self.runs.iter().find(|run| run.run_id == run_id).cloned();
402 }
403
404 self.runs
405 .iter()
406 .rev()
407 .find(|run| run.status == "starting" || run.status == "running")
408 .cloned()
409 .or_else(|| self.runs.last().cloned())
410 }
411
412 fn trim_history(&mut self) {
413 while self.runs.len() > 8 {
414 let newest_index = self.runs.len().saturating_sub(1);
415 if let Some(index) =
416 self.runs
417 .iter()
418 .enumerate()
419 .take(newest_index)
420 .find_map(|(index, run)| {
421 (run.status != "starting" && run.status != "running").then_some(index)
422 })
423 {
424 self.runs.remove(index);
425 } else if !self.runs.is_empty() {
426 self.runs.remove(0);
427 } else {
428 break;
429 }
430 }
431 }
432}
433
434fn trim_log_lines(log_lines: &mut Vec<String>, max_lines: usize) {
435 if log_lines.len() > max_lines {
436 let overflow = log_lines.len() - max_lines;
437 log_lines.drain(0..overflow);
438 }
439}
440
441fn run_state_file() -> std::path::PathBuf {
442 if let Ok(path) = agents_file_path() {
443 if let Some(dir) = path.parent() {
444 std::fs::create_dir_all(dir).ok();
445 return dir.join("run_state.json");
446 }
447 }
448
449 let dir = std::env::var("HOME")
450 .map(|h| {
451 std::path::PathBuf::from(h)
452 .join(".local")
453 .join("share")
454 .join("units")
455 })
456 .unwrap_or_else(|_| std::path::PathBuf::from("/tmp").join("mana"));
457 std::fs::create_dir_all(&dir).ok();
458 dir.join("run_state.json")
459}
460
461fn unix_time_ms() -> u128 {
462 std::time::SystemTime::now()
463 .duration_since(std::time::UNIX_EPOCH)
464 .unwrap_or_default()
465 .as_millis()
466}
467
468fn ensure_unit_status<'a>(
469 units: &'a mut Vec<RunUnitStatus>,
470 id: &str,
471 title: &str,
472) -> &'a mut RunUnitStatus {
473 if let Some(index) = units.iter().position(|unit| unit.id == id) {
474 return &mut units[index];
475 }
476
477 units.push(RunUnitStatus {
478 id: id.to_string(),
479 title: title.to_string(),
480 status: "queued".to_string(),
481 round: None,
482 agent: None,
483 model: None,
484 duration_secs: None,
485 tool_count: None,
486 turns: None,
487 failure_summary: None,
488 error: None,
489 });
490 let index = units.len() - 1;
491 &mut units[index]
492}
493
494fn stream_event_line(event: &StreamEvent) -> Option<String> {
495 match event {
496 StreamEvent::RunStart {
497 total_units,
498 total_rounds,
499 ..
500 } => Some(format!(
501 "Mana run started: {total_units} jobs across {total_rounds} waves"
502 )),
503 StreamEvent::RunPlan {
504 waves,
505 file_overlaps,
506 ..
507 } => Some(format!(
508 "Plan ready: {} waves · {} overlapping file groups",
509 waves.len(),
510 file_overlaps.len()
511 )),
512 StreamEvent::RoundStart {
513 round,
514 total_rounds,
515 unit_count,
516 } => Some(format!(
517 "Round {round}/{total_rounds}: {unit_count} unit(s)"
518 )),
519 StreamEvent::UnitReady {
520 id,
521 title,
522 unblocked_by,
523 } => Some(format!("Ready: {id} {title} (unblocked by {unblocked_by})")),
524 StreamEvent::UnitStart {
525 id, title, round, ..
526 } => Some(format!("▶ {id} {title} wave {round}")),
527 StreamEvent::UnitThinking { id, text } => {
528 Some(format!("… {id} {}", truncate_line_for_log(text)))
529 }
530 StreamEvent::UnitTool {
531 id,
532 tool_name,
533 tool_count,
534 file_path,
535 } => Some(match file_path {
536 Some(path) => format!("⚙ {id} #{tool_count} {tool_name} {path}"),
537 None => format!("⚙ {id} #{tool_count} {tool_name}"),
538 }),
539 StreamEvent::UnitTokens {
540 id,
541 input_tokens,
542 output_tokens,
543 cost,
544 ..
545 } => Some(format!(
546 "$ {id} in {input_tokens} · out {output_tokens} · ${cost:.4}"
547 )),
548 StreamEvent::UnitDone {
549 id,
550 success,
551 duration_secs,
552 error,
553 ..
554 } => Some(if *success {
555 format!("✓ {id} done {duration_secs}s")
556 } else {
557 format!(
558 "✗ {id} failed {}",
559 error.clone().unwrap_or_else(|| "error".to_string())
560 )
561 }),
562 StreamEvent::RoundEnd {
563 round,
564 success_count,
565 failed_count,
566 } => Some(format!(
567 "Round {round} complete: {success_count} done · {failed_count} failed"
568 )),
569 StreamEvent::RunEnd {
570 total_closed,
571 total_failed,
572 duration_secs,
573 ..
574 } => Some(format!(
575 "Mana run finished: {total_closed} done · {total_failed} failed · {duration_secs}s"
576 )),
577 StreamEvent::BatchVerify {
578 commands_run,
579 passed,
580 failed,
581 } => Some(format!(
582 "Batch verify: {commands_run} command(s) · {} passed · {} failed",
583 passed.len(),
584 failed.len()
585 )),
586 StreamEvent::VerifyGroupRun {
587 command,
588 unit_ids,
589 success,
590 } => Some(format!(
591 "Verify command: {} · {} unit(s) · {}",
592 truncate_line_for_log(command),
593 unit_ids.len(),
594 if *success { "passed" } else { "failed" }
595 )),
596 StreamEvent::DryRun { rounds, .. } => {
597 Some(format!("Dry run: {} planned wave(s)", rounds.len()))
598 }
599 StreamEvent::Error { message } => Some(format!("Run error: {message}")),
600 }
601}
602
603fn truncate_line_for_log(text: &str) -> String {
604 const MAX_CHARS: usize = 160;
605 let mut out = String::new();
606 let mut chars = text.chars();
607 for _ in 0..MAX_CHARS {
608 if let Some(ch) = chars.next() {
609 out.push(ch);
610 } else {
611 return out;
612 }
613 }
614 if chars.next().is_some() {
615 out.push('…');
616 }
617 out
618}
619
620fn update_run_store_with_event(
621 store: &std::sync::Mutex<ManaRunStore>,
622 run_id: &str,
623 event: &StreamEvent,
624) {
625 if let Ok(mut store) = store.lock() {
626 store.update_with_event(run_id, event);
627 }
628}
629
630fn finish_run_in_store(store: &std::sync::Mutex<ManaRunStore>, run_id: &str, view: &RunView) {
631 if let Ok(mut store) = store.lock() {
632 store.finish_run(run_id, view);
633 store.persist();
634 }
635}
636
637fn fail_run_in_store(store: &std::sync::Mutex<ManaRunStore>, run_id: &str, error: String) {
638 if let Ok(mut store) = store.lock() {
639 store.fail_run(run_id, error);
640 store.persist();
641 }
642}
643
644fn run_summary_lines(view: &RunView) -> Vec<String> {
645 let mut lines = vec![format!(
646 "Mana run: {} total · {} done · {} failed · {} candidate complete / awaiting verify · {} skipped",
647 view.summary.total_units,
648 view.summary.total_closed,
649 view.summary.total_failed,
650 view.summary.total_awaiting_verify,
651 view.summary.total_skipped
652 )];
653
654 for unit in &view.units {
655 let marker = match unit.status.as_str() {
656 "running" => "▶",
657 "done" => "✓",
658 "failed" => "✗",
659 "blocked" => "!",
660 _ => "…",
661 };
662 let mut extras = Vec::new();
663 if let Some(round) = unit.round {
664 extras.push(format!("wave {round}"));
665 }
666 if let Some(agent) = &unit.agent {
667 extras.push(agent.clone());
668 }
669 if let Some(duration) = unit.duration_secs {
670 extras.push(format!("{}s", duration));
671 }
672 let extra_suffix = if extras.is_empty() {
673 String::new()
674 } else {
675 format!(" {}", extras.join(" · "))
676 };
677 lines.push(format!(
678 "{marker} {} {} {}{}",
679 unit.id, unit.title, unit.status, extra_suffix
680 ));
681 }
682
683 lines
684}
685
686fn mana_widget_lines(summary: impl Into<String>, detail: Option<String>) -> WidgetContent {
687 let mut lines = vec![summary.into()];
688 if let Some(detail) = detail {
689 lines.push(detail);
690 }
691 WidgetContent::Lines(lines)
692}
693
694async fn set_mana_delta_widget(
695 ctx: &ToolContext,
696 summary: impl Into<String>,
697 detail: Option<String>,
698) {
699 ctx.ui
700 .set_widget("mana", Some(mana_widget_lines(summary, detail)))
701 .await;
702}
703
704fn unit_delta_label(unit: &serde_json::Value) -> Option<String> {
705 let id = unit.get("id").and_then(|v| v.as_str())?;
706 let title = unit
707 .get("title")
708 .and_then(|v| v.as_str())
709 .unwrap_or("(untitled)");
710 Some(format!("{id} · {title}"))
711}
712
713fn target_from_params(params: &serde_json::Value) -> Result<RunTarget> {
714 if let Some(values) = params["targets"].as_array() {
715 let ids: Vec<String> = values
716 .iter()
717 .filter_map(|value| value.as_str().map(|s| s.to_string()))
718 .collect();
719 if ids.is_empty() {
720 return Err(crate::error::Error::Tool(
721 "mana run targets must contain at least one string id".into(),
722 ));
723 }
724 return Ok(RunTarget::Explicit(ids));
725 }
726
727 if let Some(id) = params["id"].as_str() {
728 return Ok(RunTarget::Unit(id.to_string()));
729 }
730
731 Ok(RunTarget::AllReady)
732}
733
734fn scope_from_target(target: &RunTarget) -> String {
735 match target {
736 RunTarget::AllReady => "all ready units".to_string(),
737 RunTarget::Unit(id) => format!("unit {id}"),
738 RunTarget::Explicit(ids) => format!("targets {}", ids.join(", ")),
739 }
740}
741
742fn make_follow_up_summary(scope: &str, view: &RunView) -> String {
743 let mut summary = if view.summary.total_failed > 0 {
744 format!(
745 "Native mana orchestration finished for {scope}: {} done, {} failed, {} candidate complete / awaiting verify.",
746 view.summary.total_closed,
747 view.summary.total_failed,
748 view.summary.total_awaiting_verify
749 )
750 } else if view.summary.total_awaiting_verify > 0 {
751 format!(
752 "Native mana orchestration finished for {scope}: {} done, {} candidate complete / awaiting verify.",
753 view.summary.total_closed, view.summary.total_awaiting_verify
754 )
755 } else {
756 format!(
757 "Native mana orchestration finished for {scope}: {} done, 0 failed.",
758 view.summary.total_closed
759 )
760 };
761
762 if let Some(runtime) = &view.runtime {
763 let agent = runtime.direct_agent.as_deref().unwrap_or("imp-worker");
764 let model = runtime.model.as_deref().unwrap_or("default-model");
765 summary.push_str(&format!(
766 " Orchestration ran through mana; worker runtime: {agent} · {model}."
767 ));
768 }
769
770 summary.push_str(" Inspect with mana(action=\"run_state\") or mana(action=\"evaluate\").");
771 summary
772}
773
774fn parse_csv_strings(value: &serde_json::Value, field_name: &str) -> Result<Vec<String>> {
775 if let Some(values) = value.as_array() {
776 let parsed = values
777 .iter()
778 .filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
779 .filter(|s| !s.is_empty())
780 .collect();
781 return Ok(parsed);
782 }
783
784 if let Some(raw) = value.as_str() {
785 return Ok(raw
786 .split(',')
787 .map(|s| s.trim().to_string())
788 .filter(|s| !s.is_empty())
789 .collect());
790 }
791
792 if value.is_null() {
793 return Ok(Vec::new());
794 }
795
796 Err(crate::error::Error::Tool(format!(
797 "{field_name} must be a comma-separated string or array of strings"
798 )))
799}
800
801fn parse_optional_string(value: &serde_json::Value) -> Option<String> {
802 value
803 .as_str()
804 .map(str::trim)
805 .filter(|s| !s.is_empty())
806 .map(|s| s.to_string())
807}
808
809fn parse_on_fail(value: &serde_json::Value) -> Result<Option<OnFailAction>> {
810 if value.is_null() {
811 return Ok(None);
812 }
813
814 if let Some(raw) = value.as_str() {
815 return mana_core::ops::create::parse_on_fail(raw)
816 .map(Some)
817 .map_err(|e| crate::error::Error::Tool(e.to_string()));
818 }
819
820 let Some(obj) = value.as_object() else {
821 return Err(crate::error::Error::Tool(
822 "on_fail must be a string like 'retry:3'/'escalate:P1' or an object".into(),
823 ));
824 };
825
826 let action = obj
827 .get("action")
828 .and_then(|v| v.as_str())
829 .ok_or_else(|| crate::error::Error::Tool("on_fail object requires 'action'".into()))?;
830
831 match action {
832 "retry" => Ok(Some(OnFailAction::Retry {
833 max: obj.get("max").and_then(|v| v.as_u64()).map(|v| v as u32),
834 delay_secs: obj.get("delay_secs").and_then(|v| v.as_u64()),
835 })),
836 "escalate" => Ok(Some(OnFailAction::Escalate {
837 priority: obj
838 .get("priority")
839 .and_then(|v| v.as_u64())
840 .map(|v| v as u8),
841 message: obj
842 .get("message")
843 .and_then(|v| v.as_str())
844 .map(|s| s.trim().to_string())
845 .filter(|s| !s.is_empty()),
846 })),
847 other => Err(crate::error::Error::Tool(format!(
848 "unsupported on_fail action: {other}"
849 ))),
850 }
851}
852
853fn parse_unit_kind(value: &serde_json::Value) -> Result<Option<UnitType>> {
854 let Some(raw) = value.as_str().map(str::trim).filter(|s| !s.is_empty()) else {
855 return Ok(None);
856 };
857
858 match raw {
859 "epic" => Ok(Some(UnitType::Epic)),
860 "task" | "job" => Ok(Some(UnitType::Task)),
861 "fact" => Ok(Some(UnitType::Fact)),
862 other => Err(crate::error::Error::Tool(format!(
863 "type must be one of: epic, task, fact (legacy alias: job; got {other})"
864 ))),
865 }
866}
867
868fn background_run_started_output(
869 scope: &str,
870 run_id: &str,
871 run_args: &NativeRunParams,
872) -> ToolOutput {
873 let text = format!(
874 "Started native mana orchestration in background for {scope} as {run_id}. Mana will coordinate the run and dispatch imp workers underneath. Use mana(action=\"run_state\", run_id=\"{run_id}\") for orchestration status, mana(action=\"logs\", run_id=\"{run_id}\") for recent native events, and mana(action=\"agents\") / mana(action=\"logs\", id=...) for worker output."
875 );
876 ToolOutput {
877 content: vec![imp_llm::ContentBlock::Text { text }],
878 details: json!({
879 "background": true,
880 "run_id": run_id,
881 "scope": scope,
882 "target": match &run_args.target {
883 RunTarget::AllReady => json!({"kind": "all_ready"}),
884 RunTarget::Unit(id) => json!({"kind": "unit", "id": id}),
885 RunTarget::Explicit(ids) => json!({"kind": "explicit", "ids": ids}),
886 },
887 "jobs": run_args.jobs,
888 "loop": run_args.loop_mode,
889 "dry_run": run_args.dry_run,
890 "review": run_args.review,
891 }),
892 is_error: false,
893 }
894}
895
896fn spawn_background_run(
897 mana_dir: std::path::PathBuf,
898 run_args: NativeRunParams,
899 ctx: ToolContext,
900 run_store: Arc<std::sync::Mutex<ManaRunStore>>,
901 run_id: String,
902) {
903 let ui = ctx.ui.clone();
904 let command_tx = ctx.command_tx.clone();
905 let scope = scope_from_target(&run_args.target);
906
907 tokio::spawn(async move {
908 ui.set_status(
909 "mana",
910 Some(&format!("mana orchestration: running {scope}")),
911 )
912 .await;
913 ui.set_widget(
914 "mana",
915 Some(mana_widget_lines(
916 format!("orchestrating {scope}"),
917 Some(format!(
918 "native mana tool → mana orchestration → imp workers · inspect with mana run_state/logs (run_id={run_id})"
919 )),
920 )),
921 )
922 .await;
923
924 let run_store_for_sink = run_store.clone();
925 let run_id_for_sink = run_id.clone();
926 let result = tokio::task::spawn_blocking(move || {
927 mana::commands::run::run_with_stream_capture_and_sink(
928 &mana_dir,
929 run_args,
930 Some(Arc::new(move |event| {
931 update_run_store_with_event(&run_store_for_sink, &run_id_for_sink, &event);
932 })),
933 )
934 })
935 .await;
936
937 match result {
938 Ok(Ok(view)) => {
939 finish_run_in_store(&run_store, &run_id, &view);
940 let summary = format!(
941 "mana orchestration: {scope} finished · {} done · {} failed",
942 view.summary.total_closed, view.summary.total_failed
943 );
944 let runtime_detail = view
945 .runtime
946 .as_ref()
947 .map(|runtime| {
948 let agent = runtime.direct_agent.as_deref().unwrap_or("imp-worker");
949 let model = runtime.model.as_deref().unwrap_or("default-model");
950 format!(
951 "native mana tool → mana orchestration → {agent} workers · {scope} · {model}"
952 )
953 })
954 .unwrap_or_else(|| scope.clone());
955 ui.set_status("mana", Some(&summary)).await;
956 ui.set_widget(
957 "mana",
958 Some(mana_widget_lines(summary.clone(), Some(runtime_detail))),
959 )
960 .await;
961 ui.notify(&summary, NotifyLevel::Info).await;
962 if !ui.has_ui() {
963 let _ = command_tx
964 .send(crate::agent::AgentCommand::FollowUp(
965 make_follow_up_summary(&scope, &view),
966 ))
967 .await;
968 }
969 let ui_clear = ui.clone();
970 tokio::spawn(async move {
971 tokio::time::sleep(std::time::Duration::from_secs(12)).await;
972 ui_clear.set_widget("mana", None).await;
973 ui_clear.set_status("mana", None).await;
974 });
975 }
976 Ok(Err(err)) => {
977 let message = format!("mana orchestration: {scope} failed: {err}");
978 fail_run_in_store(&run_store, &run_id, message.clone());
979 ui.set_status("mana", Some(&message)).await;
980 ui.set_widget("mana", Some(mana_widget_lines(message.clone(), None)))
981 .await;
982 ui.notify(&message, NotifyLevel::Error).await;
983 if !ui.has_ui() {
984 let _ = command_tx
985 .send(crate::agent::AgentCommand::FollowUp(format!(
986 "Native mana orchestration failed for {scope}: {err}. Inspect with mana(action=\"run_state\") or mana(action=\"logs\", run_id=\"{run_id}\")."
987 )))
988 .await;
989 }
990 }
991 Err(join_err) => {
992 let message = format!("mana orchestration: {scope} task failed: {join_err}");
993 fail_run_in_store(&run_store, &run_id, message.clone());
994 ui.set_status("mana", Some(&message)).await;
995 ui.set_widget("mana", Some(mana_widget_lines(message.clone(), None)))
996 .await;
997 ui.notify(&message, NotifyLevel::Error).await;
998 if !ui.has_ui() {
999 let _ = command_tx
1000 .send(crate::agent::AgentCommand::FollowUp(format!(
1001 "Native mana orchestration background task failed for {scope}: {join_err}. Inspect with mana(action=\"run_state\") or mana(action=\"logs\", run_id=\"{run_id}\")."
1002 )))
1003 .await;
1004 }
1005 }
1006 }
1007 });
1008}
1009
1010fn text_output(text: String, details: serde_json::Value) -> ToolOutput {
1011 ToolOutput {
1012 content: vec![imp_llm::ContentBlock::Text { text }],
1013 details,
1014 is_error: false,
1015 }
1016}
1017
1018fn run_state_snapshot(
1019 run_store: &Arc<std::sync::Mutex<ManaRunStore>>,
1020 run_id: Option<&str>,
1021) -> Option<NativeRunState> {
1022 run_store
1023 .lock()
1024 .ok()
1025 .and_then(|store| store.snapshot(run_id))
1026}
1027
1028fn run_state_output(state: &NativeRunState) -> ToolOutput {
1029 let mut lines = vec![format!(
1030 "Native mana orchestration {}: {} · {}",
1031 state.run_id, state.scope, state.status
1032 )];
1033 if let Some(runtime) = &state.runtime {
1034 let agent = runtime["direct_agent"].as_str().unwrap_or("imp-worker");
1035 let model = runtime["model"].as_str().unwrap_or("default-model");
1036 lines.push(format!("Worker runtime: {agent} · {model}"));
1037 }
1038 lines.push(format!(
1039 "{} total · {} done · {} failed · {} candidate complete / awaiting verify · {} skipped",
1040 state.summary.total_units,
1041 state.summary.total_closed,
1042 state.summary.total_failed,
1043 state.summary.total_awaiting_verify,
1044 state.summary.total_skipped
1045 ));
1046
1047 if !state.units.is_empty() {
1048 let preview = state
1049 .units
1050 .iter()
1051 .take(3)
1052 .map(|unit| format!("{}:{}", unit.id, unit.status))
1053 .collect::<Vec<_>>()
1054 .join(", ");
1055 lines.push(format!("Units: {preview}"));
1056 }
1057
1058 if let Some(last) = state.log_lines.last() {
1059 lines.push(format!("Latest: {last}"));
1060 }
1061 text_output(
1062 lines.join("\n"),
1063 serde_json::to_value(state).unwrap_or(serde_json::Value::Null),
1064 )
1065}
1066
1067fn evaluate_run_output(state: &NativeRunState) -> ToolOutput {
1068 let headline = match state.status.as_str() {
1069 "starting" | "running" => {
1070 format!("Native mana orchestration run {} is still running for {}.", state.run_id, state.scope)
1071 }
1072 "failed" => format!("Native mana orchestration run {} failed for {}.", state.run_id, state.scope),
1073 _ if state.summary.total_failed > 0 => format!(
1074 "Native mana orchestration run {} finished with {} failed unit(s).",
1075 state.run_id, state.summary.total_failed
1076 ),
1077 _ if state.summary.total_awaiting_verify > 0 => format!(
1078 "Native mana orchestration run {} finished with {} unit(s) candidate complete / awaiting verify.",
1079 state.run_id, state.summary.total_awaiting_verify
1080 ),
1081 _ => format!(
1082 "Native mana orchestration run {} finished successfully: {} unit(s) done.",
1083 state.run_id, state.summary.total_closed
1084 ),
1085 };
1086
1087 let runtime = state
1088 .runtime
1089 .as_ref()
1090 .map(|runtime| {
1091 format!(
1092 "Worker runtime: {} · {}",
1093 runtime["direct_agent"].as_str().unwrap_or("imp-worker"),
1094 runtime["model"].as_str().unwrap_or("default-model")
1095 )
1096 })
1097 .unwrap_or_else(|| "Runtime: unknown".to_string());
1098
1099 let latest = state
1100 .log_lines
1101 .last()
1102 .map(|line| format!("Latest: {line}"))
1103 .unwrap_or_else(|| "Latest: (no stream events captured yet)".to_string());
1104
1105 text_output(
1106 format!("{headline}\n{runtime}\n{latest}"),
1107 serde_json::to_value(state).unwrap_or(serde_json::Value::Null),
1108 )
1109}
1110
1111fn claim_output(result: &mana_core::ops::claim::ClaimResult) -> ToolOutput {
1112 let text = format!(
1113 "Claimed unit {} ({}) by {}",
1114 result.unit.id, result.unit.title, result.claimer
1115 );
1116 ToolOutput {
1117 content: vec![imp_llm::ContentBlock::Text { text }],
1118 details: json!({
1119 "unit": {
1120 "id": result.unit.id,
1121 "title": result.unit.title,
1122 "status": result.unit.status,
1123 "claimed_by": result.unit.claimed_by,
1124 },
1125 "claimer": result.claimer,
1126 "is_goal": result.is_goal,
1127 "path": result.path,
1128 }),
1129 is_error: false,
1130 }
1131}
1132
1133fn release_output(result: &mana_core::ops::claim::ReleaseResult) -> ToolOutput {
1134 let text = format!(
1135 "Released unit {} ({}) back to {}",
1136 result.unit.id, result.unit.title, result.unit.status
1137 );
1138 ToolOutput {
1139 content: vec![imp_llm::ContentBlock::Text { text }],
1140 details: json!({
1141 "unit": {
1142 "id": result.unit.id,
1143 "title": result.unit.title,
1144 "status": result.unit.status,
1145 "claimed_by": result.unit.claimed_by,
1146 },
1147 "path": result.path,
1148 }),
1149 is_error: false,
1150 }
1151}
1152
1153fn truncate_with_note(text: &str) -> String {
1154 let result = truncate_head(text, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES);
1155 if !result.truncated {
1156 return result.content;
1157 }
1158
1159 let mut output = result.content;
1160 output.push_str(&format!(
1161 "\n[Output truncated: showing first {} of {} lines{}]",
1162 result.output_lines,
1163 result.total_lines,
1164 result
1165 .temp_file
1166 .as_ref()
1167 .map(|p| format!(". Full output saved to {}", p.display()))
1168 .unwrap_or_default()
1169 ));
1170 output
1171}
1172
1173fn scored_units_to_text(units: &[ScoredUnit]) -> String {
1174 if units.is_empty() {
1175 return "No ready units. Create one with: mana create \"task\" --verify \"cmd\""
1176 .to_string();
1177 }
1178
1179 let mut lines = Vec::new();
1180 for unit in units {
1181 lines.push(format!(
1182 "P{} {:.1} {}",
1183 unit.priority, unit.score, unit.title
1184 ));
1185 if !unit.unblocks.is_empty() {
1186 lines.push(format!(" Unblocks: {}", unit.unblocks.join(", ")));
1187 }
1188 let attempts = if unit.attempts > 0 {
1189 format!(" | Attempts: {}", unit.attempts)
1190 } else {
1191 String::new()
1192 };
1193 lines.push(format!(
1194 " ID: {} | Age: {} days{}",
1195 unit.id, unit.age_days, attempts
1196 ));
1197 lines.push(String::new());
1198 }
1199 lines.join("\n")
1200}
1201
1202fn tree_lines(node: &mana_core::api::TreeNode, indent: usize, out: &mut Vec<String>) {
1203 let prefix = " ".repeat(indent);
1204 let verify = if node.has_verify { "spec" } else { "goal" };
1205 out.push(format!(
1206 "{}{} {} [{} P{} · {}]",
1207 prefix, node.id, node.title, node.status, node.priority, verify
1208 ));
1209 for child in &node.children {
1210 tree_lines(child, indent + 1, out);
1211 }
1212}
1213
1214pub struct ManaTool {
1215 run_store: Arc<std::sync::Mutex<ManaRunStore>>,
1216}
1217
1218impl Default for ManaTool {
1219 fn default() -> Self {
1220 Self {
1221 run_store: Arc::new(std::sync::Mutex::new(ManaRunStore::load_persisted())),
1222 }
1223 }
1224}
1225
1226#[async_trait]
1227impl Tool for ManaTool {
1228 fn name(&self) -> &str {
1229 "mana"
1230 }
1231 fn label(&self) -> &str {
1232 "Mana"
1233 }
1234 fn description(&self) -> &str {
1235 "Native mana work coordination: inspect, update, create, and run units or orchestration state. Prefer it over bash for equivalent mana actions."
1236 }
1237 fn parameters(&self) -> serde_json::Value {
1238 let string_or_array = || {
1239 json!({
1240 "oneOf": [
1241 { "type": "string" },
1242 { "type": "array", "items": { "type": "string" } }
1243 ]
1244 })
1245 };
1246
1247 let mut properties = serde_json::Map::new();
1248 properties.insert(
1249 "action".into(),
1250 json!({ "type": "string", "enum": ["status", "list", "show", "create", "close", "update", "run", "run_state", "evaluate", "claim", "release", "logs", "agents", "next", "tree", "reopen", "verify", "fail", "delete", "dep_add", "dep_remove", "fact_create", "fact_verify", "notes_append", "decision_add", "decision_resolve"] }),
1251 );
1252 properties.insert("id".into(), json!({ "type": "string" }));
1253 properties.insert(
1254 "scope".into(),
1255 json!({ "type": "string", "enum": ["auto", "project", "root"], "description": "Mana scope selection for this action" }),
1256 );
1257 properties.insert(
1258 "mana_scope".into(),
1259 json!({ "type": "string", "enum": ["auto", "project", "root"], "description": "Alias for scope" }),
1260 );
1261 properties.insert(
1262 "path".into(),
1263 json!({ "type": "string", "description": "Explicit project directory or .mana directory to target for this action" }),
1264 );
1265 properties.insert(
1266 "mana_dir".into(),
1267 json!({ "type": "string", "description": "Alias for path; explicit .mana or project directory to target" }),
1268 );
1269 properties.insert(
1270 "from_id".into(),
1271 json!({ "type": "string", "description": "Source unit ID for dependency updates" }),
1272 );
1273 properties.insert(
1274 "dep_id".into(),
1275 json!({ "type": "string", "description": "Dependency unit ID to add or remove" }),
1276 );
1277 properties.insert(
1278 "run_id".into(),
1279 json!({ "type": "string", "description": "Native in-session mana run ID, returned by action=run" }),
1280 );
1281 properties.insert("title".into(), json!({ "type": "string" }));
1282 properties.insert(
1283 "verify".into(),
1284 json!({ "type": "string", "description": "Shell command, must exit 0" }),
1285 );
1286 properties.insert("description".into(), json!({ "type": "string" }));
1287 properties.insert(
1288 "acceptance".into(),
1289 json!({ "type": "string", "description": "Concrete acceptance criteria for the unit" }),
1290 );
1291 properties.insert(
1292 "notes".into(),
1293 json!({ "type": "string", "description": "Progress log or authoring notes" }),
1294 );
1295 properties.insert(
1296 "design".into(),
1297 json!({ "type": "string", "description": "Supplemental design context for the unit" }),
1298 );
1299 properties.insert(
1300 "assignee".into(),
1301 json!({ "type": "string", "description": "Assignee or owner for the unit" }),
1302 );
1303 properties.insert("parent".into(), json!({ "type": "string" }));
1304 let mut deps = string_or_array();
1305 deps["description"] = json!("Dependency unit IDs as a comma-separated string or array");
1306 properties.insert("deps".into(), deps);
1307 let mut produces = string_or_array();
1308 produces["description"] = json!("Artifacts this unit produces");
1309 properties.insert("produces".into(), produces);
1310 let mut requires = string_or_array();
1311 requires["description"] = json!("Artifacts this unit requires");
1312 properties.insert("requires".into(), requires);
1313 let mut paths = string_or_array();
1314 paths["description"] = json!("Relevant file paths for context/relevance");
1315 properties.insert("paths".into(), paths);
1316 let mut decisions = string_or_array();
1317 decisions["description"] = json!("Blocking decisions to record on the unit");
1318 properties.insert("decisions".into(), decisions);
1319 let mut resolve_decisions = string_or_array();
1320 resolve_decisions["description"] =
1321 json!("Decision entries or indexes to resolve during update");
1322 properties.insert("resolve_decisions".into(), resolve_decisions);
1323 properties.insert("status".into(), json!({ "type": "string" }));
1324 properties.insert("priority".into(), json!({ "type": "integer" }));
1325 let mut labels = string_or_array();
1326 labels["description"] = json!("Labels as a comma-separated string or array");
1327 properties.insert("labels".into(), labels);
1328 properties.insert(
1329 "add_label".into(),
1330 json!({ "type": "string", "description": "Single label to add during update" }),
1331 );
1332 properties.insert(
1333 "remove_label".into(),
1334 json!({ "type": "string", "description": "Single label to remove during update" }),
1335 );
1336 properties.insert(
1337 "kind".into(),
1338 json!({ "type": "string", "enum": ["epic", "task", "fact", "job"], "description": "Explicit mana unit type (`job` is a legacy alias for `task`)" }),
1339 );
1340 properties.insert(
1341 "feature".into(),
1342 json!({ "type": "boolean", "description": "Whether the unit is a feature-level goal" }),
1343 );
1344 properties.insert(
1345 "fail_first".into(),
1346 json!({ "type": "boolean", "description": "Require verify to fail first at creation time" }),
1347 );
1348 properties.insert(
1349 "verify_timeout".into(),
1350 json!({ "type": "integer", "description": "Timeout in seconds for verify" }),
1351 );
1352 properties.insert(
1353 "on_fail".into(),
1354 json!({ "description": "On-fail policy as a string like retry:3 / escalate:P1 or an object" }),
1355 );
1356 properties.insert(
1357 "fact_title".into(),
1358 json!({ "type": "string", "description": "Title for fact_create; falls back to title" }),
1359 );
1360 properties.insert(
1361 "paths_csv".into(),
1362 json!({ "type": "string", "description": "Comma-separated paths for fact_create convenience" }),
1363 );
1364 properties.insert(
1365 "ttl_days".into(),
1366 json!({ "type": "integer", "description": "TTL in days for fact_create" }),
1367 );
1368 properties.insert(
1369 "pass_ok".into(),
1370 json!({ "type": "boolean", "description": "Permit fact creation even if verify currently passes" }),
1371 );
1372 properties.insert("force".into(), json!({ "type": "boolean" }));
1373 properties.insert("reason".into(), json!({ "type": "string" }));
1374 properties.insert("all".into(), json!({ "type": "boolean" }));
1375 properties.insert(
1376 "by".into(),
1377 json!({ "type": "string", "description": "Who is claiming the unit" }),
1378 );
1379 properties.insert(
1380 "count".into(),
1381 json!({ "type": "integer", "description": "Number of next recommendations to return" }),
1382 );
1383 properties.insert(
1384 "background".into(),
1385 json!({ "type": "boolean", "description": "Run mana orchestration in the background and return immediately (default true unless dry_run=true)" }),
1386 );
1387 properties.insert(
1388 "targets".into(),
1389 json!({ "type": "array", "items": { "type": "string" }, "description": "Explicit target unit IDs to run as a canonical target set" }),
1390 );
1391 properties.insert("jobs".into(), json!({ "type": "integer" }));
1392 properties.insert("dry_run".into(), json!({ "type": "boolean" }));
1393 properties.insert("loop".into(), json!({ "type": "boolean" }));
1394 properties.insert("keep_going".into(), json!({ "type": "boolean" }));
1395 properties.insert("timeout".into(), json!({ "type": "integer" }));
1396 properties.insert("idle_timeout".into(), json!({ "type": "integer" }));
1397 properties.insert("review".into(), json!({ "type": "boolean" }));
1398
1399 serde_json::Value::Object(serde_json::Map::from_iter([
1400 ("type".into(), json!("object")),
1401 ("properties".into(), serde_json::Value::Object(properties)),
1402 ("required".into(), json!(["action"])),
1403 ]))
1404 }
1405 fn is_readonly(&self) -> bool {
1406 false
1407 }
1408
1409 async fn execute(
1410 &self,
1411 _call_id: &str,
1412 params: serde_json::Value,
1413 ctx: ToolContext,
1414 ) -> Result<ToolOutput> {
1415 let action = params["action"]
1416 .as_str()
1417 .ok_or_else(|| crate::error::Error::Tool("missing 'action' parameter".into()))?;
1418
1419 let mode = ctx.mode;
1420
1421 if !mode.allows_mana_action(action) {
1422 let mode_name = format!("{mode:?}").to_lowercase();
1423 return Ok(ToolOutput::error(format!(
1424 "Mana action '{action}' is not available in {mode_name} mode"
1425 )));
1426 }
1427
1428 let mana_dir = resolve_mana_dir(&ctx.cwd, ¶ms).map_err(crate::error::Error::Tool)?;
1429
1430 match action {
1431 "status" => match mana_core::api::get_status(&mana_dir) {
1432 Ok(status) => Ok(json_output(&status)),
1433 Err(e) => Ok(ToolOutput::error(e.to_string())),
1434 },
1435 "list" => {
1436 let list_params = mana_core::ops::list::ListParams {
1437 status: params["status"].as_str().map(|s| s.to_string()),
1438 priority: params["priority"].as_u64().map(|p| p as u8),
1439 parent: params["parent"].as_str().map(|s| s.to_string()),
1440 label: params["label"].as_str().map(|s| s.to_string()),
1441 assignee: None,
1442 current_user: None,
1443 include_closed: params["all"].as_bool().unwrap_or(false),
1444 };
1445 match mana_core::api::list_units(&mana_dir, &list_params) {
1446 Ok(entries) => Ok(json_output(&entries)),
1447 Err(e) => {
1448 let message = format!("mana run failed: {e}");
1449 ctx.ui
1450 .set_widget("mana", Some(mana_widget_lines(message.clone(), None)))
1451 .await;
1452 ctx.ui.set_status("mana", Some(&message)).await;
1453 Ok(ToolOutput::error(e.to_string()))
1454 },
1455 }
1456 }
1457 "show" => {
1458 let id = params["id"]
1459 .as_str()
1460 .ok_or_else(|| crate::error::Error::Tool("show requires 'id'".into()))?;
1461 match mana_core::ops::show::get(&mana_dir, id) {
1462 Ok(result) => Ok(json_output(&result.unit)),
1463 Err(e) => Ok(ToolOutput::error(e.to_string())),
1464 }
1465 }
1466 "create" => {
1467 let title = params["title"]
1468 .as_str()
1469 .ok_or_else(|| crate::error::Error::Tool("create requires 'title'".into()))?;
1470 let dependencies = parse_csv_strings(¶ms["deps"], "deps")?;
1471 let labels = parse_csv_strings(¶ms["labels"], "labels")?;
1472 let produces = parse_csv_strings(¶ms["produces"], "produces")?;
1473 let requires = parse_csv_strings(¶ms["requires"], "requires")?;
1474 let paths = parse_csv_strings(¶ms["paths"], "paths")?;
1475 let decisions = parse_csv_strings(¶ms["decisions"], "decisions")?;
1476 let on_fail = parse_on_fail(¶ms["on_fail"])?;
1477 let kind = parse_unit_kind(¶ms["kind"])?;
1478
1479 let create_params = mana_core::ops::create::CreateParams {
1480 title: title.to_string(),
1481 description: parse_optional_string(¶ms["description"]),
1482 acceptance: parse_optional_string(¶ms["acceptance"]),
1483 notes: parse_optional_string(¶ms["notes"]),
1484 design: parse_optional_string(¶ms["design"]),
1485 verify: parse_optional_string(¶ms["verify"]),
1486 priority: params["priority"].as_u64().map(|p| p as u8),
1487 labels,
1488 assignee: parse_optional_string(¶ms["assignee"]),
1489 dependencies,
1490 parent: parse_optional_string(¶ms["parent"]),
1491 produces,
1492 requires,
1493 paths,
1494 on_fail,
1495 fail_first: params["fail_first"].as_bool().unwrap_or(false),
1496 feature: params["feature"].as_bool().unwrap_or(false),
1497 kind,
1498 verify_timeout: params["verify_timeout"].as_u64(),
1499 decisions,
1500 force: params["force"].as_bool().unwrap_or(false),
1501 };
1502 match mana_core::api::create_unit(&mana_dir, create_params) {
1503 Ok(result) => {
1504 let unit_value = serde_json::to_value(&result.unit)
1505 .unwrap_or(serde_json::Value::Null);
1506 let summary = unit_delta_label(&unit_value)
1507 .map(|label| format!("mana delta: created {label}"))
1508 .unwrap_or_else(|| "mana delta: created unit".to_string());
1509 let detail = parse_optional_string(¶ms["parent"])
1510 .map(|parent| format!("parent {parent}"));
1511 set_mana_delta_widget(&ctx, summary.clone(), detail).await;
1512 Ok(text_output(
1513 summary,
1514 json!({
1515 "action": "create",
1516 "title": title,
1517 "description": params["description"],
1518 "verify": params["verify"],
1519 "priority": params["priority"],
1520 "parent": params["parent"],
1521 "deps": params["deps"],
1522 "labels": params["labels"],
1523 "unit": unit_value,
1524 "path": result.path,
1525 }),
1526 ))
1527 }
1528 Err(e) => Ok(ToolOutput::error(e.to_string())),
1529 }
1530 }
1531 "claim" => {
1532 let id = params["id"]
1533 .as_str()
1534 .ok_or_else(|| crate::error::Error::Tool("claim requires 'id'".into()))?;
1535 let claim_params = ClaimParams {
1536 by: params["by"].as_str().map(|s| s.to_string()),
1537 force: params["force"].as_bool().unwrap_or(true),
1538 };
1539 match mana_core::api::claim_unit(&mana_dir, id, claim_params) {
1540 Ok(result) => Ok(claim_output(&result)),
1541 Err(e) => Ok(ToolOutput::error(e.to_string())),
1542 }
1543 }
1544 "release" => {
1545 let id = params["id"]
1546 .as_str()
1547 .ok_or_else(|| crate::error::Error::Tool("release requires 'id'".into()))?;
1548 match mana_core::api::release_unit(&mana_dir, id) {
1549 Ok(result) => Ok(release_output(&result)),
1550 Err(e) => Ok(ToolOutput::error(e.to_string())),
1551 }
1552 }
1553 "close" => {
1554 let id = params["id"]
1555 .as_str()
1556 .ok_or_else(|| crate::error::Error::Tool("close requires 'id'".into()))?;
1557 let opts = mana_core::ops::close::CloseOpts {
1558 reason: params["reason"].as_str().map(|s| s.to_string()),
1559 force: params["force"].as_bool().unwrap_or(false),
1560 defer_verify: false,
1561 };
1562 match mana_core::api::close_unit(&mana_dir, id, opts) {
1563 Ok(outcome) => {
1564 let details = serde_json::to_value(&outcome).unwrap_or(serde_json::Value::Null);
1565 if let Some(unit) = details.get("unit") {
1566 let summary = unit_delta_label(unit)
1567 .map(|label| format!("mana delta: closed {label}"))
1568 .unwrap_or_else(|| format!("mana delta: closed {id}"));
1569 set_mana_delta_widget(
1570 &ctx,
1571 summary,
1572 params["reason"].as_str().map(|s| s.to_string()),
1573 )
1574 .await;
1575 }
1576 let mut details_obj = details.as_object().cloned().unwrap_or_default();
1577 details_obj.insert("action".into(), json!("close"));
1578 if let Some(reason) = params["reason"].as_str() {
1579 details_obj.insert("reason".into(), json!(reason));
1580 }
1581 Ok(text_output(
1582 format!("Closed unit {id}"),
1583 serde_json::Value::Object(details_obj),
1584 ))
1585 }
1586 Err(e) => Ok(ToolOutput::error(e.to_string())),
1587 }
1588 }
1589 "update" => {
1590 let id = params["id"]
1591 .as_str()
1592 .ok_or_else(|| crate::error::Error::Tool("update requires 'id'".into()))?;
1593 let decisions = parse_csv_strings(¶ms["decisions"], "decisions")?;
1594 let resolve_decisions =
1595 parse_csv_strings(¶ms["resolve_decisions"], "resolve_decisions")?;
1596 let update_params = mana_core::ops::update::UpdateParams {
1597 title: parse_optional_string(¶ms["title"]),
1598 description: parse_optional_string(¶ms["description"]),
1599 acceptance: parse_optional_string(¶ms["acceptance"]),
1600 notes: parse_optional_string(¶ms["notes"]),
1601 design: parse_optional_string(¶ms["design"]),
1602 status: parse_optional_string(¶ms["status"]),
1603 priority: params["priority"].as_u64().map(|p| p as u8),
1604 assignee: parse_optional_string(¶ms["assignee"]),
1605 add_label: parse_optional_string(¶ms["add_label"]),
1606 remove_label: parse_optional_string(¶ms["remove_label"]),
1607 decisions,
1608 resolve_decisions,
1609 };
1610 match mana_core::api::update_unit(&mana_dir, id, update_params) {
1611 Ok(result) => {
1612 let unit_value = serde_json::to_value(&result.unit)
1613 .unwrap_or(serde_json::Value::Null);
1614 let summary = unit_delta_label(&unit_value)
1615 .map(|label| format!("mana delta: updated {label}"))
1616 .unwrap_or_else(|| format!("mana delta: updated {id}"));
1617 set_mana_delta_widget(&ctx, summary.clone(), None).await;
1618 Ok(text_output(
1619 summary,
1620 json!({
1621 "action": "update",
1622 "id": id,
1623 "status": params["status"],
1624 "title": params["title"],
1625 "description": params["description"],
1626 "priority": params["priority"],
1627 "notes": params["notes"],
1628 "acceptance": params["acceptance"],
1629 "add_label": params["add_label"],
1630 "remove_label": params["remove_label"],
1631 "decisions": params["decisions"],
1632 "resolve_decisions": params["resolve_decisions"],
1633 "unit": unit_value,
1634 "path": result.path,
1635 }),
1636 ))
1637 }
1638 Err(e) => Ok(ToolOutput::error(e.to_string())),
1639 }
1640 }
1641 "notes_append" => {
1642 let id = params["id"]
1643 .as_str()
1644 .ok_or_else(|| crate::error::Error::Tool("notes_append requires 'id'".into()))?;
1645 let note = parse_optional_string(¶ms["notes"])
1646 .ok_or_else(|| crate::error::Error::Tool("notes_append requires 'notes'".into()))?;
1647 let update_params = mana_core::ops::update::UpdateParams {
1648 title: None,
1649 description: None,
1650 acceptance: None,
1651 notes: Some(note),
1652 design: None,
1653 status: None,
1654 priority: None,
1655 assignee: None,
1656 add_label: None,
1657 remove_label: None,
1658 decisions: Vec::new(),
1659 resolve_decisions: Vec::new(),
1660 };
1661 match mana_core::api::update_unit(&mana_dir, id, update_params) {
1662 Ok(result) => {
1663 let unit_value = serde_json::to_value(&result.unit)
1664 .unwrap_or(serde_json::Value::Null);
1665 let summary = unit_delta_label(&unit_value)
1666 .map(|label| format!("mana delta: notes appended on {label}"))
1667 .unwrap_or_else(|| format!("mana delta: notes appended on {id}"));
1668 set_mana_delta_widget(&ctx, summary.clone(), Some("notes appended".into())).await;
1669 Ok(text_output(
1670 summary,
1671 json!({
1672 "action": "notes_append",
1673 "id": id,
1674 "notes": params["notes"],
1675 "unit": unit_value,
1676 "path": result.path,
1677 }),
1678 ))
1679 }
1680 Err(e) => Ok(ToolOutput::error(e.to_string())),
1681 }
1682 }
1683 "decision_add" => {
1684 let id = params["id"]
1685 .as_str()
1686 .ok_or_else(|| crate::error::Error::Tool("decision_add requires 'id'".into()))?;
1687 let decision = parse_optional_string(¶ms["description"])
1688 .or_else(|| parse_optional_string(¶ms["notes"]))
1689 .ok_or_else(|| crate::error::Error::Tool("decision_add requires 'description' or 'notes'".into()))?;
1690 let update_params = mana_core::ops::update::UpdateParams {
1691 title: None,
1692 description: None,
1693 acceptance: None,
1694 notes: None,
1695 design: None,
1696 status: None,
1697 priority: None,
1698 assignee: None,
1699 add_label: None,
1700 remove_label: None,
1701 decisions: vec![decision],
1702 resolve_decisions: Vec::new(),
1703 };
1704 match mana_core::api::update_unit(&mana_dir, id, update_params) {
1705 Ok(result) => {
1706 let unit_value = serde_json::to_value(&result.unit)
1707 .unwrap_or(serde_json::Value::Null);
1708 let summary = unit_delta_label(&unit_value)
1709 .map(|label| format!("mana delta: decision added on {label}"))
1710 .unwrap_or_else(|| format!("mana delta: decision added on {id}"));
1711 set_mana_delta_widget(&ctx, summary.clone(), Some("decision added".into())).await;
1712 Ok(text_output(
1713 summary,
1714 json!({
1715 "action": "decision_add",
1716 "id": id,
1717 "description": params["description"],
1718 "unit": unit_value,
1719 "path": result.path,
1720 }),
1721 ))
1722 }
1723 Err(e) => Ok(ToolOutput::error(e.to_string())),
1724 }
1725 }
1726 "decision_resolve" => {
1727 let id = params["id"]
1728 .as_str()
1729 .ok_or_else(|| crate::error::Error::Tool("decision_resolve requires 'id'".into()))?;
1730 let resolve_decisions = parse_csv_strings(¶ms["resolve_decisions"], "resolve_decisions")?;
1731 if resolve_decisions.is_empty() {
1732 return Ok(ToolOutput::error(
1733 "decision_resolve requires 'resolve_decisions'",
1734 ));
1735 }
1736 let update_params = mana_core::ops::update::UpdateParams {
1737 title: None,
1738 description: None,
1739 acceptance: None,
1740 notes: None,
1741 design: None,
1742 status: None,
1743 priority: None,
1744 assignee: None,
1745 add_label: None,
1746 remove_label: None,
1747 decisions: Vec::new(),
1748 resolve_decisions,
1749 };
1750 match mana_core::api::update_unit(&mana_dir, id, update_params) {
1751 Ok(result) => {
1752 let unit_value = serde_json::to_value(&result.unit)
1753 .unwrap_or(serde_json::Value::Null);
1754 let summary = unit_delta_label(&unit_value)
1755 .map(|label| format!("mana delta: decision resolved on {label}"))
1756 .unwrap_or_else(|| format!("mana delta: decision resolved on {id}"));
1757 set_mana_delta_widget(&ctx, summary.clone(), Some("decision resolved".into())).await;
1758 Ok(text_output(
1759 summary,
1760 json!({
1761 "action": "decision_resolve",
1762 "id": id,
1763 "resolve_decisions": params["resolve_decisions"],
1764 "unit": unit_value,
1765 "path": result.path,
1766 }),
1767 ))
1768 }
1769 Err(e) => Ok(ToolOutput::error(e.to_string())),
1770 }
1771 }
1772 "reopen" => {
1773 let id = params["id"]
1774 .as_str()
1775 .ok_or_else(|| crate::error::Error::Tool("reopen requires 'id'".into()))?;
1776 match mana_core::api::reopen_unit(&mana_dir, id) {
1777 Ok(result) => {
1778 let summary = format!("mana delta: reopened {} ({})", result.unit.id, result.unit.title);
1779 set_mana_delta_widget(&ctx, summary, Some("status=open".into())).await;
1780 Ok(text_output(
1781 format!("Reopened unit {} ({})", result.unit.id, result.unit.title),
1782 json!({
1783 "action": "reopen",
1784 "unit": {
1785 "id": result.unit.id,
1786 "title": result.unit.title,
1787 "status": result.unit.status,
1788 },
1789 "path": result.path,
1790 }),
1791 ))
1792 }
1793 Err(e) => Ok(ToolOutput::error(e.to_string())),
1794 }
1795 }
1796 "verify" => {
1797 let id = params["id"]
1798 .as_str()
1799 .ok_or_else(|| crate::error::Error::Tool("verify requires 'id'".into()))?;
1800 match mana_core::api::run_verify(&mana_dir, id) {
1801 Ok(Some(result)) => Ok(text_output(
1802 format!(
1803 "Verify {} for unit {id}{}",
1804 if result.passed { "passed" } else { "failed" },
1805 result
1806 .exit_code
1807 .map(|code| format!(" (exit {code})"))
1808 .unwrap_or_default()
1809 ),
1810 json!({
1811 "passed": result.passed,
1812 "exit_code": result.exit_code,
1813 "stdout": result.stdout,
1814 "stderr": result.stderr,
1815 "timed_out": result.timed_out,
1816 "command": result.command,
1817 "timeout_secs": result.timeout_secs,
1818 "unit_id": id,
1819 }),
1820 )),
1821 Ok(None) => Ok(ToolOutput::text(format!("Unit {id} has no verify command."))),
1822 Err(e) => Ok(ToolOutput::error(e.to_string())),
1823 }
1824 }
1825 "fail" => {
1826 let id = params["id"]
1827 .as_str()
1828 .ok_or_else(|| crate::error::Error::Tool("fail requires 'id'".into()))?;
1829 match mana_core::api::fail_unit(&mana_dir, id, parse_optional_string(¶ms["reason"])) {
1830 Ok(unit) => {
1831 let unit_value = serde_json::to_value(&unit)
1832 .unwrap_or(serde_json::Value::Null);
1833 let summary = unit_delta_label(&unit_value)
1834 .map(|label| format!("mana delta: marked failed {label}"))
1835 .unwrap_or_else(|| format!("mana delta: marked failed {id}"));
1836 set_mana_delta_widget(
1837 &ctx,
1838 summary,
1839 params["reason"].as_str().map(|s| s.to_string()),
1840 )
1841 .await;
1842 Ok(text_output(
1843 format!("Marked unit {id} as failed"),
1844 json!({
1845 "action": "fail",
1846 "id": id,
1847 "reason": params["reason"],
1848 "unit": unit_value,
1849 }),
1850 ))
1851 }
1852 Err(e) => Ok(ToolOutput::error(e.to_string())),
1853 }
1854 }
1855 "delete" => {
1856 let id = params["id"]
1857 .as_str()
1858 .ok_or_else(|| crate::error::Error::Tool("delete requires 'id'".into()))?;
1859 match mana_core::api::delete_unit(&mana_dir, id) {
1860 Ok(result) => {
1861 let summary = format!("mana delta: deleted {} ({})", result.id, result.title);
1862 set_mana_delta_widget(&ctx, summary.clone(), None).await;
1863 Ok(text_output(
1864 format!("Deleted unit {} ({})", result.id, result.title),
1865 json!({ "action": "delete", "id": result.id, "title": result.title }),
1866 ))
1867 }
1868 Err(e) => Ok(ToolOutput::error(e.to_string())),
1869 }
1870 }
1871 "dep_add" => {
1872 let from_id = params["from_id"]
1873 .as_str()
1874 .ok_or_else(|| crate::error::Error::Tool("dep_add requires 'from_id'".into()))?;
1875 let dep_id = params["dep_id"]
1876 .as_str()
1877 .ok_or_else(|| crate::error::Error::Tool("dep_add requires 'dep_id'".into()))?;
1878 match mana_core::api::add_dep(&mana_dir, from_id, dep_id) {
1879 Ok(result) => {
1880 let summary = format!("mana delta: dependency added {} -> {}", result.from_id, result.to_id);
1881 set_mana_delta_widget(&ctx, summary.clone(), None).await;
1882 Ok(text_output(
1883 format!("Added dependency: {} depends on {}", result.from_id, result.to_id),
1884 json!({ "action": "dep_add", "from_id": result.from_id, "dep_id": result.to_id }),
1885 ))
1886 }
1887 Err(e) => Ok(ToolOutput::error(e.to_string())),
1888 }
1889 }
1890 "dep_remove" => {
1891 let from_id = params["from_id"]
1892 .as_str()
1893 .ok_or_else(|| crate::error::Error::Tool("dep_remove requires 'from_id'".into()))?;
1894 let dep_id = params["dep_id"]
1895 .as_str()
1896 .ok_or_else(|| crate::error::Error::Tool("dep_remove requires 'dep_id'".into()))?;
1897 match mana_core::api::remove_dep(&mana_dir, from_id, dep_id) {
1898 Ok(result) => {
1899 let summary = format!("mana delta: dependency removed {} -> {}", result.from_id, result.to_id);
1900 set_mana_delta_widget(&ctx, summary.clone(), None).await;
1901 Ok(text_output(
1902 format!("Removed dependency: {} no longer depends on {}", result.from_id, result.to_id),
1903 json!({ "action": "dep_remove", "from_id": result.from_id, "dep_id": result.to_id }),
1904 ))
1905 }
1906 Err(e) => Ok(ToolOutput::error(e.to_string())),
1907 }
1908 }
1909 "fact_create" => {
1910 let title = parse_optional_string(¶ms["fact_title"])
1911 .or_else(|| parse_optional_string(¶ms["title"]))
1912 .ok_or_else(|| crate::error::Error::Tool("fact_create requires 'fact_title' or 'title'".into()))?;
1913 let verify = parse_optional_string(¶ms["verify"])
1914 .ok_or_else(|| crate::error::Error::Tool("fact_create requires 'verify'".into()))?;
1915 let paths_csv = parse_optional_string(¶ms["paths_csv"])
1916 .or_else(|| {
1917 let paths = parse_csv_strings(¶ms["paths"], "paths").ok()?;
1918 if paths.is_empty() { None } else { Some(paths.join(",")) }
1919 });
1920 let fact_params = mana_core::ops::fact::FactParams {
1921 title,
1922 verify,
1923 description: parse_optional_string(¶ms["description"]),
1924 paths: paths_csv,
1925 ttl_days: params["ttl_days"].as_i64(),
1926 pass_ok: params["pass_ok"].as_bool().unwrap_or(true),
1927 };
1928 match mana_core::api::create_fact(&mana_dir, fact_params) {
1929 Ok(result) => {
1930 let summary = format!("mana delta: created fact {} ({})", result.unit_id, result.unit.title);
1931 set_mana_delta_widget(&ctx, summary.clone(), Some("fact".into())).await;
1932 Ok(text_output(
1933 format!("Created fact {} ({})", result.unit_id, result.unit.title),
1934 json!({
1935 "action": "fact_create",
1936 "unit_id": result.unit_id,
1937 "unit": {
1938 "id": result.unit.id,
1939 "title": result.unit.title,
1940 "unit_type": result.unit.unit_type,
1941 "verify": result.unit.verify,
1942 "paths": result.unit.paths,
1943 "stale_after": result.unit.stale_after,
1944 }
1945 }),
1946 ))
1947 }
1948 Err(e) => Ok(ToolOutput::error(e.to_string())),
1949 }
1950 }
1951 "fact_verify" => match mana_core::api::verify_facts(&mana_dir) {
1952 Ok(result) => Ok(text_output(
1953 format!(
1954 "Verified {}/{} facts · {} stale · {} failing · {} suspect",
1955 result.verified_count,
1956 result.total_facts,
1957 result.stale_count,
1958 result.failing_count,
1959 result.suspect_count
1960 ),
1961 json!({
1962 "total_facts": result.total_facts,
1963 "verified_count": result.verified_count,
1964 "stale_count": result.stale_count,
1965 "failing_count": result.failing_count,
1966 "suspect_count": result.suspect_count,
1967 }),
1968 )),
1969 Err(e) => Ok(ToolOutput::error(e.to_string())),
1970 },
1971 "logs" => {
1972 if let Some(run_id) = params["run_id"].as_str() {
1973 if let Some(state) = run_state_snapshot(&self.run_store, Some(run_id)) {
1974 let text = if state.log_lines.is_empty() {
1975 format!(
1976 "No native stream events captured yet for run {}.",
1977 state.run_id
1978 )
1979 } else {
1980 truncate_with_note(&state.log_lines.join("\n"))
1981 };
1982 return Ok(text_output(
1983 text,
1984 serde_json::to_value(&state).unwrap_or(serde_json::Value::Null),
1985 ));
1986 }
1987 return Ok(ToolOutput::error(format!(
1988 "Unknown native mana run_id: {run_id}"
1989 )));
1990 }
1991
1992 let id = params["id"]
1993 .as_str()
1994 .ok_or_else(|| crate::error::Error::Tool("logs requires 'id' or 'run_id'".into()))?;
1995 match find_all_logs(id) {
1996 Ok(paths) if paths.is_empty() => Ok(ToolOutput::text(format!(
1997 "No logs for unit {id}. Has it been dispatched with mana run?"
1998 ))),
1999 Ok(paths) => {
2000 let mut sections = Vec::new();
2001 for path in &paths {
2002 let filename = path
2003 .file_name()
2004 .and_then(|n| n.to_str())
2005 .unwrap_or("unknown");
2006 let body = std::fs::read_to_string(path)
2007 .unwrap_or_else(|e| format!("(error reading {}: {e})", path.display()));
2008 sections.push(format!("═══ {filename} ═══\n\n{body}"));
2009 }
2010 let text = truncate_with_note(§ions.join("\n\n"));
2011 Ok(text_output(text, json!({ "unit_id": id, "logs": paths })))
2012 }
2013 Err(e) => Ok(ToolOutput::error(e.to_string())),
2014 }
2015 }
2016 "agents" => match load_agents() {
2017 Ok(agents) => Ok(json_output(&agents)),
2018 Err(e) => Ok(ToolOutput::error(e.to_string())),
2019 },
2020 "run_state" | "evaluate" => {
2021 let run_id = params["run_id"].as_str();
2022 match run_state_snapshot(&self.run_store, run_id) {
2023 Some(state) => {
2024 if action == "evaluate" {
2025 Ok(evaluate_run_output(&state))
2026 } else {
2027 Ok(run_state_output(&state))
2028 }
2029 }
2030 None => {
2031 let which = run_id.unwrap_or("latest");
2032 Ok(ToolOutput::error(format!(
2033 "No native mana run state available for {which}. Start one with mana(action=\"run\")."
2034 )))
2035 }
2036 }
2037 }
2038 "next" => {
2039 let count = params["count"].as_u64().unwrap_or(1).max(1) as usize;
2040 match mana_core::api::load_index(&mana_dir) {
2041 Ok(index) => {
2042 let ready: Vec<&mana_core::index::IndexEntry> = index
2043 .units
2044 .iter()
2045 .filter(|e| {
2046 e.status == mana_core::unit::Status::Open
2047 && e.has_verify
2048 && !e.feature
2049 && mana_core::blocking::check_blocked(e, &index).is_none()
2050 && !index.units.iter().any(|child| {
2051 child.parent.as_deref() == Some(e.id.as_str())
2052 && child.status != mana_core::unit::Status::Closed
2053 })
2054 })
2055 .collect();
2056
2057 let mut reverse_deps: std::collections::HashMap<String, Vec<String>> =
2058 std::collections::HashMap::new();
2059 for entry in &index.units {
2060 for dep_id in &entry.dependencies {
2061 reverse_deps
2062 .entry(dep_id.clone())
2063 .or_default()
2064 .push(entry.id.clone());
2065 }
2066 }
2067
2068 fn count_transitive_unblocks(
2069 unit_id: &str,
2070 reverse_deps: &std::collections::HashMap<String, Vec<String>>,
2071 ) -> usize {
2072 let mut visited = std::collections::HashSet::new();
2073 let mut stack = vec![unit_id.to_string()];
2074 while let Some(current) = stack.pop() {
2075 if let Some(dependents) = reverse_deps.get(¤t) {
2076 for dep in dependents {
2077 if visited.insert(dep.clone()) {
2078 stack.push(dep.clone());
2079 }
2080 }
2081 }
2082 }
2083 visited.len()
2084 }
2085
2086 fn score_unit(entry: &mana_core::index::IndexEntry, unblock_count: usize) -> f64 {
2087 let priority_score =
2088 (5u8.saturating_sub(entry.priority)) as f64 * 10.0;
2089 let unblock_score = (unblock_count as f64 * 5.0).min(50.0);
2090 let age_days = std::time::SystemTime::now()
2091 .duration_since(std::time::UNIX_EPOCH)
2092 .unwrap_or_default()
2093 .as_secs()
2094 / 86_400;
2095 let created_days = entry.created_at.timestamp().max(0) as u64 / 86_400;
2096 let age_days = age_days.saturating_sub(created_days) as f64;
2097 let age_score = age_days.min(30.0);
2098 let attempt_penalty = (entry.attempts as f64 * 3.0).min(15.0);
2099 priority_score + unblock_score + age_score - attempt_penalty
2100 }
2101
2102 let mut scored: Vec<ScoredUnit> = ready
2103 .iter()
2104 .map(|entry| {
2105 let transitive_count =
2106 count_transitive_unblocks(&entry.id, &reverse_deps);
2107 let unblocks = reverse_deps
2108 .get(&entry.id)
2109 .cloned()
2110 .unwrap_or_default();
2111 let score = score_unit(entry, transitive_count);
2112 let now_days = std::time::SystemTime::now()
2113 .duration_since(std::time::UNIX_EPOCH)
2114 .unwrap_or_default()
2115 .as_secs()
2116 / 86_400;
2117 let created_days = entry.created_at.timestamp().max(0) as u64 / 86_400;
2118 let age_days = now_days.saturating_sub(created_days);
2119 ScoredUnit {
2120 id: entry.id.clone(),
2121 title: entry.title.clone(),
2122 priority: entry.priority,
2123 score,
2124 unblocks,
2125 age_days,
2126 attempts: entry.attempts,
2127 }
2128 })
2129 .collect();
2130
2131 scored.sort_by(|a, b| {
2132 b.score
2133 .partial_cmp(&a.score)
2134 .unwrap_or(std::cmp::Ordering::Equal)
2135 });
2136 scored.truncate(count);
2137 Ok(text_output(
2138 scored_units_to_text(&scored),
2139 serde_json::to_value(&scored)
2140 .unwrap_or(serde_json::Value::Null),
2141 ))
2142 }
2143 Err(e) => Ok(ToolOutput::error(e.to_string())),
2144 }
2145 }
2146 "tree" => {
2147 let id = params["id"].as_str();
2148 let lines = if let Some(root_id) = id {
2149 match mana_core::api::get_tree(&mana_dir, root_id) {
2150 Ok(tree) => {
2151 let mut lines = Vec::new();
2152 tree_lines(&tree, 0, &mut lines);
2153 lines
2154 }
2155 Err(tree_err) => match mana_core::ops::show::get(&mana_dir, root_id) {
2156 Ok(result) if result.unit.is_archived => {
2157 return Ok(ToolOutput::error(format!(
2158 "Archived unit {root_id} can be shown but not rendered in tree view. Tree only includes active units."
2159 )));
2160 }
2161 Ok(_) | Err(_) => return Ok(ToolOutput::error(tree_err.to_string())),
2162 },
2163 }
2164 } else {
2165 match mana_core::api::load_index(&mana_dir) {
2166 Ok(index) => {
2167 let roots: Vec<_> = index
2168 .units
2169 .iter()
2170 .filter(|entry| entry.parent.is_none())
2171 .map(|entry| entry.id.clone())
2172 .collect();
2173 let mut lines = Vec::new();
2174 for (idx, root_id) in roots.iter().enumerate() {
2175 match mana_core::api::get_tree(&mana_dir, root_id) {
2176 Ok(tree) => {
2177 if idx > 0 {
2178 lines.push(String::new());
2179 }
2180 tree_lines(&tree, 0, &mut lines);
2181 }
2182 Err(e) => return Ok(ToolOutput::error(e.to_string())),
2183 }
2184 }
2185 lines
2186 }
2187 Err(e) => return Ok(ToolOutput::error(e.to_string())),
2188 }
2189 };
2190 let text = if lines.is_empty() {
2191 "(no units)".to_string()
2192 } else {
2193 truncate_with_note(&lines.join("\n"))
2194 };
2195 Ok(text_output(text, json!({ "root": id })))
2196 }
2197 "run" => {
2198 let run_params = NativeRunParams {
2199 target: target_from_params(¶ms)?,
2200 jobs: params["jobs"].as_u64().unwrap_or(4) as u32,
2201 dry_run: params["dry_run"].as_bool().unwrap_or(false),
2202 loop_mode: params["loop"].as_bool().unwrap_or(false),
2203 keep_going: params["keep_going"].as_bool().unwrap_or(false),
2204 timeout: params["timeout"].as_u64().unwrap_or(30) as u32,
2205 idle_timeout: params["idle_timeout"].as_u64().unwrap_or(5) as u32,
2206 json_stream: true,
2207 review: params["review"].as_bool().unwrap_or(false),
2208 };
2209 let background = params["background"].as_bool().unwrap_or(!run_params.dry_run);
2210 let scope = scope_from_target(&run_params.target);
2211 let run_id = {
2212 let mut store = self.run_store.lock().map_err(|_| {
2213 crate::error::Error::Tool("mana run state lock poisoned".into())
2214 })?;
2215 let run_id = store.start_run(scope.clone(), background, &run_params);
2216 store.persist();
2217 run_id
2218 };
2219
2220 if background {
2221 let started = background_run_started_output(&scope, &run_id, &run_params);
2222 spawn_background_run(
2223 mana_dir.clone(),
2224 run_params,
2225 ctx,
2226 self.run_store.clone(),
2227 run_id,
2228 );
2229 return Ok(started);
2230 }
2231
2232 send_update(
2233 &ctx,
2234 format!("Starting mana run {run_id}..."),
2235 json!({"kind": "mana_run_status", "status": "starting", "run_id": run_id, "scope": scope}),
2236 );
2237 ctx.ui
2238 .set_widget(
2239 "mana",
2240 Some(mana_widget_lines(
2241 format!("running mana ({run_id})"),
2242 Some(format!("native foreground orchestration · {scope}")),
2243 )),
2244 )
2245 .await;
2246 ctx.ui.set_status("mana", Some("mana: running")).await;
2247
2248 let run_store = self.run_store.clone();
2249 let run_id_for_sink = run_id.clone();
2250 let update_tx = ctx.update_tx.clone();
2251 match mana::commands::run::run_with_stream_capture_and_sink(
2252 &mana_dir,
2253 run_params,
2254 Some(Arc::new(move |event| {
2255 update_run_store_with_event(&run_store, &run_id_for_sink, &event);
2256 if let Some(line) = stream_event_line(&event) {
2257 let _ = update_tx.try_send(ToolUpdate {
2258 content: vec![imp_llm::ContentBlock::Text { text: line }],
2259 details: serde_json::to_value(&event)
2260 .unwrap_or(serde_json::Value::Null),
2261 });
2262 }
2263 })),
2264 ) {
2265 Ok(view) => {
2266 finish_run_in_store(&self.run_store, &run_id, &view);
2267 for line in run_summary_lines(&view) {
2268 send_update(
2269 &ctx,
2270 line,
2271 json!({"kind": "mana_run_view", "run_id": run_id, "scope": scope, "view": view}),
2272 );
2273 }
2274 let summary = format!(
2275 "mana finished · {} done · {} failed",
2276 view.summary.total_closed, view.summary.total_failed
2277 );
2278 ctx.ui
2279 .set_widget("mana", Some(mana_widget_lines(summary.clone(), Some(scope.clone()))))
2280 .await;
2281 ctx.ui.set_status("mana", Some(&summary)).await;
2282 Ok(ToolOutput {
2283 content: run_summary_lines(&view)
2284 .into_iter()
2285 .map(|text| imp_llm::ContentBlock::Text { text })
2286 .collect(),
2287 details: json!({
2288 "run_id": run_id,
2289 "scope": scope,
2290 "runtime": view.runtime,
2291 "view": serde_json::to_value(&view).unwrap_or(serde_json::Value::Null)
2292 }),
2293 is_error: false,
2294 })
2295 }
2296 Err(e) => {
2297 fail_run_in_store(&self.run_store, &run_id, e.to_string());
2298 Ok(ToolOutput::error(e.to_string()))
2299 }
2300 }
2301 }
2302 other => Ok(ToolOutput::error(format!(
2303 "Unknown action: {other}. Use: status, list, show, create, close, update, run, run_state, evaluate, claim, release, logs, agents, next, tree, reopen, verify, fail, delete, dep_add, dep_remove, fact_create, fact_verify, notes_append, decision_add, decision_resolve"
2304 ))),
2305 }
2306 }
2307}
2308
2309#[cfg(test)]
2310mod tests {
2311 use std::sync::Arc;
2312
2313 use async_trait::async_trait;
2314 use serde_json::json;
2315 use tokio::sync::mpsc;
2316
2317 use super::{evaluate_run_output, stream_event_line, ManaRunStore, ManaTool, NativeRunState};
2318 use crate::tools::{FileCache, FileTracker, Tool, ToolContext, ToolUpdate};
2319 use crate::ui::{NotifyLevel, NullInterface, WidgetContent};
2320
2321 enum ManaResult {
2322 ModeBlocked(String),
2323 Attempted(crate::tools::ToolOutput),
2324 }
2325
2326 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2327
2328 struct TestUi {
2329 widgets: Arc<std::sync::Mutex<Vec<(String, Option<WidgetContent>)>>>,
2330 }
2331
2332 #[async_trait]
2333 impl crate::ui::UserInterface for TestUi {
2334 fn has_ui(&self) -> bool {
2335 true
2336 }
2337
2338 async fn notify(&self, _message: &str, _level: NotifyLevel) {}
2339
2340 async fn confirm(&self, _title: &str, _message: &str) -> Option<bool> {
2341 None
2342 }
2343
2344 async fn select_with_context(
2345 &self,
2346 _title: &str,
2347 _context: &str,
2348 _options: &[crate::ui::SelectOption],
2349 ) -> Option<usize> {
2350 None
2351 }
2352
2353 async fn input_with_context(
2354 &self,
2355 _title: &str,
2356 _context: &str,
2357 _placeholder: &str,
2358 ) -> Option<String> {
2359 None
2360 }
2361
2362 async fn set_status(&self, _key: &str, _text: Option<&str>) {}
2363
2364 async fn set_widget(&self, key: &str, content: Option<WidgetContent>) {
2365 self.widgets
2366 .lock()
2367 .unwrap()
2368 .push((key.to_string(), content));
2369 }
2370
2371 async fn custom(&self, _component: crate::ui::ComponentSpec) -> Option<serde_json::Value> {
2372 None
2373 }
2374 }
2375
2376 async fn run_with_mode(mode_name: &str, action: &str) -> ManaResult {
2377 let prev = {
2378 let _guard = ENV_LOCK.lock().unwrap();
2379 let prev = std::env::var("IMP_MODE").ok();
2380 std::env::set_var("IMP_MODE", mode_name);
2381 prev
2382 };
2383
2384 let dir = tempfile::tempdir().unwrap();
2385 let mana_dir = dir.path().join(".mana");
2386 std::fs::create_dir_all(&mana_dir).unwrap();
2387 std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
2388 std::fs::write(
2389 mana_dir.join("1-test-unit.md"),
2390 "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
2391 )
2392 .unwrap();
2393 let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
2394 let (cmd_tx, _cmd_rx) = mpsc::channel(16);
2395 let ctx = ToolContext {
2396 cwd: dir.path().to_path_buf(),
2397 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2398 update_tx: tx,
2399 command_tx: cmd_tx,
2400 ui: Arc::new(NullInterface),
2401 file_cache: Arc::new(FileCache::new()),
2402 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
2403 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
2404 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
2405 lua_tool_loader: None,
2406 mode: crate::config::AgentMode::from_name(mode_name)
2407 .unwrap_or(crate::config::AgentMode::Full),
2408 read_max_lines: 500,
2409 turn_mana_review: Arc::new(std::sync::Mutex::new(
2410 crate::mana_review::TurnManaReviewAccumulator::default(),
2411 )),
2412 config: Arc::new(crate::config::Config::default()),
2413 };
2414
2415 let tool = ManaTool::default();
2416 let outcome = tool
2417 .execute("call_1", json!({ "action": action, "id": "1" }), ctx)
2418 .await;
2419
2420 match prev {
2421 Some(v) => {
2422 let _guard = ENV_LOCK.lock().unwrap();
2423 std::env::set_var("IMP_MODE", v)
2424 }
2425 None => {
2426 let _guard = ENV_LOCK.lock().unwrap();
2427 std::env::remove_var("IMP_MODE")
2428 }
2429 }
2430
2431 match outcome {
2432 Err(crate::error::Error::Tool(msg)) => {
2433 ManaResult::Attempted(crate::tools::ToolOutput::error(msg))
2434 }
2435 Err(e) => ManaResult::Attempted(crate::tools::ToolOutput::error(e.to_string())),
2436 Ok(output) => {
2437 if output.is_error {
2438 if let Some(text) = output.text_content() {
2439 if text.contains("mode") && text.contains(action) {
2440 return ManaResult::ModeBlocked(text.to_string());
2441 }
2442 }
2443 }
2444 ManaResult::Attempted(output)
2445 }
2446 }
2447 }
2448
2449 fn ctx_with_mode(
2450 dir: &std::path::Path,
2451 mode: crate::config::AgentMode,
2452 ) -> (ToolContext, tempfile::TempDir) {
2453 let mana_dir = dir.join(".mana");
2454 std::fs::create_dir_all(&mana_dir).unwrap();
2455 std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
2456 std::fs::write(
2457 mana_dir.join("1-test-unit.md"),
2458 "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
2459 )
2460 .unwrap();
2461 let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
2462 let (cmd_tx, _cmd_rx) = mpsc::channel(16);
2463 let ctx = ToolContext {
2464 cwd: dir.to_path_buf(),
2465 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2466 update_tx: tx,
2467 command_tx: cmd_tx,
2468 ui: Arc::new(NullInterface),
2469 file_cache: Arc::new(FileCache::new()),
2470 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
2471 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
2472 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
2473 lua_tool_loader: None,
2474 mode,
2475 read_max_lines: 500,
2476 turn_mana_review: Arc::new(std::sync::Mutex::new(
2477 crate::mana_review::TurnManaReviewAccumulator::default(),
2478 )),
2479 config: Arc::new(crate::config::Config::default()),
2480 };
2481 (ctx, tempfile::tempdir().unwrap())
2482 }
2483
2484 fn ctx_with_ui(
2485 dir: &std::path::Path,
2486 mode: crate::config::AgentMode,
2487 ) -> (
2488 ToolContext,
2489 tempfile::TempDir,
2490 Arc<std::sync::Mutex<Vec<(String, Option<WidgetContent>)>>>,
2491 ) {
2492 let mana_dir = dir.join(".mana");
2493 std::fs::create_dir_all(&mana_dir).unwrap();
2494 std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
2495 std::fs::write(
2496 mana_dir.join("1-test-unit.md"),
2497 "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
2498 )
2499 .unwrap();
2500 let widgets = Arc::new(std::sync::Mutex::new(Vec::new()));
2501 let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
2502 let (cmd_tx, _cmd_rx) = mpsc::channel(16);
2503 let ctx = ToolContext {
2504 cwd: dir.to_path_buf(),
2505 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2506 update_tx: tx,
2507 command_tx: cmd_tx,
2508 ui: Arc::new(TestUi {
2509 widgets: widgets.clone(),
2510 }),
2511 file_cache: Arc::new(FileCache::new()),
2512 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
2513 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
2514 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
2515 lua_tool_loader: None,
2516 mode,
2517 read_max_lines: 500,
2518 turn_mana_review: Arc::new(std::sync::Mutex::new(
2519 crate::mana_review::TurnManaReviewAccumulator::default(),
2520 )),
2521 config: Arc::new(crate::config::Config::default()),
2522 };
2523 (ctx, tempfile::tempdir().unwrap(), widgets)
2524 }
2525
2526 async fn run_with_ctx_mode(mode: crate::config::AgentMode, action: &str) -> ManaResult {
2527 let dir = tempfile::tempdir().unwrap();
2528 let (ctx, _keep) = ctx_with_mode(dir.path(), mode);
2529 let tool = ManaTool::default();
2530 let outcome = tool
2531 .execute("call_ctx", json!({ "action": action, "id": "1" }), ctx)
2532 .await;
2533 match outcome {
2534 Err(crate::error::Error::Tool(msg)) => {
2535 ManaResult::Attempted(crate::tools::ToolOutput::error(msg))
2536 }
2537 Err(e) => ManaResult::Attempted(crate::tools::ToolOutput::error(e.to_string())),
2538 Ok(output) => {
2539 if output.is_error {
2540 if let Some(text) = output.text_content() {
2541 if text.contains("mode") && text.contains(action) {
2542 return ManaResult::ModeBlocked(text.to_string());
2543 }
2544 }
2545 }
2546 ManaResult::Attempted(output)
2547 }
2548 }
2549 }
2550
2551 #[tokio::test]
2552 async fn create_sets_mana_delta_widget_and_action_details() {
2553 let dir = tempfile::tempdir().unwrap();
2554 let (ctx, _keep, widgets) = ctx_with_ui(dir.path(), crate::config::AgentMode::Full);
2555 let tool = ManaTool::default();
2556 let result = tool
2557 .execute(
2558 "call_create_widget",
2559 json!({ "action": "create", "title": "Widget unit", "verify": "test -n ok" }),
2560 ctx,
2561 )
2562 .await
2563 .unwrap();
2564
2565 assert_eq!(result.details["action"], "create");
2566 assert_eq!(result.details["unit"]["title"], "Widget unit");
2567 let widgets = widgets.lock().unwrap();
2568 assert!(widgets.iter().any(|(key, content)| {
2569 key == "mana"
2570 && matches!(content, Some(WidgetContent::Lines(lines)) if lines.iter().any(|line| line.contains("mana delta: created 2 · Widget unit")))
2571 }));
2572 }
2573
2574 #[tokio::test]
2575 async fn decision_add_sets_mana_delta_widget_and_action_details() {
2576 let dir = tempfile::tempdir().unwrap();
2577 let (ctx, _keep, widgets) = ctx_with_ui(dir.path(), crate::config::AgentMode::Full);
2578 let tool = ManaTool::default();
2579 let result = tool
2580 .execute(
2581 "call_decision_widget",
2582 json!({ "action": "decision_add", "id": "1", "description": "Choose retry limit" }),
2583 ctx,
2584 )
2585 .await
2586 .unwrap();
2587
2588 assert_eq!(result.details["action"], "decision_add");
2589 assert_eq!(result.details["unit"]["decisions"][0], "Choose retry limit");
2590 let widgets = widgets.lock().unwrap();
2591 assert!(widgets.iter().any(|(key, content)| {
2592 key == "mana"
2593 && matches!(content, Some(WidgetContent::Lines(lines)) if lines.iter().any(|line| line.contains("mana delta: decision added on 1 · Test unit")))
2594 }));
2595 }
2596
2597 #[tokio::test]
2598 async fn worker_blocks_create() {
2599 match run_with_mode("worker", "create").await {
2600 ManaResult::ModeBlocked(_) => {}
2601 ManaResult::Attempted(out) => {
2602 panic!(
2603 "worker should block 'create', got: {:?}",
2604 out.text_content()
2605 )
2606 }
2607 }
2608 }
2609
2610 #[tokio::test]
2611 async fn create_supports_rich_unit_fields() {
2612 let dir = tempfile::tempdir().unwrap();
2613 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
2614 let tool = ManaTool::default();
2615 let result = tool
2616 .execute(
2617 "call_create_rich",
2618 json!({
2619 "action": "create",
2620 "title": "Rich unit",
2621 "description": "Implement the thing",
2622 "acceptance": "- works\n- tested",
2623 "notes": "start here",
2624 "design": "follow existing pattern",
2625 "verify": "test -n ok",
2626 "labels": ["feature", "backend"],
2627 "deps": ["1"],
2628 "paths": ["src/lib.rs", "src/auth.rs"],
2629 "requires": ["auth-api"],
2630 "produces": ["auth-fix"],
2631 "decisions": ["Confirm whether auth should stay sync"],
2632 "feature": true,
2633 "fail_first": true,
2634 "verify_timeout": 12,
2635 "force": false
2636 }),
2637 ctx,
2638 )
2639 .await
2640 .unwrap();
2641 let unit = &result.details["unit"];
2642 assert_eq!(unit["acceptance"], "- works\n- tested");
2643 assert_eq!(unit["labels"][0], "feature");
2644 assert_eq!(unit["dependencies"][0], "1");
2645 assert_eq!(unit["paths"][0], "src/lib.rs");
2646 assert_eq!(unit["requires"][0], "auth-api");
2647 assert_eq!(unit["produces"][0], "auth-fix");
2648 assert_eq!(
2649 unit["decisions"][0],
2650 "Confirm whether auth should stay sync"
2651 );
2652 assert_eq!(unit["feature"], true);
2653 assert_eq!(unit["fail_first"], true);
2654 assert_eq!(unit["verify_timeout"], 12);
2655 }
2656
2657 #[tokio::test]
2658 async fn update_supports_acceptance_labels_and_decisions() {
2659 let dir = tempfile::tempdir().unwrap();
2660 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
2661 let tool = ManaTool::default();
2662 let _created = tool
2663 .execute(
2664 "call_create_update_target",
2665 json!({ "action": "create", "title": "Update target", "verify": "test -n ok" }),
2666 ctx,
2667 )
2668 .await
2669 .unwrap();
2670
2671 let dir2 = tempfile::tempdir().unwrap();
2672 let (ctx2, _keep2) = ctx_with_mode(dir2.path(), crate::config::AgentMode::Full);
2673 std::fs::write(
2674 dir2.path().join(".mana").join("1-test-unit.md"),
2675 "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
2676 ).unwrap();
2677 let result = tool
2678 .execute(
2679 "call_update_rich",
2680 json!({
2681 "action": "update",
2682 "id": "1",
2683 "acceptance": "must pass auth flow",
2684 "add_label": "backend",
2685 "decisions": ["Choose retry limit"],
2686 "resolve_decisions": []
2687 }),
2688 ctx2,
2689 )
2690 .await
2691 .unwrap();
2692 let unit = &result.details["unit"];
2693 assert_eq!(unit["acceptance"], "must pass auth flow");
2694 assert_eq!(unit["labels"][0], "backend");
2695 assert_eq!(unit["decisions"][0], "Choose retry limit");
2696 }
2697
2698 #[tokio::test]
2699 async fn create_respects_verify_lint_by_default() {
2700 let dir = tempfile::tempdir().unwrap();
2701 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
2702 let tool = ManaTool::default();
2703 let result = tool
2704 .execute(
2705 "call_create_lint",
2706 json!({ "action": "create", "title": "Weak verify", "verify": "echo done" }),
2707 ctx,
2708 )
2709 .await
2710 .unwrap();
2711 assert!(result.is_error, "weak verify should be rejected by default");
2712 let text = result.text_content().unwrap_or("");
2713 assert!(text.contains("Verify command has lint errors") || text.contains("verify"));
2714 }
2715
2716 #[tokio::test]
2717 async fn native_verify_reopen_and_fact_actions_work() {
2718 let dir = tempfile::tempdir().unwrap();
2719 let mana_dir = dir.path().join(".mana");
2720 std::fs::create_dir_all(&mana_dir).unwrap();
2721 std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
2722 std::fs::write(
2723 mana_dir.join("1-test-unit.md"),
2724 "---\nid: '1'\ntitle: Test unit\nstatus: closed\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\nclosed_at: '2026-03-28T00:00:00Z'\nclose_reason: done\n---\n\nbody\n",
2725 ).unwrap();
2726 let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
2727 let (cmd_tx, _cmd_rx) = mpsc::channel(16);
2728 let ctx = ToolContext {
2729 cwd: dir.path().to_path_buf(),
2730 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2731 update_tx: tx,
2732 command_tx: cmd_tx,
2733 ui: Arc::new(NullInterface),
2734 file_cache: Arc::new(FileCache::new()),
2735 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
2736 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
2737 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
2738 lua_tool_loader: None,
2739 mode: crate::config::AgentMode::Full,
2740 read_max_lines: 500,
2741 turn_mana_review: Arc::new(std::sync::Mutex::new(
2742 crate::mana_review::TurnManaReviewAccumulator::default(),
2743 )),
2744 config: Arc::new(crate::config::Config::default()),
2745 };
2746 let tool = ManaTool::default();
2747 let reopened = tool
2748 .execute("call_reopen", json!({ "action": "reopen", "id": "1" }), ctx)
2749 .await
2750 .unwrap();
2751 assert_eq!(reopened.details["unit"]["status"], "open");
2752
2753 let dir2 = tempfile::tempdir().unwrap();
2754 let mana_dir2 = dir2.path().join(".mana");
2755 std::fs::create_dir_all(&mana_dir2).unwrap();
2756 std::fs::write(mana_dir2.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
2757 std::fs::write(
2758 mana_dir2.join("1-test-unit.md"),
2759 "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
2760 ).unwrap();
2761 let (tx2, _rx2) = mpsc::channel::<ToolUpdate>(1);
2762 let (cmd_tx2, _cmd_rx2) = mpsc::channel(16);
2763 let ctx2 = ToolContext {
2764 cwd: dir2.path().to_path_buf(),
2765 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2766 update_tx: tx2,
2767 command_tx: cmd_tx2,
2768 ui: Arc::new(NullInterface),
2769 file_cache: Arc::new(FileCache::new()),
2770 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
2771 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
2772 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
2773 lua_tool_loader: None,
2774 mode: crate::config::AgentMode::Full,
2775 read_max_lines: 500,
2776 turn_mana_review: Arc::new(std::sync::Mutex::new(
2777 crate::mana_review::TurnManaReviewAccumulator::default(),
2778 )),
2779 config: Arc::new(crate::config::Config::default()),
2780 };
2781 let verify = tool
2782 .execute(
2783 "call_verify",
2784 json!({ "action": "verify", "id": "1" }),
2785 ctx2,
2786 )
2787 .await
2788 .unwrap();
2789 assert_eq!(verify.details["passed"], true);
2790
2791 let dir3 = tempfile::tempdir().unwrap();
2792 let mana_dir3 = dir3.path().join(".mana");
2793 std::fs::create_dir_all(&mana_dir3).unwrap();
2794 std::fs::write(mana_dir3.join("config.yaml"), "project: test\nnext_id: 1\n").unwrap();
2795 let (tx3, _rx3) = mpsc::channel::<ToolUpdate>(1);
2796 let (cmd_tx3, _cmd_rx3) = mpsc::channel(16);
2797 let ctx3 = ToolContext {
2798 cwd: dir3.path().to_path_buf(),
2799 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2800 update_tx: tx3,
2801 command_tx: cmd_tx3,
2802 ui: Arc::new(NullInterface),
2803 file_cache: Arc::new(FileCache::new()),
2804 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
2805 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
2806 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
2807 lua_tool_loader: None,
2808 mode: crate::config::AgentMode::Full,
2809 read_max_lines: 500,
2810 turn_mana_review: Arc::new(std::sync::Mutex::new(
2811 crate::mana_review::TurnManaReviewAccumulator::default(),
2812 )),
2813 config: Arc::new(crate::config::Config::default()),
2814 };
2815 let fact = tool.execute("call_fact", json!({ "action": "fact_create", "fact_title": "Auth fact", "verify": "test -d .mana", "description": "fact body", "ttl_days": 7 }), ctx3).await.unwrap();
2816 assert_eq!(fact.details["unit"]["unit_type"], "fact");
2817 }
2818
2819 #[tokio::test]
2820 async fn notes_append_is_safe_partial_update() {
2821 let dir = tempfile::tempdir().unwrap();
2822 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
2823 let tool = ManaTool::default();
2824 let result = tool
2825 .execute(
2826 "call_notes_append",
2827 json!({
2828 "action": "notes_append",
2829 "id": "1",
2830 "notes": "diagnosis from turn 2"
2831 }),
2832 ctx,
2833 )
2834 .await
2835 .unwrap();
2836 let unit = &result.details["unit"];
2837 assert_eq!(unit["title"], "Test unit");
2838 assert!(unit["notes"]
2839 .as_str()
2840 .unwrap_or("")
2841 .contains("diagnosis from turn 2"));
2842 }
2843
2844 #[tokio::test]
2845 async fn decision_add_and_resolve_work() {
2846 let dir = tempfile::tempdir().unwrap();
2847 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
2848 let tool = ManaTool::default();
2849 let added = tool
2850 .execute(
2851 "call_decision_add",
2852 json!({
2853 "action": "decision_add",
2854 "id": "1",
2855 "description": "Choose retry limit"
2856 }),
2857 ctx,
2858 )
2859 .await
2860 .unwrap();
2861 assert_eq!(added.details["unit"]["decisions"][0], "Choose retry limit");
2862
2863 let dir2 = tempfile::tempdir().unwrap();
2864 let (ctx2, _keep2) = ctx_with_mode(dir2.path(), crate::config::AgentMode::Full);
2865 std::fs::write(
2866 dir2.path().join(".mana").join("1-test-unit.md"),
2867 "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\ndecisions:\n - Choose retry limit\n---\n\nbody\n",
2868 ).unwrap();
2869 let resolved = tool
2870 .execute(
2871 "call_decision_resolve",
2872 json!({
2873 "action": "decision_resolve",
2874 "id": "1",
2875 "resolve_decisions": ["Choose retry limit"]
2876 }),
2877 ctx2,
2878 )
2879 .await
2880 .unwrap();
2881 let decisions = resolved.details["unit"]["decisions"]
2882 .as_array()
2883 .cloned()
2884 .unwrap_or_default();
2885 assert!(decisions.is_empty());
2886 }
2887
2888 #[tokio::test]
2889 async fn show_returns_archived_unit_when_active_missing() {
2890 let dir = tempfile::tempdir().unwrap();
2891 let mana_dir = dir.path().join(".mana");
2892 std::fs::create_dir_all(mana_dir.join("archive/2026/04")).unwrap();
2893 std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
2894 std::fs::write(
2895 mana_dir.join("archive/2026/04/1-archived-unit.md"),
2896 "---\nid: '1'\ntitle: Archived unit\nstatus: closed\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nclosed_at: '2026-03-28T00:00:00Z'\nclose_reason: done\nis_archived: true\n---\n\nbody\n",
2897 )
2898 .unwrap();
2899 let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
2900 let (cmd_tx, _cmd_rx) = mpsc::channel(16);
2901 let ctx = ToolContext {
2902 cwd: dir.path().to_path_buf(),
2903 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2904 update_tx: tx,
2905 command_tx: cmd_tx,
2906 ui: Arc::new(NullInterface),
2907 file_cache: Arc::new(FileCache::new()),
2908 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
2909 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
2910 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
2911 lua_tool_loader: None,
2912 mode: crate::config::AgentMode::Full,
2913 read_max_lines: 500,
2914 turn_mana_review: Arc::new(std::sync::Mutex::new(
2915 crate::mana_review::TurnManaReviewAccumulator::default(),
2916 )),
2917 config: Arc::new(crate::config::Config::default()),
2918 };
2919 let tool = ManaTool::default();
2920 let result = tool
2921 .execute(
2922 "call_show_archived",
2923 json!({ "action": "show", "id": "1" }),
2924 ctx,
2925 )
2926 .await
2927 .unwrap();
2928
2929 assert!(!result.is_error);
2930 assert_eq!(result.details["title"], "Archived unit");
2931 assert_eq!(result.details["is_archived"], true);
2932 }
2933
2934 #[tokio::test]
2935 async fn tree_reports_archived_root_as_active_only_limitation() {
2936 let dir = tempfile::tempdir().unwrap();
2937 let mana_dir = dir.path().join(".mana");
2938 std::fs::create_dir_all(mana_dir.join("archive/2026/04")).unwrap();
2939 std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
2940 std::fs::write(
2941 mana_dir.join("archive/2026/04/1-archived-unit.md"),
2942 "---\nid: '1'\ntitle: Archived unit\nstatus: closed\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nclosed_at: '2026-03-28T00:00:00Z'\nclose_reason: done\nis_archived: true\n---\n\nbody\n",
2943 )
2944 .unwrap();
2945 let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
2946 let (cmd_tx, _cmd_rx) = mpsc::channel(16);
2947 let ctx = ToolContext {
2948 cwd: dir.path().to_path_buf(),
2949 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2950 update_tx: tx,
2951 command_tx: cmd_tx,
2952 ui: Arc::new(NullInterface),
2953 file_cache: Arc::new(FileCache::new()),
2954 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
2955 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
2956 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
2957 lua_tool_loader: None,
2958 mode: crate::config::AgentMode::Full,
2959 read_max_lines: 500,
2960 turn_mana_review: Arc::new(std::sync::Mutex::new(
2961 crate::mana_review::TurnManaReviewAccumulator::default(),
2962 )),
2963 config: Arc::new(crate::config::Config::default()),
2964 };
2965 let tool = ManaTool::default();
2966 let result = tool
2967 .execute(
2968 "call_tree_archived",
2969 json!({ "action": "tree", "id": "1" }),
2970 ctx,
2971 )
2972 .await
2973 .unwrap();
2974
2975 assert!(result.is_error);
2976 let text = result.text_content().unwrap_or("");
2977 assert!(text.contains("Archived unit 1 can be shown but not rendered in tree view"));
2978 }
2979
2980 #[tokio::test]
2981 async fn root_scope_targets_outermost_mana() {
2982 let tower = tempfile::tempdir().unwrap();
2983 let root_mana = tower.path().join(".mana");
2984 std::fs::create_dir_all(&root_mana).unwrap();
2985 std::fs::write(root_mana.join("config.yaml"), "project: root\nnext_id: 2\n").unwrap();
2986 std::fs::write(
2987 root_mana.join("1-root-unit.md"),
2988 "---\nid: '1'\ntitle: Root unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
2989 ).unwrap();
2990 let project = tower.path().join("imp");
2991 let project_mana = project.join(".mana");
2992 std::fs::create_dir_all(&project_mana).unwrap();
2993 std::fs::write(
2994 project_mana.join("config.yaml"),
2995 "project: nested\nnext_id: 2\n",
2996 )
2997 .unwrap();
2998 std::fs::write(
2999 project_mana.join("1-project-unit.md"),
3000 "---\nid: '1'\ntitle: Project unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
3001 ).unwrap();
3002 let workdir = project.join("src");
3003 std::fs::create_dir_all(&workdir).unwrap();
3004 let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
3005 let (cmd_tx, _cmd_rx) = mpsc::channel(16);
3006 let ctx = ToolContext {
3007 cwd: workdir,
3008 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
3009 update_tx: tx,
3010 command_tx: cmd_tx,
3011 ui: Arc::new(NullInterface),
3012 file_cache: Arc::new(FileCache::new()),
3013 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
3014 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
3015 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
3016 lua_tool_loader: None,
3017 mode: crate::config::AgentMode::Full,
3018 read_max_lines: 500,
3019 turn_mana_review: Arc::new(std::sync::Mutex::new(
3020 crate::mana_review::TurnManaReviewAccumulator::default(),
3021 )),
3022 config: Arc::new(crate::config::Config::default()),
3023 };
3024 let tool = ManaTool::default();
3025 let result = tool
3026 .execute(
3027 "call_root_scope",
3028 json!({ "action": "show", "id": "1", "scope": "root" }),
3029 ctx,
3030 )
3031 .await
3032 .unwrap();
3033 assert_eq!(result.details["title"], "Root unit");
3034 }
3035
3036 #[tokio::test]
3037 async fn explicit_path_targets_project_outside_cwd_ancestry() {
3038 let outside = tempfile::tempdir().unwrap();
3039 let target_project = outside.path().join("other-project");
3040 let target_mana = target_project.join(".mana");
3041 std::fs::create_dir_all(&target_mana).unwrap();
3042 std::fs::write(
3043 target_mana.join("config.yaml"),
3044 "project: other\nnext_id: 2\n",
3045 )
3046 .unwrap();
3047 std::fs::write(
3048 target_mana.join("1-other-unit.md"),
3049 "---\nid: '1'\ntitle: Other unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
3050 )
3051 .unwrap();
3052
3053 let unrelated = tempfile::tempdir().unwrap();
3054 let workdir = unrelated.path().join("scratch");
3055 std::fs::create_dir_all(&workdir).unwrap();
3056 let (tx, _rx) = mpsc::channel::<ToolUpdate>(1);
3057 let (cmd_tx, _cmd_rx) = mpsc::channel(16);
3058 let ctx = ToolContext {
3059 cwd: workdir,
3060 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
3061 update_tx: tx,
3062 command_tx: cmd_tx,
3063 ui: Arc::new(NullInterface),
3064 file_cache: Arc::new(FileCache::new()),
3065 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
3066 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
3067 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
3068 lua_tool_loader: None,
3069 mode: crate::config::AgentMode::Full,
3070 read_max_lines: 500,
3071 turn_mana_review: Arc::new(std::sync::Mutex::new(
3072 crate::mana_review::TurnManaReviewAccumulator::default(),
3073 )),
3074 config: Arc::new(crate::config::Config::default()),
3075 };
3076 let tool = ManaTool::default();
3077 let result = tool
3078 .execute(
3079 "call_explicit_path",
3080 json!({ "action": "show", "id": "1", "path": target_project }),
3081 ctx,
3082 )
3083 .await
3084 .unwrap();
3085 assert_eq!(result.details["title"], "Other unit");
3086 }
3087
3088 #[tokio::test]
3089 async fn worker_blocks_fact_create() {
3090 match run_with_mode("worker", "fact_create").await {
3091 ManaResult::ModeBlocked(_) => {}
3092 ManaResult::Attempted(out) => {
3093 panic!(
3094 "worker should block 'fact_create', got: {:?}",
3095 out.text_content()
3096 )
3097 }
3098 }
3099 }
3100
3101 #[tokio::test]
3102 async fn worker_allows_verify() {
3103 match run_with_mode("worker", "verify").await {
3104 ManaResult::Attempted(_) => {}
3105 ManaResult::ModeBlocked(msg) => {
3106 panic!("worker should allow 'verify' but was blocked: {msg}")
3107 }
3108 }
3109 }
3110
3111 #[tokio::test]
3112 async fn auditor_allows_show() {
3113 match run_with_mode("auditor", "show").await {
3114 ManaResult::Attempted(_) => {}
3115 ManaResult::ModeBlocked(msg) => {
3116 panic!("auditor should allow 'show' but was blocked: {msg}")
3117 }
3118 }
3119 }
3120
3121 #[tokio::test]
3122 async fn auditor_blocks_update() {
3123 match run_with_mode("auditor", "update").await {
3124 ManaResult::ModeBlocked(_) => {}
3125 ManaResult::Attempted(out) => {
3126 panic!(
3127 "auditor should block 'update', got: {:?}",
3128 out.text_content()
3129 )
3130 }
3131 }
3132 }
3133
3134 #[tokio::test]
3135 async fn worker_allows_logs() {
3136 match run_with_mode("worker", "logs").await {
3137 ManaResult::Attempted(_) => {}
3138 ManaResult::ModeBlocked(msg) => {
3139 panic!("worker should allow 'logs' but was blocked: {msg}")
3140 }
3141 }
3142 }
3143
3144 #[tokio::test]
3145 async fn orchestrator_allows_extended_actions() {
3146 for action in &[
3147 "status",
3148 "list",
3149 "show",
3150 "create",
3151 "close",
3152 "update",
3153 "run",
3154 "run_state",
3155 "evaluate",
3156 "claim",
3157 "release",
3158 "logs",
3159 "agents",
3160 "next",
3161 ] {
3162 match run_with_mode("orchestrator", action).await {
3163 ManaResult::Attempted(_) => {}
3164 ManaResult::ModeBlocked(msg) => {
3165 panic!("orchestrator should allow '{action}' but was blocked: {msg}")
3166 }
3167 }
3168 }
3169 }
3170
3171 #[tokio::test]
3172 async fn ctx_mode_wins_over_env() {
3173 let prev = {
3174 let _guard = ENV_LOCK.lock().unwrap();
3175 let prev = std::env::var("IMP_MODE").ok();
3176 std::env::set_var("IMP_MODE", "full");
3177 prev
3178 };
3179
3180 let result = run_with_ctx_mode(crate::config::AgentMode::Worker, "create").await;
3181
3182 match prev {
3183 Some(v) => {
3184 let _guard = ENV_LOCK.lock().unwrap();
3185 std::env::set_var("IMP_MODE", v)
3186 }
3187 None => {
3188 let _guard = ENV_LOCK.lock().unwrap();
3189 std::env::remove_var("IMP_MODE")
3190 }
3191 }
3192
3193 match result {
3194 ManaResult::ModeBlocked(_) => {}
3195 ManaResult::Attempted(out) => {
3196 panic!(
3197 "ctx.mode=Worker should block 'create' even when IMP_MODE=full, got: {:?}",
3198 out.text_content()
3199 )
3200 }
3201 }
3202 }
3203
3204 #[tokio::test]
3205 async fn ctx_worker_blocks_create() {
3206 match run_with_ctx_mode(crate::config::AgentMode::Worker, "create").await {
3207 ManaResult::ModeBlocked(_) => {}
3208 ManaResult::Attempted(out) => {
3209 panic!(
3210 "ctx Worker mode should block 'create', got: {:?}",
3211 out.text_content()
3212 )
3213 }
3214 }
3215 }
3216
3217 #[tokio::test]
3218 async fn ctx_full_allows_extended_actions() {
3219 for action in &[
3220 "status",
3221 "list",
3222 "show",
3223 "create",
3224 "close",
3225 "update",
3226 "run",
3227 "run_state",
3228 "evaluate",
3229 "claim",
3230 "release",
3231 "logs",
3232 "agents",
3233 "next",
3234 "tree",
3235 ] {
3236 match run_with_ctx_mode(crate::config::AgentMode::Full, action).await {
3237 ManaResult::Attempted(_) => {}
3238 ManaResult::ModeBlocked(msg) => {
3239 panic!("ctx Full mode should allow '{action}' but was blocked: {msg}")
3240 }
3241 }
3242 }
3243 }
3244
3245 #[tokio::test]
3246 async fn next_returns_ranked_text() {
3247 let dir = tempfile::tempdir().unwrap();
3248 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
3249 let tool = ManaTool::default();
3250 let result = tool
3251 .execute("call_next", json!({ "action": "next", "count": 1 }), ctx)
3252 .await
3253 .unwrap();
3254 let text = result.text_content().unwrap_or("");
3255 assert!(text.contains("Test unit") || text.contains("No ready units"));
3256 }
3257
3258 #[tokio::test]
3259 async fn background_run_returns_promptly() {
3260 let dir = tempfile::tempdir().unwrap();
3261 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
3262 let tool = ManaTool::default();
3263 let result = tool
3264 .execute(
3265 "call_bg",
3266 json!({ "action": "run", "background": true, "dry_run": true }),
3267 ctx,
3268 )
3269 .await
3270 .unwrap();
3271 let text = result.text_content().unwrap_or("");
3272 assert!(text.contains("Started native mana orchestration in background"));
3273 assert_eq!(result.details["background"], true);
3274 assert!(result.details["run_id"].as_str().is_some());
3275 }
3276
3277 #[tokio::test]
3278 async fn background_run_enqueues_follow_up_on_completion_without_ui() {
3279 let dir = tempfile::tempdir().unwrap();
3280 let mana_dir = dir.path().join(".mana");
3281 std::fs::create_dir_all(&mana_dir).unwrap();
3282 std::fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 2\n").unwrap();
3283 std::fs::write(
3284 mana_dir.join("1-test-unit.md"),
3285 "---\nid: '1'\ntitle: Test unit\nstatus: open\npriority: 2\ncreated_at: '2026-03-28T00:00:00Z'\nupdated_at: '2026-03-28T00:00:00Z'\nverify: test -n \"ok\"\n---\n\nbody\n",
3286 )
3287 .unwrap();
3288
3289 let (tx, _rx) = mpsc::channel::<ToolUpdate>(8);
3290 let (cmd_tx, mut cmd_rx) = mpsc::channel(8);
3291 let ctx = ToolContext {
3292 cwd: dir.path().to_path_buf(),
3293 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
3294 update_tx: tx,
3295 command_tx: cmd_tx,
3296 ui: Arc::new(NullInterface),
3297 file_cache: Arc::new(FileCache::new()),
3298 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
3299 file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
3300 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
3301 lua_tool_loader: None,
3302 mode: crate::config::AgentMode::Full,
3303 read_max_lines: 500,
3304 turn_mana_review: Arc::new(std::sync::Mutex::new(
3305 crate::mana_review::TurnManaReviewAccumulator::default(),
3306 )),
3307 config: Arc::new(crate::config::Config::default()),
3308 };
3309
3310 let tool = ManaTool::default();
3311 let _ = tool
3312 .execute(
3313 "call_bg_follow_up",
3314 json!({ "action": "run", "background": true, "dry_run": true }),
3315 ctx,
3316 )
3317 .await
3318 .unwrap();
3319
3320 let follow_up = tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv())
3321 .await
3322 .expect("follow-up timeout")
3323 .expect("follow-up message");
3324
3325 match follow_up {
3326 crate::agent::AgentCommand::FollowUp(text) => {
3327 assert!(
3328 text.contains("Native mana orchestration finished"),
3329 "text was: {text}"
3330 );
3331 assert!(
3332 text.contains("Inspect with mana(action=\"run_state\")"),
3333 "text was: {text}"
3334 );
3335 }
3336 other => panic!("expected follow-up, got {other:?}"),
3337 }
3338 }
3339
3340 #[tokio::test]
3341 async fn background_run_with_ui_does_not_enqueue_follow_up_on_completion() {
3342 let dir = tempfile::tempdir().unwrap();
3343 let (ctx, _keep, _widgets) = ctx_with_ui(dir.path(), crate::config::AgentMode::Full);
3344 let tool = ManaTool::default();
3345 let (cmd_tx, mut cmd_rx) = mpsc::channel(8);
3346 let ctx = ToolContext {
3347 command_tx: cmd_tx,
3348 ..ctx
3349 };
3350
3351 let _ = tool
3352 .execute(
3353 "call_bg_follow_up_ui",
3354 json!({ "action": "run", "background": true, "dry_run": true }),
3355 ctx,
3356 )
3357 .await
3358 .unwrap();
3359
3360 let follow_up =
3361 tokio::time::timeout(std::time::Duration::from_millis(700), cmd_rx.recv()).await;
3362 match follow_up {
3363 Err(_) | Ok(None) => {}
3364 Ok(Some(msg)) => panic!(
3365 "UI mode should rely on widget/status instead of queueing duplicate follow-up chat text, got: {msg:?}"
3366 ),
3367 }
3368 }
3369
3370 #[tokio::test]
3371 async fn background_run_supports_explicit_targets() {
3372 let dir = tempfile::tempdir().unwrap();
3373 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
3374 let tool = ManaTool::default();
3375 let result = tool
3376 .execute(
3377 "call_bg_targets",
3378 json!({ "action": "run", "background": true, "dry_run": true, "targets": ["1", "2"] }),
3379 ctx,
3380 )
3381 .await
3382 .unwrap();
3383 assert_eq!(result.details["target"]["kind"], "explicit");
3384 assert_eq!(result.details["target"]["ids"][0], "1");
3385 assert_eq!(result.details["target"]["ids"][1], "2");
3386 }
3387
3388 #[tokio::test]
3389 async fn run_state_and_evaluate_report_native_run() {
3390 let dir = tempfile::tempdir().unwrap();
3391 let (ctx, _keep) = ctx_with_mode(dir.path(), crate::config::AgentMode::Full);
3392 let tool = ManaTool::default();
3393
3394 let run_result = tool
3395 .execute(
3396 "call_run",
3397 json!({ "action": "run", "background": false, "dry_run": true }),
3398 ctx,
3399 )
3400 .await
3401 .unwrap();
3402 let run_id = run_result.details["run_id"]
3403 .as_str()
3404 .expect("run_id")
3405 .to_string();
3406
3407 let dir2 = tempfile::tempdir().unwrap();
3408 let (ctx2, _keep2) = ctx_with_mode(dir2.path(), crate::config::AgentMode::Full);
3409 let state = tool
3410 .execute(
3411 "call_state",
3412 json!({ "action": "run_state", "run_id": run_id.as_str() }),
3413 ctx2,
3414 )
3415 .await
3416 .unwrap();
3417 let state_text = state.text_content().unwrap_or("");
3418 assert!(
3419 state_text.contains("Native mana orchestration "),
3420 "state_text was: {state_text}"
3421 );
3422 assert!(
3423 state_text.contains("Worker runtime:"),
3424 "state_text was: {state_text}"
3425 );
3426 assert!(
3427 state_text.contains("Units:") || state_text.contains("Latest: Dry run:"),
3428 "state_text was: {state_text}"
3429 );
3430 assert!(
3431 state_text.contains("all ready units") || state_text.contains("unit"),
3432 "state_text was: {state_text}"
3433 );
3434
3435 let dir3 = tempfile::tempdir().unwrap();
3436 let (ctx3, _keep3) = ctx_with_mode(dir3.path(), crate::config::AgentMode::Full);
3437 let evaluation = tool
3438 .execute(
3439 "call_eval",
3440 json!({ "action": "evaluate", "run_id": run_result.details["run_id"] }),
3441 ctx3,
3442 )
3443 .await
3444 .unwrap();
3445 let eval_text = evaluation.text_content().unwrap_or("");
3446 assert!(
3447 eval_text.contains("Native mana orchestration run ") && eval_text.contains("finished"),
3448 "eval_text was: {eval_text}"
3449 );
3450 assert!(
3451 eval_text.contains("Worker runtime:"),
3452 "eval_text was: {eval_text}"
3453 );
3454 }
3455
3456 #[test]
3457 fn run_store_prefers_active_run_snapshot() {
3458 let mut store = ManaRunStore::default();
3459 let active_id = store.start_run(
3460 "all ready units".to_string(),
3461 true,
3462 &mana::commands::run::NativeRunParams {
3463 target: mana::commands::run::RunTarget::AllReady,
3464 jobs: 2,
3465 dry_run: false,
3466 loop_mode: false,
3467 keep_going: false,
3468 timeout: 30,
3469 idle_timeout: 5,
3470 json_stream: true,
3471 review: false,
3472 },
3473 );
3474 let finished_id = store.start_run(
3475 "unit 1".to_string(),
3476 false,
3477 &mana::commands::run::NativeRunParams {
3478 target: mana::commands::run::RunTarget::Unit("1".to_string()),
3479 jobs: 1,
3480 dry_run: true,
3481 loop_mode: false,
3482 keep_going: false,
3483 timeout: 30,
3484 idle_timeout: 5,
3485 json_stream: true,
3486 review: false,
3487 },
3488 );
3489 store.fail_run(&finished_id, "done".to_string());
3490
3491 let latest = store.snapshot(None).expect("snapshot");
3492 assert_eq!(latest.run_id, active_id);
3493 assert_eq!(latest.status, "starting");
3494 }
3495
3496 #[test]
3497 fn stream_event_line_formats_tool_activity() {
3498 let line = stream_event_line(&mana::stream::StreamEvent::UnitTool {
3499 id: "1".to_string(),
3500 tool_name: "read".to_string(),
3501 tool_count: 3,
3502 file_path: Some("src/lib.rs".to_string()),
3503 })
3504 .expect("line");
3505 assert!(line.contains("#3 read"));
3506 assert!(line.contains("src/lib.rs"));
3507 }
3508
3509 #[test]
3510 fn evaluate_output_reports_failures() {
3511 let mut state = NativeRunState::new(
3512 "run-7".to_string(),
3513 "unit 7".to_string(),
3514 false,
3515 &mana::commands::run::NativeRunParams {
3516 target: mana::commands::run::RunTarget::Unit("7".to_string()),
3517 jobs: 1,
3518 dry_run: false,
3519 loop_mode: false,
3520 keep_going: false,
3521 timeout: 30,
3522 idle_timeout: 5,
3523 json_stream: true,
3524 review: false,
3525 },
3526 );
3527 state.status = "finished".to_string();
3528 state.summary.total_failed = 2;
3529 state.log_lines.push("✗ 7 failed verify".to_string());
3530
3531 let output = evaluate_run_output(&state);
3532 let text = output.text_content().unwrap_or("");
3533 assert!(text.contains("2 failed unit"));
3534 assert!(text.contains("Latest: ✗ 7 failed verify"));
3535 }
3536}