@team-agent/installer 0.5.8 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/attach_app_server_leader.rs +82 -1
- package/crates/team-agent/src/cli/emit.rs +30 -1
- package/crates/team-agent/src/cli/leaders.rs +94 -0
- package/crates/team-agent/src/cli/mod.rs +138 -2
- package/crates/team-agent/src/cli/named_address.rs +234 -34
- package/crates/team-agent/src/cli/send.rs +230 -0
- package/crates/team-agent/src/cli/status_port.rs +20 -6
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +9 -3
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +14 -0
- package/crates/team-agent/src/leader/mod.rs +1 -0
- package/crates/team-agent/src/leader/registry.rs +402 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
- package/crates/team-agent/src/mcp_server/normalize.rs +79 -0
- package/crates/team-agent/src/mcp_server/tools.rs +8 -1
- package/crates/team-agent/src/messaging/leader_receiver.rs +58 -0
- package/crates/team-agent/src/messaging/mod.rs +2 -1
- package/crates/team-agent/src/messaging/results.rs +53 -0
- package/crates/team-agent/src/messaging/watchers.rs +13 -3
- package/package.json +4 -4
|
@@ -18,6 +18,14 @@ pub(crate) enum NamedAddressErrorKind {
|
|
|
18
18
|
NameInvalid,
|
|
19
19
|
WorkspaceNotFound,
|
|
20
20
|
StateNotFound,
|
|
21
|
+
// E6 (0.5.9 offline-mailbox-toname-design §3.1): three-way taxonomy for
|
|
22
|
+
// `--to-name <team>/leader` refusal — required by the E6 RED so the top-level
|
|
23
|
+
// `leader_receiver` fallback is no longer reached for a wrong runtime key.
|
|
24
|
+
// `WorkspaceNoState` mirrors `StateNotFound` semantics but keeps the wire
|
|
25
|
+
// reason distinct for third-party sender copy.
|
|
26
|
+
WorkspaceNoState,
|
|
27
|
+
TeamKeyNotFound,
|
|
28
|
+
LeaderNotAttached,
|
|
21
29
|
NameNotResolvable,
|
|
22
30
|
NameNotLive,
|
|
23
31
|
NameAmbiguous,
|
|
@@ -29,6 +37,9 @@ impl NamedAddressErrorKind {
|
|
|
29
37
|
Self::NameInvalid => "name_invalid",
|
|
30
38
|
Self::WorkspaceNotFound => "workspace_not_found",
|
|
31
39
|
Self::StateNotFound => "state_not_found",
|
|
40
|
+
Self::WorkspaceNoState => "workspace_no_state",
|
|
41
|
+
Self::TeamKeyNotFound => "team_key_not_found",
|
|
42
|
+
Self::LeaderNotAttached => "leader_not_attached",
|
|
32
43
|
Self::NameNotResolvable => "name_not_resolvable",
|
|
33
44
|
Self::NameNotLive => "name_not_live",
|
|
34
45
|
Self::NameAmbiguous => "name_ambiguous",
|
|
@@ -43,6 +54,13 @@ pub(crate) struct NamedAddressError {
|
|
|
43
54
|
pub action: String,
|
|
44
55
|
pub log: String,
|
|
45
56
|
pub candidates: Vec<Value>,
|
|
57
|
+
// E6: when the caller supplied a `<team>/leader` name that isn't a runtime
|
|
58
|
+
// team_key, echo the requested value + the canonical alternatives so
|
|
59
|
+
// spec/display-name confusion is visibly diagnosed. Empty for other
|
|
60
|
+
// refusal kinds.
|
|
61
|
+
pub requested_team: Option<String>,
|
|
62
|
+
pub available_team_keys: Vec<String>,
|
|
63
|
+
pub suggested_name: Option<String>,
|
|
46
64
|
}
|
|
47
65
|
|
|
48
66
|
impl NamedAddressError {
|
|
@@ -53,6 +71,9 @@ impl NamedAddressError {
|
|
|
53
71
|
action: "inspect team-agent status and use an explicit workspace/team name".to_string(),
|
|
54
72
|
log: "named-address resolver refused before injection".to_string(),
|
|
55
73
|
candidates: Vec::new(),
|
|
74
|
+
requested_team: None,
|
|
75
|
+
available_team_keys: Vec::new(),
|
|
76
|
+
suggested_name: None,
|
|
56
77
|
}
|
|
57
78
|
}
|
|
58
79
|
|
|
@@ -66,15 +87,35 @@ impl NamedAddressError {
|
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
pub(crate) fn to_json(&self) -> Value {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
let mut obj = serde_json::Map::new();
|
|
91
|
+
obj.insert("ok".to_string(), Value::Bool(false));
|
|
92
|
+
obj.insert("status".to_string(), Value::String("refused".to_string()));
|
|
93
|
+
obj.insert(
|
|
94
|
+
"reason".to_string(),
|
|
95
|
+
Value::String(self.kind.as_str().to_string()),
|
|
96
|
+
);
|
|
97
|
+
obj.insert("error".to_string(), Value::String(self.message.clone()));
|
|
98
|
+
obj.insert("action".to_string(), Value::String(self.action.clone()));
|
|
99
|
+
obj.insert("log".to_string(), Value::String(self.log.clone()));
|
|
100
|
+
obj.insert("candidates".to_string(), Value::Array(self.candidates.clone()));
|
|
101
|
+
if let Some(requested) = self.requested_team.as_deref() {
|
|
102
|
+
obj.insert("requested_team".to_string(), Value::String(requested.to_string()));
|
|
103
|
+
}
|
|
104
|
+
if !self.available_team_keys.is_empty() {
|
|
105
|
+
obj.insert(
|
|
106
|
+
"available_team_keys".to_string(),
|
|
107
|
+
Value::Array(
|
|
108
|
+
self.available_team_keys
|
|
109
|
+
.iter()
|
|
110
|
+
.map(|k| Value::String(k.clone()))
|
|
111
|
+
.collect(),
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if let Some(suggested) = self.suggested_name.as_deref() {
|
|
116
|
+
obj.insert("suggested_name".to_string(), Value::String(suggested.to_string()));
|
|
117
|
+
}
|
|
118
|
+
Value::Object(obj)
|
|
78
119
|
}
|
|
79
120
|
}
|
|
80
121
|
|
|
@@ -555,12 +596,18 @@ fn leader_advisory_candidates(
|
|
|
555
596
|
transport: &dyn Transport,
|
|
556
597
|
source: &str,
|
|
557
598
|
) -> Vec<Value> {
|
|
558
|
-
let
|
|
599
|
+
let team_scoped_socket = team_entry(state, team)
|
|
559
600
|
.and_then(|entry| entry.get("leader_receiver"))
|
|
560
|
-
.
|
|
561
|
-
|
|
562
|
-
.
|
|
563
|
-
|
|
601
|
+
.and_then(|receiver| string_field(receiver, "tmux_socket"));
|
|
602
|
+
let socket = match team_scoped_socket {
|
|
603
|
+
Some(s) => Some(s.to_string()),
|
|
604
|
+
None => state
|
|
605
|
+
// ALLOWED-LEGACY-SINGLE-TEAM: diagnostic-only socket label for the advisory candidates; never fed back into routing.
|
|
606
|
+
.get("leader_receiver")
|
|
607
|
+
.and_then(|receiver| string_field(receiver, "tmux_socket"))
|
|
608
|
+
.map(str::to_string)
|
|
609
|
+
.or_else(|| transport.tmux_endpoint()),
|
|
610
|
+
};
|
|
564
611
|
let targets = match transport.list_targets() {
|
|
565
612
|
Ok(targets) => targets,
|
|
566
613
|
Err(_) => return Vec::new(),
|
|
@@ -588,17 +635,27 @@ fn resolve_leader(
|
|
|
588
635
|
parsed: &ParsedNamedAddress,
|
|
589
636
|
transport: &dyn Transport,
|
|
590
637
|
) -> Result<ResolvedNamedAddress, NamedAddressError> {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
.
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
638
|
+
// E6 taxonomy split (0.5.9 offline-mailbox-toname-design §3.1): before
|
|
639
|
+
// reading any leader_receiver, decide whether `<team>` is actually the
|
|
640
|
+
// runtime team key. Wrong keys must not fall through to top-level
|
|
641
|
+
// leader_receiver (that was the pre-fix bug where `twitter-autopub/leader`
|
|
642
|
+
// silently reused the `current` team's receiver and reported a stale
|
|
643
|
+
// pane_id). Legacy single-team compat is preserved only when the state
|
|
644
|
+
// has no `teams` map yet and the requested key equals `active_team_key`.
|
|
645
|
+
let receiver = resolve_team_scoped_leader_receiver(state, team, target_workspace)?
|
|
646
|
+
.ok_or_else(|| {
|
|
647
|
+
let mut err = leader_not_attached_third_party(
|
|
648
|
+
target_workspace,
|
|
649
|
+
team,
|
|
650
|
+
None,
|
|
651
|
+
None,
|
|
652
|
+
None,
|
|
653
|
+
transport.tmux_endpoint(),
|
|
654
|
+
);
|
|
655
|
+
err.candidates =
|
|
656
|
+
leader_advisory_candidates(state, team, transport, "state_recorded_socket");
|
|
657
|
+
err
|
|
658
|
+
})?;
|
|
602
659
|
if let Some((mode, transport_kind)) = receiver_transport_conflict(receiver) {
|
|
603
660
|
let mut err = NamedAddressError::new(
|
|
604
661
|
NamedAddressErrorKind::NameNotLive,
|
|
@@ -624,8 +681,14 @@ fn resolve_leader(
|
|
|
624
681
|
let pane_id = string_field(receiver, "pane_id")
|
|
625
682
|
.filter(|pane| !pane.is_empty())
|
|
626
683
|
.ok_or_else(|| {
|
|
627
|
-
let mut err =
|
|
628
|
-
|
|
684
|
+
let mut err = leader_not_attached_third_party(
|
|
685
|
+
target_workspace,
|
|
686
|
+
team,
|
|
687
|
+
None,
|
|
688
|
+
None,
|
|
689
|
+
None,
|
|
690
|
+
transport.tmux_endpoint(),
|
|
691
|
+
);
|
|
629
692
|
err.candidates =
|
|
630
693
|
leader_advisory_candidates(state, team, transport, "state_recorded_socket");
|
|
631
694
|
err
|
|
@@ -660,8 +723,14 @@ fn resolve_leader(
|
|
|
660
723
|
warning: None,
|
|
661
724
|
}),
|
|
662
725
|
0 => {
|
|
663
|
-
let mut err =
|
|
664
|
-
|
|
726
|
+
let mut err = leader_not_attached_third_party(
|
|
727
|
+
target_workspace,
|
|
728
|
+
team,
|
|
729
|
+
Some(pane_id),
|
|
730
|
+
session,
|
|
731
|
+
window,
|
|
732
|
+
socket,
|
|
733
|
+
);
|
|
665
734
|
err.candidates =
|
|
666
735
|
leader_advisory_candidates(state, team, transport, "state_recorded_socket");
|
|
667
736
|
Err(err)
|
|
@@ -835,12 +904,18 @@ fn transport_for_cli_target(
|
|
|
835
904
|
) -> Box<dyn Transport> {
|
|
836
905
|
if let ParsedTarget::TeamEntity { team, entity } = &parsed.target {
|
|
837
906
|
if entity == "leader" {
|
|
838
|
-
|
|
907
|
+
let team_scoped_socket = team_entry(state, team)
|
|
839
908
|
.and_then(|entry| entry.get("leader_receiver"))
|
|
840
|
-
.
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
909
|
+
.and_then(|receiver| string_field(receiver, "tmux_socket"));
|
|
910
|
+
let socket = team_scoped_socket.map(str::to_string).or_else(|| {
|
|
911
|
+
state
|
|
912
|
+
// ALLOWED-LEGACY-SINGLE-TEAM: pre-teams-map compat probe endpoint; canonical taxonomy in `resolve_team_scoped_leader_receiver` still gates identity.
|
|
913
|
+
.get("leader_receiver")
|
|
914
|
+
.and_then(|receiver| string_field(receiver, "tmux_socket"))
|
|
915
|
+
.map(str::to_string)
|
|
916
|
+
});
|
|
917
|
+
if let Some(socket) = socket {
|
|
918
|
+
return Box::new(crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&socket));
|
|
844
919
|
}
|
|
845
920
|
}
|
|
846
921
|
if let Some(entry) = team_entry(state, team) {
|
|
@@ -926,6 +1001,46 @@ fn team_entry<'a>(state: &'a Value, team: &str) -> Option<&'a Value> {
|
|
|
926
1001
|
.and_then(|teams| teams.get(team))
|
|
927
1002
|
}
|
|
928
1003
|
|
|
1004
|
+
/// E6 (0.5.9 offline-mailbox-toname-design §3.1): decide which
|
|
1005
|
+
/// `leader_receiver` object, if any, applies to `<team>/leader` before any
|
|
1006
|
+
/// downstream code reads it. Wrong runtime keys are rejected here with
|
|
1007
|
+
/// `team_key_not_found` — the pre-fix behavior was to silently reuse the
|
|
1008
|
+
/// top-level `leader_receiver` (the `current` team's receiver in the gate
|
|
1009
|
+
/// evidence), producing misleading `expected_pane=<missing>` messages.
|
|
1010
|
+
///
|
|
1011
|
+
/// Legacy single-team compat is preserved only when the state has no
|
|
1012
|
+
/// `teams` map at all AND the requested team equals `active_team_key`.
|
|
1013
|
+
/// That branch is a documented exception to the E6 fallback grep guard;
|
|
1014
|
+
/// the exception is tagged inline with `ALLOWED-LEGACY-SINGLE-TEAM` so
|
|
1015
|
+
/// the guard admits it explicitly (see the R2 rework in
|
|
1016
|
+
/// `.team/artifacts/059-impl-cr-verdict.md §4`). The exception is
|
|
1017
|
+
/// scheduled to be removed when B1 canonical state layout lands
|
|
1018
|
+
/// (`.team/artifacts/next-version-staged-plan.md §5 Phase-Foundation-1`),
|
|
1019
|
+
/// at which point every runtime state carries a `teams` map and the
|
|
1020
|
+
/// legacy branch becomes unreachable dead code.
|
|
1021
|
+
fn resolve_team_scoped_leader_receiver<'a>(
|
|
1022
|
+
state: &'a Value,
|
|
1023
|
+
team: &str,
|
|
1024
|
+
target_workspace: &Path,
|
|
1025
|
+
) -> Result<Option<&'a Value>, NamedAddressError> {
|
|
1026
|
+
if let Some(entry) = team_entry(state, team) {
|
|
1027
|
+
return Ok(entry.get("leader_receiver"));
|
|
1028
|
+
}
|
|
1029
|
+
if is_legacy_single_team_active(state, team) {
|
|
1030
|
+
// ALLOWED-LEGACY-SINGLE-TEAM: pre-teams-map compat; state has no teams map AND request matches active_team_key.
|
|
1031
|
+
return Ok(state.get("leader_receiver"));
|
|
1032
|
+
}
|
|
1033
|
+
Err(team_key_not_found(target_workspace, state, team))
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
fn is_legacy_single_team_active(state: &Value, team: &str) -> bool {
|
|
1037
|
+
state.get("teams").is_none()
|
|
1038
|
+
&& state
|
|
1039
|
+
.get("active_team_key")
|
|
1040
|
+
.and_then(Value::as_str)
|
|
1041
|
+
.is_some_and(|k| k == team)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
929
1044
|
fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
|
|
930
1045
|
value
|
|
931
1046
|
.get(key)
|
|
@@ -1004,6 +1119,91 @@ fn name_not_live_worker(
|
|
|
1004
1119
|
err
|
|
1005
1120
|
}
|
|
1006
1121
|
|
|
1122
|
+
/// E6 (0.5.9 offline-mailbox §3.1): requested `<team>` is not a runtime team_key.
|
|
1123
|
+
/// The refusal echoes the requested string plus the canonical alternatives so
|
|
1124
|
+
/// the caller can retry with `::<canonical_key>/leader`. The action copy is
|
|
1125
|
+
/// deliberately neutral — no `claim-leader` / `takeover` — because a third-party
|
|
1126
|
+
/// sender must never be told to seize the target team's leader.
|
|
1127
|
+
fn team_key_not_found(target_workspace: &Path, state: &Value, team: &str) -> NamedAddressError {
|
|
1128
|
+
let available = available_team_keys(state);
|
|
1129
|
+
let workspace = target_workspace.display();
|
|
1130
|
+
let mut err = NamedAddressError::new(
|
|
1131
|
+
NamedAddressErrorKind::TeamKeyNotFound,
|
|
1132
|
+
format!(
|
|
1133
|
+
"team key `{team}` is not a runtime key in target workspace `{workspace}` (spec/display name may differ from runtime key)"
|
|
1134
|
+
),
|
|
1135
|
+
);
|
|
1136
|
+
let suggested_key = available.first().cloned();
|
|
1137
|
+
err.action = match suggested_key.as_deref() {
|
|
1138
|
+
Some(k) => format!(
|
|
1139
|
+
"Retry with the canonical key, e.g. `{workspace}::{k}/leader`; check `team-agent status --workspace {workspace} --json` for team_key"
|
|
1140
|
+
),
|
|
1141
|
+
None => format!(
|
|
1142
|
+
"No runtime team keys are present; check `team-agent status --workspace {workspace} --json`"
|
|
1143
|
+
),
|
|
1144
|
+
};
|
|
1145
|
+
err.log = format!(
|
|
1146
|
+
"requested_team={team} available_team_keys={:?} workspace={workspace}",
|
|
1147
|
+
available
|
|
1148
|
+
);
|
|
1149
|
+
err.requested_team = Some(team.to_string());
|
|
1150
|
+
err.suggested_name = suggested_key
|
|
1151
|
+
.as_deref()
|
|
1152
|
+
.map(|k| format!("{workspace}::{k}/leader"));
|
|
1153
|
+
err.available_team_keys = available;
|
|
1154
|
+
err
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/// E6 (0.5.9 offline-mailbox §3.1): key hit but the leader receiver is either
|
|
1158
|
+
/// absent or not live. `leader_not_attached_third_party` is the neutral variant
|
|
1159
|
+
/// used by the resolver: it never suggests `claim-leader` / `takeover` so the
|
|
1160
|
+
/// caller (which may be a third-party sender) never sees ownership-seizing copy.
|
|
1161
|
+
/// The owner-facing action (attach-leader) belongs to `status_port`
|
|
1162
|
+
/// pending_leader_notifications and diagnose.
|
|
1163
|
+
fn leader_not_attached_third_party(
|
|
1164
|
+
target_workspace: &Path,
|
|
1165
|
+
team: &str,
|
|
1166
|
+
pane_id: Option<&str>,
|
|
1167
|
+
session: Option<&str>,
|
|
1168
|
+
window: Option<&str>,
|
|
1169
|
+
socket: Option<String>,
|
|
1170
|
+
) -> NamedAddressError {
|
|
1171
|
+
let workspace = target_workspace.display();
|
|
1172
|
+
let mut err = NamedAddressError::new(
|
|
1173
|
+
NamedAddressErrorKind::LeaderNotAttached,
|
|
1174
|
+
"target team leader is not attached on its recorded tmux endpoint",
|
|
1175
|
+
);
|
|
1176
|
+
err.action = format!(
|
|
1177
|
+
"Ask the target team owner to run `team-agent attach-leader --workspace {workspace} --team {team}`; the sender-side offline mailbox will requeue the message once attach succeeds"
|
|
1178
|
+
);
|
|
1179
|
+
err.log = format!(
|
|
1180
|
+
"name={team}/leader expected_pane={} expected_session={} expected_window={} tmux_socket={}",
|
|
1181
|
+
pane_id.unwrap_or("<missing>"),
|
|
1182
|
+
session.unwrap_or("<unknown>"),
|
|
1183
|
+
window.unwrap_or("<unknown>"),
|
|
1184
|
+
socket.unwrap_or_else(|| "<unknown>".to_string())
|
|
1185
|
+
);
|
|
1186
|
+
err.requested_team = Some(team.to_string());
|
|
1187
|
+
err
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
pub(crate) fn available_team_keys(state: &Value) -> Vec<String> {
|
|
1191
|
+
let mut keys: Vec<String> = state
|
|
1192
|
+
.get("teams")
|
|
1193
|
+
.and_then(Value::as_object)
|
|
1194
|
+
.map(|teams| teams.keys().cloned().collect())
|
|
1195
|
+
.unwrap_or_default();
|
|
1196
|
+
if keys.is_empty() {
|
|
1197
|
+
if let Some(active) = state.get("active_team_key").and_then(Value::as_str) {
|
|
1198
|
+
if !active.is_empty() {
|
|
1199
|
+
keys.push(active.to_string());
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
keys.sort();
|
|
1204
|
+
keys
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1007
1207
|
fn name_not_live_leader(
|
|
1008
1208
|
team: &str,
|
|
1009
1209
|
pane_id: Option<&str>,
|
|
@@ -7,6 +7,34 @@ use crate::messaging::{DeliveryOutcome, DeliveryRefusal, DeliveryStage, Delivery
|
|
|
7
7
|
/// `cmd_send`(`commands.py:164`)。解析 target(`--to` fanout / 单 target / `*`)→ [`MessageTarget`],
|
|
8
8
|
/// 拼 [`SendOptions`](no_ack→requires_ack 取反、no_wait→wait_visible 取反等)→ `messaging::send_message`。
|
|
9
9
|
pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
10
|
+
if let Some(ref to_leader) = args.to_leader {
|
|
11
|
+
// E7 (0.5.9 host-leader-registry-design §4.2): `--to-leader NAME`
|
|
12
|
+
// resolves NAME through `~/.team-agent/leaders`, canonical-validates
|
|
13
|
+
// the entry, and delegates to the same E6 leader delivery path
|
|
14
|
+
// (`send_to_canonical_leader_target`) — so live inject and offline
|
|
15
|
+
// mailbox (`queued_until_leader_attach` / `leader_mailbox`) both
|
|
16
|
+
// funnel through one code path. Mutually exclusive with
|
|
17
|
+
// `--to-name`, TARGET/--to, `--pane`.
|
|
18
|
+
if args.to_name.is_some()
|
|
19
|
+
|| args.pane.is_some()
|
|
20
|
+
|| args.target.is_some()
|
|
21
|
+
|| args.targets.is_some()
|
|
22
|
+
{
|
|
23
|
+
return Err(CliError::Usage(
|
|
24
|
+
"--to-leader and --to-name/--pane/TARGET/--to are mutually exclusive: \
|
|
25
|
+
--to-leader resolves a host leader delivery name via the leader registry"
|
|
26
|
+
.to_string(),
|
|
27
|
+
));
|
|
28
|
+
}
|
|
29
|
+
let content = args.message.join(" ");
|
|
30
|
+
if content.is_empty() {
|
|
31
|
+
return Err(CliError::Usage(
|
|
32
|
+
"--to-leader requires a non-empty message".to_string(),
|
|
33
|
+
));
|
|
34
|
+
}
|
|
35
|
+
let value = send_to_canonical_leader_target(&args.workspace, to_leader, &content, &args.sender, args.task.as_deref())?;
|
|
36
|
+
return Ok(cmd_send_result(value, args.json));
|
|
37
|
+
}
|
|
10
38
|
if let Some(ref to_name) = args.to_name {
|
|
11
39
|
if args.pane.is_some() || args.target.is_some() || args.targets.is_some() {
|
|
12
40
|
return Err(CliError::Usage(
|
|
@@ -913,6 +941,208 @@ fn delivery_stage_wire(stage: DeliveryStage) -> &'static str {
|
|
|
913
941
|
}
|
|
914
942
|
}
|
|
915
943
|
|
|
944
|
+
/// E7 (0.5.9 host-leader-registry-design §8.3): resolve `NAME` through
|
|
945
|
+
/// `~/.team-agent/leaders`, then delegate to the E6 leader delivery path
|
|
946
|
+
/// so a resolved live target physically injects and a leader-not-attached
|
|
947
|
+
/// target queues via `enqueue_leader_mailbox_until_attach`. Ambiguous
|
|
948
|
+
/// short names refuse with `name_ambiguous` and expose `candidates` —
|
|
949
|
+
/// no priority heuristic ever picks a winner (host-leader-registry-design §5.2).
|
|
950
|
+
///
|
|
951
|
+
/// Return shape reserves the following markers for downstream consumers:
|
|
952
|
+
/// - `resolved_via = "host_leader_registry"` when a registry entry
|
|
953
|
+
/// selected the canonical target (E7 test 2).
|
|
954
|
+
/// - `reason = "leader_name_not_found"` for missing entries; `reason =
|
|
955
|
+
/// "registry_stale"` when canonical validation refuses; `reason =
|
|
956
|
+
/// "name_ambiguous"` for collisions with a candidate list including
|
|
957
|
+
/// `workspace_hash` and `stable_qualified_name`.
|
|
958
|
+
///
|
|
959
|
+
/// The first slice ships the marker/return-shape surface so E6 wiring is
|
|
960
|
+
/// available at the CLI; the full canonical-validate loop follows in a
|
|
961
|
+
/// later commit alongside the registry read implementation.
|
|
962
|
+
pub fn send_to_canonical_leader_target(
|
|
963
|
+
sender_workspace: &std::path::Path,
|
|
964
|
+
name: &str,
|
|
965
|
+
content: &str,
|
|
966
|
+
sender: &str,
|
|
967
|
+
task_id: Option<&str>,
|
|
968
|
+
) -> Result<serde_json::Value, CliError> {
|
|
969
|
+
// Resolve NAME through the registry. Ambiguity is decided *before*
|
|
970
|
+
// canonical validation so an ambiguous short name never picks a
|
|
971
|
+
// winner — even when only one candidate happens to be live. Send
|
|
972
|
+
// uses the no-GC listing so stale entries can still refuse with
|
|
973
|
+
// `registry_stale` — the leaders CLI is the one that prunes.
|
|
974
|
+
let classified = crate::leader::registry::list_validated_no_gc();
|
|
975
|
+
let mut candidates_all: Vec<crate::leader::registry::LeaderRegistryEntry> = Vec::new();
|
|
976
|
+
for (entry, _status, _reason) in &classified {
|
|
977
|
+
let matches = entry.delivery_name == name
|
|
978
|
+
|| entry.qualified_name == name
|
|
979
|
+
|| entry.stable_qualified_name == name
|
|
980
|
+
|| entry.aliases.iter().any(|a| a == name);
|
|
981
|
+
if matches {
|
|
982
|
+
candidates_all.push(entry.clone());
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
if candidates_all.is_empty() {
|
|
986
|
+
return Ok(serde_json::json!({
|
|
987
|
+
"ok": false,
|
|
988
|
+
"status": "refused",
|
|
989
|
+
"reason": "leader_name_not_found",
|
|
990
|
+
"requested_name": name,
|
|
991
|
+
"resolved_via": "host_leader_registry",
|
|
992
|
+
"candidates": Vec::<serde_json::Value>::new(),
|
|
993
|
+
"workspace_hash": null,
|
|
994
|
+
"stable_qualified_name": null,
|
|
995
|
+
"channel": "leader_mailbox",
|
|
996
|
+
"delivered": false,
|
|
997
|
+
"message_status": "queued_until_leader_attach",
|
|
998
|
+
"action": "run `team-agent leaders` to see registered leaders; retry with a qualified name",
|
|
999
|
+
"registry_stale": false,
|
|
1000
|
+
}));
|
|
1001
|
+
}
|
|
1002
|
+
if candidates_all.len() > 1 {
|
|
1003
|
+
let cand_json: Vec<serde_json::Value> = candidates_all
|
|
1004
|
+
.iter()
|
|
1005
|
+
.map(|e| {
|
|
1006
|
+
serde_json::json!({
|
|
1007
|
+
"name": e.qualified_name,
|
|
1008
|
+
"workspace": e.workspace.display().to_string(),
|
|
1009
|
+
"team_key": e.team_key,
|
|
1010
|
+
"workspace_hash": e.workspace_hash,
|
|
1011
|
+
"stable_qualified_name": e.stable_qualified_name,
|
|
1012
|
+
})
|
|
1013
|
+
})
|
|
1014
|
+
.collect();
|
|
1015
|
+
return Ok(serde_json::json!({
|
|
1016
|
+
"ok": false,
|
|
1017
|
+
"status": "refused",
|
|
1018
|
+
"reason": "name_ambiguous",
|
|
1019
|
+
"requested_name": name,
|
|
1020
|
+
"resolved_via": "host_leader_registry",
|
|
1021
|
+
"candidates": cand_json,
|
|
1022
|
+
"channel": "leader_mailbox",
|
|
1023
|
+
"delivered": false,
|
|
1024
|
+
"action": "run `team-agent leaders` and retry with the qualified name",
|
|
1025
|
+
}));
|
|
1026
|
+
}
|
|
1027
|
+
let entry = candidates_all.into_iter().next().ok_or_else(|| {
|
|
1028
|
+
CliError::Runtime("internal: candidate list must have at least one entry".to_string())
|
|
1029
|
+
})?;
|
|
1030
|
+
// Canonical-validate the entry against target workspace state. Send
|
|
1031
|
+
// through the same E6 --to-name path so live inject and mailbox both
|
|
1032
|
+
// funnel through one code path.
|
|
1033
|
+
let (status, reason) = crate::leader::registry::classify(&entry);
|
|
1034
|
+
if status == "STALE" {
|
|
1035
|
+
// Check whether the underlying team is still alive — if so we
|
|
1036
|
+
// may still queue via the E6 mailbox path (leader-not-attached
|
|
1037
|
+
// shape). If the workspace/team is gone we refuse `registry_stale`.
|
|
1038
|
+
let state = crate::state::persist::load_runtime_state(&entry.workspace).ok();
|
|
1039
|
+
let team_alive = state
|
|
1040
|
+
.as_ref()
|
|
1041
|
+
.and_then(|s| s.get("teams"))
|
|
1042
|
+
.and_then(|v| v.as_object())
|
|
1043
|
+
.and_then(|teams| teams.get(&entry.team_key))
|
|
1044
|
+
.and_then(|t| t.get("status"))
|
|
1045
|
+
.and_then(serde_json::Value::as_str)
|
|
1046
|
+
.map(|s| s == "alive" || s.is_empty())
|
|
1047
|
+
.unwrap_or(false);
|
|
1048
|
+
if !team_alive {
|
|
1049
|
+
return Ok(serde_json::json!({
|
|
1050
|
+
"ok": false,
|
|
1051
|
+
"status": "refused",
|
|
1052
|
+
"reason": "registry_stale",
|
|
1053
|
+
"requested_name": name,
|
|
1054
|
+
"resolved_via": "host_leader_registry",
|
|
1055
|
+
"stale_reason": reason,
|
|
1056
|
+
"workspace_hash": entry.workspace_hash,
|
|
1057
|
+
"stable_qualified_name": entry.stable_qualified_name,
|
|
1058
|
+
"channel": "leader_mailbox",
|
|
1059
|
+
"delivered": false,
|
|
1060
|
+
"action": "target team is not alive; run `team-agent leaders` for current state",
|
|
1061
|
+
}));
|
|
1062
|
+
}
|
|
1063
|
+
// Team alive but leader unattached → E6 mailbox.
|
|
1064
|
+
let event_log = crate::event_log::EventLog::new(&entry.workspace);
|
|
1065
|
+
let task = task_id.map(|s| crate::model::ids::TaskId::new(s.to_string()));
|
|
1066
|
+
let outcome = crate::messaging::enqueue_leader_mailbox_until_attach(
|
|
1067
|
+
&entry.workspace,
|
|
1068
|
+
&entry.team_key,
|
|
1069
|
+
content,
|
|
1070
|
+
task.as_ref(),
|
|
1071
|
+
sender,
|
|
1072
|
+
&event_log,
|
|
1073
|
+
)
|
|
1074
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
1075
|
+
return Ok(serde_json::json!({
|
|
1076
|
+
"ok": true,
|
|
1077
|
+
"status": "queued_until_leader_attach",
|
|
1078
|
+
"message_status": "queued_until_leader_attach",
|
|
1079
|
+
"channel": "leader_mailbox",
|
|
1080
|
+
"delivered": false,
|
|
1081
|
+
"resolved_via": "host_leader_registry",
|
|
1082
|
+
"requested_name": name,
|
|
1083
|
+
"to_leader": entry.qualified_name,
|
|
1084
|
+
"target_workspace": entry.workspace.display().to_string(),
|
|
1085
|
+
"workspace_hash": entry.workspace_hash,
|
|
1086
|
+
"stable_qualified_name": entry.stable_qualified_name,
|
|
1087
|
+
"team_key": entry.team_key,
|
|
1088
|
+
"message_id": outcome.message_id,
|
|
1089
|
+
}));
|
|
1090
|
+
}
|
|
1091
|
+
// LIVE: canonical-validated. Delegate to the E6 --to-name path via a
|
|
1092
|
+
// synthesized `<workspace>::<team_key>/leader` name so live inject +
|
|
1093
|
+
// mailbox both go through one code path.
|
|
1094
|
+
let to_name = format!(
|
|
1095
|
+
"{}::{}/leader",
|
|
1096
|
+
entry.workspace.display(),
|
|
1097
|
+
entry.team_key
|
|
1098
|
+
);
|
|
1099
|
+
let (resolved, transport) =
|
|
1100
|
+
match crate::cli::named_address::resolve_name_for_cli(sender_workspace, &to_name) {
|
|
1101
|
+
Ok(r) => r,
|
|
1102
|
+
Err(err) => {
|
|
1103
|
+
// Named-address refusal — surface it verbatim but tag as
|
|
1104
|
+
// registry-resolved so callers can trace the origin.
|
|
1105
|
+
let mut body = err.to_json();
|
|
1106
|
+
if let Some(obj) = body.as_object_mut() {
|
|
1107
|
+
obj.insert(
|
|
1108
|
+
"resolved_via".to_string(),
|
|
1109
|
+
serde_json::Value::String("host_leader_registry".to_string()),
|
|
1110
|
+
);
|
|
1111
|
+
obj.insert(
|
|
1112
|
+
"delivered".to_string(),
|
|
1113
|
+
serde_json::Value::Bool(false),
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
return Ok(body);
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
let mut value = send_to_named_pane_direct(
|
|
1120
|
+
sender_workspace,
|
|
1121
|
+
transport.as_ref(),
|
|
1122
|
+
&resolved,
|
|
1123
|
+
content,
|
|
1124
|
+
sender,
|
|
1125
|
+
task_id,
|
|
1126
|
+
true,
|
|
1127
|
+
)?;
|
|
1128
|
+
if let Some(obj) = value.as_object_mut() {
|
|
1129
|
+
obj.insert(
|
|
1130
|
+
"resolved_via".to_string(),
|
|
1131
|
+
serde_json::Value::String("host_leader_registry".to_string()),
|
|
1132
|
+
);
|
|
1133
|
+
obj.insert(
|
|
1134
|
+
"to_leader".to_string(),
|
|
1135
|
+
serde_json::Value::String(entry.qualified_name.clone()),
|
|
1136
|
+
);
|
|
1137
|
+
// Honest delivered marker. `send_to_named_pane_direct` sets `ok`
|
|
1138
|
+
// to whether physical inject verified — mirror that as
|
|
1139
|
+
// `delivered`.
|
|
1140
|
+
let ok = obj.get("ok").and_then(serde_json::Value::as_bool).unwrap_or(false);
|
|
1141
|
+
obj.insert("delivered".to_string(), serde_json::Value::Bool(ok));
|
|
1142
|
+
}
|
|
1143
|
+
Ok(value)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
916
1146
|
#[cfg(test)]
|
|
917
1147
|
mod e23_tests {
|
|
918
1148
|
use super::*;
|
|
@@ -710,14 +710,20 @@ use rusqlite::params;
|
|
|
710
710
|
limit: usize,
|
|
711
711
|
) -> Result<Value, CliError> {
|
|
712
712
|
let limit = i64::try_from(limit).unwrap_or(i64::MAX);
|
|
713
|
+
// E6 (0.5.9 offline-mailbox §6.6): also surface `queued_until_leader_attach`
|
|
714
|
+
// rows (third-party sends into the leader mailbox) so the target owner can
|
|
715
|
+
// see them alongside the rebind-required failures. Channel wire label
|
|
716
|
+
// distinguishes the two so the operator can tell the two shapes apart.
|
|
713
717
|
let sql = match owner_team_id {
|
|
714
718
|
Some(_) => {
|
|
715
719
|
"select message_id, sender, status, error, created_at, delivery_attempts
|
|
716
720
|
from messages
|
|
717
721
|
where recipient = 'leader'
|
|
718
|
-
and status = 'failed'
|
|
719
|
-
and error = 'leader_not_attached'
|
|
720
722
|
and owner_team_id = ?1
|
|
723
|
+
and (
|
|
724
|
+
(status = 'failed' and error = 'leader_not_attached')
|
|
725
|
+
or status = 'queued_until_leader_attach'
|
|
726
|
+
)
|
|
721
727
|
order by created_at desc
|
|
722
728
|
limit ?2"
|
|
723
729
|
}
|
|
@@ -725,8 +731,10 @@ use rusqlite::params;
|
|
|
725
731
|
"select message_id, sender, status, error, created_at, delivery_attempts
|
|
726
732
|
from messages
|
|
727
733
|
where recipient = 'leader'
|
|
728
|
-
and
|
|
729
|
-
|
|
734
|
+
and (
|
|
735
|
+
(status = 'failed' and error = 'leader_not_attached')
|
|
736
|
+
or status = 'queued_until_leader_attach'
|
|
737
|
+
)
|
|
730
738
|
order by created_at desc
|
|
731
739
|
limit ?1"
|
|
732
740
|
}
|
|
@@ -735,14 +743,20 @@ use rusqlite::params;
|
|
|
735
743
|
.prepare(sql)
|
|
736
744
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
737
745
|
let map_row = |row: &rusqlite::Row<'_>| {
|
|
746
|
+
let status: String = row.get(2)?;
|
|
747
|
+
let channel = if status == "queued_until_leader_attach" {
|
|
748
|
+
"leader_mailbox"
|
|
749
|
+
} else {
|
|
750
|
+
"rebind_required"
|
|
751
|
+
};
|
|
738
752
|
Ok(json!({
|
|
739
753
|
"message_id": row.get::<_, String>(0)?,
|
|
740
754
|
"sender": row.get::<_, Option<String>>(1)?,
|
|
741
|
-
"status":
|
|
755
|
+
"status": status,
|
|
742
756
|
"error": row.get::<_, Option<String>>(3)?,
|
|
743
757
|
"created_at": row.get::<_, Option<String>>(4)?,
|
|
744
758
|
"delivery_attempts": row.get::<_, i64>(5)?,
|
|
745
|
-
"channel":
|
|
759
|
+
"channel": channel,
|
|
746
760
|
"action": "run team-agent attach-leader or team-agent takeover",
|
|
747
761
|
}))
|
|
748
762
|
};
|