1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::tmux;
9
10const REGISTRY_KIND: &str = "batty.projectRegistry";
11pub const REGISTRY_SCHEMA_VERSION: u32 = 2;
12const REGISTRY_FILENAME: &str = "project-registry.json";
13const REGISTRY_PATH_ENV: &str = "BATTY_PROJECT_REGISTRY_PATH";
14
15const ROUTING_STATE_KIND: &str = "batty.projectRoutingState";
16pub const ROUTING_STATE_SCHEMA_VERSION: u32 = 1;
17const ROUTING_STATE_FILENAME: &str = "project-routing-state.json";
18const ROUTING_STATE_PATH_ENV: &str = "BATTY_PROJECT_ROUTING_STATE_PATH";
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(rename_all = "camelCase")]
22pub struct ProjectRegistry {
23 pub kind: String,
24 pub schema_version: u32,
25 #[serde(default)]
26 pub projects: Vec<RegisteredProject>,
27}
28
29impl Default for ProjectRegistry {
30 fn default() -> Self {
31 Self {
32 kind: REGISTRY_KIND.to_string(),
33 schema_version: REGISTRY_SCHEMA_VERSION,
34 projects: Vec::new(),
35 }
36 }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40#[serde(rename_all = "camelCase")]
41pub struct RegisteredProject {
42 pub project_id: String,
43 pub name: String,
44 #[serde(default)]
45 pub aliases: Vec<String>,
46 pub project_root: PathBuf,
47 pub board_dir: PathBuf,
48 pub team_name: String,
49 pub session_name: String,
50 #[serde(default)]
51 pub channel_bindings: Vec<ProjectChannelBinding>,
52 pub owner: Option<String>,
53 #[serde(default)]
54 pub tags: Vec<String>,
55 #[serde(default)]
56 pub policy_flags: ProjectPolicyFlags,
57 pub created_at: u64,
58 pub updated_at: u64,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62#[serde(rename_all = "camelCase")]
63pub struct ProjectChannelBinding {
64 pub channel: String,
65 pub binding: String,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub thread_binding: Option<String>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
71#[serde(rename_all = "camelCase")]
72pub struct ProjectPolicyFlags {
73 #[serde(default)]
74 pub allow_openclaw_supervision: bool,
75 #[serde(default)]
76 pub allow_cross_project_routing: bool,
77 #[serde(default)]
78 pub allow_shared_service_routing: bool,
79 #[serde(default)]
80 pub archived: bool,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct ProjectRegistration {
85 pub project_id: String,
86 pub name: String,
87 pub aliases: Vec<String>,
88 pub project_root: PathBuf,
89 pub board_dir: PathBuf,
90 pub team_name: String,
91 pub session_name: String,
92 pub channel_bindings: Vec<ProjectChannelBinding>,
93 pub owner: Option<String>,
94 pub tags: Vec<String>,
95 pub policy_flags: ProjectPolicyFlags,
96}
97
98#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
99#[serde(rename_all = "camelCase")]
100pub enum ProjectLifecycleState {
101 Running,
102 Stopped,
103 Degraded,
104 Recovering,
105}
106
107#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
108#[serde(rename_all = "camelCase")]
109pub enum ProjectLifecycleAction {
110 Start,
111 Stop,
112 Restart,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
116#[serde(rename_all = "camelCase")]
117pub struct ProjectHealthSummary {
118 pub paused: bool,
119 pub watchdog_state: String,
120 pub unhealthy_members: Vec<String>,
121 pub member_count: usize,
122 pub active_member_count: usize,
123 pub pending_inbox_count: usize,
124 pub triage_backlog_count: usize,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
128#[serde(rename_all = "camelCase")]
129pub struct ProjectPipelineMetrics {
130 pub active_task_count: usize,
131 pub review_queue_count: usize,
132 pub runnable_count: u32,
133 pub blocked_count: u32,
134 pub stale_in_progress_count: u32,
135 pub stale_review_count: u32,
136 pub auto_merge_rate: Option<f64>,
137 pub rework_rate: Option<f64>,
138 pub avg_review_latency_secs: Option<f64>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
142#[serde(rename_all = "camelCase")]
143pub struct ProjectStatusDto {
144 pub project_id: String,
145 pub name: String,
146 pub team_name: String,
147 pub session_name: String,
148 pub project_root: PathBuf,
149 pub lifecycle: ProjectLifecycleState,
150 pub running: bool,
151 pub health: ProjectHealthSummary,
152 pub pipeline: ProjectPipelineMetrics,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
156#[serde(rename_all = "camelCase")]
157pub struct ProjectLifecycleActionResult {
158 pub project_id: String,
159 pub action: ProjectLifecycleAction,
160 pub changed: bool,
161 pub lifecycle: ProjectLifecycleState,
162 pub running: bool,
163 pub audit_message: String,
164 pub status: ProjectStatusDto,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
168#[serde(rename_all = "camelCase")]
169pub struct ProjectRoutingState {
170 pub kind: String,
171 pub schema_version: u32,
172 #[serde(default)]
173 pub selections: Vec<ActiveProjectSelection>,
174}
175
176impl Default for ProjectRoutingState {
177 fn default() -> Self {
178 Self {
179 kind: ROUTING_STATE_KIND.to_string(),
180 schema_version: ROUTING_STATE_SCHEMA_VERSION,
181 selections: Vec::new(),
182 }
183 }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
187#[serde(rename_all = "camelCase")]
188pub struct ActiveProjectSelection {
189 pub project_id: String,
190 pub scope: ActiveProjectScope,
191 pub updated_at: u64,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
195#[serde(tag = "kind", rename_all = "camelCase")]
196pub enum ActiveProjectScope {
197 Global,
198 Channel {
199 channel: String,
200 binding: String,
201 },
202 Thread {
203 channel: String,
204 binding: String,
205 thread_binding: String,
206 },
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct ProjectRoutingRequest {
211 pub message: String,
212 pub channel: Option<String>,
213 pub binding: Option<String>,
214 pub thread_binding: Option<String>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
218#[serde(rename_all = "snake_case")]
219pub enum RoutingConfidence {
220 High,
221 Medium,
222 Low,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
226#[serde(rename_all = "camelCase")]
227pub struct ProjectRoutingCandidate {
228 pub project_id: String,
229 pub reason: String,
230 pub score: u32,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
234#[serde(rename_all = "camelCase")]
235pub struct ProjectRoutingDecision {
236 pub selected_project_id: Option<String>,
237 pub requires_confirmation: bool,
238 pub confidence: RoutingConfidence,
239 pub reason: String,
240 #[serde(default)]
241 pub candidates: Vec<ProjectRoutingCandidate>,
242}
243
244#[derive(Debug, Deserialize)]
245#[serde(rename_all = "camelCase")]
246struct ProjectRegistryV1 {
247 kind: String,
248 schema_version: u32,
249 #[serde(default)]
250 projects: Vec<RegisteredProjectV1>,
251}
252
253#[derive(Debug, Deserialize)]
254#[serde(rename_all = "camelCase")]
255struct RegisteredProjectV1 {
256 project_id: String,
257 name: String,
258 project_root: PathBuf,
259 board_dir: PathBuf,
260 team_name: String,
261 session_name: String,
262 #[serde(default)]
263 channel_bindings: Vec<ProjectChannelBindingV1>,
264 owner: Option<String>,
265 #[serde(default)]
266 tags: Vec<String>,
267 #[serde(default)]
268 policy_flags: ProjectPolicyFlags,
269 created_at: u64,
270 updated_at: u64,
271}
272
273#[derive(Debug, Deserialize)]
274#[serde(rename_all = "camelCase")]
275struct ProjectChannelBindingV1 {
276 channel: String,
277 binding: String,
278}
279
280pub fn registry_path() -> Result<PathBuf> {
281 if let Some(path) = std::env::var_os(REGISTRY_PATH_ENV) {
282 return Ok(PathBuf::from(path));
283 }
284
285 let home = std::env::var("HOME").context("cannot determine home directory")?;
286 Ok(PathBuf::from(home).join(".batty").join(REGISTRY_FILENAME))
287}
288
289pub fn routing_state_path() -> Result<PathBuf> {
290 if let Some(path) = std::env::var_os(ROUTING_STATE_PATH_ENV) {
291 return Ok(PathBuf::from(path));
292 }
293
294 let home = std::env::var("HOME").context("cannot determine home directory")?;
295 Ok(PathBuf::from(home)
296 .join(".batty")
297 .join(ROUTING_STATE_FILENAME))
298}
299
300pub fn load_registry() -> Result<ProjectRegistry> {
301 load_registry_at(®istry_path()?)
302}
303
304pub fn register_project(registration: ProjectRegistration) -> Result<RegisteredProject> {
305 register_project_at(®istry_path()?, registration)
306}
307
308pub fn unregister_project(project_id: &str) -> Result<Option<RegisteredProject>> {
309 unregister_project_at(®istry_path()?, project_id)
310}
311
312pub fn list_projects() -> Result<Vec<RegisteredProject>> {
313 let mut projects = load_registry()?.projects;
314 projects.sort_by(|left, right| left.project_id.cmp(&right.project_id));
315 Ok(projects)
316}
317
318pub fn get_project(project_id: &str) -> Result<Option<RegisteredProject>> {
319 get_project_at(®istry_path()?, project_id)
320}
321
322pub fn get_project_status(project_id: &str) -> Result<ProjectStatusDto> {
323 let Some(project) = get_project(project_id)? else {
324 bail!("project '{}' is not registered", project_id);
325 };
326 project_status(&project)
327}
328
329pub fn start_project(project_id: &str) -> Result<ProjectLifecycleActionResult> {
330 let Some(project) = get_project(project_id)? else {
331 bail!("project '{}' is not registered", project_id);
332 };
333 let status = project_status(&project)?;
334 if status.running {
335 return Ok(ProjectLifecycleActionResult {
336 project_id: project.project_id.clone(),
337 action: ProjectLifecycleAction::Start,
338 changed: false,
339 lifecycle: status.lifecycle,
340 running: status.running,
341 audit_message: format!("project '{}' is already running", project.project_id),
342 status,
343 });
344 }
345
346 crate::team::start_team(&project.project_root, false)?;
347 let status = project_status(&project)?;
348 Ok(ProjectLifecycleActionResult {
349 project_id: project.project_id.clone(),
350 action: ProjectLifecycleAction::Start,
351 changed: true,
352 lifecycle: status.lifecycle,
353 running: status.running,
354 audit_message: format!(
355 "started project '{}' in session {}",
356 project.project_id, project.session_name
357 ),
358 status,
359 })
360}
361
362pub fn stop_project(project_id: &str) -> Result<ProjectLifecycleActionResult> {
363 let Some(project) = get_project(project_id)? else {
364 bail!("project '{}' is not registered", project_id);
365 };
366 let status = project_status(&project)?;
367 if !status.running {
368 return Ok(ProjectLifecycleActionResult {
369 project_id: project.project_id.clone(),
370 action: ProjectLifecycleAction::Stop,
371 changed: false,
372 lifecycle: status.lifecycle,
373 running: status.running,
374 audit_message: format!("project '{}' is already stopped", project.project_id),
375 status,
376 });
377 }
378
379 crate::team::stop_team(&project.project_root)?;
380 let status = project_status(&project)?;
381 Ok(ProjectLifecycleActionResult {
382 project_id: project.project_id.clone(),
383 action: ProjectLifecycleAction::Stop,
384 changed: true,
385 lifecycle: status.lifecycle,
386 running: status.running,
387 audit_message: format!(
388 "stopped project '{}' and recorded shutdown summary",
389 project.project_id
390 ),
391 status,
392 })
393}
394
395pub fn restart_project(project_id: &str) -> Result<ProjectLifecycleActionResult> {
396 let Some(project) = get_project(project_id)? else {
397 bail!("project '{}' is not registered", project_id);
398 };
399 let before = project_status(&project)?;
400 if before.running {
401 crate::team::stop_team(&project.project_root)?;
402 }
403 crate::team::start_team(&project.project_root, false)?;
404 let status = project_status(&project)?;
405 Ok(ProjectLifecycleActionResult {
406 project_id: project.project_id.clone(),
407 action: ProjectLifecycleAction::Restart,
408 changed: true,
409 lifecycle: status.lifecycle,
410 running: status.running,
411 audit_message: if before.running {
412 format!(
413 "restarted project '{}' in session {}",
414 project.project_id, project.session_name
415 )
416 } else {
417 format!(
418 "started stopped project '{}' via restart in session {}",
419 project.project_id, project.session_name
420 )
421 },
422 status,
423 })
424}
425
426pub fn load_routing_state() -> Result<ProjectRoutingState> {
427 load_routing_state_at(&routing_state_path()?)
428}
429
430pub fn set_active_project(
431 project_id: &str,
432 scope: ActiveProjectScope,
433) -> Result<ActiveProjectSelection> {
434 set_active_project_at(®istry_path()?, &routing_state_path()?, project_id, scope)
435}
436
437pub fn resolve_project_for_message(
438 request: &ProjectRoutingRequest,
439) -> Result<ProjectRoutingDecision> {
440 resolve_project_for_message_at(®istry_path()?, &routing_state_path()?, request)
441}
442
443pub fn load_registry_at(path: &Path) -> Result<ProjectRegistry> {
444 if !path.exists() {
445 return Ok(ProjectRegistry::default());
446 }
447
448 let content = std::fs::read_to_string(path)
449 .with_context(|| format!("failed to read registry {}", path.display()))?;
450 let raw: Value = serde_json::from_str(&content)
451 .with_context(|| format!("failed to parse registry {}", path.display()))?;
452
453 let Some(kind) = raw.get("kind").and_then(Value::as_str) else {
454 bail!("registry {} is missing kind", path.display());
455 };
456 if kind != REGISTRY_KIND {
457 bail!(
458 "registry {} has unsupported kind '{}' (expected '{}')",
459 path.display(),
460 kind,
461 REGISTRY_KIND
462 );
463 }
464
465 let Some(schema_version) = raw
466 .get("schemaVersion")
467 .and_then(Value::as_u64)
468 .map(|value| value as u32)
469 else {
470 bail!("registry {} is missing schemaVersion", path.display());
471 };
472
473 let registry = match schema_version {
474 1 => migrate_registry_v1(raw)?,
475 REGISTRY_SCHEMA_VERSION => serde_json::from_value(raw)
476 .with_context(|| format!("failed to decode registry {}", path.display()))?,
477 other => {
478 bail!(
479 "registry {} uses unsupported schemaVersion {}",
480 path.display(),
481 other
482 )
483 }
484 };
485 validate_registry(®istry)?;
486 Ok(registry)
487}
488
489pub fn load_routing_state_at(path: &Path) -> Result<ProjectRoutingState> {
490 if !path.exists() {
491 return Ok(ProjectRoutingState::default());
492 }
493
494 let content = std::fs::read_to_string(path)
495 .with_context(|| format!("failed to read routing state {}", path.display()))?;
496 let raw: Value = serde_json::from_str(&content)
497 .with_context(|| format!("failed to parse routing state {}", path.display()))?;
498
499 let Some(kind) = raw.get("kind").and_then(Value::as_str) else {
500 bail!("routing state {} is missing kind", path.display());
501 };
502 if kind != ROUTING_STATE_KIND {
503 bail!(
504 "routing state {} has unsupported kind '{}' (expected '{}')",
505 path.display(),
506 kind,
507 ROUTING_STATE_KIND
508 );
509 }
510
511 let Some(schema_version) = raw
512 .get("schemaVersion")
513 .and_then(Value::as_u64)
514 .map(|value| value as u32)
515 else {
516 bail!("routing state {} is missing schemaVersion", path.display());
517 };
518 if schema_version != ROUTING_STATE_SCHEMA_VERSION {
519 bail!(
520 "routing state {} uses unsupported schemaVersion {}",
521 path.display(),
522 schema_version
523 );
524 }
525
526 let state: ProjectRoutingState = serde_json::from_value(raw)
527 .with_context(|| format!("failed to decode routing state {}", path.display()))?;
528 validate_routing_state(&state)?;
529 Ok(state)
530}
531
532pub fn register_project_at(
533 path: &Path,
534 registration: ProjectRegistration,
535) -> Result<RegisteredProject> {
536 let mut registry = load_registry_at(path)?;
537 let project = normalize_registration(registration)?;
538 ensure_unique(®istry, &project)?;
539
540 registry.projects.push(project.clone());
541 registry
542 .projects
543 .sort_by(|left, right| left.project_id.cmp(&right.project_id));
544 save_registry(path, ®istry)?;
545 Ok(project)
546}
547
548pub fn unregister_project_at(path: &Path, project_id: &str) -> Result<Option<RegisteredProject>> {
549 let mut registry = load_registry_at(path)?;
550 if let Some(index) = registry
551 .projects
552 .iter()
553 .position(|project| project.project_id == project_id)
554 {
555 let removed = registry.projects.remove(index);
556 save_registry(path, ®istry)?;
557 Ok(Some(removed))
558 } else {
559 Ok(None)
560 }
561}
562
563pub fn get_project_at(path: &Path, project_id: &str) -> Result<Option<RegisteredProject>> {
564 let registry = load_registry_at(path)?;
565 Ok(registry
566 .projects
567 .into_iter()
568 .find(|project| project.project_id == project_id))
569}
570
571pub fn set_active_project_at(
572 registry_path: &Path,
573 state_path: &Path,
574 project_id: &str,
575 scope: ActiveProjectScope,
576) -> Result<ActiveProjectSelection> {
577 let registry = load_registry_at(registry_path)?;
578 if registry
579 .projects
580 .iter()
581 .all(|project| project.project_id != project_id)
582 {
583 bail!("project '{}' is not registered", project_id);
584 }
585
586 let mut state = load_routing_state_at(state_path)?;
587 let selection = ActiveProjectSelection {
588 project_id: project_id.to_string(),
589 scope,
590 updated_at: crate::team::now_unix(),
591 };
592
593 state
594 .selections
595 .retain(|existing| !same_scope(&existing.scope, &selection.scope));
596 state.selections.push(selection.clone());
597 sort_selections(&mut state.selections);
598 save_routing_state(state_path, &state)?;
599 Ok(selection)
600}
601
602pub fn resolve_project_for_message_at(
603 registry_path: &Path,
604 state_path: &Path,
605 request: &ProjectRoutingRequest,
606) -> Result<ProjectRoutingDecision> {
607 let registry = load_registry_at(registry_path)?;
608 let routing_state = load_routing_state_at(state_path).unwrap_or_default();
609 let projects = registry
610 .projects
611 .iter()
612 .filter(|project| !project.policy_flags.archived)
613 .collect::<Vec<_>>();
614
615 if projects.is_empty() {
616 return Ok(ProjectRoutingDecision {
617 selected_project_id: None,
618 requires_confirmation: true,
619 confidence: RoutingConfidence::Low,
620 reason: "No active projects are registered.".to_string(),
621 candidates: Vec::new(),
622 });
623 }
624
625 let control_action = looks_like_control_action(&request.message);
626 if projects.len() == 1 {
627 let project = projects[0];
628 let requires_confirmation = control_action;
629 return Ok(ProjectRoutingDecision {
630 selected_project_id: Some(project.project_id.clone()),
631 requires_confirmation,
632 confidence: if requires_confirmation {
633 RoutingConfidence::Medium
634 } else {
635 RoutingConfidence::High
636 },
637 reason: if requires_confirmation {
638 format!(
639 "Only one registered project exists ({}), but this looks like a control action and should be confirmed.",
640 project.project_id
641 )
642 } else {
643 format!(
644 "Selected {} because it is the only registered project.",
645 project.project_id
646 )
647 },
648 candidates: vec![ProjectRoutingCandidate {
649 project_id: project.project_id.clone(),
650 reason: "only registered project".to_string(),
651 score: 100,
652 }],
653 });
654 }
655
656 let mut candidates = projects
657 .iter()
658 .filter_map(|project| score_project(project, &routing_state, request))
659 .collect::<Vec<_>>();
660 candidates.sort_by(|left, right| {
661 right
662 .score
663 .cmp(&left.score)
664 .then_with(|| left.project_id.cmp(&right.project_id))
665 });
666
667 let Some(top) = candidates.first().cloned() else {
668 return Ok(ProjectRoutingDecision {
669 selected_project_id: None,
670 requires_confirmation: true,
671 confidence: RoutingConfidence::Low,
672 reason: "Message did not identify a project with high confidence. Ask the user to choose a projectId.".to_string(),
673 candidates,
674 });
675 };
676
677 let ambiguous = candidates
678 .get(1)
679 .is_some_and(|second| second.score + 10 >= top.score);
680 let requires_confirmation = ambiguous || !auto_route_allowed(&top, control_action);
681
682 Ok(ProjectRoutingDecision {
683 selected_project_id: (!ambiguous).then_some(top.project_id.clone()),
684 requires_confirmation,
685 confidence: routing_confidence(top.score),
686 reason: routing_reason(&top, ambiguous, control_action),
687 candidates,
688 })
689}
690
691pub fn parse_channel_binding(spec: &str) -> Result<ProjectChannelBinding> {
692 let Some((channel, binding)) = spec.split_once('=') else {
693 bail!("invalid channel binding '{spec}'; expected <channel>=<binding>");
694 };
695
696 let channel = trim_required("channelBinding.channel", channel)?;
697 let binding = trim_required("channelBinding.binding", binding)?;
698 Ok(ProjectChannelBinding {
699 channel,
700 binding,
701 thread_binding: None,
702 })
703}
704
705pub fn parse_thread_binding(spec: &str) -> Result<ProjectChannelBinding> {
706 let Some((channel_spec, thread_binding)) = spec.split_once('#') else {
707 bail!("invalid thread binding '{spec}'; expected <channel>=<binding>#<thread-binding>");
708 };
709 let mut binding = parse_channel_binding(channel_spec)?;
710 binding.thread_binding = Some(trim_required(
711 "channelBinding.threadBinding",
712 thread_binding,
713 )?);
714 Ok(binding)
715}
716
717fn migrate_registry_v1(raw: Value) -> Result<ProjectRegistry> {
718 let legacy: ProjectRegistryV1 =
719 serde_json::from_value(raw).context("failed to decode legacy schemaVersion 1 registry")?;
720 if legacy.kind != REGISTRY_KIND {
721 bail!("registry kind must be '{}'", REGISTRY_KIND);
722 }
723 if legacy.schema_version != 1 {
724 bail!("legacy registry schemaVersion must be 1");
725 }
726
727 Ok(ProjectRegistry {
728 kind: REGISTRY_KIND.to_string(),
729 schema_version: REGISTRY_SCHEMA_VERSION,
730 projects: legacy
731 .projects
732 .into_iter()
733 .map(|project| RegisteredProject {
734 project_id: project.project_id,
735 name: project.name,
736 aliases: Vec::new(),
737 project_root: project.project_root,
738 board_dir: project.board_dir,
739 team_name: project.team_name,
740 session_name: project.session_name,
741 channel_bindings: project
742 .channel_bindings
743 .into_iter()
744 .map(|binding| ProjectChannelBinding {
745 channel: binding.channel,
746 binding: binding.binding,
747 thread_binding: None,
748 })
749 .collect(),
750 owner: project.owner,
751 tags: project.tags,
752 policy_flags: project.policy_flags,
753 created_at: project.created_at,
754 updated_at: project.updated_at,
755 })
756 .collect(),
757 })
758}
759
760fn save_registry(path: &Path, registry: &ProjectRegistry) -> Result<()> {
761 validate_registry(registry)?;
762
763 if let Some(parent) = path.parent() {
764 std::fs::create_dir_all(parent)
765 .with_context(|| format!("failed to create registry dir {}", parent.display()))?;
766 }
767
768 let content = serde_json::to_string_pretty(registry)?;
769 std::fs::write(path, format!("{content}\n"))
770 .with_context(|| format!("failed to write registry {}", path.display()))?;
771 Ok(())
772}
773
774fn save_routing_state(path: &Path, state: &ProjectRoutingState) -> Result<()> {
775 validate_routing_state(state)?;
776
777 if let Some(parent) = path.parent() {
778 std::fs::create_dir_all(parent)
779 .with_context(|| format!("failed to create routing state dir {}", parent.display()))?;
780 }
781
782 let content = serde_json::to_string_pretty(state)?;
783 std::fs::write(path, format!("{content}\n"))
784 .with_context(|| format!("failed to write routing state {}", path.display()))?;
785 Ok(())
786}
787
788fn validate_registry(registry: &ProjectRegistry) -> Result<()> {
789 if registry.kind != REGISTRY_KIND {
790 bail!("registry kind must be '{}'", REGISTRY_KIND);
791 }
792 if registry.schema_version != REGISTRY_SCHEMA_VERSION {
793 bail!("registry schemaVersion must be {}", REGISTRY_SCHEMA_VERSION);
794 }
795
796 let mut project_ids = HashSet::new();
797 let mut project_roots = HashSet::new();
798 let mut team_names = HashSet::new();
799 let mut session_names = HashSet::new();
800 let mut aliases = HashSet::new();
801
802 for project in ®istry.projects {
803 validate_project(project)?;
804
805 if !project_ids.insert(project.project_id.clone()) {
806 bail!("duplicate projectId '{}'", project.project_id);
807 }
808 if !project_roots.insert(project.project_root.clone()) {
809 bail!("duplicate projectRoot '{}'", project.project_root.display());
810 }
811 if !team_names.insert(project.team_name.clone()) {
812 bail!("duplicate teamName '{}'", project.team_name);
813 }
814 if !session_names.insert(project.session_name.clone()) {
815 bail!("duplicate sessionName '{}'", project.session_name);
816 }
817 for alias in &project.aliases {
818 if !aliases.insert(alias.clone()) {
819 bail!("duplicate alias '{}'", alias);
820 }
821 }
822 }
823
824 Ok(())
825}
826
827fn validate_routing_state(state: &ProjectRoutingState) -> Result<()> {
828 if state.kind != ROUTING_STATE_KIND {
829 bail!("routing state kind must be '{}'", ROUTING_STATE_KIND);
830 }
831 if state.schema_version != ROUTING_STATE_SCHEMA_VERSION {
832 bail!(
833 "routing state schemaVersion must be {}",
834 ROUTING_STATE_SCHEMA_VERSION
835 );
836 }
837
838 let mut scopes = HashSet::new();
839 for selection in &state.selections {
840 validate_project_id(&selection.project_id)?;
841 if !scopes.insert(selection.scope.clone()) {
842 bail!("duplicate active-project scope in routing state");
843 }
844 }
845 Ok(())
846}
847
848fn ensure_unique(registry: &ProjectRegistry, project: &RegisteredProject) -> Result<()> {
849 if registry
850 .projects
851 .iter()
852 .any(|existing| existing.project_id == project.project_id)
853 {
854 bail!("projectId '{}' is already registered", project.project_id);
855 }
856 if registry
857 .projects
858 .iter()
859 .any(|existing| existing.project_root == project.project_root)
860 {
861 bail!(
862 "projectRoot '{}' is already registered",
863 project.project_root.display()
864 );
865 }
866 if registry
867 .projects
868 .iter()
869 .any(|existing| existing.team_name == project.team_name)
870 {
871 bail!("teamName '{}' is already registered", project.team_name);
872 }
873 if registry
874 .projects
875 .iter()
876 .any(|existing| existing.session_name == project.session_name)
877 {
878 bail!(
879 "sessionName '{}' is already registered",
880 project.session_name
881 );
882 }
883
884 let existing_aliases = registry
885 .projects
886 .iter()
887 .flat_map(|existing| existing.aliases.iter())
888 .cloned()
889 .collect::<HashSet<_>>();
890 for alias in &project.aliases {
891 if existing_aliases.contains(alias) {
892 bail!("alias '{}' is already registered", alias);
893 }
894 }
895 Ok(())
896}
897
898fn normalize_registration(registration: ProjectRegistration) -> Result<RegisteredProject> {
899 validate_project_id(®istration.project_id)?;
900
901 let name = trim_required("name", ®istration.name)?;
902 let team_name = trim_required("teamName", ®istration.team_name)?;
903 let session_name = trim_required("sessionName", ®istration.session_name)?;
904 let owner = registration
905 .owner
906 .as_deref()
907 .map(str::trim)
908 .and_then(|value| {
909 if value.is_empty() {
910 None
911 } else {
912 Some(value.to_string())
913 }
914 });
915
916 let project_root = normalize_path(®istration.project_root)?;
917 let board_dir = normalize_path(®istration.board_dir)?;
918 if !board_dir.starts_with(&project_root) {
919 bail!(
920 "boardDir '{}' must be inside projectRoot '{}'",
921 board_dir.display(),
922 project_root.display()
923 );
924 }
925
926 let aliases = normalize_labels("alias", registration.aliases)?;
927 let tags = normalize_labels("tag", registration.tags)?;
928
929 let mut seen_bindings = HashSet::new();
930 let mut channel_bindings = Vec::with_capacity(registration.channel_bindings.len());
931 for binding in registration.channel_bindings {
932 let channel = trim_required("channelBinding.channel", &binding.channel)?;
933 let binding_value = trim_required("channelBinding.binding", &binding.binding)?;
934 let thread_binding = binding
935 .thread_binding
936 .as_deref()
937 .map(|value| trim_required("channelBinding.threadBinding", value))
938 .transpose()?;
939 let binding_key = (
940 channel.clone(),
941 binding_value.clone(),
942 thread_binding.clone().unwrap_or_default(),
943 );
944 if !seen_bindings.insert(binding_key) {
945 bail!("duplicate channel/thread binding for channel '{}'", channel);
946 }
947 channel_bindings.push(ProjectChannelBinding {
948 channel,
949 binding: binding_value,
950 thread_binding,
951 });
952 }
953 channel_bindings.sort_by(|left, right| {
954 left.channel
955 .cmp(&right.channel)
956 .then_with(|| left.binding.cmp(&right.binding))
957 .then_with(|| left.thread_binding.cmp(&right.thread_binding))
958 });
959
960 let now = crate::team::now_unix();
961 let project = RegisteredProject {
962 project_id: registration.project_id,
963 name,
964 aliases,
965 project_root,
966 board_dir,
967 team_name,
968 session_name,
969 channel_bindings,
970 owner,
971 tags,
972 policy_flags: registration.policy_flags,
973 created_at: now,
974 updated_at: now,
975 };
976
977 validate_project(&project)?;
978 Ok(project)
979}
980
981fn project_status(project: &RegisteredProject) -> Result<ProjectStatusDto> {
982 let report = load_project_status_report(project)?;
983 let lifecycle = resolve_lifecycle_state(&report);
984 let workflow_metrics = report.workflow_metrics.unwrap_or_default();
985
986 Ok(ProjectStatusDto {
987 project_id: project.project_id.clone(),
988 name: project.name.clone(),
989 team_name: project.team_name.clone(),
990 session_name: project.session_name.clone(),
991 project_root: project.project_root.clone(),
992 lifecycle,
993 running: report.running,
994 health: ProjectHealthSummary {
995 paused: report.paused,
996 watchdog_state: report.watchdog.state,
997 unhealthy_members: report.health.unhealthy_members,
998 member_count: report.health.member_count,
999 active_member_count: report.health.active_member_count,
1000 pending_inbox_count: report.health.pending_inbox_count,
1001 triage_backlog_count: report.health.triage_backlog_count,
1002 },
1003 pipeline: ProjectPipelineMetrics {
1004 active_task_count: report.active_tasks.len(),
1005 review_queue_count: report.review_queue.len(),
1006 runnable_count: workflow_metrics.runnable_count,
1007 blocked_count: workflow_metrics.blocked_count,
1008 stale_in_progress_count: workflow_metrics.stale_in_progress_count,
1009 stale_review_count: workflow_metrics.stale_review_count,
1010 auto_merge_rate: workflow_metrics.auto_merge_rate,
1011 rework_rate: workflow_metrics.rework_rate,
1012 avg_review_latency_secs: workflow_metrics.avg_review_latency_secs,
1013 },
1014 })
1015}
1016
1017fn resolve_lifecycle_state(
1018 report: &crate::team::status::TeamStatusJsonReport,
1019) -> ProjectLifecycleState {
1020 if !report.running {
1021 ProjectLifecycleState::Stopped
1022 } else if report.watchdog.state == "restarting" {
1023 ProjectLifecycleState::Recovering
1024 } else if report.paused
1025 || report.watchdog.state == "circuit-open"
1026 || !report.health.unhealthy_members.is_empty()
1027 {
1028 ProjectLifecycleState::Degraded
1029 } else {
1030 ProjectLifecycleState::Running
1031 }
1032}
1033
1034fn load_project_status_report(
1035 project: &RegisteredProject,
1036) -> Result<crate::team::status::TeamStatusJsonReport> {
1037 let config_path = crate::team::team_config_path(&project.project_root);
1038 if !config_path.exists() {
1039 bail!(
1040 "no team config found for project '{}' at {}",
1041 project.project_id,
1042 config_path.display()
1043 );
1044 }
1045
1046 let team_config = crate::team::config::TeamConfig::load(&config_path)?;
1047 let members = crate::team::hierarchy::resolve_hierarchy(&team_config)?;
1048 let session_running = tmux::session_exists(&project.session_name);
1049 let runtime_statuses = if session_running {
1050 crate::team::status::list_runtime_member_statuses(&project.session_name).unwrap_or_default()
1051 } else {
1052 std::collections::HashMap::new()
1053 };
1054 let pending_inbox_counts =
1055 crate::team::status::pending_inbox_counts(&project.project_root, &members);
1056 let triage_backlog_counts =
1057 crate::team::status::triage_backlog_counts(&project.project_root, &members);
1058 let owned_task_buckets =
1059 crate::team::status::owned_task_buckets(&project.project_root, &members);
1060 let supervisory_pressures = crate::team::status::supervisory_status_pressure(
1061 &project.project_root,
1062 &members,
1063 session_running,
1064 &runtime_statuses,
1065 );
1066 let branch_mismatches =
1067 crate::team::status::branch_mismatch_by_member(&project.project_root, &members);
1068 let worktree_staleness =
1069 crate::team::status::worktree_staleness_by_member(&project.project_root, &members);
1070 let agent_health = crate::team::status::agent_health_by_member(&project.project_root, &members);
1071 let paused = crate::team::pause_marker_path(&project.project_root).exists();
1072 let rows = crate::team::status::build_team_status_rows(
1073 &members,
1074 session_running,
1075 &runtime_statuses,
1076 &pending_inbox_counts,
1077 &triage_backlog_counts,
1078 &owned_task_buckets,
1079 &supervisory_pressures,
1080 &branch_mismatches,
1081 &worktree_staleness,
1082 &agent_health,
1083 );
1084 let workflow_metrics =
1085 crate::team::status::workflow_metrics_section(&project.project_root, &members)
1086 .map(|(_, metrics)| metrics);
1087 let watchdog =
1088 crate::team::status::load_watchdog_status(&project.project_root, session_running);
1089 let (active_tasks, review_queue) =
1090 crate::team::status::board_status_task_queues(&project.project_root)?;
1091
1092 Ok(crate::team::status::build_team_status_json_report(
1093 crate::team::status::TeamStatusJsonReportInput {
1094 team: team_config.name,
1095 session: project.session_name.clone(),
1096 session_running,
1097 paused,
1098 main_smoke: crate::team::status::load_main_smoke_state(&project.project_root),
1099 watchdog,
1100 workflow_metrics,
1101 active_tasks,
1102 review_queue,
1103 engineer_profiles: None,
1104 optional_subsystems: None,
1105 members: rows,
1106 },
1107 ))
1108}
1109
1110fn validate_project(project: &RegisteredProject) -> Result<()> {
1111 validate_project_id(&project.project_id)?;
1112 trim_required("name", &project.name)?;
1113 trim_required("teamName", &project.team_name)?;
1114 trim_required("sessionName", &project.session_name)?;
1115 if !project.project_root.is_absolute() {
1116 bail!(
1117 "projectRoot '{}' must be absolute",
1118 project.project_root.display()
1119 );
1120 }
1121 if !project.board_dir.is_absolute() {
1122 bail!(
1123 "boardDir '{}' must be absolute",
1124 project.board_dir.display()
1125 );
1126 }
1127 if !project.board_dir.starts_with(&project.project_root) {
1128 bail!(
1129 "boardDir '{}' must be inside projectRoot '{}'",
1130 project.board_dir.display(),
1131 project.project_root.display()
1132 );
1133 }
1134 for alias in &project.aliases {
1135 validate_label("alias", alias)?;
1136 }
1137 for tag in &project.tags {
1138 validate_label("tag", tag)?;
1139 }
1140 Ok(())
1141}
1142
1143fn validate_project_id(project_id: &str) -> Result<()> {
1144 if project_id.is_empty() {
1145 bail!("projectId cannot be empty");
1146 }
1147 if !project_id
1148 .chars()
1149 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_' | '.'))
1150 {
1151 bail!(
1152 "projectId '{}' must use lowercase ASCII letters, digits, '.', '-', or '_'",
1153 project_id
1154 );
1155 }
1156 Ok(())
1157}
1158
1159fn validate_label(field_name: &str, value: &str) -> Result<()> {
1160 if value.is_empty() {
1161 bail!("{field_name} cannot be empty");
1162 }
1163 if !value
1164 .chars()
1165 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_' | '.'))
1166 {
1167 bail!(
1168 "{field_name} '{}' must use lowercase ASCII letters, digits, '.', '-', or '_'",
1169 value
1170 );
1171 }
1172 Ok(())
1173}
1174
1175fn trim_required(field_name: &str, value: &str) -> Result<String> {
1176 let trimmed = value.trim();
1177 if trimmed.is_empty() {
1178 bail!("{field_name} cannot be empty");
1179 }
1180 Ok(trimmed.to_string())
1181}
1182
1183fn normalize_labels(field_name: &str, values: Vec<String>) -> Result<Vec<String>> {
1184 let mut labels = values
1185 .into_iter()
1186 .map(|value| value.trim().to_ascii_lowercase())
1187 .filter(|value| !value.is_empty())
1188 .collect::<Vec<_>>();
1189 labels.sort();
1190 labels.dedup();
1191 for label in &labels {
1192 validate_label(field_name, label)?;
1193 }
1194 Ok(labels)
1195}
1196
1197fn normalize_path(path: &Path) -> Result<PathBuf> {
1198 let absolute = if path.is_absolute() {
1199 path.to_path_buf()
1200 } else {
1201 std::env::current_dir()
1202 .context("failed to determine current directory")?
1203 .join(path)
1204 };
1205
1206 if absolute.exists() {
1207 absolute
1208 .canonicalize()
1209 .with_context(|| format!("failed to canonicalize {}", absolute.display()))
1210 } else {
1211 Ok(absolute)
1212 }
1213}
1214
1215fn sort_selections(selections: &mut [ActiveProjectSelection]) {
1216 selections.sort_by(|left, right| {
1217 scope_rank(&left.scope)
1218 .cmp(&scope_rank(&right.scope))
1219 .then_with(|| left.project_id.cmp(&right.project_id))
1220 });
1221}
1222
1223fn scope_rank(scope: &ActiveProjectScope) -> (&'static str, &str, &str, &str) {
1224 match scope {
1225 ActiveProjectScope::Global => ("global", "", "", ""),
1226 ActiveProjectScope::Channel { channel, binding } => ("channel", channel, binding, ""),
1227 ActiveProjectScope::Thread {
1228 channel,
1229 binding,
1230 thread_binding,
1231 } => ("thread", channel, binding, thread_binding),
1232 }
1233}
1234
1235fn same_scope(left: &ActiveProjectScope, right: &ActiveProjectScope) -> bool {
1236 match (left, right) {
1237 (ActiveProjectScope::Global, ActiveProjectScope::Global) => true,
1238 (
1239 ActiveProjectScope::Channel {
1240 channel: left_channel,
1241 binding: left_binding,
1242 },
1243 ActiveProjectScope::Channel {
1244 channel: right_channel,
1245 binding: right_binding,
1246 },
1247 ) => left_channel == right_channel && left_binding == right_binding,
1248 (
1249 ActiveProjectScope::Thread {
1250 channel: left_channel,
1251 binding: left_binding,
1252 thread_binding: left_thread,
1253 },
1254 ActiveProjectScope::Thread {
1255 channel: right_channel,
1256 binding: right_binding,
1257 thread_binding: right_thread,
1258 },
1259 ) => {
1260 left_channel == right_channel
1261 && left_binding == right_binding
1262 && left_thread == right_thread
1263 }
1264 _ => false,
1265 }
1266}
1267
1268fn score_project(
1269 project: &RegisteredProject,
1270 routing_state: &ProjectRoutingState,
1271 request: &ProjectRoutingRequest,
1272) -> Option<ProjectRoutingCandidate> {
1273 let message = request.message.to_ascii_lowercase();
1274 let tokens = normalized_tokens(&message);
1275 let mut score = 0u32;
1276 let mut reasons = Vec::new();
1277
1278 if tokens.iter().any(|token| token == &project.project_id) {
1279 score = score.max(100);
1280 reasons.push("explicit projectId mention".to_string());
1281 }
1282
1283 if project
1284 .aliases
1285 .iter()
1286 .any(|alias| tokens.iter().any(|token| token == alias))
1287 {
1288 score = score.max(98);
1289 reasons.push("explicit alias mention".to_string());
1290 }
1291
1292 if phrase_match(&message, &project.name.to_ascii_lowercase()) {
1293 score = score.max(95);
1294 reasons.push("project name mention".to_string());
1295 }
1296
1297 let mentioned_tags = project
1298 .tags
1299 .iter()
1300 .filter(|tag| tokens.iter().any(|token| token == *tag))
1301 .cloned()
1302 .collect::<Vec<_>>();
1303 if !mentioned_tags.is_empty() {
1304 score = score.max(70);
1305 reasons.push(format!("tag match ({})", mentioned_tags.join(", ")));
1306 }
1307
1308 if let Some(reason) = thread_binding_match(project, request) {
1309 score = score.max(100);
1310 reasons.push(reason);
1311 } else if let Some(reason) = channel_binding_match(project, request) {
1312 score = score.max(90);
1313 reasons.push(reason);
1314 }
1315
1316 if let Some(reason) = active_selection_match(project, routing_state, request) {
1317 score = score.max(reason.0);
1318 reasons.push(reason.1);
1319 }
1320
1321 if score == 0 {
1322 None
1323 } else {
1324 Some(ProjectRoutingCandidate {
1325 project_id: project.project_id.clone(),
1326 reason: reasons.join("; "),
1327 score,
1328 })
1329 }
1330}
1331
1332fn thread_binding_match(
1333 project: &RegisteredProject,
1334 request: &ProjectRoutingRequest,
1335) -> Option<String> {
1336 let channel = request.channel.as_deref()?;
1337 let binding = request.binding.as_deref()?;
1338 let thread_binding = request.thread_binding.as_deref()?;
1339 project.channel_bindings.iter().find_map(|candidate| {
1340 (candidate.channel == channel
1341 && candidate.binding == binding
1342 && candidate.thread_binding.as_deref() == Some(thread_binding))
1343 .then(|| "thread binding match".to_string())
1344 })
1345}
1346
1347fn channel_binding_match(
1348 project: &RegisteredProject,
1349 request: &ProjectRoutingRequest,
1350) -> Option<String> {
1351 let channel = request.channel.as_deref()?;
1352 let binding = request.binding.as_deref()?;
1353 project.channel_bindings.iter().find_map(|candidate| {
1354 (candidate.channel == channel
1355 && candidate.binding == binding
1356 && candidate.thread_binding.is_none())
1357 .then(|| "channel binding match".to_string())
1358 })
1359}
1360
1361fn active_selection_match(
1362 project: &RegisteredProject,
1363 routing_state: &ProjectRoutingState,
1364 request: &ProjectRoutingRequest,
1365) -> Option<(u32, String)> {
1366 routing_state
1367 .selections
1368 .iter()
1369 .find(|selection| selection.project_id == project.project_id)
1370 .and_then(|selection| match &selection.scope {
1371 ActiveProjectScope::Thread {
1372 channel,
1373 binding,
1374 thread_binding,
1375 } => (request.channel.as_deref() == Some(channel.as_str())
1376 && request.binding.as_deref() == Some(binding.as_str())
1377 && request.thread_binding.as_deref() == Some(thread_binding.as_str()))
1378 .then(|| (96, "active project selected for this thread".to_string())),
1379 ActiveProjectScope::Channel { channel, binding } => (request.channel.as_deref()
1380 == Some(channel.as_str())
1381 && request.binding.as_deref() == Some(binding.as_str()))
1382 .then(|| (80, "active project selected for this channel".to_string())),
1383 ActiveProjectScope::Global => Some((65, "global active project selection".to_string())),
1384 })
1385}
1386
1387fn normalized_tokens(value: &str) -> Vec<String> {
1388 let mut current = String::new();
1389 let mut tokens = Vec::new();
1390 for ch in value.chars() {
1391 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
1392 current.push(ch);
1393 } else if !current.is_empty() {
1394 tokens.push(std::mem::take(&mut current));
1395 }
1396 }
1397 if !current.is_empty() {
1398 tokens.push(current);
1399 }
1400 tokens
1401}
1402
1403fn phrase_match(message: &str, phrase: &str) -> bool {
1404 if phrase.is_empty() {
1405 return false;
1406 }
1407 message.contains(phrase)
1408}
1409
1410fn looks_like_control_action(message: &str) -> bool {
1411 let tokens = normalized_tokens(&message.to_ascii_lowercase());
1412 tokens.iter().any(|token| {
1413 matches!(
1414 token.as_str(),
1415 "stop"
1416 | "restart"
1417 | "delete"
1418 | "archive"
1419 | "merge"
1420 | "ship"
1421 | "deploy"
1422 | "kill"
1423 | "pause"
1424 | "resume"
1425 | "assign"
1426 | "unregister"
1427 | "register"
1428 | "instruct"
1429 )
1430 })
1431}
1432
1433fn auto_route_allowed(candidate: &ProjectRoutingCandidate, control_action: bool) -> bool {
1434 if candidate.score >= 98 {
1435 return true;
1436 }
1437 if candidate.reason.contains("thread binding match") {
1438 return true;
1439 }
1440 if control_action {
1441 return candidate.reason.contains("explicit projectId mention")
1442 || candidate.reason.contains("explicit alias mention")
1443 || candidate.reason.contains("thread binding match");
1444 }
1445 candidate.score >= 80
1446}
1447
1448fn routing_confidence(score: u32) -> RoutingConfidence {
1449 if score >= 95 {
1450 RoutingConfidence::High
1451 } else if score >= 75 {
1452 RoutingConfidence::Medium
1453 } else {
1454 RoutingConfidence::Low
1455 }
1456}
1457
1458fn routing_reason(top: &ProjectRoutingCandidate, ambiguous: bool, control_action: bool) -> String {
1459 if ambiguous {
1460 return format!(
1461 "Routing is ambiguous across multiple projects. Top match was {} because {}.",
1462 top.project_id, top.reason
1463 );
1464 }
1465 if control_action && !auto_route_allowed(top, true) {
1466 return format!(
1467 "Matched {} because {}, but this looks like a control action and requires confirmation.",
1468 top.project_id, top.reason
1469 );
1470 }
1471 format!("Selected {} because {}.", top.project_id, top.reason)
1472}
1473
1474#[cfg(test)]
1475mod tests {
1476 use super::*;
1477
1478 fn sample_registration(project_root: &Path) -> ProjectRegistration {
1479 ProjectRegistration {
1480 project_id: "alpha".to_string(),
1481 name: "Alpha".to_string(),
1482 aliases: vec!["batty".to_string()],
1483 project_root: project_root.to_path_buf(),
1484 board_dir: project_root
1485 .join(".batty")
1486 .join("team_config")
1487 .join("board"),
1488 team_name: "alpha".to_string(),
1489 session_name: "batty-alpha".to_string(),
1490 channel_bindings: vec![
1491 ProjectChannelBinding {
1492 channel: "telegram".to_string(),
1493 binding: "chat:123".to_string(),
1494 thread_binding: None,
1495 },
1496 ProjectChannelBinding {
1497 channel: "slack".to_string(),
1498 binding: "channel:C123".to_string(),
1499 thread_binding: Some("thread:abc".to_string()),
1500 },
1501 ],
1502 owner: Some("ops".to_string()),
1503 tags: vec!["core".to_string(), "pilot".to_string()],
1504 policy_flags: ProjectPolicyFlags {
1505 allow_openclaw_supervision: true,
1506 allow_cross_project_routing: false,
1507 allow_shared_service_routing: true,
1508 archived: false,
1509 },
1510 }
1511 }
1512
1513 fn write_beta(registry_path: &Path, root: &Path) {
1514 let beta_root = root.join("beta");
1515 std::fs::create_dir_all(beta_root.join(".batty/team_config/board")).unwrap();
1516 register_project_at(
1517 registry_path,
1518 ProjectRegistration {
1519 project_id: "beta".to_string(),
1520 name: "Beta".to_string(),
1521 aliases: vec!["other".to_string()],
1522 project_root: beta_root.clone(),
1523 board_dir: beta_root.join(".batty/team_config/board"),
1524 team_name: "beta".to_string(),
1525 session_name: "batty-beta".to_string(),
1526 channel_bindings: vec![ProjectChannelBinding {
1527 channel: "telegram".to_string(),
1528 binding: "chat:999".to_string(),
1529 thread_binding: None,
1530 }],
1531 owner: None,
1532 tags: vec!["backend".to_string()],
1533 policy_flags: ProjectPolicyFlags::default(),
1534 },
1535 )
1536 .unwrap();
1537 }
1538
1539 #[test]
1540 fn register_and_get_round_trip() {
1541 let tmp = tempfile::tempdir().unwrap();
1542 let project_root = tmp.path().join("alpha");
1543 std::fs::create_dir_all(project_root.join(".batty/team_config/board")).unwrap();
1544 let registry_path = tmp.path().join("project-registry.json");
1545
1546 let created =
1547 register_project_at(®istry_path, sample_registration(&project_root)).unwrap();
1548 assert_eq!(created.project_id, "alpha");
1549 assert_eq!(created.aliases, vec!["batty"]);
1550 assert_eq!(created.channel_bindings.len(), 2);
1551
1552 let fetched = get_project_at(®istry_path, "alpha").unwrap().unwrap();
1553 assert_eq!(fetched, created);
1554
1555 let listed = load_registry_at(®istry_path).unwrap();
1556 assert_eq!(listed.projects.len(), 1);
1557 assert_eq!(listed.schema_version, REGISTRY_SCHEMA_VERSION);
1558 }
1559
1560 #[test]
1561 fn load_migrates_schema_version_one() {
1562 let tmp = tempfile::tempdir().unwrap();
1563 let registry_path = tmp.path().join("project-registry.json");
1564 std::fs::write(
1565 ®istry_path,
1566 r#"{
1567 "kind": "batty.projectRegistry",
1568 "schemaVersion": 1,
1569 "projects": [
1570 {
1571 "projectId": "alpha",
1572 "name": "Alpha",
1573 "projectRoot": "/tmp/alpha",
1574 "boardDir": "/tmp/alpha/.batty/team_config/board",
1575 "teamName": "alpha",
1576 "sessionName": "batty-alpha",
1577 "channelBindings": [{ "channel": "telegram", "binding": "chat:123" }],
1578 "owner": null,
1579 "tags": ["core"],
1580 "policyFlags": {
1581 "allowOpenclawSupervision": true,
1582 "allowCrossProjectRouting": false,
1583 "allowSharedServiceRouting": false,
1584 "archived": false
1585 },
1586 "createdAt": 1,
1587 "updatedAt": 1
1588 }
1589 ]
1590}
1591"#,
1592 )
1593 .unwrap();
1594
1595 let registry = load_registry_at(®istry_path).unwrap();
1596 assert_eq!(registry.schema_version, 2);
1597 assert!(registry.projects[0].aliases.is_empty());
1598 assert_eq!(
1599 registry.projects[0].channel_bindings[0].thread_binding,
1600 None
1601 );
1602 }
1603
1604 #[test]
1605 fn parse_thread_binding_requires_hash_separator() {
1606 let error = parse_thread_binding("slack=channel:C123").unwrap_err();
1607 assert!(
1608 error
1609 .to_string()
1610 .contains("expected <channel>=<binding>#<thread-binding>")
1611 );
1612 }
1613
1614 #[test]
1615 fn set_active_project_upserts_scope() {
1616 let tmp = tempfile::tempdir().unwrap();
1617 let registry_path = tmp.path().join("project-registry.json");
1618 let state_path = tmp.path().join("project-routing-state.json");
1619 let project_root = tmp.path().join("alpha");
1620 std::fs::create_dir_all(project_root.join(".batty/team_config/board")).unwrap();
1621 register_project_at(®istry_path, sample_registration(&project_root)).unwrap();
1622
1623 set_active_project_at(
1624 ®istry_path,
1625 &state_path,
1626 "alpha",
1627 ActiveProjectScope::Global,
1628 )
1629 .unwrap();
1630 set_active_project_at(
1631 ®istry_path,
1632 &state_path,
1633 "alpha",
1634 ActiveProjectScope::Channel {
1635 channel: "telegram".to_string(),
1636 binding: "chat:123".to_string(),
1637 },
1638 )
1639 .unwrap();
1640
1641 let state = load_routing_state_at(&state_path).unwrap();
1642 assert_eq!(state.selections.len(), 2);
1643 }
1644
1645 #[test]
1646 fn resolve_prefers_explicit_alias() {
1647 let tmp = tempfile::tempdir().unwrap();
1648 let registry_path = tmp.path().join("project-registry.json");
1649 let state_path = tmp.path().join("project-routing-state.json");
1650 let project_root = tmp.path().join("alpha");
1651 std::fs::create_dir_all(project_root.join(".batty/team_config/board")).unwrap();
1652 register_project_at(®istry_path, sample_registration(&project_root)).unwrap();
1653 write_beta(®istry_path, tmp.path());
1654
1655 let decision = resolve_project_for_message_at(
1656 ®istry_path,
1657 &state_path,
1658 &ProjectRoutingRequest {
1659 message: "check batty".to_string(),
1660 channel: None,
1661 binding: None,
1662 thread_binding: None,
1663 },
1664 )
1665 .unwrap();
1666
1667 assert_eq!(decision.selected_project_id.as_deref(), Some("alpha"));
1668 assert!(!decision.requires_confirmation);
1669 assert_eq!(decision.confidence, RoutingConfidence::High);
1670 }
1671
1672 #[test]
1673 fn resolve_uses_thread_binding_as_high_confidence() {
1674 let tmp = tempfile::tempdir().unwrap();
1675 let registry_path = tmp.path().join("project-registry.json");
1676 let state_path = tmp.path().join("project-routing-state.json");
1677 let project_root = tmp.path().join("alpha");
1678 std::fs::create_dir_all(project_root.join(".batty/team_config/board")).unwrap();
1679 register_project_at(®istry_path, sample_registration(&project_root)).unwrap();
1680 write_beta(®istry_path, tmp.path());
1681
1682 let decision = resolve_project_for_message_at(
1683 ®istry_path,
1684 &state_path,
1685 &ProjectRoutingRequest {
1686 message: "check status".to_string(),
1687 channel: Some("slack".to_string()),
1688 binding: Some("channel:C123".to_string()),
1689 thread_binding: Some("thread:abc".to_string()),
1690 },
1691 )
1692 .unwrap();
1693
1694 assert_eq!(decision.selected_project_id.as_deref(), Some("alpha"));
1695 assert!(!decision.requires_confirmation);
1696 assert!(decision.reason.contains("thread binding"));
1697 }
1698
1699 #[test]
1700 fn resolve_requires_confirmation_for_control_action_from_global_active_project() {
1701 let tmp = tempfile::tempdir().unwrap();
1702 let registry_path = tmp.path().join("project-registry.json");
1703 let state_path = tmp.path().join("project-routing-state.json");
1704 let project_root = tmp.path().join("alpha");
1705 std::fs::create_dir_all(project_root.join(".batty/team_config/board")).unwrap();
1706 register_project_at(®istry_path, sample_registration(&project_root)).unwrap();
1707 write_beta(®istry_path, tmp.path());
1708 set_active_project_at(
1709 ®istry_path,
1710 &state_path,
1711 "alpha",
1712 ActiveProjectScope::Global,
1713 )
1714 .unwrap();
1715
1716 let decision = resolve_project_for_message_at(
1717 ®istry_path,
1718 &state_path,
1719 &ProjectRoutingRequest {
1720 message: "restart it".to_string(),
1721 channel: None,
1722 binding: None,
1723 thread_binding: None,
1724 },
1725 )
1726 .unwrap();
1727
1728 assert_eq!(decision.selected_project_id.as_deref(), Some("alpha"));
1729 assert!(decision.requires_confirmation);
1730 assert_eq!(decision.confidence, RoutingConfidence::Low);
1731 }
1732
1733 #[test]
1734 fn resolve_requires_clarification_when_only_generic_tag_matches() {
1735 let tmp = tempfile::tempdir().unwrap();
1736 let registry_path = tmp.path().join("project-registry.json");
1737 let state_path = tmp.path().join("project-routing-state.json");
1738 let alpha_root = tmp.path().join("alpha");
1739 std::fs::create_dir_all(alpha_root.join(".batty/team_config/board")).unwrap();
1740 register_project_at(®istry_path, sample_registration(&alpha_root)).unwrap();
1741 let gamma_root = tmp.path().join("gamma");
1742 std::fs::create_dir_all(gamma_root.join(".batty/team_config/board")).unwrap();
1743 register_project_at(
1744 ®istry_path,
1745 ProjectRegistration {
1746 project_id: "gamma".to_string(),
1747 name: "Gamma".to_string(),
1748 aliases: vec!["gamma".to_string()],
1749 project_root: gamma_root.clone(),
1750 board_dir: gamma_root.join(".batty/team_config/board"),
1751 team_name: "gamma".to_string(),
1752 session_name: "batty-gamma".to_string(),
1753 channel_bindings: Vec::new(),
1754 owner: None,
1755 tags: vec!["core".to_string()],
1756 policy_flags: ProjectPolicyFlags::default(),
1757 },
1758 )
1759 .unwrap();
1760
1761 let decision = resolve_project_for_message_at(
1762 ®istry_path,
1763 &state_path,
1764 &ProjectRoutingRequest {
1765 message: "check the core project".to_string(),
1766 channel: None,
1767 binding: None,
1768 thread_binding: None,
1769 },
1770 )
1771 .unwrap();
1772
1773 assert!(decision.selected_project_id.is_none());
1774 assert!(decision.requires_confirmation);
1775 assert!(
1776 decision.reason.contains("ambiguous") || decision.reason.contains("high confidence")
1777 );
1778 }
1779
1780 #[test]
1781 fn resolve_lifecycle_state_maps_stopped_recovering_and_degraded() {
1782 let base = crate::team::status::TeamStatusJsonReport {
1783 team: "batty".to_string(),
1784 session: "batty-batty".to_string(),
1785 running: true,
1786 paused: false,
1787 main_smoke: None,
1788 watchdog: crate::team::status::WatchdogStatus {
1789 state: "running".to_string(),
1790 restart_count: 0,
1791 current_backoff_secs: None,
1792 last_exit_reason: None,
1793 },
1794 health: crate::team::status::TeamStatusHealth {
1795 session_running: true,
1796 paused: false,
1797 member_count: 3,
1798 active_member_count: 1,
1799 pending_inbox_count: 0,
1800 triage_backlog_count: 0,
1801 unhealthy_members: Vec::new(),
1802 },
1803 workflow_metrics: None,
1804 active_tasks: Vec::new(),
1805 review_queue: Vec::new(),
1806 engineer_profiles: None,
1807 members: Vec::new(),
1808 optional_subsystems: None,
1809 };
1810
1811 let mut stopped = base.clone();
1812 stopped.running = false;
1813 assert_eq!(
1814 resolve_lifecycle_state(&stopped),
1815 ProjectLifecycleState::Stopped
1816 );
1817
1818 let mut recovering = base.clone();
1819 recovering.watchdog.state = "restarting".to_string();
1820 assert_eq!(
1821 resolve_lifecycle_state(&recovering),
1822 ProjectLifecycleState::Recovering
1823 );
1824
1825 let mut degraded = base.clone();
1826 degraded.health.unhealthy_members.push("eng-1".to_string());
1827 assert_eq!(
1828 resolve_lifecycle_state(°raded),
1829 ProjectLifecycleState::Degraded
1830 );
1831
1832 assert_eq!(
1833 resolve_lifecycle_state(&base),
1834 ProjectLifecycleState::Running
1835 );
1836 }
1837
1838 #[test]
1839 fn project_status_dto_serializes_stable_camel_case_shape() {
1840 let dto = ProjectStatusDto {
1841 project_id: "alpha".to_string(),
1842 name: "Alpha".to_string(),
1843 team_name: "alpha-team".to_string(),
1844 session_name: "batty-alpha".to_string(),
1845 project_root: PathBuf::from("/tmp/alpha"),
1846 lifecycle: ProjectLifecycleState::Recovering,
1847 running: true,
1848 health: ProjectHealthSummary {
1849 paused: false,
1850 watchdog_state: "restarting".to_string(),
1851 unhealthy_members: vec!["eng-1".to_string()],
1852 member_count: 4,
1853 active_member_count: 2,
1854 pending_inbox_count: 3,
1855 triage_backlog_count: 1,
1856 },
1857 pipeline: ProjectPipelineMetrics {
1858 active_task_count: 2,
1859 review_queue_count: 1,
1860 runnable_count: 5,
1861 blocked_count: 1,
1862 stale_in_progress_count: 0,
1863 stale_review_count: 1,
1864 auto_merge_rate: Some(0.75),
1865 rework_rate: Some(0.2),
1866 avg_review_latency_secs: Some(120.0),
1867 },
1868 };
1869
1870 let value = serde_json::to_value(&dto).unwrap();
1871 assert_eq!(value["projectId"], "alpha");
1872 assert_eq!(value["teamName"], "alpha-team");
1873 assert_eq!(value["sessionName"], "batty-alpha");
1874 assert_eq!(value["lifecycle"], "recovering");
1875 assert_eq!(value["health"]["watchdogState"], "restarting");
1876 assert_eq!(value["pipeline"]["activeTaskCount"], 2);
1877 assert_eq!(value["pipeline"]["avgReviewLatencySecs"], 120.0);
1878 }
1879}