@team-agent/installer 0.5.50 → 0.5.52

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.
Files changed (93) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +5 -3
  4. package/crates/team-agent/src/cli/emit.rs +2 -1
  5. package/crates/team-agent/src/cli/mod.rs +32 -14
  6. package/crates/team-agent/src/cli/named_address.rs +46 -115
  7. package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
  8. package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
  9. package/crates/team-agent/src/cli/send/persist.rs +154 -0
  10. package/crates/team-agent/src/cli/send/presentation.rs +333 -0
  11. package/crates/team-agent/src/cli/send/resolve.rs +361 -0
  12. package/crates/team-agent/src/cli/send.rs +44 -1169
  13. package/crates/team-agent/src/cli/spec.rs +1 -1
  14. package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
  15. package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
  16. package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
  17. package/crates/team-agent/src/cli/status_port/format.rs +145 -0
  18. package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
  19. package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
  20. package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
  21. package/crates/team-agent/src/cli/status_port/store.rs +412 -0
  22. package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
  23. package/crates/team-agent/src/cli/status_port.rs +47 -1548
  24. package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
  25. package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -0
  26. package/crates/team-agent/src/cli/types.rs +1 -0
  27. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  28. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  29. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  30. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  31. package/crates/team-agent/src/db/message_store.rs +154 -44
  32. package/crates/team-agent/src/event_log.rs +73 -0
  33. package/crates/team-agent/src/leader/lease.rs +202 -16
  34. package/crates/team-agent/src/leader/start.rs +28 -4
  35. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  36. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +303 -0
  37. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  38. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  39. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  40. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  41. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  42. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  43. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  44. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  45. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  46. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  47. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  48. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  49. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  50. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  51. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  52. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  53. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  54. package/crates/team-agent/src/lifecycle/restart/agent.rs +63 -29
  55. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  56. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +167 -27
  57. package/crates/team-agent/src/lifecycle/restart/remove.rs +503 -77
  58. package/crates/team-agent/src/lifecycle/restart.rs +26 -2
  59. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +906 -17
  60. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +67 -2
  61. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  62. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  63. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  64. package/crates/team-agent/src/mcp_server/mod.rs +1 -1
  65. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -10
  66. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  67. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  68. package/crates/team-agent/src/mcp_server/tools.rs +14 -5
  69. package/crates/team-agent/src/messaging/activity.rs +4 -2
  70. package/crates/team-agent/src/messaging/address.rs +86 -0
  71. package/crates/team-agent/src/messaging/delivery.rs +288 -183
  72. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  73. package/crates/team-agent/src/messaging/leader_channel.rs +171 -0
  74. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  75. package/crates/team-agent/src/messaging/mod.rs +15 -2
  76. package/crates/team-agent/src/messaging/persist.rs +309 -0
  77. package/crates/team-agent/src/messaging/results.rs +16 -24
  78. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  79. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  80. package/crates/team-agent/src/messaging/send.rs +101 -49
  81. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  82. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  83. package/crates/team-agent/src/messaging/tests/mod.rs +2 -0
  84. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  85. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  86. package/crates/team-agent/src/redaction.rs +72 -2
  87. package/crates/team-agent/src/state/persist.rs +284 -21
  88. package/crates/team-agent/src/state/projection.rs +245 -30
  89. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  90. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  91. package/crates/team-agent/src/state/repository.rs +94 -27
  92. package/crates/team-agent/src/state/selector.rs +9 -18
  93. package/package.json +4 -4
@@ -1,11 +1,46 @@
1
- //! status_port extracted from cli::mod inline placeholder.
1
+ //! Status read-model facade. RuntimeSnapshot is the sole live + persisted assembler.
2
2
  use super::*;
3
3
  use crate::state::projection::OwnerTeamResolution;
4
4
  use crate::transport::Transport;
5
5
  use rusqlite::params;
6
6
  use std::path::PathBuf;
7
7
 
