Dontcryx_07 commited on
Commit
98f0a17
Β·
1 Parent(s): d660fa9
backend/Odin.py CHANGED
@@ -235,6 +235,30 @@ async def require_admin(authorization: str = Header(None)):
235
  return user
236
 
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  def _print_agent_plans(engine):
239
  """Print each agent's full action plan to the CLI."""
240
  from src.core.agent_registry import AgentRuntimeState
 
235
  return user
236
 
237
 
238
+ # ---------------------------------------------------------------------------
239
+ # Admin log relay β€” live view of backend logs without the Space console.
240
+ # The relay mirror lives in src/core/log_relay.py and is installed by
241
+ # src/core/log.py setup_logging(); only these two endpoints expose it.
242
+ # ---------------------------------------------------------------------------
243
+
244
+ @app.get("/api/logs")
245
+ async def get_log_lines(
246
+ since: int = Query(default=0, ge=0),
247
+ _user: dict = Depends(require_admin),
248
+ ):
249
+ """Return relayed log lines newer than `since`; `next` is the poll cursor."""
250
+ from src.core.log_relay import relay_lines
251
+ return relay_lines(since)
252
+
253
+
254
+ @app.post("/api/logs/clear")
255
+ async def clear_log_lines(_user: dict = Depends(require_admin)):
256
+ """Clear the in-memory relay buffer. The live log file is left intact."""
257
+ from src.core.log_relay import clear_relay
258
+ clear_relay()
259
+ return {"ok": True}
260
+
261
+
262
  def _print_agent_plans(engine):
263
  """Print each agent's full action plan to the CLI."""
264
  from src.core.agent_registry import AgentRuntimeState
backend/src/core/log.py CHANGED
@@ -5,7 +5,9 @@ and level stay consistent across the engine, planner, and server.
5
 
6
  Architecture: a dependency of every backend module; no business logic.
7
  Design: logs land in backend/output/logs with timestamps for post-hoc
8
- analysis of long runs.
 
 
9
  """
10
 
11
  from __future__ import annotations
@@ -37,7 +39,7 @@ def _build_log_path(run_id: Optional[str] = None) -> Path:
37
  def setup_logging(
38
  level: int = DEFAULT_LEVEL,
39
  run_id: Optional[str] = None,
40
- console: bool = True,
41
  file: bool = True,
42
  max_bytes: int = 5 * 1024 * 1024,
43
  backup_count: int = 5,
@@ -53,7 +55,8 @@ def setup_logging(
53
  run_id -- optional tag folded into the log filename, e.g. a
54
  persona name or simulation id, so a run's logs are
55
  easy to find in output/logs/
56
- console -- also stream logs to stdout
 
57
  file -- also write logs to output/logs/<timestamp>[_<run_id>].log
58
  max_bytes /
59
  backup_count -- rotation settings for the file handler
@@ -113,6 +116,14 @@ def setup_logging(
113
  logging.config.dictConfig(config)
114
  _configured = True
115
 
 
 
 
 
 
 
 
 
116
  if log_path:
117
  logging.getLogger(__name__).info("[log] logging initialized -> %s", log_path)
118
 
 
5
 
6
  Architecture: a dependency of every backend module; no business logic.
7
  Design: logs land in backend/output/logs with timestamps for post-hoc
8
+ analysis of long runs. Console output is off by default β€” the admin log
9
+ terminal (src/core/log_relay.py) mirrors every line to the live log file
10
+ and the admin-only /api/logs endpoints instead.
11
  """
12
 
13
  from __future__ import annotations
 
