@team-agent/installer 0.5.82 → 0.5.84

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.
@@ -1,220 +1,357 @@
1
- //! ---
2
- //! purpose: `team-agent leaders` 发现面。人读默认面按节点列出 workspace 全路径、
3
- //! team/leader、可复制 `team-agent send '<ws>::<team>/leader' '<消息>'`。
4
- //! contract:
5
- //! provides:
6
- //! - name: cmd_leaders
7
- //! what: --json 保持 E7 字段(含 hash/send_hint); 默认 stdout 只有三要素、不含 hash/channel/pane id
8
- //! boundary:
9
- //! - 不改 send 解析; 不把 --json 字段从默认面漏出去
10
- //! - Leaders-only; worker 行仍缺席
11
- //! ---
1
+ //! `team-agent leaders` host-level discovery and registry maintenance.
12
2
  //!
13
- //! E7 (0.5.9 host-leader-registry-design §4.1): `team-agent leaders` command.
14
- //!
15
- //! Enumerates entries under `~/.team-agent/leaders`, re-validates each
16
- //! against the target workspace's canonical runtime state, and emits a
17
- //! LIVE / STALE / AMBIGUOUS classification. Leaders-only — worker rows,
18
- //! task rows, and result rows are deliberately absent from the output.
19
- //!
20
- //! The command is a **discovery** surface, not a route authority. Callers
21
- //! that consume `send_hint` must still re-validate via
22
- //! `named_address::resolve_name_for_cli` before delivery — this file only
23
- //! reports what registry sees now.
3
+ //! Listing is read-only: it validates the derived registry index without
4
+ //! deleting entries. Explicit `--prune` is the only path that removes entries.
5
+ //! The command is a discovery surface, not a route authority; send callers
6
+ //! continue to re-validate through the named-address resolver.
7
+
8
+ use std::collections::BTreeMap;
24
9
 
25
- use super::{types::LeadersArgs, CliError, CmdResult};
26
10
  use serde_json::{json, Value};
27
11
 
12
+ use super::{
13
+ types::{LeadersArgs, LeadersView},
14
+ CliError, CmdResult, ExitCode,
15
+ };
16
+ use crate::leader::registry::{LeaderRegistryEntry, RegistryPruneItem, RegistryPruneReport};
17
+
18
+ #[derive(Debug, Clone)]
19
+ struct LeaderRow {
20
+ entry: LeaderRegistryEntry,
21
+ status: &'static str,
22
+ stale_reason: Option<String>,
23
+ send_hint: String,
24
+ }
25
+
26
+ /// E7 CLI entry point.
28
27
  ///