8
- /// `status.status(workspace, as_json, compact)`(`queries.py:33`,**有副作用**:capture→refresh→save)。
8
+ mod snapshot;
9
+ use snapshot::read_runtime_state;
10
+ pub(crate) use snapshot::RuntimeSnapshot;
11
+
12
+ mod format;
13
+ pub use format::format_approvals;
14
+ use format::{ensure_agent_known, format_agent_status};
15
+
16
+ mod approvals;
17
+ pub(super) use approvals::*;
18
+ pub use approvals::{approvals, approvals_scoped};
19
+
20
+ mod inbox;
21
+ pub use inbox::inbox;
22
+ pub(super) use inbox::*;
23
+
24
+ mod agents;
25
+ pub(super) use agents::*;
26
+
27
+ mod store;
28
+ use store::{
29
+ agent_health, count_rows, count_undelivered_backlog, count_where_status, insert_optional_i64,
30
+ insert_optional_string, latest_result_summaries, message_counts, pending_leader_notifications,
31
+ queued_messages, recent_agent_messages, result_counts, result_status_counts, status_counts,
32
+ };
33
+
34
+ mod compact;
35
+ pub(super) use compact::*;
36
+
37
+ mod runtime;
38
+ pub(super) use runtime::*;
39
+
40
+ #[cfg(test)]
41
+ mod tests;
42
+
43
+ /// `status.status(workspace, as_json, compact)` (`queries.py:33`).
9
44
  pub fn status(workspace: &Path, compact: bool, detail: bool) -> Result<Value, CliError> {
10
45
  let state = read_runtime_state(workspace);
11
46
  status_scoped(workspace, &state, None, compact, detail)
@@ -18,120 +53,14 @@ pub fn status_scoped(
18
53
  compact: bool,
19
54
  detail: bool,
20
55
  ) -> Result<Value, CliError> {
21
- // commands.py:99 — `--json --detail` maps to compact=False: detail wins and
22
- // returns the FULL payload.
23
- let compact = compact && !detail;
24
- let resolved_owner_team_id = resolve_status_owner_team(workspace, owner_team_id)?;
25
- let owner_team_id = resolved_owner_team_id.as_deref().or(owner_team_id);
26
- let health = crate::coordinator::coordinator_health(&crate::coordinator::WorkspacePath::new(
27
- workspace.to_path_buf(),
28
- ));
29
- let store = crate::message_store::MessageStore::open(workspace)
30
- .map_err(|e| CliError::Runtime(e.to_string()))?;
31
- let conn = crate::db::schema::open_db(store.db_path())
32
- .map_err(|e| CliError::Runtime(e.to_string()))?;
33
- // B-5 / 036b N38 explicable — status 出口 runtime 块:把 coordinator_health
34
- // (现状)+ undelivered backlog count 一起暴露;coordinator not running ∧
35
- // backlog>0 才挂 down-hint(anti-nag)。auto-recovery 不做(user 已裁)。
36
- let coordinator_running = coordinator_status_running(&health);
37
- let undelivered_backlog = count_undelivered_backlog(&conn, owner_team_id)?;
38
- let session_name = state.get("session_name").cloned().unwrap_or(Value::Null);
39
- let tmux_present = tmux_session_present(workspace, state, session_name.as_str());
40
- // 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3): resolve
41
- // RuntimeFreshness once and thread it through the runtime block
42
- // and per-agent enrichment. This is the single-source read that
43
- // makes host-boot / coordinator / provider-exit staleness win
44
- // over cached state.agents and DB agent_health.
45
- let freshness = compute_runtime_freshness(workspace, state, &health);
46
- let runtime_block = build_runtime_status_block(
47
- coordinator_running,
48
- undelivered_backlog,
49
- !tmux_present,
50
- &freshness,
51
- );
52
- let agents = enrich_agents(workspace, state, tmux_present, &freshness);
53
- let tasks = state.get("tasks").cloned().unwrap_or_else(|| json!([]));
54
- let leader_receiver = state
55
- .get("leader_receiver")
56
- .cloned()
57
- .unwrap_or_else(|| json!({}));
58
- let is_external_leader = crate::state::projection::state_is_external_leader(state);
59
- let leader_topology = if is_external_leader {
60
- "external"
61
- } else {
62
- "managed"
63
- };
64
- let leader_attach_command = if is_external_leader {
65
- None
66
- } else {
67
- let window_name = state
68
- .pointer("/leader_receiver/window_name")
69
- .and_then(Value::as_str)
70
- .unwrap_or("leader");
71
- session_name.as_str().and_then(|session| {
72
- // Bug #7 (gate review §6): build the attach command from the
73
- // SAME endpoint the readiness probe uses (state's persisted
74
- // tmux_endpoint/tmux_socket), so the printed command matches
75
- // where the session actually lives.
76
- crate::tmux_backend::attach_command_for_runtime_state_or_workspace(
77
- workspace,
78
- Some(state),
79
- &crate::transport::SessionName::new(session.to_string()),
80
- window_name,
81
- )
82
- })
83
- };
84
- let mut readiness_state = state.clone();
85
- if let Some(obj) = readiness_state.as_object_mut() {
86
- obj.insert(
87
- "tmux_session_present".to_string(),
88
- serde_json::json!(tmux_present),
89
- );
90
- }
91
- let readiness = crate::cli::diagnose::wait_readiness(&readiness_state);
92
- let full = crate::redaction::redact_external_value(&json!({
93
- "ok": true,
94
- "team": state.pointer("/leader/id").cloned().unwrap_or_else(|| json!("leader")),
95
- "session_name": state.get("session_name").cloned().unwrap_or(Value::Null),
96
- "leader_topology": leader_topology,
97
- "is_external_leader": is_external_leader,
98
- "leader_attach_command": leader_attach_command,
99
- "leader_client": state.get("leader_client").cloned().unwrap_or(Value::Null),
100
- "tmux_session_present": tmux_present,
101
- "all_spawned": readiness.get("all_spawned").cloned().unwrap_or(Value::Bool(false)),
102
- "all_attached_receiver": readiness.get("all_attached_receiver").cloned().unwrap_or(Value::Bool(true)),
103
- "all_resumable_have_session": readiness.get("all_resumable_have_session").cloned().unwrap_or(Value::Bool(true)),
104
- "session_capture_complete": readiness.get("session_capture_complete").cloned().unwrap_or(Value::Bool(true)),
105
- "session_capture_incomplete": readiness.get("session_capture_incomplete").cloned().unwrap_or(Value::Bool(false)),
106
- "incomplete_session_capture_agents": readiness.get("incomplete_session_capture_agents").cloned().unwrap_or_else(|| json!([])),
107
- "pending_session_agent_ids": readiness.get("pending_session_agent_ids").cloned().unwrap_or_else(|| json!([])),
108
- "leader_receiver": leader_receiver,
109
- "teams": state.get("teams").cloned().unwrap_or_else(|| json!({})),
110
- "agents": agents,
111
- "agent_health": agent_health(&conn, owner_team_id)?,
112
- "tasks": tasks,
113
- "messages": message_counts(&conn, owner_team_id)?,
114
- "queued_messages": queued_messages(&conn, owner_team_id, 8)?,
115
- "pending_leader_notifications": pending_leader_notifications(&conn, owner_team_id, 8)?,
116
- "results": result_counts(&conn, owner_team_id)?,
117
- "latest_results": latest_result_summaries(&store, owner_team_id)?,
118
- "readiness": readiness,
119
- "coordinator": coordinator_health_value(health),
120
- "runtime": runtime_block,
121
- "reminder": crate::cli::STATUS_REMINDER,
122
- "last_events": Value::Array(
123
- crate::event_log::EventLog::new(workspace)
124
- .tail(10)
125
- .map_err(|e| CliError::Runtime(e.to_string()))?,
126
- ),
127
- }));
128
- if compact {
129
- Ok(compact_status(full))
56
+ let snapshot = RuntimeSnapshot::assemble(workspace, state, owner_team_id)?;
57
+ if compact && !detail {
58
+ Ok(snapshot.compact())
130
59
  } else {
131
- Ok(full)
60
+ Ok(snapshot.into_full())
132
61
  }
133
62
  }
134
- /// `status.format_status(workspace, agent)`(人读)。
63
+
135
64
  pub fn format_status(workspace: &Path, agent: Option<&str>) -> Result<String, CliError> {
136
65
  let state = read_runtime_state(workspace);
137
66
  format_status_scoped(workspace, &state, None, agent)
@@ -143,1443 +72,13 @@ pub fn format_status_scoped(
143
72
  owner_team_id: Option<&str>,
144
73
  agent: Option<&str>,
145
74
  ) -> Result<String, CliError> {
75
+ let snapshot = RuntimeSnapshot::assemble(workspace, state, owner_team_id)?;
146
76
  match agent {
147
- // queries.py:130-162 — the agent branch renders the multi-line agent detail
148
- // from the FULL status payload; an unknown agent id errors.
149
77
  Some(agent) => {
150
- let status = status_scoped(workspace, state, owner_team_id, false, false)?;
151
- format_agent_status(workspace, &status, agent)
152
- }
153
- None => {
154
- let status = status_scoped(workspace, state, owner_team_id, false, false)?;
155
- Ok(crate::cli::format_status_csv(&status))
156
- }
157
- }
158
- }
159
-
160
- /// `format_status` agent 分支(`queries.py:135-162`)。
161
- fn format_agent_status(
162
- workspace: &Path,
163
- status: &Value,
164
- agent_id: &str,
165
- ) -> Result<String, CliError> {
166
- let agents = status.get("agents").and_then(Value::as_object);
167
- let health = status.get("agent_health").and_then(Value::as_object);
168
- let known = agents.is_some_and(|map| map.contains_key(agent_id))
169
- || health.is_some_and(|map| map.contains_key(agent_id));
170
- if !known {
171
- return Err(CliError::Runtime(format!("unknown agent id: {agent_id}")));
172
- }
173
- let empty = json!({});
174
- let agent = agents.and_then(|map| map.get(agent_id)).unwrap_or(&empty);
175
- let row = health.and_then(|map| map.get(agent_id)).unwrap_or(&empty);
176
- let status_text = row
177
- .get("status")
178
- .and_then(Value::as_str)
179
- .map(str::to_string)
180
- .unwrap_or_else(|| {
181
- agent_health_status_text(agent.get("status").and_then(Value::as_str).unwrap_or(""))
182
- });
183
- let tasks = status
184
- .get("tasks")
185
- .and_then(Value::as_array)
186
- .cloned()
187
- .unwrap_or_default();
188
- let task_id = current_task_for_agent(&tasks, agent_id).unwrap_or_else(|| "-".to_string());
189
- let inbox_rows = crate::message_store::MessageStore::open(workspace)
190
- .map_err(|e| CliError::Runtime(e.to_string()))?
191
- .inbox(agent_id, 3, None)
192
- .map_err(|e| CliError::Runtime(e.to_string()))?;
193
- let mut lines = vec![
194
- format!("{agent_id} {status_text}"),
195
- format!(" provider: {}", py_get(agent, "provider")),
196
- format!(" model: {}", py_get(agent, "model")),
197
- format!(" profile: {}", py_get(agent, "profile")),
198
- format!(" session_id: {}", py_get_or_dash(agent, "session_id")),
199
- format!(" captured_via: {}", py_get_or_dash(agent, "captured_via")),
200
- format!(
201
- " attribution_confidence: {}",
202
- py_get_or_dash(agent, "attribution_confidence")
203
- ),
204
- format!(" task: {task_id}"),
205
- format!(" handoff: {}", py_get(agent, "handoff_path")),
206
- " recent messages:".to_string(),
207
- ];
208
- if inbox_rows.is_empty() {
209
- lines.push(" none".to_string());
210
- } else {
211
- for item in &inbox_rows {
212
- let content = item.get("content").and_then(Value::as_str).unwrap_or("");
213
- let content: String = content.chars().take(120).collect();
214
- lines.push(format!(
215
- " {} {} -> {} {}: {content}",
216
- py_get_or_dash(item, "created_at"),
217
- py_get_or_dash(item, "sender"),
218
- py_get_or_dash(item, "recipient"),
219
- py_get_or_dash(item, "status"),
220
- ));
221
- }
222
- }
223
- Ok(lines.join("\n"))
224
- }
225
-
226
- /// `current_task_for_agent`(`approvals/status.py:127-132`)。
227
- fn current_task_for_agent(tasks: &[Value], agent_id: &str) -> Option<String> {
228
- const ACTIVE: [&str; 5] = ["pending", "ready", "running", "blocked", "needs_retry"];
229
- for task in tasks.iter().rev() {
230
- let assignee = task.get("assignee").and_then(Value::as_str);
231
- let status = task
232
- .get("status")
233
- .and_then(Value::as_str)
234
- .unwrap_or("pending");
235
- if assignee == Some(agent_id) && ACTIVE.contains(&status) {
236
- return task.get("id").and_then(Value::as_str).map(str::to_string);
237
- }
238
- }
239
- None
240
- }
241
-
242
- fn agent_health_status_text(status: &str) -> String {
243
- serde_json::to_value(crate::provider::agent_health_status(status))
244
- .ok()
245
- .and_then(|v| v.as_str().map(str::to_string))
246
- .unwrap_or_else(|| "-".to_string())
247
- }
248
-
249
- /// Python `agent.get(key, '-')`:键缺失 → `-`;键存在但为 null → 打印 `None`。
250
- fn py_get(agent: &Value, key: &str) -> String {
251
- match agent.get(key) {
252
- None => "-".to_string(),
253
- Some(Value::Null) => "None".to_string(),
254
- Some(Value::String(s)) => s.clone(),
255
- Some(other) => other.to_string(),
256
- }
257
- }
258
-
259
- /// Python `agent.get(key) or '-'`:缺失/null/空串都落 `-`。
260
- fn py_get_or_dash(agent: &Value, key: &str) -> String {
261
- match agent.get(key) {
262
- Some(Value::String(s)) if !s.is_empty() => s.clone(),
263
- Some(Value::Number(n)) => n.to_string(),
264
- _ => "-".to_string(),
265
- }
266
- }
267
-
268
- /// `latest_result_summaries`(`queries.py:83-89`)。
269
- fn latest_result_summaries(
270
- store: &crate::message_store::MessageStore,
271
- owner_team_id: Option<&str>,
272
- ) -> Result<Value, CliError> {
273
- let rows = store
274
- .latest_results(5, owner_team_id)
275
- .map_err(|e| CliError::Runtime(e.to_string()))?;
276
- Ok(Value::Array(
277
- rows.iter()
278
- .filter_map(crate::message_store::result_summary_from_row)
279
- .collect(),
280
- ))
281
- }
282
- /// `status.approvals(workspace, agent_id)`(JSON)/`format_approvals`(人读)。
283
- pub fn approvals(workspace: &Path, agent: Option<&str>, as_json: bool) -> Result<Value, CliError> {
284
- let _ = as_json;
285
- let state = read_runtime_state(workspace);
286
- approvals_scoped(workspace, &state, agent, as_json)
287
- }
288
-
289
- pub fn approvals_scoped(
290
- workspace: &Path,
291
- state: &Value,
292
- agent: Option<&str>,
293
- as_json: bool,
294
- ) -> Result<Value, CliError> {
295
- let _ = as_json;
296
- let session = state
297
- .get("session_name")
298
- .and_then(Value::as_str)
299
- .filter(|s| !s.is_empty());
300
- let mut approvals = Vec::new();
301
- if let (Some(session), Some(agents)) = (session, state.get("agents").and_then(Value::as_object))
302
- {
303
- let run_ws = crate::model::paths::canonical_run_workspace(workspace)
304
- .unwrap_or_else(|_| workspace.to_path_buf());
305
- // 0.5.x Phase 1d Batch 3: use the factory-resolved backend
306
- // so conpty teams get their scrollback from the shim rather
307
- // than a fake tmux capture that always returns empty. Tmux
308
- // teams take the same code path as before (byte-equivalent).
309
- let resolved = crate::transport_factory::resolve_read_only_transport(
310
- &run_ws,
311
- Some(state),
312
- crate::transport_factory::TransportPurpose::Status,
313
- );
314
- let backend: Box<dyn crate::transport::Transport> = match resolved {
315
- Ok(r) => r.backend,
316
- Err(_) => {
317
- // Read-path fallback: refused factory means we don't
318
- // try to inspect approval prompts. Empty vec = no
319
- // waiting approvals, honest.
320
- return Ok(json!({
321
- "ok": true,
322
- "waiting": false,
323
- "waiting_count": 0,
324
- "approvals": [],
325
- }));
326
- }
327
- };
328
- for (agent_id, agent_state) in agents {
329
- if agent.is_some_and(|wanted| wanted != agent_id) {
330
- continue;
331
- }
332
- let window = agent_window(agent_id, agent_state);
333
- let target = crate::transport::Target::SessionWindow {
334
- session: crate::transport::SessionName::new(session.to_string()),
335
- window: crate::transport::WindowName::new(window.clone()),
336
- };
337
- let Ok(captured) = backend.capture(&target, crate::transport::CaptureRange::Tail(120))
338
- else {
339
- continue;
340
- };
341
- if let Some(prompt) = crate::provider::extract_approval_prompt(agent_id, &captured.text)
342
- {
343
- approvals.push(prompt.to_ordered_value());
344
- }
345
- }
346
- }
347
- let waiting_count = approvals.len();
348
- Ok(json!({
349
- "ok": true,
350
- "waiting": waiting_count > 0,
351
- "waiting_count": waiting_count,
352
- "approvals": approvals,
353
- "scan": {
354
- "mode": "tail",
355
- "lines": 120,
356
- "raw_output": false,
357
- },
358
- }))
359
- }
360
-
361
- pub fn format_approvals(value: &Value) -> String {
362
- let approvals = value
363
- .get("approvals")
364
- .and_then(Value::as_array)
365
- .map(Vec::as_slice)
366
- .unwrap_or(&[]);
367
- if approvals.is_empty() {
368
- return "No pending approvals.".to_string();
369
- }
370
- approvals
371
- .iter()
372
- .map(|approval| {
373
- let agent = approval
374
- .get("agent_id")
375
- .and_then(Value::as_str)
376
- .unwrap_or("-");
377
- let kind = approval
378
- .get("kind")
379
- .and_then(Value::as_str)
380
- .unwrap_or("unknown");
381
- let prompt = approval
382
- .get("prompt")
383
- .and_then(Value::as_str)
384
- .or_else(|| approval.get("subject").and_then(Value::as_str))
385
- .unwrap_or("-");
386
- format!("{agent}: {kind} {prompt}")
387
- })
388
- .collect::<Vec<_>>()
389
- .join("\n")
390
- }
391
- /// `status.inbox(workspace, agent, limit, since)`(JSON)/`format_inbox`(人读)。
392
- pub fn inbox(
393
- workspace: &Path,
394
- agent: &str,
395
- limit: usize,
396
- since: Option<&str>,
397
- as_json: bool,
398
- owner_team_id: Option<&str>,
399
- ) -> Result<Value, CliError> {
400
- let _ = as_json;
401
- let store = crate::message_store::MessageStore::open(workspace)
402
- .map_err(|e| CliError::Runtime(e.to_string()))?;
403
- let mut messages = store
404
- .inbox(agent, limit, owner_team_id)
405
- .map_err(|e| CliError::Runtime(e.to_string()))?;
406
- if let Some(cutoff) = since.and_then(parse_rfc3339) {
407
- messages.retain(|message| {
408
- message
409
- .get("created_at")
410
- .and_then(Value::as_str)
411
- .and_then(parse_rfc3339)
412
- .is_some_and(|created| created >= cutoff)
413
- });
414
- }
415
- Ok(json!({
416
- "ok": true,
417
- "agent_id": agent,
418
- "messages": messages,
419
- "since": since,
420
- }))
421
- }
422
-
423
- fn parse_rfc3339(value: &str) -> Option<chrono::DateTime<chrono::FixedOffset>> {
424
- chrono::DateTime::parse_from_rfc3339(value).ok()
425
- }
426
-
427
- fn read_runtime_state(workspace: &Path) -> Value {
428
- let path = workspace.join(".team").join("runtime").join("state.json");
429
- std::fs::read_to_string(path)
430
- .ok()
431
- .and_then(|s| serde_json::from_str(&s).ok())
432
- .unwrap_or_else(|| json!({}))
433
- }
434
-
435
- fn resolve_status_owner_team(
436
- workspace: &Path,
437
- owner_team_id: Option<&str>,
438
- ) -> Result<Option<String>, CliError> {
439
- let Some(requested) = owner_team_id.filter(|team| !team.is_empty()) else {
440
- return Ok(None);
441
- };
442
- let state = read_runtime_state(workspace);
443
- match crate::state::projection::resolve_owner_team_id(&state, requested) {
444
- OwnerTeamResolution::Canonical(canonical) => Ok(Some(canonical)),
445
- OwnerTeamResolution::LegacyAlias {
446
- requested,
447
- canonical,
448
- } => {
449
- let log = crate::event_log::EventLog::new(workspace);
450
- crate::messaging::delivery::normalize_owner_team_id_rows(
451
- workspace,
452
- &requested,
453
- &canonical,
454
- None,
455
- Some(&log),
456
- )
457
- .map_err(CliError::from)?;
458
- Ok(Some(canonical))
459
- }
460
- OwnerTeamResolution::Unresolved { .. } | OwnerTeamResolution::Ambiguous { .. } => Ok(None),
461
- }
462
- }
463
-
464
- fn agent_window(agent_id: &str, agent_state: &Value) -> String {
465
- ["window", "window_name"]
466
- .iter()
467
- .find_map(|key| {
468
- agent_state
469
- .get(*key)
470
- .and_then(Value::as_str)
471
- .filter(|s| !s.is_empty())
472
- })
473
- .unwrap_or(agent_id)
474
- .to_string()
475
- }
476
-
477
- /// 0.5.41 Slice 3 (fault-invisibility-locate.md §5): single-source
478
- /// runtime freshness projection consumed by status/diagnose. Read-
479
- /// only over existing sources (`coordinator_health`, heartbeat
480
- /// sidecar, watch state); computes NO transport calls of its own.
481
- #[derive(Default, Clone)]
482
- pub(crate) struct RuntimeFreshness {
483
- pub coordinator_service_available: bool,
484
- pub host_boot_stale: bool,
485
- pub host_boot_recorded: Option<String>,
486
- pub host_boot_current: Option<String>,
487
- pub provider_exited_agents: std::collections::BTreeSet<String>,
488
- }
489
-
490
- impl RuntimeFreshness {
491
- fn host_boot_stale_reason(&self) -> Option<&'static str> {
492
- self.host_boot_stale.then_some("host_boot_mismatch")
493
- }
494
- }
495
-
496
- pub(crate) fn compute_runtime_freshness(
497
- workspace: &Path,
498
- state: &Value,
499
- health: &crate::coordinator::HealthReport,
500
- ) -> RuntimeFreshness {
501
- let workspace_path = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
502
- let heartbeat = crate::coordinator::read_coordinator_heartbeat(&workspace_path);
503
- let host_boot_recorded = heartbeat
504
- .as_ref()
505
- .and_then(|hb| hb.get("host_boot_id"))
506
- .and_then(Value::as_str)
507
- .filter(|s| !s.is_empty() && *s != "unknown")
508
- .map(str::to_string);
509
- let host_boot_current = crate::coordinator::probe_host_boot_id();
510
- let host_boot_stale = match (host_boot_recorded.as_deref(), host_boot_current.as_deref()) {
511
- (Some(recorded), Some(current)) => recorded != current,
512
- _ => false,
513
- };
514
- // Collect worker-provider-exited agents from the coordinator
515
- // abnormal_exit_watch payload (0.5.41 Slice 4 writes
516
- // `worker_provider_exited` / `provider_process_dead=true`
517
- // there). Read from top-level `coordinator.abnormal_exit_watch`
518
- // OR the team-scoped mirror `teams.<key>.coordinator...`.
519
- let mut provider_exited_agents = std::collections::BTreeSet::new();
520
- for path in [
521
- "/coordinator/abnormal_exit_watch",
522
- &format!(
523
- "/teams/{}/coordinator/abnormal_exit_watch",
524
- state
525
- .get("active_team_key")
526
- .and_then(Value::as_str)
527
- .unwrap_or("")
528
- ),
529
- ] {
530
- if let Some(watch) = state.pointer(path).and_then(Value::as_object) {
531
- for (agent_id, entry) in watch {
532
- let exited = entry.get("worker_provider_exited").and_then(Value::as_bool)
533
- == Some(true)
534
- || entry.get("provider_process_dead").and_then(Value::as_bool) == Some(true)
535
- || entry.get("provider_exit_marker").is_some();
536
- if exited {
537
- provider_exited_agents.insert(agent_id.clone());
538
- }
539
- }
78
+ ensure_agent_known(snapshot.full(), agent)?;
79
+ let recent_messages = recent_agent_messages(workspace, agent)?;
80
+ format_agent_status(snapshot.full(), agent, &recent_messages)
540
81
  }
541
- }
542
- RuntimeFreshness {
543
- coordinator_service_available: health.service_available,
544
- host_boot_stale,
545
- host_boot_recorded,
546
- host_boot_current,
547
- provider_exited_agents,
548
- }
549
- }
550
-
551
- fn enrich_agents(
552
- workspace: &Path,
553
- state: &Value,
554
- tmux_session_present: bool,
555
- freshness: &RuntimeFreshness,
556
- ) -> Value {
557
- let agents = state.get("agents");
558
- let Some(Value::Object(input)) = agents else {
559
- return json!({});
560
- };
561
- let team_dir = state
562
- .get("team_dir")
563
- .and_then(Value::as_str)
564
- .filter(|value| !value.is_empty())
565
- .map(PathBuf::from)
566
- .unwrap_or_else(|| workspace.to_path_buf());
567
- let mut out = Map::new();
568
- for (agent_id, value) in input {
569
- match value {
570
- Value::Object(obj) => {
571
- let mut enriched = obj.clone();
572
- apply_effective_role_projection(&team_dir, agent_id, &mut enriched);
573
- enriched.insert(
574
- "interacted".to_string(),
575
- Value::String(interacted_marker(obj.get("first_send_at"))),
576
- );
577
- // 0.5.41 Slice 3: order stale sources most-authoritative-first.
578
- // Host boot mismatch wins because it invalidates all cached
579
- // pane/pid/session facts. Provider-exit marker wins over
580
- // pane liveness because the wrapper leaves an interactive
581
- // shell live. Coordinator unavailability without stronger
582
- // live provider proof means DB agent_health rows are stale.
583
- // Tmux session missing keeps its existing legacy path so
584
- // pre-0.5.41 tests remain byte-identical when no new
585
- // signal fires.
586
- let has_pane_binding = agent_has_pane_fact(&Value::Object(obj.clone()));
587
- // 0.5.41 Slice 3 (fault-invisibility-locate.md §5 point 3
588
- // + §9 RED4 live-provider guard): the wrapper-era pane
589
- // liveness cannot prove provider liveness — but a state
590
- // `pane_current_command` that MATCHES the agent's provider
591
- // IS positive proof (the abnormal.rs classifier writes it
592
- // when the pane's foreground command is the provider CLI).
593
- // When that positive proof is present, no stale downgrade
594
- // fires here so the agent renders as working.
595
- let provider_command_positive_proof = provider_current_command_matches(obj);
596
- // 0.5.41 Slice 3 (0.5.35 R4 regression guard): when the
597
- // runtime classifier has already written canonical
598
- // `worker_state=UNKNOWN` / `activity.status=uncertain`,
599
- // that is the authoritative honest observation — do NOT
600
- // reclassify it as `coordinator_unavailable` stale (which
601
- // would land it in the Stopped bucket instead of Unknown).
602
- // Host-boot mismatch and provider-exited marker are
603
- // stronger, more specific signals and still win.
604
- let canonical_unknown = agent_canonical_worker_state_is_unknown(obj);
605
- let new_reason = if freshness.host_boot_stale && has_pane_binding {
606
- freshness.host_boot_stale_reason()
607
- } else if freshness.provider_exited_agents.contains(agent_id) {
608
- Some("worker_provider_exited")
609
- } else if !freshness.coordinator_service_available
610
- && has_pane_binding
611
- && !provider_command_positive_proof
612
- && !canonical_unknown
613
- {
614
- Some("coordinator_unavailable")
615
- } else {
616
- None
617
- };
618
- let legacy_reason = if provider_command_positive_proof {
619
- None
620
- } else {
621
- stale_reason_for_agent(&Value::Object(obj.clone()), tmux_session_present)
622
- };
623
- let reason = new_reason.or(legacy_reason);
624
- if let Some(reason) = reason {
625
- enriched.insert("stale".to_string(), Value::Bool(true));
626
- enriched.insert(
627
- "stale_reason".to_string(),
628
- Value::String(reason.to_string()),
629
- );
630
- // Downgrade cached BUSY/working when the stale source is
631
- // one of the new authoritative signals OR the pre-existing
632
- // session-missing signal. Legacy code only downgraded on
633
- // !tmux_session_present; that let host_boot / provider-
634
- // exit / coord-unavailable stale rows keep raw=running.
635
- let is_new_signal = matches!(
636
- reason,
637
- "host_boot_mismatch" | "worker_provider_exited" | "coordinator_unavailable"
638
- );
639
- if !tmux_session_present || is_new_signal {
640
- downgrade_stale_agent(&mut enriched);
641
- }
642
- }
643
- out.insert(agent_id.clone(), Value::Object(enriched));
644
- }
645
- _ => {
646
- out.insert(agent_id.clone(), value.clone());
647
- }
648
- }
649
- }
650
- Value::Object(out)
651
- }
652
-
653
- fn apply_effective_role_projection(
654
- team_dir: &Path,
655
- agent_id: &str,
656
- agent: &mut Map<String, Value>,
657
- ) {
658
- let role_file = agent
659
- .get("dynamic_role_file")
660
- .and_then(Value::as_str)
661
- .filter(|value| !value.is_empty())
662
- .map(PathBuf::from)
663
- .unwrap_or_else(|| team_dir.join("agents").join(format!("{agent_id}.md")));
664
- let Ok((meta, _)) = crate::compiler::read_front_matter(&role_file) else {
665
- return;
666
- };
667
- let Some(meta) = yaml_map(&meta) else {
668
- return;
669
- };
670
- if let Some(provider) = yaml_str(meta, "provider").filter(|value| !value.is_empty()) {
671
- agent.insert("provider".to_string(), Value::String(provider.to_string()));
672
- agent.insert(
673
- "provider_source".to_string(),
674
- Value::String("role".to_string()),
675
- );
676
- }
677
- if let Some(model) = yaml_str(meta, "model").filter(|value| !value.is_empty()) {
678
- if agent.get("model").and_then(Value::as_str) != Some(model) {
679
- agent.insert("model_stale".to_string(), Value::Bool(true));
680
- }
681
- agent.insert("model".to_string(), Value::String(model.to_string()));
682
- agent.insert(
683
- "model_source".to_string(),
684
- Value::String("role".to_string()),
685
- );
686
- }
687
- }
688
-
689
- fn yaml_map(
690
- value: &crate::model::yaml::Value,
691
- ) -> Option<&Vec<(String, crate::model::yaml::Value)>> {
692
- match value {
693
- crate::model::yaml::Value::Map(items) => Some(items),
694
- _ => None,
695
- }
696
- }
697
-
698
- fn yaml_str<'a>(items: &'a [(String, crate::model::yaml::Value)], key: &str) -> Option<&'a str> {
699
- items.iter().find_map(|(name, value)| {
700
- if name == key {
701
- match value {
702
- crate::model::yaml::Value::Str(value) => Some(value.as_str()),
703
- _ => None,
704
- }
705
- } else {
706
- None
707
- }
708
- })
709
- }
710
-
711
- fn downgrade_stale_agent(agent: &mut Map<String, Value>) {
712
- let raw = agent
713
- .get("status")
714
- .and_then(Value::as_str)
715
- .unwrap_or("")
716
- .to_ascii_lowercase();
717
- if matches!(raw.as_str(), "running" | "busy" | "working" | "idle") {
718
- agent.insert("status".to_string(), Value::String("stopped".to_string()));
719
- }
720
- let worker_state = agent
721
- .get("worker_state")
722
- .and_then(Value::as_str)
723
- .unwrap_or("")
724
- .to_ascii_uppercase();
725
- if matches!(worker_state.as_str(), "RUNNING" | "BUSY" | "PROBABLY_IDLE") {
726
- agent.insert(
727
- "worker_state".to_string(),
728
- Value::String("DEAD".to_string()),
729
- );
730
- }
731
- }
732
-
733
- fn stale_reason_for_agent(agent: &Value, tmux_session_present: bool) -> Option<&'static str> {
734
- let pane_dead = !tmux_session_present && agent_has_pane_fact(agent);
735
- let process_dead =
736
- agent_process_dead(agent) || (!tmux_session_present && agent_has_process_fact(agent));
737
- match (pane_dead, process_dead) {
738
- (true, true) => Some("both"),
739
- (false, true) => Some("process_dead"),
740
- (true, false) => Some("pane_dead"),
741
- (false, false) => None,
742
- }
743
- }
744
-
745
- /// 0.5.41 Slice 3 (0.5.35 R4 regression guard): true when the agent
746
- /// row carries the canonical `worker_state=UNKNOWN` OR
747
- /// `activity.status=uncertain` observation the runtime classifier
748
- /// writes. Used to skip the coordinator-unavailable stale mark
749
- /// (see `enrich_agents`) so the pre-existing R4 rendering (UNKNOWN
750
- /// beats WORKING) is preserved.
751
- fn agent_canonical_worker_state_is_unknown(agent: &serde_json::Map<String, Value>) -> bool {
752
- let worker_state_unknown = agent
753
- .get("worker_state")
754
- .and_then(Value::as_str)
755
- .is_some_and(|value| value.eq_ignore_ascii_case("UNKNOWN"));
756
- let activity_uncertain = agent
757
- .get("activity")
758
- .and_then(|v| v.get("status"))
759
- .and_then(Value::as_str)
760
- .is_some_and(|value| value.eq_ignore_ascii_case("uncertain"));
761
- worker_state_unknown || activity_uncertain
762
- }
763
-
764
- /// 0.5.41 Slice 3 (fault-invisibility-locate.md §9 RED4 live-provider
765
- /// guard): true when the agent row carries a `pane_current_command`
766
- /// that matches the agent's provider CLI. This is positive proof
767
- /// the provider is the pane's foreground process — the abnormal.rs
768
- /// classifier writes this field after the marker/current-command
769
- /// check clears. When true, stale-downgrade paths in
770
- /// `enrich_agents` skip so the row keeps its BUSY/working state.
771
- fn provider_current_command_matches(agent: &serde_json::Map<String, Value>) -> bool {
772
- let Some(command) = agent
773
- .get("pane_current_command")
774
- .and_then(Value::as_str)
775
- .filter(|s| !s.is_empty())
776
- else {
777
- return false;
778
- };
779
- let Some(provider_wire) = agent.get("provider").and_then(Value::as_str) else {
780
- return false;
781
- };
782
- let Some(provider) = crate::provider::wire::parse_provider(provider_wire) else {
783
- return false;
784
- };
785
- crate::leader::command_matches_provider(provider, command)
786
- }
787
-
788
- fn agent_has_pane_fact(agent: &Value) -> bool {
789
- ["pane_id", "window", "window_name"].iter().any(|key| {
790
- agent
791
- .get(*key)
792
- .and_then(Value::as_str)
793
- .is_some_and(|value| !value.is_empty())
794
- })
795
- }
796
-
797
- fn agent_has_process_fact(agent: &Value) -> bool {
798
- agent.get("pid").and_then(Value::as_i64).is_some()
799
- || agent.get("process_started").and_then(Value::as_bool) == Some(true)
800
- || agent
801
- .get("provider_process_dead")
802
- .and_then(Value::as_bool)
803
- .is_some()
804
- || agent
805
- .get("process_liveness")
806
- .and_then(Value::as_str)
807
- .is_some()
808
- }
809
-
810
- fn agent_process_dead(agent: &Value) -> bool {
811
- if agent.get("provider_process_dead").and_then(Value::as_bool) == Some(true) {
812
- return true;
813
- }
814
- ["process_liveness", "worker_state"].iter().any(|key| {
815
- agent
816
- .get(*key)
817
- .and_then(Value::as_str)
818
- .is_some_and(is_dead_process_state)
819
- })
820
- }
821
-
822
- fn is_dead_process_state(value: &str) -> bool {
823
- matches!(
824
- value,
825
- "dead" | "missing" | "stopped" | "exited" | "terminated"
826
- )
827
- }
828
-
829
- fn interacted_marker(value: Option<&Value>) -> String {
830
- let Some(raw) = value.and_then(Value::as_str) else {
831
- return "never".to_string();
832
- };
833
- if raw.is_empty() {
834
- return "never".to_string();
835
- }
836
- if chrono::DateTime::parse_from_rfc3339(raw).is_ok() {
837
- raw.to_string()
838
- } else {
839
- "never".to_string()
840
- }
841
- }
842
-
843
- fn tmux_session_present(workspace: &Path, state: &Value, session_name: Option<&str>) -> bool {
844
- // Bug #7 (prerelease 0.4.0 gate review §6): probe the SAME endpoint
845
- // the runtime actually uses (state.tmux_endpoint / tmux_socket), not
846
- // the workspace-hash socket. When state has no persisted endpoint,
847
- // fall back to workspace — preserves legacy behavior. wait_readiness
848
- // formula unchanged per 不可改项; only the input signal is fixed.
849
- let Some(name) = session_name else {
850
- return false;
851
- };
852
- if name.is_empty() {
853
- return false;
854
- }
855
- let run_ws = crate::model::paths::canonical_run_workspace(workspace)
856
- .unwrap_or_else(|_| workspace.to_path_buf());
857
- // 0.5.x Phase 1d Batch 3: route through the factory so a
858
- // conpty team does NOT get its `has_session` probe served by a
859
- // tmux backend (which would always return false and drive the
860
- // reader into a false `tmux_session_missing` state — design
861
- // §Batch 3 Verification anchor). Tmux teams see byte-equivalent
862
- // behavior because factory Layer 3 (legacy tmux endpoint) uses
863
- // the same `tmux_backend_for_runtime_state_or_workspace` shape.
864
- let resolved = crate::transport_factory::resolve_read_only_transport(
865
- &run_ws,
866
- Some(state),
867
- crate::transport_factory::TransportPurpose::Status,
868
- );
869
- match resolved {
870
- Ok(r) => r
871
- .backend
872
- .has_session(&crate::transport::SessionName::new(name))
873
- .unwrap_or(false),
874
- Err(_) => {
875
- // Factory refused (e.g. explicit conpty without a
876
- // resolvable team_key). Honest: return false rather
877
- // than pretend a tmux session exists.
878
- false
879
- }
880
- }
881
- }
882
-
883
- fn message_counts(
884
- conn: &rusqlite::Connection,
885
- owner_team_id: Option<&str>,
886
- ) -> Result<Value, CliError> {
887
- status_counts(conn, "messages", owner_team_id)
888
- }
889
-
890
- fn result_counts(
891
- conn: &rusqlite::Connection,
892
- owner_team_id: Option<&str>,
893
- ) -> Result<Value, CliError> {
894
- let by_status = result_status_counts(conn, owner_team_id)?;
895
- let total = count_rows(conn, "results", owner_team_id)?;
896
- let invalid = count_where_status(conn, "results", owner_team_id, "invalid")?;
897
- let collected = count_where_status(conn, "results", owner_team_id, "collected")?;
898
- let uncollected = total.saturating_sub(collected).saturating_sub(invalid);
899
- Ok(json!({
900
- "total": total,
901
- "uncollected": uncollected,
902
- "collected": collected,
903
- "invalid": invalid,
904
- "by_status": by_status,
905
- }))
906
- }
907
-
908
- fn status_counts(
909
- conn: &rusqlite::Connection,
910
- table: &str,
911
- owner_team_id: Option<&str>,
912
- ) -> Result<Value, CliError> {
913
- let sql = match owner_team_id {
914
- Some(_) => format!(
915
- "select status, count(*) from {table}
916
- where owner_team_id = ?1
917
- group by status order by status"
918
- ),
919
- None => format!("select status, count(*) from {table} group by status order by status"),
920
- };
921
- let mut stmt = conn
922
- .prepare(&sql)
923
- .map_err(|e| CliError::Runtime(e.to_string()))?;
924
- let mut rows = match owner_team_id {
925
- Some(team) => stmt
926
- .query(params![team])
927
- .map_err(|e| CliError::Runtime(e.to_string()))?,
928
- None => stmt
929
- .query([])
930
- .map_err(|e| CliError::Runtime(e.to_string()))?,
931
- };
932
- let mut out = Map::new();
933
- while let Some(row) = rows.next().map_err(|e| CliError::Runtime(e.to_string()))? {
934
- let status: String = row.get(0).map_err(|e| CliError::Runtime(e.to_string()))?;
935
- let count: i64 = row.get(1).map_err(|e| CliError::Runtime(e.to_string()))?;
936
- out.insert(status, json!(count));
937
- }
938
- Ok(Value::Object(out))
939
- }
940
-
941
- fn result_status_counts(
942
- conn: &rusqlite::Connection,
943
- owner_team_id: Option<&str>,
944
- ) -> Result<Value, CliError> {
945
- let sql = match owner_team_id {
946
- Some(_) => {
947
- "select status, count(*) from results
948
- where status not in ('collected', 'invalid') and owner_team_id = ?1
949
- group by status
950
- order by status"
951
- }
952
- None => {
953
- "select status, count(*) from results
954
- where status not in ('collected', 'invalid')
955
- group by status
956
- order by status"
957
- }
958
- };
959
- let mut stmt = conn
960
- .prepare(sql)
961
- .map_err(|e| CliError::Runtime(e.to_string()))?;
962
- let mut rows = match owner_team_id {
963
- Some(team) => stmt
964
- .query(params![team])
965
- .map_err(|e| CliError::Runtime(e.to_string()))?,
966
- None => stmt
967
- .query([])
968
- .map_err(|e| CliError::Runtime(e.to_string()))?,
969
- };
970
- let mut out = Map::new();
971
- while let Some(row) = rows.next().map_err(|e| CliError::Runtime(e.to_string()))? {
972
- let status: String = row.get(0).map_err(|e| CliError::Runtime(e.to_string()))?;
973
- let count: i64 = row.get(1).map_err(|e| CliError::Runtime(e.to_string()))?;
974
- out.insert(status, json!(count));
975
- }
976
- Ok(Value::Object(out))
977
- }
978
-
979
- fn queued_messages(
980
- conn: &rusqlite::Connection,
981
- owner_team_id: Option<&str>,
982
- limit: usize,
983
- ) -> Result<Value, CliError> {
984
- let limit = i64::try_from(limit).unwrap_or(i64::MAX);
985
- let sql = match owner_team_id {
986
- Some(_) => {
987
- "select message_id, recipient, status, created_at, delivery_attempts
988
- from messages
989
- where status like 'queued%' and owner_team_id = ?1
990
- order by created_at desc
991
- limit ?2"
992
- }
993
- None => {
994
- "select message_id, recipient, status, created_at, delivery_attempts
995
- from messages
996
- where status like 'queued%'
997
- order by created_at desc
998
- limit ?1"
999
- }
1000
- };
1001
- let mut stmt = conn
1002
- .prepare(sql)
1003
- .map_err(|e| CliError::Runtime(e.to_string()))?;
1004
- let map_row = |row: &rusqlite::Row<'_>| {
1005
- Ok(json!({
1006
- "message_id": row.get::<_, String>(0)?,
1007
- "recipient": row.get::<_, Option<String>>(1)?,
1008
- "status": row.get::<_, String>(2)?,
1009
- "created_at": row.get::<_, Option<String>>(3)?,
1010
- "delivery_attempts": row.get::<_, i64>(4)?,
1011
- }))
1012
- };
1013
- let rows = match owner_team_id {
1014
- Some(team) => stmt.query_map(params![team, limit], map_row),
1015
- None => stmt.query_map(params![limit], map_row),
1016
- }
1017
- .map_err(|e| CliError::Runtime(e.to_string()))?;
1018
- let values = rows
1019
- .collect::<Result<Vec<_>, _>>()
1020
- .map_err(|e| CliError::Runtime(e.to_string()))?;
1021
- Ok(Value::Array(values))
1022
- }
1023
-
1024
- /// 0.5.5 gate054 round-2: leader notifications that were refused with
1025
- /// `rebind_required` (status=failed, error=leader_not_attached) sit as
1026
- /// failed rows in the store; without a dedicated status field the
1027
- /// operator sees only `messages.failed=N` and cannot tell that the
1028
- /// notifications are waiting for a rebind. This field surfaces them
1029
- /// alongside `queued_messages` so `attach-leader` / `takeover` is
1030
- /// visibly the fix. Once the pane is rebound the requeue path flips
1031
- /// each row back to `status=accepted` and it drops out of this list.
1032
- fn pending_leader_notifications(
1033
- conn: &rusqlite::Connection,
1034
- owner_team_id: Option<&str>,
1035
- limit: usize,
1036
- ) -> Result<Value, CliError> {
1037
- let limit = i64::try_from(limit).unwrap_or(i64::MAX);
1038
- // E6 (0.5.9 offline-mailbox §6.6): also surface `queued_until_leader_attach`
1039
- // rows (third-party sends into the leader mailbox) so the target owner can
1040
- // see them alongside the rebind-required failures. Channel wire label
1041
- // distinguishes the two so the operator can tell the two shapes apart.
1042
- let sql = match owner_team_id {
1043
- Some(_) => {
1044
- "select message_id, sender, status, error, created_at, delivery_attempts
1045
- from messages
1046
- where recipient = 'leader'
1047
- and owner_team_id = ?1
1048
- and (
1049
- (status = 'failed' and error = 'leader_not_attached')
1050
- or status = 'queued_until_leader_attach'
1051
- )
1052
- order by created_at desc
1053
- limit ?2"
1054
- }
1055
- None => {
1056
- "select message_id, sender, status, error, created_at, delivery_attempts
1057
- from messages
1058
- where recipient = 'leader'
1059
- and (
1060
- (status = 'failed' and error = 'leader_not_attached')
1061
- or status = 'queued_until_leader_attach'
1062
- )
1063
- order by created_at desc
1064
- limit ?1"
1065
- }
1066
- };
1067
- let mut stmt = conn
1068
- .prepare(sql)
1069
- .map_err(|e| CliError::Runtime(e.to_string()))?;
1070
- let map_row = |row: &rusqlite::Row<'_>| {
1071
- let status: String = row.get(2)?;
1072
- let channel = if status == "queued_until_leader_attach" {
1073
- "leader_mailbox"
1074
- } else {
1075
- "rebind_required"
1076
- };
1077
- Ok(json!({
1078
- "message_id": row.get::<_, String>(0)?,
1079
- "sender": row.get::<_, Option<String>>(1)?,
1080
- "status": status,
1081
- "error": row.get::<_, Option<String>>(3)?,
1082
- "created_at": row.get::<_, Option<String>>(4)?,
1083
- "delivery_attempts": row.get::<_, i64>(5)?,
1084
- "channel": channel,
1085
- "action": "run team-agent attach-leader or team-agent takeover",
1086
- }))
1087
- };
1088
- let rows = match owner_team_id {
1089
- Some(team) => stmt.query_map(params![team, limit], map_row),
1090
- None => stmt.query_map(params![limit], map_row),
1091
- }
1092
- .map_err(|e| CliError::Runtime(e.to_string()))?;
1093
- let values = rows
1094
- .collect::<Result<Vec<_>, _>>()
1095
- .map_err(|e| CliError::Runtime(e.to_string()))?;
1096
- Ok(Value::Array(values))
1097
- }
1098
-
1099
- /// 0.4.x: slim default compact payload — exactly 7 top-level fields.
1100
- /// Diagnostic detail moves to `--detail`. Plan:
1101
- /// /Users/alauda/Documents/code/team-agent-public/.team/artifacts/status-compact-plan.md
1102
- fn compact_status(full: Value) -> Value {
1103
- let not_ready = compact_not_ready(&full);
1104
- let ready = compact_ready(&full, &not_ready);
1105
- json!({
1106
- "ok": true,
1107
- "team": full.get("team").cloned().unwrap_or(Value::Null),
1108
- "session_name": full.get("session_name").cloned().unwrap_or(Value::Null),
1109
- "leader_attach_command": full.get("leader_attach_command").cloned().unwrap_or(Value::Null),
1110
- "ready": ready,
1111
- "not_ready": not_ready,
1112
- "agents": compact_agents(full.get("agents")),
1113
- })
1114
- }
1115
-
1116
- /// Synthesized readiness boolean for the slim payload. Stricter than the
1117
- /// raw `readiness.ready` because it also folds in coordinator + schema +
1118
- /// tmux session presence so operators don't need to read separate booleans.
1119
- fn compact_ready(full: &Value, not_ready: &Value) -> bool {
1120
- not_ready.is_null()
1121
- && full
1122
- .get("readiness")
1123
- .and_then(|r| r.get("ready"))
1124
- .and_then(Value::as_bool)
1125
- .unwrap_or(false)
1126
- && full
1127
- .get("coordinator")
1128
- .and_then(|c| c.get("status"))
1129
- .and_then(Value::as_str)
1130
- .is_some_and(|s| s == "running" || s == "ok")
1131
- && full
1132
- .get("coordinator")
1133
- .and_then(|c| c.get("schema_ok"))
1134
- .and_then(Value::as_bool)
1135
- .unwrap_or(true)
1136
- }
1137
-
1138
- /// Returns `Value::Null` when fully ready, otherwise an object:
1139
- /// `{"reasons": [...], "agents": [...]}` listing every gating issue.
1140
- fn compact_not_ready(full: &Value) -> Value {
1141
- let reasons = not_ready_reasons(full);
1142
- if reasons.is_empty() {
1143
- return Value::Null;
1144
- }
1145
- let agents = full
1146
- .get("incomplete_session_capture_agents")
1147
- .and_then(Value::as_array)
1148
- .cloned()
1149
- .or_else(|| {
1150
- full.get("pending_session_agent_ids")
1151
- .and_then(Value::as_array)
1152
- .cloned()
1153
- })
1154
- .unwrap_or_default();
1155
- let mut obj = Map::new();
1156
- obj.insert(
1157
- "reasons".to_string(),
1158
- Value::Array(reasons.into_iter().map(Value::String).collect()),
1159
- );
1160
- obj.insert("agents".to_string(), Value::Array(agents));
1161
- Value::Object(obj)
1162
- }
1163
-
1164
- fn not_ready_reasons(full: &Value) -> Vec<String> {
1165
- let mut reasons = Vec::new();
1166
- let coord = full.get("coordinator");
1167
- let coord_status = coord
1168
- .and_then(|c| c.get("status"))
1169
- .and_then(Value::as_str)
1170
- .unwrap_or("");
1171
- if coord_status != "running" && coord_status != "ok" {
1172
- reasons.push("coordinator_not_running".to_string());
1173
- }
1174
- if coord
1175
- .and_then(|c| c.get("schema_ok"))
1176
- .and_then(Value::as_bool)
1177
- == Some(false)
1178
- {
1179
- reasons.push("coordinator_schema_not_ok".to_string());
1180
- }
1181
- if full.get("tmux_session_present").and_then(Value::as_bool) == Some(false) {
1182
- reasons.push("tmux_session_missing".to_string());
1183
- }
1184
- let readiness = full.get("readiness");
1185
- if readiness
1186
- .and_then(|r| r.get("all_spawned"))
1187
- .and_then(Value::as_bool)
1188
- == Some(false)
1189
- {
1190
- reasons.push("workers_not_spawned".to_string());
1191
- }
1192
- if readiness
1193
- .and_then(|r| r.get("all_attached_receiver"))
1194
- .and_then(Value::as_bool)
1195
- == Some(false)
1196
- {
1197
- reasons.push("leader_receiver_unbound".to_string());
1198
- }
1199
- if readiness
1200
- .and_then(|r| r.get("session_capture_complete"))
1201
- .and_then(Value::as_bool)
1202
- == Some(false)
1203
- {
1204
- reasons.push("session_capture_incomplete".to_string());
1205
- }
1206
- if readiness
1207
- .and_then(|r| r.get("awaiting_trust_prompt"))
1208
- .and_then(Value::as_bool)
1209
- == Some(true)
1210
- {
1211
- reasons.push("awaiting_trust_prompt".to_string());
1212
- }
1213
- reasons
1214
- }
1215
-
1216
- fn compact_agents(value: Option<&Value>) -> Value {
1217
- let Some(Value::Object(input)) = value else {
1218
- return json!({});
1219
- };
1220
- let mut out = Map::new();
1221
- for (agent_id, agent) in input {
1222
- out.insert(agent_id.clone(), compact_agent_state(agent_id, agent));
1223
- }
1224
- Value::Object(out)
1225
- }
1226
-
1227
- /// 0.4.x: agent rows in the slim payload have exactly 4 fields. agent_id
1228
- /// is no longer copied in — the map key already carries it. Diagnostic
1229
- /// fields (model, tmux_window_present, session_id, captured_via,
1230
- /// attribution_confidence, display, interacted) move to `--detail`.
1231
- /// `activity` + `last_output_at` are preserved (RM-039-STAT-001).
1232
- fn compact_agent_state(_agent_id: &str, agent: &Value) -> Value {
1233
- let Some(input) = agent.as_object() else {
1234
- return json!({});
1235
- };
1236
- let mut out = Map::new();
1237
- // 0.4.x Phase 1: add `worker_state` (canonical 5-state product
1238
- // surface). `activity` is preserved alongside as the deprecated
1239
- // legacy classifier output (CR R3 same-source contract).
1240
- for key in [
1241
- "status",
1242
- "provider",
1243
- "worker_state",
1244
- "activity",
1245
- "last_output_at",
1246
- "stale",
1247
- "stale_reason",
1248
- ] {
1249
- if let Some(value) = input.get(key) {
1250
- out.insert(key.to_string(), value.clone());
1251
- }
1252
- }
1253
- Value::Object(out)
1254
- }
1255
-
1256
- fn compact_tasks(value: Option<&Value>) -> Value {
1257
- let Some(Value::Array(tasks)) = value else {
1258
- return json!([]);
1259
- };
1260
- Value::Array(
1261
- tasks
1262
- .iter()
1263
- .map(|task| {
1264
- compact_object(
1265
- Some(task),
1266
- &[
1267
- "id",
1268
- "title",
1269
- "status",
1270
- "assignee",
1271
- "type",
1272
- "accepted_result_id",
1273
- ],
1274
- )
1275
- })
1276
- .collect(),
1277
- )
1278
- }
1279
-
1280
- fn compact_object(value: Option<&Value>, keys: &[&str]) -> Value {
1281
- let Some(Value::Object(input)) = value else {
1282
- return json!({});
1283
- };
1284
- let mut out = Map::new();
1285
- for key in keys {
1286
- if let Some(value) = input.get(*key) {
1287
- out.insert((*key).to_string(), value.clone());
1288
- }
1289
- }
1290
- Value::Object(out)
1291
- }
1292
-
1293
- fn take_array(value: Option<&Value>, limit: usize) -> Value {
1294
- let Some(Value::Array(items)) = value else {
1295
- return json!([]);
1296
- };
1297
- Value::Array(items.iter().take(limit).cloned().collect())
1298
- }
1299
-
1300
- fn take_array_tail(value: Option<&Value>, limit: usize) -> Value {
1301
- let Some(Value::Array(items)) = value else {
1302
- return json!([]);
1303
- };
1304
- let start = items.len().saturating_sub(limit);
1305
- Value::Array(items.iter().skip(start).cloned().collect())
1306
- }
1307
-
1308
- fn count_rows(
1309
- conn: &rusqlite::Connection,
1310
- table: &str,
1311
- owner_team_id: Option<&str>,
1312
- ) -> Result<i64, CliError> {
1313
- match owner_team_id {
1314
- Some(team) => {
1315
- let sql = format!("select count(*) from {table} where owner_team_id = ?1");
1316
- conn.query_row(&sql, [team], |row| row.get::<_, i64>(0))
1317
- .map_err(|e| CliError::Runtime(e.to_string()))
1318
- }
1319
- None => {
1320
- let sql = format!("select count(*) from {table}");
1321
- conn.query_row(&sql, [], |row| row.get::<_, i64>(0))
1322
- .map_err(|e| CliError::Runtime(e.to_string()))
1323
- }
1324
- }
1325
- }
1326
-
1327
- fn count_where_status(
1328
- conn: &rusqlite::Connection,
1329
- table: &str,
1330
- owner_team_id: Option<&str>,
1331
- status: &str,
1332
- ) -> Result<i64, CliError> {
1333
- match owner_team_id {
1334
- Some(team) => {
1335
- let sql =
1336
- format!("select count(*) from {table} where status = ?1 and owner_team_id = ?2");
1337
- conn.query_row(&sql, params![status, team], |row| row.get::<_, i64>(0))
1338
- .map_err(|e| CliError::Runtime(e.to_string()))
1339
- }
1340
- None => {
1341
- let sql = format!("select count(*) from {table} where status = ?1");
1342
- conn.query_row(&sql, [status], |row| row.get::<_, i64>(0))
1343
- .map_err(|e| CliError::Runtime(e.to_string()))
1344
- }
1345
- }
1346
- }
1347
-
1348
- fn agent_health(
1349
- conn: &rusqlite::Connection,
1350
- owner_team_id: Option<&str>,
1351
- ) -> Result<Value, CliError> {
1352
- let sql = match owner_team_id {
1353
- Some(_) => {
1354
- "select agent_id, status, last_output_at, context_usage_pct, current_task_id, updated_at, owner_team_id
1355
- from agent_health where owner_team_id = ?1 order by agent_id"
1356
- }
1357
- None => {
1358
- "select agent_id, status, last_output_at, context_usage_pct, current_task_id, updated_at, owner_team_id
1359
- from agent_health order by agent_id"
1360
- }
1361
- };
1362
- let mut stmt = conn
1363
- .prepare(sql)
1364
- .map_err(|e| CliError::Runtime(e.to_string()))?;
1365
- let mut rows = match owner_team_id {
1366
- Some(team) => stmt
1367
- .query(params![team])
1368
- .map_err(|e| CliError::Runtime(e.to_string()))?,
1369
- None => stmt
1370
- .query([])
1371
- .map_err(|e| CliError::Runtime(e.to_string()))?,
1372
- };
1373
- let mut out = Map::new();
1374
- while let Some(row) = rows.next().map_err(|e| CliError::Runtime(e.to_string()))? {
1375
- let agent_id: String = row.get(0).map_err(|e| CliError::Runtime(e.to_string()))?;
1376
- let status: String = row.get(1).map_err(|e| CliError::Runtime(e.to_string()))?;
1377
- let mut item = Map::new();
1378
- item.insert("status".to_string(), json!(status));
1379
- item.insert(
1380
- "health_status".to_string(),
1381
- json!(crate::provider::agent_health_status(
1382
- item.get("status").and_then(Value::as_str).unwrap_or("")
1383
- )),
1384
- );
1385
- insert_optional_string(
1386
- &mut item,
1387
- "last_output_at",
1388
- row.get(2).map_err(|e| CliError::Runtime(e.to_string()))?,
1389
- );
1390
- insert_optional_i64(
1391
- &mut item,
1392
- "context_usage_pct",
1393
- row.get(3).map_err(|e| CliError::Runtime(e.to_string()))?,
1394
- );
1395
- let current_task_id: Option<String> =
1396
- row.get(4).map_err(|e| CliError::Runtime(e.to_string()))?;
1397
- let has_current_task = current_task_id.is_some();
1398
- insert_optional_string(&mut item, "current_task_id", current_task_id);
1399
- let updated_at: String = row.get(5).map_err(|e| CliError::Runtime(e.to_string()))?;
1400
- // Phase-DX E2 (plan §4 / CR supplement A): expose the last agent_health
1401
- // observation timestamp as `health_updated_at` alongside the legacy
1402
- // `updated_at` alias. Two names for one column keep old scrapers working
1403
- // while surfacing the semantic (heartbeat, not row bookkeeping).
1404
- item.insert("updated_at".to_string(), json!(updated_at.clone()));
1405
- item.insert("health_updated_at".to_string(), json!(updated_at));
1406
- // Phase-DX E2 (CR P0 red line #6, supplements A/B): current_task is a
1407
- // best-effort *display* field until A1 makes task FSM authoritative.
1408
- // The structured source/confidence markers stop downstream code from
1409
- // treating agent_health.current_task_id as authority. `current_task_source`
1410
- // records where the display value came from (only "health" today —
1411
- // Phase-DX never merges state tasks into this projection); the
1412
- // `current_task_confidence` enum stays "best_effort" for the whole
1413
- // Phase-DX slice (A1 will later flip it to "authoritative" when the
1414
- // task FSM lands). Field is written unconditionally so consumers can
1415
- // switch on it even when `current_task_id` is null.
1416
- item.insert(
1417
- "current_task_source".to_string(),
1418
- json!(if has_current_task { "health" } else { "none" }),
1419
- );
1420
- item.insert("current_task_confidence".to_string(), json!("best_effort"));
1421
- insert_optional_string(
1422
- &mut item,
1423
- "owner_team_id",
1424
- row.get(6).map_err(|e| CliError::Runtime(e.to_string()))?,
1425
- );
1426
- out.insert(agent_id, Value::Object(item));
1427
- }
1428
- Ok(Value::Object(out))
1429
- }
1430
-
1431
- fn insert_optional_string(map: &mut Map<String, Value>, key: &str, value: Option<String>) {
1432
- if let Some(value) = value {
1433
- map.insert(key.to_string(), Value::String(value));
1434
- }
1435
- }
1436
-
1437
- fn insert_optional_i64(map: &mut Map<String, Value>, key: &str, value: Option<i64>) {
1438
- if let Some(value) = value {
1439
- map.insert(key.to_string(), json!(value));
1440
- }
1441
- }
1442
-
1443
- /// B-5 / 036b N38 — status 出口的 runtime 块:把 coordinator_health 与
1444
- /// undelivered backlog 合体暴露。down-hint 只在【coordinator 不在跑 ∧ 有 backlog】
1445
- /// 两条件同时满足才挂(anti-nag);健康状态下不挂提示。auto-recovery 不做。
1446
- fn build_runtime_status_block(
1447
- coordinator_running: bool,
1448
- undelivered: i64,
1449
- tmux_session_missing: bool,
1450
- freshness: &RuntimeFreshness,
1451
- ) -> Value {
1452
- let mut runtime = serde_json::Map::new();
1453
- runtime.insert(
1454
- "coordinator".to_string(),
1455
- json!({"ok": coordinator_running}),
1456
- );
1457
- runtime.insert("undelivered".to_string(), json!(undelivered));
1458
- // 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3): expose
1459
- // typed issues on the runtime block so `status --json --detail`
1460
- // can be scanned for `runtime_bindings_stale_after_boot` the
1461
- // same way `diagnose` surfaces it. Hint precedence: session-
1462
- // missing > host-boot-stale > coordinator-not-running-with-
1463
- // backlog — most-actionable-first.
1464
- let mut issues: Vec<Value> = Vec::new();
1465
- if freshness.host_boot_stale {
1466
- issues.push(json!({
1467
- "id": "runtime_bindings_stale_after_boot",
1468
- "recorded_host_boot_id": freshness.host_boot_recorded,
1469
- "current_host_boot_id": freshness.host_boot_current,
1470
- }));
1471
- }
1472
- if !issues.is_empty() {
1473
- runtime.insert("issues".to_string(), Value::Array(issues));
1474
- }
1475
- if tmux_session_missing {
1476
- runtime.insert(
1477
- "hint".to_string(),
1478
- json!("tmux session missing — run team-agent restart"),
1479
- );
1480
- } else if freshness.host_boot_stale {
1481
- runtime.insert(
1482
- "hint".to_string(),
1483
- json!("runtime_bindings_stale_after_boot — run team-agent restart"),
1484
- );
1485
- } else if !coordinator_running && undelivered > 0 {
1486
- runtime.insert(
1487
- "hint".to_string(),
1488
- json!(format!(
1489
- "coordinator not running with {undelivered} undelivered — run team-agent restart"
1490
- )),
1491
- );
1492
- }
1493
- Value::Object(runtime)
1494
- }
1495
-
1496
- /// Whether the coordinator HealthReport reflects a running tick loop. Used by the
1497
- /// runtime block + the hint gate.
1498
- fn coordinator_status_running(health: &crate::coordinator::HealthReport) -> bool {
1499
- // 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3): runtime
1500
- // service predicate is `service_available`, not any-`Running` pid.
1501
- // A stale-pid daemon is CoordinatorHealthStatus::Stale (or Running
1502
- // with metadata_ok=false); a service-compatible newer daemon is
1503
- // Running + service_available=true — send/diagnose already use
1504
- // this same truth source, and status must agree.
1505
- health.service_available
1506
- }
1507
-
1508
- /// Count of messages currently sitting in delivery-able backlog
1509
- /// (accepted/pending/queued forms — not delivered / not failed / not refused).
1510
- /// owner_team_id scope honored when present.
1511
- fn count_undelivered_backlog(
1512
- conn: &rusqlite::Connection,
1513
- owner_team_id: Option<&str>,
1514
- ) -> Result<i64, CliError> {
1515
- // Backlog statuses chosen to mirror what `deliver_pending` would pick up.
1516
- let sql = match owner_team_id {
1517
- Some(_) => "select count(*) from messages
1518
- where owner_team_id = ?1 and status in ('accepted','pending','queued','queued_until_trust')",
1519
- None => "select count(*) from messages
1520
- where status in ('accepted','pending','queued','queued_until_trust')",
1521
- };
1522
- let count: i64 = match owner_team_id {
1523
- Some(team) => conn
1524
- .query_row(sql, params![team], |row| row.get(0))
1525
- .map_err(|e| CliError::Runtime(e.to_string()))?,
1526
- None => conn
1527
- .query_row(sql, [], |row| row.get(0))
1528
- .map_err(|e| CliError::Runtime(e.to_string()))?,
1529
- };
1530
- Ok(count)
1531
- }
1532
-
1533
- fn coordinator_health_value(health: crate::coordinator::HealthReport) -> Value {
1534
- let expose_binary_drift = health.service_available && !health.metadata_ok;
1535
- let binary_identity_relation = health.binary_identity_relation.as_str();
1536
- let mut value = json!({
1537
- "ok": health.ok,
1538
- "status": coordinator_status_wire(health.status),
1539
- "pid": health.pid.map(|p| p.get()),
1540
- "metadata": health.metadata.map(|m| json!({
1541
- "pid": m.pid.get(),
1542
- "protocol_version": m.protocol_version,
1543
- "message_store_schema_version": m.message_store_schema_version,
1544
- "binary_path": m.binary_path,
1545
- "binary_version": m.binary_version,
1546
- "source": m.source,
1547
- "updated_at": m.updated_at,
1548
- })),
1549
- "metadata_ok": health.metadata_ok,
1550
- "metadata_mismatch_reason": health.metadata_mismatch_reason,
1551
- "binary_path": health.current_binary_identity.binary_path,
1552
- "binary_version": health.current_binary_identity.binary_version,
1553
- "schema_ok": health.schema.ok,
1554
- "schema_error": health.schema.error.map(|e| format!("{e:?}")),
1555
- "schema": {
1556
- "message_store_schema_version": health.schema.schema_version,
1557
- },
1558
- // 0.5.41 Slice 3 (fault-invisibility-locate.md §5/§6.3):
1559
- // `service_available` is now always exposed alongside `ok`
1560
- // and `status` so status/diagnose consumers can share one
1561
- // service-availability truth without keying on the legacy
1562
- // `expose_binary_drift` conditional (that path only fired
1563
- // for the newer-daemon-preserved shape). `binary_identity_
1564
- // relation` moves to always-on for the same reason — RED3
1565
- // scans the summary for `daemon_newer_than_caller`.
1566
- "service_available": health.service_available,
1567
- "binary_identity_relation": binary_identity_relation,
1568
- });
1569
- if expose_binary_drift {
1570
- if let Some(obj) = value.as_object_mut() {
1571
- obj.insert("wire_metadata_ok".to_string(), Value::Bool(true));
1572
- obj.insert("binary_identity_ok".to_string(), Value::Bool(false));
1573
- }
1574
- }
1575
- value
1576
- }
1577
-
1578
- fn coordinator_status_wire(status: crate::coordinator::CoordinatorHealthStatus) -> &'static str {
1579
- match status {
1580
- crate::coordinator::CoordinatorHealthStatus::Missing => "missing",
1581
- crate::coordinator::CoordinatorHealthStatus::InvalidPid => "invalid_pid",
1582
- crate::coordinator::CoordinatorHealthStatus::Running => "running",
1583
- crate::coordinator::CoordinatorHealthStatus::Stale => "stale",
82
+ None => Ok(crate::cli::format_status_csv(snapshot.full())),
1584
83
  }
1585
84
  }