@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.
package/Cargo.lock CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.8"
578
+ version = "0.5.10"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.8"
12
+ version = "0.5.10"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -5,12 +5,93 @@ use super::*;
5
5
  pub fn cmd_attach_app_server_leader(
6
6
  args: &AttachAppServerLeaderArgs,
7
7
  ) -> Result<CmdResult, CliError> {
8
- let value = crate::leader::attach_app_server_leader(
8
+ let mut value = crate::leader::attach_app_server_leader(
9
9
  &args.workspace,
10
10
  args.team.as_deref(),
11
11
  &args.socket,
12
12
  &args.thread_id,
13
13
  )
14
14
  .map_err(|error| CliError::Runtime(error.to_string()))?;
15
+ // E7 register-after-success (host-leader-registry-design §8.4): mirror
16
+ // the tmux binding flow so app-server bindings appear in the host
17
+ // discovery index too. Registry write failure is discoverability
18
+ // degradation, not binding failure.
19
+ if value.get("ok").and_then(serde_json::Value::as_bool) == Some(true) {
20
+ register_app_server_binding_in_registry(&args.workspace, args.team.as_deref(), &mut value);
21
+ }
15
22
  Ok(CmdResult::from_json(value, args.json))
16
23
  }
24
+
25
+ fn register_app_server_binding_in_registry(
26
+ workspace: &std::path::Path,
27
+ team: Option<&str>,
28
+ response: &mut serde_json::Value,
29
+ ) {
30
+ let Ok(state) = crate::state::persist::load_runtime_state(workspace) else {
31
+ return;
32
+ };
33
+ let team_key = match team.filter(|t| !t.is_empty()) {
34
+ Some(t) => t.to_string(),
35
+ None => crate::state::projection::team_state_key(&state),
36
+ };
37
+ let receiver = state
38
+ .get("teams")
39
+ .and_then(|v| v.as_object())
40
+ .and_then(|teams| teams.get(&team_key))
41
+ .and_then(|t| t.get("leader_receiver"))
42
+ .cloned();
43
+ let Some(receiver) = receiver else {
44
+ return;
45
+ };
46
+ let transport_kind = receiver
47
+ .get("transport_kind")
48
+ .and_then(serde_json::Value::as_str)
49
+ .unwrap_or("codex_app_server")
50
+ .to_string();
51
+ let channel = receiver
52
+ .get("app_server")
53
+ .cloned()
54
+ .unwrap_or(serde_json::Value::Null);
55
+ let owner_epoch = receiver
56
+ .get("owner_epoch")
57
+ .and_then(serde_json::Value::as_u64)
58
+ .unwrap_or(0);
59
+ let entry = crate::leader::registry::build_entry(
60
+ workspace,
61
+ &team_key,
62
+ &transport_kind,
63
+ channel,
64
+ owner_epoch,
65
+ "attach-app-server-leader",
66
+ chrono::Utc::now().to_rfc3339(),
67
+ );
68
+ let event_log = crate::event_log::EventLog::new(workspace);
69
+ let write_result = crate::leader::registry::write_entry_best_effort(&entry);
70
+ let registry_status = match &write_result {
71
+ Some(path) => {
72
+ let _ = event_log.write(
73
+ crate::leader::registry::EVENT_REGISTERED,
74
+ serde_json::json!({
75
+ "path": path.display().to_string(),
76
+ "team_key": team_key,
77
+ "workspace_hash": entry.workspace_hash,
78
+ "source": "attach-app-server-leader",
79
+ }),
80
+ );
81
+ serde_json::json!({"status": "registered", "path": path.display().to_string()})
82
+ }
83
+ None => {
84
+ let _ = event_log.write(
85
+ crate::leader::registry::EVENT_WRITE_FAILED,
86
+ serde_json::json!({
87
+ "team_key": team_key,
88
+ "source": "attach-app-server-leader",
89
+ }),
90
+ );
91
+ serde_json::json!({"status": "write_failed"})
92
+ }
93
+ };
94
+ if let Some(obj) = response.as_object_mut() {
95
+ obj.insert("leader_registry".to_string(), registry_status);
96
+ }
97
+ }
@@ -122,6 +122,14 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
122
122
  "doctor" => cmd_doctor(&doctor_args(args, cwd)).map(emit_result),
123
123
  "watch" => cmd_watch(&watch_args(args, cwd)).map(emit_result),
124
124
  "sessions" => cmd_sessions(&sessions_args(args, cwd)).map(emit_result),
125
+ // 0.5.9 E7 host-leader-registry: `leaders` is the host-level derived
126
+ // discovery command. It reads ~/.team-agent/leaders, validates each
127
+ // entry against canonical state, and reports LIVE/STALE/AMBIGUOUS
128
+ // status. `--to-leader NAME` on `send` uses the same registry to
129
+ // resolve short/qualified/hash-qualified names to a canonical
130
+ // (workspace, team_key) tuple and delegates to the E6 named-leader
131
+ // delivery path — no separate route authority.
132
+ "leaders" => cmd_leaders(&leaders_args(args, cwd)).map(emit_result),
125
133
  "validate" => cmd_validate(&validate_args(args, cwd)).map(emit_result),
126
134
  "install-skill" => cmd_install_skill(&install_skill_args(args)?).map(emit_result),
127
135
  "profile" => cmd_profile(&profile_args(args, cwd)?).map(emit_result),
@@ -178,6 +186,8 @@ const DISPATCH_COMMANDS: &[&str] = &[
178
186
  "doctor",
179
187
  "watch",
180
188
  "sessions",
189
+ // 0.5.9 E7: host leader discovery command surface.
190
+ "leaders",
181
191
  "validate",
182
192
  "install-skill",
183
193
  "profile",
@@ -692,6 +702,11 @@ struct ParsedArgs {
692
702
  auth_mode: Option<String>,
693
703
  pane: Option<String>,
694
704
  to_name: Option<String>,
705
+ /// E7 (0.5.9 host-leader-registry-design §4.2): `send --to-leader NAME`
706
+ /// resolves NAME through `~/.team-agent/leaders` to a canonical target
707
+ /// (workspace, team_key) then delegates to the E6 leader delivery path.
708
+ /// Mutually exclusive with `--to-name`, TARGET, `--pane`, and `--to`.
709
+ to_leader: Option<String>,
695
710
  provider: Option<String>,
696
711
  socket: Option<String>,
697
712
  thread_id: Option<String>,
@@ -782,6 +797,7 @@ fn parse_args(args: &[String]) -> ParsedArgs {
782
797
  "--auth-mode" => parsed.auth_mode = next_arg(args, &mut i),
783
798
  "--pane" => parsed.pane = next_arg(args, &mut i),
784
799
  "--to-name" => parsed.to_name = next_arg(args, &mut i),
800
+ "--to-leader" => parsed.to_leader = next_arg(args, &mut i),
785
801
  "--provider" => parsed.provider = next_arg(args, &mut i),
786
802
  "--socket" => parsed.socket = next_arg(args, &mut i),
787
803
  "--thread-id" => parsed.thread_id = next_arg(args, &mut i),
@@ -799,6 +815,9 @@ fn parse_args(args: &[String]) -> ParsedArgs {
799
815
  other if other.starts_with("--to-name=") => {
800
816
  parsed.to_name = Some(other.trim_start_matches("--to-name=").to_string());
801
817
  }
818
+ other if other.starts_with("--to-leader=") => {
819
+ parsed.to_leader = Some(other.trim_start_matches("--to-leader=").to_string());
820
+ }
802
821
  other if other.starts_with("--provider=") => {
803
822
  parsed.provider = Some(other.trim_start_matches("--provider=").to_string());
804
823
  }
@@ -933,7 +952,11 @@ fn resolve_cli_path(cwd: &Path, path: &Path) -> PathBuf {
933
952
 
934
953
  fn send_args(args: &[String], cwd: &Path) -> Result<SendArgs, CliError> {
935
954
  let parsed = parse_args(args);
936
- let target = if parsed.targets.is_some() || parsed.pane.is_some() || parsed.to_name.is_some() {
955
+ let target = if parsed.targets.is_some()
956
+ || parsed.pane.is_some()
957
+ || parsed.to_name.is_some()
958
+ || parsed.to_leader.is_some()
959
+ {
937
960
  None
938
961
  } else {
939
962
  parsed.positionals.first().cloned()
@@ -962,6 +985,7 @@ fn send_args(args: &[String], cwd: &Path) -> Result<SendArgs, CliError> {
962
985
  message_id: parsed.message_id,
963
986
  pane: parsed.pane.clone(),
964
987
  to_name: parsed.to_name.clone(),
988
+ to_leader: parsed.to_leader.clone(),
965
989
  })
966
990
  }
967
991
 
@@ -1351,6 +1375,11 @@ fn sessions_args(args: &[String], cwd: &Path) -> SessionsArgs {
1351
1375
  }
1352
1376
  }
1353
1377
 
1378
+ fn leaders_args(args: &[String], _cwd: &Path) -> LeadersArgs {
1379
+ let parsed = parse_args(args);
1380
+ LeadersArgs { json: parsed.json }
1381
+ }
1382
+
1354
1383
  fn validate_args(args: &[String], cwd: &Path) -> ValidateArgs {
1355
1384
  let parsed = parse_args(args);
1356
1385
  ValidateArgs {
@@ -0,0 +1,94 @@
1
+ //! E7 (0.5.9 host-leader-registry-design §4.1): `team-agent leaders` command.
2
+ //!
3
+ //! Enumerates entries under `~/.team-agent/leaders`, re-validates each
4
+ //! against the target workspace's canonical runtime state, and emits a
5
+ //! LIVE / STALE / AMBIGUOUS classification. Leaders-only — worker rows,
6
+ //! task rows, and result rows are deliberately absent from the output.
7
+ //!
8
+ //! The command is a **discovery** surface, not a route authority. Callers
9
+ //! that consume `send_hint` must still re-validate via
10
+ //! `named_address::resolve_name_for_cli` before delivery — this file only
11
+ //! reports what registry sees now.
12
+
13
+ use super::{types::LeadersArgs, CliError, CmdResult};
14
+ use serde_json::{json, Value};
15
+
16
+ /// E7 CLI entry point (E7 test 2 marker: `cmd_leaders`).
17
+ ///
18
+ /// Reads registry entries, canonical-validates each, and groups by
19
+ /// delivery_name to classify each as LIVE / STALE / AMBIGUOUS. Terminal
20
+ /// stale entries (canonical workspace/team gone) are pruned as GC side
21
+ /// effect. Leader-detached-but-team-alive stays visible as STALE so the
22
+ /// operator can decide.
23
+ pub fn cmd_leaders(args: &LeadersArgs) -> Result<CmdResult, CliError> {
24
+ let dir = crate::leader::registry::registry_dir()
25
+ .map(|p| p.display().to_string())
26
+ .unwrap_or_else(|| "~/.team-agent/leaders".to_string());
27
+ let classified = crate::leader::registry::list_validated_with_gc();
28
+ let mut name_counts: std::collections::HashMap<String, Vec<Value>> =
29
+ std::collections::HashMap::new();
30
+ for (entry, _status, _reason) in &classified {
31
+ name_counts
32
+ .entry(entry.delivery_name.clone())
33
+ .or_default()
34
+ .push(json!({
35
+ "name": entry.qualified_name,
36
+ "workspace": entry.workspace.display().to_string(),
37
+ "team_key": entry.team_key,
38
+ "workspace_hash": entry.workspace_hash,
39
+ "stable_qualified_name": entry.stable_qualified_name,
40
+ }));
41
+ }
42
+ let ambiguous_map: std::collections::HashMap<String, Vec<Value>> = name_counts
43
+ .iter()
44
+ .filter(|(_, list)| list.len() > 1)
45
+ .map(|(k, v)| (k.clone(), v.clone()))
46
+ .collect();
47
+ let mut leaders: Vec<Value> = Vec::new();
48
+ for (entry, status, reason) in &classified {
49
+ let final_status = if ambiguous_map.contains_key(&entry.delivery_name) {
50
+ "AMBIGUOUS"
51
+ } else {
52
+ *status
53
+ };
54
+ let send_hint = format!(
55
+ "team-agent send --to-leader {} MESSAGE",
56
+ entry.qualified_name
57
+ );
58
+ leaders.push(json!({
59
+ "name": entry.delivery_name,
60
+ "delivery_name": entry.delivery_name,
61
+ "qualified_name": entry.qualified_name,
62
+ "stable_qualified_name": entry.stable_qualified_name,
63
+ "workspace": entry.workspace.display().to_string(),
64
+ "workspace_hash": entry.workspace_hash,
65
+ "workspace_short": entry.workspace_short,
66
+ "team_key": entry.team_key,
67
+ "transport_kind": entry.transport_kind,
68
+ "owner_epoch": entry.owner_epoch,
69
+ "status": final_status,
70
+ "stale_reason": reason.clone(),
71
+ "send_hint": send_hint,
72
+ }));
73
+ }
74
+ let ambiguous_names: Vec<Value> = ambiguous_map
75
+ .into_iter()
76
+ .map(|(name, candidates)| {
77
+ json!({
78
+ "name": name,
79
+ "candidates": candidates,
80
+ })
81
+ })
82
+ .collect();
83
+ let value = json!({
84
+ "ok": true,
85
+ "registry_dir": dir,
86
+ "leaders": leaders,
87
+ "ambiguous_names": ambiguous_names,
88
+ // Status vocabulary pinned by E7 RED: LIVE / STALE / AMBIGUOUS.
89
+ // "stale_reason" is null for LIVE and carries a machine-readable
90
+ // reason for STALE/AMBIGUOUS entries.
91
+ "status_wire_values": ["LIVE", "STALE", "AMBIGUOUS"],
92
+ });
93
+ Ok(CmdResult::from_json(value, args.json))
94
+ }
@@ -58,6 +58,7 @@ pub mod diagnose;
58
58
  pub mod emit;
59
59
  pub mod helpers;
60
60
  pub mod leader;
61
+ pub mod leaders;
61
62
  pub mod named_address;
62
63
  pub mod profile;
63
64
  pub mod send;
@@ -69,6 +70,7 @@ pub use attach_app_server_leader::*;
69
70
  pub use diagnose::*;
70
71
  pub use emit::*;
71
72
  pub use leader::*;
73
+ pub use leaders::*;
72
74
  pub use named_address::*;
73
75
  pub use profile::*;
74
76
  pub use send::*;
@@ -989,6 +991,14 @@ pub mod lifecycle_port {
989
991
  {
990
992
  let _ = &run_workspace;
991
993
  }
994
+ // E7 unregister-after-success (host-leader-registry-design §14 step 6):
995
+ // Only prune the derived registry entry when the canonical shutdown
996
+ // succeeded and there was no dirty_state / verification degradation.
997
+ // Failed/degraded shutdowns leave the entry STALE so operators can
998
+ // decide.
999
+ if ok && !verification_degraded && !probe_degraded {
1000
+ let _ = crate::cli::leader_port::unregister_after_shutdown_success(&run_workspace, team);
1001
+ }
992
1002
  Ok(response)
993
1003
  }
994
1004
 
@@ -3939,7 +3949,11 @@ pub mod leader_port {
3939
3949
  }
3940
3950
  let result = crate::leader::claim_leader(workspace, team, true)
3941
3951
  .map_err(|e| CliError::Runtime(e.to_string()))?;
3942
- Ok(lease_value(result))
3952
+ let mut value = lease_value(result);
3953
+ if value.get("ok").and_then(Value::as_bool) == Some(true) {
3954
+ register_after_binding_success(workspace, team, "takeover", &mut value);
3955
+ }
3956
+ Ok(value)
3943
3957
  }
3944
3958
  /// `runtime.claim_leader(...)` 的 CLI `--json` 投影(`cmd_claim_leader`;含 inbox_hint)。
3945
3959
  pub fn claim_leader(
@@ -3968,7 +3982,11 @@ pub mod leader_port {
3968
3982
  }
3969
3983
  let result = crate::leader::claim_leader(workspace, team, confirm)
3970
3984
  .map_err(|e| CliError::Runtime(e.to_string()))?;
3971
- Ok(lease_value(result))
3985
+ let mut value = lease_value(result);
3986
+ if value.get("ok").and_then(Value::as_bool) == Some(true) {
3987
+ register_after_binding_success(workspace, team, "claim-leader", &mut value);
3988
+ }
3989
+ Ok(value)
3972
3990
  }
3973
3991
 
3974
3992
  /// `runtime.attach_leader(...)` 的 CLI `--json` 投影。
@@ -3990,6 +4008,11 @@ pub mod leader_port {
3990
4008
  obj.insert("team_key".to_string(), json!(team));
3991
4009
  }
3992
4010
  }
4011
+ // E7 register-after-success: canonical lease write already
4012
+ // finished inside `crate::leader::attach_leader`.
4013
+ if value.get("ok").and_then(Value::as_bool) == Some(true) {
4014
+ register_after_binding_success(workspace, team, "attach-leader", &mut value);
4015
+ }
3993
4016
  Ok(value)
3994
4017
  }
3995
4018
 
@@ -3999,6 +4022,119 @@ pub mod leader_port {
3999
4022
  .map_err(|e| CliError::Runtime(e.to_string()))
4000
4023
  }
4001
4024
 
4025
+ /// E7 (0.5.9 host-leader-registry-design §14 step 3): after a
4026
+ /// canonical binding command succeeds, write a derived discovery
4027
+ /// entry to `~/.team-agent/leaders`. Registry write failure is
4028
+ /// **never** a binding failure — we only append `leader_registry`
4029
+ /// status to the JSON response so operators can see the degradation.
4030
+ ///
4031
+ /// The receiver payload is read from the JUST-persisted runtime
4032
+ /// state so we capture the canonical fields (pane_id, socket,
4033
+ /// owner_epoch) after the lease writers finished.
4034
+ fn register_after_binding_success(
4035
+ workspace: &Path,
4036
+ team: Option<&str>,
4037
+ source: &str,
4038
+ response: &mut Value,
4039
+ ) {
4040
+ let Ok(state) = crate::state::persist::load_runtime_state(workspace) else {
4041
+ return;
4042
+ };
4043
+ let team_key = match team.filter(|t| !t.is_empty()) {
4044
+ Some(t) => t.to_string(),
4045
+ None => crate::state::projection::team_state_key(&state),
4046
+ };
4047
+ let receiver = state
4048
+ .get("teams")
4049
+ .and_then(|v| v.as_object())
4050
+ .and_then(|teams| teams.get(&team_key))
4051
+ .and_then(|t| t.get("leader_receiver"))
4052
+ .or_else(|| state.get("leader_receiver"))
4053
+ .cloned();
4054
+ let Some(receiver) = receiver else {
4055
+ return;
4056
+ };
4057
+ let transport_kind = receiver
4058
+ .get("transport_kind")
4059
+ .and_then(Value::as_str)
4060
+ .unwrap_or("direct_tmux")
4061
+ .to_string();
4062
+ let owner_epoch = receiver
4063
+ .get("owner_epoch")
4064
+ .and_then(Value::as_u64)
4065
+ .or_else(|| {
4066
+ state
4067
+ .get("teams")
4068
+ .and_then(|v| v.as_object())
4069
+ .and_then(|teams| teams.get(&team_key))
4070
+ .and_then(|t| t.get("owner_epoch"))
4071
+ .and_then(Value::as_u64)
4072
+ })
4073
+ .unwrap_or(0);
4074
+ let entry = crate::leader::registry::build_entry(
4075
+ workspace,
4076
+ &team_key,
4077
+ &transport_kind,
4078
+ receiver,
4079
+ owner_epoch,
4080
+ source,
4081
+ chrono::Utc::now().to_rfc3339(),
4082
+ );
4083
+ let event_log = crate::event_log::EventLog::new(workspace);
4084
+ let write_result = crate::leader::registry::write_entry_best_effort(&entry);
4085
+ let registry_status = match &write_result {
4086
+ Some(path) => {
4087
+ let _ = event_log.write(
4088
+ crate::leader::registry::EVENT_REGISTERED,
4089
+ json!({
4090
+ "path": path.display().to_string(),
4091
+ "team_key": team_key,
4092
+ "workspace_hash": entry.workspace_hash,
4093
+ "source": source,
4094
+ "owner_epoch": entry.owner_epoch,
4095
+ }),
4096
+ );
4097
+ json!({"status": "registered", "path": path.display().to_string()})
4098
+ }
4099
+ None => {
4100
+ let _ = event_log.write(
4101
+ crate::leader::registry::EVENT_WRITE_FAILED,
4102
+ json!({
4103
+ "team_key": team_key,
4104
+ "workspace_hash": entry.workspace_hash,
4105
+ "source": source,
4106
+ }),
4107
+ );
4108
+ json!({"status": "write_failed"})
4109
+ }
4110
+ };
4111
+ if let Some(obj) = response.as_object_mut() {
4112
+ obj.insert("leader_registry".to_string(), registry_status);
4113
+ }
4114
+ }
4115
+
4116
+ /// E7 GC hook: called from shutdown/unbind success paths.
4117
+ pub(crate) fn unregister_after_shutdown_success(
4118
+ workspace: &Path,
4119
+ team: Option<&str>,
4120
+ ) -> Option<PathBuf> {
4121
+ let team_key = team.filter(|t| !t.is_empty()).map(str::to_string).or_else(|| {
4122
+ crate::state::persist::load_runtime_state(workspace)
4123
+ .ok()
4124
+ .map(|s| crate::state::projection::team_state_key(&s))
4125
+ })?;
4126
+ let path = crate::leader::registry::unregister_entry(workspace, &team_key)?;
4127
+ let event_log = crate::event_log::EventLog::new(workspace);
4128
+ let _ = event_log.write(
4129
+ crate::leader::registry::EVENT_UNREGISTERED,
4130
+ json!({
4131
+ "path": path.display().to_string(),
4132
+ "team_key": team_key,
4133
+ }),
4134
+ );
4135
+ Some(path)
4136
+ }
4137
+
4002
4138
  fn owner_bind_value(result: crate::leader::OwnerBindResult) -> Value {
4003
4139
  json!({
4004
4140
  "ok": result.ok,