Skip to main content

batty_cli/
project_registry.rs

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(&registry_path()?)
302}
303
304pub fn register_project(registration: ProjectRegistration) -> Result<RegisteredProject> {
305    register_project_at(&registry_path()?, registration)
306}
307
308pub fn unregister_project(project_id: &str) -> Result<Option<RegisteredProject>> {
309    unregister_project_at(&registry_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(&registry_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(&registry_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(&registry_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(&registry)?;
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(&registry, &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, &registry)?;
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, &registry)?;
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 &registry.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(&registration.project_id)?;
900
901    let name = trim_required("name", &registration.name)?;
902    let team_name = trim_required("teamName", &registration.team_name)?;
903    let session_name = trim_required("sessionName", &registration.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(&registration.project_root)?;
917    let board_dir = normalize_path(&registration.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(&registry_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(&registry_path, "alpha").unwrap().unwrap();
1553        assert_eq!(fetched, created);
1554
1555        let listed = load_registry_at(&registry_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            &registry_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(&registry_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(&registry_path, sample_registration(&project_root)).unwrap();
1622
1623        set_active_project_at(
1624            &registry_path,
1625            &state_path,
1626            "alpha",
1627            ActiveProjectScope::Global,
1628        )
1629        .unwrap();
1630        set_active_project_at(
1631            &registry_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(&registry_path, sample_registration(&project_root)).unwrap();
1653        write_beta(&registry_path, tmp.path());
1654
1655        let decision = resolve_project_for_message_at(
1656            &registry_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(&registry_path, sample_registration(&project_root)).unwrap();
1680        write_beta(&registry_path, tmp.path());
1681
1682        let decision = resolve_project_for_message_at(
1683            &registry_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(&registry_path, sample_registration(&project_root)).unwrap();
1707        write_beta(&registry_path, tmp.path());
1708        set_active_project_at(
1709            &registry_path,
1710            &state_path,
1711            "alpha",
1712            ActiveProjectScope::Global,
1713        )
1714        .unwrap();
1715
1716        let decision = resolve_project_for_message_at(
1717            &registry_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(&registry_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            &registry_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            &registry_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(&degraded),
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}