返回 CodeWhale
coordination_detail.rs
根目录 / crates / tui / src / tui / coordination_detail.rs
1 //! Bounded, non-transcript presentation for delegated coordination receipts.
2 //!
3 //! Headless callers keep the machine-readable typed projection. The TUI uses
4 //! this single formatter for its Work inspector so compact rows and details do
5 //! not grow a second, string-parsed coordination model.
6
7 use std::fmt::Write as _;
8
9 use crate::localization::{Locale, MessageId, tr};
10 use crate::tools::subagent::CoordinationDetailProjection;
11 use crate::tools::subagent::coord::{DecisionStatus, ReconciliationReceipt};
12
13 #[must_use]
14 pub(crate) fn summary(locale: Locale, projection: &CoordinationDetailProjection) -> String {
15 [
16 tr(locale, MessageId::CoordinationSummaryDecisions)
17 .replace("{count}", &projection.decisions.len().to_string()),
18 tr(locale, MessageId::CoordinationSummaryContentions)
19 .replace("{count}", &projection.contentions.len().to_string()),
20 tr(locale, MessageId::CoordinationSummaryReconciled)
21 .replace("{count}", &projection.reconciliations.len().to_string()),
22 ]
23 .join(" · ")
24 }
25
26 #[must_use]
27 pub(crate) fn needs_attention(projection: &CoordinationDetailProjection) -> bool {
28 !projection.process_lock_held
29 || projection
30 .decisions
31 .iter()
32 .any(|decision| decision.status == DecisionStatus::Proposed)
33 || projection
34 .reconciliations
35 .iter()
36 .any(|receipt| receipt.verification_outcome != "verified")
37 || projection
38 .contentions
39 .iter()
40 .any(|contention| contention.disposition.blocks_admission())
41 }
42
43 /// Format the durable coordination projection for the shared Work pager.
44 ///
45 /// Deliberately omitted: decision constraints and general evidence handles.
46 /// Those fields inform delegated prompts and headless inspection, but they can
47 /// contain operator-authored detail that does not belong in ambient TUI chrome.
48 #[must_use]
49 pub(crate) fn format(locale: Locale, projection: &CoordinationDetailProjection) -> String {
50 let mut out = String::new();
51 let _ = writeln!(
52 out,
53 "{} · {} · {}",
54 tr(locale, MessageId::CoordinationSchema)
55 .replace("{value}", &projection.schema_version.to_string()),
56 tr(locale, MessageId::CoordinationSequence)
57 .replace("{value}", &projection.sequence.to_string()),
58 tr(locale, MessageId::CoordinationPerSectionLimit)
59 .replace("{limit}", &projection.limit.to_string())
60 );
61 if !projection.process_lock_held {
62 let note = projection
63 .process_lock_note
64 .as_deref()
65 .unwrap_or("another Codewhale process owns this workspace lock");
66 let _ = writeln!(out, "Coordination lock: unavailable — {note}");
67 }
68
69 section(
70 &mut out,
71 tr(locale, MessageId::CoordinationDecisionsHeading).as_ref(),
72 );
73 if projection.decisions.is_empty() {
74 let _ = writeln!(out, "{}", tr(locale, MessageId::CoordinationNone));
75 } else {
76 for decision in &projection.decisions {
77 let status = tr(locale, MessageId::CoordinationStatus).replace(
78 "{status}",
79 decision_status(locale, decision.status).as_ref(),
80 );
81 let owner =
82 tr(locale, MessageId::CoordinationOwner).replace("{owner}", &decision.owner);
83 let version = tr(locale, MessageId::CoordinationVersion)
84 .replace("{version}", &decision.version.to_string());
85 let _ = writeln!(
86 out,
87 "{} · {}\n {} · {} · {}",
88 decision.decision_id, decision.subject, status, owner, version
89 );
90 }
91 }
92
93 section(
94 &mut out,
95 tr(locale, MessageId::CoordinationWriteClaimsHeading).as_ref(),
96 );
97 if projection.write_claims.is_empty() {
98 let _ = writeln!(out, "{}", tr(locale, MessageId::CoordinationNone));
99 } else {
100 for receipt in &projection.write_claims {
101 let claim = &receipt.claim;
102 let workspace = if receipt.isolated_worktree {
103 tr(locale, MessageId::CoordinationIsolated)
104 } else {
105 tr(locale, MessageId::CoordinationSharedWorkspace)
106 };
107 let _ = writeln!(
108 out,
109 "{} · {}\n {}\n {}",
110 claim.owner,
111 workspace,
112 tr(locale, MessageId::CoordinationPaths).replace(
113 "{paths}",
114 &joined_paths(locale, &claim.roots, &claim.exact_files)
115 ),
116 tr(locale, MessageId::CoordinationContracts)
117 .replace("{contracts}", &joined_or_none(locale, &claim.contracts))
118 );
119 }
120 }
121
122 section(
123 &mut out,
124 tr(locale, MessageId::CoordinationContentionsHeading).as_ref(),
125 );
126 if projection.contentions.is_empty() {
127 let _ = writeln!(out, "{}", tr(locale, MessageId::CoordinationNone));
128 } else {
129 for receipt in &projection.contentions {
130 let claimant = tr(locale, MessageId::CoordinationClaimant)
131 .replace("{claimant}", &receipt.claimant);
132 let owner = tr(locale, MessageId::CoordinationOwner)
133 .replace("{owner}", &receipt.conflicting_owner);
134 let _ = writeln!(
135 out,
136 "{} · {}\n {}\n {}\n {}",
137 claimant,
138 owner,
139 tr(locale, MessageId::CoordinationPaths).replace(
140 "{paths}",
141 &joined_paths(locale, &receipt.roots, &receipt.exact_files)
142 ),
143 tr(locale, MessageId::CoordinationContracts)
144 .replace("{contracts}", &joined_or_none(locale, &receipt.contracts)),
145 tr(locale, MessageId::CoordinationDisposition)
146 .replace("{disposition}", receipt.disposition.as_str())
147 );
148 }
149 }
150
151 section(
152 &mut out,
153 tr(locale, MessageId::CoordinationNeutralReconciliationHeading).as_ref(),
154 );
155 if projection.reconciliations.is_empty() {
156 let _ = writeln!(out, "{}", tr(locale, MessageId::CoordinationNone));
157 } else {
158 for receipt in &projection.reconciliations {
159 format_reconciliation(&mut out, locale, receipt);
160 }
161 }
162
163 section(
164 &mut out,
165 tr(locale, MessageId::CoordinationContextProjectionsHeading).as_ref(),
166 );
167 if projection.context_projections.is_empty() {
168 let _ = writeln!(out, "{}", tr(locale, MessageId::CoordinationNone));
169 } else {
170 for receipt in &projection.context_projections {
171 let decisions = tr(locale, MessageId::CoordinationContextDecisions).replace(
172 "{decisions}",
173 &joined_or_none(locale, &receipt.decision_ids),
174 );
175 let bytes = tr(locale, MessageId::CoordinationBytes)
176 .replace("{count}", &receipt.projected_bytes.to_string());
177 let deduplicated = tr(locale, MessageId::CoordinationDeduplicated)
178 .replace("{count}", &receipt.deduplicated.to_string());
179 let omitted = tr(locale, MessageId::CoordinationOmitted)
180 .replace("{count}", &receipt.omitted.to_string());
181 let _ = writeln!(
182 out,
183 "{} · {} · {} · {} · {}",
184 receipt.child_id, decisions, bytes, deduplicated, omitted
185 );
186 }
187 }
188
189 section(
190 &mut out,
191 tr(locale, MessageId::CoordinationActiveHotPathsHeading).as_ref(),
192 );
193 if projection.metrics.hottest_paths.is_empty() {
194 let _ = writeln!(out, "{}", tr(locale, MessageId::CoordinationNone));
195 } else {
196 for path in &projection.metrics.hottest_paths {
197 let active_claims = tr(locale, MessageId::CoordinationActiveClaims)
198 .replace("{count}", &path.active_claims.to_string());
199 let _ = writeln!(out, "{} · {}", path.path, active_claims);
200 }
201 }
202 section(
203 &mut out,
204 tr(locale, MessageId::CoordinationMetricsNoteHeading).as_ref(),
205 );
206 // The headless projection retains the exact typed metrics note. Ambient
207 // TUI chrome owns a localized explanation of the same current invariant.
208 let _ = writeln!(
209 out,
210 "{}",
211 tr(locale, MessageId::CoordinationMetricsNoAuthoritativeSource)
212 );
213
214 out.trim_end().to_string()
215 }
216
217 fn section(out: &mut String, label: &str) {
218 let _ = write!(out, "\n{label}\n");
219 }
220
221 fn format_reconciliation(out: &mut String, locale: Locale, receipt: &ReconciliationReceipt) {
222 let candidates = tr(locale, MessageId::CoordinationCandidates)
223 .replace("{count}", &receipt.candidate_handles.len().to_string());
224 let retry = tr(locale, MessageId::CoordinationRetry)
225 .replace("{count}", &receipt.retry_count.to_string())
226 .replace("{limit}", &receipt.retry_limit.to_string());
227 let _ = writeln!(
228 out,
229 "{} · {} · {}\n {}\n {}\n {}\n {}",
230 receipt.subject,
231 candidates,
232 retry,
233 tr(locale, MessageId::CoordinationOwner).replace("{owner}", &receipt.owner),
234 tr(locale, MessageId::CoordinationReviewer).replace(
235 "{reviewer}",
236 &joined_or_none(locale, &receipt.reviewer_evidence_handles)
237 ),
238 tr(locale, MessageId::CoordinationVerifier).replace(
239 "{verifier}",
240 &joined_or_none(locale, &receipt.verifier_evidence_handles)
241 ),
242 tr(locale, MessageId::CoordinationVerification)
243 .replace("{verification}", &receipt.verification_outcome)
244 );
245 }
246
247 fn decision_status(locale: Locale, status: DecisionStatus) -> std::borrow::Cow<'static, str> {
248 match status {
249 DecisionStatus::Proposed => tr(locale, MessageId::CoordinationStatusProposed),
250 DecisionStatus::Accepted => tr(locale, MessageId::CoordinationStatusAccepted),
251 DecisionStatus::Superseded => tr(locale, MessageId::CoordinationStatusSuperseded),
252 }
253 }
254
255 fn joined_paths(locale: Locale, roots: &[String], exact_files: &[String]) -> String {
256 let values = roots
257 .iter()
258 .chain(exact_files)
259 .map(String::as_str)
260 .collect::<Vec<_>>();
261 if values.is_empty() {
262 tr(locale, MessageId::CoordinationNoneValue).into_owned()
263 } else {
264 values.join(", ")
265 }
266 }
267
268 fn joined_or_none(locale: Locale, values: &[String]) -> String {
269 if values.is_empty() {
270 tr(locale, MessageId::CoordinationNoneValue).into_owned()
271 } else {
272 values.join(", ")
273 }
274 }
275
276 #[cfg(test)]
277 mod tests {
278 use serde_json::json;
279
280 use super::*;
281 use crate::tools::subagent::coord::{
282 ContextProjectionReceipt, CoordinationDetailMetrics, CoordinationHotPath, DecisionRecord,
283 PersistedWriteClaim, WriteContentionDisposition, WriteContentionReceipt, WriteScopeClaim,
284 };
285
286 fn projection() -> CoordinationDetailProjection {
287 CoordinationDetailProjection {
288 schema_version: 1,
289 sequence: 9,
290 decisions: vec![DecisionRecord {
291 decision_id: "decision-ui".to_string(),
292 subject: "composer edges".to_string(),
293 status: DecisionStatus::Accepted,
294 owner: "planner".to_string(),
295 scope: vec!["path:crates/tui".to_string()],
296 constraints: vec!["PRIVATE-TRANSCRIPT-MARKER".to_string()],
297 evidence_handles: vec!["artifact:hidden-evidence".to_string()],
298 version: 3,
299 sequence: 1,
300 }],
301 write_claims: vec![PersistedWriteClaim {
302 claim: WriteScopeClaim {
303 owner: "worker-a".to_string(),
304 roots: vec!["crates/tui".to_string()],
305 exact_files: vec!["Cargo.toml".to_string()],
306 contracts: vec!["ui-contract".to_string()],
307 },
308 sequence: 2,
309 isolated_worktree: false,
310 }],
311 reconciliations: vec![ReconciliationReceipt {
312 reconciliation_id: "reconcile-ui".to_string(),
313 subject: "composer edges".to_string(),
314 owner: "release-owner".to_string(),
315 input_decisions: vec!["decision-a".to_string(), "decision-b".to_string()],
316 outcome: "candidate-a".to_string(),
317 evidence_handles: Vec::new(),
318 candidate_handles: vec!["branch:a".to_string(), "branch:b".to_string()],
319 retry_count: 1,
320 retry_limit: 3,
321 reviewer_evidence_handles: vec!["agent:reviewer".to_string()],
322 verifier_evidence_handles: vec!["agent:verifier".to_string()],
323 verification_outcome: "verified".to_string(),
324 sequence: 3,
325 }],
326 context_projections: vec![ContextProjectionReceipt {
327 child_id: "worker-a".to_string(),
328 decision_ids: vec!["decision-ui".to_string()],
329 projected_bytes: 128,
330 deduplicated: 2,
331 omitted: 1,
332 sequence: 4,
333 }],
334 contentions: vec![WriteContentionReceipt {
335 claimant: "worker-b".to_string(),
336 conflicting_owner: "worker-a".to_string(),
337 roots: vec!["crates/tui".to_string()],
338 exact_files: vec!["Cargo.toml".to_string()],
339 contracts: vec!["ui-contract".to_string()],
340 disposition: WriteContentionDisposition::BlockedPendingIsolationOrSerialization,
341 resolution_sequence: None,
342 sequence: 5,
343 }],
344 metrics: CoordinationDetailMetrics {
345 hottest_paths: vec![CoordinationHotPath {
346 path: "crates/tui".to_string(),
347 active_claims: 2,
348 }],
349 package_or_module_growth: Some(json!({"ignored": true})),
350 route_or_cost: None,
351 note: "Only active owners contribute to hot paths".to_string(),
352 },
353 bounded: true,
354 limit: 24,
355 process_lock_held: true,
356 process_lock_note: None,
357 }
358 }
359
360 #[test]
361 fn formatter_uses_typed_receipts_without_transcript_shaped_fields() {
362 let text = format(Locale::En, &projection());
363 for required in [
364 "Schema 1 · sequence 9 · up to 24 per section",
365 "decision-ui · composer edges",
366 "status accepted · owner planner · version 3",
367 "claimant worker-b · owner worker-a",
368 "paths crates/tui, Cargo.toml",
369 "contracts ui-contract",
370 "disposition blocked_pending_isolation_or_serialization",
371 "composer edges · 2 candidates · retry 1/3",
372 "reviewer agent:reviewer",
373 "verifier agent:verifier",
374 "verification verified",
375 "worker-a · decisions decision-ui · 128 bytes · 2 deduplicated · 1 omitted",
376 "crates/tui · 2 active claims",
377 ] {
378 assert!(text.contains(required), "missing {required}:\n{text}");
379 }
380 assert!(!text.contains("PRIVATE-TRANSCRIPT-MARKER"), "{text}");
381 assert!(!text.contains("bounded to 24 records"), "{text}");
382 assert!(!text.contains("hidden-evidence"), "{text}");
383 assert!(!text.contains("ignored"), "{text}");
384 }
385
386 #[test]
387 fn complete_locale_packs_translate_chrome_and_preserve_receipt_values() {
388 let value = projection();
389 let english = format(Locale::En, &value);
390 for locale in Locale::shipped_complete() {
391 let text = format(*locale, &value);
392 let summary = summary(*locale, &value);
393 for literal in [
394 "decision-ui",
395 "composer edges",
396 "worker-a",
397 "blocked_pending_isolation_or_serialization",
398 "agent:reviewer",
399 "agent:verifier",
400 "crates/tui",
401 ] {
402 assert!(text.contains(literal), "{locale:?} lost {literal}:\n{text}");
403 }
404 assert!(
405 !text.contains('{'),
406 "{locale:?} has a raw placeholder:\n{text}"
407 );
408 assert!(
409 !summary.contains('{'),
410 "{locale:?} summary has a raw placeholder: {summary}"
411 );
412 if *locale != Locale::En {
413 assert_ne!(text, english, "{locale:?} fell back to English");
414 }
415 }
416 }
417
418 #[test]
419 fn proposed_decisions_and_unverified_reconciliation_need_attention() {
420 let mut value = projection();
421 value.contentions.clear();
422 assert!(!needs_attention(&value));
423 value.decisions[0].status = DecisionStatus::Proposed;
424 assert!(needs_attention(&value));
425 value.decisions[0].status = DecisionStatus::Accepted;
426 value.reconciliations[0].verification_outcome = "blocked".to_string();
427 assert!(needs_attention(&value));
428 }
429
430 #[test]
431 fn blocked_contention_attention_uses_durable_resolution_state() {
432 let mut value = projection();
433 assert!(needs_attention(&value));
434
435 value.contentions[0].disposition = WriteContentionDisposition::ResolvedBySuccessfulClaim;
436 value.contentions[0].resolution_sequence = Some(6);
437 assert!(!needs_attention(&value));
438
439 let text = format(Locale::En, &value);
440 assert!(
441 text.contains("disposition resolved_by_successful_claim"),
442 "{text}"
443 );
444 assert!(!text.contains("disposition blocked_pending"), "{text}");
445 }
446 }
447
447 lines RUST