gabriel / muse public
list_coord.py python
563 lines 20.5 KB
Raw
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f fix: show full cryptographic IDs in all human-readable CLI output Sonnet 4.6 patch 35 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 "elapsed_seconds": float
75 }
76
77 Exit codes::
78
79 0 — success (zero records is still success)
80 1 — bad arguments
81 """
82
83 from __future__ import annotations
84
85 import argparse
86 import datetime
87 import json
88 import sys
89 import time
90 from typing import TypedDict
91
92 from muse.core.coordination import (
93 Reservation,
94 filter_intents,
95 filter_reservations,
96 load_all_intents,
97 load_all_reservations,
98 load_heartbeat_map,
99 load_released_ids,
100 )
101 from muse.core.repo import require_repo
102 from muse.core.validation import sanitize_display
103
104
105
106 type _ResIdMap = dict[str, set[str]]
107 # ── Typed JSON schema ─────────────────────────────────────────────────────────
108
109
110 class _ReservationEntry(TypedDict):
111 reservation_id: str
112 run_id: str
113 branch: str
114 addresses: list[str]
115 created_at: str
116 expires_at: str
117 effective_expires_at: str
118 ttl_remaining_seconds: float
119 operation: str | None
120 is_active: bool
121 released: bool
122 conflict_count: int
123
124
125 class _IntentEntry(TypedDict):
126 intent_id: str
127 reservation_id: str
128 run_id: str
129 branch: str
130 addresses: list[str]
131 operation: str
132 created_at: str
133 detail: str
134
135
136 class _FiltersEntry(TypedDict):
137 run_id: str | None
138 branch: str | None
139 address_glob: str | None
140 operation: str | None
141 include_expired: bool
142 kind: str
143 limit: int | None
144
145
146 class _ListJson(TypedDict):
147 active_reservations: int
148 expired_reservations: int
149 released_reservations: int
150 total_reservations_shown: int
151 total_intents_shown: int
152 has_conflicts: bool
153 filters: _FiltersEntry
154 reservations: list[_ReservationEntry]
155 intents: list[_IntentEntry]
156 elapsed_seconds: float
157
158
159 # ── Helpers ───────────────────────────────────────────────────────────────────
160
161
162 def _format_ttl(seconds: float) -> str:
163 """Format a TTL in seconds as a human-readable string.
164
165 Examples
166 --------
167 ``3661.0`` → ``"1h 1m 1s"``
168 ``90.0`` → ``"1m 30s"``
169 ``45.0`` → ``"45s"``
170 ``0.0`` → ``"EXPIRED"``
171 ``-10.0`` → ``"EXPIRED"``
172 """
173 if seconds <= 0:
174 return "EXPIRED"
175 total = int(seconds)
176 hours, remainder = divmod(total, 3600)
177 minutes, secs = divmod(remainder, 60)
178 if hours:
179 return f"{hours}h {minutes}m {secs}s"
180 if minutes:
181 return f"{minutes}m {secs}s"
182 return f"{secs}s"
183
184
185 # ── CLI registration ──────────────────────────────────────────────────────────
186
187
188 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
189 """Register the ``list`` subcommand on *subparsers* (under ``muse coord``)."""
190 parser = subparsers.add_parser(
191 "list",
192 help="Display the current coordination state of the swarm.",
193 description=__doc__,
194 formatter_class=argparse.RawDescriptionHelpFormatter,
195 )
196 parser.add_argument(
197 "--all", "-a",
198 action="store_true",
199 dest="include_expired",
200 help="Include expired reservations (default: active only).",
201 )
202 parser.add_argument(
203 "--kind",
204 default="all",
205 choices=("all", "reservations", "intents"),
206 metavar="KIND",
207 help="What to show: all (default), reservations, or intents.",
208 )
209 parser.add_argument(
210 "--run-id",
211 default=None,
212 dest="run_id",
213 metavar="ID",
214 help="Show only records whose run-id exactly matches ID.",
215 )
216 parser.add_argument(
217 "--branch", "-b",
218 default=None,
219 dest="branch",
220 metavar="BRANCH",
221 help="Show only records on this branch (exact match).",
222 )
223 parser.add_argument(
224 "--address",
225 default=None,
226 dest="address_glob",
227 metavar="GLOB",
228 help=(
229 "Show only records where at least one address matches this "
230 "fnmatch glob (e.g. \"billing.py::*\")."
231 ),
232 )
233 parser.add_argument(
234 "--op",
235 default=None,
236 dest="operation",
237 metavar="OPERATION",
238 help=(
239 "Show only records with this operation type "
240 "(e.g. rename, modify, delete, extract, move, inline, split, merge)."
241 ),
242 )
243 parser.add_argument(
244 "--limit", "-n",
245 type=int,
246 default=None,
247 dest="limit",
248 metavar="N",
249 help="Cap output to N reservations and N intents (useful for large swarms).",
250 )
251 parser.add_argument(
252 "--summary",
253 action="store_true",
254 help="Print a single-line count summary and exit.",
255 )
256 parser.add_argument(
257 "--format", "-f",
258 default="text",
259 dest="fmt",
260 choices=("text", "json"),
261 help="Output format: text (default) or json.",
262 )
263 parser.add_argument(
264 "--json",
265 action="store_const",
266 const="json",
267 dest="fmt",
268 help="Shorthand for --format json.",
269 )
270 parser.set_defaults(func=run)
271
272
273 # ── Command implementation ────────────────────────────────────────────────────
274
275
276 def run(args: argparse.Namespace) -> None:
277 """Display the live coordination state of the swarm.
278
279 Loads all reservation and intent records from ``.muse/coordination/``,
280 applies the requested filters in memory (no additional I/O per record
281 after the initial directory scan), and formats the result for human or
282 agent consumption.
283
284 Filtering
285 ---------
286 ``--run-id``, ``--branch``, and ``--op`` are exact-match filters.
287 ``--address`` uses :mod:`fnmatch` glob matching against each address in
288 the record — a record is included when *any* of its addresses match the
289 glob. All filters compose with AND semantics.
290
291 Conflict detection
292 ------------------
293 Each reservation entry in JSON output includes ``conflict_count``: the
294 number of *other* active reservations that share at least one address.
295 The top-level ``has_conflicts`` flag is ``True`` when any active
296 reservation has ``conflict_count > 0``. This gives agents a lightweight
297 conflict signal without running ``muse forecast``.
298
299 Security
300 --------
301 All strings sourced from persisted records (``run_id``, ``branch``,
302 ``detail``, addresses) are passed through
303 :func:`~muse.core.validation.sanitize_display` before printing to strip
304 ANSI escape sequences and control characters that could corrupt a
305 terminal. ``--address`` glob matching is a pure string operation — no
306 filesystem access occurs.
307
308 Performance
309 -----------
310 A single ``datetime.now()`` is captured at the start of the run and
311 reused for all TTL calculations, ensuring consistent timestamps across
312 all entries in a single invocation. Records are loaded with one
313 directory glob per category, then filtered in one pass.
314
315 JSON output
316 -----------
317 Includes ``elapsed_seconds`` and a ``filters`` object so agents can
318 verify which filters were active. ``ttl_remaining_seconds`` is a float
319 (negative when the reservation is expired) so agents can compute urgency
320 without parsing ISO 8601 strings. ``conflict_count`` per entry and
321 ``has_conflicts`` at the top level provide a quick conflict signal.
322 """
323 t0 = time.monotonic()
324
325 include_expired: bool = args.include_expired
326 kind: str = args.kind
327 run_id: str | None = args.run_id
328 branch: str | None = args.branch
329 address_glob: str | None = args.address_glob
330 operation: str | None = args.operation
331 limit: int | None = args.limit
332 summary_only: bool = args.summary
333 fmt: str = args.fmt
334 as_json = fmt == "json"
335
336 root = require_repo()
337
338 # ── Load ──────────────────────────────────────────────────────────────────
339 all_reservations = load_all_reservations(root)
340 all_intents = load_all_intents(root)
341 released_ids = load_released_ids(root)
342 hb_map = load_heartbeat_map(root)
343 # Pre-compute heartbeat effective-expiry mapping for filter_reservations.
344 heartbeat_expires = {rid: hb.extended_expires_at for rid, hb in hb_map.items()}
345
346 # Capture now once — reused for all TTL calculations so timestamps are
347 # consistent across every entry in this invocation.
348 now_utc = datetime.datetime.now(datetime.timezone.utc)
349
350 def _effective_expires(r: Reservation) -> datetime.datetime:
351 hb = hb_map.get(r.reservation_id)
352 return max(r.expires_at, hb.extended_expires_at) if hb is not None else r.expires_at
353
354 def _effective_ttl(r: Reservation) -> float:
355 return (_effective_expires(r) - now_utc).total_seconds()
356
357 # Count totals before filtering for summary bookkeeping.
358 total_active = sum(
359 1 for r in all_reservations
360 if r.reservation_id not in released_ids and now_utc < _effective_expires(r)
361 )
362 total_expired = len(all_reservations) - total_active
363
364 # ── Filter ────────────────────────────────────────────────────────────────
365 reservations = filter_reservations(
366 all_reservations,
367 run_id=run_id,
368 branch=branch,
369 address_glob=address_glob,
370 operation=operation,
371 include_expired=include_expired,
372 released_ids=released_ids,
373 heartbeat_expires=heartbeat_expires,
374 )
375 intents = filter_intents(
376 all_intents,
377 run_id=run_id,
378 branch=branch,
379 address_glob=address_glob,
380 operation=operation,
381 )
382
383 # Sort both lists by created_at ascending (oldest first).
384 reservations.sort(key=lambda r: r.created_at)
385 intents.sort(key=lambda i: i.created_at)
386
387 # Apply limit after sorting so the N oldest are returned.
388 if limit is not None and limit > 0:
389 reservations = reservations[:limit]
390 intents = intents[:limit]
391
392 # ── Conflict detection ────────────────────────────────────────────────────
393 # Build address → {reservation_id} map for the active (post-filter) set.
394 # This lets each reservation report how many peers share ≥1 address.
395 active_res_ids = {
396 r.reservation_id for r in reservations
397 if _effective_ttl(r) > 0 and r.reservation_id not in released_ids
398 }
399 addr_to_res_ids: _ResIdMap = {}
400 for r in reservations:
401 if r.reservation_id in active_res_ids:
402 for addr in r.addresses:
403 addr_to_res_ids.setdefault(addr, set()).add(r.reservation_id)
404
405 def _conflict_count(r: Reservation) -> int:
406 """Count distinct active reservations sharing ≥1 address with *r*."""
407 conflicting: set[str] = set()
408 for addr in r.addresses:
409 for rid in addr_to_res_ids.get(addr, set()):
410 if rid != r.reservation_id:
411 conflicting.add(rid)
412 return len(conflicting)
413
414 elapsed = round(time.monotonic() - t0, 4)
415
416 # ── Summary mode ──────────────────────────────────────────────────────────
417 if summary_only:
418 show_res = kind in ("all", "reservations")
419 show_int = kind in ("all", "intents")
420 parts: list[str] = []
421 if show_res:
422 parts.append(f"{len(reservations)} reservation(s)")
423 if show_int:
424 parts.append(f"{len(intents)} intent(s)")
425 suffix = ""
426 if not include_expired and total_expired:
427 suffix = f" [{total_expired} expired hidden — use --all to show]"
428 if not parts or (len(reservations) == 0 and len(intents) == 0 and not (show_res or show_int)):
429 print("No coordination records.")
430 else:
431 print(", ".join(parts) + suffix)
432 return
433
434 # ── JSON output ───────────────────────────────────────────────────────────
435 if as_json:
436 res_entries: list[_ReservationEntry] = []
437 for r in reservations:
438 if kind in ("all", "reservations"):
439 eff_exp = _effective_expires(r)
440 eff_ttl = _effective_ttl(r)
441 res_entries.append(_ReservationEntry(
442 reservation_id=r.reservation_id,
443 run_id=r.run_id,
444 branch=r.branch,
445 addresses=r.addresses,
446 created_at=r.created_at.isoformat(),
447 expires_at=r.expires_at.isoformat(),
448 effective_expires_at=eff_exp.isoformat(),
449 ttl_remaining_seconds=round(eff_ttl, 2),
450 operation=r.operation,
451 is_active=eff_ttl > 0 and r.reservation_id not in released_ids,
452 released=r.reservation_id in released_ids,
453 conflict_count=_conflict_count(r),
454 ))
455
456 int_entries: list[_IntentEntry] = []
457 for i in intents:
458 if kind in ("all", "intents"):
459 int_entries.append(_IntentEntry(
460 intent_id=i.intent_id,
461 reservation_id=i.reservation_id,
462 run_id=i.run_id,
463 branch=i.branch,
464 addresses=i.addresses,
465 operation=i.operation,
466 created_at=i.created_at.isoformat(),
467 detail=i.detail,
468 ))
469
470 total_released = len(released_ids)
471 has_conflicts = any(e["conflict_count"] > 0 for e in res_entries)
472 payload = _ListJson(
473 active_reservations=total_active,
474 expired_reservations=total_expired,
475 released_reservations=total_released,
476 total_reservations_shown=len(res_entries),
477 total_intents_shown=len(int_entries),
478 has_conflicts=has_conflicts,
479 filters=_FiltersEntry(
480 run_id=run_id,
481 branch=branch,
482 address_glob=address_glob,
483 operation=operation,
484 include_expired=include_expired,
485 kind=kind,
486 limit=limit,
487 ),
488 reservations=res_entries,
489 intents=int_entries,
490 elapsed_seconds=elapsed,
491 )
492 print(json.dumps(payload, indent=2))
493 return
494
495 # ── Text output ───────────────────────────────────────────────────────────
496 show_res = kind in ("all", "reservations")
497 show_int = kind in ("all", "intents")
498
499 n_res = len(reservations) if show_res else 0
500 n_int = len(intents) if show_int else 0
501
502 header = f"\nCoordination state"
503 parts = []
504 if show_res:
505 parts.append(f"{n_res} reservation(s)")
506 if show_int:
507 parts.append(f"{n_int} intent(s)")
508 if parts:
509 header += " — " + ", ".join(parts)
510 if not include_expired and total_expired and show_res:
511 header += f" [{total_expired} expired hidden]"
512 print(header)
513 print("─" * 62)
514
515 if n_res == 0 and n_int == 0:
516 print("\n (no coordination records")
517 if not include_expired and total_expired:
518 print(f" {total_expired} expired — run with --all to show them)")
519 else:
520 print(" run 'muse coord reserve' to create one)")
521 return
522
523 # Reservations block.
524 if show_res and reservations:
525 print("\nReservations")
526 for res in reservations:
527 is_released = res.reservation_id in released_ids
528 eff_ttl = _effective_ttl(res)
529 ttl_str = _format_ttl(eff_ttl)
530 if is_released:
531 status_label = "RELEASED"
532 elif eff_ttl > 0:
533 status_label = "expires in"
534 else:
535 status_label = ""
536 op_str = f" op={sanitize_display(res.operation)}" if res.operation else ""
537 run_label = sanitize_display(res.run_id)
538 branch_label = sanitize_display(res.branch)
539 res_id_short = res.reservation_id[:8]
540 conflicts = _conflict_count(res)
541 conflict_str = f" ⚠ {conflicts} conflict(s)" if conflicts > 0 else ""
542 print(
543 f" [{res_id_short}] {run_label:<20} {branch_label:<22}"
544 f"{op_str} {status_label} {ttl_str}{conflict_str}"
545 )
546 for addr in res.addresses:
547 print(f" {sanitize_display(addr)}")
548
549 # Intents block.
550 if show_int and intents:
551 print("\nIntents")
552 for intent in intents:
553 run_label = sanitize_display(intent.run_id)
554 branch_label = sanitize_display(intent.branch)
555 op_label = sanitize_display(intent.operation)
556 intent_id_short = intent.intent_id[:8]
557 print(f" [{intent_id_short}] {run_label:<20} {branch_label:<22} {op_label}")
558 for addr in intent.addresses:
559 print(f" {sanitize_display(addr)}")
560 if intent.detail:
561 print(f" → {sanitize_display(intent.detail)}")
562
563 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 35 days ago