@team-agent/installer 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/schemas/team.schema.json +6 -0
- package/src/team_agent/abnormal_track.py +253 -0
- package/src/team_agent/approvals/runtime_prompts.py +1 -1
- package/src/team_agent/cli/commands.py +104 -3
- package/src/team_agent/cli/parser.py +10 -1
- package/src/team_agent/compiler.py +1 -1
- package/src/team_agent/coordinator/lifecycle.py +23 -2
- package/src/team_agent/diagnose/orphan_cleanup.py +199 -28
- package/src/team_agent/display/__init__.py +31 -0
- package/src/team_agent/display/adaptive.py +425 -0
- package/src/team_agent/display/backend.py +46 -0
- package/src/team_agent/display/close.py +6 -0
- package/src/team_agent/display/rebuild.py +102 -0
- package/src/team_agent/display/tiling.py +156 -0
- package/src/team_agent/display/worker_window.py +4 -0
- package/src/team_agent/display/workspace.py +36 -127
- package/src/team_agent/idle_predicate.py +200 -0
- package/src/team_agent/idle_takeover.py +59 -0
- package/src/team_agent/idle_takeover_wiring.py +111 -0
- package/src/team_agent/launch/core.py +14 -4
- package/src/team_agent/leader/__init__.py +444 -61
- package/src/team_agent/lifecycle/operations.py +1 -0
- package/src/team_agent/lifecycle/start.py +1 -1
- package/src/team_agent/message_store/core.py +38 -11
- package/src/team_agent/message_store/leader_notification_log.py +47 -26
- package/src/team_agent/message_store/schema.py +8 -2
- package/src/team_agent/messaging/delivery.py +336 -1
- package/src/team_agent/messaging/leader.py +13 -4
- package/src/team_agent/messaging/leader_api_errors.py +216 -0
- package/src/team_agent/messaging/leader_panes.py +294 -0
- package/src/team_agent/messaging/scheduler.py +12 -0
- package/src/team_agent/messaging/send.py +54 -26
- package/src/team_agent/messaging/tmux_io.py +202 -33
- package/src/team_agent/messaging/tmux_prompt.py +87 -0
- package/src/team_agent/messaging/trust_auto_answer.py +52 -0
- package/src/team_agent/provider_state/README.md +78 -0
- package/src/team_agent/provider_state/__init__.py +86 -0
- package/src/team_agent/provider_state/claude.py +86 -0
- package/src/team_agent/provider_state/codex.py +84 -0
- package/src/team_agent/provider_state/common.py +207 -0
- package/src/team_agent/provider_state/registry.py +118 -0
- package/src/team_agent/restart/orchestration.py +215 -12
- package/src/team_agent/runtime.py +65 -15
- package/src/team_agent/sessions/capture.py +65 -15
- package/src/team_agent/spec.py +63 -3
- package/src/team_agent/status/queries.py +32 -1
- package/src/team_agent/wake.py +58 -0
- package/src/team_agent/watch/__init__.py +145 -0
package/src/team_agent/spec.py
CHANGED
|
@@ -4,6 +4,7 @@ from pathlib import Path
|
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
6
|
from team_agent.errors import ValidationError
|
|
7
|
+
from team_agent.display.backend import VALID_DISPLAY_BACKENDS
|
|
7
8
|
from team_agent.permissions import CANONICAL_TOOLS, expand_tools
|
|
8
9
|
from team_agent.profiles import AUTH_MODES
|
|
9
10
|
from team_agent.simple_yaml import loads
|
|
@@ -27,9 +28,60 @@ def load_yaml(path: Path) -> dict[str, Any]:
|
|
|
27
28
|
def load_spec(path: Path) -> dict[str, Any]:
|
|
28
29
|
spec = load_yaml(path)
|
|
29
30
|
validate_spec(spec, base_dir=path.parent)
|
|
31
|
+
_emit_load_time_deprecations(spec, path)
|
|
30
32
|
return spec
|
|
31
33
|
|
|
32
34
|
|
|
35
|
+
def _emit_load_time_deprecations(spec: dict[str, Any], path: Path) -> None:
|
|
36
|
+
"""Stage 7 S7 (2026-05-27): deprecation signals attached to the spec field
|
|
37
|
+
itself must fire when the YAML is read, not lazily inside the trust-prompt
|
|
38
|
+
code path. A user with the deprecated field in team.spec.yaml needs to see
|
|
39
|
+
the warning even when startup never reaches attempt_trust_auto_answer.
|
|
40
|
+
|
|
41
|
+
The leader-panes helper owns the one-shot stderr guard + the structured
|
|
42
|
+
audit event, so we reuse it. EventLog points at the WORKSPACE ROOT (not
|
|
43
|
+
the spec file's directory) so a quick-start layout that stores the spec
|
|
44
|
+
under <workspace>/.team/current/team.spec.yaml still routes the audit
|
|
45
|
+
event into the single canonical <workspace>/.team/logs/events.jsonl
|
|
46
|
+
instead of a doubled <workspace>/.team/current/.team/logs/events.jsonl
|
|
47
|
+
nesting.
|
|
48
|
+
"""
|
|
49
|
+
runtime = spec.get("runtime")
|
|
50
|
+
if not isinstance(runtime, dict):
|
|
51
|
+
return
|
|
52
|
+
if not bool(runtime.get("auto_trust_own_workspace")):
|
|
53
|
+
return
|
|
54
|
+
# Local import keeps the spec module free of messaging-layer coupling at
|
|
55
|
+
# import time; only YAMLs that opt into the deprecated field pay the cost.
|
|
56
|
+
from team_agent.events import EventLog
|
|
57
|
+
from team_agent.messaging.leader_panes import _emit_spec_opt_in_deprecation
|
|
58
|
+
_emit_spec_opt_in_deprecation(EventLog(_resolve_workspace_root(path)))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_workspace_root(spec_path: Path) -> Path:
|
|
62
|
+
"""Find the workspace root that owns this spec.
|
|
63
|
+
|
|
64
|
+
A workspace root is the directory whose `.team/` subdirectory holds the
|
|
65
|
+
runtime state, logs, artifacts, and (for quick-start layouts) the spec
|
|
66
|
+
itself under `.team/current/`. We climb from the spec file's parent
|
|
67
|
+
looking for the first ancestor that has a `.team/` child. If no ancestor
|
|
68
|
+
qualifies (fresh workspace before init, or a spec deliberately placed
|
|
69
|
+
outside any team workspace), we fall back to `spec_path.parent` which is
|
|
70
|
+
the legacy single-layout behaviour.
|
|
71
|
+
|
|
72
|
+
Implementation note: we use real filesystem evidence (`(dir/.team).is_dir()`)
|
|
73
|
+
rather than path-string parsing so the resolver works correctly even when
|
|
74
|
+
workspace paths legitimately contain a `.team` segment.
|
|
75
|
+
"""
|
|
76
|
+
direct_parent = spec_path.parent
|
|
77
|
+
if (direct_parent / ".team").is_dir():
|
|
78
|
+
return direct_parent
|
|
79
|
+
for ancestor in direct_parent.parents:
|
|
80
|
+
if (ancestor / ".team").is_dir():
|
|
81
|
+
return ancestor
|
|
82
|
+
return direct_parent
|
|
83
|
+
|
|
84
|
+
|
|
33
85
|
def validate_spec(spec: dict[str, Any], base_dir: Path | None = None) -> None:
|
|
34
86
|
messages = _basic_schema_errors(spec)
|
|
35
87
|
messages.extend(_semantic_errors(spec, base_dir or Path.cwd()))
|
|
@@ -182,24 +234,32 @@ def _check_communication(comm: Any, errors: list[str]) -> None:
|
|
|
182
234
|
|
|
183
235
|
|
|
184
236
|
def _check_runtime(runtime: Any, errors: list[str]) -> None:
|
|
185
|
-
required = {"backend", "
|
|
186
|
-
allowed = required | {
|
|
237
|
+
required = {"backend", "session_name", "auto_launch", "require_user_approval_before_launch", "max_active_agents", "startup_order"}
|
|
238
|
+
allowed = required | {"display_backend"} | {
|
|
187
239
|
"dangerous_auto_approve",
|
|
188
240
|
"auto_attach_leader",
|
|
189
241
|
"fast",
|
|
190
242
|
"tick_interval_sec",
|
|
191
243
|
"push_min_interval_sec",
|
|
192
244
|
"stuck_timeout_sec",
|
|
245
|
+
# Gap 29 / F3 deprecation (2026-05-26): accept the legacy spec opt-in so
|
|
246
|
+
# YAMLs that still set it validate and the deprecation warning + structured
|
|
247
|
+
# event in messaging/leader_panes.py can fire. The preferred per-session
|
|
248
|
+
# opt-in is the env var TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE; this spec
|
|
249
|
+
# field will be removed in 0.3.0.
|
|
250
|
+
"auto_trust_own_workspace",
|
|
193
251
|
}
|
|
194
252
|
_check_keys(runtime, "/runtime", required, allowed, errors)
|
|
195
253
|
if not isinstance(runtime, dict):
|
|
196
254
|
return
|
|
197
255
|
if runtime.get("backend") not in {"tmux", "pty"}:
|
|
198
256
|
errors.append("/runtime/backend: invalid backend")
|
|
199
|
-
if runtime.get("display_backend") not in
|
|
257
|
+
if "display_backend" in runtime and runtime.get("display_backend") not in VALID_DISPLAY_BACKENDS:
|
|
200
258
|
errors.append("/runtime/display_backend: invalid display backend")
|
|
201
259
|
if "dangerous_auto_approve" in runtime and not isinstance(runtime["dangerous_auto_approve"], bool):
|
|
202
260
|
errors.append("/runtime/dangerous_auto_approve: must be a boolean")
|
|
261
|
+
if "auto_trust_own_workspace" in runtime and not isinstance(runtime["auto_trust_own_workspace"], bool):
|
|
262
|
+
errors.append("/runtime/auto_trust_own_workspace: must be a boolean")
|
|
203
263
|
_check_list(runtime.get("startup_order"), "/runtime/startup_order", errors)
|
|
204
264
|
|
|
205
265
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
from datetime import datetime
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
@@ -11,6 +12,24 @@ from team_agent.status.compact import compact_status
|
|
|
11
12
|
from team_agent.status.constants import PENDING_DELIVERY_STATUSES
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
def _interacted_marker(first_send_at: Any) -> str:
|
|
16
|
+
"""C3 (cr verdict, 2026-05-27): render the persisted first_send_at as a
|
|
17
|
+
user-visible status field. Valid ISO 8601 UTC strings pass through; any
|
|
18
|
+
other shape (None, empty string, 0, False, corrupt garbage) renders as
|
|
19
|
+
the literal "never" so the operator sees a consistent classification
|
|
20
|
+
instead of leaking raw garbage into status output. Restart enforces
|
|
21
|
+
strict typing separately (corrupt values fail the operation); status is
|
|
22
|
+
a read-only surface and tolerantly degrades to "never" rather than
|
|
23
|
+
failing the status command."""
|
|
24
|
+
if isinstance(first_send_at, str) and first_send_at:
|
|
25
|
+
try:
|
|
26
|
+
datetime.fromisoformat(first_send_at)
|
|
27
|
+
except (ValueError, TypeError):
|
|
28
|
+
return "never"
|
|
29
|
+
return first_send_at
|
|
30
|
+
return "never"
|
|
31
|
+
|
|
32
|
+
|
|
14
33
|
def status(workspace: Path, as_json: bool = False, *, compact: bool = False) -> dict[str, Any]:
|
|
15
34
|
from team_agent.runtime import (
|
|
16
35
|
_capture_missing_sessions,
|
|
@@ -31,12 +50,24 @@ def status(workspace: Path, as_json: bool = False, *, compact: bool = False) ->
|
|
|
31
50
|
save_runtime_state(workspace, state)
|
|
32
51
|
session_name = state.get("session_name")
|
|
33
52
|
tmux_exists = _tmux_session_exists(session_name) if session_name else False
|
|
53
|
+
# C3 (cr verdict): enrich each worker entry with an explicit `interacted`
|
|
54
|
+
# field derived from the persisted first_send_at. The original entry
|
|
55
|
+
# passes through unchanged so any pre-existing field (including raw
|
|
56
|
+
# first_send_at) stays visible.
|
|
57
|
+
enriched_agents: dict[str, Any] = {}
|
|
58
|
+
for aid, raw in (state.get("agents") or {}).items():
|
|
59
|
+
if isinstance(raw, dict):
|
|
60
|
+
entry = dict(raw)
|
|
61
|
+
entry["interacted"] = _interacted_marker(raw.get("first_send_at"))
|
|
62
|
+
else:
|
|
63
|
+
entry = raw
|
|
64
|
+
enriched_agents[aid] = entry
|
|
34
65
|
result = {
|
|
35
66
|
"team": state.get("leader", {}).get("id", "leader"),
|
|
36
67
|
"session_name": session_name,
|
|
37
68
|
"tmux_session_present": tmux_exists,
|
|
38
69
|
"leader_receiver": state.get("leader_receiver", {}),
|
|
39
|
-
"agents":
|
|
70
|
+
"agents": enriched_agents,
|
|
40
71
|
"agent_health": store.agent_health(),
|
|
41
72
|
"tasks": state.get("tasks", []),
|
|
42
73
|
"messages": store.message_counts(),
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Provider-neutral wake layer (Gap 32 §2): decide WHEN to re-read a session
|
|
2
|
+
file, never HOW to parse it. No polling loop, no screen, no provider names.
|
|
3
|
+
|
|
4
|
+
Two cheap, passive sources:
|
|
5
|
+
- a file-change watch (the caller wires FSEvents/inotify/kqueue and calls
|
|
6
|
+
``on_file_changed``) — naturally per-session (one file per node);
|
|
7
|
+
- an mtime gate — only re-read the tail when the file changed since the last
|
|
8
|
+
classification, or when it has been quiet past the debounce window.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def should_reread(
|
|
17
|
+
*,
|
|
18
|
+
last_mtime: float | None,
|
|
19
|
+
current_mtime: float | None,
|
|
20
|
+
last_classified_mtime: float | None,
|
|
21
|
+
now: float,
|
|
22
|
+
debounce_seconds: float,
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
"""Return whether a fresh tail-read is warranted and why.
|
|
25
|
+
|
|
26
|
+
A read is warranted when the file changed since we last classified it, or
|
|
27
|
+
when it has been silent longer than the debounce window and we have not yet
|
|
28
|
+
classified at this mtime (so an idle-close that we already saw is not
|
|
29
|
+
re-read forever).
|
|
30
|
+
"""
|
|
31
|
+
if current_mtime is None:
|
|
32
|
+
return {"reread": False, "reason": "no_file"}
|
|
33
|
+
if last_classified_mtime is None:
|
|
34
|
+
return {"reread": True, "reason": "never_classified"}
|
|
35
|
+
if current_mtime != last_classified_mtime:
|
|
36
|
+
return {"reread": True, "reason": "file_changed"}
|
|
37
|
+
silent_for = max(0.0, now - current_mtime)
|
|
38
|
+
if silent_for >= debounce_seconds:
|
|
39
|
+
return {"reread": False, "reason": "quiescent_already_classified"}
|
|
40
|
+
return {"reread": False, "reason": "unchanged"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def on_file_changed(watch_state: dict[str, Any] | None, *, node_id: str, mtime: float) -> dict[str, Any]:
|
|
44
|
+
"""Record a file-change wake for a node (the push path)."""
|
|
45
|
+
state = dict(watch_state or {})
|
|
46
|
+
pending = set(state.get("pending") or [])
|
|
47
|
+
pending.add(node_id)
|
|
48
|
+
state["pending"] = sorted(pending)
|
|
49
|
+
state.setdefault("mtimes", {})[node_id] = mtime
|
|
50
|
+
return state
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def take_pending(watch_state: dict[str, Any] | None) -> tuple[list[str], dict[str, Any]]:
|
|
54
|
+
"""Drain the set of nodes whose files changed since the last drain."""
|
|
55
|
+
state = dict(watch_state or {})
|
|
56
|
+
pending = sorted(state.get("pending") or [])
|
|
57
|
+
state["pending"] = []
|
|
58
|
+
return pending, state
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from team_agent.message_store import MessageStore
|
|
10
|
+
from team_agent.paths import logs_dir, runtime_dir
|
|
11
|
+
from team_agent.status.queries import result_summary_from_row
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class WatchCursor:
|
|
16
|
+
event_offset: int = 0
|
|
17
|
+
seen_result_ids: set[str] = field(default_factory=set)
|
|
18
|
+
initialized: bool = False
|
|
19
|
+
archive_signature: tuple[int, int] | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
ROTATION_MARKER = "[watch] log rotated; archived segment events.jsonl.1 not replayed — historical replay deferred to a future --replay flag"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_watch(
|
|
26
|
+
workspace: Path,
|
|
27
|
+
*,
|
|
28
|
+
team: str | None = None,
|
|
29
|
+
interval: float = 0.5,
|
|
30
|
+
output: Callable[[str], None] = print,
|
|
31
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
32
|
+
) -> None:
|
|
33
|
+
cursor = WatchCursor()
|
|
34
|
+
while True:
|
|
35
|
+
for line in collect_watch_lines(workspace, cursor, team=team):
|
|
36
|
+
output(line)
|
|
37
|
+
sleep(interval)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def collect_watch_lines(workspace: Path, cursor: WatchCursor, *, team: str | None = None) -> list[str]:
|
|
41
|
+
lines = _collect_event_lines(workspace, cursor, team=team)
|
|
42
|
+
lines.extend(_collect_result_lines(workspace, cursor, team=team))
|
|
43
|
+
return lines
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def render_event_line(event: dict[str, Any]) -> str | None:
|
|
47
|
+
kind = event.get("event")
|
|
48
|
+
if kind == "result_received":
|
|
49
|
+
return _result_line(event.get("agent_id"), event.get("summary"))
|
|
50
|
+
if kind in {"leader_receiver.injected", "leader_receiver.submitted"}:
|
|
51
|
+
return f"leader_receiver.injected: {_message_snippet(event)} -> {_recipient(event)}"
|
|
52
|
+
if kind == "send.failed":
|
|
53
|
+
return f"send.failed: {_recipient(event)} reason={_clean(event.get('reason') or event.get('error') or '-')}"
|
|
54
|
+
if kind == "leader_receiver.rebind_required":
|
|
55
|
+
pane = event.get("old_pane_id") or event.get("pane_id") or event.get("target") or "-"
|
|
56
|
+
reason = event.get("reason") or event.get("rediscovery_status") or "-"
|
|
57
|
+
return f"leader_receiver.rebind_required: pane={pane} reason={_clean(reason)}"
|
|
58
|
+
if kind == "leader.api_error":
|
|
59
|
+
error_class = event.get("error_class") or "Unknown"
|
|
60
|
+
provider = event.get("provider") or "-"
|
|
61
|
+
snippet = _clean(event.get("matched_pattern_snippet") or event.get("snippet") or "-")
|
|
62
|
+
return f"leader.api_error: {error_class} provider={provider} snippet={snippet}"
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _collect_event_lines(workspace: Path, cursor: WatchCursor, *, team: str | None = None) -> list[str]:
|
|
67
|
+
path = logs_dir(workspace) / "events.jsonl"
|
|
68
|
+
archive_signature = _archive_signature(path.with_name("events.jsonl.1"))
|
|
69
|
+
lines: list[str] = []
|
|
70
|
+
if not cursor.initialized:
|
|
71
|
+
cursor.archive_signature = archive_signature
|
|
72
|
+
cursor.initialized = True
|
|
73
|
+
elif archive_signature and archive_signature != cursor.archive_signature:
|
|
74
|
+
lines.append(ROTATION_MARKER)
|
|
75
|
+
cursor.archive_signature = archive_signature
|
|
76
|
+
cursor.event_offset = 0
|
|
77
|
+
if not path.exists():
|
|
78
|
+
return lines
|
|
79
|
+
size = path.stat().st_size
|
|
80
|
+
if cursor.event_offset > size:
|
|
81
|
+
if ROTATION_MARKER not in lines:
|
|
82
|
+
lines.append(ROTATION_MARKER)
|
|
83
|
+
cursor.event_offset = 0
|
|
84
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
85
|
+
handle.seek(cursor.event_offset)
|
|
86
|
+
for raw in handle:
|
|
87
|
+
try:
|
|
88
|
+
event = json.loads(raw)
|
|
89
|
+
except json.JSONDecodeError:
|
|
90
|
+
continue
|
|
91
|
+
if team and _event_team_id(event) != team:
|
|
92
|
+
continue
|
|
93
|
+
rendered = render_event_line(event)
|
|
94
|
+
if rendered:
|
|
95
|
+
lines.append(rendered)
|
|
96
|
+
cursor.event_offset = handle.tell()
|
|
97
|
+
return lines
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _collect_result_lines(workspace: Path, cursor: WatchCursor, *, team: str | None = None) -> list[str]:
|
|
101
|
+
if not (runtime_dir(workspace) / "team.db").exists():
|
|
102
|
+
return []
|
|
103
|
+
store = MessageStore(workspace)
|
|
104
|
+
lines: list[str] = []
|
|
105
|
+
for row in store.latest_results(limit=20, owner_team_id=team):
|
|
106
|
+
result_id = str(row.get("result_id") or "")
|
|
107
|
+
if not result_id or result_id in cursor.seen_result_ids:
|
|
108
|
+
continue
|
|
109
|
+
cursor.seen_result_ids.add(result_id)
|
|
110
|
+
summary = result_summary_from_row(row) or {}
|
|
111
|
+
lines.append(_result_line(summary.get("agent_id"), summary.get("summary")))
|
|
112
|
+
return lines
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _result_line(agent_id: Any, summary: Any) -> str:
|
|
116
|
+
return f"result_received: {agent_id or '-'} -> {_clean(summary or '-')[:80]}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _message_snippet(event: dict[str, Any]) -> str:
|
|
120
|
+
message_id = str(event.get("message_id") or event.get("msg_id") or "-")
|
|
121
|
+
return message_id[:12] if message_id != "-" else "-"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _recipient(event: dict[str, Any]) -> str:
|
|
125
|
+
return str(event.get("recipient") or event.get("to") or event.get("target") or "-")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _clean(value: Any) -> str:
|
|
129
|
+
return " ".join(str(value).split())
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _event_team_id(event: dict[str, Any]) -> str | None:
|
|
133
|
+
value = event.get("team_id") or event.get("owner_team_id") or event.get("team")
|
|
134
|
+
return str(value) if value else None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _archive_signature(path: Path) -> tuple[int, int] | None:
|
|
138
|
+
try:
|
|
139
|
+
stat = path.stat()
|
|
140
|
+
except FileNotFoundError:
|
|
141
|
+
return None
|
|
142
|
+
return (stat.st_size, stat.st_mtime_ns)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
__all__ = ["ROTATION_MARKER", "WatchCursor", "collect_watch_lines", "render_event_line", "run_watch"]
|