@team-agent/installer 0.5.107 → 0.5.109
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/adapters.rs +1 -1
- package/crates/team-agent/src/cli/emit.rs +3 -3
- package/crates/team-agent/src/cli/mod.rs +87 -4
- package/crates/team-agent/src/cli/models.rs +252 -3
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/status_port/brief.rs +379 -70
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +184 -0
- package/crates/team-agent/src/cli/tests/status_brief.rs +624 -0
- package/crates/team-agent/src/provider/model_catalog.rs +343 -5
- package/crates/team-agent/src/provider/testdata/cursor-list-models-native.stdout +250 -0
- package/crates/team-agent/src/provider/testdata/grok-models-authenticated.stdout +9 -0
- package/crates/team-agent/src/provider/testdata/grok-models-unauthenticated.stdout +7 -0
- package/crates/team-agent/src/tmux_backend.rs +99 -2
- package/package.json +4 -4
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//! status brief contract.
|
|
6
6
|
|
|
7
7
|
use serde_json::{json, Map, Value};
|
|
8
|
-
use std::collections::BTreeMap;
|
|
8
|
+
use std::collections::{BTreeMap, BTreeSet};
|
|
9
9
|
use std::path::Path;
|
|
10
10
|
use std::process::Command;
|
|
11
11
|
|
|
@@ -117,6 +117,14 @@ struct BriefNode {
|
|
|
117
117
|
tmux_command: Option<String>,
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
121
|
+
struct AttachContext {
|
|
122
|
+
endpoint: String,
|
|
123
|
+
session: String,
|
|
124
|
+
window: String,
|
|
125
|
+
pane: String,
|
|
126
|
+
}
|
|
127
|
+
|
|
120
128
|
#[derive(Debug)]
|
|
121
129
|
struct ProbeResult {
|
|
122
130
|
nodes: Vec<ProbeNode>,
|
|
@@ -150,34 +158,45 @@ pub(crate) fn status_brief_scoped(
|
|
|
150
158
|
state: &Value,
|
|
151
159
|
agent: Option<&str>,
|
|
152
160
|
) -> Value {
|
|
153
|
-
let
|
|
154
|
-
let probes = probe_registered_endpoints(®istered);
|
|
155
|
-
let nodes = registered
|
|
156
|
-
.iter()
|
|
157
|
-
.map(|node| project_node(node, probes.get(node.endpoint.as_deref().unwrap_or(""))))
|
|
158
|
-
.collect::<Vec<_>>();
|
|
161
|
+
let nodes = project_status_nodes(state, agent);
|
|
159
162
|
json!({
|
|
160
|
-
"nodes": nodes
|
|
163
|
+
"nodes": nodes
|
|
164
|
+
.into_iter()
|
|
165
|
+
.map(|(node, _)| node.into_json())
|
|
166
|
+
.collect::<Vec<_>>()
|
|
161
167
|
})
|
|
162
168
|
}
|
|
163
169
|
|
|
164
|
-
/// Human output
|
|
165
|
-
///
|
|
166
|
-
/// diagnostic or an inferred value.
|
|
170
|
+
/// Human output keeps the legacy single-node form and groups shared attach
|
|
171
|
+
/// context only when multiple nodes make that repetition useful.
|
|
167
172
|
pub(crate) fn format_status_brief(
|
|
168
|
-
|
|
173
|
+
_workspace: &Path,
|
|
169
174
|
state: &Value,
|
|
170
175
|
agent: Option<&str>,
|
|
171
176
|
) -> String {
|
|
172
|
-
let
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
177
|
+
let nodes = project_status_nodes(state, agent);
|
|
178
|
+
if nodes.len() <= 1 {
|
|
179
|
+
return nodes
|
|
180
|
+
.into_iter()
|
|
181
|
+
.filter_map(|(node, _)| format_brief_node(&node.into_json()))
|
|
182
|
+
.collect::<Vec<_>>()
|
|
183
|
+
.join("\n");
|
|
184
|
+
}
|
|
185
|
+
format_multi_node_brief(nodes)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
fn project_status_nodes(
|
|
189
|
+
state: &Value,
|
|
190
|
+
agent: Option<&str>,
|
|
191
|
+
) -> Vec<(BriefNode, Option<AttachContext>)> {
|
|
192
|
+
let registered = registered_nodes(state, agent);
|
|
193
|
+
let probes = probe_registered_endpoints(®istered);
|
|
194
|
+
registered
|
|
195
|
+
.iter()
|
|
196
|
+
.map(|node| {
|
|
197
|
+
project_node_with_attach(node, probes.get(node.endpoint.as_deref().unwrap_or("")))
|
|
198
|
+
})
|
|
199
|
+
.collect()
|
|
181
200
|
}
|
|
182
201
|
|
|
183
202
|
pub(crate) fn registered_agent_exists(state: &Value, agent: &str) -> bool {
|
|
@@ -322,6 +341,86 @@ fn format_brief_node(value: &Value) -> Option<String> {
|
|
|
322
341
|
))
|
|
323
342
|
}
|
|
324
343
|
|
|
344
|
+
fn format_multi_node_brief(nodes: Vec<(BriefNode, Option<AttachContext>)>) -> String {
|
|
345
|
+
let mut groups = Vec::<(String, String)>::new();
|
|
346
|
+
for (_, context) in &nodes {
|
|
347
|
+
let Some(context) = context else { continue };
|
|
348
|
+
if !groups.iter().any(|(endpoint, session)| {
|
|
349
|
+
endpoint == &context.endpoint && session == &context.session
|
|
350
|
+
}) {
|
|
351
|
+
groups.push((context.endpoint.clone(), context.session.clone()));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let mut lines = groups
|
|
356
|
+
.iter()
|
|
357
|
+
.enumerate()
|
|
358
|
+
.map(|(index, (endpoint, session))| {
|
|
359
|
+
format_attach_template(index + 1, endpoint, session)
|
|
360
|
+
})
|
|
361
|
+
.collect::<Vec<_>>();
|
|
362
|
+
if !groups.is_empty() {
|
|
363
|
+
lines.push(
|
|
364
|
+
"TMUX templates: replace <target> with the window.pane part of the matching ATTACH value."
|
|
365
|
+
.to_string(),
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
lines.push(
|
|
369
|
+
"NAME\tPROVIDER\tMODEL\tEFFORT\tRUNTIME_STATUS\tACTIVITY\tHEALTH\tSESSION_NAME\tATTACH"
|
|
370
|
+
.to_string(),
|
|
371
|
+
);
|
|
372
|
+
for (node, context) in nodes {
|
|
373
|
+
let attach = context
|
|
374
|
+
.as_ref()
|
|
375
|
+
.and_then(|context| {
|
|
376
|
+
groups
|
|
377
|
+
.iter()
|
|
378
|
+
.position(|(endpoint, session)| {
|
|
379
|
+
endpoint == &context.endpoint && session == &context.session
|
|
380
|
+
})
|
|
381
|
+
.map(|index| format!("{}:{}.{}", index + 1, context.window, context.pane))
|
|
382
|
+
})
|
|
383
|
+
.unwrap_or_else(|| "null".to_string());
|
|
384
|
+
lines.push(
|
|
385
|
+
[
|
|
386
|
+
escape_human_cell(&node.name),
|
|
387
|
+
escape_human_cell(&node.provider),
|
|
388
|
+
escape_human_cell(node.model.as_deref().unwrap_or("null")),
|
|
389
|
+
escape_human_cell(node.effort.as_deref().unwrap_or("null")),
|
|
390
|
+
escape_human_cell(&node.runtime_status),
|
|
391
|
+
escape_human_cell(&node.activity),
|
|
392
|
+
escape_human_cell(&node.health),
|
|
393
|
+
escape_human_cell(node.session_name.as_deref().unwrap_or("null")),
|
|
394
|
+
escape_human_cell(&attach),
|
|
395
|
+
]
|
|
396
|
+
.join("\t"),
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
lines.join("\n")
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
fn format_attach_template(group: usize, endpoint: &str, session: &str) -> String {
|
|
403
|
+
let flag = if Path::new(endpoint).is_absolute() {
|
|
404
|
+
"-S"
|
|
405
|
+
} else {
|
|
406
|
+
"-L"
|
|
407
|
+
};
|
|
408
|
+
let target = format!("{session}:<target>");
|
|
409
|
+
format!(
|
|
410
|
+
"TMUX[{group}] tmux {flag} {} attach -t {}",
|
|
411
|
+
shell_quote(endpoint),
|
|
412
|
+
shell_quote(&target)
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
fn escape_human_cell(value: &str) -> String {
|
|
417
|
+
value
|
|
418
|
+
.replace('\\', "\\\\")
|
|
419
|
+
.replace('\t', "\\t")
|
|
420
|
+
.replace('\n', "\\n")
|
|
421
|
+
.replace('\r', "\\r")
|
|
422
|
+
}
|
|
423
|
+
|
|
325
424
|
fn display_optional(value: &Value) -> String {
|
|
326
425
|
value
|
|
327
426
|
.as_str()
|
|
@@ -449,11 +548,37 @@ fn sample_native_endpoint(
|
|
|
449
548
|
registered: &[RegisteredNode],
|
|
450
549
|
) -> Option<ProbeResult> {
|
|
451
550
|
let output = run_native_tmux_list_panes(endpoint)?;
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
551
|
+
Some(sample_native_panes(&output, endpoint, registered, process_snapshots))
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
fn sample_native_panes(
|
|
555
|
+
output: &str,
|
|
556
|
+
endpoint: &str,
|
|
557
|
+
registered: &[RegisteredNode],
|
|
558
|
+
snapshots: impl FnOnce(&BTreeSet<u32>) -> BTreeMap<u32, ProcessSnapshot>,
|
|
559
|
+
) -> ProbeResult {
|
|
560
|
+
let panes = output.lines().filter_map(native_pane_fields).collect::<Vec<_>>();
|
|
561
|
+
let pids = panes.iter().filter_map(|fields| native_pane_pid(fields[3])).collect::<BTreeSet<_>>();
|
|
562
|
+
let processes = if pids.is_empty() { BTreeMap::new() } else { snapshots(&pids) };
|
|
563
|
+
let nodes = panes.into_iter()
|
|
564
|
+
.filter_map(|fields| parse_native_pane(fields, endpoint, registered, &processes))
|
|
565
|
+
.collect();
|
|
566
|
+
ProbeResult { nodes }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
fn native_pane_fields(line: &str) -> Option<[&str; 5]> {
|
|
570
|
+
let mut fields = line.split('\t');
|
|
571
|
+
let session = fields.next()?;
|
|
572
|
+
let window = fields.next()?;
|
|
573
|
+
let pane = fields.next()?;
|
|
574
|
+
let pid = fields.next().unwrap_or_default();
|
|
575
|
+
let command = fields.next().unwrap_or_default();
|
|
576
|
+
if session.is_empty() || window.is_empty() || pane.is_empty() { return None; }
|
|
577
|
+
Some([session, window, pane, pid, command])
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
fn native_pane_pid(pid: &str) -> Option<u32> {
|
|
581
|
+
pid.parse::<u32>().ok().filter(|pid| *pid > 0 && *pid <= i32::MAX as u32)
|
|
457
582
|
}
|
|
458
583
|
|
|
459
584
|
fn run_native_tmux_list_panes(endpoint: &str) -> Option<String> {
|
|
@@ -469,27 +594,23 @@ fn run_native_tmux_list_panes(endpoint: &str) -> Option<String> {
|
|
|
469
594
|
}
|
|
470
595
|
|
|
471
596
|
fn parse_native_pane(
|
|
472
|
-
|
|
597
|
+
fields: [&str; 5],
|
|
473
598
|
endpoint: &str,
|
|
474
599
|
registered: &[RegisteredNode],
|
|
600
|
+
processes: &BTreeMap<u32, ProcessSnapshot>,
|
|
475
601
|
) -> Option<ProbeNode> {
|
|
476
|
-
let
|
|
477
|
-
let session =
|
|
478
|
-
let window =
|
|
479
|
-
let pane =
|
|
480
|
-
let pid = fields.next().filter(|value| !value.is_empty());
|
|
481
|
-
let command = fields.next().unwrap_or_default();
|
|
482
|
-
if session.is_empty() || window.is_empty() || pane.is_empty() {
|
|
483
|
-
return None;
|
|
484
|
-
}
|
|
602
|
+
let [session, window, pane, pid, command] = fields;
|
|
603
|
+
let session = session.to_string();
|
|
604
|
+
let window = window.to_string();
|
|
605
|
+
let pane = pane.to_string();
|
|
485
606
|
let matched = registered.iter().find(|node| {
|
|
486
607
|
node.endpoint.as_deref() == Some(endpoint)
|
|
487
608
|
&& node.session.as_deref() == Some(session.as_str())
|
|
488
609
|
&& node.window.as_deref().is_none_or(|value| value == window)
|
|
489
610
|
&& node.pane.as_deref() == Some(pane.as_str())
|
|
490
611
|
});
|
|
491
|
-
if
|
|
492
|
-
let snapshot =
|
|
612
|
+
if !pid.is_empty() {
|
|
613
|
+
let snapshot = processes.get(&native_pane_pid(pid)?)?;
|
|
493
614
|
if snapshot.zombie {
|
|
494
615
|
return None;
|
|
495
616
|
}
|
|
@@ -521,22 +642,35 @@ struct ProcessSnapshot {
|
|
|
521
642
|
zombie: bool,
|
|
522
643
|
}
|
|
523
644
|
|
|
524
|
-
fn
|
|
525
|
-
let
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
645
|
+
fn process_snapshot_command(pids: &BTreeSet<u32>) -> Command {
|
|
646
|
+
let pids = pids.iter().map(u32::to_string).collect::<Vec<_>>().join(",");
|
|
647
|
+
let mut command = Command::new("ps");
|
|
648
|
+
command.args(["-o", "pid=,ppid=,stat=,comm=", "-p", &pids]);
|
|
649
|
+
command
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
fn process_snapshots(pids: &BTreeSet<u32>) -> BTreeMap<u32, ProcessSnapshot> {
|
|
653
|
+
if pids.is_empty() { return BTreeMap::new(); }
|
|
654
|
+
let Ok(output) = process_snapshot_command(pids).output() else { return BTreeMap::new(); };
|
|
655
|
+
if !output.status.success() { return BTreeMap::new(); }
|
|
656
|
+
parse_process_snapshots(&output.stdout, pids)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
fn parse_process_snapshots(bytes: &[u8], requested: &BTreeSet<u32>) -> BTreeMap<u32, ProcessSnapshot> {
|
|
660
|
+
let mut snapshots = BTreeMap::<u32, ProcessSnapshot>::new();
|
|
661
|
+
let output = String::from_utf8_lossy(bytes);
|
|
662
|
+
for line in output.lines() {
|
|
663
|
+
let mut fields = line.split_whitespace();
|
|
664
|
+
let Some(pid) = fields.next().and_then(native_pane_pid) else { continue; };
|
|
665
|
+
if !requested.contains(&pid) { continue; }
|
|
666
|
+
let Some(_ppid) = fields.next() else { continue; };
|
|
667
|
+
let Some(stat) = fields.next() else { continue; };
|
|
668
|
+
let zombie = stat.contains('Z');
|
|
669
|
+
snapshots.entry(pid)
|
|
670
|
+
.and_modify(|snapshot| snapshot.zombie |= zombie)
|
|
671
|
+
.or_insert(ProcessSnapshot { zombie });
|
|
531
672
|
}
|
|
532
|
-
|
|
533
|
-
let stat = output_text
|
|
534
|
-
.split_whitespace()
|
|
535
|
-
.nth(2)
|
|
536
|
-
.unwrap_or_default();
|
|
537
|
-
Some(ProcessSnapshot {
|
|
538
|
-
zombie: stat.contains('Z'),
|
|
539
|
-
})
|
|
673
|
+
snapshots
|
|
540
674
|
}
|
|
541
675
|
|
|
542
676
|
#[cfg(test)]
|
|
@@ -1051,14 +1185,22 @@ fn non_empty(value: &Value, key: &str) -> Option<String> {
|
|
|
1051
1185
|
.map(str::to_string)
|
|
1052
1186
|
}
|
|
1053
1187
|
|
|
1188
|
+
#[cfg(test)]
|
|
1054
1189
|
fn project_node(node: &RegisteredNode, probe: Option<&Option<ProbeResult>>) -> BriefNode {
|
|
1190
|
+
project_node_with_attach(node, probe).0
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
fn project_node_with_attach(
|
|
1194
|
+
node: &RegisteredNode,
|
|
1195
|
+
probe: Option<&Option<ProbeResult>>,
|
|
1196
|
+
) -> (BriefNode, Option<AttachContext>) {
|
|
1055
1197
|
let stopped = matches!(
|
|
1056
1198
|
node.lifecycle.as_deref(),
|
|
1057
1199
|
Some("stopped" | "done" | "failed" | "error" | "terminated")
|
|
1058
1200
|
);
|
|
1059
1201
|
let matched = match_result(node, probe);
|
|
1060
1202
|
if stopped {
|
|
1061
|
-
|
|
1203
|
+
let projected = match matched {
|
|
1062
1204
|
MatchResult::Missing => BriefNode {
|
|
1063
1205
|
name: node.name.clone(),
|
|
1064
1206
|
provider: node.provider.clone(),
|
|
@@ -1072,14 +1214,15 @@ fn project_node(node: &RegisteredNode, probe: Option<&Option<ProbeResult>>) -> B
|
|
|
1072
1214
|
},
|
|
1073
1215
|
MatchResult::Unique(_) | MatchResult::Ambiguous => BriefNode::unknown(node),
|
|
1074
1216
|
};
|
|
1217
|
+
return (projected, None);
|
|
1075
1218
|
}
|
|
1076
1219
|
let MatchResult::Unique(observed) = matched else {
|
|
1077
|
-
return BriefNode::unknown(node);
|
|
1220
|
+
return (BriefNode::unknown(node), None);
|
|
1078
1221
|
};
|
|
1079
1222
|
if !node.provider.eq_ignore_ascii_case("unknown")
|
|
1080
1223
|
&& !node.provider.eq_ignore_ascii_case(&observed.provider)
|
|
1081
1224
|
{
|
|
1082
|
-
return BriefNode::unknown(node);
|
|
1225
|
+
return (BriefNode::unknown(node), None);
|
|
1083
1226
|
}
|
|
1084
1227
|
let pi_channel = observed.provider.eq_ignore_ascii_case("pi")
|
|
1085
1228
|
&& observed.evidence_method.as_deref() == Some("pi_activity_channel");
|
|
@@ -1097,21 +1240,31 @@ fn project_node(node: &RegisteredNode, probe: Option<&Option<ProbeResult>>) -> B
|
|
|
1097
1240
|
} else {
|
|
1098
1241
|
valid_health(&observed.health)
|
|
1099
1242
|
};
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1243
|
+
let tmux_command = tmux_command(node, observed);
|
|
1244
|
+
let attach = tmux_command.as_ref().map(|_| AttachContext {
|
|
1245
|
+
endpoint: observed.socket.clone(),
|
|
1246
|
+
session: observed.session.clone(),
|
|
1247
|
+
window: observed.window.clone(),
|
|
1248
|
+
pane: observed.pane.clone(),
|
|
1249
|
+
});
|
|
1250
|
+
(
|
|
1251
|
+
BriefNode {
|
|
1252
|
+
name: node.name.clone(),
|
|
1253
|
+
provider: if node.provider.eq_ignore_ascii_case("unknown") {
|
|
1254
|
+
observed.provider.clone()
|
|
1255
|
+
} else {
|
|
1256
|
+
node.provider.clone()
|
|
1257
|
+
},
|
|
1258
|
+
model: node.model.clone(),
|
|
1259
|
+
effort: node.effort.clone(),
|
|
1260
|
+
runtime_status: "running".to_string(),
|
|
1261
|
+
activity: activity.to_string(),
|
|
1262
|
+
health: health.to_string(),
|
|
1263
|
+
session_name: observed.session_name.clone(),
|
|
1264
|
+
tmux_command,
|
|
1106
1265
|
},
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
runtime_status: "running".to_string(),
|
|
1110
|
-
activity: activity.to_string(),
|
|
1111
|
-
health: health.to_string(),
|
|
1112
|
-
session_name: observed.session_name.clone(),
|
|
1113
|
-
tmux_command: tmux_command(node, observed),
|
|
1114
|
-
}
|
|
1266
|
+
attach,
|
|
1267
|
+
)
|
|
1115
1268
|
}
|
|
1116
1269
|
|
|
1117
1270
|
fn match_result<'a>(
|
|
@@ -1217,6 +1370,162 @@ impl BriefNode {
|
|
|
1217
1370
|
}
|
|
1218
1371
|
}
|
|
1219
1372
|
|
|
1373
|
+
#[cfg(test)]
|
|
1374
|
+
mod native_ps_batch_regressions {
|
|
1375
|
+
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
1376
|
+
use super::*;
|
|
1377
|
+
|
|
1378
|
+
fn registered(endpoint: &str, pane: &str) -> RegisteredNode {
|
|
1379
|
+
RegisteredNode {
|
|
1380
|
+
name: format!("{endpoint}-{pane}"),
|
|
1381
|
+
provider: "pi".into(),
|
|
1382
|
+
model: Some("fixture-model".into()),
|
|
1383
|
+
effort: Some("high".into()),
|
|
1384
|
+
endpoint: Some(endpoint.into()),
|
|
1385
|
+
session: Some("hp".into()),
|
|
1386
|
+
window: Some("w".into()),
|
|
1387
|
+
pane: Some(pane.into()),
|
|
1388
|
+
lifecycle: None,
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
#[test]
|
|
1393
|
+
fn eight_panes_use_one_endpoint_query() {
|
|
1394
|
+
let registered = (0..8).map(|i| registered("e1", &format!("%{i}"))).collect::<Vec<_>>();
|
|
1395
|
+
let panes = (0..8).map(|i| format!("hp\tw\t%{i}\t{}\tpi\n", 100 + i)).collect::<String>();
|
|
1396
|
+
let rows = (0..8).map(|i| format!("{} 1 S pi\n", 100 + i)).collect::<String>();
|
|
1397
|
+
let mut calls = Vec::new();
|
|
1398
|
+
let result = sample_native_panes(&panes, "e1", ®istered, |pids| {
|
|
1399
|
+
calls.push(pids.iter().copied().collect::<Vec<_>>());
|
|
1400
|
+
parse_process_snapshots(rows.as_bytes(), pids)
|
|
1401
|
+
});
|
|
1402
|
+
assert_eq!(calls, vec![(100..108).collect::<Vec<_>>()]);
|
|
1403
|
+
assert_eq!(result.nodes.len(), 8);
|
|
1404
|
+
assert!(result.nodes.iter().all(|node| node.provider == "pi" && node.activity == "working" && node.health == "normal"));
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
#[test]
|
|
1408
|
+
fn pid_deduplication_and_command_preserve_the_privacy_whitelist() {
|
|
1409
|
+
let mut calls = Vec::new();
|
|
1410
|
+
let result = sample_native_panes("hp\tw\t%1\t11\tsh\nhp\tw\t%2\t011\tsh\n", "e1", &[], |pids| {
|
|
1411
|
+
calls.push(pids.iter().copied().collect::<Vec<_>>());
|
|
1412
|
+
parse_process_snapshots(b"11 1 S sh\n", pids)
|
|
1413
|
+
});
|
|
1414
|
+
assert_eq!(calls, vec![vec![11]]);
|
|
1415
|
+
assert_eq!(result.nodes.len(), 2);
|
|
1416
|
+
let pids = BTreeSet::from([22, 11]);
|
|
1417
|
+
let command = process_snapshot_command(&pids);
|
|
1418
|
+
assert_eq!(command.get_program(), "ps");
|
|
1419
|
+
assert_eq!(
|
|
1420
|
+
command.get_args().map(|arg| arg.to_string_lossy().into_owned()).collect::<Vec<_>>(),
|
|
1421
|
+
vec!["-o", "pid=,ppid=,stat=,comm=", "-p", "11,22"]
|
|
1422
|
+
);
|
|
1423
|
+
let command = process_snapshot_command(&BTreeSet::from([11]));
|
|
1424
|
+
assert_eq!(command.get_args().last().unwrap(), "11");
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
#[cfg(unix)]
|
|
1428
|
+
#[test]
|
|
1429
|
+
fn native_ps_partial_selection_preserves_a_live_pid() {
|
|
1430
|
+
let pid = std::process::id();
|
|
1431
|
+
let requested = BTreeSet::from([pid, i32::MAX as u32]);
|
|
1432
|
+
let command = process_snapshot_command(&requested);
|
|
1433
|
+
// Absolute ps avoids unrelated PATH-fixture mutation in parallel unit tests.
|
|
1434
|
+
let output = Command::new("/bin/ps").args(command.get_args()).output().unwrap();
|
|
1435
|
+
assert!(output.status.success(), "partial PID selection must not poison the live row");
|
|
1436
|
+
let snapshots = parse_process_snapshots(&output.stdout, &requested);
|
|
1437
|
+
assert!(!snapshots.get(&pid).unwrap().zombie);
|
|
1438
|
+
assert!(!snapshots.contains_key(&(i32::MAX as u32)));
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
#[test]
|
|
1442
|
+
fn single_pane_keeps_its_native_health_and_activity() {
|
|
1443
|
+
let registered = vec![registered("e1", "%1")];
|
|
1444
|
+
let result = sample_native_panes("hp\tw\t%1\t11\tsh\n", "e1", ®istered, |pids| {
|
|
1445
|
+
assert_eq!(pids, &BTreeSet::from([11]));
|
|
1446
|
+
parse_process_snapshots(b"11 1 S sh\n", pids)
|
|
1447
|
+
});
|
|
1448
|
+
assert_eq!(result.nodes.len(), 1);
|
|
1449
|
+
assert_eq!(result.nodes[0].provider, "pi");
|
|
1450
|
+
assert_eq!(result.nodes[0].activity, "idle");
|
|
1451
|
+
assert_eq!(result.nodes[0].health, "normal");
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
#[test]
|
|
1455
|
+
fn missing_rows_and_zombies_omit_only_their_panes() {
|
|
1456
|
+
let panes = "hp\tw\t%1\t11\tsh\nhp\tw\t%2\t22\tpi\nhp\tw\t%3\t33\tpi\nhp\tw\t%4\t\tsh\n";
|
|
1457
|
+
let result = sample_native_panes(panes, "e1", &[], |pids| {
|
|
1458
|
+
parse_process_snapshots(b"22 1 Z+ dead\n11 1 S sh\n999 1 S unrelated\n33 1\n", pids)
|
|
1459
|
+
});
|
|
1460
|
+
assert_eq!(result.nodes.iter().map(|node| node.pane.as_str()).collect::<Vec<_>>(), vec!["%1", "%4"]);
|
|
1461
|
+
assert!(result.nodes.iter().all(|node| node.activity == "idle" && node.health == "normal"));
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
#[test]
|
|
1465
|
+
fn empty_or_invalid_pid_sets_do_not_run_ps() {
|
|
1466
|
+
let panes = "hp\tw\t%1\t\tsh\nhp\tw\t%2\t0\tsh\nhp\tw\t%3\t1,2\tsh\nhp\tw\t%4\t4294967295\tsh\n\tw\t%5\t99\tsh\n";
|
|
1467
|
+
let result = sample_native_panes(panes, "e1", &[], |_| panic!("no valid PID means no ps query"));
|
|
1468
|
+
assert_eq!(result.nodes.len(), 1);
|
|
1469
|
+
assert_eq!(result.nodes[0].pane, "%1");
|
|
1470
|
+
assert_eq!(result.nodes[0].health, "normal");
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
#[test]
|
|
1474
|
+
fn snapshot_failure_and_endpoint_results_are_not_shared() {
|
|
1475
|
+
let registered = vec![registered("e1", "%1"), registered("e2", "%1")];
|
|
1476
|
+
let mut calls = Vec::new();
|
|
1477
|
+
let first = sample_native_panes("hp\tw\t%1\t88\tsh\n", "e1", ®istered, |pids| {
|
|
1478
|
+
calls.push(("e1", pids.iter().copied().collect::<Vec<_>>()));
|
|
1479
|
+
BTreeMap::new()
|
|
1480
|
+
});
|
|
1481
|
+
let second = sample_native_panes("hp\tw\t%1\t88\tsh\n", "e2", ®istered, |pids| {
|
|
1482
|
+
calls.push(("e2", pids.iter().copied().collect::<Vec<_>>()));
|
|
1483
|
+
parse_process_snapshots(b"88 1 S sh\n", pids)
|
|
1484
|
+
});
|
|
1485
|
+
assert_eq!(calls, vec![("e1", vec![88]), ("e2", vec![88])]);
|
|
1486
|
+
assert!(first.nodes.is_empty());
|
|
1487
|
+
assert_eq!(second.nodes.len(), 1);
|
|
1488
|
+
let failed = Some(first);
|
|
1489
|
+
assert_eq!(project_node_with_attach(®istered[0], Some(&failed)).0.health, "unknown");
|
|
1490
|
+
let live = Some(second);
|
|
1491
|
+
assert_eq!(project_node_with_attach(®istered[1], Some(&live)).0.health, "normal");
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
#[test]
|
|
1495
|
+
fn duplicate_process_rows_cannot_hide_a_zombie() {
|
|
1496
|
+
let requested = BTreeSet::from([11]);
|
|
1497
|
+
for rows in [b"11 1 Z dead\n11 1 S sh\n".as_slice(), b"11 1 S sh\n11 1 Z dead\n".as_slice()] {
|
|
1498
|
+
assert!(parse_process_snapshots(rows, &requested).get(&11).unwrap().zombie);
|
|
1499
|
+
}
|
|
1500
|
+
assert!(parse_process_snapshots(b"not a process row\n", &requested).is_empty());
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
#[test]
|
|
1504
|
+
fn json_and_human_keep_nine_fields_without_ps_details() {
|
|
1505
|
+
let registered = vec![registered("e1", "%1"), registered("e1", "%2")];
|
|
1506
|
+
let probe = Some(sample_native_panes(
|
|
1507
|
+
"hp\tw\t%1\t11\tsh\nhp\tw\t%2\t22\tpi\n", "e1", ®istered,
|
|
1508
|
+
|pids| parse_process_snapshots(b"22 1 R PRIVATE_COMMAND_DETAIL\n11 1 S PRIVATE_COMMAND_DETAIL\n", pids),
|
|
1509
|
+
));
|
|
1510
|
+
let projected = registered.iter().map(|node| project_node_with_attach(node, Some(&probe))).collect::<Vec<_>>();
|
|
1511
|
+
let keys = BTreeSet::from(["name", "provider", "model", "effort", "runtime_status", "activity", "health", "session_name", "tmux_command"]);
|
|
1512
|
+
for (node, _) in &projected {
|
|
1513
|
+
let json = node.clone().into_json();
|
|
1514
|
+
assert_eq!(json.as_object().unwrap().keys().map(String::as_str).collect::<BTreeSet<_>>(), keys);
|
|
1515
|
+
assert_eq!(json["model"], "fixture-model");
|
|
1516
|
+
assert_eq!(json["effort"], "high");
|
|
1517
|
+
assert_eq!(json["runtime_status"], "running");
|
|
1518
|
+
assert_eq!(json["health"], "normal");
|
|
1519
|
+
assert!(!json.to_string().contains("PRIVATE_COMMAND_DETAIL"));
|
|
1520
|
+
}
|
|
1521
|
+
let human = format_multi_node_brief(projected);
|
|
1522
|
+
let mut lines = human.lines().skip_while(|line| !line.starts_with("NAME\t"));
|
|
1523
|
+
assert_eq!(lines.next().unwrap().split('\t').count(), 9);
|
|
1524
|
+
assert!(lines.all(|line| line.split('\t').count() == 9));
|
|
1525
|
+
assert!(!human.contains("PRIVATE_COMMAND_DETAIL"));
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1220
1529
|
#[cfg(test)]
|
|
1221
1530
|
mod tests {
|
|
1222
1531
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|