29
- /// E7 CLI entry point (E7 test 2 marker: `cmd_leaders`).
30
- ///
31
- /// Reads registry entries, canonical-validates each, and groups by
32
- /// delivery_name to classify each as LIVE / STALE / AMBIGUOUS. Terminal
33
- /// stale entries (canonical workspace/team gone) are pruned as GC side
34
- /// effect. Leader-detached-but-team-alive stays visible as STALE so the
35
- /// operator can decide.
28
+ /// The default view contains only canonical-live entries. `--all` preserves
29
+ /// live and retained stale entries, while `--stale` selects only stale rows.
30
+ /// None of the listing views mutates the host registry.
36
31
  pub fn cmd_leaders(args: &LeadersArgs) -> Result<CmdResult, CliError> {
37
32
  let dir = crate::leader::registry::registry_dir()
38
33
  .map(|p| p.display().to_string())
39
34
  .unwrap_or_else(|| "~/.team-agent/leaders".to_string());
40
- let classified = crate::leader::registry::list_validated_with_gc();
41
- let mut name_counts: std::collections::HashMap<String, Vec<Value>> =
42
- std::collections::HashMap::new();
43
- for (entry, _status, _reason) in &classified {
44
- name_counts
45
- .entry(entry.delivery_name.clone())
46
- .or_default()
47
- .push(json!({
48
- "name": entry.qualified_name,
49
- "workspace": entry.workspace.display().to_string(),
50
- "team_key": entry.team_key,
51
- "workspace_hash": entry.workspace_hash,
52
- "stable_qualified_name": entry.stable_qualified_name,
53
- }));
35
+ if args.prune {
36
+ return cmd_prune(args, &dir);
54
37
  }
55
- let ambiguous_map: std::collections::HashMap<String, Vec<Value>> = name_counts
56
- .iter()
57
- .filter(|(_, list)| list.len() > 1)
58
- .map(|(k, v)| (k.clone(), v.clone()))
59
- .collect();
60
- let mut leaders: Vec<Value> = Vec::new();
61
- for (entry, status, reason) in &classified {
62
- let final_status = if ambiguous_map.contains_key(&entry.delivery_name) {
63
- "AMBIGUOUS"
64
- } else {
65
- *status
66
- };
67
- let send_hint = format!(
68
- "team-agent send --to-leader {} MESSAGE",
69
- entry.qualified_name
70
- );
71
- leaders.push(json!({
72
- "name": entry.delivery_name,
73
- "delivery_name": entry.delivery_name,
74
- "qualified_name": entry.qualified_name,
75
- "stable_qualified_name": entry.stable_qualified_name,
76
- "workspace": entry.workspace.display().to_string(),
77
- "workspace_hash": entry.workspace_hash,
78
- "workspace_short": entry.workspace_short,
79
- "team_key": entry.team_key,
80
- "transport_kind": entry.transport_kind,
81
- "owner_epoch": entry.owner_epoch,
82
- "status": final_status,
83
- "stale_reason": reason.clone(),
84
- "send_hint": send_hint,
85
- }));
38
+
39
+ let mut classified = crate::leader::registry::list_validated_no_gc();
40
+ classified.sort_by(|left, right| {
41
+ left.0
42
+ .workspace
43
+ .cmp(&right.0.workspace)
44
+ .then_with(|| left.0.team_key.cmp(&right.0.team_key))
45
+ });
46
+ let mut active_candidates: BTreeMap<String, Vec<Value>> = BTreeMap::new();
47
+ for (entry, status, _) in &classified {
48
+ if *status == "LIVE" {
49
+ active_candidates
50
+ .entry(entry.delivery_name.clone())
51
+ .or_default()
52
+ .push(candidate_json(entry));
53
+ }
86
54
  }
87
- let ambiguous_names: Vec<Value> = ambiguous_map
88
- .into_iter()
55
+ let ambiguous_names = active_candidates
56
+ .iter()
57
+ .filter(|(_, candidates)| candidates.len() > 1)
89
58
  .map(|(name, candidates)| {
90
59
  json!({
91
60
  "name": name,
92
61
  "candidates": candidates,
93
62
  })
94
63
  })
95
- .collect();
64
+ .collect::<Vec<_>>();
65
+
66
+ let rows = classified
67
+ .into_iter()
68
+ .map(|(entry, status, reason)| {
69
+ let ambiguous = status == "LIVE"
70
+ && active_candidates
71
+ .get(&entry.delivery_name)
72
+ .is_some_and(|candidates| candidates.len() > 1);
73
+ let status = if ambiguous { "AMBIGUOUS" } else { status };
74
+ let stale_reason = if ambiguous {
75
+ Some(crate::leader::registry::REASON_AMBIGUOUS.to_string())
76
+ } else {
77
+ reason
78
+ };
79
+ let send_hint = if status == "STALE" {
80
+ "-".to_string()
81
+ } else {
82
+ send_hint(&entry)
83
+ };
84
+ LeaderRow {
85
+ entry,
86
+ status,
87
+ stale_reason,
88
+ send_hint,
89
+ }
90
+ })
91
+ .collect::<Vec<_>>();
92
+ let rows = filter_rows(rows, args.view, args.query.as_deref());
93
+
94
+ let leaders = rows.iter().map(row_json).collect::<Vec<_>>();
96
95
  let value = json!({
97
96
  "ok": true,
98
97
  "registry_dir": dir,
99
98
  "leaders": leaders,
100
- "ambiguous_names": ambiguous_names,
101
- // Status vocabulary pinned by E7 RED: LIVE / STALE / AMBIGUOUS.
102
- // "stale_reason" is null for LIVE and carries a machine-readable
103
- // reason for STALE/AMBIGUOUS entries.
99
+ "ambiguous_names": if matches!(args.view, LeadersView::Stale) {
100
+ Vec::<Value>::new()
101
+ } else {
102
+ ambiguous_names
103
+ },
104
104
  "status_wire_values": ["LIVE", "STALE", "AMBIGUOUS"],
105
105
  });
106
106
  if args.json {
107
107
  return Ok(CmdResult::from_json(value, true));
108
108
  }
109
- let rows: Vec<(String, String)> = classified
110
- .iter()
111
- .map(|(entry, _, _)| {
112
- (
113
- entry.workspace.display().to_string(),
114
- entry.team_key.clone(),
115
- )
109
+ Ok(CmdResult::human(format_leaders_human(&rows, args.view)))
110
+ }
111
+
112
+ fn candidate_json(entry: &LeaderRegistryEntry) -> Value {
113
+ json!({
114
+ "name": entry.qualified_name,
115
+ "workspace": entry.workspace.display().to_string(),
116
+ "team_key": entry.team_key,
117
+ "workspace_hash": entry.workspace_hash,
118
+ "stable_qualified_name": entry.stable_qualified_name,
119
+ })
120
+ }
121
+
122
+ fn send_hint(entry: &LeaderRegistryEntry) -> String {
123
+ let to = format!("{}::{}/leader", entry.workspace.display(), entry.team_key);
124
+ format!(
125
+ "team-agent send {} {}",
126
+ quote_cli_arg(&to),
127
+ quote_cli_arg("消息")
128
+ )
129
+ }
130
+
131
+ fn row_json(row: &LeaderRow) -> Value {
132
+ json!({
133
+ "name": row.entry.delivery_name,
134
+ "delivery_name": row.entry.delivery_name,
135
+ "qualified_name": row.entry.qualified_name,
136
+ "stable_qualified_name": row.entry.stable_qualified_name,
137
+ "workspace": row.entry.workspace.display().to_string(),
138
+ "workspace_hash": row.entry.workspace_hash,
139
+ "workspace_short": row.entry.workspace_short,
140
+ "team_key": row.entry.team_key,
141
+ "transport_kind": row.entry.transport_kind,
142
+ "owner_epoch": row.entry.owner_epoch,
143
+ "status": row.status,
144
+ "stale_reason": row.stale_reason,
145
+ "send_hint": row.send_hint,
146
+ })
147
+ }
148
+
149
+ fn filter_rows(rows: Vec<LeaderRow>, view: LeadersView, query: Option<&str>) -> Vec<LeaderRow> {
150
+ let query = query.map(str::to_lowercase);
151
+ rows.into_iter()
152
+ .filter(|row| match view {
153
+ LeadersView::Live => row.status == "LIVE" || row.status == "AMBIGUOUS",
154
+ LeadersView::All => true,
155
+ LeadersView::Stale => row.status == "STALE",
116
156
  })
117
- .collect();
118
- Ok(CmdResult::human(format_leaders_human(&rows)))
157
+ .filter(|row| {
158
+ let Some(query) = query.as_deref() else {
159
+ return true;
160
+ };
161
+ [
162
+ row.entry.workspace.display().to_string(),
163
+ row.entry.team_key.clone(),
164
+ row.entry.qualified_name.clone(),
165
+ row.entry.delivery_name.clone(),
166
+ ]
167
+ .iter()
168
+ .any(|field| field.to_lowercase().contains(query))
169
+ })
170
+ .collect()
119
171
  }
120
172
 
121
173
  fn quote_cli_arg(raw: &str) -> String {
122
- if !raw.contains('\'') {
123
- format!("'{raw}'")
124
- } else {
125
- format!("\"{}\"", raw.replace('\\', "\\\\").replace('"', "\\\""))
126
- }
174
+ // POSIX single-quoted strings are literal except for the quote itself;
175
+ // split and escape quotes rather than falling back to unsafe double quotes.
176
+ format!("'{}'", raw.replace('\'', "'\\''"))
127
177
  }
128
178
 
129
- /// Default human face: workspace path, `<team>/leader`, copyable send line.
130
- fn format_leaders_human(rows: &[(String, String)]) -> String {
179
+ /// Human face: status, complete workspace path, canonical team key, and a
180
+ /// copyable SEND command. Tabs keep paths and commands intact without a table
181
+ /// dependency or truncation.
182
+ fn format_leaders_human(rows: &[LeaderRow], view: LeadersView) -> String {
131
183
  if rows.is_empty() {
132
- return "no registered leaders\n".to_string();
184
+ return match view {
185
+ LeadersView::Live => "no live leaders\n".to_string(),
186
+ LeadersView::All => "no registered leaders\n".to_string(),
187
+ LeadersView::Stale => "no stale leaders\n".to_string(),
188
+ };
133
189
  }
134
- let mut out = String::new();
135
- for (workspace, team_key) in rows {
136
- let role = format!("{team_key}/leader");
137
- let to = format!("{workspace}::{role}");
138
- out.push_str(workspace);
139
- out.push('\n');
140
- out.push_str(&role);
141
- out.push('\n');
142
- out.push_str("team-agent send ");
143
- out.push_str(&quote_cli_arg(&to));
144
- out.push(' ');
145
- out.push_str(&quote_cli_arg("消息"));
190
+ let mut out = String::from("STATUS\tWORKSPACE\tTEAM\tSEND\n");
191
+ for row in rows {
192
+ out.push_str(row.status);
193
+ out.push('\t');
194
+ out.push_str(&row.entry.workspace.display().to_string());
195
+ out.push('\t');
196
+ out.push_str(&row.entry.team_key);
197
+ out.push('\t');
198
+ out.push_str(&row.send_hint);
146
199
  out.push('\n');
147
200
  }
148
201
  out
149
202
  }
150
203
 
204
+ fn prune_item_json(item: &RegistryPruneItem) -> Value {
205
+ json!({
206
+ "workspace": item.workspace.display().to_string(),
207
+ "team_key": item.team_key,
208
+ "reason": item.reason,
209
+ })
210
+ }
211
+
212
+ fn prune_json(report: &RegistryPruneReport) -> Value {
213
+ json!({
214
+ "dry_run": report.dry_run,
215
+ "candidates": report.candidates.iter().map(prune_item_json).collect::<Vec<_>>(),
216
+ "removed": report.removed.iter().map(prune_item_json).collect::<Vec<_>>(),
217
+ "kept": report.kept.iter().map(prune_item_json).collect::<Vec<_>>(),
218
+ "skipped": report.skipped.iter().map(prune_item_json).collect::<Vec<_>>(),
219
+ "errors": report.errors.iter().map(prune_item_json).collect::<Vec<_>>(),
220
+ })
221
+ }
222
+
223
+ fn format_prune_human(report: &RegistryPruneReport) -> String {
224
+ let mut out = format!("dry-run: {}\n", report.dry_run);
225
+ for (label, items) in [
226
+ ("candidates", &report.candidates),
227
+ ("removed", &report.removed),
228
+ ("kept", &report.kept),
229
+ ("skipped", &report.skipped),
230
+ ("errors", &report.errors),
231
+ ] {
232
+ out.push_str(&format!("{label}: {}\n", items.len()));
233
+ for item in items {
234
+ out.push_str(" ");
235
+ out.push_str(&item.workspace.display().to_string());
236
+ out.push_str("::");
237
+ out.push_str(&item.team_key);
238
+ out.push_str(" [");
239
+ out.push_str(&item.reason);
240
+ out.push_str("]\n");
241
+ }
242
+ }
243
+ out
244
+ }
245
+
246
+ fn cmd_prune(args: &LeadersArgs, dir: &str) -> Result<CmdResult, CliError> {
247
+ let report = crate::leader::registry::prune_registry(args.dry_run);
248
+ let value = json!({
249
+ "ok": report.errors.is_empty(),
250
+ "registry_dir": dir,
251
+ "prune": prune_json(&report),
252
+ });
253
+ if args.json {
254
+ return Ok(CmdResult::from_json(value, true));
255
+ }
256
+ let mut result = CmdResult::human(format_prune_human(&report));
257
+ if !report.errors.is_empty() {
258
+ result.exit = ExitCode::Error;
259
+ }
260
+ Ok(result)
261
+ }
262
+
151
263
  #[cfg(test)]
152
264
  mod tests {
153
265
  use super::*;
154
266
 
155
- fn sample_human() -> String {
156
- format_leaders_human(&[
157
- (
158
- "/Volumes/nvme/Projects/讨论team-agent".to_string(),
159
- "wiki-team".to_string(),
160
- ),
161
- (
162
- "/Users/alauda/Documents/code/agent前沿探索/多agent协作".to_string(),
163
- "refactor-maintainability".to_string(),
267
+ fn row(workspace: &str, team: &str, status: &'static str, send_hint: &str) -> LeaderRow {
268
+ LeaderRow {
269
+ entry: LeaderRegistryEntry {
270
+ schema_version: crate::leader::registry::REGISTRY_SCHEMA_VERSION,
271
+ delivery_name: team.to_string(),
272
+ qualified_name: format!("workspace/{team}"),
273
+ stable_qualified_name: format!("hash/{team}"),
274
+ aliases: Vec::new(),
275
+ workspace: workspace.into(),
276
+ workspace_hash: "hash".to_string(),
277
+ workspace_short: "workspace".to_string(),
278
+ team_key: team.to_string(),
279
+ transport_kind: "direct_tmux".to_string(),
280
+ channel: Value::Null,
281
+ owner_epoch: 1,
282
+ attached_at: String::new(),
283
+ updated_at: String::new(),
284
+ source: "test".to_string(),
285
+ status: "attached".to_string(),
286
+ },
287
+ status,
288
+ stale_reason: (status == "STALE").then(|| "leader_pane_dead".to_string()),
289
+ send_hint: send_hint.to_string(),
290
+ }
291
+ }
292
+
293
+ #[test]
294
+ fn leaders_human_is_a_status_table_and_stale_has_no_send() {
295
+ let rows = vec![
296
+ row(
297
+ "/Volumes/nvme/Projects/讨论team-agent",
298
+ "wiki-team",
299
+ "LIVE",
300
+ "send-live",
164
301
  ),
165
- ])
302
+ row("/Users/alauda/stale", "old-team", "STALE", "-"),
303
+ ];
304
+ let out = format_leaders_human(&rows, LeadersView::All);
305
+ assert!(out.starts_with("STATUS\tWORKSPACE\tTEAM\tSEND\n"));
306
+ assert!(out.contains("LIVE\t/Volumes/nvme/Projects/讨论team-agent\twiki-team\tsend-live\n"));
307
+ assert!(out.contains("STALE\t/Users/alauda/stale\told-team\t-\n"));
166
308
  }
167
309
 
168
310
  #[test]
169
- fn leaders_human_lists_workspace_team_role_and_copyable_send() {
170
- let out = sample_human();
171
- assert!(
172
- out.contains("team-agent send '/"),
173
- "A: copyable send TO must start with absolute workspace path; got {out:?}"
174
- );
175
- assert!(
176
- out.contains("::wiki-team/leader'"),
177
- "B: send TO must contain ::<team>/<role>; got {out:?}"
178
- );
179
- assert!(
180
- out.contains(
181
- "team-agent send '/Volumes/nvme/Projects/讨论team-agent::wiki-team/leader' '消息'"
311
+ fn stale_rows_are_filtered_from_live_view() {
312
+ let rows = vec![
313
+ row("/live", "live", "LIVE", "send"),
314
+ row("/stale", "stale", "STALE", "-"),
315
+ ];
316
+ let rows = filter_rows(rows, LeadersView::Live, None);
317
+ assert_eq!(rows.len(), 1);
318
+ assert_eq!(rows[0].entry.team_key, "live");
319
+ }
320
+
321
+ #[test]
322
+ fn quote_cli_arg_escapes_single_quotes_without_shell_expansion() {
323
+ assert_eq!(quote_cli_arg("a'b $HOME `date`"), "'a'\\''b $HOME `date`'");
324
+ }
325
+
326
+ #[test]
327
+ fn search_matches_selected_fields_case_insensitively() {
328
+ let rows = vec![
329
+ row(
330
+ "/Volumes/nvme/Projects/讨论team-agent",
331
+ "Wiki-Team",
332
+ "LIVE",
333
+ "send",
182
334
  ),
183
- "C: complete copyable send line; got {out:?}"
184
- );
185
- assert!(out.contains("/Volumes/nvme/Projects/讨论team-agent\nwiki-team/leader\n"));
186
- assert!(out.contains(
187
- "/Users/alauda/Documents/code/agent前沿探索/多agent协作\nrefactor-maintainability/leader\n"
188
- ));
189
- assert!(out.contains(
190
- "team-agent send '/Users/alauda/Documents/code/agent前沿探索/多agent协作::refactor-maintainability/leader' '消息'"
191
- ));
335
+ row("/Users/alauda/stale", "old-team", "STALE", "-"),
336
+ ];
337
+ let rows = filter_rows(rows, LeadersView::All, Some("WIKI-"));
338
+ assert_eq!(rows.len(), 1);
339
+ assert_eq!(rows[0].entry.team_key, "Wiki-Team");
192
340
  }
193
341
 
194
342
  #[test]
195
- fn leaders_human_omits_hash_channel_and_pane_id_fields() {
196
- let out = sample_human();
197
- for noise in [
198
- "workspace_hash",
199
- "stable_qualified_name",
200
- "channel_id",
201
- "\"channel\":",
202
- "pane_id",
203
- ] {
204
- assert!(
205
- !out.contains(noise),
206
- "D: default surface must not print {noise}; got {out:?}"
207
- );
208
- }
209
- let empty = format_leaders_human(&[]);
210
- for noise in [
211
- "workspace_hash",
212
- "stable_qualified_name",
213
- "channel_id",
214
- "\"channel\":",
215
- "pane_id",
216
- ] {
217
- assert!(!empty.contains(noise), "empty surface leaked {noise}");
218
- }
343
+ fn empty_view_names_are_explicit() {
344
+ assert_eq!(
345
+ format_leaders_human(&[], LeadersView::Live),
346
+ "no live leaders\n"
347
+ );
348
+ assert_eq!(
349
+ format_leaders_human(&[], LeadersView::Stale),
350
+ "no stale leaders\n"
351
+ );
352
+ assert_eq!(
353
+ format_leaders_human(&[], LeadersView::All),
354
+ "no registered leaders\n"
355
+ );
219
356
  }
220
357
  }