1use serde::{Deserialize, Serialize};
9
10use super::{events, status};
11
12#[cfg(test)]
13use crate::project_registry::RegisteredProject;
14
15pub const CONTRACT_SCHEMA_VERSION: u32 = 1;
16pub const MIN_SUPPORTED_SCHEMA_VERSION: u32 = 1;
17pub const CONTRACT_DESCRIPTOR_KIND: &str = "batty.openclaw.contractDescriptor";
18pub const TEAM_STATUS_KIND: &str = "batty.openclaw.teamStatus";
19pub const TEAM_EVENT_KIND: &str = "batty.openclaw.teamEvent";
20pub const TEAM_COMMAND_KIND: &str = "batty.openclaw.teamCommand";
21pub const CAPABILITY_NEGOTIATION_KIND: &str = "batty.openclaw.capabilityNegotiation";
22
23const SUPPORTED_CAPABILITIES: &[OpenClawCapability] = &[
24 OpenClawCapability::TeamStatus,
25 OpenClawCapability::TeamEvents,
26 OpenClawCapability::TeamCommands,
27 OpenClawCapability::EscalationSurface,
28 OpenClawCapability::ApprovalSurface,
29 OpenClawCapability::CapabilityNegotiation,
30];
31
32const SUPPORTED_EVENT_KINDS: &[TeamEventKind] = &[
33 TeamEventKind::TaskCompleted,
34 TeamEventKind::ReviewNudged,
35 TeamEventKind::ReviewEscalated,
36 TeamEventKind::ReviewStalled,
37 TeamEventKind::AgentStalled,
38 TeamEventKind::TaskStalled,
39 TeamEventKind::TaskMergedAutomatic,
40 TeamEventKind::TaskMergedManual,
41 TeamEventKind::TaskEscalated,
42 TeamEventKind::VerificationEscalated,
43 TeamEventKind::DeliveryFailed,
44 TeamEventKind::SessionStarted,
45 TeamEventKind::SessionReloading,
46 TeamEventKind::SessionReloaded,
47 TeamEventKind::SessionStopped,
48 TeamEventKind::AgentStarted,
49 TeamEventKind::AgentRestarted,
50 TeamEventKind::AgentCrashed,
51 TeamEventKind::AgentStopped,
52 TeamEventKind::AgentRespawned,
53 TeamEventKind::AgentContextExhausted,
54 TeamEventKind::AgentHealthChanged,
55 TeamEventKind::SessionTopologyChanged,
56 TeamEventKind::AgentRemoved,
57];
58
59const HUMAN_ONLY_DECISIONS: &[HumanDecisionKind] = &[
60 HumanDecisionKind::StopSession,
61 HumanDecisionKind::RestartSession,
62 HumanDecisionKind::ReviewDisposition,
63 HumanDecisionKind::MergeDisposition,
64 HumanDecisionKind::PolicyOverride,
65];
66
67const ESCALATION_KINDS: &[EscalationKind] = &[
68 EscalationKind::SessionUnavailable,
69 EscalationKind::MemberUnhealthy,
70 EscalationKind::ReviewQueueBlocked,
71 EscalationKind::TaskBlocked,
72 EscalationKind::HumanApprovalRequired,
73];
74
75#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
76#[serde(rename_all = "snake_case")]
77pub enum OpenClawCapability {
78 TeamStatus,
79 TeamEvents,
80 TeamCommands,
81 EscalationSurface,
82 ApprovalSurface,
83 CapabilityNegotiation,
84}
85
86#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
87#[serde(rename_all = "snake_case")]
88pub enum TeamLifecycle {
89 Running,
90 Stopped,
91 Degraded,
92 Recovering,
93}
94
95#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
96#[serde(rename_all = "snake_case")]
97pub enum MemberState {
98 Starting,
99 Idle,
100 Working,
101 Done,
102 Crashed,
103 Unknown,
104}
105
106#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
107#[serde(rename_all = "snake_case")]
108pub enum MemberHealth {
109 Healthy,
110 Warning,
111 Unhealthy,
112}
113
114#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
115#[serde(rename_all = "snake_case")]
116pub enum BackendHealth {
117 Healthy,
118 Degraded,
119 Unreachable,
120}
121
122#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
123#[serde(rename_all = "snake_case")]
124pub enum TeamEventTopic {
125 Completion,
126 Review,
127 Stall,
128 Merge,
129 Escalation,
130 DeliveryFailure,
131 Lifecycle,
132}
133
134#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
135#[serde(rename_all = "snake_case")]
136pub enum TeamEventKind {
137 TaskCompleted,
138 ReviewNudged,
139 ReviewEscalated,
140 ReviewStalled,
141 AgentStalled,
142 TaskStalled,
143 TaskMergedAutomatic,
144 TaskMergedManual,
145 TaskEscalated,
146 VerificationEscalated,
147 DeliveryFailed,
148 SessionStarted,
149 SessionReloading,
150 SessionReloaded,
151 SessionStopped,
152 AgentStarted,
153 AgentRestarted,
154 AgentCrashed,
155 AgentStopped,
156 AgentRespawned,
157 AgentContextExhausted,
158 AgentHealthChanged,
159 SessionTopologyChanged,
160 AgentRemoved,
161}
162
163impl TeamEventKind {
164 pub(crate) const fn legacy_event_type(self) -> &'static str {
165 match self {
166 Self::TaskCompleted => "task.completed",
167 Self::ReviewNudged => "review.nudged",
168 Self::ReviewEscalated => "review.escalated",
169 Self::ReviewStalled => "review.stalled",
170 Self::AgentStalled => "agent.stalled",
171 Self::TaskStalled => "task.stalled",
172 Self::TaskMergedAutomatic => "task.merged.automatic",
173 Self::TaskMergedManual => "task.merged.manual",
174 Self::TaskEscalated => "task.escalated",
175 Self::VerificationEscalated => "verification.escalated",
176 Self::DeliveryFailed => "delivery.failed",
177 Self::SessionStarted => "session.started",
178 Self::SessionReloading => "session.reloading",
179 Self::SessionReloaded => "session.reloaded",
180 Self::SessionStopped => "session.stopped",
181 Self::AgentStarted => "agent.started",
182 Self::AgentRestarted => "agent.restarted",
183 Self::AgentCrashed => "agent.crashed",
184 Self::AgentStopped => "agent.stopped",
185 Self::AgentRespawned => "agent.respawned",
186 Self::AgentContextExhausted => "agent.context_exhausted",
187 Self::AgentHealthChanged => "agent.health_changed",
188 Self::SessionTopologyChanged => "session.topology_changed",
189 Self::AgentRemoved => "agent.removed",
190 }
191 }
192}
193
194#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
195#[serde(rename_all = "snake_case")]
196pub enum TeamCommandKind {
197 Start,
198 Stop,
199 Restart,
200 Send,
201 Nudge,
202 Review,
203 Merge,
204}
205
206#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
207#[serde(rename_all = "snake_case")]
208pub enum CommandScope {
209 Team,
210 Member,
211 Task,
212}
213
214#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
215#[serde(rename_all = "snake_case")]
216pub enum ApprovalLevel {
217 NotRequired,
218 Suggested,
219 Required,
220}
221
222#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
223#[serde(rename_all = "snake_case")]
224pub enum HumanDecisionKind {
225 StopSession,
226 RestartSession,
227 ReviewDisposition,
228 MergeDisposition,
229 PolicyOverride,
230}
231
232#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
233#[serde(rename_all = "snake_case")]
234pub enum EscalationAuthority {
235 RecommendOnly,
236 HumanApproved,
237}
238
239#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
240#[serde(rename_all = "snake_case")]
241pub enum EscalationKind {
242 SessionUnavailable,
243 MemberUnhealthy,
244 ReviewQueueBlocked,
245 TaskBlocked,
246 HumanApprovalRequired,
247}
248
249#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
250#[serde(rename_all = "snake_case")]
251pub enum ReviewDisposition {
252 Request,
253 Approve,
254 Rework,
255}
256
257#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
258#[serde(rename_all = "snake_case")]
259pub enum MergeStrategy {
260 FastForward,
261 Squash,
262 Rebase,
263 Manual,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
267#[serde(rename_all = "camelCase")]
268pub struct DtoKinds {
269 pub team_status: String,
270 pub team_event: String,
271 pub team_command: String,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
275#[serde(rename_all = "camelCase")]
276pub struct VersioningPolicy {
277 pub current_schema_version: u32,
278 pub min_supported_schema_version: u32,
279 pub add_only_fields: bool,
280 pub new_enum_variants_require_capability_review: bool,
281 pub incompatible_changes_require_new_schema_version: bool,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
285#[serde(rename_all = "camelCase")]
286pub struct AntiCorruptionBoundary {
287 pub batty_is_system_of_record: bool,
288 pub prompt_wording_leaks_forbidden: bool,
289 pub command_intents_are_explicit: bool,
290 pub status_inputs: Vec<String>,
291 pub event_inputs: Vec<String>,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
295#[serde(rename_all = "camelCase")]
296pub struct CommandPolicy {
297 pub command: TeamCommandKind,
298 pub scope: CommandScope,
299 pub approval_level: ApprovalLevel,
300 #[serde(default)]
301 pub human_only_decisions: Vec<HumanDecisionKind>,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
305#[serde(rename_all = "camelCase")]
306pub struct EscalationSurface {
307 pub authority: EscalationAuthority,
308 #[serde(default)]
309 pub supported_kinds: Vec<EscalationKind>,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
313#[serde(rename_all = "camelCase")]
314pub struct ApprovalSurface {
315 #[serde(default)]
316 pub command_policies: Vec<CommandPolicy>,
317 #[serde(default)]
318 pub human_only_decisions: Vec<HumanDecisionKind>,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
322#[serde(rename_all = "camelCase")]
323pub struct ContractDescriptor {
324 pub kind: String,
325 pub schema_version: u32,
326 pub min_supported_schema_version: u32,
327 pub dto_kinds: DtoKinds,
328 pub versioning: VersioningPolicy,
329 pub anti_corruption_boundary: AntiCorruptionBoundary,
330 #[serde(default)]
331 pub capabilities: Vec<OpenClawCapability>,
332 #[serde(default)]
333 pub supported_event_kinds: Vec<TeamEventKind>,
334 pub escalation_surface: EscalationSurface,
335 pub approval_surface: ApprovalSurface,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
339#[serde(rename_all = "camelCase")]
340pub struct CapabilityNegotiationRequest {
341 pub kind: String,
342 pub requested_schema_version: u32,
343 pub min_compatible_schema_version: u32,
344 #[serde(default)]
345 pub requested_capabilities: Vec<OpenClawCapability>,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
349#[serde(rename_all = "camelCase")]
350pub struct CapabilityNegotiationResult {
351 pub kind: String,
352 pub schema_version: u32,
353 pub min_supported_schema_version: u32,
354 pub compatible: bool,
355 #[serde(skip_serializing_if = "Option::is_none")]
356 pub negotiated_schema_version: Option<u32>,
357 #[serde(default)]
358 pub granted_capabilities: Vec<OpenClawCapability>,
359 pub reason: String,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
363#[serde(rename_all = "camelCase")]
364pub struct MemberStatus {
365 pub name: String,
366 pub role: String,
367 pub role_type: String,
368 pub state: MemberState,
369 pub health: MemberHealth,
370 #[serde(default)]
371 pub active_task_ids: Vec<String>,
372 #[serde(default)]
373 pub review_task_ids: Vec<String>,
374 pub pending_inbox_count: usize,
375 pub triage_backlog_count: usize,
376 #[serde(skip_serializing_if = "Option::is_none")]
377 pub signal: Option<String>,
378 pub restart_count: u32,
379 pub context_exhaustion_count: u32,
380 pub delivery_failure_count: u32,
381 pub supervisory_digest_count: u32,
382 #[serde(skip_serializing_if = "Option::is_none")]
383 pub stall_reason: Option<String>,
384 #[serde(skip_serializing_if = "Option::is_none")]
385 pub stall_summary: Option<String>,
386 #[serde(skip_serializing_if = "Option::is_none")]
387 pub task_elapsed_secs: Option<u64>,
388 pub backend_health: BackendHealth,
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
392#[serde(rename_all = "camelCase")]
393pub struct PipelineMetrics {
394 pub active_task_count: usize,
395 pub review_queue_count: usize,
396 pub runnable_count: u32,
397 pub blocked_count: u32,
398 pub in_review_count: u32,
399 pub in_progress_count: u32,
400 pub stale_in_progress_count: u32,
401 pub stale_review_count: u32,
402 pub triage_backlog_count: usize,
403 pub unhealthy_member_count: usize,
404 #[serde(skip_serializing_if = "Option::is_none")]
405 pub auto_merge_rate: Option<f64>,
406 #[serde(skip_serializing_if = "Option::is_none")]
407 pub rework_rate: Option<f64>,
408 #[serde(skip_serializing_if = "Option::is_none")]
409 pub avg_review_latency_secs: Option<f64>,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
413#[serde(rename_all = "camelCase")]
414pub struct TeamStatus {
415 pub kind: String,
416 pub schema_version: u32,
417 pub min_supported_schema_version: u32,
418 pub team_name: String,
419 pub session_name: String,
420 pub lifecycle: TeamLifecycle,
421 pub running: bool,
422 pub paused: bool,
423 #[serde(default)]
424 pub members: Vec<MemberStatus>,
425 pub pipeline: PipelineMetrics,
426 pub escalation_surface: EscalationSurface,
427 pub approval_surface: ApprovalSurface,
428 #[serde(default)]
429 pub capabilities: Vec<OpenClawCapability>,
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
433#[serde(rename_all = "camelCase")]
434pub struct TeamEvent {
435 pub topic: TeamEventTopic,
436 pub event_kind: TeamEventKind,
437 pub ts: u64,
438 #[serde(skip_serializing_if = "Option::is_none")]
439 pub member_name: Option<String>,
440 #[serde(skip_serializing_if = "Option::is_none")]
441 pub task_id: Option<String>,
442 #[serde(skip_serializing_if = "Option::is_none")]
443 pub sender: Option<String>,
444 #[serde(skip_serializing_if = "Option::is_none")]
445 pub recipient: Option<String>,
446 #[serde(skip_serializing_if = "Option::is_none")]
447 pub reason: Option<String>,
448 #[serde(skip_serializing_if = "Option::is_none")]
449 pub detail: Option<String>,
450 #[serde(skip_serializing_if = "Option::is_none")]
451 pub action_type: Option<String>,
452 #[serde(skip_serializing_if = "Option::is_none")]
453 pub success: Option<bool>,
454 #[serde(skip_serializing_if = "Option::is_none")]
455 pub restart_count: Option<u32>,
456 #[serde(skip_serializing_if = "Option::is_none")]
457 pub load: Option<f64>,
458 #[serde(skip_serializing_if = "Option::is_none")]
459 pub uptime_secs: Option<u64>,
460 #[serde(skip_serializing_if = "Option::is_none")]
461 pub session_running: Option<bool>,
462}
463
464#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
465#[serde(rename_all = "camelCase")]
466pub struct ProjectEventEnvelope {
467 pub kind: String,
468 pub schema_version: u32,
469 pub min_supported_schema_version: u32,
470 pub project_id: String,
471 pub project_name: String,
472 pub project_root: String,
473 pub team_name: String,
474 pub session_name: String,
475 pub event: TeamEvent,
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
479#[serde(rename_all = "camelCase")]
480pub struct CommandActor {
481 pub source: String,
482 #[serde(skip_serializing_if = "Option::is_none")]
483 pub source_role: Option<String>,
484}
485
486#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
487#[serde(rename_all = "camelCase")]
488pub struct ApprovalRequirement {
489 pub level: ApprovalLevel,
490 #[serde(default)]
491 pub human_only_decisions: Vec<HumanDecisionKind>,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
495#[serde(rename_all = "camelCase")]
496pub struct LifecycleCommand {
497 pub scope: CommandScope,
498 #[serde(skip_serializing_if = "Option::is_none")]
499 pub member_name: Option<String>,
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
503#[serde(rename_all = "camelCase")]
504pub struct RestartCommand {
505 pub scope: CommandScope,
506 #[serde(skip_serializing_if = "Option::is_none")]
507 pub member_name: Option<String>,
508 #[serde(skip_serializing_if = "Option::is_none")]
509 pub reason_code: Option<String>,
510}
511
512#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
513#[serde(rename_all = "camelCase")]
514pub struct SendCommand {
515 pub from_role: String,
516 pub to_role: String,
517 pub message: String,
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
521#[serde(rename_all = "camelCase")]
522pub struct NudgeCommand {
523 pub member_name: String,
524 pub reason_code: String,
525 #[serde(skip_serializing_if = "Option::is_none")]
526 pub summary: Option<String>,
527}
528
529#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
530#[serde(rename_all = "camelCase")]
531pub struct ReviewCommand {
532 pub task_id: String,
533 pub disposition: ReviewDisposition,
534 #[serde(skip_serializing_if = "Option::is_none")]
535 pub reviewer_role: Option<String>,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
539#[serde(rename_all = "camelCase")]
540pub struct MergeCommand {
541 pub task_id: String,
542 pub strategy: MergeStrategy,
543}
544
545#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
546#[serde(tag = "action", rename_all = "snake_case")]
547pub enum TeamCommandAction {
548 Start(LifecycleCommand),
549 Stop(LifecycleCommand),
550 Restart(RestartCommand),
551 Send(SendCommand),
552 Nudge(NudgeCommand),
553 Review(ReviewCommand),
554 Merge(MergeCommand),
555}
556
557#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
558#[serde(rename_all = "camelCase")]
559pub struct TeamCommand {
560 pub kind: String,
561 pub schema_version: u32,
562 pub min_supported_schema_version: u32,
563 pub project_id: String,
564 pub actor: CommandActor,
565 pub approval: ApprovalRequirement,
566 #[serde(flatten)]
567 pub action: TeamCommandAction,
568}
569
570impl TeamCommand {
571 pub fn start(project_id: impl Into<String>, source: impl Into<String>) -> Self {
572 Self::lifecycle_command(
573 project_id,
574 source,
575 TeamCommandAction::Start(LifecycleCommand {
576 scope: CommandScope::Team,
577 member_name: None,
578 }),
579 )
580 }
581
582 pub fn stop(project_id: impl Into<String>, source: impl Into<String>) -> Self {
583 Self::lifecycle_command(
584 project_id,
585 source,
586 TeamCommandAction::Stop(LifecycleCommand {
587 scope: CommandScope::Team,
588 member_name: None,
589 }),
590 )
591 }
592
593 pub fn restart(
594 project_id: impl Into<String>,
595 source: impl Into<String>,
596 member_name: Option<String>,
597 reason_code: Option<String>,
598 ) -> Self {
599 Self {
600 kind: TEAM_COMMAND_KIND.to_string(),
601 schema_version: CONTRACT_SCHEMA_VERSION,
602 min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
603 project_id: project_id.into(),
604 actor: CommandActor {
605 source: source.into(),
606 source_role: None,
607 },
608 approval: approval_for_command(TeamCommandKind::Restart),
609 action: TeamCommandAction::Restart(RestartCommand {
610 scope: if member_name.is_some() {
611 CommandScope::Member
612 } else {
613 CommandScope::Team
614 },
615 member_name,
616 reason_code,
617 }),
618 }
619 }
620
621 pub fn send(
622 project_id: impl Into<String>,
623 source: impl Into<String>,
624 from_role: impl Into<String>,
625 to_role: impl Into<String>,
626 message: impl Into<String>,
627 ) -> Self {
628 Self {
629 kind: TEAM_COMMAND_KIND.to_string(),
630 schema_version: CONTRACT_SCHEMA_VERSION,
631 min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
632 project_id: project_id.into(),
633 actor: CommandActor {
634 source: source.into(),
635 source_role: None,
636 },
637 approval: approval_for_command(TeamCommandKind::Send),
638 action: TeamCommandAction::Send(SendCommand {
639 from_role: from_role.into(),
640 to_role: to_role.into(),
641 message: message.into(),
642 }),
643 }
644 }
645
646 pub fn nudge(
647 project_id: impl Into<String>,
648 source: impl Into<String>,
649 member_name: impl Into<String>,
650 reason_code: impl Into<String>,
651 summary: Option<String>,
652 ) -> Self {
653 Self {
654 kind: TEAM_COMMAND_KIND.to_string(),
655 schema_version: CONTRACT_SCHEMA_VERSION,
656 min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
657 project_id: project_id.into(),
658 actor: CommandActor {
659 source: source.into(),
660 source_role: None,
661 },
662 approval: approval_for_command(TeamCommandKind::Nudge),
663 action: TeamCommandAction::Nudge(NudgeCommand {
664 member_name: member_name.into(),
665 reason_code: reason_code.into(),
666 summary,
667 }),
668 }
669 }
670
671 pub fn review(
672 project_id: impl Into<String>,
673 source: impl Into<String>,
674 task_id: impl Into<String>,
675 disposition: ReviewDisposition,
676 reviewer_role: Option<String>,
677 ) -> Self {
678 Self {
679 kind: TEAM_COMMAND_KIND.to_string(),
680 schema_version: CONTRACT_SCHEMA_VERSION,
681 min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
682 project_id: project_id.into(),
683 actor: CommandActor {
684 source: source.into(),
685 source_role: None,
686 },
687 approval: approval_for_command(TeamCommandKind::Review),
688 action: TeamCommandAction::Review(ReviewCommand {
689 task_id: task_id.into(),
690 disposition,
691 reviewer_role,
692 }),
693 }
694 }
695
696 pub fn merge(
697 project_id: impl Into<String>,
698 source: impl Into<String>,
699 task_id: impl Into<String>,
700 strategy: MergeStrategy,
701 ) -> Self {
702 Self {
703 kind: TEAM_COMMAND_KIND.to_string(),
704 schema_version: CONTRACT_SCHEMA_VERSION,
705 min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
706 project_id: project_id.into(),
707 actor: CommandActor {
708 source: source.into(),
709 source_role: None,
710 },
711 approval: approval_for_command(TeamCommandKind::Merge),
712 action: TeamCommandAction::Merge(MergeCommand {
713 task_id: task_id.into(),
714 strategy,
715 }),
716 }
717 }
718
719 fn lifecycle_command(
720 project_id: impl Into<String>,
721 source: impl Into<String>,
722 action: TeamCommandAction,
723 ) -> Self {
724 let kind = match &action {
725 TeamCommandAction::Start(_) => TeamCommandKind::Start,
726 TeamCommandAction::Stop(_) => TeamCommandKind::Stop,
727 TeamCommandAction::Restart(_) => TeamCommandKind::Restart,
728 TeamCommandAction::Send(_) => TeamCommandKind::Send,
729 TeamCommandAction::Nudge(_) => TeamCommandKind::Nudge,
730 TeamCommandAction::Review(_) => TeamCommandKind::Review,
731 TeamCommandAction::Merge(_) => TeamCommandKind::Merge,
732 };
733 Self {
734 kind: TEAM_COMMAND_KIND.to_string(),
735 schema_version: CONTRACT_SCHEMA_VERSION,
736 min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
737 project_id: project_id.into(),
738 actor: CommandActor {
739 source: source.into(),
740 source_role: None,
741 },
742 approval: approval_for_command(kind),
743 action,
744 }
745 }
746}
747
748pub fn descriptor() -> ContractDescriptor {
749 ContractDescriptor {
750 kind: CONTRACT_DESCRIPTOR_KIND.to_string(),
751 schema_version: CONTRACT_SCHEMA_VERSION,
752 min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
753 dto_kinds: DtoKinds {
754 team_status: TEAM_STATUS_KIND.to_string(),
755 team_event: TEAM_EVENT_KIND.to_string(),
756 team_command: TEAM_COMMAND_KIND.to_string(),
757 },
758 versioning: VersioningPolicy {
759 current_schema_version: CONTRACT_SCHEMA_VERSION,
760 min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
761 add_only_fields: true,
762 new_enum_variants_require_capability_review: true,
763 incompatible_changes_require_new_schema_version: true,
764 },
765 anti_corruption_boundary: AntiCorruptionBoundary {
766 batty_is_system_of_record: true,
767 prompt_wording_leaks_forbidden: true,
768 command_intents_are_explicit: true,
769 status_inputs: vec![
770 "team status report".to_string(),
771 "workflow metrics".to_string(),
772 "member health counters".to_string(),
773 ],
774 event_inputs: vec![
775 "team events jsonl".to_string(),
776 "project registry".to_string(),
777 ],
778 },
779 capabilities: supported_capabilities(),
780 supported_event_kinds: supported_event_kinds(),
781 escalation_surface: default_escalation_surface(),
782 approval_surface: default_approval_surface(),
783 }
784}
785
786pub fn negotiate_capabilities(
787 request: &CapabilityNegotiationRequest,
788) -> CapabilityNegotiationResult {
789 if request.requested_schema_version < MIN_SUPPORTED_SCHEMA_VERSION {
790 return CapabilityNegotiationResult {
791 kind: CAPABILITY_NEGOTIATION_KIND.to_string(),
792 schema_version: CONTRACT_SCHEMA_VERSION,
793 min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
794 compatible: false,
795 negotiated_schema_version: None,
796 granted_capabilities: Vec::new(),
797 reason: format!(
798 "requested schema version {} is older than the minimum supported {}",
799 request.requested_schema_version, MIN_SUPPORTED_SCHEMA_VERSION
800 ),
801 };
802 }
803
804 if request.min_compatible_schema_version > CONTRACT_SCHEMA_VERSION {
805 return CapabilityNegotiationResult {
806 kind: CAPABILITY_NEGOTIATION_KIND.to_string(),
807 schema_version: CONTRACT_SCHEMA_VERSION,
808 min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
809 compatible: false,
810 negotiated_schema_version: None,
811 granted_capabilities: Vec::new(),
812 reason: format!(
813 "minimum compatible schema version {} is newer than Batty's current {}",
814 request.min_compatible_schema_version, CONTRACT_SCHEMA_VERSION
815 ),
816 };
817 }
818
819 let granted_capabilities = if request.requested_capabilities.is_empty() {
820 supported_capabilities()
821 } else {
822 SUPPORTED_CAPABILITIES
823 .iter()
824 .copied()
825 .filter(|capability| request.requested_capabilities.contains(capability))
826 .collect()
827 };
828
829 CapabilityNegotiationResult {
830 kind: CAPABILITY_NEGOTIATION_KIND.to_string(),
831 schema_version: CONTRACT_SCHEMA_VERSION,
832 min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
833 compatible: true,
834 negotiated_schema_version: Some(
835 request
836 .requested_schema_version
837 .min(CONTRACT_SCHEMA_VERSION),
838 ),
839 granted_capabilities,
840 reason: "compatible via add-only schema evolution".to_string(),
841 }
842}
843
844pub(crate) fn team_status_from_report(report: &status::TeamStatusJsonReport) -> TeamStatus {
845 let workflow_metrics = report.workflow_metrics.clone().unwrap_or_default();
846 TeamStatus {
847 kind: TEAM_STATUS_KIND.to_string(),
848 schema_version: CONTRACT_SCHEMA_VERSION,
849 min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
850 team_name: report.team.clone(),
851 session_name: report.session.clone(),
852 lifecycle: team_lifecycle(report),
853 running: report.running,
854 paused: report.paused,
855 members: report.members.iter().map(member_status_from_row).collect(),
856 pipeline: PipelineMetrics {
857 active_task_count: report.active_tasks.len(),
858 review_queue_count: report.review_queue.len(),
859 runnable_count: workflow_metrics.runnable_count,
860 blocked_count: workflow_metrics.blocked_count,
861 in_review_count: workflow_metrics.in_review_count,
862 in_progress_count: workflow_metrics.in_progress_count,
863 stale_in_progress_count: workflow_metrics.stale_in_progress_count,
864 stale_review_count: workflow_metrics.stale_review_count,
865 triage_backlog_count: report.health.triage_backlog_count,
866 unhealthy_member_count: report.health.unhealthy_members.len(),
867 auto_merge_rate: workflow_metrics.auto_merge_rate,
868 rework_rate: workflow_metrics.rework_rate,
869 avg_review_latency_secs: workflow_metrics.avg_review_latency_secs,
870 },
871 escalation_surface: default_escalation_surface(),
872 approval_surface: default_approval_surface(),
873 capabilities: supported_capabilities(),
874 }
875}
876
877#[cfg(test)]
878pub(crate) fn project_event_from_internal(
879 project: &RegisteredProject,
880 event: &events::TeamEvent,
881) -> Option<ProjectEventEnvelope> {
882 let (topic, event_kind) = contract_for_internal_event(event)?;
883 Some(ProjectEventEnvelope {
884 kind: TEAM_EVENT_KIND.to_string(),
885 schema_version: CONTRACT_SCHEMA_VERSION,
886 min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
887 project_id: project.project_id.clone(),
888 project_name: project.name.clone(),
889 project_root: project.project_root.display().to_string(),
890 team_name: project.team_name.clone(),
891 session_name: project.session_name.clone(),
892 event: TeamEvent {
893 topic,
894 event_kind,
895 ts: event.ts,
896 member_name: event.role.clone(),
897 task_id: event.task.clone(),
898 sender: event.from.clone(),
899 recipient: event.recipient.clone().or_else(|| event.to.clone()),
900 reason: event.reason.clone(),
901 detail: event.details.clone(),
902 action_type: event.action_type.clone(),
903 success: event.success,
904 restart_count: event.restart_count,
905 load: event.load,
906 uptime_secs: event.uptime_secs,
907 session_running: event.session_running,
908 },
909 })
910}
911
912pub(crate) fn contract_for_internal_event(
913 event: &events::TeamEvent,
914) -> Option<(TeamEventTopic, TeamEventKind)> {
915 match event.event.as_str() {
916 "task_completed" => Some((TeamEventTopic::Completion, TeamEventKind::TaskCompleted)),
917 "review_nudge_sent" => Some((TeamEventTopic::Review, TeamEventKind::ReviewNudged)),
918 "review_escalated" => Some((TeamEventTopic::Review, TeamEventKind::ReviewEscalated)),
919 "review_stale" => Some((TeamEventTopic::Review, TeamEventKind::ReviewStalled)),
920 "stall_detected" => Some((TeamEventTopic::Stall, TeamEventKind::AgentStalled)),
921 "task_stale" => Some((TeamEventTopic::Stall, TeamEventKind::TaskStalled)),
922 "task_auto_merged" => Some((TeamEventTopic::Merge, TeamEventKind::TaskMergedAutomatic)),
923 "task_manual_merged" => Some((TeamEventTopic::Merge, TeamEventKind::TaskMergedManual)),
924 "task_escalated" => Some((TeamEventTopic::Escalation, TeamEventKind::TaskEscalated)),
925 "verification_max_iterations_reached" => Some((
926 TeamEventTopic::Escalation,
927 TeamEventKind::VerificationEscalated,
928 )),
929 "delivery_failed" => Some((
930 TeamEventTopic::DeliveryFailure,
931 TeamEventKind::DeliveryFailed,
932 )),
933 "daemon_started" => Some((TeamEventTopic::Lifecycle, TeamEventKind::SessionStarted)),
934 "daemon_reloading" => Some((TeamEventTopic::Lifecycle, TeamEventKind::SessionReloading)),
935 "daemon_reloaded" => Some((TeamEventTopic::Lifecycle, TeamEventKind::SessionReloaded)),
936 "daemon_stopped" => Some((TeamEventTopic::Lifecycle, TeamEventKind::SessionStopped)),
937 "agent_spawned" => Some((TeamEventTopic::Lifecycle, TeamEventKind::AgentStarted)),
938 "agent_restarted" => Some((TeamEventTopic::Lifecycle, TeamEventKind::AgentRestarted)),
939 "member_crashed" => Some((TeamEventTopic::Lifecycle, TeamEventKind::AgentCrashed)),
940 "pane_death" => Some((TeamEventTopic::Lifecycle, TeamEventKind::AgentStopped)),
941 "pane_respawned" => Some((TeamEventTopic::Lifecycle, TeamEventKind::AgentRespawned)),
942 "context_exhausted" => Some((
943 TeamEventTopic::Lifecycle,
944 TeamEventKind::AgentContextExhausted,
945 )),
946 "health_changed" => Some((TeamEventTopic::Lifecycle, TeamEventKind::AgentHealthChanged)),
947 "topology_changed" => Some((
948 TeamEventTopic::Lifecycle,
949 TeamEventKind::SessionTopologyChanged,
950 )),
951 "agent_removed" => Some((TeamEventTopic::Lifecycle, TeamEventKind::AgentRemoved)),
952 _ => None,
953 }
954}
955
956pub(crate) fn event_kind_from_legacy_event_type(event_type: &str) -> Option<TeamEventKind> {
957 SUPPORTED_EVENT_KINDS
958 .iter()
959 .copied()
960 .find(|kind| kind.legacy_event_type() == event_type)
961}
962
963fn supported_capabilities() -> Vec<OpenClawCapability> {
964 SUPPORTED_CAPABILITIES.to_vec()
965}
966
967fn supported_event_kinds() -> Vec<TeamEventKind> {
968 SUPPORTED_EVENT_KINDS.to_vec()
969}
970
971fn default_escalation_surface() -> EscalationSurface {
972 EscalationSurface {
973 authority: EscalationAuthority::RecommendOnly,
974 supported_kinds: ESCALATION_KINDS.to_vec(),
975 }
976}
977
978fn default_approval_surface() -> ApprovalSurface {
979 ApprovalSurface {
980 command_policies: vec![
981 CommandPolicy {
982 command: TeamCommandKind::Start,
983 scope: CommandScope::Team,
984 approval_level: ApprovalLevel::NotRequired,
985 human_only_decisions: Vec::new(),
986 },
987 CommandPolicy {
988 command: TeamCommandKind::Stop,
989 scope: CommandScope::Team,
990 approval_level: ApprovalLevel::Required,
991 human_only_decisions: vec![HumanDecisionKind::StopSession],
992 },
993 CommandPolicy {
994 command: TeamCommandKind::Restart,
995 scope: CommandScope::Member,
996 approval_level: ApprovalLevel::Suggested,
997 human_only_decisions: vec![HumanDecisionKind::RestartSession],
998 },
999 CommandPolicy {
1000 command: TeamCommandKind::Send,
1001 scope: CommandScope::Member,
1002 approval_level: ApprovalLevel::NotRequired,
1003 human_only_decisions: Vec::new(),
1004 },
1005 CommandPolicy {
1006 command: TeamCommandKind::Nudge,
1007 scope: CommandScope::Member,
1008 approval_level: ApprovalLevel::Suggested,
1009 human_only_decisions: Vec::new(),
1010 },
1011 CommandPolicy {
1012 command: TeamCommandKind::Review,
1013 scope: CommandScope::Task,
1014 approval_level: ApprovalLevel::Required,
1015 human_only_decisions: vec![HumanDecisionKind::ReviewDisposition],
1016 },
1017 CommandPolicy {
1018 command: TeamCommandKind::Merge,
1019 scope: CommandScope::Task,
1020 approval_level: ApprovalLevel::Required,
1021 human_only_decisions: vec![HumanDecisionKind::MergeDisposition],
1022 },
1023 ],
1024 human_only_decisions: HUMAN_ONLY_DECISIONS.to_vec(),
1025 }
1026}
1027
1028fn approval_for_command(command: TeamCommandKind) -> ApprovalRequirement {
1029 let policy = default_approval_surface()
1030 .command_policies
1031 .into_iter()
1032 .find(|policy| policy.command == command)
1033 .unwrap_or(CommandPolicy {
1034 command,
1035 scope: CommandScope::Team,
1036 approval_level: ApprovalLevel::Required,
1037 human_only_decisions: vec![HumanDecisionKind::PolicyOverride],
1038 });
1039 ApprovalRequirement {
1040 level: policy.approval_level,
1041 human_only_decisions: policy.human_only_decisions,
1042 }
1043}
1044
1045fn team_lifecycle(report: &status::TeamStatusJsonReport) -> TeamLifecycle {
1046 if !report.running {
1047 TeamLifecycle::Stopped
1048 } else if report.watchdog.current_backoff_secs.is_some()
1049 || report.watchdog.state.eq_ignore_ascii_case("recovering")
1050 || report.watchdog.state.eq_ignore_ascii_case("backoff")
1051 {
1052 TeamLifecycle::Recovering
1053 } else if !report.health.unhealthy_members.is_empty() {
1054 TeamLifecycle::Degraded
1055 } else {
1056 TeamLifecycle::Running
1057 }
1058}
1059
1060fn member_status_from_row(row: &status::TeamStatusRow) -> MemberStatus {
1061 MemberStatus {
1062 name: row.name.clone(),
1063 role: row.role.clone(),
1064 role_type: row.role_type.clone(),
1065 state: member_state(&row.state),
1066 health: member_health(row),
1067 active_task_ids: row.active_owned_tasks.iter().map(u32::to_string).collect(),
1068 review_task_ids: row.review_owned_tasks.iter().map(u32::to_string).collect(),
1069 pending_inbox_count: row.pending_inbox,
1070 triage_backlog_count: row.triage_backlog,
1071 signal: row.signal.clone(),
1072 restart_count: row.health.restart_count,
1073 context_exhaustion_count: row.health.context_exhaustion_count,
1074 delivery_failure_count: row.health.delivery_failure_count,
1075 supervisory_digest_count: row.health.supervisory_digest_count,
1076 stall_reason: row.health.stall_reason.clone(),
1077 stall_summary: row.health.stall_summary.clone(),
1078 task_elapsed_secs: row.health.task_elapsed_secs,
1079 backend_health: backend_health(row.health.backend_health),
1080 }
1081}
1082
1083fn member_state(raw: &str) -> MemberState {
1084 match raw {
1085 "starting" => MemberState::Starting,
1086 "idle" => MemberState::Idle,
1087 "working" => MemberState::Working,
1088 "done" => MemberState::Done,
1089 "crashed" => MemberState::Crashed,
1090 _ => MemberState::Unknown,
1091 }
1092}
1093
1094fn member_health(row: &status::TeamStatusRow) -> MemberHealth {
1095 if matches!(
1096 row.health.backend_health,
1097 crate::agent::BackendHealth::Unreachable
1098 ) || row.state == "crashed"
1099 {
1100 MemberHealth::Unhealthy
1101 } else if row.health.has_operator_warning() {
1102 MemberHealth::Warning
1103 } else {
1104 MemberHealth::Healthy
1105 }
1106}
1107
1108fn backend_health(health: crate::agent::BackendHealth) -> BackendHealth {
1109 match health {
1110 crate::agent::BackendHealth::Healthy => BackendHealth::Healthy,
1111 crate::agent::BackendHealth::Degraded => BackendHealth::Degraded,
1112 crate::agent::BackendHealth::Unreachable => BackendHealth::Unreachable,
1113 crate::agent::BackendHealth::QuotaExhausted => BackendHealth::Unreachable,
1114 }
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119 use super::*;
1120
1121 fn sample_report() -> status::TeamStatusJsonReport {
1122 status::TeamStatusJsonReport {
1123 team: "fixture-team".to_string(),
1124 session: "batty-fixture-team".to_string(),
1125 running: true,
1126 paused: false,
1127 watchdog: status::WatchdogStatus {
1128 state: "running".to_string(),
1129 restart_count: 0,
1130 current_backoff_secs: None,
1131 last_exit_reason: None,
1132 },
1133 health: status::TeamStatusHealth {
1134 session_running: true,
1135 paused: false,
1136 member_count: 2,
1137 active_member_count: 1,
1138 pending_inbox_count: 1,
1139 triage_backlog_count: 2,
1140 unhealthy_members: vec!["eng-1".to_string()],
1141 },
1142 workflow_metrics: Some(status::WorkflowMetrics {
1143 runnable_count: 3,
1144 blocked_count: 1,
1145 in_review_count: 1,
1146 in_progress_count: 2,
1147 stale_in_progress_count: 1,
1148 aged_todo_count: 0,
1149 stale_review_count: 1,
1150 idle_with_runnable: vec!["manager".to_string()],
1151 top_runnable_tasks: vec!["#42 (high) Inbox fix".to_string()],
1152 oldest_review_age_secs: Some(120),
1153 oldest_assignment_age_secs: Some(60),
1154 auto_merge_count: 1,
1155 manual_merge_count: 0,
1156 direct_root_merge_count: 1,
1157 isolated_integration_merge_count: 0,
1158 direct_root_failure_count: 0,
1159 isolated_integration_failure_count: 0,
1160 auto_merge_rate: Some(0.5),
1161 rework_count: 1,
1162 rework_rate: Some(0.25),
1163 review_nudge_count: 1,
1164 review_escalation_count: 1,
1165 avg_review_latency_secs: Some(42.0),
1166 }),
1167 active_tasks: vec![status::StatusTaskEntry {
1168 id: 42,
1169 title: "Fix contract".to_string(),
1170 status: "in-progress".to_string(),
1171 priority: "high".to_string(),
1172 claimed_by: Some("eng-1".to_string()),
1173 review_owner: None,
1174 blocked_on: None,
1175 branch: None,
1176 worktree_path: None,
1177 commit: None,
1178 branch_mismatch: None,
1179 next_action: None,
1180 test_summary: None,
1181 }],
1182 review_queue: vec![status::StatusTaskEntry {
1183 id: 43,
1184 title: "Review contract".to_string(),
1185 status: "review".to_string(),
1186 priority: "medium".to_string(),
1187 claimed_by: None,
1188 review_owner: Some("manager".to_string()),
1189 blocked_on: None,
1190 branch: None,
1191 worktree_path: None,
1192 commit: None,
1193 branch_mismatch: None,
1194 next_action: None,
1195 test_summary: None,
1196 }],
1197 optional_subsystems: None,
1198 engineer_profiles: None,
1199 members: vec![
1200 status::TeamStatusRow {
1201 name: "eng-1".to_string(),
1202 role: "engineer".to_string(),
1203 role_type: "engineer".to_string(),
1204 agent: Some("codex".to_string()),
1205 reports_to: Some("manager".to_string()),
1206 state: "working".to_string(),
1207 pending_inbox: 1,
1208 triage_backlog: 2,
1209 active_owned_tasks: vec![42],
1210 review_owned_tasks: Vec::new(),
1211 signal: Some("nudge sent".to_string()),
1212 runtime_label: Some("working".to_string()),
1213 worktree_staleness: None,
1214 health: status::AgentHealthSummary {
1215 restart_count: 1,
1216 context_exhaustion_count: 1,
1217 delivery_failure_count: 0,
1218 supervisory_digest_count: 2,
1219 dispatch_fallback_count: 0,
1220 dispatch_fallback_reason: None,
1221 task_elapsed_secs: Some(300),
1222 stall_reason: Some("supervisory_stalled".to_string()),
1223 stall_summary: Some(
1224 "eng-1 stayed in Working for 5m (timeout=60s)".to_string(),
1225 ),
1226 backend_health: crate::agent::BackendHealth::Degraded,
1227 },
1228 health_summary: "warning".to_string(),
1229 eta: "soon".to_string(),
1230 },
1231 status::TeamStatusRow {
1232 name: "manager".to_string(),
1233 role: "manager".to_string(),
1234 role_type: "manager".to_string(),
1235 agent: Some("codex".to_string()),
1236 reports_to: None,
1237 state: "idle".to_string(),
1238 pending_inbox: 0,
1239 triage_backlog: 0,
1240 active_owned_tasks: Vec::new(),
1241 review_owned_tasks: vec![43],
1242 signal: None,
1243 runtime_label: Some("idle".to_string()),
1244 worktree_staleness: None,
1245 health: status::AgentHealthSummary {
1246 restart_count: 0,
1247 context_exhaustion_count: 0,
1248 delivery_failure_count: 0,
1249 supervisory_digest_count: 0,
1250 dispatch_fallback_count: 0,
1251 dispatch_fallback_reason: None,
1252 task_elapsed_secs: None,
1253 stall_reason: None,
1254 stall_summary: None,
1255 backend_health: crate::agent::BackendHealth::Healthy,
1256 },
1257 health_summary: "healthy".to_string(),
1258 eta: "idle".to_string(),
1259 },
1260 ],
1261 }
1262 }
1263
1264 #[test]
1265 fn descriptor_exposes_versioning_and_surfaces() {
1266 let descriptor = descriptor();
1267
1268 assert_eq!(descriptor.kind, CONTRACT_DESCRIPTOR_KIND);
1269 assert_eq!(descriptor.schema_version, CONTRACT_SCHEMA_VERSION);
1270 assert!(
1271 descriptor
1272 .anti_corruption_boundary
1273 .batty_is_system_of_record
1274 );
1275 assert!(
1276 descriptor
1277 .approval_surface
1278 .human_only_decisions
1279 .contains(&HumanDecisionKind::MergeDisposition)
1280 );
1281 assert!(
1282 descriptor
1283 .supported_event_kinds
1284 .contains(&TeamEventKind::TaskEscalated)
1285 );
1286 }
1287
1288 #[test]
1289 fn team_status_from_report_normalizes_internal_status() {
1290 let status = team_status_from_report(&sample_report());
1291
1292 assert_eq!(status.kind, TEAM_STATUS_KIND);
1293 assert_eq!(status.lifecycle, TeamLifecycle::Degraded);
1294 assert_eq!(status.pipeline.active_task_count, 1);
1295 assert_eq!(status.pipeline.review_queue_count, 1);
1296 assert_eq!(status.pipeline.triage_backlog_count, 2);
1297 assert_eq!(status.members[0].state, MemberState::Working);
1298 assert_eq!(status.members[0].health, MemberHealth::Warning);
1299 assert_eq!(status.members[0].backend_health, BackendHealth::Degraded);
1300 assert_eq!(status.members[0].supervisory_digest_count, 2);
1301 assert_eq!(
1302 status.members[0].stall_reason.as_deref(),
1303 Some("supervisory_stalled")
1304 );
1305 assert_eq!(
1306 status.members[0].stall_summary.as_deref(),
1307 Some("eng-1 stayed in Working for 5m (timeout=60s)")
1308 );
1309 assert_eq!(status.members[1].review_task_ids, vec!["43".to_string()]);
1310 }
1311
1312 #[test]
1313 fn member_health_warns_on_supervisory_stall_only() {
1314 let row = status::TeamStatusRow {
1315 name: "eng-1".to_string(),
1316 role: "engineer".to_string(),
1317 role_type: "engineer".to_string(),
1318 agent: Some("codex".to_string()),
1319 reports_to: Some("manager".to_string()),
1320 state: "working".to_string(),
1321 pending_inbox: 0,
1322 triage_backlog: 0,
1323 active_owned_tasks: Vec::new(),
1324 review_owned_tasks: Vec::new(),
1325 signal: None,
1326 runtime_label: Some("working".to_string()),
1327 worktree_staleness: None,
1328 health: status::AgentHealthSummary {
1329 stall_reason: Some("supervisory_stalled".to_string()),
1330 stall_summary: Some("eng-1 stayed in Working for 5m".to_string()),
1331 ..status::AgentHealthSummary::default()
1332 },
1333 health_summary: "stall:supervisory_stalled".to_string(),
1334 eta: "-".to_string(),
1335 };
1336
1337 assert_eq!(member_health(&row), MemberHealth::Warning);
1338 }
1339
1340 #[test]
1341 fn project_event_from_internal_uses_explicit_event_kind() {
1342 let project = RegisteredProject {
1343 project_id: "fixture".to_string(),
1344 name: "Fixture".to_string(),
1345 aliases: Vec::new(),
1346 project_root: std::path::PathBuf::from("/tmp/fixture"),
1347 board_dir: std::path::PathBuf::from("/tmp/fixture/.batty/team_config/board"),
1348 team_name: "fixture-team".to_string(),
1349 session_name: "batty-fixture-team".to_string(),
1350 channel_bindings: Vec::new(),
1351 owner: None,
1352 tags: vec!["openclaw".to_string()],
1353 policy_flags: crate::project_registry::ProjectPolicyFlags {
1354 allow_openclaw_supervision: true,
1355 allow_cross_project_routing: false,
1356 allow_shared_service_routing: false,
1357 archived: false,
1358 },
1359 created_at: 0,
1360 updated_at: 0,
1361 };
1362 let event = events::TeamEvent::task_escalated("eng-1", "42", Some("tests_failed"));
1363
1364 let envelope = project_event_from_internal(&project, &event).unwrap();
1365
1366 assert_eq!(envelope.kind, TEAM_EVENT_KIND);
1367 assert_eq!(envelope.event.topic, TeamEventTopic::Escalation);
1368 assert_eq!(envelope.event.event_kind, TeamEventKind::TaskEscalated);
1369 assert_eq!(envelope.event.member_name.as_deref(), Some("eng-1"));
1370 assert_eq!(envelope.event.task_id.as_deref(), Some("42"));
1371 }
1372
1373 #[test]
1374 fn capability_negotiation_rejects_incompatible_schema_versions() {
1375 let result = negotiate_capabilities(&CapabilityNegotiationRequest {
1376 kind: CAPABILITY_NEGOTIATION_KIND.to_string(),
1377 requested_schema_version: 0,
1378 min_compatible_schema_version: 0,
1379 requested_capabilities: vec![OpenClawCapability::TeamStatus],
1380 });
1381
1382 assert!(!result.compatible);
1383 assert!(result.negotiated_schema_version.is_none());
1384 }
1385
1386 #[test]
1387 fn team_command_serialization_keeps_action_explicit() {
1388 let command = TeamCommand::merge("fixture", "openclaw", "42", MergeStrategy::Manual);
1389 let json = serde_json::to_value(&command).unwrap();
1390
1391 assert_eq!(json["kind"], TEAM_COMMAND_KIND);
1392 assert_eq!(json["action"], "merge");
1393 assert_eq!(json["taskId"], "42");
1394 assert_eq!(json["approval"]["level"], "required");
1395 }
1396}