agent-observatory-cli 0.1.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.
- agent_observatory_cli-0.1.0.dist-info/METADATA +115 -0
- agent_observatory_cli-0.1.0.dist-info/RECORD +31 -0
- agent_observatory_cli-0.1.0.dist-info/WHEEL +5 -0
- agent_observatory_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_observatory_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_observatory_cli-0.1.0.dist-info/top_level.txt +1 -0
- observatory/__init__.py +5 -0
- observatory/__main__.py +105 -0
- observatory/activity.css +10 -0
- observatory/activity.js +116 -0
- observatory/auto.py +212 -0
- observatory/client.py +253 -0
- observatory/core.py +1654 -0
- observatory/distribution.py +67 -0
- observatory/findings.py +176 -0
- observatory/index.html +84 -0
- observatory/integrations/__init__.py +1 -0
- observatory/integrations/langchain.py +234 -0
- observatory/integrations/openai_agents.py +177 -0
- observatory/local_activity.py +207 -0
- observatory/local_agents.py +394 -0
- observatory/local_runtime.py +49 -0
- observatory/logs.py +109 -0
- observatory/metrics.py +281 -0
- observatory/otlp_proto.py +210 -0
- observatory/server.py +368 -0
- observatory/setup.css +40 -0
- observatory/setup.js +145 -0
- observatory/terminal.py +525 -0
- observatory/terminal_ui.py +1299 -0
- observatory/watch.py +62 -0
observatory/terminal.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""Receiver lifecycle and live data for the terminal cockpit."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import textwrap
|
|
16
|
+
import unicodedata
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
import webbrowser
|
|
20
|
+
from collections import deque
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from urllib.parse import urlencode, urlsplit
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CockpitError(Exception):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def clean(value):
|
|
31
|
+
text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", str(value))
|
|
32
|
+
return "".join(char for char in text if unicodedata.category(char) not in {"Cc", "Cf"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def clipped(value, width):
|
|
36
|
+
result, used = [], 0
|
|
37
|
+
for char in clean(value):
|
|
38
|
+
size = 0 if unicodedata.combining(char) else 2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1
|
|
39
|
+
if used + size > max(0, width):
|
|
40
|
+
break
|
|
41
|
+
result.append(char)
|
|
42
|
+
used += size
|
|
43
|
+
return "".join(result)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def number(value):
|
|
47
|
+
value = float(value or 0)
|
|
48
|
+
if abs(value) >= 1_000_000:
|
|
49
|
+
return f"{value / 1_000_000:.1f}m"
|
|
50
|
+
if abs(value) >= 10_000:
|
|
51
|
+
return f"{value / 1_000:.1f}k"
|
|
52
|
+
return f"{value:,.0f}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def trace_name(trace):
|
|
56
|
+
if trace.get("task"):
|
|
57
|
+
return trace["task"]
|
|
58
|
+
if trace.get("models"):
|
|
59
|
+
return ", ".join(trace["models"])
|
|
60
|
+
return trace.get("name") or trace.get("trace_id", "Agent run")[:12]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Api:
|
|
64
|
+
def __init__(self, endpoint, token=None):
|
|
65
|
+
parsed = urlsplit(endpoint)
|
|
66
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.query or parsed.fragment:
|
|
67
|
+
raise CockpitError("Use a receiver HTTP address for --endpoint.")
|
|
68
|
+
self.endpoint = endpoint.rstrip("/")
|
|
69
|
+
self.token = token
|
|
70
|
+
|
|
71
|
+
def get(self, path):
|
|
72
|
+
headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
|
|
73
|
+
request = urllib.request.Request(self.endpoint + path, headers=headers)
|
|
74
|
+
with urllib.request.urlopen(request, timeout=3) as response:
|
|
75
|
+
return json.load(response)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class LocalReceiver:
|
|
79
|
+
def __init__(self, db_path, port, token, local_agents=True, metadata_only=False):
|
|
80
|
+
from http.server import ThreadingHTTPServer
|
|
81
|
+
from .core import Store
|
|
82
|
+
from .server import handler_for
|
|
83
|
+
self.store = Store(db_path, async_derived_refresh=True)
|
|
84
|
+
try:
|
|
85
|
+
self.server = ThreadingHTTPServer(("127.0.0.1", port), handler_for(self.store, token, quiet=True))
|
|
86
|
+
except OSError:
|
|
87
|
+
self.store.close()
|
|
88
|
+
raise
|
|
89
|
+
self.thread = threading.Thread(target=self.server.serve_forever, name="observatory-receiver", daemon=True)
|
|
90
|
+
self.thread.start()
|
|
91
|
+
self.local = None
|
|
92
|
+
if local_agents:
|
|
93
|
+
from .local_agents import LocalAgents
|
|
94
|
+
self.local = LocalAgents(self.store, capture_details=not metadata_only).start()
|
|
95
|
+
self.store.local_agent_monitor = self.local
|
|
96
|
+
|
|
97
|
+
def close(self):
|
|
98
|
+
if self.local:
|
|
99
|
+
self.local.close()
|
|
100
|
+
self.server.shutdown()
|
|
101
|
+
self.server.server_close()
|
|
102
|
+
self.thread.join()
|
|
103
|
+
self.store.close()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AgentProcess:
|
|
107
|
+
def __init__(self, target, arguments, endpoint, service, python, module=False, capture_content=False):
|
|
108
|
+
command = [python, "-u", "-m", "observatory", "run", "--endpoint", endpoint, "--service", service]
|
|
109
|
+
if module:
|
|
110
|
+
command.append("--module")
|
|
111
|
+
if capture_content:
|
|
112
|
+
command.append("--capture-content")
|
|
113
|
+
command.extend([target, *arguments])
|
|
114
|
+
self.lines = deque(maxlen=400)
|
|
115
|
+
self.lines.append("Starting " + clean(target))
|
|
116
|
+
self.process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
117
|
+
text=True, encoding="utf-8", errors="replace",
|
|
118
|
+
start_new_session=os.name != "nt")
|
|
119
|
+
self.thread = threading.Thread(target=self._read, name="observatory-agent-output", daemon=True)
|
|
120
|
+
self.thread.start()
|
|
121
|
+
|
|
122
|
+
def _read(self):
|
|
123
|
+
with self.process.stdout:
|
|
124
|
+
for line in self.process.stdout:
|
|
125
|
+
self.lines.append(clean(line.rstrip()))
|
|
126
|
+
|
|
127
|
+
def close(self):
|
|
128
|
+
if self.process.poll() is None:
|
|
129
|
+
if os.name != "nt":
|
|
130
|
+
try:
|
|
131
|
+
os.killpg(self.process.pid, signal.SIGINT)
|
|
132
|
+
except ProcessLookupError:
|
|
133
|
+
pass
|
|
134
|
+
else:
|
|
135
|
+
self.process.terminate()
|
|
136
|
+
try:
|
|
137
|
+
self.process.wait(timeout=4)
|
|
138
|
+
except subprocess.TimeoutExpired:
|
|
139
|
+
if os.name != "nt":
|
|
140
|
+
try:
|
|
141
|
+
os.killpg(self.process.pid, signal.SIGKILL)
|
|
142
|
+
except ProcessLookupError:
|
|
143
|
+
pass
|
|
144
|
+
else:
|
|
145
|
+
self.process.kill()
|
|
146
|
+
self.process.wait()
|
|
147
|
+
self.thread.join(timeout=1)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class Snapshot:
|
|
152
|
+
totals: dict = field(default_factory=dict)
|
|
153
|
+
agents: list = field(default_factory=list)
|
|
154
|
+
traces: list = field(default_factory=list)
|
|
155
|
+
detail: dict = field(default_factory=dict)
|
|
156
|
+
findings: list = field(default_factory=list)
|
|
157
|
+
error: str = ""
|
|
158
|
+
updated: float = 0
|
|
159
|
+
local_status: dict = field(default_factory=dict)
|
|
160
|
+
activity: list = field(default_factory=list)
|
|
161
|
+
sessions: list = field(default_factory=list)
|
|
162
|
+
mcp: list = field(default_factory=list)
|
|
163
|
+
mcp_available: bool = True
|
|
164
|
+
activity_results: list | None = None
|
|
165
|
+
mcp_results: list | None = None
|
|
166
|
+
queries: dict = field(default_factory=dict)
|
|
167
|
+
pagination: dict = field(default_factory=dict)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Feed:
|
|
171
|
+
def __init__(self, api):
|
|
172
|
+
self.api = api
|
|
173
|
+
self.snapshot = Snapshot()
|
|
174
|
+
self.service = ""
|
|
175
|
+
self.query = ""
|
|
176
|
+
self.activity_query = ""
|
|
177
|
+
self.mcp_query = ""
|
|
178
|
+
self.errors_only = False
|
|
179
|
+
self.selected_trace = None
|
|
180
|
+
self.selected_span_id = None
|
|
181
|
+
self.session_id = ''
|
|
182
|
+
self.include_reviews = False
|
|
183
|
+
self.pinned_trace = False
|
|
184
|
+
self.extra_cache = None
|
|
185
|
+
self.extra_updated = 0
|
|
186
|
+
self.extra_scope = None
|
|
187
|
+
self.paused = False
|
|
188
|
+
self.history = {page: [None] for page in ('activity','mcp','traces')}
|
|
189
|
+
self.history_scopes = {}
|
|
190
|
+
self.refresh_once = False
|
|
191
|
+
self.stop = threading.Event()
|
|
192
|
+
self.wake = threading.Event()
|
|
193
|
+
self.thread = threading.Thread(target=self._loop, name="observatory-terminal-feed", daemon=True)
|
|
194
|
+
|
|
195
|
+
def refresh(self):
|
|
196
|
+
service, query, errors_only, selected = self.service, self.query, self.errors_only, self.selected_trace
|
|
197
|
+
activity_query, mcp_query = self.activity_query, self.mcp_query
|
|
198
|
+
requested_trace, requested_span = selected, self.selected_span_id
|
|
199
|
+
session_id, include_reviews = self.session_id, self.include_reviews
|
|
200
|
+
for page, text in (('activity',activity_query),('mcp',mcp_query),('traces',query)):
|
|
201
|
+
scope=(service,session_id,text,include_reviews)
|
|
202
|
+
if self.history_scopes.get(page)!=scope:
|
|
203
|
+
self.history[page]=[None]
|
|
204
|
+
self.history_scopes[page]=scope
|
|
205
|
+
history={page:tuple(cursors) for page,cursors in self.history.items()}
|
|
206
|
+
pagination={}
|
|
207
|
+
def page_result(page, response):
|
|
208
|
+
pagination[page]={'page':len(history[page]), 'next_cursor':response.get('next_cursor'),
|
|
209
|
+
'supported':'next_cursor' in response}
|
|
210
|
+
try:
|
|
211
|
+
local_status = self.api.get("/api/health").get("local_agents", {})
|
|
212
|
+
totals = self.api.get("/api/metrics")
|
|
213
|
+
agents = self.api.get("/api/connections")["connections"]
|
|
214
|
+
try:
|
|
215
|
+
activity_params = {"limit": 200, "service": service, "session_id": session_id,
|
|
216
|
+
"include_reviews":int(include_reviews)}
|
|
217
|
+
activity_page = self.api.get("/api/activity?" + urlencode(activity_params))
|
|
218
|
+
activity = activity_page['activity']
|
|
219
|
+
if activity_query or history['activity'][-1]:
|
|
220
|
+
activity_page = self.api.get("/api/activity?" + urlencode({**activity_params,
|
|
221
|
+
"q":activity_query,"cursor":history['activity'][-1] or ''}))
|
|
222
|
+
activity_results = activity_page['activity']
|
|
223
|
+
page_result('activity',activity_page)
|
|
224
|
+
except urllib.error.HTTPError as exc:
|
|
225
|
+
if exc.code != 404:
|
|
226
|
+
raise
|
|
227
|
+
activity, activity_results = [], []
|
|
228
|
+
scope = (service, session_id, mcp_query, include_reviews, history['mcp'])
|
|
229
|
+
if self.extra_cache is None or scope != self.extra_scope or time.monotonic()-self.extra_updated>3:
|
|
230
|
+
sessions = self.api.get('/api/sessions?limit=500')['sessions']
|
|
231
|
+
try:
|
|
232
|
+
mcp_params = {'limit':100,'service':service,'session_id':session_id,
|
|
233
|
+
'include_reviews':int(include_reviews)}
|
|
234
|
+
mcp_page = self.api.get('/api/mcp?'+urlencode(mcp_params))
|
|
235
|
+
mcp = mcp_page['calls']
|
|
236
|
+
if mcp_query or history['mcp'][-1]:
|
|
237
|
+
mcp_page = self.api.get('/api/mcp?'+urlencode({**mcp_params,'q':mcp_query,
|
|
238
|
+
'cursor':history['mcp'][-1] or ''}))
|
|
239
|
+
mcp_results = mcp_page['calls']
|
|
240
|
+
mcp_available = True
|
|
241
|
+
except urllib.error.HTTPError as exc:
|
|
242
|
+
if exc.code!=404:
|
|
243
|
+
raise
|
|
244
|
+
mcp,mcp_results,mcp_available,mcp_page=[],[],False,{}
|
|
245
|
+
self.extra_cache = sessions,mcp,mcp_results,mcp_available,mcp_page
|
|
246
|
+
self.extra_scope,self.extra_updated = scope,time.monotonic()
|
|
247
|
+
sessions,mcp,mcp_results,mcp_available,mcp_page=self.extra_cache
|
|
248
|
+
page_result('mcp',mcp_page)
|
|
249
|
+
params = {"limit": 100, "service": service, "session_id": session_id, "q": query,
|
|
250
|
+
"cursor":history['traces'][-1] or '', 'include_reviews':int(include_reviews)}
|
|
251
|
+
if errors_only:
|
|
252
|
+
params["status"] = "ERROR"
|
|
253
|
+
trace_page = self.api.get("/api/traces?" + urlencode(params))
|
|
254
|
+
traces = trace_page['traces']
|
|
255
|
+
page_result('traces',trace_page)
|
|
256
|
+
if not include_reviews:
|
|
257
|
+
traces = [item for item in traces if 'codex-review' not in item.get('services',[])]
|
|
258
|
+
activity = [item for item in activity if item.get('service')!='codex-review']
|
|
259
|
+
activity_results = [item for item in activity_results if item.get('service')!='codex-review']
|
|
260
|
+
mcp = [item for item in mcp if item.get('service')!='codex-review']
|
|
261
|
+
mcp_results = [item for item in mcp_results if item.get('service')!='codex-review']
|
|
262
|
+
traces,activity = traces[:100],activity[:200]
|
|
263
|
+
if not self.pinned_trace and selected not in {item["trace_id"] for item in traces}:
|
|
264
|
+
selected = traces[0]["trace_id"] if traces else None
|
|
265
|
+
detail = self.api.get("/api/traces/" + selected + "?span_limit=100") if selected else {}
|
|
266
|
+
if selected and requested_span:
|
|
267
|
+
wanted=requested_span
|
|
268
|
+
known={span['span_id'] for span in detail.get('spans',[])}
|
|
269
|
+
for _ in range(8):
|
|
270
|
+
if not wanted or wanted in known:break
|
|
271
|
+
page=self.api.get('/api/traces/'+selected+'/spans?'+urlencode({'limit':100,'q':wanted}))
|
|
272
|
+
span=next((item for item in page['spans'] if item['span_id']==wanted),None)
|
|
273
|
+
if not span:break
|
|
274
|
+
detail['spans'].append(span);known.add(wanted)
|
|
275
|
+
wanted=span.get('parent_span_id')
|
|
276
|
+
findings = detail.get("findings", [])
|
|
277
|
+
if not selected:
|
|
278
|
+
findings = self.api.get("/api/findings?limit=8")["findings"]
|
|
279
|
+
# A filter changed while the HTTP reads were in flight; discard the older view.
|
|
280
|
+
if (service, query, activity_query, mcp_query, errors_only, session_id, include_reviews) != (self.service, self.query, self.activity_query, self.mcp_query, self.errors_only, self.session_id, self.include_reviews):
|
|
281
|
+
self.wake.set()
|
|
282
|
+
return
|
|
283
|
+
if self.selected_trace!=requested_trace or self.selected_span_id!=requested_span or history!={page:tuple(cursors) for page,cursors in self.history.items()}:
|
|
284
|
+
self.wake.set()
|
|
285
|
+
return
|
|
286
|
+
self.selected_trace = selected
|
|
287
|
+
self.snapshot = Snapshot(totals, agents, traces, detail, findings, updated=time.time(), local_status=local_status, activity=activity,
|
|
288
|
+
sessions=sessions,mcp=mcp,mcp_available=mcp_available,
|
|
289
|
+
activity_results=activity_results[:200],mcp_results=mcp_results,
|
|
290
|
+
queries={'activity':activity_query,'mcp':mcp_query,'traces':query},pagination=pagination)
|
|
291
|
+
except Exception as exc:
|
|
292
|
+
if isinstance(exc, urllib.error.HTTPError) and exc.code == 401:
|
|
293
|
+
message = "Receiver requires authentication. Set OBSERVATORY_TOKEN and restart."
|
|
294
|
+
else:
|
|
295
|
+
message = "Receiver unavailable. Retrying: " + clean(str(exc))
|
|
296
|
+
old = self.snapshot
|
|
297
|
+
from dataclasses import replace
|
|
298
|
+
self.snapshot = replace(old,error=message)
|
|
299
|
+
|
|
300
|
+
def request_refresh(self):
|
|
301
|
+
self.refresh_once = True
|
|
302
|
+
self.wake.set()
|
|
303
|
+
|
|
304
|
+
def navigate_history(self, page, direction):
|
|
305
|
+
cursors=self.history[page]
|
|
306
|
+
next_cursor=self.snapshot.pagination.get(page,{}).get('next_cursor')
|
|
307
|
+
if direction=='older' and next_cursor:
|
|
308
|
+
self.history[page]=[*cursors,next_cursor]
|
|
309
|
+
elif direction=='newer' and len(cursors)>1:
|
|
310
|
+
self.history[page]=cursors[:-1]
|
|
311
|
+
elif direction=='latest':
|
|
312
|
+
self.history[page]=[None]
|
|
313
|
+
else:
|
|
314
|
+
return False
|
|
315
|
+
if page=='traces':
|
|
316
|
+
self.selected_trace=None;self.selected_span_id=None;self.pinned_trace=False
|
|
317
|
+
self.request_refresh()
|
|
318
|
+
return True
|
|
319
|
+
|
|
320
|
+
def _loop(self):
|
|
321
|
+
while not self.stop.is_set():
|
|
322
|
+
if not self.paused or self.refresh_once:
|
|
323
|
+
self.refresh_once = False
|
|
324
|
+
self.refresh()
|
|
325
|
+
self.wake.wait(1)
|
|
326
|
+
self.wake.clear()
|
|
327
|
+
|
|
328
|
+
def close(self):
|
|
329
|
+
self.stop.set()
|
|
330
|
+
self.wake.set()
|
|
331
|
+
self.thread.join(timeout=4)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def agent_rows(snapshot):
|
|
335
|
+
rows = [("", "ALL AGENTS", "")]
|
|
336
|
+
known = set()
|
|
337
|
+
local_groups = {}
|
|
338
|
+
for agent in snapshot.agents:
|
|
339
|
+
service = agent["service"]
|
|
340
|
+
known.add(service)
|
|
341
|
+
local = "local-session-log" in agent.get("integrations", [])
|
|
342
|
+
if local:
|
|
343
|
+
local_groups.setdefault(service, []).append(agent)
|
|
344
|
+
continue
|
|
345
|
+
state = ("RECENT" if agent["state"] == "running" else "IDLE") if local else (
|
|
346
|
+
"LIVE" if agent["state"] == "running" else "DONE" if agent["state"] == "stopped" else "OFFLINE")
|
|
347
|
+
rows.append((service, service, f"{state} · {number(agent['span_count'])} spans"))
|
|
348
|
+
for service, agents in local_groups.items():
|
|
349
|
+
state = "RECENT" if any(agent["state"] == "running" for agent in agents) else "IDLE"
|
|
350
|
+
rows.append((service, service, f"{state} · {len(agents)} sessions · {number(sum(agent['span_count'] for agent in agents))} spans"))
|
|
351
|
+
for service in snapshot.totals.get("services", []):
|
|
352
|
+
if service not in known:
|
|
353
|
+
rows.append((service, service, "OTLP / imported"))
|
|
354
|
+
return rows
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def event_rows(snapshot):
|
|
358
|
+
rows = []
|
|
359
|
+
detail = snapshot.detail
|
|
360
|
+
for event in detail.get("progress", []):
|
|
361
|
+
rows.append((event.get("time_ns", 0), event.get("stage", "Progress"), event.get("message", "")))
|
|
362
|
+
for log in detail.get("logs", [])[:30]:
|
|
363
|
+
body = log.get("body", "")
|
|
364
|
+
rows.append((log.get("time_unix_nano", 0), log.get("severity_text") or log.get("event_name") or "LOG",
|
|
365
|
+
body if isinstance(body, str) else json.dumps(body, ensure_ascii=False)))
|
|
366
|
+
for finding in snapshot.findings[:10]:
|
|
367
|
+
rows.append((finding.get("time_unix_nano", 0), finding.get("severity", "finding").upper(), finding.get("title", "Review finding")))
|
|
368
|
+
for span in detail.get("spans", []):
|
|
369
|
+
if span.get("scope_name") == "observatory.local_agents":
|
|
370
|
+
rows.append((span["end_unix_nano"], span["status"], span["name"]))
|
|
371
|
+
rows.sort(key=lambda item: item[0], reverse=True)
|
|
372
|
+
return [f"{time.strftime('%H:%M:%S', time.localtime(stamp/1e9)) if stamp else '--:--:--'} {label} {body}" for stamp, label, body in rows]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def span_rows(snapshot):
|
|
376
|
+
rows = []
|
|
377
|
+
for span in snapshot.detail.get("spans", []):
|
|
378
|
+
duration = (span["end_unix_nano"] - span["start_unix_nano"]) / 1e6
|
|
379
|
+
timing = " unknown" if span.get("attributes", {}).get("observatory.duration_unknown") else f"{duration:7.1f}ms"
|
|
380
|
+
rows.append(f"{span['status'][:7]:7} {timing} {span['name']}")
|
|
381
|
+
return rows
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def activity_rows(snapshot):
|
|
385
|
+
rows = []
|
|
386
|
+
for item in snapshot.activity:
|
|
387
|
+
stamp = time.strftime('%H:%M:%S', time.localtime(item['time_unix_nano']/1e9))
|
|
388
|
+
state = str(item.get('result') or 'observed').upper()
|
|
389
|
+
rows.append(f"{stamp} {item['domain'][:7].upper():7} {state[:7]:7} {item['action']} {item['subject']}")
|
|
390
|
+
return rows
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def wrapped(text, width):
|
|
394
|
+
lines = []
|
|
395
|
+
for line in str(text).splitlines():
|
|
396
|
+
lines.extend(textwrap.wrap(line, width=max(10, width), replace_whitespace=False) or [''])
|
|
397
|
+
return lines
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def print_snapshot(feed, agent=None):
|
|
401
|
+
feed.refresh()
|
|
402
|
+
snapshot = feed.snapshot
|
|
403
|
+
totals = snapshot.totals
|
|
404
|
+
print("OBSERVATORY " + feed.api.endpoint)
|
|
405
|
+
if snapshot.local_status.get("enabled"):
|
|
406
|
+
mode = "bounded, redacted content" if snapshot.local_status.get('capture_content') else "metadata only"
|
|
407
|
+
print(f"Source: local Codex / Claude Code session logs · {snapshot.local_status.get('sources', 0)} files · {mode}")
|
|
408
|
+
print(f"Runs {number(totals.get('runs'))} Spans {number(totals.get('spans'))} "
|
|
409
|
+
f"Tokens {number(totals.get('input_tokens'))} in / {number(totals.get('output_tokens'))} out "
|
|
410
|
+
f"Errors {number(totals.get('errors'))} Known cost ${totals.get('known_cost_usd', 0):.4f}")
|
|
411
|
+
for title, rows in (
|
|
412
|
+
("CONNECTED AGENTS", [f"{label} {state}" for _, label, state in agent_rows(snapshot)[1:]]),
|
|
413
|
+
("RECENT TRACES", [f"{item['status']:10} {trace_name(item)} {number(item['input_tokens'])} in / {number(item['output_tokens'])} out" for item in snapshot.traces[:10]]),
|
|
414
|
+
("SELECTED TRACE / SPANS", span_rows(snapshot)[:10]),
|
|
415
|
+
("LIVE PROGRESS / ERRORS", event_rows(snapshot)[:8]),
|
|
416
|
+
("RECENT ACTIVITY", activity_rows(snapshot)[:12]),
|
|
417
|
+
("AGENT OUTPUT", list(agent.lines)[-5:] if agent else []),
|
|
418
|
+
):
|
|
419
|
+
print("\n+-- " + title + " " + "-" * max(0, 66-len(title)))
|
|
420
|
+
for line in rows or ["Waiting for telemetry. Press c in the cockpit for connection commands."]:
|
|
421
|
+
print("| " + clipped(line, 100))
|
|
422
|
+
print("+" + "-" * 72)
|
|
423
|
+
if snapshot.error:
|
|
424
|
+
print(snapshot.error)
|
|
425
|
+
raise CockpitError(snapshot.error)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def open_terminal(command):
|
|
429
|
+
if sys.platform == "darwin":
|
|
430
|
+
script = tempfile.NamedTemporaryFile(mode="w", prefix="observatory-", suffix=".command", delete=False)
|
|
431
|
+
with script:
|
|
432
|
+
script.write("#!/bin/sh\nrm -- " + shlex.quote(script.name) + "\ncd " + shlex.quote(os.getcwd()) + "\nexec " + shlex.join(command) + "\n")
|
|
433
|
+
os.chmod(script.name, 0o700)
|
|
434
|
+
subprocess.Popen(["open", "-a", "Terminal", script.name])
|
|
435
|
+
return
|
|
436
|
+
if os.name == "nt":
|
|
437
|
+
terminal = shutil.which("wt.exe")
|
|
438
|
+
if terminal:
|
|
439
|
+
subprocess.Popen([terminal, "--title", "Observatory", "--startingDirectory", os.getcwd(), *command])
|
|
440
|
+
return
|
|
441
|
+
elif os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
|
|
442
|
+
for executable, prefix in (("gnome-terminal", ["--working-directory", os.getcwd(), "--"]),
|
|
443
|
+
("konsole", ["--workdir", os.getcwd(), "-e"]),
|
|
444
|
+
("x-terminal-emulator", ["-e"]), ("xterm", ["-e"])):
|
|
445
|
+
terminal = shutil.which(executable)
|
|
446
|
+
if terminal:
|
|
447
|
+
subprocess.Popen([terminal, *prefix, *command])
|
|
448
|
+
return
|
|
449
|
+
raise CockpitError("No graphical terminal is available. Run the cockpit in your current terminal, or use --once.")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def run_cockpit(*, db_path="observatory.db", port=14318, endpoint=None, service="my-agent", target=None,
|
|
453
|
+
arguments=(), python=None, module=False, capture_content=False, once=False, new_window=False, local_agents=True, metadata_only=False):
|
|
454
|
+
if not 1 <= port <= 65535:
|
|
455
|
+
raise CockpitError("Choose a port between 1 and 65535.")
|
|
456
|
+
if once and target:
|
|
457
|
+
raise CockpitError("--once prints a snapshot. Omit the agent filename, or run the interactive cockpit to launch it.")
|
|
458
|
+
if new_window:
|
|
459
|
+
command = [sys.executable, "-m", "observatory", "--db", str(Path(db_path).resolve()), "cockpit", "--port", str(port), "--service", service]
|
|
460
|
+
if endpoint:
|
|
461
|
+
command.extend(["--endpoint", endpoint])
|
|
462
|
+
if python:
|
|
463
|
+
command.extend(["--python", python])
|
|
464
|
+
if module:
|
|
465
|
+
command.append("--module")
|
|
466
|
+
if capture_content:
|
|
467
|
+
command.append("--capture-content")
|
|
468
|
+
if not local_agents:
|
|
469
|
+
command.append("--no-local-agents")
|
|
470
|
+
if metadata_only:
|
|
471
|
+
command.append("--metadata-only")
|
|
472
|
+
if once:
|
|
473
|
+
command.append("--once")
|
|
474
|
+
if target:
|
|
475
|
+
command.extend([str(Path(target).resolve()) if not module else target, *arguments])
|
|
476
|
+
open_terminal(command)
|
|
477
|
+
return
|
|
478
|
+
if not once and (not sys.stdin.isatty() or not sys.stdout.isatty()):
|
|
479
|
+
raise CockpitError("Run this command in an interactive terminal. Use --new-window to open one, or --once for a snapshot.")
|
|
480
|
+
if not once:
|
|
481
|
+
try:
|
|
482
|
+
from .terminal_ui import ObservatoryApp
|
|
483
|
+
except ImportError as exc:
|
|
484
|
+
raise CockpitError("The terminal UI needs Textual. Reinstall the CLI, or install this checkout with: python -m pip install .") from exc
|
|
485
|
+
token = os.environ.get("OBSERVATORY_TOKEN")
|
|
486
|
+
receiver = agent = feed = None
|
|
487
|
+
api = Api(endpoint or f"http://127.0.0.1:{port}", token)
|
|
488
|
+
try:
|
|
489
|
+
try:
|
|
490
|
+
health = api.get("/api/health")
|
|
491
|
+
if not health.get("ok"):
|
|
492
|
+
raise CockpitError("The endpoint is not a ready Observatory receiver.")
|
|
493
|
+
if metadata_only and health.get("local_agents", {}).get("capture_content"):
|
|
494
|
+
raise CockpitError("The existing receiver captures session content. Restart it with serve --metadata-only, then reconnect.")
|
|
495
|
+
except urllib.error.HTTPError as exc:
|
|
496
|
+
if exc.code == 401:
|
|
497
|
+
raise CockpitError("Receiver requires authentication. Set OBSERVATORY_TOKEN before starting.") from exc
|
|
498
|
+
raise CockpitError(f"The endpoint returned HTTP {exc.code}. Check the receiver address.") from exc
|
|
499
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
500
|
+
if endpoint:
|
|
501
|
+
raise CockpitError("Cannot connect to the receiver at " + api.endpoint) from exc
|
|
502
|
+
try:
|
|
503
|
+
receiver = LocalReceiver(db_path, port, token, local_agents=local_agents, metadata_only=metadata_only)
|
|
504
|
+
except OSError as bind_error:
|
|
505
|
+
raise CockpitError(f"Cannot start a local receiver on port {port}: {bind_error}") from bind_error
|
|
506
|
+
feed = Feed(api)
|
|
507
|
+
if target:
|
|
508
|
+
try:
|
|
509
|
+
agent = AgentProcess(target, arguments, api.endpoint, service, python or sys.executable, module, capture_content)
|
|
510
|
+
except OSError as exc:
|
|
511
|
+
raise CockpitError("Cannot launch the agent: " + str(exc)) from exc
|
|
512
|
+
if once:
|
|
513
|
+
print_snapshot(feed, agent)
|
|
514
|
+
else:
|
|
515
|
+
ObservatoryApp(feed, agent, receiver is not None).run()
|
|
516
|
+
except KeyboardInterrupt:
|
|
517
|
+
pass
|
|
518
|
+
finally:
|
|
519
|
+
if agent is not None:
|
|
520
|
+
agent.close()
|
|
521
|
+
if feed is not None:
|
|
522
|
+
if feed.thread.is_alive():
|
|
523
|
+
feed.close()
|
|
524
|
+
if receiver is not None:
|
|
525
|
+
receiver.close()
|