@team-agent/installer 0.5.82 → 0.5.83
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 +155 -5
- package/crates/team-agent/src/cli/leaders.rs +304 -167
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/types.rs +11 -0
- package/crates/team-agent/src/leader/registry.rs +295 -64
- package/crates/team-agent/src/state/persist.rs +50 -1
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -218,7 +218,7 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
|
|
|
218
218
|
// resolve short/qualified/hash-qualified names to a canonical
|
|
219
219
|
// (workspace, team_key) tuple and delegates to the E6 named-leader
|
|
220
220
|
// delivery path — no separate route authority.
|
|
221
|
-
"leaders" => cmd_leaders(&leaders_args(args, cwd)).map(emit_result),
|
|
221
|
+
"leaders" => cmd_leaders(&leaders_args(args, cwd)?).map(emit_result),
|
|
222
222
|
"models" => cmd_models(&models_args(args)?).map(emit_result),
|
|
223
223
|
"validate" => cmd_validate(&validate_args(args, cwd)).map(emit_result),
|
|
224
224
|
"install-skill" => cmd_install_skill(&install_skill_args(args)?).map(emit_result),
|
|
@@ -324,7 +324,15 @@ pub(crate) fn default_help() -> String {
|
|
|
324
324
|
append_help_section(
|
|
325
325
|
&mut out,
|
|
326
326
|
"Core",
|
|
327
|
-
&[
|
|
327
|
+
&[
|
|
328
|
+
"quick-start",
|
|
329
|
+
"send",
|
|
330
|
+
"status",
|
|
331
|
+
"collect",
|
|
332
|
+
"results",
|
|
333
|
+
"models",
|
|
334
|
+
"leaders",
|
|
335
|
+
],
|
|
328
336
|
);
|
|
329
337
|
append_help_section(
|
|
330
338
|
&mut out,
|
|
@@ -411,6 +419,7 @@ fn command_help(command: Option<&str>) -> String {
|
|
|
411
419
|
Some("allow-peer-talk") => "usage: team-agent allow-peer-talk A B [--workspace WORKSPACE] [--json]".to_string(),
|
|
412
420
|
Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n输出七字段:name/provider/runtime_status/activity/health/session_name/tmux_command;人读与 --json 使用同一投影。缺少可靠定位或 nodeprobe 证据时显示 unknown;tmux_command 可复制到对应目标。--summary/--detail 仅保留兼容性,不增加诊断字段。".to_string(),
|
|
413
421
|
Some("models") => "usage: team-agent models --provider pi [--search TEXT] [--json]\n\nPrints models.v1 exact role_model entries; each entry includes current=true|false. Catalog readiness is reported as auth=ok|not_ready with auth_basis=catalog_visibility.".to_string(),
|
|
422
|
+
Some("leaders") => "usage: team-agent leaders [QUERY|--search TEXT] [--all|--stale] [--json] | --prune [--dry-run] [--json]\n\nLists LIVE leaders by default. Use --all to include retained STALE entries, --stale to inspect only STALE entries, QUERY or --search TEXT to match workspace/team/name fields, and --prune to remove only entries proven terminal by canonical state. --dry-run is valid only with --prune.".to_string(),
|
|
414
423
|
Some("stop") => compat_hidden_help("stop", "usage: team-agent stop [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]"),
|
|
415
424
|
Some("shutdown") => "usage: team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]".to_string(),
|
|
416
425
|
Some("restart") => "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json] [--detail]".to_string(),
|
|
@@ -1683,9 +1692,109 @@ fn sessions_args(args: &[String], cwd: &Path) -> SessionsArgs {
|
|
|
1683
1692
|
}
|
|
1684
1693
|
}
|
|
1685
1694
|
|
|
1686
|
-
fn leaders_args(args: &[String], _cwd: &Path) -> LeadersArgs {
|
|
1687
|
-
let
|
|
1688
|
-
|
|
1695
|
+
fn leaders_args(args: &[String], _cwd: &Path) -> Result<LeadersArgs, CliError> {
|
|
1696
|
+
let mut view = None;
|
|
1697
|
+
let mut query = None;
|
|
1698
|
+
let mut prune = false;
|
|
1699
|
+
let mut dry_run = false;
|
|
1700
|
+
let mut json = false;
|
|
1701
|
+
let mut i = 0usize;
|
|
1702
|
+
while i < args.len() {
|
|
1703
|
+
let arg = &args[i];
|
|
1704
|
+
match arg.as_str() {
|
|
1705
|
+
"--json" => json = true,
|
|
1706
|
+
"--all" => {
|
|
1707
|
+
if view.replace(LeadersView::All).is_some() {
|
|
1708
|
+
return Err(CliError::Usage(
|
|
1709
|
+
"leaders accepts only one of --all or --stale".to_string(),
|
|
1710
|
+
));
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
"--stale" => {
|
|
1714
|
+
if view.replace(LeadersView::Stale).is_some() {
|
|
1715
|
+
return Err(CliError::Usage(
|
|
1716
|
+
"leaders accepts only one of --all or --stale".to_string(),
|
|
1717
|
+
));
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
"--prune" => {
|
|
1721
|
+
if prune {
|
|
1722
|
+
return Err(CliError::Usage(
|
|
1723
|
+
"leaders accepts --prune at most once".to_string(),
|
|
1724
|
+
));
|
|
1725
|
+
}
|
|
1726
|
+
prune = true;
|
|
1727
|
+
}
|
|
1728
|
+
"--dry-run" => dry_run = true,
|
|
1729
|
+
"--search" => {
|
|
1730
|
+
let Some(value) = args.get(i.saturating_add(1)) else {
|
|
1731
|
+
return Err(CliError::Usage(
|
|
1732
|
+
"leaders --search requires TEXT".to_string(),
|
|
1733
|
+
));
|
|
1734
|
+
};
|
|
1735
|
+
if value.starts_with('-') {
|
|
1736
|
+
return Err(CliError::Usage(
|
|
1737
|
+
"leaders --search requires TEXT".to_string(),
|
|
1738
|
+
));
|
|
1739
|
+
}
|
|
1740
|
+
i = i.saturating_add(1);
|
|
1741
|
+
if query.replace(value.clone()).is_some() {
|
|
1742
|
+
return Err(CliError::Usage(
|
|
1743
|
+
"leaders accepts only one QUERY or --search TEXT".to_string(),
|
|
1744
|
+
));
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
value if value.starts_with("--search=") => {
|
|
1748
|
+
let value = value.trim_start_matches("--search=").to_string();
|
|
1749
|
+
if query.replace(value).is_some() {
|
|
1750
|
+
return Err(CliError::Usage(
|
|
1751
|
+
"leaders accepts only one QUERY or --search TEXT".to_string(),
|
|
1752
|
+
));
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
value if value.starts_with('-') => {
|
|
1756
|
+
return Err(CliError::Usage(format!(
|
|
1757
|
+
"unknown leaders argument: {value}"
|
|
1758
|
+
)))
|
|
1759
|
+
}
|
|
1760
|
+
value => {
|
|
1761
|
+
if query.replace(value.to_string()).is_some() {
|
|
1762
|
+
return Err(CliError::Usage(
|
|
1763
|
+
"leaders accepts only one QUERY or --search TEXT".to_string(),
|
|
1764
|
+
));
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
i = i.saturating_add(1);
|
|
1769
|
+
}
|
|
1770
|
+
if query
|
|
1771
|
+
.as_deref()
|
|
1772
|
+
.is_some_and(|value| value.trim().is_empty())
|
|
1773
|
+
{
|
|
1774
|
+
return Err(CliError::Usage("leaders QUERY cannot be blank".to_string()));
|
|
1775
|
+
}
|
|
1776
|
+
if prune && view.is_some() {
|
|
1777
|
+
return Err(CliError::Usage(
|
|
1778
|
+
"leaders --prune cannot be combined with --all or --stale".to_string(),
|
|
1779
|
+
));
|
|
1780
|
+
}
|
|
1781
|
+
if prune && query.is_some() {
|
|
1782
|
+
return Err(CliError::Usage(
|
|
1783
|
+
"leaders --prune cannot be combined with QUERY or --search".to_string(),
|
|
1784
|
+
));
|
|
1785
|
+
}
|
|
1786
|
+
if dry_run && !prune {
|
|
1787
|
+
return Err(CliError::Usage(
|
|
1788
|
+
"leaders --dry-run requires --prune".to_string(),
|
|
1789
|
+
));
|
|
1790
|
+
}
|
|
1791
|
+
Ok(LeadersArgs {
|
|
1792
|
+
view: view.unwrap_or(LeadersView::Live),
|
|
1793
|
+
query,
|
|
1794
|
+
prune,
|
|
1795
|
+
dry_run,
|
|
1796
|
+
json,
|
|
1797
|
+
})
|
|
1689
1798
|
}
|
|
1690
1799
|
|
|
1691
1800
|
fn validate_args(args: &[String], cwd: &Path) -> ValidateArgs {
|
|
@@ -1935,6 +2044,37 @@ mod tests {
|
|
|
1935
2044
|
.collect()
|
|
1936
2045
|
}
|
|
1937
2046
|
|
|
2047
|
+
#[test]
|
|
2048
|
+
fn leaders_parser_supports_views_and_search() {
|
|
2049
|
+
let parsed = leaders_args(
|
|
2050
|
+
&cli_argv(&["--all", "--search", "Wiki", "--json"]),
|
|
2051
|
+
Path::new("."),
|
|
2052
|
+
)
|
|
2053
|
+
.unwrap();
|
|
2054
|
+
assert_eq!(parsed.view, LeadersView::All);
|
|
2055
|
+
assert_eq!(parsed.query.as_deref(), Some("Wiki"));
|
|
2056
|
+
assert!(!parsed.prune);
|
|
2057
|
+
assert!(!parsed.dry_run);
|
|
2058
|
+
assert!(parsed.json);
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
#[test]
|
|
2062
|
+
fn leaders_parser_rejects_invalid_search_and_prune_combinations() {
|
|
2063
|
+
for args in [
|
|
2064
|
+
cli_argv(&["one", "two"]),
|
|
2065
|
+
cli_argv(&["one", "--search", "two"]),
|
|
2066
|
+
cli_argv(&["--search", " "]),
|
|
2067
|
+
cli_argv(&["--dry-run"]),
|
|
2068
|
+
cli_argv(&["--prune", "--stale"]),
|
|
2069
|
+
cli_argv(&["--prune", "--search", "x"]),
|
|
2070
|
+
] {
|
|
2071
|
+
assert!(
|
|
2072
|
+
leaders_args(&args, Path::new(".")).is_err(),
|
|
2073
|
+
"args={args:?}"
|
|
2074
|
+
);
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
|
|
1938
2078
|
#[test]
|
|
1939
2079
|
fn command_specs_have_unique_names_and_valid_aliases() {
|
|
1940
2080
|
let mut names = std::collections::BTreeSet::new();
|
|
@@ -2031,6 +2171,16 @@ mod tests {
|
|
|
2031
2171
|
}
|
|
2032
2172
|
}
|
|
2033
2173
|
|
|
2174
|
+
#[test]
|
|
2175
|
+
fn leaders_help_publishes_selectors_and_prune() {
|
|
2176
|
+
let help = command_help(Some("leaders"));
|
|
2177
|
+
assert!(help.contains("--all"));
|
|
2178
|
+
assert!(help.contains("--stale"));
|
|
2179
|
+
assert!(help.contains("--search TEXT"));
|
|
2180
|
+
assert!(help.contains("--prune"));
|
|
2181
|
+
assert!(help.contains("--dry-run"));
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2034
2184
|
#[test]
|
|
2035
2185
|
fn compat_hidden_help_has_sunset_action() {
|
|
2036
2186
|
for command in ["stop", "restart-agent", "start", "init"] {
|
|
@@ -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
|
}
|
|
@@ -198,7 +198,7 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
|
|
|
198
198
|
CommandSpec { name: "stuck-list", tier: CommandTier::CompatHidden, category: CommandCategory::Compat, kind: CommandKind::Dispatch(DispatchKind::StuckList), summary: "legacy stuck alert listing", usage: "usage: team-agent stuck-list [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: Some("C2"), action: Some("use `team-agent diagnose`"), governance: None },
|
|
199
199
|
CommandSpec { name: "stuck-cancel", tier: CommandTier::CompatHidden, category: CommandCategory::Compat, kind: CommandKind::Dispatch(DispatchKind::StuckCancel), summary: "legacy stuck alert suppression", usage: "usage: team-agent stuck-cancel AGENT [--workspace WORKSPACE] [--alert-type stuck|idle_fallback|cross_worker_deadlock|all] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: Some("C2"), action: Some("use the concrete notification action or `team-agent diagnose`"), governance: None },
|
|
200
200
|
CommandSpec { name: "acknowledge-idle", tier: CommandTier::CompatHidden, category: CommandCategory::Compat, kind: CommandKind::Dispatch(DispatchKind::AcknowledgeIdle), summary: "legacy idle acknowledgement", usage: "usage: team-agent acknowledge-idle [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: Some("C2"), action: Some("use the concrete idle notification action or `team-agent diagnose`"), governance: None },
|
|
201
|
-
CommandSpec { name: "leaders", tier: CommandTier::Secondary, category: CommandCategory::Observe, kind: CommandKind::Dispatch(DispatchKind::Leaders), summary: "
|
|
201
|
+
CommandSpec { name: "leaders", tier: CommandTier::Secondary, category: CommandCategory::Observe, kind: CommandKind::Dispatch(DispatchKind::Leaders), summary: "list live, stale, or ambiguous host leaders", usage: "usage: team-agent leaders [QUERY|--search TEXT] [--all|--stale] [--json] | --prune [--dry-run] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
202
202
|
CommandSpec { name: "doctor", tier: CommandTier::Secondary, category: CommandCategory::Setup, kind: CommandKind::Dispatch(DispatchKind::Doctor), summary: "run host maintenance checks", usage: "usage: team-agent doctor [SPEC] [--workspace WORKSPACE] [--team TEAM] [--gate orphans|comms] [--comms] [--fix] [--fix-schema] [--cleanup-orphans] [--confirm] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
203
203
|
CommandSpec { name: "attach-app-server-leader", tier: CommandTier::Guided, category: CommandCategory::GuidedRecovery, kind: CommandKind::Dispatch(DispatchKind::AttachAppServerLeader), summary: "bind an app-server leader receiver", usage: "usage: team-agent attach-app-server-leader [--workspace WORKSPACE] [--team TEAM] --socket unix:///path.sock --thread-id THREAD_ID [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
204
204
|
CommandSpec { name: "remove-agent", tier: CommandTier::Secondary, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::RemoveAgent), summary: "remove a worker", usage: "usage: team-agent remove-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--from-spec] [--confirm] [--force] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
@@ -639,8 +639,19 @@ pub struct SessionsArgs {
|
|
|
639
639
|
/// enumerates the host discovery index and classifies each entry as
|
|
640
640
|
/// LIVE, STALE, or AMBIGUOUS after re-validating against canonical
|
|
641
641
|
/// workspace state.
|
|
642
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
643
|
+
pub enum LeadersView {
|
|
644
|
+
Live,
|
|
645
|
+
All,
|
|
646
|
+
Stale,
|
|
647
|
+
}
|
|
648
|
+
|
|
642
649
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
643
650
|
pub struct LeadersArgs {
|
|
651
|
+
pub view: LeadersView,
|
|
652
|
+
pub query: Option<String>,
|
|
653
|
+
pub prune: bool,
|
|
654
|
+
pub dry_run: bool,
|
|
644
655
|
pub json: bool,
|
|
645
656
|
}
|
|
646
657
|
|
|
@@ -367,8 +367,8 @@ pub fn restore_entry_if_current_matches(
|
|
|
367
367
|
if receipt.path.parent() != Some(dir) {
|
|
368
368
|
return Err(RegistryRollbackError::InvalidReceipt);
|
|
369
369
|
}
|
|
370
|
-
let current =
|
|
371
|
-
.map_err(|_| RegistryRollbackError::ReadFailed)?;
|
|
370
|
+
let current =
|
|
371
|
+
std::fs::read(&receipt.path).map_err(|_| RegistryRollbackError::ReadFailed)?;
|
|
372
372
|
if current != receipt.bytes {
|
|
373
373
|
return Ok(RegistryRollback::Superseded);
|
|
374
374
|
}
|
|
@@ -377,33 +377,117 @@ pub fn restore_entry_if_current_matches(
|
|
|
377
377
|
Ok(RegistryRollback::Restored)
|
|
378
378
|
}
|
|
379
379
|
Some(_) => Err(RegistryRollbackError::RestoreFailed),
|
|
380
|
-
None if std::fs::remove_file(&receipt.path).is_ok() =>
|
|
381
|
-
Ok(RegistryRollback::Restored)
|
|
382
|
-
}
|
|
380
|
+
None if std::fs::remove_file(&receipt.path).is_ok() => Ok(RegistryRollback::Restored),
|
|
383
381
|
None => Err(RegistryRollbackError::DeleteFailed),
|
|
384
382
|
}
|
|
385
383
|
})
|
|
386
384
|
}
|
|
387
385
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
386
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
387
|
+
pub struct RegistryPruneItem {
|
|
388
|
+
pub workspace: PathBuf,
|
|
389
|
+
pub team_key: String,
|
|
390
|
+
pub reason: String,
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
394
|
+
pub struct RegistryPruneReport {
|
|
395
|
+
pub dry_run: bool,
|
|
396
|
+
pub candidates: Vec<RegistryPruneItem>,
|
|
397
|
+
pub removed: Vec<RegistryPruneItem>,
|
|
398
|
+
pub kept: Vec<RegistryPruneItem>,
|
|
399
|
+
pub skipped: Vec<RegistryPruneItem>,
|
|
400
|
+
pub errors: Vec<RegistryPruneItem>,
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
404
|
+
enum PruneAttempt {
|
|
405
|
+
Removed,
|
|
406
|
+
Skipped(&'static str),
|
|
407
|
+
Error,
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
fn prune_item(entry: &LeaderRegistryEntry, reason: impl Into<String>) -> RegistryPruneItem {
|
|
411
|
+
RegistryPruneItem {
|
|
412
|
+
workspace: entry.workspace.clone(),
|
|
413
|
+
team_key: entry.team_key.clone(),
|
|
414
|
+
reason: reason.into(),
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
fn prune_reason_from_state(entry: &LeaderRegistryEntry, state: &Value) -> Option<&'static str> {
|
|
419
|
+
let (status, reason) = classify_loaded(entry, state);
|
|
420
|
+
if status != "STALE" {
|
|
421
|
+
return None;
|
|
422
|
+
}
|
|
423
|
+
match reason.as_deref() {
|
|
424
|
+
Some("team_key_not_found") => Some("team_key_not_found"),
|
|
425
|
+
Some("team_not_alive") => Some("team_not_alive"),
|
|
426
|
+
_ => None,
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
fn prune_reason(
|
|
431
|
+
entry: &LeaderRegistryEntry,
|
|
432
|
+
status: &str,
|
|
433
|
+
reason: Option<&str>,
|
|
434
|
+
) -> Option<&'static str> {
|
|
435
|
+
if status != "STALE" || !crate::state::persist::runtime_state_path(&entry.workspace).is_file() {
|
|
436
|
+
// A missing state file is unavailable rather than proof that the
|
|
437
|
+
// workspace was retired. Only an existing, readable canonical state
|
|
438
|
+
// can authorize the team-key cleanup below.
|
|
439
|
+
return None;
|
|
440
|
+
}
|
|
441
|
+
match reason {
|
|
442
|
+
Some("team_key_not_found") => Some("team_key_not_found"),
|
|
443
|
+
Some("team_not_alive") => Some("team_not_alive"),
|
|
444
|
+
_ => None,
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
fn prune_entry_if_current(
|
|
449
|
+
path: &Path,
|
|
450
|
+
expected: &LeaderRegistryEntry,
|
|
451
|
+
snapshot: &[u8],
|
|
452
|
+
) -> PruneAttempt {
|
|
453
|
+
let result = crate::state::persist::with_runtime_state_lock_without_migrations(
|
|
454
|
+
&expected.workspace,
|
|
455
|
+
|state| {
|
|
456
|
+
let Some(state) = state.as_ref() else {
|
|
457
|
+
return PruneAttempt::Skipped("canonical_state_unavailable");
|
|
458
|
+
};
|
|
459
|
+
// Lock ordering is canonical state -> host registry. The state
|
|
460
|
+
// remains unchanged while the exact registry bytes are checked.
|
|
461
|
+
with_registry_lock(|_| {
|
|
462
|
+
let Ok(bytes) = std::fs::read(path) else {
|
|
463
|
+
return PruneAttempt::Skipped("registry_entry_missing");
|
|
464
|
+
};
|
|
465
|
+
if bytes != snapshot {
|
|
466
|
+
return PruneAttempt::Skipped("registry_entry_changed");
|
|
467
|
+
}
|
|
468
|
+
let Ok(current) = serde_json::from_slice::<LeaderRegistryEntry>(&bytes) else {
|
|
469
|
+
return PruneAttempt::Skipped("registry_entry_changed");
|
|
470
|
+
};
|
|
471
|
+
if current != *expected {
|
|
472
|
+
return PruneAttempt::Skipped("registry_entry_changed");
|
|
473
|
+
}
|
|
474
|
+
if prune_reason_from_state(¤t, state).is_none() {
|
|
475
|
+
return PruneAttempt::Skipped("canonical_state_changed");
|
|
476
|
+
}
|
|
477
|
+
if std::fs::remove_file(path).is_ok() {
|
|
478
|
+
PruneAttempt::Removed
|
|
479
|
+
} else {
|
|
480
|
+
PruneAttempt::Error
|
|
481
|
+
}
|
|
482
|
+
})
|
|
483
|
+
.unwrap_or(PruneAttempt::Skipped("registry_lock_unavailable"))
|
|
484
|
+
},
|
|
485
|
+
);
|
|
486
|
+
result.unwrap_or(PruneAttempt::Skipped("canonical_state_unavailable"))
|
|
399
487
|
}
|
|
400
488
|
|
|
401
489
|
fn replace_entry_bytes_locked(path: &Path, bytes: &[u8]) -> bool {
|
|
402
|
-
let tmp = path.with_extension(format!(
|
|
403
|
-
"rollback-{}-{}",
|
|
404
|
-
std::process::id(),
|
|
405
|
-
rand_suffix()
|
|
406
|
-
));
|
|
490
|
+
let tmp = path.with_extension(format!("rollback-{}-{}", std::process::id(), rand_suffix()));
|
|
407
491
|
if std::fs::write(&tmp, bytes).is_err() {
|
|
408
492
|
let _ = std::fs::remove_file(&tmp);
|
|
409
493
|
return false;
|
|
@@ -497,7 +581,7 @@ pub fn unregister_entry(workspace: &Path, team_key: &str) -> Option<PathBuf> {
|
|
|
497
581
|
/// registry is a derived discovery index and unreadable files are the
|
|
498
582
|
/// "STALE / UNREADABLE" dirty class rather than an error.
|
|
499
583
|
#[must_use]
|
|
500
|
-
fn
|
|
584
|
+
fn read_all_entries_with_bytes() -> Vec<(PathBuf, LeaderRegistryEntry, Vec<u8>)> {
|
|
501
585
|
let Some(dir) = registry_dir() else {
|
|
502
586
|
return Vec::new();
|
|
503
587
|
};
|
|
@@ -517,18 +601,26 @@ fn read_all_entries() -> Vec<(PathBuf, LeaderRegistryEntry)> {
|
|
|
517
601
|
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
|
518
602
|
continue;
|
|
519
603
|
}
|
|
520
|
-
let Ok(
|
|
604
|
+
let Ok(bytes) = std::fs::read(&path) else {
|
|
521
605
|
continue;
|
|
522
606
|
};
|
|
523
|
-
let Ok(entry) = serde_json::
|
|
607
|
+
let Ok(entry) = serde_json::from_slice::<LeaderRegistryEntry>(&bytes) else {
|
|
524
608
|
continue;
|
|
525
609
|
};
|
|
526
|
-
out.push((path, entry));
|
|
610
|
+
out.push((path, entry, bytes));
|
|
527
611
|
}
|
|
528
612
|
out.sort_by(|a, b| a.0.cmp(&b.0));
|
|
529
613
|
out
|
|
530
614
|
}
|
|
531
615
|
|
|
616
|
+
#[must_use]
|
|
617
|
+
fn read_all_entries() -> Vec<(PathBuf, LeaderRegistryEntry)> {
|
|
618
|
+
read_all_entries_with_bytes()
|
|
619
|
+
.into_iter()
|
|
620
|
+
.map(|(path, entry, _bytes)| (path, entry))
|
|
621
|
+
.collect()
|
|
622
|
+
}
|
|
623
|
+
|
|
532
624
|
/// Validate a registry entry against its canonical workspace state.
|
|
533
625
|
/// Returns the wire status label — LIVE when canonical `leader_receiver`
|
|
534
626
|
/// matches, STALE otherwise. This is the pruning gate for GC and the
|
|
@@ -538,11 +630,17 @@ pub fn classify(entry: &LeaderRegistryEntry) -> (&'static str, Option<String>) {
|
|
|
538
630
|
let Ok(state) = crate::state::persist::load_runtime_state(&entry.workspace) else {
|
|
539
631
|
return ("STALE", Some("workspace_no_state".to_string()));
|
|
540
632
|
};
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
633
|
+
classify_loaded(entry, &state)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
fn classify_loaded(entry: &LeaderRegistryEntry, state: &Value) -> (&'static str, Option<String>) {
|
|
637
|
+
// A missing or malformed `teams` root is an unavailable/legacy state,
|
|
638
|
+
// not proof that this registry entry's team was retired. Keep it out of
|
|
639
|
+
// the prune candidate set unless a valid map was independently found.
|
|
640
|
+
let Some(teams) = state.get("teams").and_then(Value::as_object) else {
|
|
641
|
+
return ("STALE", Some("teams_invalid".to_string()));
|
|
642
|
+
};
|
|
643
|
+
let team = match teams.get(&entry.team_key) {
|
|
546
644
|
Some(t) => t,
|
|
547
645
|
None => return ("STALE", Some("team_key_not_found".to_string())),
|
|
548
646
|
};
|
|
@@ -599,47 +697,57 @@ fn tmux_pane_live(socket: &str, pane_id: &str) -> bool {
|
|
|
599
697
|
}
|
|
600
698
|
}
|
|
601
699
|
|
|
602
|
-
///
|
|
603
|
-
///
|
|
604
|
-
///
|
|
605
|
-
/// classified for the `leaders` command output.
|
|
606
|
-
///
|
|
607
|
-
/// Design §14 step 6: "Successful scoped shutdown removes matching registry
|
|
608
|
-
/// entry. Failed/degraded shutdown leaves entry STALE, not deleted."
|
|
609
|
-
/// The pruning here is the GC arm — it only removes entries whose
|
|
610
|
-
/// canonical workspace/team has no state at all, i.e. the target has been
|
|
611
|
-
/// permanently removed. Leader-detached-but-team-alive stays STALE and
|
|
612
|
-
/// visible so the operator can decide.
|
|
700
|
+
/// Explicitly prune only entries whose readable canonical state proves that
|
|
701
|
+
/// the team is gone or terminal. Dead panes, old epochs, unattached leaders,
|
|
702
|
+
/// missing/unreadable state, and probe failures remain untouched.
|
|
613
703
|
#[must_use]
|
|
614
|
-
pub fn
|
|
615
|
-
let mut
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
704
|
+
pub fn prune_registry(dry_run: bool) -> RegistryPruneReport {
|
|
705
|
+
let mut report = RegistryPruneReport {
|
|
706
|
+
dry_run,
|
|
707
|
+
candidates: Vec::new(),
|
|
708
|
+
removed: Vec::new(),
|
|
709
|
+
kept: Vec::new(),
|
|
710
|
+
skipped: Vec::new(),
|
|
711
|
+
errors: Vec::new(),
|
|
712
|
+
};
|
|
713
|
+
let mut registry_lock_unavailable = false;
|
|
714
|
+
for (path, entry, snapshot) in read_all_entries_with_bytes() {
|
|
715
|
+
let (status, classified_reason) = classify(&entry);
|
|
716
|
+
let reason = prune_reason(&entry, status, classified_reason.as_deref());
|
|
717
|
+
let kept_reason = classified_reason.as_deref().unwrap_or("LIVE");
|
|
718
|
+
let Some(reason) = reason else {
|
|
719
|
+
report.kept.push(prune_item(&entry, kept_reason));
|
|
720
|
+
continue;
|
|
721
|
+
};
|
|
722
|
+
let candidate = prune_item(&entry, reason);
|
|
723
|
+
report.candidates.push(candidate.clone());
|
|
724
|
+
if dry_run {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
if registry_lock_unavailable {
|
|
728
|
+
report
|
|
729
|
+
.skipped
|
|
730
|
+
.push(prune_item(&entry, "registry_lock_unavailable"));
|
|
632
731
|
continue;
|
|
633
732
|
}
|
|
634
|
-
|
|
733
|
+
match prune_entry_if_current(&path, &entry, &snapshot) {
|
|
734
|
+
PruneAttempt::Removed => report.removed.push(candidate),
|
|
735
|
+
PruneAttempt::Skipped(reason) => {
|
|
736
|
+
if reason == "registry_lock_unavailable" {
|
|
737
|
+
registry_lock_unavailable = true;
|
|
738
|
+
}
|
|
739
|
+
report.skipped.push(prune_item(&entry, reason));
|
|
740
|
+
}
|
|
741
|
+
PruneAttempt::Error => report.errors.push(prune_item(&entry, "remove_failed")),
|
|
742
|
+
}
|
|
635
743
|
}
|
|
636
|
-
|
|
744
|
+
report
|
|
637
745
|
}
|
|
638
746
|
|
|
639
|
-
///
|
|
640
|
-
/// --to-leader` needs to see stale entries so it can refuse with
|
|
747
|
+
/// Read all entries and classify each without mutating the derived index.
|
|
748
|
+
/// `send --to-leader` needs to see stale entries so it can refuse with
|
|
641
749
|
/// `registry_stale` (and never silently fall through to
|
|
642
|
-
/// `leader_name_not_found` just because
|
|
750
|
+
/// `leader_name_not_found` just because a listing ran first).
|
|
643
751
|
#[must_use]
|
|
644
752
|
pub fn list_validated_no_gc() -> Vec<(LeaderRegistryEntry, &'static str, Option<String>)> {
|
|
645
753
|
read_all_entries()
|
|
@@ -650,3 +758,126 @@ pub fn list_validated_no_gc() -> Vec<(LeaderRegistryEntry, &'static str, Option<
|
|
|
650
758
|
})
|
|
651
759
|
.collect()
|
|
652
760
|
}
|
|
761
|
+
|
|
762
|
+
#[cfg(test)]
|
|
763
|
+
mod tests {
|
|
764
|
+
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
|
765
|
+
|
|
766
|
+
use super::*;
|
|
767
|
+
use serde_json::json;
|
|
768
|
+
use serial_test::serial;
|
|
769
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
770
|
+
|
|
771
|
+
static TEST_SEQ: AtomicU64 = AtomicU64::new(0);
|
|
772
|
+
|
|
773
|
+
struct HomeGuard(Option<std::ffi::OsString>);
|
|
774
|
+
|
|
775
|
+
impl Drop for HomeGuard {
|
|
776
|
+
fn drop(&mut self) {
|
|
777
|
+
if let Some(home) = self.0.take() {
|
|
778
|
+
std::env::set_var("HOME", home);
|
|
779
|
+
} else {
|
|
780
|
+
std::env::remove_var("HOME");
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
fn test_home(label: &str) -> (HomeGuard, PathBuf, PathBuf) {
|
|
786
|
+
let n = TEST_SEQ.fetch_add(1, Ordering::Relaxed);
|
|
787
|
+
let home =
|
|
788
|
+
std::env::temp_dir().join(format!("ta-registry-{label}-{}-{n}", std::process::id()));
|
|
789
|
+
let workspace = home.join("workspace");
|
|
790
|
+
std::fs::create_dir_all(&workspace).expect("create workspace");
|
|
791
|
+
let previous = std::env::var_os("HOME");
|
|
792
|
+
std::env::set_var("HOME", &home);
|
|
793
|
+
(HomeGuard(previous), home, workspace)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
fn write_state(workspace: &Path, state: Value) {
|
|
797
|
+
let path = crate::state::persist::runtime_state_path(workspace);
|
|
798
|
+
std::fs::create_dir_all(path.parent().expect("runtime parent")).expect("create runtime");
|
|
799
|
+
std::fs::write(
|
|
800
|
+
path,
|
|
801
|
+
serde_json::to_vec_pretty(&state).expect("serialize state"),
|
|
802
|
+
)
|
|
803
|
+
.expect("write state");
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
fn entry(workspace: &Path, team_key: &str) -> LeaderRegistryEntry {
|
|
807
|
+
build_entry(
|
|
808
|
+
workspace,
|
|
809
|
+
team_key,
|
|
810
|
+
"direct_tmux",
|
|
811
|
+
json!({"pane_id": "%fixture"}),
|
|
812
|
+
1,
|
|
813
|
+
"test",
|
|
814
|
+
"2026-09-16T00:00:00Z".to_string(),
|
|
815
|
+
)
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
#[test]
|
|
819
|
+
#[serial]
|
|
820
|
+
fn prune_removes_team_proven_terminal() {
|
|
821
|
+
let (_home_guard, _home, workspace) = test_home("terminal");
|
|
822
|
+
write_state(
|
|
823
|
+
&workspace,
|
|
824
|
+
json!({"teams": {"retired": {"status": "stopped"}}}),
|
|
825
|
+
);
|
|
826
|
+
let registered = entry(&workspace, "retired");
|
|
827
|
+
let path = write_entry_best_effort(®istered).expect("register terminal team");
|
|
828
|
+
|
|
829
|
+
let report = prune_registry(false);
|
|
830
|
+
assert_eq!(report.candidates.len(), 1);
|
|
831
|
+
assert_eq!(report.removed.len(), 1);
|
|
832
|
+
assert!(report.skipped.is_empty());
|
|
833
|
+
assert!(!path.exists(), "terminal registry entry must be removed");
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
#[test]
|
|
837
|
+
#[serial]
|
|
838
|
+
fn prune_removes_missing_key_only_from_valid_teams_map() {
|
|
839
|
+
let (_home_guard, _home, workspace) = test_home("missing-key");
|
|
840
|
+
write_state(&workspace, json!({"teams": {"other": {"status": "alive"}}}));
|
|
841
|
+
let registered = entry(&workspace, "gone");
|
|
842
|
+
let path = write_entry_best_effort(®istered).expect("register missing team");
|
|
843
|
+
|
|
844
|
+
let report = prune_registry(false);
|
|
845
|
+
assert_eq!(report.removed.len(), 1);
|
|
846
|
+
assert!(
|
|
847
|
+
!path.exists(),
|
|
848
|
+
"missing key in valid teams map is removable"
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
#[test]
|
|
853
|
+
#[serial]
|
|
854
|
+
fn prune_keeps_null_nonobject_and_legacy_teams_shapes() {
|
|
855
|
+
for (label, state) in [
|
|
856
|
+
("null", json!({"teams": null})),
|
|
857
|
+
("array", json!({"teams": []})),
|
|
858
|
+
("string", json!({"teams": "legacy"})),
|
|
859
|
+
(
|
|
860
|
+
"single",
|
|
861
|
+
json!({"team_key": "retired", "status": "stopped"}),
|
|
862
|
+
),
|
|
863
|
+
] {
|
|
864
|
+
let (_home_guard, _home, workspace) = test_home(label);
|
|
865
|
+
write_state(&workspace, state);
|
|
866
|
+
let registered = entry(&workspace, "retired");
|
|
867
|
+
let path = write_entry_best_effort(®istered).expect("register malformed state");
|
|
868
|
+
|
|
869
|
+
let report = prune_registry(false);
|
|
870
|
+
assert!(
|
|
871
|
+
report.candidates.is_empty(),
|
|
872
|
+
"malformed shape became candidate: {label}"
|
|
873
|
+
);
|
|
874
|
+
assert_eq!(
|
|
875
|
+
report.kept.len(),
|
|
876
|
+
1,
|
|
877
|
+
"malformed shape must be retained: {label}"
|
|
878
|
+
);
|
|
879
|
+
assert_eq!(report.kept[0].reason, "teams_invalid");
|
|
880
|
+
assert!(path.exists(), "malformed state entry must remain: {label}");
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
@@ -1706,12 +1706,35 @@ pub(crate) fn load_runtime_state_without_migrations(workspace: &Path) -> Result<
|
|
|
1706
1706
|
Ok(state)
|
|
1707
1707
|
}
|
|
1708
1708
|
|
|
1709
|
+
/// Run a read-only callback while holding the canonical `state-save` lock.
|
|
1710
|
+
/// Unlike `load_runtime_state`, this helper performs no migration, cache
|
|
1711
|
+
/// update, or write. A missing state file is represented as `None` so callers
|
|
1712
|
+
/// can conservatively retain derived indexes during workspace unavailability.
|
|
1713
|
+
pub(crate) fn with_runtime_state_lock_without_migrations<T>(
|
|
1714
|
+
workspace: &Path,
|
|
1715
|
+
callback: impl FnOnce(Option<Value>) -> T,
|
|
1716
|
+
) -> Result<T, StateError> {
|
|
1717
|
+
let path = runtime_state_path(workspace);
|
|
1718
|
+
if !path.exists() {
|
|
1719
|
+
return Ok(callback(None));
|
|
1720
|
+
}
|
|
1721
|
+
let _lock = RuntimeLock::acquire(workspace, "state-save", 2.0)?;
|
|
1722
|
+
let state = match std::fs::read_to_string(&path) {
|
|
1723
|
+
Ok(text) => Some(serde_json::from_str::<Value>(&text)?),
|
|
1724
|
+
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
|
|
1725
|
+
Err(error) => return Err(error.into()),
|
|
1726
|
+
};
|
|
1727
|
+
Ok(callback(state))
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1709
1730
|
pub(crate) fn save_runtime_state_without_migrations(
|
|
1710
1731
|
workspace: &Path,
|
|
1711
1732
|
state: &Value,
|
|
1712
1733
|
) -> Result<(), StateError> {
|
|
1713
1734
|
let path = runtime_state_path(workspace);
|
|
1714
|
-
|
|
1735
|
+
// Raw writers share the canonical lock so readers/pruners cannot race
|
|
1736
|
+
// a restore write through a parallel lock namespace.
|
|
1737
|
+
let _lock = RuntimeLock::acquire(workspace, "state-save", 2.0)?;
|
|
1715
1738
|
if let Some(parent) = path.parent() {
|
|
1716
1739
|
std::fs::create_dir_all(parent)?;
|
|
1717
1740
|
}
|
|
@@ -3546,4 +3569,30 @@ mod tests {
|
|
|
3546
3569
|
2,
|
|
3547
3570
|
);
|
|
3548
3571
|
}
|
|
3572
|
+
|
|
3573
|
+
#[test]
|
|
3574
|
+
fn state_save_lock_serializes_raw_writer_and_prune_reader() {
|
|
3575
|
+
let ws = temp_ws();
|
|
3576
|
+
save_runtime_state(&ws, &json!({"teams": {"fresh": {}}})).unwrap();
|
|
3577
|
+
let lock = RuntimeLock::acquire(&ws, "state-save", 2.0).unwrap();
|
|
3578
|
+
|
|
3579
|
+
let writer_ws = ws.clone();
|
|
3580
|
+
let writer = std::thread::spawn(move || {
|
|
3581
|
+
save_runtime_state_without_migrations(&writer_ws, &json!({"teams": {"fresh": {}}}))
|
|
3582
|
+
});
|
|
3583
|
+
std::thread::sleep(Duration::from_millis(100));
|
|
3584
|
+
assert!(!writer.is_finished(), "raw writer must share state-save lock");
|
|
3585
|
+
drop(lock);
|
|
3586
|
+
writer.join().unwrap().unwrap();
|
|
3587
|
+
|
|
3588
|
+
let lock = RuntimeLock::acquire(&ws, "state-save", 2.0).unwrap();
|
|
3589
|
+
let reader_ws = ws.clone();
|
|
3590
|
+
let reader = std::thread::spawn(move || {
|
|
3591
|
+
with_runtime_state_lock_without_migrations(&reader_ws, |state| state.is_some())
|
|
3592
|
+
});
|
|
3593
|
+
std::thread::sleep(Duration::from_millis(100));
|
|
3594
|
+
assert!(!reader.is_finished(), "prune reader must use state-save lock");
|
|
3595
|
+
drop(lock);
|
|
3596
|
+
assert!(reader.join().unwrap().unwrap());
|
|
3597
|
+
}
|
|
3549
3598
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.83",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.5.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.5.83",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.83",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.83"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|