@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +193 -14
- package/crates/team-agent/src/cli/leaders.rs +304 -167
- package/crates/team-agent/src/cli/models.rs +231 -24
- package/crates/team-agent/src/cli/spec.rs +2 -2
- package/crates/team-agent/src/cli/types.rs +12 -1
- package/crates/team-agent/src/leader/registry.rs +295 -64
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +16 -18
- package/crates/team-agent/src/mcp_server/tests/tools.rs +31 -20
- package/crates/team-agent/src/mcp_server/tests/wire.rs +30 -0
- package/crates/team-agent/src/mcp_server/tools.rs +113 -11
- package/crates/team-agent/src/mcp_server/wire.rs +20 -1
- package/crates/team-agent/src/state/persist.rs +50 -1
- package/package.json +4 -4
|
@@ -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
|
-
//!
|
|
14
|
-
//!
|
|
15
|
-
//!
|
|
16
|
-
//!
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
///
|
|
30
|
-
///
|
|
31
|
-
///
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
88
|
-
.
|
|
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":
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
.
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
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
|
-
///
|
|
130
|
-
|
|
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
|
|
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::
|
|
135
|
-
for
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
out.push_str(workspace);
|
|
139
|
-
out.push('\
|
|
140
|
-
out.push_str(&
|
|
141
|
-
out.push('\
|
|
142
|
-
out.push_str(
|
|
143
|
-
out.push_str("e_cli_arg(&to));
|
|
144
|
-
out.push(' ');
|
|
145
|
-
out.push_str("e_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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
170
|
-
let
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
"
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
"
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
"
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
}
|