39
  def setup_logging(
40
  level: int = DEFAULT_LEVEL,
41
  run_id: Optional[str] = None,
42
+ console: bool = False,
43
  file: bool = True,
44
  max_bytes: int = 5 * 1024 * 1024,
45
  backup_count: int = 5,
 
55
  run_id -- optional tag folded into the log filename, e.g. a
56
  persona name or simulation id, so a run's logs are
57
  easy to find in output/logs/
58
+ console -- also stream logs to stdout (default off; watch the
59
+ admin log terminal instead)
60
  file -- also write logs to output/logs/<timestamp>[_<run_id>].log
61
  max_bytes /
62
  backup_count -- rotation settings for the file handler
 
116
  logging.config.dictConfig(config)
117
  _configured = True
118
 
119
+ # Mirror every emitted line into the admin log terminal's buffer and the
120
+ # live log file, regardless of the console/file toggles above.
121
+ try:
122
+ from src.core.log_relay import install_relay
123
+ install_relay()
124
+ except Exception:
125
+ pass
126
+
127
  if log_path:
128
  logging.getLogger(__name__).info("[log] logging initialized -> %s", log_path)
129
 
backend/src/core/log_relay.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """log_relay β€” mirror of every project log line for the admin log terminal.
2
+
3
+ Attaches a second handler to the ROOT logger: each emitted record is
4
+ formatted with the project's standard format and appended to a bounded
5
+ in-memory ring buffer (polled by the admin-only /api/logs endpoints) and
6
+ to a live file backend/output/logs/live.log, so the same output also
7
+ survives restarts and stays viewable without the Space console.
8
+
9
+ Architecture: installed once by src/core/log.py setup_logging(); consumed
10
+ by Odin.py's admin-gated endpoints and the frontend LogTerminal panel.
11
+ Design: stdlib-only, a one-way mirror β€” never changes existing handlers,
12
+ levels, or the file rotation policy.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import threading
19
+ from collections import deque
20
+ from itertools import count
21
+
22
+ from src.config import LOG_DIR
23
+ from src.core.log import DATE_FORMAT, LOG_FORMAT
24
+
25
+ MAX_LINES = 2000
26
+ LIVE_LOG_PATH = LOG_DIR / "live.log"
27
+
28
+ _seq = count(1)
29
+ _buffer: deque = deque(maxlen=MAX_LINES)
30
+ _latest_seq = 0
31
+ _lock = threading.Lock()
32
+ _installed = False
33
+
34
+
35
+ class LogRelayHandler(logging.Handler):
36
+ """Appends each formatted record to the ring buffer and the live file."""
37
+
38
+ def __init__(self, level: int = logging.NOTSET) -> None:
39
+ super().__init__(level)
40
+ self._formatter = logging.Formatter(LOG_FORMAT, DATE_FORMAT)
41
+
42
+ def emit(self, record: logging.LogRecord) -> None:
43
+ global _latest_seq
44
+ try:
45
+ text = self._formatter.format(record)
46
+ except Exception:
47
+ text = f"{record.name} | {record.getMessage()}"
48
+ with _lock:
49
+ _latest_seq = next(_seq)
50
+ _buffer.append({"seq": _latest_seq, "level": record.levelname, "text": text})
51
+ try:
52
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
53
+ with open(LIVE_LOG_PATH, "a", encoding="utf-8") as fh:
54
+ fh.write(text + "\n")
55
+ except OSError:
56
+ pass
57
+
58
+
59
+ def install_relay() -> None:
60
+ """Attach the relay handler to the root logger exactly once."""
61
+ global _installed
62
+ if _installed:
63
+ return
64
+ logging.getLogger().addHandler(LogRelayHandler())
65
+ _installed = True
66
+
67
+
68
+ def relay_lines(since: int = 0) -> dict:
69
+ """Return relayed entries with seq > since, plus the latest seq as cursor.
70
+
71
+ The cursor tracks the last emitted sequence, so polling stays
72
+ incremental even after the buffer is cleared.
73
+ """
74
+ with _lock:
75
+ lines = [entry for entry in _buffer if entry["seq"] > since]
76
+ return {"lines": lines, "next": _latest_seq}
77
+
78
+
79
+ def clear_relay() -> None:
80
+ """Drop the in-memory buffer (the live file is intentionally kept)."""
81
+ with _lock:
82
+ _buffer.clear()
frontend/src/App.jsx CHANGED
@@ -23,6 +23,7 @@ import EventsPanel from "./components/EventsPanel";
23
  import DebugPanel from "./components/DebugPanel";
24
  import RosterManager from "./components/RosterManager";
25
  import LoginButton from "./components/LoginButton";
 
26
  import { apiUrl } from "./utils/api";
27
  import "./App.css";
28
 
@@ -56,6 +57,7 @@ function AppContent() {
56
  const [controlError, setControlError] = useState(null);
57
  const [simulationRunning, setSimulationRunning] = useState(true);
58
  const [rosterOpen, setRosterOpen] = useState(false);
 
59
 
60
  useEffect(() => {
61
  if (!snapshot) return;
@@ -153,6 +155,7 @@ function AppContent() {
153
  <EventsPanel events={snapshot?.events} />
154
  {showDebug && <DebugPanel health={snapshot?.health} />}
155
  <RosterManager open={rosterOpen} onClose={() => setRosterOpen(false)} simulationRunning={simulationRunning} onError={setControlError} isAuthenticated={isAuthenticated} />
 
156
 
157
  {agentIds.map((id, index) => {
158
  const expanded = expandedAgentIds.has(id);
@@ -185,6 +188,8 @@ function AppContent() {
185
  }}
186
  onToggleRoster={() => setRosterOpen((value) => !value)}
187
  isAuthenticated={isAuthenticated}
 
 
188
  />
189
  <LoginButton />
190
  </div>
 
23
  import DebugPanel from "./components/DebugPanel";
24
  import RosterManager from "./components/RosterManager";
25
  import LoginButton from "./components/LoginButton";
26
+ import LogTerminal from "./components/LogTerminal";
27
  import { apiUrl } from "./utils/api";
28
  import "./App.css";
29
 
 
57
  const [controlError, setControlError] = useState(null);
58
  const [simulationRunning, setSimulationRunning] = useState(true);
59
  const [rosterOpen, setRosterOpen] = useState(false);
60
+ const [showTerminal, setShowTerminal] = useState(false);
61
 
62
  useEffect(() => {
63
  if (!snapshot) return;
 
155
  <EventsPanel events={snapshot?.events} />
156
  {showDebug && <DebugPanel health={snapshot?.health} />}
157
  <RosterManager open={rosterOpen} onClose={() => setRosterOpen(false)} simulationRunning={simulationRunning} onError={setControlError} isAuthenticated={isAuthenticated} />
158
+ {isAuthenticated && showTerminal && <LogTerminal onClose={() => setShowTerminal(false)} />}
159
 
160
  {agentIds.map((id, index) => {
161
  const expanded = expandedAgentIds.has(id);
 
188
  }}
189
  onToggleRoster={() => setRosterOpen((value) => !value)}
190
  isAuthenticated={isAuthenticated}
191
+ terminalOpen={showTerminal}
192
+ onToggleTerminal={() => setShowTerminal((value) => !value)}
193
  />
194
  <LoginButton />
195
  </div>
frontend/src/components/InfoBar.jsx CHANGED
@@ -13,7 +13,7 @@
13
 
14
  import { useState } from "react";
15
 
16
- export default function InfoBar({ snapshot, showDebug, onToggleDebug, onFastForward, onSlowDown, onRewind, simulationRunning, onToggleSimulation, onToggleRoster, isAuthenticated }) {
17
  const [rewindAmount, setRewindAmount] = useState("10");
18
  const [rewindUnit, setRewindUnit] = useState("ticks");
19
  if (!snapshot) return null;
@@ -140,11 +140,20 @@ export default function InfoBar({ snapshot, showDebug, onToggleDebug, onFastForw
140
  {showDebug ? "DEBUG ON" : "DEBUG"}
141
  </button>
142
  {isAuthenticated && (
143
- <button onClick={onToggleRoster} style={{
144
- pointerEvents: "auto", border: "1px solid rgba(212,160,74,.3)", borderRadius: 3,
145
- background: "rgba(212,160,74,.08)", color: "#e7bd70", padding: "2px 5px",
146
- fontFamily: "'Space Mono', monospace", fontSize: 8, cursor: "pointer",
147
- }} title="Add, retire, or rename agents while the simulation is stopped">ROSTER</button>
 
 
 
 
 
 
 
 
 
148
  )}
149
  </div>
150
  );
 
13
 
14
  import { useState } from "react";
15
 
16
+ export default function InfoBar({ snapshot, showDebug, onToggleDebug, onFastForward, onSlowDown, onRewind, simulationRunning, onToggleSimulation, onToggleRoster, isAuthenticated, terminalOpen, onToggleTerminal }) {
17
  const [rewindAmount, setRewindAmount] = useState("10");
18
  const [rewindUnit, setRewindUnit] = useState("ticks");
19
  if (!snapshot) return null;
 
140
  {showDebug ? "DEBUG ON" : "DEBUG"}
141
  </button>
142
  {isAuthenticated && (
143
+ <>
144
+ <button onClick={onToggleRoster} style={{
145
+ pointerEvents: "auto", border: "1px solid rgba(212,160,74,.3)", borderRadius: 3,
146
+ background: "rgba(212,160,74,.08)", color: "#e7bd70", padding: "2px 5px",
147
+ fontFamily: "'Space Mono', monospace", fontSize: 8, cursor: "pointer",
148
+ }} title="Add, retire, or rename agents while the simulation is stopped">ROSTER</button>
149
+ <button onClick={onToggleTerminal} style={{
150
+ pointerEvents: "auto", border: "1px solid rgba(91,155,213,.3)", borderRadius: 3,
151
+ background: terminalOpen ? "rgba(91,155,213,.22)" : "transparent", color: "#8fbbe8", padding: "2px 5px",
152
+ fontFamily: "'Space Mono', monospace", fontSize: 8, cursor: "pointer",
153
+ }} title="Open the live backend log terminal">
154
+ {terminalOpen ? "TERMINAL ON" : "TERMINAL"}
155
+ </button>
156
+ </>
157
  )}
158
  </div>
159
  );
frontend/src/components/LogTerminal.jsx ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LogTerminal β€” admin-only live view of the backend log relay.
3
+ *
4
+ * Polls GET /api/logs?since=<cursor> with the admin bearer token and
5
+ * renders the captured logger output as a read-only terminal.
6
+ *
7
+ * Architecture: rendered by App.jsx only for authenticated users; toggled
8
+ * by the TERMINAL button in InfoBar.
9
+ *
10
+ * Design: 2s REST polling reuses the existing bearer-token flow instead of
11
+ * adding WebSocket authentication; auto-scroll halts while the pointer is
12
+ * over the terminal so lines can be read mid-stream.
13
+ */
14
+
15
+ import { useEffect, useRef, useState, useCallback } from "react";
16
+ import { useAuth } from "../hooks/useAuth";
17
+ import { apiUrl } from "../utils/api";
18
+
19
+ const POLL_MS = 2000;
20
+ const LEVEL_COLORS = {
21
+ DEBUG: "#6b6b78",
22
+ INFO: "#8fbbe8",
23
+ WARNING: "#e7bd70",
24
+ ERROR: "#ff8b8b",
25
+ CRITICAL: "#ff6b6b",
26
+ };
27
+
28
+ export default function LogTerminal({ onClose }) {
29
+ const { token } = useAuth();
30
+ const [lines, setLines] = useState([]);
31
+ const [cursor, setCursor] = useState(0);
32
+ const [error, setError] = useState(null);
33
+ const [paused, setPaused] = useState(false);
34
+ const bodyRef = useRef(null);
35
+ const pausedRef = useRef(false);
36
+
37
+ const fetchNew = useCallback(async () => {
38
+ try {
39
+ const res = await fetch(apiUrl(`/api/logs?since=${cursor}`), {
40
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
41
+ });
42
+ if (!res.ok) throw new Error(`Log fetch failed (${res.status})`);
43
+ const data = await res.json();
44
+ if (data.lines?.length) {
45
+ setLines((previous) => [...previous.slice(-2000), ...data.lines]);
46
+ }
47
+ if (typeof data.next === "number") setCursor(data.next);
48
+ setError(null);
49
+ } catch (err) {
50
+ setError(err.message);
51
+ }
52
+ }, [cursor, token]);
53
+
54
+ useEffect(() => {
55
+ fetchNew();
56
+ const timer = setInterval(fetchNew, POLL_MS);
57
+ return () => clearInterval(timer);
58
+ }, [fetchNew]);
59
+
60
+ useEffect(() => {
61
+ const el = bodyRef.current;
62
+ if (el && !pausedRef.current) el.scrollTop = el.scrollHeight;
63
+ }, [lines]);
64
+
65
+ async function clearLogs() {
66
+ try {
67
+ const res = await fetch(apiUrl("/api/logs/clear"), {
68
+ method: "POST",
69
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
70
+ });
71
+ if (!res.ok) throw new Error(`Clear failed (${res.status})`);
72
+ setLines([]);
73
+ } catch (err) {
74
+ setError(err.message);
75
+ }
76
+ }
77
+
78
+ async function copyLogs() {
79
+ try {
80
+ await navigator.clipboard.writeText(lines.map((line) => line.text).join("\n"));
81
+ } catch {
82
+ /* clipboard unavailable β€” ignore */
83
+ }
84
+ }
85
+
86
+ const headerButton = {
87
+ background: "rgba(91,155,213,.08)",
88
+ border: "1px solid rgba(91,155,213,.30)",
89
+ borderRadius: 3,
90
+ color: "#8fbbe8",
91
+ padding: "2px 6px",
92
+ fontFamily: "'Space Mono', monospace",
93
+ fontSize: 8,
94
+ cursor: "pointer",
95
+ };
96
+
97
+ return (
98
+ <aside
99
+ style={{
100
+ position: "fixed",
101
+ right: 16,
102
+ top: 66,
103
+ bottom: 96,
104
+ width: 460,
105
+ maxWidth: "calc(100vw - 32px)",
106
+ display: "flex",
107
+ flexDirection: "column",
108
+ background: "rgba(8,10,12,0.95)",
109
+ backdropFilter: "blur(8px)",
110
+ border: "1px solid rgba(91,155,213,0.30)",
111
+ borderRadius: 6,
112
+ boxShadow: "0 14px 44px rgba(0,0,0,0.6)",
113
+ fontFamily: "'Space Mono', monospace",
114
+ zIndex: 1300,
115
+ }}
116
+ >
117
+ <header
118
+ style={{
119
+ display: "flex",
120
+ alignItems: "center",
121
+ gap: 8,
122
+ padding: "8px 12px",
123
+ borderBottom: "1px solid rgba(255,255,255,0.07)",
124
+ }}
125
+ >
126
+ <span style={{ color: "#8fbbe8", fontWeight: 700, fontSize: 10, letterSpacing: "0.14em" }}>
127
+ LOG TERMINAL
128
+ </span>
129
+ <span style={{ fontSize: 9, color: "#6b6b78" }}>{lines.length} lines</span>
130
+ <span style={{ flex: 1 }} />
131
+ <button onClick={copyLogs} style={headerButton} title="Copy all relayed lines to the clipboard">
132
+ COPY
133
+ </button>
134
+ <button onClick={clearLogs} style={headerButton} title="Clear the in-memory relay buffer">
135
+ CLEAR
136
+ </button>
137
+ <button onClick={onClose} style={headerButton} title="Close the log terminal">
138
+ X
139
+ </button>
140
+ </header>
141
+
142
+ <div
143
+ ref={bodyRef}
144
+ onMouseEnter={() => {
145
+ pausedRef.current = true;
146
+ setPaused(true);
147
+ }}
148
+ onMouseLeave={() => {
149
+ pausedRef.current = false;
150
+ setPaused(false);
151
+ }}
152
+ style={{
153
+ flex: 1,
154
+ overflowY: "auto",
155
+ padding: "10px 12px",
156
+ fontSize: 9,
157
+ lineHeight: 1.65,
158
+ }}
159
+ >
160
+ {lines.length === 0 && (
161
+ <div style={{ color: "#6b6b78" }}>no lines yet β€” the relay captures log output once the simulation starts</div>
162
+ )}
163
+ {lines.map((entry) => (
164
+ <div
165
+ key={entry.seq}
166
+ style={{
167
+ color: LEVEL_COLORS[entry.level] || "#d0d0da",
168
+ whiteSpace: "pre-wrap",
169
+ wordBreak: "break-word",
170
+ }}
171
+ >
172
+ {entry.text}
173
+ </div>
174
+ ))}
175
+ </div>
176
+
177
+ <footer
178
+ style={{
179
+ padding: "6px 12px",
180
+ borderTop: "1px solid rgba(255,255,255,0.07)",
181
+ fontSize: 9,
182
+ color: error ? "#ff8b8b" : paused ? "#e7bd70" : "#6b6b78",
183
+ }}
184
+ >
185
+ {error ? `ERROR: ${error}` : paused ? "PAUSED β€” move the pointer away to resume auto-scroll" : "TAILING β€” 2s poll"}
186
+ </footer>
187
+ </aside>
188
+ );
189
+ }