gabriel / muse public
list_coord.py python
523 lines 19.4 KB
Raw
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f fix: show full cryptographic IDs in all human-readable CLI output Sonnet 4.6 patch 8 days ago
1 """``muse coord list`` — display the live coordination state of the swarm.
2
3 Shows every reservation and intent currently stored in
4 ``.muse/coordination/``, with optional filtering, sorting, and structured
5 JSON output for agents. This is the primary observability primitive for a
6 running swarm: without it, the coordination layer is a write-only black box.
7
8 Without ``--all`` only *active* (non-expired) reservations are shown;
9 intents are always shown because they carry no TTL.
10
11 Usage::
12
13 muse coord list # active reservations + intents
14 muse coord list --all # include expired reservations
15 muse coord list --kind reservations # reservations only
16 muse coord list --kind intents # intents only
17 muse coord list --run-id agent-42 # filter by agent
18 muse coord list --branch feat/billing # filter by branch (exact)
19 muse coord list --address "billing.py::*"# fnmatch glob on addresses
20 muse coord list --op rename # filter by operation type
21 muse coord list --limit 20 # cap output to 20 of each kind
22 muse coord list --summary # one-line count summary
23 muse coord list --format json # machine-readable JSON
24 muse coord list --json # shorthand for --format json
25
26 JSON schema::
27
28 {
29 "active_reservations": int,
30 "expired_reservations": int,
31 "released_reservations": int,
32 "total_reservations_shown": int,
33 "total_intents_shown": int,
34 "has_conflicts": bool,
35 "filters": {
36 "run_id": str | null,
37 "branch": str | null,
38 "address_glob": str | null,
39 "operation": str | null,
40 "include_expired": bool,
41 "kind": "all" | "reservations" | "intents",
42 "limit": int | null
43 },
44 "reservations": [
45 {
46 "reservation_id": str,
47 "run_id": str,
48 "branch": str,
49 "addresses": [str, ...],
50 "created_at": str, // ISO 8601
51 "expires_at": str, // ISO 8601 (original TTL)
52 "effective_expires_at": str, // ISO 8601 (max of expires_at and heartbeat)
53 "ttl_remaining_seconds": float, // negative when expired
54 "operation": str | null,
55 "is_active": bool,
56 "released": bool, // true when a release tombstone exists
57 "conflict_count": int // other active reservations sharing ≥1 address
58 },
59 ...
60 ],
61 "intents": [
62 {
63 "intent_id": str,
64 "reservation_id": str,
65 "run_id": str,
66 "branch": str,
67 "addresses": [str, ...],
68 "operation": str,
69 "created_at": str,
70 "detail": str
71 },
72 ...
73 ],
74 "duration_ms": float
75 }
76
77 Exit codes::
78
79 0 — success (zero records is still success)
80 1 — bad arguments
81 """
82
83 import argparse
84 import datetime
85 import json
86 import sys
87 import time
88 from typing import TypedDict
89
90 from muse.core.coordination import (
91 Reservation,
92 filter_intents,
93 filter_reservations,
94 load_all_intents,
95 load_all_reservations,
96 load_heartbeat_map,
97 load_released_ids,
98 )
99 from muse.core.envelope import EnvelopeJson, make_envelope
100 from muse.core.repo import require_repo
101 from muse.core.timing import start_timer
102 from muse.core.validation import sanitize_display
103
104 type _ResIdMap = dict[str, set[str]]
105 # ── Typed JSON schema ─────────────────────────────────────────────────────────
106
107 class _ReservationEntry(TypedDict):
108 reservation_id: str
109 run_id: str
110 branch: str
111 addresses: list[str]
112 created_at: str
113 expires_at: str
114 effective_expires_at: str
115 ttl_remaining_seconds: float
116 operation: str | None
117 is_active: bool
118 released: bool
119 conflict_count: int
120
121 class _IntentEntry(TypedDict):
122 intent_id: str
123 reservation_id: str
124 run_id: str
125 branch: str
126 addresses: list[str]
127 operation: str
128 created_at: str
129 detail: str
130
131 class _FiltersEntry(TypedDict):
132 run_id: str | None
133 branch: str | None
134 address_glob: str | None
135 operation: str | None
136 include_expired: bool
137 kind: str
138 limit: int | None
139
140 class _ListJson(EnvelopeJson):
141 active_reservations: int
142 expired_reservations: int
143 released_reservations: int
144 total_reservations_shown: int
145 total_intents_shown: int
146 has_conflicts: bool
147 filters: _FiltersEntry
148 reservations: list[_ReservationEntry]
149 intents: list[_IntentEntry]
150
151 # ── Helpers ───────────────────────────────────────────────────────────────────
152
153 def _format_ttl(seconds: float) -> str:
154 """Format a TTL in seconds as a human-readable string.
155
156 Examples
157 --------
158 ``3661.0`` → ``"1h 1m 1s"``
159 ``90.0`` → ``"1m 30s"``
160 ``45.0`` → ``"45s"``
161 ``0.0`` → ``"EXPIRED"``
162 ``-10.0`` → ``"EXPIRED"``
163 """
164 if seconds <= 0:
165 return "EXPIRED"
166 total = int(seconds)
167 hours, remainder = divmod(total, 3600)
168 minutes, secs = divmod(remainder, 60)
169 if hours:
170 return f"{hours}h {minutes}m {secs}s"
171 if minutes:
172 return f"{minutes}m {secs}s"
173 return f"{secs}s"
174
175 # ── CLI registration ──────────────────────────────────────────────────────────
176
177 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
178 """Register the ``list`` subcommand on *subparsers* (under ``muse coord``)."""
179 parser = subparsers.add_parser(
180 "list",
181 help="Display the current coordination state of the swarm.",
182 description=__doc__,
183 formatter_class=argparse.RawDescriptionHelpFormatter,
184 )
185 parser.add_argument(
186 "--all", "-a",
187 action="store_true",
188 dest="include_expired",
189 help="Include expired reservations (default: active only).",
190 )
191 parser.add_argument(
192 "--kind",
193 default="all",
194 choices=("all", "reservations", "intents"),
195 metavar="KIND",
196 help="What to show: all (default), reservations, or intents.",
197 )
198 parser.add_argument(
199 "--run-id",
200 default=None,
201 dest="run_id",
202 metavar="ID",
203 help="Show only records whose run-id exactly matches ID.",
204 )
205 parser.add_argument(
206 "--branch", "-b",
207 default=None,
208 dest="branch",
209 metavar="BRANCH",
210 help="Show only records on this branch (exact match).",
211 )
212 parser.add_argument(
213 "--address",
214 default=None,
215 dest="address_glob",
216 metavar="GLOB",
217 help=(
218 "Show only records where at least one address matches this "
219 "fnmatch glob (e.g. \"billing.py::*\")."
220 ),
221 )
222 parser.add_argument(
223 "--op",
224 default=None,
225 dest="operation",
226 metavar="OPERATION",
227 help=(
228 "Show only records with this operation type "
229 "(e.g. rename, modify, delete, extract, move, inline, split, merge)."
230 ),
231 )
232 parser.add_argument(
233 "--limit",
234 type=int,
235 default=None,
236 dest="limit",
237 metavar="N",
238 help="Cap output to N reservations and N intents (useful for large swarms).",
239 )
240 parser.add_argument(
241 "--summary",
242 action="store_true",
243 help="Print a single-line count summary and exit.",
244 )
245 parser.add_argument(
246 "--json", "-j",
247 action="store_true",
248 dest="json_out",
249 help="Emit machine-readable JSON.",
250 )
251 parser.set_defaults(func=run)
252
253 # ── Command implementation ────────────────────────────────────────────────────
254
255 def run(args: argparse.Namespace) -> None:
256 """Display the live coordination state of the swarm.
257
258 Loads all reservation and intent records from ``.muse/coordination/``,
259 applies filters in memory, and emits the result. Each reservation entry
260 includes ``conflict_count`` (how many other active reservations share an
261 address) and the top-level ``has_conflicts`` flag for a quick signal
262 without running ``muse coord forecast``.
263
264 Agent quickstart
265 ----------------
266 ::
267
268 muse coord list --format json
269 muse coord list --run-id agent-42 --format json
270 muse coord list --branch feat/billing --format json
271 muse coord list --include-expired --format json
272
273 JSON fields
274 -----------
275 active_reservations Total active reservation count.
276 expired_reservations Expired (but not released) count.
277 total_reservations_shown Reservations after filters applied.
278 total_intents_shown Intents after filters applied.
279 has_conflicts ``true`` when any reservation has overlap.
280 filters Echo of filter arguments used.
281 reservations List of reservation objects.
282 intents List of intent objects.
283
284 Exit codes
285 ----------
286 0 Success.
287 1 Invalid arguments.
288 2 Not inside a Muse repository.
289 """
290 elapsed = start_timer()
291
292 include_expired: bool = args.include_expired
293 kind: str = args.kind
294 run_id: str | None = args.run_id
295 branch: str | None = args.branch
296 address_glob: str | None = args.address_glob
297 operation: str | None = args.operation
298 limit: int | None = args.limit
299 summary_only: bool = args.summary
300 json_out: bool = args.json_out
301 root = require_repo()
302
303 # ── Load ──────────────────────────────────────────────────────────────────
304 all_reservations = load_all_reservations(root)
305 all_intents = load_all_intents(root)
306 released_ids = load_released_ids(root)
307 hb_map = load_heartbeat_map(root)
308 # Pre-compute heartbeat effective-expiry mapping for filter_reservations.
309 heartbeat_expires = {rid: hb.extended_expires_at for rid, hb in hb_map.items()}
310
311 # Capture now once — reused for all TTL calculations so timestamps are
312 # consistent across every entry in this invocation.
313 now_utc = datetime.datetime.now(datetime.timezone.utc)
314
315 def _effective_expires(r: Reservation) -> datetime.datetime:
316 hb = hb_map.get(r.reservation_id)
317 return max(r.expires_at, hb.extended_expires_at) if hb is not None else r.expires_at
318
319 def _effective_ttl(r: Reservation) -> float:
320 return (_effective_expires(r) - now_utc).total_seconds()
321
322 # Count totals before filtering for summary bookkeeping.
323 total_active = sum(
324 1 for r in all_reservations
325 if r.reservation_id not in released_ids and now_utc < _effective_expires(r)
326 )
327 total_expired = len(all_reservations) - total_active
328
329 # ── Filter ────────────────────────────────────────────────────────────────
330 reservations = filter_reservations(
331 all_reservations,
332 run_id=run_id,
333 branch=branch,
334 address_glob=address_glob,
335 operation=operation,
336 include_expired=include_expired,
337 released_ids=released_ids,
338 heartbeat_expires=heartbeat_expires,
339 )
340 intents = filter_intents(
341 all_intents,
342 run_id=run_id,
343 branch=branch,
344 address_glob=address_glob,
345 operation=operation,
346 )
347
348 # Sort both lists by created_at ascending (oldest first).
349 reservations.sort(key=lambda r: r.created_at)
350 intents.sort(key=lambda i: i.created_at)
351
352 # Apply limit after sorting so the N oldest are returned.
353 if limit is not None and limit > 0:
354 reservations = reservations[:limit]
355 intents = intents[:limit]
356
357 # ── Conflict detection ────────────────────────────────────────────────────
358 # Build address → {reservation_id} map for the active (post-filter) set.
359 # This lets each reservation report how many peers share ≥1 address.
360 active_res_ids = {
361 r.reservation_id for r in reservations
362 if _effective_ttl(r) > 0 and r.reservation_id not in released_ids
363 }
364 addr_to_res_ids: _ResIdMap = {}
365 for r in reservations:
366 if r.reservation_id in active_res_ids:
367 for addr in r.addresses:
368 addr_to_res_ids.setdefault(addr, set()).add(r.reservation_id)
369
370 def _conflict_count(r: Reservation) -> int:
371 """Count distinct active reservations sharing ≥1 address with *r*."""
372 conflicting: set[str] = set()
373 for addr in r.addresses:
374 for rid in addr_to_res_ids.get(addr, set()):
375 if rid != r.reservation_id:
376 conflicting.add(rid)
377 return len(conflicting)
378
379 # ── Summary mode ──────────────────────────────────────────────────────────
380 if summary_only:
381 show_res = kind in ("all", "reservations")
382 show_int = kind in ("all", "intents")
383 parts: list[str] = []
384 if show_res:
385 parts.append(f"{len(reservations)} reservation(s)")
386 if show_int:
387 parts.append(f"{len(intents)} intent(s)")
388 suffix = ""
389 if not include_expired and total_expired:
390 suffix = f" [{total_expired} expired hidden — use --all to show]"
391 if not parts or (len(reservations) == 0 and len(intents) == 0 and not (show_res or show_int)):
392 print("No coordination records.")
393 else:
394 print(", ".join(parts) + suffix)
395 return
396
397 # ── JSON output ───────────────────────────────────────────────────────────
398 if json_out:
399 res_entries: list[_ReservationEntry] = []
400 for r in reservations:
401 if kind in ("all", "reservations"):
402 eff_exp = _effective_expires(r)
403 eff_ttl = _effective_ttl(r)
404 res_entries.append(_ReservationEntry(
405 reservation_id=r.reservation_id,
406 run_id=r.run_id,
407 branch=r.branch,
408 addresses=r.addresses,
409 created_at=r.created_at.isoformat(),
410 expires_at=r.expires_at.isoformat(),
411 effective_expires_at=eff_exp.isoformat(),
412 ttl_remaining_seconds=round(eff_ttl, 2),
413 operation=r.operation,
414 is_active=eff_ttl > 0 and r.reservation_id not in released_ids,
415 released=r.reservation_id in released_ids,
416 conflict_count=_conflict_count(r),
417 ))
418
419 int_entries: list[_IntentEntry] = []
420 for i in intents:
421 if kind in ("all", "intents"):
422 int_entries.append(_IntentEntry(
423 intent_id=i.intent_id,
424 reservation_id=i.reservation_id,
425 run_id=i.run_id,
426 branch=i.branch,
427 addresses=i.addresses,
428 operation=i.operation,
429 created_at=i.created_at.isoformat(),
430 detail=i.detail,
431 ))
432
433 total_released = len(released_ids)
434 has_conflicts = any(e["conflict_count"] > 0 for e in res_entries)
435 print(json.dumps(_ListJson(
436 **make_envelope(elapsed),
437 active_reservations=total_active,
438 expired_reservations=total_expired,
439 released_reservations=total_released,
440 total_reservations_shown=len(res_entries),
441 total_intents_shown=len(int_entries),
442 has_conflicts=has_conflicts,
443 filters=_FiltersEntry(
444 run_id=run_id,
445 branch=branch,
446 address_glob=address_glob,
447 operation=operation,
448 include_expired=include_expired,
449 kind=kind,
450 limit=limit,
451 ),
452 reservations=res_entries,
453 intents=int_entries,
454 )))
455 return
456
457 # ── Text output ───────────────────────────────────────────────────────────
458 show_res = kind in ("all", "reservations")
459 show_int = kind in ("all", "intents")
460
461 n_res = len(reservations) if show_res else 0
462 n_int = len(intents) if show_int else 0
463
464 header = f"\nCoordination state"
465 parts = []
466 if show_res:
467 parts.append(f"{n_res} reservation(s)")
468 if show_int:
469 parts.append(f"{n_int} intent(s)")
470 if parts:
471 header += f" — {', '.join(parts)}"
472 if not include_expired and total_expired and show_res:
473 header += f" [{total_expired} expired hidden]"
474 print(header)
475 print("─" * 62)
476
477 if n_res == 0 and n_int == 0:
478 print("\n (no coordination records")
479 if not include_expired and total_expired:
480 print(f" {total_expired} expired — run with --all to show them)")
481 else:
482 print(" run 'muse coord reserve' to create one)")
483 return
484
485 # Reservations block.
486 if show_res and reservations:
487 print("\nReservations")
488 for res in reservations:
489 is_released = res.reservation_id in released_ids
490 eff_ttl = _effective_ttl(res)
491 ttl_str = _format_ttl(eff_ttl)
492 if is_released:
493 status_label = "RELEASED"
494 elif eff_ttl > 0:
495 status_label = "expires in"
496 else:
497 status_label = ""
498 op_str = f" op={sanitize_display(res.operation)}" if res.operation else ""
499 run_label = sanitize_display(res.run_id)
500 branch_label = sanitize_display(res.branch)
501 conflicts = _conflict_count(res)
502 conflict_str = f" ⚠ {conflicts} conflict(s)" if conflicts > 0 else ""
503 print(
504 f" [{sanitize_display(res.reservation_id)}] {run_label:<20} {branch_label:<22}"
505 f"{op_str} {status_label} {ttl_str}{conflict_str}"
506 )
507 for addr in res.addresses:
508 print(f" {sanitize_display(addr)}")
509
510 # Intents block.
511 if show_int and intents:
512 print("\nIntents")
513 for intent in intents:
514 run_label = sanitize_display(intent.run_id)
515 branch_label = sanitize_display(intent.branch)
516 op_label = sanitize_display(intent.operation)
517 print(f" [{sanitize_display(intent.intent_id)}] {run_label:<20} {branch_label:<22} {op_label}")
518 for addr in intent.addresses:
519 print(f" {sanitize_display(addr)}")
520 if intent.detail:
521 print(f" → {sanitize_display(intent.detail)}")
522
523 print(f"\n ({elapsed():.3f}s)")
File History 1 commit
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f fix: show full cryptographic IDs in all human-readable CLI output Sonnet 4.6 patch 8 days ago