@team-agent/installer 0.5.8 → 0.5.10

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.
@@ -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
- json!({
70
- "ok": false,
71
- "status": "refused",
72
- "reason": self.kind.as_str(),
73
- "error": self.message,
74
- "action": self.action,
75
- "log": self.log,
76
- "candidates": self.candidates,
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
 
@@ -226,6 +267,26 @@ pub(crate) fn resolve_name_for_cli(
226
267
  Ok((resolved, transport))
227
268
  }
228
269
 
270
+ /// E6 wiring helper (`cli::send::maybe_enqueue_offline_leader_mailbox`):
271
+ /// re-parse a `<workspace>::<team>/leader` name and return the resolved
272
+ /// target workspace + canonical team_key so send.rs can enqueue the
273
+ /// mailbox without duplicating the parser. Returns `Ok(None)` for names
274
+ /// that aren't `<team>/leader` shapes (worker sends, session:window,
275
+ /// etc. keep their existing refusal semantics).
276
+ pub(crate) fn parse_leader_target_workspace_and_team(
277
+ sender_workspace: &Path,
278
+ raw_name: &str,
279
+ ) -> Result<Option<(PathBuf, String)>, NamedAddressError> {
280
+ let parsed = parse_named_address(raw_name)?;
281
+ let target_workspace = resolve_workspace(sender_workspace, parsed.workspace.as_deref())?;
282
+ match parsed.target {
283
+ ParsedTarget::TeamEntity { team, entity } if entity == "leader" => {
284
+ Ok(Some((target_workspace, team)))
285
+ }
286
+ _ => Ok(None),
287
+ }
288
+ }
289
+
229
290
  #[derive(Debug, Clone, PartialEq, Eq)]
230
291
  struct ParsedNamedAddress {
231
292
  workspace: Option<PathBuf>,
@@ -555,12 +616,18 @@ fn leader_advisory_candidates(
555
616
  transport: &dyn Transport,
556
617
  source: &str,
557
618
  ) -> Vec<Value> {
558
- let socket = team_entry(state, team)
619
+ let team_scoped_socket = team_entry(state, team)
559
620
  .and_then(|entry| entry.get("leader_receiver"))
560
- .or_else(|| state.get("leader_receiver"))
561
- .and_then(|receiver| string_field(receiver, "tmux_socket"))
562
- .map(str::to_string)
563
- .or_else(|| transport.tmux_endpoint());
621
+ .and_then(|receiver| string_field(receiver, "tmux_socket"));
622
+ let socket = match team_scoped_socket {
623
+ Some(s) => Some(s.to_string()),
624
+ None => state
625
+ // ALLOWED-LEGACY-SINGLE-TEAM: diagnostic-only socket label for the advisory candidates; never fed back into routing.
626
+ .get("leader_receiver")
627
+ .and_then(|receiver| string_field(receiver, "tmux_socket"))
628
+ .map(str::to_string)
629
+ .or_else(|| transport.tmux_endpoint()),
630
+ };
564
631
  let targets = match transport.list_targets() {
565
632
  Ok(targets) => targets,
566
633
  Err(_) => return Vec::new(),
@@ -588,17 +655,27 @@ fn resolve_leader(
588
655
  parsed: &ParsedNamedAddress,
589
656
  transport: &dyn Transport,
590
657
  ) -> Result<ResolvedNamedAddress, NamedAddressError> {
591
- let receiver = if let Some(team_entry) = team_entry(state, team) {
592
- team_entry.get("leader_receiver")
593
- } else {
594
- state.get("leader_receiver")
595
- }
596
- .ok_or_else(|| {
597
- let mut err = name_not_live_leader(team, None, None, None, transport.tmux_endpoint());
598
- err.candidates =
599
- leader_advisory_candidates(state, team, transport, "state_recorded_socket");
600
- err
601
- })?;
658
+ // E6 taxonomy split (0.5.9 offline-mailbox-toname-design §3.1): before
659
+ // reading any leader_receiver, decide whether `<team>` is actually the
660
+ // runtime team key. Wrong keys must not fall through to top-level
661
+ // leader_receiver (that was the pre-fix bug where `twitter-autopub/leader`
662
+ // silently reused the `current` team's receiver and reported a stale
663
+ // pane_id). Legacy single-team compat is preserved only when the state
664
+ // has no `teams` map yet and the requested key equals `active_team_key`.
665
+ let receiver = resolve_team_scoped_leader_receiver(state, team, target_workspace)?
666
+ .ok_or_else(|| {
667
+ let mut err = leader_not_attached_third_party(
668
+ target_workspace,
669
+ team,
670
+ None,
671
+ None,
672
+ None,
673
+ transport.tmux_endpoint(),
674
+ );
675
+ err.candidates =
676
+ leader_advisory_candidates(state, team, transport, "state_recorded_socket");
677
+ err
678
+ })?;
602
679
  if let Some((mode, transport_kind)) = receiver_transport_conflict(receiver) {
603
680
  let mut err = NamedAddressError::new(
604
681
  NamedAddressErrorKind::NameNotLive,
@@ -624,8 +701,14 @@ fn resolve_leader(
624
701
  let pane_id = string_field(receiver, "pane_id")
625
702
  .filter(|pane| !pane.is_empty())
626
703
  .ok_or_else(|| {
627
- let mut err =
628
- name_not_live_leader(team, None, None, None, transport.tmux_endpoint());
704
+ let mut err = leader_not_attached_third_party(
705
+ target_workspace,
706
+ team,
707
+ None,
708
+ None,
709
+ None,
710
+ transport.tmux_endpoint(),
711
+ );
629
712
  err.candidates =
630
713
  leader_advisory_candidates(state, team, transport, "state_recorded_socket");
631
714
  err
@@ -660,8 +743,14 @@ fn resolve_leader(
660
743
  warning: None,
661
744
  }),
662
745
  0 => {
663
- let mut err =
664
- name_not_live_leader(team, Some(pane_id), session, window, socket);
746
+ let mut err = leader_not_attached_third_party(
747
+ target_workspace,
748
+ team,
749
+ Some(pane_id),
750
+ session,
751
+ window,
752
+ socket,
753
+ );
665
754
  err.candidates =
666
755
  leader_advisory_candidates(state, team, transport, "state_recorded_socket");
667
756
  Err(err)
@@ -835,12 +924,18 @@ fn transport_for_cli_target(
835
924
  ) -> Box<dyn Transport> {
836
925
  if let ParsedTarget::TeamEntity { team, entity } = &parsed.target {
837
926
  if entity == "leader" {
838
- if let Some(socket) = team_entry(state, team)
927
+ let team_scoped_socket = team_entry(state, team)
839
928
  .and_then(|entry| entry.get("leader_receiver"))
840
- .or_else(|| state.get("leader_receiver"))
841
- .and_then(|receiver| string_field(receiver, "tmux_socket"))
842
- {
843
- return Box::new(crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket));
929
+ .and_then(|receiver| string_field(receiver, "tmux_socket"));
930
+ let socket = team_scoped_socket.map(str::to_string).or_else(|| {
931
+ state
932
+ // ALLOWED-LEGACY-SINGLE-TEAM: pre-teams-map compat probe endpoint; canonical taxonomy in `resolve_team_scoped_leader_receiver` still gates identity.
933
+ .get("leader_receiver")
934
+ .and_then(|receiver| string_field(receiver, "tmux_socket"))
935
+ .map(str::to_string)
936
+ });
937
+ if let Some(socket) = socket {
938
+ return Box::new(crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&socket));
844
939
  }
845
940
  }
846
941
  if let Some(entry) = team_entry(state, team) {
@@ -926,6 +1021,46 @@ fn team_entry<'a>(state: &'a Value, team: &str) -> Option<&'a Value> {
926
1021
  .and_then(|teams| teams.get(team))
927
1022
  }
928
1023
 
1024
+ /// E6 (0.5.9 offline-mailbox-toname-design §3.1): decide which
1025
+ /// `leader_receiver` object, if any, applies to `<team>/leader` before any
1026
+ /// downstream code reads it. Wrong runtime keys are rejected here with
1027
+ /// `team_key_not_found` — the pre-fix behavior was to silently reuse the
1028
+ /// top-level `leader_receiver` (the `current` team's receiver in the gate
1029
+ /// evidence), producing misleading `expected_pane=<missing>` messages.
1030
+ ///
1031
+ /// Legacy single-team compat is preserved only when the state has no
1032
+ /// `teams` map at all AND the requested team equals `active_team_key`.
1033
+ /// That branch is a documented exception to the E6 fallback grep guard;
1034
+ /// the exception is tagged inline with `ALLOWED-LEGACY-SINGLE-TEAM` so
1035
+ /// the guard admits it explicitly (see the R2 rework in
1036
+ /// `.team/artifacts/059-impl-cr-verdict.md §4`). The exception is
1037
+ /// scheduled to be removed when B1 canonical state layout lands
1038
+ /// (`.team/artifacts/next-version-staged-plan.md §5 Phase-Foundation-1`),
1039
+ /// at which point every runtime state carries a `teams` map and the
1040
+ /// legacy branch becomes unreachable dead code.
1041
+ fn resolve_team_scoped_leader_receiver<'a>(
1042
+ state: &'a Value,
1043
+ team: &str,
1044
+ target_workspace: &Path,
1045
+ ) -> Result<Option<&'a Value>, NamedAddressError> {
1046
+ if let Some(entry) = team_entry(state, team) {
1047
+ return Ok(entry.get("leader_receiver"));
1048
+ }
1049
+ if is_legacy_single_team_active(state, team) {
1050
+ // ALLOWED-LEGACY-SINGLE-TEAM: pre-teams-map compat; state has no teams map AND request matches active_team_key.
1051
+ return Ok(state.get("leader_receiver"));
1052
+ }
1053
+ Err(team_key_not_found(target_workspace, state, team))
1054
+ }
1055
+
1056
+ fn is_legacy_single_team_active(state: &Value, team: &str) -> bool {
1057
+ state.get("teams").is_none()
1058
+ && state
1059
+ .get("active_team_key")
1060
+ .and_then(Value::as_str)
1061
+ .is_some_and(|k| k == team)
1062
+ }
1063
+
929
1064
  fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
930
1065
  value
931
1066
  .get(key)
@@ -1004,6 +1139,91 @@ fn name_not_live_worker(
1004
1139
  err
1005
1140
  }
1006
1141
 
1142
+ /// E6 (0.5.9 offline-mailbox §3.1): requested `<team>` is not a runtime team_key.
1143
+ /// The refusal echoes the requested string plus the canonical alternatives so
1144
+ /// the caller can retry with `::<canonical_key>/leader`. The action copy is
1145
+ /// deliberately neutral — no `claim-leader` / `takeover` — because a third-party
1146
+ /// sender must never be told to seize the target team's leader.
1147
+ fn team_key_not_found(target_workspace: &Path, state: &Value, team: &str) -> NamedAddressError {
1148
+ let available = available_team_keys(state);
1149
+ let workspace = target_workspace.display();
1150
+ let mut err = NamedAddressError::new(
1151
+ NamedAddressErrorKind::TeamKeyNotFound,
1152
+ format!(
1153
+ "team key `{team}` is not a runtime key in target workspace `{workspace}` (spec/display name may differ from runtime key)"
1154
+ ),
1155
+ );
1156
+ let suggested_key = available.first().cloned();
1157
+ err.action = match suggested_key.as_deref() {
1158
+ Some(k) => format!(
1159
+ "Retry with the canonical key, e.g. `{workspace}::{k}/leader`; check `team-agent status --workspace {workspace} --json` for team_key"
1160
+ ),
1161
+ None => format!(
1162
+ "No runtime team keys are present; check `team-agent status --workspace {workspace} --json`"
1163
+ ),
1164
+ };
1165
+ err.log = format!(
1166
+ "requested_team={team} available_team_keys={:?} workspace={workspace}",
1167
+ available
1168
+ );
1169
+ err.requested_team = Some(team.to_string());
1170
+ err.suggested_name = suggested_key
1171
+ .as_deref()
1172
+ .map(|k| format!("{workspace}::{k}/leader"));
1173
+ err.available_team_keys = available;
1174
+ err
1175
+ }
1176
+
1177
+ /// E6 (0.5.9 offline-mailbox §3.1): key hit but the leader receiver is either
1178
+ /// absent or not live. `leader_not_attached_third_party` is the neutral variant
1179
+ /// used by the resolver: it never suggests `claim-leader` / `takeover` so the
1180
+ /// caller (which may be a third-party sender) never sees ownership-seizing copy.
1181
+ /// The owner-facing action (attach-leader) belongs to `status_port`
1182
+ /// pending_leader_notifications and diagnose.
1183
+ fn leader_not_attached_third_party(
1184
+ target_workspace: &Path,
1185
+ team: &str,
1186
+ pane_id: Option<&str>,
1187
+ session: Option<&str>,
1188
+ window: Option<&str>,
1189
+ socket: Option<String>,
1190
+ ) -> NamedAddressError {
1191
+ let workspace = target_workspace.display();
1192
+ let mut err = NamedAddressError::new(
1193
+ NamedAddressErrorKind::LeaderNotAttached,
1194
+ "target team leader is not attached on its recorded tmux endpoint",
1195
+ );
1196
+ err.action = format!(
1197
+ "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"
1198
+ );
1199
+ err.log = format!(
1200
+ "name={team}/leader expected_pane={} expected_session={} expected_window={} tmux_socket={}",
1201
+ pane_id.unwrap_or("<missing>"),
1202
+ session.unwrap_or("<unknown>"),
1203
+ window.unwrap_or("<unknown>"),
1204
+ socket.unwrap_or_else(|| "<unknown>".to_string())
1205
+ );
1206
+ err.requested_team = Some(team.to_string());
1207
+ err
1208
+ }
1209
+
1210
+ pub(crate) fn available_team_keys(state: &Value) -> Vec<String> {
1211
+ let mut keys: Vec<String> = state
1212
+ .get("teams")
1213
+ .and_then(Value::as_object)
1214
+ .map(|teams| teams.keys().cloned().collect())
1215
+ .unwrap_or_default();
1216
+ if keys.is_empty() {
1217
+ if let Some(active) = state.get("active_team_key").and_then(Value::as_str) {
1218
+ if !active.is_empty() {
1219
+ keys.push(active.to_string());
1220
+ }
1221
+ }
1222
+ }
1223
+ keys.sort();
1224
+ keys
1225
+ }
1226
+
1007
1227
  fn name_not_live_leader(
1008
1228
  team: &str,
1009
1229
  pane_id: Option<&str>,