@team-agent/installer 0.5.8 → 0.5.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/attach_app_server_leader.rs +82 -1
- package/crates/team-agent/src/cli/emit.rs +30 -1
- package/crates/team-agent/src/cli/leaders.rs +94 -0
- package/crates/team-agent/src/cli/mod.rs +138 -2
- package/crates/team-agent/src/cli/named_address.rs +254 -34
- package/crates/team-agent/src/cli/send.rs +370 -3
- package/crates/team-agent/src/cli/status_port.rs +20 -6
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +9 -3
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +14 -0
- package/crates/team-agent/src/leader/lease.rs +80 -3
- package/crates/team-agent/src/leader/mod.rs +1 -0
- package/crates/team-agent/src/leader/registry.rs +402 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
- package/crates/team-agent/src/mcp_server/normalize.rs +79 -0
- package/crates/team-agent/src/mcp_server/tools.rs +8 -1
- package/crates/team-agent/src/messaging/leader_receiver.rs +58 -0
- package/crates/team-agent/src/messaging/mod.rs +2 -1
- package/crates/team-agent/src/messaging/results.rs +53 -0
- package/crates/team-agent/src/messaging/watchers.rs +13 -3
- package/crates/team-agent/src/transport/tests/mod.rs +1 -0
- package/crates/team-agent/src/transport.rs +6 -0
- package/package.json +4 -4
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
//! Phase-DX E7 (0.5.9 host-leader-registry-design): file-per-leader discovery index.
|
|
2
|
+
//!
|
|
3
|
+
//! Location: `~/.team-agent/leaders/<workspace_hash>__<team_key>.json`.
|
|
4
|
+
//!
|
|
5
|
+
//! Registry entries are a **derived** discovery index — never a source of
|
|
6
|
+
//! authority. Read paths must always canonical-validate a resolved entry
|
|
7
|
+
//! against the target workspace's runtime state (leader_receiver,
|
|
8
|
+
//! owner_epoch, transport_kind) before delivering. Ambiguous short names
|
|
9
|
+
//! are refused (`name_ambiguous`) rather than resolved by any priority
|
|
10
|
+
//! heuristic.
|
|
11
|
+
//!
|
|
12
|
+
//! Design constraints (host-leader-registry-design.md §§3, 9):
|
|
13
|
+
//! - No shared/global relational store (file-per-team on the host filesystem).
|
|
14
|
+
//! - No identity writes on read/send paths — write only happens from
|
|
15
|
+
//! `claim-leader`/`attach-leader`/`takeover`/`attach-app-server-leader`
|
|
16
|
+
//! canonical success hooks and shutdown/unregister on canonical success.
|
|
17
|
+
//! - Registry write failure never fails the underlying binding command;
|
|
18
|
+
//! it is discoverability degradation, not binding failure.
|
|
19
|
+
|
|
20
|
+
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
21
|
+
|
|
22
|
+
use std::path::{Path, PathBuf};
|
|
23
|
+
|
|
24
|
+
use serde::{Deserialize, Serialize};
|
|
25
|
+
use sha2::{Digest, Sha256};
|
|
26
|
+
|
|
27
|
+
use crate::transport::Transport;
|
|
28
|
+
|
|
29
|
+
/// Current file schema_version for `~/.team-agent/leaders/*.json`.
|
|
30
|
+
pub const REGISTRY_SCHEMA_VERSION: u32 = 1;
|
|
31
|
+
|
|
32
|
+
/// Event name emitted after a successful atomic temp+rename write.
|
|
33
|
+
pub const EVENT_REGISTERED: &str = "leader_registry.registered";
|
|
34
|
+
|
|
35
|
+
/// Event name emitted when the write step (fs::write / rename / dir create)
|
|
36
|
+
/// failed — the binding command still returns ok, but discoverability
|
|
37
|
+
/// degrades until the next successful canonical hook.
|
|
38
|
+
pub const EVENT_WRITE_FAILED: &str = "leader_registry.write_failed";
|
|
39
|
+
|
|
40
|
+
/// Event name emitted when a canonical shutdown/unbind removed the entry.
|
|
41
|
+
pub const EVENT_UNREGISTERED: &str = "leader_registry.unregistered";
|
|
42
|
+
|
|
43
|
+
/// v1 file schema. Fields are copied verbatim from `LeaderRegistryEntry`
|
|
44
|
+
/// during atomic writes; readers deserialize the same shape then validate
|
|
45
|
+
/// against canonical state before using any field for routing.
|
|
46
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
47
|
+
pub struct LeaderRegistryEntry {
|
|
48
|
+
pub schema_version: u32,
|
|
49
|
+
/// Default delivery name — the runtime `team_key`. Never the display or
|
|
50
|
+
/// spec name (host-leader-registry-design.md §5.1).
|
|
51
|
+
pub delivery_name: String,
|
|
52
|
+
/// Human-friendly qualified name: `<workspace_short>/<team_key>`.
|
|
53
|
+
pub qualified_name: String,
|
|
54
|
+
/// Stable qualified name: `<workspace_hash>/<team_key>`. Always accepted
|
|
55
|
+
/// by resolvers even when the human form is ambiguous.
|
|
56
|
+
pub stable_qualified_name: String,
|
|
57
|
+
/// Explicit aliases attached at binding time. Alias collisions follow
|
|
58
|
+
/// the same `name_ambiguous` rule as delivery_name collisions.
|
|
59
|
+
pub aliases: Vec<String>,
|
|
60
|
+
pub workspace: PathBuf,
|
|
61
|
+
/// sha256-prefix of canonical realpath workspace; used only as a file
|
|
62
|
+
/// name component and stable identifier — never an authority key.
|
|
63
|
+
pub workspace_hash: String,
|
|
64
|
+
pub workspace_short: String,
|
|
65
|
+
pub team_key: String,
|
|
66
|
+
pub transport_kind: String,
|
|
67
|
+
pub channel: serde_json::Value,
|
|
68
|
+
pub owner_epoch: u64,
|
|
69
|
+
pub attached_at: String,
|
|
70
|
+
pub updated_at: String,
|
|
71
|
+
pub source: String,
|
|
72
|
+
pub status: String,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
impl LeaderRegistryEntry {
|
|
76
|
+
/// Ambiguous-name refusal semantics (host-leader-registry-design.md §5.2):
|
|
77
|
+
/// two entries with the same `delivery_name` remain visible in
|
|
78
|
+
/// `leaders` under `ambiguous_names`, and `send --to-leader <short>`
|
|
79
|
+
/// refuses `name_ambiguous`. Callers surface the ambiguity via
|
|
80
|
+
/// candidates rather than choosing a winner. The design explicitly
|
|
81
|
+
/// forbids priority heuristics based on write order, modification
|
|
82
|
+
/// time, or ambient state — see host-leader-registry-design.md §5.2
|
|
83
|
+
/// for the enumerated exclusions.
|
|
84
|
+
#[must_use]
|
|
85
|
+
pub fn short_name_collision_refuses() -> &'static str {
|
|
86
|
+
"ambiguous"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// Payload key for the list of short-name collisions surfaced by
|
|
91
|
+
/// `team-agent leaders` — kept as a top-level constant so both `leaders`
|
|
92
|
+
/// and `send --to-leader` refer to the same wire name.
|
|
93
|
+
pub const AMBIGUOUS_NAMES_FIELD: &str = "ambiguous_names";
|
|
94
|
+
|
|
95
|
+
/// Wire reason used by both `leaders` and `send --to-leader` when a name
|
|
96
|
+
/// resolves to multiple canonical-live entries. Candidates carry
|
|
97
|
+
/// `workspace_hash` + `stable_qualified_name` so the caller can retry with
|
|
98
|
+
/// a fully-qualified form.
|
|
99
|
+
pub const REASON_AMBIGUOUS: &str = "name_ambiguous";
|
|
100
|
+
|
|
101
|
+
/// Absolute path to `~/.team-agent/leaders`. Returns `None` when the
|
|
102
|
+
/// caller has no HOME (e.g. some CI shells) — callers should treat this as
|
|
103
|
+
/// "no registry" rather than an error, since registry is derived.
|
|
104
|
+
#[must_use]
|
|
105
|
+
pub fn registry_dir() -> Option<PathBuf> {
|
|
106
|
+
let home = std::env::var_os("HOME").map(PathBuf::from)?;
|
|
107
|
+
Some(home.join(".team-agent").join("leaders"))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// Compute the sha256 hex prefix (12 chars) of a canonical workspace path.
|
|
111
|
+
/// The canonical form is used so equivalent paths (symlinks, `.`, etc.)
|
|
112
|
+
/// map to the same discovery id. If canonicalize fails the input path is
|
|
113
|
+
/// used verbatim — the hash is a stable label, not an authority key.
|
|
114
|
+
#[must_use]
|
|
115
|
+
pub fn workspace_hash(workspace: &Path) -> String {
|
|
116
|
+
let canonical = std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
|
|
117
|
+
let mut hasher = Sha256::new();
|
|
118
|
+
hasher.update(canonical.to_string_lossy().as_bytes());
|
|
119
|
+
let digest = hasher.finalize();
|
|
120
|
+
digest[..6]
|
|
121
|
+
.iter()
|
|
122
|
+
.map(|byte| format!("{byte:02x}"))
|
|
123
|
+
.collect()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Compute the discovery-visible short label for a workspace path —
|
|
127
|
+
/// basename by default, "workspace" when the path has no name component.
|
|
128
|
+
#[must_use]
|
|
129
|
+
pub fn workspace_short(workspace: &Path) -> String {
|
|
130
|
+
workspace
|
|
131
|
+
.file_name()
|
|
132
|
+
.and_then(|s| s.to_str())
|
|
133
|
+
.map(str::to_string)
|
|
134
|
+
.unwrap_or_else(|| "workspace".to_string())
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// Assemble a schema-v1 entry from canonical binding fields. Callers use
|
|
138
|
+
/// this from the binding-command success hooks; the entry then flows into
|
|
139
|
+
/// [`write_entry_best_effort`].
|
|
140
|
+
#[must_use]
|
|
141
|
+
#[allow(clippy::too_many_arguments)]
|
|
142
|
+
pub fn build_entry(
|
|
143
|
+
workspace: &Path,
|
|
144
|
+
team_key: &str,
|
|
145
|
+
transport_kind: &str,
|
|
146
|
+
channel: serde_json::Value,
|
|
147
|
+
owner_epoch: u64,
|
|
148
|
+
source: &str,
|
|
149
|
+
now_rfc3339: String,
|
|
150
|
+
) -> LeaderRegistryEntry {
|
|
151
|
+
let hash = workspace_hash(workspace);
|
|
152
|
+
let short = workspace_short(workspace);
|
|
153
|
+
LeaderRegistryEntry {
|
|
154
|
+
schema_version: REGISTRY_SCHEMA_VERSION,
|
|
155
|
+
delivery_name: team_key.to_string(),
|
|
156
|
+
qualified_name: format!("{short}/{team_key}"),
|
|
157
|
+
stable_qualified_name: format!("{hash}/{team_key}"),
|
|
158
|
+
aliases: Vec::new(),
|
|
159
|
+
workspace: std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()),
|
|
160
|
+
workspace_hash: hash,
|
|
161
|
+
workspace_short: short,
|
|
162
|
+
team_key: team_key.to_string(),
|
|
163
|
+
transport_kind: transport_kind.to_string(),
|
|
164
|
+
channel,
|
|
165
|
+
owner_epoch,
|
|
166
|
+
attached_at: now_rfc3339.clone(),
|
|
167
|
+
updated_at: now_rfc3339,
|
|
168
|
+
source: source.to_string(),
|
|
169
|
+
status: "attached".to_string(),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fn entry_filename(entry: &LeaderRegistryEntry) -> String {
|
|
174
|
+
format!("{}__{}.json", entry.workspace_hash, entry.team_key)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// Registry write is best-effort. Writes to `.<file>.tmp-<pid>-<counter>`
|
|
178
|
+
/// first, then atomically renames to `<workspace_hash>__<team_key>.json`.
|
|
179
|
+
/// Returns the final path when the write succeeded, `None` otherwise —
|
|
180
|
+
/// the binding command must never fail on registry errors.
|
|
181
|
+
pub fn write_entry_best_effort(entry: &LeaderRegistryEntry) -> Option<PathBuf> {
|
|
182
|
+
let dir = registry_dir()?;
|
|
183
|
+
if let Err(_error) = std::fs::create_dir_all(&dir) {
|
|
184
|
+
return None;
|
|
185
|
+
}
|
|
186
|
+
let final_path = dir.join(entry_filename(entry));
|
|
187
|
+
let tmp_name = format!(
|
|
188
|
+
".{}.tmp-{}-{}",
|
|
189
|
+
entry_filename(entry),
|
|
190
|
+
std::process::id(),
|
|
191
|
+
rand_suffix()
|
|
192
|
+
);
|
|
193
|
+
let tmp_path = dir.join(tmp_name);
|
|
194
|
+
let serialized = serde_json::to_string_pretty(entry).ok()?;
|
|
195
|
+
if std::fs::write(&tmp_path, serialized).is_err() {
|
|
196
|
+
let _ = std::fs::remove_file(&tmp_path);
|
|
197
|
+
return None;
|
|
198
|
+
}
|
|
199
|
+
if std::fs::rename(&tmp_path, &final_path).is_err() {
|
|
200
|
+
let _ = std::fs::remove_file(&tmp_path);
|
|
201
|
+
return None;
|
|
202
|
+
}
|
|
203
|
+
Some(final_path)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
fn rand_suffix() -> String {
|
|
207
|
+
// Non-cryptographic uniqueness: a process-local monotonic counter is
|
|
208
|
+
// enough because the tmp name only needs to be unique within one
|
|
209
|
+
// process lifetime (we also embed the pid).
|
|
210
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
211
|
+
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
212
|
+
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
213
|
+
format!("{n:x}")
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// Remove the registry entry for `(workspace, team_key)` if it exists.
|
|
217
|
+
/// Called from shutdown/unbind success hooks after canonical state
|
|
218
|
+
/// reflects the leader as no longer bound. Missing files are treated as
|
|
219
|
+
/// success — the invariant is "no registry entry after unbind", not
|
|
220
|
+
/// "must have found something to delete".
|
|
221
|
+
pub fn unregister_entry(workspace: &Path, team_key: &str) -> Option<PathBuf> {
|
|
222
|
+
let dir = registry_dir()?;
|
|
223
|
+
let hash = workspace_hash(workspace);
|
|
224
|
+
let path = dir.join(format!("{hash}__{team_key}.json"));
|
|
225
|
+
if path.exists() {
|
|
226
|
+
std::fs::remove_file(&path).ok()?;
|
|
227
|
+
return Some(path);
|
|
228
|
+
}
|
|
229
|
+
None
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/// Read and deserialize every `*.json` file under the registry directory.
|
|
233
|
+
/// Skips unreadable / malformed files silently (returns fewer entries) —
|
|
234
|
+
/// registry is a derived discovery index and unreadable files are the
|
|
235
|
+
/// "STALE / UNREADABLE" dirty class rather than an error.
|
|
236
|
+
#[must_use]
|
|
237
|
+
pub fn read_all_entries() -> Vec<(PathBuf, LeaderRegistryEntry)> {
|
|
238
|
+
let Some(dir) = registry_dir() else {
|
|
239
|
+
return Vec::new();
|
|
240
|
+
};
|
|
241
|
+
let Ok(read_dir) = std::fs::read_dir(&dir) else {
|
|
242
|
+
return Vec::new();
|
|
243
|
+
};
|
|
244
|
+
let mut out = Vec::new();
|
|
245
|
+
for entry in read_dir.flatten() {
|
|
246
|
+
let path = entry.path();
|
|
247
|
+
if path
|
|
248
|
+
.file_name()
|
|
249
|
+
.and_then(|s| s.to_str())
|
|
250
|
+
.is_some_and(|name| name.starts_with('.'))
|
|
251
|
+
{
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
let Ok(text) = std::fs::read_to_string(&path) else {
|
|
258
|
+
continue;
|
|
259
|
+
};
|
|
260
|
+
let Ok(entry) = serde_json::from_str::<LeaderRegistryEntry>(&text) else {
|
|
261
|
+
continue;
|
|
262
|
+
};
|
|
263
|
+
out.push((path, entry));
|
|
264
|
+
}
|
|
265
|
+
out.sort_by(|a, b| a.0.cmp(&b.0));
|
|
266
|
+
out
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/// Validate a registry entry against its canonical workspace state.
|
|
270
|
+
/// Returns the wire status label — LIVE when canonical `leader_receiver`
|
|
271
|
+
/// matches, STALE otherwise. This is the pruning gate for GC and the
|
|
272
|
+
/// per-entry classification the `leaders` CLI reports.
|
|
273
|
+
#[must_use]
|
|
274
|
+
pub fn classify(entry: &LeaderRegistryEntry) -> (&'static str, Option<String>) {
|
|
275
|
+
let Ok(state) = crate::state::persist::load_runtime_state(&entry.workspace) else {
|
|
276
|
+
return ("STALE", Some("workspace_no_state".to_string()));
|
|
277
|
+
};
|
|
278
|
+
let team = state
|
|
279
|
+
.get("teams")
|
|
280
|
+
.and_then(|v| v.as_object())
|
|
281
|
+
.and_then(|teams| teams.get(&entry.team_key));
|
|
282
|
+
let team = match team {
|
|
283
|
+
Some(t) => t,
|
|
284
|
+
None => return ("STALE", Some("team_key_not_found".to_string())),
|
|
285
|
+
};
|
|
286
|
+
// Team status must indicate liveness. Empty / missing counts as
|
|
287
|
+
// alive for pre-status states; explicit down/stopped/archived is
|
|
288
|
+
// terminal STALE.
|
|
289
|
+
let team_status = team
|
|
290
|
+
.get("status")
|
|
291
|
+
.and_then(|v| v.as_str())
|
|
292
|
+
.unwrap_or("alive");
|
|
293
|
+
if team_status == "down" || team_status == "stopped" || team_status == "archived" {
|
|
294
|
+
return ("STALE", Some("team_not_alive".to_string()));
|
|
295
|
+
}
|
|
296
|
+
let receiver = team.get("leader_receiver");
|
|
297
|
+
let receiver = match receiver {
|
|
298
|
+
Some(r) if !r.is_null() => r,
|
|
299
|
+
_ => return ("STALE", Some("leader_not_attached".to_string())),
|
|
300
|
+
};
|
|
301
|
+
let canonical_epoch = receiver
|
|
302
|
+
.get("owner_epoch")
|
|
303
|
+
.and_then(|v| v.as_u64())
|
|
304
|
+
.or_else(|| team.get("owner_epoch").and_then(|v| v.as_u64()))
|
|
305
|
+
.unwrap_or(0);
|
|
306
|
+
if canonical_epoch != entry.owner_epoch {
|
|
307
|
+
return (
|
|
308
|
+
"STALE",
|
|
309
|
+
Some(format!(
|
|
310
|
+
"owner_epoch_mismatch:registry={},canonical={}",
|
|
311
|
+
entry.owner_epoch, canonical_epoch
|
|
312
|
+
)),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
// Verify the recorded leader pane is actually live on its recorded
|
|
316
|
+
// tmux endpoint. A killed tmux session (test's kill_session) means
|
|
317
|
+
// the receiver is stale even though the state hasn't been updated.
|
|
318
|
+
let pane_id = receiver.get("pane_id").and_then(|v| v.as_str());
|
|
319
|
+
let socket = receiver.get("tmux_socket").and_then(|v| v.as_str());
|
|
320
|
+
if let (Some(pane), Some(sock)) = (pane_id, socket) {
|
|
321
|
+
if !tmux_pane_live(sock, pane) {
|
|
322
|
+
return ("STALE", Some("leader_pane_dead".to_string()));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
("LIVE", None)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
fn tmux_pane_live(socket: &str, pane_id: &str) -> bool {
|
|
329
|
+
// Best-effort tmux liveness check via the same transport factory the
|
|
330
|
+
// main runtime uses. On error we treat the pane as NOT live so a
|
|
331
|
+
// send never routes through a socket we could not verify.
|
|
332
|
+
let backend = crate::transport_factory::tmux_endpoint_transport(socket);
|
|
333
|
+
match backend.list_targets() {
|
|
334
|
+
Ok(targets) => targets.iter().any(|t| t.pane_id.as_str() == pane_id),
|
|
335
|
+
Err(_) => false,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/// Return the canonical state's `leader_receiver` value for the entry's
|
|
340
|
+
/// team, if one exists. Send/leaders paths use this so they never route
|
|
341
|
+
/// through a stale registry channel — canonical state is the truth.
|
|
342
|
+
#[must_use]
|
|
343
|
+
pub fn canonical_receiver(entry: &LeaderRegistryEntry) -> Option<serde_json::Value> {
|
|
344
|
+
let state = crate::state::persist::load_runtime_state(&entry.workspace).ok()?;
|
|
345
|
+
let team = state
|
|
346
|
+
.get("teams")
|
|
347
|
+
.and_then(|v| v.as_object())
|
|
348
|
+
.and_then(|teams| teams.get(&entry.team_key))?;
|
|
349
|
+
team.get("leader_receiver").cloned()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/// Read all entries, classify each, and prune terminal-stale entries
|
|
353
|
+
/// (`workspace_no_state`, `team_key_not_found`). Live entries are never
|
|
354
|
+
/// touched. Returns the surviving (LIVE + STALE-not-yet-pruned) entries
|
|
355
|
+
/// classified for the `leaders` command output.
|
|
356
|
+
///
|
|
357
|
+
/// Design §14 step 6: "Successful scoped shutdown removes matching registry
|
|
358
|
+
/// entry. Failed/degraded shutdown leaves entry STALE, not deleted."
|
|
359
|
+
/// The pruning here is the GC arm — it only removes entries whose
|
|
360
|
+
/// canonical workspace/team has no state at all, i.e. the target has been
|
|
361
|
+
/// permanently removed. Leader-detached-but-team-alive stays STALE and
|
|
362
|
+
/// visible so the operator can decide.
|
|
363
|
+
#[must_use]
|
|
364
|
+
pub fn list_validated_with_gc() -> Vec<(LeaderRegistryEntry, &'static str, Option<String>)> {
|
|
365
|
+
let mut out = Vec::new();
|
|
366
|
+
for (path, entry) in read_all_entries() {
|
|
367
|
+
let (status, reason) = classify(&entry);
|
|
368
|
+
// Terminal stale reasons that indicate the entry can never be
|
|
369
|
+
// useful again: canonical workspace/team gone, or the leader
|
|
370
|
+
// binding has been fully released (no receiver at all). These
|
|
371
|
+
// are safe to prune; leader-attached-with-dead-pane and
|
|
372
|
+
// epoch-mismatch stay visible so the operator can see them.
|
|
373
|
+
if status == "STALE"
|
|
374
|
+
&& reason.as_deref().is_some_and(|r| {
|
|
375
|
+
r == "workspace_no_state"
|
|
376
|
+
|| r == "team_key_not_found"
|
|
377
|
+
|| r == "leader_not_attached"
|
|
378
|
+
|| r == "team_not_alive"
|
|
379
|
+
})
|
|
380
|
+
{
|
|
381
|
+
let _ = std::fs::remove_file(&path);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
out.push((entry, status, reason));
|
|
385
|
+
}
|
|
386
|
+
out
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/// Same as `list_validated_with_gc` but preserves all entries. `send
|
|
390
|
+
/// --to-leader` needs to see stale entries so it can refuse with
|
|
391
|
+
/// `registry_stale` (and never silently fall through to
|
|
392
|
+
/// `leader_name_not_found` just because GC ran first).
|
|
393
|
+
#[must_use]
|
|
394
|
+
pub fn list_validated_no_gc() -> Vec<(LeaderRegistryEntry, &'static str, Option<String>)> {
|
|
395
|
+
read_all_entries()
|
|
396
|
+
.into_iter()
|
|
397
|
+
.map(|(_path, entry)| {
|
|
398
|
+
let (status, reason) = classify(&entry);
|
|
399
|
+
(entry, status, reason)
|
|
400
|
+
})
|
|
401
|
+
.collect()
|
|
402
|
+
}
|
|
@@ -118,6 +118,84 @@ pub fn normalize_report_envelope(env: &Value) -> NormalizedReportEnvelope {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
pub(crate) fn report_result_integrity_warnings(
|
|
122
|
+
source: &Value,
|
|
123
|
+
normalized: &NormalizedReportEnvelope,
|
|
124
|
+
) -> Vec<Value> {
|
|
125
|
+
let mut warnings = source
|
|
126
|
+
.get("warnings")
|
|
127
|
+
.and_then(Value::as_array)
|
|
128
|
+
.cloned()
|
|
129
|
+
.unwrap_or_default();
|
|
130
|
+
|
|
131
|
+
match normalized.status {
|
|
132
|
+
ResultStatus::Success => {
|
|
133
|
+
if normalized.tests.is_empty()
|
|
134
|
+
|| normalized
|
|
135
|
+
.tests
|
|
136
|
+
.iter()
|
|
137
|
+
.all(|test| test.status == TestStatus::NotRun)
|
|
138
|
+
{
|
|
139
|
+
push_integrity_warning(
|
|
140
|
+
&mut warnings,
|
|
141
|
+
"result_success_without_executed_tests",
|
|
142
|
+
"tests",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if change_path_missing_description(source.get("changes")) {
|
|
146
|
+
push_integrity_warning(
|
|
147
|
+
&mut warnings,
|
|
148
|
+
"result_change_missing_description",
|
|
149
|
+
"changes",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
ResultStatus::Partial | ResultStatus::Blocked => {
|
|
154
|
+
if normalized.tests.is_empty()
|
|
155
|
+
|| normalized
|
|
156
|
+
.tests
|
|
157
|
+
.iter()
|
|
158
|
+
.any(|test| test.status == TestStatus::NotRun)
|
|
159
|
+
{
|
|
160
|
+
push_integrity_warning(&mut warnings, "result_not_verified", "tests");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
ResultStatus::Failed => {}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
warnings
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
fn push_integrity_warning(warnings: &mut Vec<Value>, code: &str, field: &str) {
|
|
170
|
+
let exists = warnings.iter().any(|warning| {
|
|
171
|
+
warning.get("code").and_then(Value::as_str) == Some(code)
|
|
172
|
+
&& warning.get("field").and_then(Value::as_str) == Some(field)
|
|
173
|
+
});
|
|
174
|
+
if !exists {
|
|
175
|
+
warnings.push(serde_json::json!({
|
|
176
|
+
"code": code,
|
|
177
|
+
"field": field,
|
|
178
|
+
"severity": "warning",
|
|
179
|
+
"advisory": true
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
fn change_path_missing_description(value: Option<&Value>) -> bool {
|
|
185
|
+
items_from_value(value).iter().any(|item| {
|
|
186
|
+
let Some(obj) = item.as_object() else {
|
|
187
|
+
return false;
|
|
188
|
+
};
|
|
189
|
+
let has_path = ["path", "file", "filepath", "filename"]
|
|
190
|
+
.iter()
|
|
191
|
+
.any(|key| obj.get(*key).and_then(text_of_value).is_some());
|
|
192
|
+
let has_description = ["description", "summary", "detail", "details", "message"]
|
|
193
|
+
.iter()
|
|
194
|
+
.any(|key| obj.get(*key).and_then(text_of_value).is_some());
|
|
195
|
+
has_path && !has_description
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
121
199
|
/// `_compact_tool_result` (`normalize.py:6-64`): whitelist-key compaction of a
|
|
122
200
|
/// delegate result. ok vs error use different key sets; `fanout_*` status preserves
|
|
123
201
|
/// `deliveries`/`recipients`; `acknowledged_messages` → `acknowledged_count` (len).
|
|
@@ -170,6 +248,7 @@ pub fn compact_tool_result(result: &Value) -> ToolResult {
|
|
|
170
248
|
"notification_status",
|
|
171
249
|
"notification_channel",
|
|
172
250
|
"notification_event_id",
|
|
251
|
+
"warnings",
|
|
173
252
|
]
|
|
174
253
|
};
|
|
175
254
|
for key in keys {
|
|
@@ -26,6 +26,7 @@ use super::helpers::{
|
|
|
26
26
|
};
|
|
27
27
|
use super::normalize::{
|
|
28
28
|
compact_tool_result, normalize_report_envelope, normalize_result_status_observed,
|
|
29
|
+
report_result_integrity_warnings,
|
|
29
30
|
};
|
|
30
31
|
use super::types::{
|
|
31
32
|
Scope, SendOutcome, ToolError, ToolErrorReason, ToolOk, ToolResult, VisiblePeers,
|
|
@@ -399,7 +400,13 @@ impl TeamOrchestratorTools {
|
|
|
399
400
|
self.note_unknown_result_status(&raw);
|
|
400
401
|
}
|
|
401
402
|
let normalized = normalize_report_envelope(&base);
|
|
402
|
-
let
|
|
403
|
+
let warnings = report_result_integrity_warnings(&base, &normalized);
|
|
404
|
+
let mut env_value = normalized_envelope_value(&normalized);
|
|
405
|
+
if !warnings.is_empty() {
|
|
406
|
+
if let Some(obj) = env_value.as_object_mut() {
|
|
407
|
+
obj.insert("warnings".to_string(), Value::Array(warnings));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
403
410
|
let owner_team = self.canonical_owner_team_key()?;
|
|
404
411
|
messaging::report_result_for_owner_team(
|
|
405
412
|
&self.workspace,
|
|
@@ -541,3 +541,61 @@ fn render_fallback_pane_message(content: &str, message_id: &str, primary_error:
|
|
|
541
541
|
[team-agent-token:{message_id}]"
|
|
542
542
|
)
|
|
543
543
|
}
|
|
544
|
+
|
|
545
|
+
/// E6 (0.5.9 offline-mailbox-toname-design §6.3): enqueue a leader-bound message
|
|
546
|
+
/// row in the TARGET workspace `team.db` with status `queued_until_leader_attach`.
|
|
547
|
+
///
|
|
548
|
+
/// This is a durable "leader mailbox" write, NOT a delivery. Coordinator delivery
|
|
549
|
+
/// never claims this status (§3.3), so the row will not churn or be marked
|
|
550
|
+
/// `failed` while the leader is unattached. When the target owner runs
|
|
551
|
+
/// `attach-leader` / `claim-leader`, the existing `requeue_blocked_leader_messages`
|
|
552
|
+
/// helper flips the same row to `accepted` and the standard delivery pipeline
|
|
553
|
+
/// injects it exactly once.
|
|
554
|
+
///
|
|
555
|
+
/// Safety boundary (§5): only the `messages` table is touched. Owner identity
|
|
556
|
+
/// (`leader_receiver` / `team_owner` / `owner_epoch`) is never written by this
|
|
557
|
+
/// path, and no provider/worker process is spawned.
|
|
558
|
+
pub fn enqueue_leader_mailbox_until_attach(
|
|
559
|
+
target_workspace: &Path,
|
|
560
|
+
canonical_team_key: &str,
|
|
561
|
+
content: &str,
|
|
562
|
+
task_id: Option<&TaskId>,
|
|
563
|
+
sender: &str,
|
|
564
|
+
event_log: &EventLog,
|
|
565
|
+
) -> Result<DeliveryOutcome, MessagingError> {
|
|
566
|
+
let store = MessageStore::open(target_workspace)?;
|
|
567
|
+
let message_id = store.create_message(
|
|
568
|
+
task_id.map(TaskId::as_str),
|
|
569
|
+
sender,
|
|
570
|
+
"leader",
|
|
571
|
+
content,
|
|
572
|
+
None,
|
|
573
|
+
false,
|
|
574
|
+
Some(canonical_team_key),
|
|
575
|
+
)?;
|
|
576
|
+
// The default row status after `create_message` is `accepted`. Flip it to
|
|
577
|
+
// `queued_until_leader_attach` so it stays out of `claim_for_delivery`
|
|
578
|
+
// eligible set and out of `deliver_pending_messages` scan until an
|
|
579
|
+
// attach/claim explicitly requeues it (see requeue_blocked_leader_messages).
|
|
580
|
+
store.mark(&message_id, "queued_until_leader_attach", None)?;
|
|
581
|
+
event_log.write(
|
|
582
|
+
"leader_mailbox.queued_until_attach",
|
|
583
|
+
serde_json::json!({
|
|
584
|
+
"message_id": message_id,
|
|
585
|
+
"owner_team_id": canonical_team_key,
|
|
586
|
+
"sender": sender,
|
|
587
|
+
"target_workspace": target_workspace.display().to_string(),
|
|
588
|
+
"channel": "leader_mailbox",
|
|
589
|
+
}),
|
|
590
|
+
)?;
|
|
591
|
+
Ok(DeliveryOutcome {
|
|
592
|
+
ok: true,
|
|
593
|
+
status: DeliveryStatus::Queued,
|
|
594
|
+
message_status: MessageStatusShadow("queued_until_leader_attach".to_string()),
|
|
595
|
+
message_id: Some(message_id),
|
|
596
|
+
verification: None,
|
|
597
|
+
stage: None,
|
|
598
|
+
reason: None,
|
|
599
|
+
channel: Some("leader_mailbox".to_string()),
|
|
600
|
+
})
|
|
601
|
+
}
|
|
@@ -83,7 +83,8 @@ pub use delivery::{
|
|
|
83
83
|
};
|
|
84
84
|
pub use helpers::fail_leader_delivery;
|
|
85
85
|
pub use leader_receiver::{
|
|
86
|
-
deliver_to_leader_fallback_pane,
|
|
86
|
+
deliver_to_leader_fallback_pane, enqueue_leader_mailbox_until_attach,
|
|
87
|
+
mirror_peer_message_to_leader, send_to_leader_receiver,
|
|
87
88
|
send_to_leader_receiver_with_message_id,
|
|
88
89
|
};
|
|
89
90
|
pub use peers::allow_peer_talk;
|