fleetwatcher 0.4.0__py3-none-any.whl
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.
- fleetwatch/__init__.py +3 -0
- fleetwatch/adapters/__init__.py +36 -0
- fleetwatch/adapters/base.py +112 -0
- fleetwatch/adapters/claude.py +425 -0
- fleetwatch/adapters/codex.py +411 -0
- fleetwatch/adapters/gemini.py +377 -0
- fleetwatch/adapters/grok.py +491 -0
- fleetwatch/cli.py +83 -0
- fleetwatch/config.py +43 -0
- fleetwatch/core.py +241 -0
- fleetwatch/models.py +110 -0
- fleetwatch/palette.py +107 -0
- fleetwatch/remote.py +104 -0
- fleetwatch/render.py +157 -0
- fleetwatch/summarize.py +141 -0
- fleetwatch/tailer.py +63 -0
- fleetwatch/tui.py +475 -0
- fleetwatch/util.py +28 -0
- fleetwatcher-0.4.0.dist-info/METADATA +200 -0
- fleetwatcher-0.4.0.dist-info/RECORD +24 -0
- fleetwatcher-0.4.0.dist-info/WHEEL +5 -0
- fleetwatcher-0.4.0.dist-info/entry_points.txt +3 -0
- fleetwatcher-0.4.0.dist-info/licenses/LICENSE +21 -0
- fleetwatcher-0.4.0.dist-info/top_level.txt +1 -0
fleetwatch/core.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""The aggregator: poll every adapter, hold the current fleet, sort it.
|
|
2
|
+
|
|
3
|
+
This is the only place that knows about all the adapters at once. It scans them
|
|
4
|
+
on demand (``refresh``), keeps one normalized ``SessionState`` per session,
|
|
5
|
+
re-ages quiet sessions cheaply, drops the ones that vanished, and dispatches
|
|
6
|
+
plain-language summaries for whatever needs attention. The TUI and the CLI both
|
|
7
|
+
talk to exactly this surface:
|
|
8
|
+
|
|
9
|
+
refresh() re-scan; cheap; safe to call on an interval
|
|
10
|
+
sessions() current fleet, sorted needs-first then most-recent
|
|
11
|
+
counts() {"active": n, "waiting": n, ..., "total": n}
|
|
12
|
+
request_summary(key) force a model summary for one session (the TUI 's' key)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from . import config
|
|
22
|
+
from .adapters import all_adapters
|
|
23
|
+
from .adapters.base import LocalSource, Source
|
|
24
|
+
from .models import SessionState, State
|
|
25
|
+
from .remote import parse_hosts
|
|
26
|
+
from .summarize import Summarizer
|
|
27
|
+
|
|
28
|
+
# Sort priority: the things that want you, first.
|
|
29
|
+
_STATE_ORDER = {
|
|
30
|
+
State.WAITING: 0,
|
|
31
|
+
State.ERROR: 1,
|
|
32
|
+
State.ACTIVE: 2,
|
|
33
|
+
State.IDLE: 3,
|
|
34
|
+
State.DONE: 4,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# States that can change as time passes even when the file does not, so they
|
|
38
|
+
# must be re-read on every refresh rather than trusting the mtime cache.
|
|
39
|
+
_VOLATILE = {State.ACTIVE, State.WAITING}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Aggregator:
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
source: Optional[Source] = None,
|
|
46
|
+
summarizer: Optional[Summarizer] = None,
|
|
47
|
+
hosts: Optional[list] = None,
|
|
48
|
+
):
|
|
49
|
+
self.source = source or LocalSource()
|
|
50
|
+
self.adapters = all_adapters()
|
|
51
|
+
self.summarizer = summarizer if summarizer is not None else Summarizer()
|
|
52
|
+
# Remote hosts watched over ssh. Any object with `.name` and `.fetch()`
|
|
53
|
+
# works (tests inject fakes); by default parsed from config.
|
|
54
|
+
self.hosts = hosts if hosts is not None else parse_hosts(config.REMOTE_HOSTS)
|
|
55
|
+
self._states: dict[tuple, SessionState] = {}
|
|
56
|
+
self._mtimes: dict[tuple, float] = {}
|
|
57
|
+
self._lock = threading.Lock()
|
|
58
|
+
|
|
59
|
+
# --- public surface ---
|
|
60
|
+
|
|
61
|
+
def refresh(self) -> None:
|
|
62
|
+
now = time.time()
|
|
63
|
+
seen: set[tuple] = set()
|
|
64
|
+
|
|
65
|
+
for adapter in self.adapters:
|
|
66
|
+
try:
|
|
67
|
+
refs = adapter.discover(self.source)
|
|
68
|
+
except Exception:
|
|
69
|
+
refs = [] # a broken adapter must never sink the whole scan
|
|
70
|
+
for ref in refs:
|
|
71
|
+
key = (self.source.host, adapter.vendor, ref.session_id)
|
|
72
|
+
# Prefer the adapter's freshness hint (newest mtime across a
|
|
73
|
+
# multi-file session) so a change in any of the session's files
|
|
74
|
+
# is noticed, not just the primary transcript's.
|
|
75
|
+
mtime = (
|
|
76
|
+
ref.mtime
|
|
77
|
+
if ref.mtime is not None
|
|
78
|
+
else self.source.mtime(ref.path)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Skip files too old to care about without ever reading them —
|
|
82
|
+
# this is what keeps a refresh cheap when hundreds of stale
|
|
83
|
+
# transcripts sit on disk. Not adding to `seen` lets eviction
|
|
84
|
+
# drop one that just aged out.
|
|
85
|
+
if mtime and (now - mtime) > config.MAX_AGE:
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
seen.add(key)
|
|
89
|
+
prev = self._states.get(key)
|
|
90
|
+
|
|
91
|
+
unchanged = prev is not None and self._mtimes.get(key) == mtime
|
|
92
|
+
if unchanged and prev.state not in _VOLATILE:
|
|
93
|
+
self._reage(prev, now)
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
state = adapter.read(self.source, ref, prev)
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
state = self._error_state(adapter.vendor, ref, str(exc))
|
|
100
|
+
if state is None:
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
# Carry a model summary across re-reads of the same activity.
|
|
104
|
+
if not state.summary:
|
|
105
|
+
if prev and prev.summary and prev.last_activity == state.last_activity:
|
|
106
|
+
state.summary = prev.summary
|
|
107
|
+
else:
|
|
108
|
+
cached = self.summarizer.cached(state)
|
|
109
|
+
if cached:
|
|
110
|
+
state.summary = cached
|
|
111
|
+
|
|
112
|
+
with self._lock:
|
|
113
|
+
self._states[key] = state
|
|
114
|
+
self._mtimes[key] = mtime
|
|
115
|
+
|
|
116
|
+
self._fetch_remotes(seen)
|
|
117
|
+
self._evict(seen, now)
|
|
118
|
+
self._dispatch_summaries()
|
|
119
|
+
|
|
120
|
+
def _fetch_remotes(self, seen: set) -> None:
|
|
121
|
+
"""Pull each remote host's JSON export and merge it in. An unreachable
|
|
122
|
+
host keeps its last-known sessions (marked seen so eviction spares them)
|
|
123
|
+
rather than blinking out of existence."""
|
|
124
|
+
for host in self.hosts:
|
|
125
|
+
try:
|
|
126
|
+
states, reachable = host.fetch()
|
|
127
|
+
except Exception:
|
|
128
|
+
states, reachable = [], False
|
|
129
|
+
if not reachable:
|
|
130
|
+
with self._lock:
|
|
131
|
+
for key in self._states:
|
|
132
|
+
if key[0] == host.name:
|
|
133
|
+
seen.add(key)
|
|
134
|
+
continue
|
|
135
|
+
for st in states:
|
|
136
|
+
st.source = host.name # always display under the host it came from
|
|
137
|
+
key = (host.name, st.vendor, st.session_id)
|
|
138
|
+
seen.add(key)
|
|
139
|
+
if not st.summary:
|
|
140
|
+
prev = self._states.get(key)
|
|
141
|
+
if prev and prev.summary and prev.last_activity == st.last_activity:
|
|
142
|
+
st.summary = prev.summary
|
|
143
|
+
else:
|
|
144
|
+
cached = self.summarizer.cached(st)
|
|
145
|
+
if cached:
|
|
146
|
+
st.summary = cached
|
|
147
|
+
with self._lock:
|
|
148
|
+
self._states[key] = st
|
|
149
|
+
self._mtimes[key] = st.last_activity or 0.0
|
|
150
|
+
|
|
151
|
+
def sessions(self) -> list[SessionState]:
|
|
152
|
+
with self._lock:
|
|
153
|
+
items = list(self._states.values())
|
|
154
|
+
items.sort(
|
|
155
|
+
key=lambda s: (
|
|
156
|
+
_STATE_ORDER.get(s.state, 9),
|
|
157
|
+
-(s.last_activity or 0.0),
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
return items
|
|
161
|
+
|
|
162
|
+
def counts(self) -> dict:
|
|
163
|
+
with self._lock:
|
|
164
|
+
items = list(self._states.values())
|
|
165
|
+
out = {s.value: 0 for s in State}
|
|
166
|
+
for st in items:
|
|
167
|
+
out[st.state.value] += 1
|
|
168
|
+
out["total"] = len(items)
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
def request_summary(self, key, force: bool = True) -> None:
|
|
172
|
+
key = tuple(key)
|
|
173
|
+
with self._lock:
|
|
174
|
+
st = self._states.get(key)
|
|
175
|
+
if st is None:
|
|
176
|
+
# Stored entries are keyed by the discover-time id, but a caller
|
|
177
|
+
# (the TUI) passes the session's canonical key — adapters such as
|
|
178
|
+
# Codex/Gemini rewrite session_id from file contents. Fall back to
|
|
179
|
+
# matching on the state's own key so manual summaries never no-op.
|
|
180
|
+
for s in self._states.values():
|
|
181
|
+
if s.key == key:
|
|
182
|
+
st = s
|
|
183
|
+
break
|
|
184
|
+
if st is not None:
|
|
185
|
+
self.summarizer.request(st, force=force)
|
|
186
|
+
|
|
187
|
+
def summarize_all(self, force: bool = False) -> int:
|
|
188
|
+
"""Full sweep: request a plain-language summary for every current
|
|
189
|
+
session. Cached summaries are reused unless ``force``. Returns the
|
|
190
|
+
number of sessions swept (0 when the model is disabled)."""
|
|
191
|
+
if not self.summarizer.enabled:
|
|
192
|
+
return 0
|
|
193
|
+
sessions = self.sessions()
|
|
194
|
+
for st in sessions:
|
|
195
|
+
self.summarizer.request(st, force=force)
|
|
196
|
+
return len(sessions)
|
|
197
|
+
|
|
198
|
+
# --- internals ---
|
|
199
|
+
|
|
200
|
+
def _reage(self, st: SessionState, now: float) -> None:
|
|
201
|
+
"""Promote a quiet IDLE session to DONE once it crosses DONE_AFTER,
|
|
202
|
+
without re-reading the file."""
|
|
203
|
+
if (
|
|
204
|
+
st.state == State.IDLE
|
|
205
|
+
and st.last_activity is not None
|
|
206
|
+
and (now - st.last_activity) >= config.DONE_AFTER
|
|
207
|
+
):
|
|
208
|
+
st.state = State.DONE
|
|
209
|
+
|
|
210
|
+
def _evict(self, seen: set, now: float) -> None:
|
|
211
|
+
with self._lock:
|
|
212
|
+
for key in list(self._states):
|
|
213
|
+
st = self._states[key]
|
|
214
|
+
gone = key not in seen
|
|
215
|
+
too_old = (
|
|
216
|
+
st.last_activity is not None
|
|
217
|
+
and (now - st.last_activity) > config.MAX_AGE
|
|
218
|
+
)
|
|
219
|
+
if gone or too_old:
|
|
220
|
+
self._states.pop(key, None)
|
|
221
|
+
self._mtimes.pop(key, None)
|
|
222
|
+
|
|
223
|
+
def _dispatch_summaries(self) -> None:
|
|
224
|
+
if not self.summarizer.enabled:
|
|
225
|
+
return
|
|
226
|
+
for st in self.sessions():
|
|
227
|
+
if st.needs_attention and not st.summary:
|
|
228
|
+
self.summarizer.request(st)
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def _error_state(vendor: str, ref, msg: str) -> SessionState:
|
|
232
|
+
return SessionState(
|
|
233
|
+
vendor=vendor,
|
|
234
|
+
session_id=ref.session_id,
|
|
235
|
+
project=(ref.cwd or "?").rstrip("/").split("/")[-1] or "?",
|
|
236
|
+
cwd=ref.cwd,
|
|
237
|
+
state=State.ERROR,
|
|
238
|
+
doing="could not read session",
|
|
239
|
+
needs="unreadable transcript",
|
|
240
|
+
error=msg[:200],
|
|
241
|
+
)
|
fleetwatch/models.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""The data contract shared by every adapter, the aggregator, and the UI.
|
|
2
|
+
|
|
3
|
+
A ``SessionState`` is a normalized snapshot of one CLI coding session, no matter
|
|
4
|
+
which vendor produced it. Adapters translate vendor-specific files into this
|
|
5
|
+
shape; the aggregator collects them; the TUI renders them. It stays fully
|
|
6
|
+
JSON-serializable on purpose: a remote host can export the exact same records
|
|
7
|
+
over the wire, which is how VPS support drops in later without reworking this.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class State(str, Enum):
|
|
18
|
+
"""Lifecycle of a session, in rough order of how much it wants you."""
|
|
19
|
+
|
|
20
|
+
ACTIVE = "active" # transcript changed very recently; the agent is working
|
|
21
|
+
WAITING = "waiting" # blocked on the human (permission prompt, a question)
|
|
22
|
+
IDLE = "idle" # last turn finished recently; likely still open
|
|
23
|
+
DONE = "done" # last turn finished long ago; effectively over
|
|
24
|
+
ERROR = "error" # vendor error, or the transcript could not be read
|
|
25
|
+
|
|
26
|
+
def __str__(self) -> str: # so f-strings print "active" not "State.ACTIVE"
|
|
27
|
+
return self.value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class TodoItem:
|
|
32
|
+
text: str
|
|
33
|
+
status: str = "pending" # pending | in_progress | completed
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class SessionState:
|
|
38
|
+
# --- identity ---
|
|
39
|
+
vendor: str # "claude" | "codex" | "grok" | "gemini"
|
|
40
|
+
session_id: str
|
|
41
|
+
project: str # human label, usually the basename of cwd
|
|
42
|
+
cwd: Optional[str] = None # full working directory when known
|
|
43
|
+
source: str = "local" # host label; "local" now, a VPS hostname later
|
|
44
|
+
|
|
45
|
+
# --- liveness ---
|
|
46
|
+
last_activity: Optional[float] = None # epoch seconds (file mtime / last ts)
|
|
47
|
+
state: State = State.IDLE
|
|
48
|
+
|
|
49
|
+
# --- what it's doing ---
|
|
50
|
+
doing: str = "" # heuristic one-liner the adapter fills in
|
|
51
|
+
needs: Optional[str] = None # human-facing reason it wants attention
|
|
52
|
+
summary: Optional[str] = None # model-written paragraph, filled in lazily
|
|
53
|
+
|
|
54
|
+
# --- context for the detail pane ---
|
|
55
|
+
todos: list[TodoItem] = field(default_factory=list)
|
|
56
|
+
last_user: str = ""
|
|
57
|
+
last_agent: str = ""
|
|
58
|
+
|
|
59
|
+
# --- bookkeeping ---
|
|
60
|
+
error: Optional[str] = None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def key(self) -> tuple[str, str, str]:
|
|
64
|
+
"""Stable identity across refreshes and across hosts."""
|
|
65
|
+
return (self.source, self.vendor, self.session_id)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def needs_attention(self) -> bool:
|
|
69
|
+
return self.state in (State.WAITING, State.ERROR)
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict:
|
|
72
|
+
return {
|
|
73
|
+
"vendor": self.vendor,
|
|
74
|
+
"session_id": self.session_id,
|
|
75
|
+
"project": self.project,
|
|
76
|
+
"cwd": self.cwd,
|
|
77
|
+
"source": self.source,
|
|
78
|
+
"last_activity": self.last_activity,
|
|
79
|
+
"state": self.state.value,
|
|
80
|
+
"doing": self.doing,
|
|
81
|
+
"needs": self.needs,
|
|
82
|
+
"summary": self.summary,
|
|
83
|
+
"todos": [{"text": t.text, "status": t.status} for t in self.todos],
|
|
84
|
+
"last_user": self.last_user,
|
|
85
|
+
"last_agent": self.last_agent,
|
|
86
|
+
"error": self.error,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_dict(cls, d: dict) -> "SessionState":
|
|
91
|
+
return cls(
|
|
92
|
+
vendor=d["vendor"],
|
|
93
|
+
session_id=d["session_id"],
|
|
94
|
+
project=d["project"],
|
|
95
|
+
cwd=d.get("cwd"),
|
|
96
|
+
source=d.get("source", "local"),
|
|
97
|
+
last_activity=d.get("last_activity"),
|
|
98
|
+
state=State(d.get("state", "idle")),
|
|
99
|
+
doing=d.get("doing", ""),
|
|
100
|
+
needs=d.get("needs"),
|
|
101
|
+
summary=d.get("summary"),
|
|
102
|
+
todos=[
|
|
103
|
+
TodoItem(text=str(t.get("text", "")), status=t.get("status", "pending"))
|
|
104
|
+
for t in d.get("todos", [])
|
|
105
|
+
if isinstance(t, dict) and t.get("text")
|
|
106
|
+
],
|
|
107
|
+
last_user=d.get("last_user", ""),
|
|
108
|
+
last_agent=d.get("last_agent", ""),
|
|
109
|
+
error=d.get("error"),
|
|
110
|
+
)
|
fleetwatch/palette.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The one place fleetwatch decides what color anything is.
|
|
2
|
+
|
|
3
|
+
Two renderers consume these colors — the Textual dashboard (:mod:`tui`), which
|
|
4
|
+
speaks Rich style strings, and the plain-text snapshot (:mod:`render`), which
|
|
5
|
+
speaks ANSI for ``--once`` on a terminal. Keeping both mappings here means the
|
|
6
|
+
live board and the text view never disagree about what "waiting" looks like.
|
|
7
|
+
|
|
8
|
+
The scheme is built for color-blind readers: state is carried by luminance and
|
|
9
|
+
hue *position*, never red-vs-green alone. ``active`` is cyan (not green) so it
|
|
10
|
+
never collides with ``error``'s red on a deuteranope's screen, and ``idle`` /
|
|
11
|
+
``done`` recede into grey. Vendor accents live in a separate hue family
|
|
12
|
+
(orange, green, magenta, blue) so a vendor tag is never read as a status.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .models import State
|
|
18
|
+
|
|
19
|
+
# Bright primaries for the three states that carry signal — blue is "live",
|
|
20
|
+
# yellow is "your move", red is "broken". WAITING and ERROR are bold so they
|
|
21
|
+
# jump out of a full table. IDLE and DONE recede into grey. No state leans on a
|
|
22
|
+
# red-vs-green distinction, the one pair color-blind readers can't separate.
|
|
23
|
+
STATE_STYLE: dict[State, str] = {
|
|
24
|
+
State.ACTIVE: "bright_blue",
|
|
25
|
+
State.WAITING: "bold bright_yellow",
|
|
26
|
+
State.ERROR: "bold bright_red",
|
|
27
|
+
State.IDLE: "grey58",
|
|
28
|
+
State.DONE: "grey42",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# A glyph per state: a second, color-free channel. Read by shape alone, the
|
|
32
|
+
# board still parses for a reader who can't see the hue — and ● / ○ / · also
|
|
33
|
+
# track "presence" (live, idle, faded) at a glance.
|
|
34
|
+
STATE_GLYPH: dict[State, str] = {
|
|
35
|
+
State.ACTIVE: "●",
|
|
36
|
+
State.WAITING: "◆",
|
|
37
|
+
State.ERROR: "✗",
|
|
38
|
+
State.IDLE: "○",
|
|
39
|
+
State.DONE: "·",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Per-vendor accent: a hue family of its own (orange, cyan, magenta, violet),
|
|
43
|
+
# every one distinct from the state colors so a vendor tag never reads as a
|
|
44
|
+
# status. No green here either.
|
|
45
|
+
VENDOR_STYLE: dict[str, str] = {
|
|
46
|
+
"claude": "dark_orange",
|
|
47
|
+
"codex": "bright_cyan",
|
|
48
|
+
"grok": "bright_magenta",
|
|
49
|
+
"gemini": "medium_purple1",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_DEFAULT = "white"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def state_style(state: State) -> str:
|
|
56
|
+
"""Rich style for a session state; white for anything unrecognized."""
|
|
57
|
+
return STATE_STYLE.get(state, _DEFAULT)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def state_glyph(state: State) -> str:
|
|
61
|
+
"""Single-character status mark for a state; a space if unrecognized."""
|
|
62
|
+
return STATE_GLYPH.get(state, " ")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def state_label(state: State) -> str:
|
|
66
|
+
"""Glyph + name, e.g. ``"● active"`` — the state as it reads in a cell."""
|
|
67
|
+
return f"{state_glyph(state)} {state}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def vendor_style(vendor: str) -> str:
|
|
71
|
+
"""Rich style for a vendor accent; white for an unknown vendor."""
|
|
72
|
+
return VENDOR_STYLE.get((vendor or "").lower(), _DEFAULT)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --- ANSI for the plain-text snapshot ------------------------------------- #
|
|
76
|
+
# SGR codes mirroring the Rich styles above, so `fleetwatch --once` on a TTY
|
|
77
|
+
# carries the same scheme. Unknown styles paint nothing (graceful no-op).
|
|
78
|
+
_ANSI: dict[str, str] = {
|
|
79
|
+
"cyan": "36",
|
|
80
|
+
"bright_blue": "94",
|
|
81
|
+
"bold bright_yellow": "1;93",
|
|
82
|
+
"bold bright_red": "1;91",
|
|
83
|
+
"grey58": "38;5;246",
|
|
84
|
+
"grey42": "38;5;240",
|
|
85
|
+
"dark_orange": "38;5;208",
|
|
86
|
+
"bright_cyan": "96",
|
|
87
|
+
"bright_magenta": "95",
|
|
88
|
+
"medium_purple1": "38;5;141",
|
|
89
|
+
"white": "37",
|
|
90
|
+
"dim": "2",
|
|
91
|
+
"bold": "1",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def paint(style: str, text: str, enabled: bool = True) -> str:
|
|
96
|
+
"""Wrap ``text`` in the ANSI code for ``style`` when ``enabled``.
|
|
97
|
+
|
|
98
|
+
Returns ``text`` untouched when disabled or when the style has no ANSI
|
|
99
|
+
mapping, so callers can always route through here and let a non-TTY (or an
|
|
100
|
+
unknown style) degrade to plain text.
|
|
101
|
+
"""
|
|
102
|
+
if not enabled:
|
|
103
|
+
return text
|
|
104
|
+
code = _ANSI.get(style)
|
|
105
|
+
if not code:
|
|
106
|
+
return text
|
|
107
|
+
return f"\x1b[{code}m{text}\x1b[0m"
|
fleetwatch/remote.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Watching other machines, the cheap and robust way.
|
|
2
|
+
|
|
3
|
+
Instead of tailing a remote host's session files over a long-lived connection,
|
|
4
|
+
fleetwatch asks the remote to do its own normalization and just hand back the
|
|
5
|
+
answer: one ``ssh <host> fleetwatch --export-json`` per refresh returns that
|
|
6
|
+
host's whole fleet as JSON. One command per host (not N file tails), the work
|
|
7
|
+
happens where the files are, and the conversation content never leaves the
|
|
8
|
+
channel you already trust.
|
|
9
|
+
|
|
10
|
+
A failed fetch does NOT drop the host's sessions — the aggregator keeps the last
|
|
11
|
+
snapshot so an unreachable host reads as "stale", never as "all gone".
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import subprocess
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from .models import SessionState
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RemoteHost:
|
|
24
|
+
"""One machine reachable over ssh, watched by pulling its JSON export."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
name: str,
|
|
29
|
+
ssh_target: Optional[str] = None,
|
|
30
|
+
command: str = "fleetwatch --export-json",
|
|
31
|
+
timeout: float = 15.0,
|
|
32
|
+
):
|
|
33
|
+
self.name = name
|
|
34
|
+
self.ssh_target = ssh_target or name
|
|
35
|
+
self.command = command
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
|
|
38
|
+
def fetch(self) -> "tuple[list[SessionState], bool]":
|
|
39
|
+
"""Return ``(sessions, reachable)``. Never raises.
|
|
40
|
+
|
|
41
|
+
``reachable`` is False on any ssh/parse trouble; sessions is then empty
|
|
42
|
+
and the caller should keep whatever it already had for this host.
|
|
43
|
+
"""
|
|
44
|
+
# Force heuristics-only on the remote so it never spends tokens; the
|
|
45
|
+
# local summarizer fills in plain-language summaries for the whole fleet
|
|
46
|
+
# from one API key.
|
|
47
|
+
remote_cmd = f"FLEETWATCH_NO_MODEL=1 {self.command}"
|
|
48
|
+
argv = [
|
|
49
|
+
"ssh",
|
|
50
|
+
"-o", "BatchMode=yes",
|
|
51
|
+
"-o", f"ConnectTimeout={int(self.timeout)}",
|
|
52
|
+
self.ssh_target,
|
|
53
|
+
remote_cmd,
|
|
54
|
+
]
|
|
55
|
+
try:
|
|
56
|
+
proc = subprocess.run(
|
|
57
|
+
argv,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
timeout=self.timeout + 5,
|
|
61
|
+
)
|
|
62
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
63
|
+
return [], False
|
|
64
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
65
|
+
return [], False
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(proc.stdout)
|
|
68
|
+
except (ValueError, TypeError):
|
|
69
|
+
return [], False
|
|
70
|
+
if not isinstance(data, list):
|
|
71
|
+
return [], False
|
|
72
|
+
|
|
73
|
+
out: list[SessionState] = []
|
|
74
|
+
for d in data:
|
|
75
|
+
try:
|
|
76
|
+
st = SessionState.from_dict(d)
|
|
77
|
+
except Exception:
|
|
78
|
+
continue
|
|
79
|
+
st.source = self.name # relabel: these sessions live on this host
|
|
80
|
+
out.append(st)
|
|
81
|
+
return out, True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_hosts(spec: str) -> list[RemoteHost]:
|
|
85
|
+
"""Parse a ``--hosts`` / ``FLEETWATCH_HOSTS`` string into RemoteHosts.
|
|
86
|
+
|
|
87
|
+
Comma-separated. Each entry is ``name`` or ``name=ssh_target``. The pseudo
|
|
88
|
+
host ``local`` is ignored here (the aggregator always watches local).
|
|
89
|
+
|
|
90
|
+
"dreamer" -> ssh dreamer
|
|
91
|
+
"dreamer=luke@dr.eamer.dev" -> ssh luke@dr.eamer.dev (labelled dreamer)
|
|
92
|
+
"local,dreamer,box=user@host" -> dreamer + box
|
|
93
|
+
"""
|
|
94
|
+
hosts: list[RemoteHost] = []
|
|
95
|
+
for raw in (spec or "").split(","):
|
|
96
|
+
entry = raw.strip()
|
|
97
|
+
if not entry or entry.lower() == "local":
|
|
98
|
+
continue
|
|
99
|
+
if "=" in entry:
|
|
100
|
+
name, target = entry.split("=", 1)
|
|
101
|
+
hosts.append(RemoteHost(name.strip(), target.strip()))
|
|
102
|
+
else:
|
|
103
|
+
hosts.append(RemoteHost(entry))
|
|
104
|
+
return hosts
|