agentic-dev-loop 0.2.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.
- agentic_dev_loop/__init__.py +1 -0
- agentic_dev_loop/actions.py +189 -0
- agentic_dev_loop/agents.py +49 -0
- agentic_dev_loop/cli.py +82 -0
- agentic_dev_loop/config.py +156 -0
- agentic_dev_loop/extensions/__init__.py +0 -0
- agentic_dev_loop/extensions/aidlc.py +152 -0
- agentic_dev_loop/graph.py +275 -0
- agentic_dev_loop/llm.py +60 -0
- agentic_dev_loop/loaders.py +60 -0
- agentic_dev_loop/outputs.py +147 -0
- agentic_dev_loop/proc.py +54 -0
- agentic_dev_loop/py.typed +0 -0
- agentic_dev_loop/registry.py +133 -0
- agentic_dev_loop/results.py +41 -0
- agentic_dev_loop/stages.py +423 -0
- agentic_dev_loop/viz.py +1148 -0
- agentic_dev_loop-0.2.0.dist-info/METADATA +316 -0
- agentic_dev_loop-0.2.0.dist-info/RECORD +23 -0
- agentic_dev_loop-0.2.0.dist-info/WHEEL +5 -0
- agentic_dev_loop-0.2.0.dist-info/entry_points.txt +2 -0
- agentic_dev_loop-0.2.0.dist-info/licenses/LICENSE +201 -0
- agentic_dev_loop-0.2.0.dist-info/top_level.txt +1 -0
agentic_dev_loop/viz.py
ADDED
|
@@ -0,0 +1,1148 @@
|
|
|
1
|
+
"""Live graph visualization: StreamPart translation, a thread-safe event
|
|
2
|
+
bus, a config-driven topology renderer, and a stdlib-only HTTP/SSE server
|
|
3
|
+
serving an embedded Mermaid page.
|
|
4
|
+
|
|
5
|
+
The graph itself is untouched — every live update is derived from LangGraph's
|
|
6
|
+
``tasks``/``updates`` stream modes. The viz surface is served by default
|
|
7
|
+
(suppressed with ``--no-viz``); the
|
|
8
|
+
server is stdlib-only and the page loads Mermaid.js v11 from a CDN. The
|
|
9
|
+
topology is rendered by LangChain's own ``graph_mermaid`` renderer (a core
|
|
10
|
+
dependency already pulled in by LangGraph).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import errno
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import queue
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from collections import deque
|
|
23
|
+
from collections.abc import Iterable
|
|
24
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
25
|
+
|
|
26
|
+
from langchain_core.runnables.graph import Edge
|
|
27
|
+
from langchain_core.runnables.graph_mermaid import draw_mermaid
|
|
28
|
+
|
|
29
|
+
from .config import PipelineConfig, Route, route_target
|
|
30
|
+
|
|
31
|
+
STATE_WHITELIST = ("attempt", "step_verdicts", "outcome", "failed")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def topology_mermaid(cfg: PipelineConfig) -> tuple[str, list[str]]:
|
|
35
|
+
"""Build a single vertical flowchart for the configured steps.
|
|
36
|
+
|
|
37
|
+
Returns ``(mermaid_source, node_ids)``. The happy path is one solid
|
|
38
|
+
``-->`` chain following each step's ``on_pass``; retries render as dotted
|
|
39
|
+
``-. label .->`` loop-backs, and a non-retry ``on_fail`` as an unlabeled
|
|
40
|
+
dotted edge. LangChain's renderer is reused with ``nodes={}`` so nodes
|
|
41
|
+
auto-render as plain rectangles (no stadium declarations, no terminals).
|
|
42
|
+
"""
|
|
43
|
+
chain: list[Edge] = []
|
|
44
|
+
back_edges: list[Edge] = []
|
|
45
|
+
referenced: set[str] = set()
|
|
46
|
+
for step in cfg.steps:
|
|
47
|
+
target = route_target(step.on_pass)
|
|
48
|
+
if target is not None:
|
|
49
|
+
chain.append(Edge(step.name, target))
|
|
50
|
+
referenced.add(target)
|
|
51
|
+
if isinstance(step.on_fail, Route) and step.on_fail.retry:
|
|
52
|
+
if step.verdicts is not None:
|
|
53
|
+
label = step.verdicts.fail
|
|
54
|
+
elif step.command is not None:
|
|
55
|
+
label = "fail"
|
|
56
|
+
else:
|
|
57
|
+
label = "retry"
|
|
58
|
+
back_edges.append(Edge(step.name, step.on_fail.goto, data=label,
|
|
59
|
+
conditional=True))
|
|
60
|
+
referenced.add(step.on_fail.goto)
|
|
61
|
+
else:
|
|
62
|
+
target = route_target(step.on_fail)
|
|
63
|
+
if target is not None:
|
|
64
|
+
back_edges.append(Edge(step.name, target, conditional=True))
|
|
65
|
+
referenced.add(target)
|
|
66
|
+
node_ids = [step.name for step in cfg.steps]
|
|
67
|
+
for terminal in ("success", "failure"):
|
|
68
|
+
if terminal in referenced:
|
|
69
|
+
node_ids.append(terminal)
|
|
70
|
+
source = draw_mermaid(nodes={}, edges=chain + back_edges, first_node=None,
|
|
71
|
+
last_node=None, with_styles=False)
|
|
72
|
+
return source, node_ids
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def translate(part: dict) -> list[dict]:
|
|
76
|
+
"""Translate one v2 StreamPart into zero or more viz event dicts.
|
|
77
|
+
|
|
78
|
+
``tasks`` parts pair up by id: a part whose data carries ``triggers`` is a
|
|
79
|
+
node start; one carrying ``error`` is the matching finish (``error`` is
|
|
80
|
+
``None`` on success). ``updates`` parts carry one LoopState delta per node
|
|
81
|
+
and collapse to a single ``state`` event holding only whitelisted keys —
|
|
82
|
+
``docs``/``context``/``notes`` are large or internal and never shipped.
|
|
83
|
+
"""
|
|
84
|
+
ptype = part.get("type")
|
|
85
|
+
data = part.get("data")
|
|
86
|
+
if not isinstance(data, dict):
|
|
87
|
+
return []
|
|
88
|
+
if ptype == "tasks":
|
|
89
|
+
if "triggers" in data and "name" in data:
|
|
90
|
+
return [{"type": "node_started", "node": data["name"]}]
|
|
91
|
+
if "error" in data and "name" in data:
|
|
92
|
+
return [{"type": "node_finished", "node": data["name"],
|
|
93
|
+
"ok": data["error"] is None}]
|
|
94
|
+
return []
|
|
95
|
+
if ptype == "updates":
|
|
96
|
+
state: dict = {}
|
|
97
|
+
for delta in data.values():
|
|
98
|
+
if not isinstance(delta, dict):
|
|
99
|
+
continue
|
|
100
|
+
for key in STATE_WHITELIST:
|
|
101
|
+
if key in delta:
|
|
102
|
+
state[key] = delta[key]
|
|
103
|
+
return [{"type": "state", **state}]
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class VizBus:
|
|
108
|
+
"""Thread-safe pub/sub with bounded backlogs.
|
|
109
|
+
|
|
110
|
+
Nodes publish from the pipeline thread; each SSE connection consumes from
|
|
111
|
+
its own thread. New subscribers first receive a replay of retained
|
|
112
|
+
(lifecycle) and console events merged in publish order, so a browser tab
|
|
113
|
+
opened mid-run catches up without console volume evicting lifecycle
|
|
114
|
+
events. Heartbeats are delivered live but never replayed.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, maxlen: int = 500, console_maxlen: int = 500) -> None:
|
|
118
|
+
self._subscribers: set[queue.Queue] = set()
|
|
119
|
+
self._lock = threading.Lock()
|
|
120
|
+
self._backlog: deque = deque(maxlen=maxlen)
|
|
121
|
+
self._console_backlog: deque = deque(maxlen=console_maxlen)
|
|
122
|
+
self._seq = 0
|
|
123
|
+
|
|
124
|
+
def publish(self, event: dict) -> None:
|
|
125
|
+
with self._lock:
|
|
126
|
+
self._seq += 1
|
|
127
|
+
seq = self._seq
|
|
128
|
+
etype = event.get("type")
|
|
129
|
+
if etype == "console":
|
|
130
|
+
self._console_backlog.append((seq, event))
|
|
131
|
+
elif etype != "heartbeat":
|
|
132
|
+
self._backlog.append((seq, event))
|
|
133
|
+
subscribers = list(self._subscribers)
|
|
134
|
+
for sub in subscribers:
|
|
135
|
+
sub.put(event)
|
|
136
|
+
|
|
137
|
+
def subscribe(self) -> queue.Queue:
|
|
138
|
+
sub: queue.Queue = queue.Queue()
|
|
139
|
+
with self._lock:
|
|
140
|
+
merged = sorted(
|
|
141
|
+
list(self._backlog) + list(self._console_backlog),
|
|
142
|
+
key=lambda item: item[0])
|
|
143
|
+
for _seq, event in merged:
|
|
144
|
+
sub.put(event)
|
|
145
|
+
self._subscribers.add(sub)
|
|
146
|
+
return sub
|
|
147
|
+
|
|
148
|
+
def unsubscribe(self, sub: queue.Queue) -> None:
|
|
149
|
+
with self._lock:
|
|
150
|
+
self._subscribers.discard(sub)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
MAX_LINE = 8000 # cap per captured console line — SDK state dumps are huge
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class _Tee:
|
|
157
|
+
"""Write-through proxy for one stream. Forwards every write to ``target``
|
|
158
|
+
unchanged, buffers to line boundaries, and publishes each complete line
|
|
159
|
+
(plus any trailing partial line on detach) as a console event attributed
|
|
160
|
+
to the node NodeWatch currently reports. ANSI escapes pass through
|
|
161
|
+
verbatim — colour parsing is the frontend's job."""
|
|
162
|
+
|
|
163
|
+
def __init__(self, capture: "ConsoleCapture", stream: str, target) -> None:
|
|
164
|
+
self._capture = capture
|
|
165
|
+
self._stream = stream
|
|
166
|
+
self._target = target
|
|
167
|
+
self._buffer = ""
|
|
168
|
+
self._lock = threading.Lock()
|
|
169
|
+
|
|
170
|
+
def write(self, s) -> int:
|
|
171
|
+
if isinstance(s, bytes):
|
|
172
|
+
s = s.decode("utf-8", "replace")
|
|
173
|
+
with self._lock:
|
|
174
|
+
self._target.write(s)
|
|
175
|
+
self._buffer += s
|
|
176
|
+
self._flush_lines()
|
|
177
|
+
return len(s)
|
|
178
|
+
|
|
179
|
+
def _flush_lines(self) -> None:
|
|
180
|
+
while "\n" in self._buffer:
|
|
181
|
+
line, self._buffer = self._buffer.split("\n", 1)
|
|
182
|
+
self._capture._publish(self._stream, line.rstrip("\r"))
|
|
183
|
+
|
|
184
|
+
def _flush_partial(self) -> None:
|
|
185
|
+
with self._lock:
|
|
186
|
+
if self._buffer:
|
|
187
|
+
self._capture._publish(self._stream, self._buffer.rstrip("\r"))
|
|
188
|
+
self._buffer = ""
|
|
189
|
+
|
|
190
|
+
def flush(self) -> None:
|
|
191
|
+
self._target.flush()
|
|
192
|
+
|
|
193
|
+
def isatty(self) -> bool:
|
|
194
|
+
return self._target.isatty()
|
|
195
|
+
|
|
196
|
+
def __getattr__(self, name):
|
|
197
|
+
return getattr(self._target, name)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class ConsoleCapture:
|
|
201
|
+
"""Tees sys.stdout/sys.stderr into the viz bus, line by line, attributed
|
|
202
|
+
to the node NodeWatch currently reports (None when idle).
|
|
203
|
+
|
|
204
|
+
While attached, ``COLUMNS`` is forced to ``MAX_LINE`` so that rich-based
|
|
205
|
+
writers (e.g. the SDK's conversation visualizer) do not hard-wrap output
|
|
206
|
+
at a terminal-width fallback: under the capture, stdout is a pipe, so
|
|
207
|
+
rich would otherwise bake 80-column newlines into the console stream that
|
|
208
|
+
the browser's ``pre-wrap`` cannot rejoin. The previous value is restored
|
|
209
|
+
on detach.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(self, bus: VizBus, watch: NodeWatch) -> None:
|
|
213
|
+
self._bus = bus
|
|
214
|
+
self._watch = watch
|
|
215
|
+
self._tees: dict[str, _Tee] | None = None
|
|
216
|
+
self._old_columns: str | None = None
|
|
217
|
+
|
|
218
|
+
def attach(self) -> None:
|
|
219
|
+
if self._tees is not None:
|
|
220
|
+
raise RuntimeError("ConsoleCapture is already attached")
|
|
221
|
+
self._old_columns = os.environ.get("COLUMNS")
|
|
222
|
+
os.environ["COLUMNS"] = str(MAX_LINE)
|
|
223
|
+
self._tees = {
|
|
224
|
+
"stdout": _Tee(self, "stdout", sys.stdout),
|
|
225
|
+
"stderr": _Tee(self, "stderr", sys.stderr),
|
|
226
|
+
}
|
|
227
|
+
sys.stdout = self._tees["stdout"]
|
|
228
|
+
sys.stderr = self._tees["stderr"]
|
|
229
|
+
|
|
230
|
+
def detach(self) -> None:
|
|
231
|
+
if self._tees is None:
|
|
232
|
+
return
|
|
233
|
+
for stream, tee in self._tees.items():
|
|
234
|
+
tee._flush_partial()
|
|
235
|
+
tee.flush()
|
|
236
|
+
setattr(sys, stream, tee._target)
|
|
237
|
+
self._tees = None
|
|
238
|
+
if self._old_columns is None:
|
|
239
|
+
os.environ.pop("COLUMNS", None)
|
|
240
|
+
else:
|
|
241
|
+
os.environ["COLUMNS"] = self._old_columns
|
|
242
|
+
|
|
243
|
+
def _publish(self, stream: str, line: str) -> None:
|
|
244
|
+
if len(line) > MAX_LINE:
|
|
245
|
+
line = line[:MAX_LINE] + "…"
|
|
246
|
+
self._bus.publish({
|
|
247
|
+
"type": "console",
|
|
248
|
+
"node": self._watch.current(),
|
|
249
|
+
"stream": stream,
|
|
250
|
+
"text": line,
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class NodeWatch:
|
|
255
|
+
"""Current running node + start time.
|
|
256
|
+
|
|
257
|
+
Written by the stream-consumer thread as it publishes ``node_started`` /
|
|
258
|
+
``node_finished`` events; read by the heartbeat thread. ``start`` and
|
|
259
|
+
``finish`` are lock-protected; ``finish`` clears only its own node so a
|
|
260
|
+
stale finish can never clobber a newer running node.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
def __init__(self) -> None:
|
|
264
|
+
self._lock = threading.Lock()
|
|
265
|
+
self._node: str | None = None
|
|
266
|
+
self._started: float | None = None
|
|
267
|
+
|
|
268
|
+
def start(self, node: str) -> None:
|
|
269
|
+
with self._lock:
|
|
270
|
+
self._node = node
|
|
271
|
+
self._started = time.monotonic()
|
|
272
|
+
|
|
273
|
+
def finish(self, node: str) -> None:
|
|
274
|
+
with self._lock:
|
|
275
|
+
if self._node == node:
|
|
276
|
+
self._node = None
|
|
277
|
+
self._started = None
|
|
278
|
+
|
|
279
|
+
def current(self) -> str | None:
|
|
280
|
+
with self._lock:
|
|
281
|
+
return self._node
|
|
282
|
+
|
|
283
|
+
def elapsed_seconds(self) -> int | None:
|
|
284
|
+
with self._lock:
|
|
285
|
+
if self._started is None:
|
|
286
|
+
return None
|
|
287
|
+
return int(time.monotonic() - self._started)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class HeartbeatThread(threading.Thread):
|
|
291
|
+
"""Daemon thread publishing a liveness heartbeat while a node runs."""
|
|
292
|
+
|
|
293
|
+
def __init__(self, watch: NodeWatch, bus: VizBus,
|
|
294
|
+
interval: float = 2.0) -> None:
|
|
295
|
+
super().__init__(daemon=True)
|
|
296
|
+
self._watch = watch
|
|
297
|
+
self._bus = bus
|
|
298
|
+
self._interval = interval
|
|
299
|
+
self._stop_event = threading.Event()
|
|
300
|
+
|
|
301
|
+
def stop(self) -> None:
|
|
302
|
+
self._stop_event.set()
|
|
303
|
+
|
|
304
|
+
def run(self) -> None:
|
|
305
|
+
while not self._stop_event.is_set():
|
|
306
|
+
node = self._watch.current()
|
|
307
|
+
if node is not None:
|
|
308
|
+
self._bus.publish({
|
|
309
|
+
"type": "heartbeat",
|
|
310
|
+
"node": node,
|
|
311
|
+
"elapsed_seconds": self._watch.elapsed_seconds(),
|
|
312
|
+
})
|
|
313
|
+
self._stop_event.wait(self._interval)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _handler_class(bus: VizBus, topology_mermaid: str,
|
|
317
|
+
subject: str, nodes: Iterable[str]) -> type[BaseHTTPRequestHandler]:
|
|
318
|
+
class Handler(BaseHTTPRequestHandler):
|
|
319
|
+
protocol_version = "HTTP/1.1"
|
|
320
|
+
viz_bus = bus
|
|
321
|
+
viz_topology = topology_mermaid
|
|
322
|
+
viz_subject = subject
|
|
323
|
+
viz_nodes = list(nodes)
|
|
324
|
+
|
|
325
|
+
def log_message(self, *_args) -> None: # keep test output quiet
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
def handle_one_request(self) -> None:
|
|
329
|
+
try:
|
|
330
|
+
super().handle_one_request()
|
|
331
|
+
except (ConnectionResetError, BrokenPipeError):
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
def do_GET(self) -> None:
|
|
335
|
+
if self.path == "/":
|
|
336
|
+
self._send(200, "text/html; charset=utf-8", PAGE)
|
|
337
|
+
elif self.path == "/topology":
|
|
338
|
+
body = json.dumps({"mermaid": self.viz_topology,
|
|
339
|
+
"subject": self.viz_subject,
|
|
340
|
+
"nodes": self.viz_nodes})
|
|
341
|
+
self._send(200, "application/json", body)
|
|
342
|
+
elif self.path == "/events":
|
|
343
|
+
self._events()
|
|
344
|
+
else:
|
|
345
|
+
self.send_error(404)
|
|
346
|
+
|
|
347
|
+
def _send(self, status: int, content_type: str, body: str) -> None:
|
|
348
|
+
data = body.encode("utf-8")
|
|
349
|
+
self.send_response(status)
|
|
350
|
+
self.send_header("Content-Type", content_type)
|
|
351
|
+
self.send_header("Content-Length", str(len(data)))
|
|
352
|
+
self.end_headers()
|
|
353
|
+
self.wfile.write(data)
|
|
354
|
+
|
|
355
|
+
def _events(self) -> None:
|
|
356
|
+
self.send_response(200)
|
|
357
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
358
|
+
self.send_header("Cache-Control", "no-cache")
|
|
359
|
+
self.send_header("Connection", "keep-alive")
|
|
360
|
+
self.end_headers()
|
|
361
|
+
sub = self.viz_bus.subscribe()
|
|
362
|
+
try:
|
|
363
|
+
while True:
|
|
364
|
+
try:
|
|
365
|
+
event = sub.get(timeout=15)
|
|
366
|
+
except queue.Empty:
|
|
367
|
+
self.wfile.write(b":hb\n\n")
|
|
368
|
+
self.wfile.flush()
|
|
369
|
+
continue
|
|
370
|
+
frame = f"data: {json.dumps(event)}\n\n".encode("utf-8")
|
|
371
|
+
self.wfile.write(frame)
|
|
372
|
+
self.wfile.flush()
|
|
373
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
374
|
+
pass
|
|
375
|
+
finally:
|
|
376
|
+
self.viz_bus.unsubscribe(sub)
|
|
377
|
+
|
|
378
|
+
return Handler
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def serve_viz(bus: VizBus, topology_mermaid: str, subject: str,
|
|
382
|
+
port: int, nodes: Iterable[str] = ()) -> tuple[ThreadingHTTPServer, threading.Thread]:
|
|
383
|
+
"""Bind a loopback HTTP server on ``port`` and serve it on a daemon
|
|
384
|
+
thread. Returns ``(httpd, thread)`` — the caller reads the real bound
|
|
385
|
+
port from ``httpd.server_address[1]``.
|
|
386
|
+
|
|
387
|
+
``port`` is preferred, not fixed: when it is already in use
|
|
388
|
+
(``errno.EADDRINUSE``) the server rebinds port 0 and lets the OS pick a
|
|
389
|
+
free port. Any other bind error (e.g. ``EACCES`` on a privileged port)
|
|
390
|
+
still fails loud, naming ``--viz-port``."""
|
|
391
|
+
handler = _handler_class(bus, topology_mermaid, subject, nodes)
|
|
392
|
+
try:
|
|
393
|
+
httpd = ThreadingHTTPServer(("127.0.0.1", port), handler)
|
|
394
|
+
except OSError as exc:
|
|
395
|
+
if exc.errno == errno.EADDRINUSE and port != 0:
|
|
396
|
+
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
397
|
+
else:
|
|
398
|
+
raise OSError(
|
|
399
|
+
f"Could not bind the viz server to 127.0.0.1:{port} — the "
|
|
400
|
+
f"--viz-port may already be in use. ({exc})") from exc
|
|
401
|
+
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
402
|
+
thread.start()
|
|
403
|
+
return httpd, thread
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
PAGE = """<!doctype html>
|
|
407
|
+
<html lang="en">
|
|
408
|
+
<head>
|
|
409
|
+
<meta charset="utf-8">
|
|
410
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
411
|
+
<title>agentic-dev-loop viz</title>
|
|
412
|
+
<style>
|
|
413
|
+
:root { color-scheme: light; }
|
|
414
|
+
* { box-sizing: border-box; }
|
|
415
|
+
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
416
|
+
Roboto, Helvetica, Arial, sans-serif; display: flex; height: 100vh; }
|
|
417
|
+
#graph-panel { flex: 1 1 auto; padding: 16px; overflow: auto; display: flex;
|
|
418
|
+
flex-direction: column; }
|
|
419
|
+
#mermaid-container { flex: 1 1 auto; }
|
|
420
|
+
#mermaid-container svg { max-width: 100%; height: auto; }
|
|
421
|
+
.running > * { animation: pulse 1.2s ease-in-out infinite; }
|
|
422
|
+
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
|
|
423
|
+
#run-banner { font-size: 18px; font-weight: 700; padding: 10px 14px;
|
|
424
|
+
border-radius: 6px; margin-top: 10px; display: none; }
|
|
425
|
+
.banner-success { background: #e8f5e9; color: #2e7d32; }
|
|
426
|
+
.banner-failure { background: #ffebee; color: #c62828; }
|
|
427
|
+
#divider { flex: 0 0 6px; cursor: col-resize; background: #e0e0e0; }
|
|
428
|
+
#divider:hover, #divider.dragging { background: #9e9e9e; }
|
|
429
|
+
#sidebar { flex: 0 0 320px; border-left: 1px solid #e0e0e0; padding: 16px;
|
|
430
|
+
display: flex; flex-direction: column; gap: 12px; overflow: hidden; }
|
|
431
|
+
#subject { margin: 0; font-size: 16px; word-break: break-word; }
|
|
432
|
+
#attempt { font-size: 14px; color: #555; }
|
|
433
|
+
#thread { font-size: 14px; color: #555; }
|
|
434
|
+
#thread code { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
435
|
+
background: #f5f5f5; padding: 2px 6px; border-radius: 4px;
|
|
436
|
+
cursor: pointer; user-select: all; }
|
|
437
|
+
#elapsed { font-size: 14px; color: #1976d2; display: none; }
|
|
438
|
+
#verdicts { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
439
|
+
.badge { font-size: 12px; padding: 3px 8px; border-radius: 10px;
|
|
440
|
+
background: #f0f0f0; color: #333; }
|
|
441
|
+
.badge-pass { background: #e8f5e9; color: #2e7d32; }
|
|
442
|
+
.badge-fail { background: #ffebee; color: #c62828; }
|
|
443
|
+
.badge-unclear { background: #fff8e1; color: #f57f17; }
|
|
444
|
+
#stages { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex;
|
|
445
|
+
flex-direction: column; gap: 8px; }
|
|
446
|
+
.stage { border: 1px solid #e0e0e0; border-radius: 6px; overflow: hidden; }
|
|
447
|
+
.stage-header { display: block; width: 100%; text-align: left; cursor: pointer;
|
|
448
|
+
font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
449
|
+
Roboto, Helvetica, Arial, sans-serif; padding: 6px 10px;
|
|
450
|
+
border: 0; background: #f7f7f7; }
|
|
451
|
+
.stage-glyph { display: inline-block; width: 1.1em; }
|
|
452
|
+
.stage-status { margin-left: 0.4em; }
|
|
453
|
+
.status-pending { color: #555; }
|
|
454
|
+
.status-running { color: #1976d2; }
|
|
455
|
+
.status-pass { color: #2e7d32; }
|
|
456
|
+
.status-fail { color: #b26a00; }
|
|
457
|
+
.status-fatal { color: #c62828; }
|
|
458
|
+
.console-wrap { max-height: 240px; overflow-y: auto; background: #1e1e1e; }
|
|
459
|
+
.stage-console { margin: 0; padding: 8px 10px; background: #1e1e1e;
|
|
460
|
+
color: #e0e0e0; font: 12px/1.5 ui-monospace, SFMono-Regular,
|
|
461
|
+
Menlo, monospace; white-space: pre-wrap; word-break: break-all; }
|
|
462
|
+
.stage-console .cline { display: block; min-height: 1em; }
|
|
463
|
+
.stage-console .omitted { color: #888; font-style: italic; }
|
|
464
|
+
.stage-console .stage-placeholder { color: #888; font-style: italic; }
|
|
465
|
+
/* float puts the button in the wrap's content box, which the scrollbar
|
|
466
|
+
sits outside of — the scrollbar can never cover it; sticky keeps it
|
|
467
|
+
pinned to the top-right corner while the console scrolls */
|
|
468
|
+
.copy-btn { position: sticky; top: 6px; float: right; display: flex;
|
|
469
|
+
align-items: center; justify-content: center; width: 26px;
|
|
470
|
+
height: 26px; margin: 6px 6px 0 0; padding: 0; border: 0;
|
|
471
|
+
border-radius: 4px; cursor: pointer; background: #333333;
|
|
472
|
+
color: #f2f2f2; }
|
|
473
|
+
.copy-btn:hover { background: #4d4d4d; color: #ffffff; }
|
|
474
|
+
.copy-btn:focus-visible { outline: 2px solid #61d6d6; outline-offset: 1px; }
|
|
475
|
+
.copy-btn svg { display: block; }
|
|
476
|
+
.stage.collapsed .console-wrap { display: none; }
|
|
477
|
+
#run-log { list-style: none; margin: 0; padding: 8px 0 0; flex: 0 0 auto;
|
|
478
|
+
max-height: 25%; overflow-y: auto; border-top: 1px solid #e0e0e0;
|
|
479
|
+
font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
480
|
+
#run-log li { padding: 2px 0; border-bottom: 1px solid #f3f3f3; }
|
|
481
|
+
@media (max-width: 768px) {
|
|
482
|
+
body { flex-direction: column; height: auto; }
|
|
483
|
+
#divider { display: none; }
|
|
484
|
+
#sidebar { flex: 0 0 auto; border-left: 0; border-top: 1px solid #e0e0e0; }
|
|
485
|
+
}
|
|
486
|
+
</style>
|
|
487
|
+
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"
|
|
488
|
+
onerror="window.__mermaidFailed=true"></script>
|
|
489
|
+
</head>
|
|
490
|
+
<body>
|
|
491
|
+
<div id="graph-panel">
|
|
492
|
+
<div id="mermaid-container"><p>Loading graph…</p></div>
|
|
493
|
+
<div id="run-banner"></div>
|
|
494
|
+
</div>
|
|
495
|
+
<div id="divider"></div>
|
|
496
|
+
<aside id="sidebar">
|
|
497
|
+
<h2 id="subject">agentic-dev-loop pipeline</h2>
|
|
498
|
+
<div id="attempt">attempt ?/?</div>
|
|
499
|
+
<div id="thread">thread id: <code id="thread-id" title="click to select">\u2014</code></div>
|
|
500
|
+
<div id="elapsed"></div>
|
|
501
|
+
<div id="verdicts"></div>
|
|
502
|
+
<div id="stages"></div>
|
|
503
|
+
<ul id="run-log"></ul>
|
|
504
|
+
</aside>
|
|
505
|
+
<script>
|
|
506
|
+
(function () {
|
|
507
|
+
var nodeStatus = {};
|
|
508
|
+
var stageWord = {};
|
|
509
|
+
var verdictSteps = new Set();
|
|
510
|
+
var nodeIds = new Set();
|
|
511
|
+
var currentVerdicts = {};
|
|
512
|
+
var verdictFail = new Set();
|
|
513
|
+
var verdictPass = new Set();
|
|
514
|
+
var fatalNodes = new Set();
|
|
515
|
+
var maxAttempts = null;
|
|
516
|
+
var attempt = null;
|
|
517
|
+
var mermaidSource = null;
|
|
518
|
+
var renderCount = 0;
|
|
519
|
+
var mermaidFailed = !!window.__mermaidFailed;
|
|
520
|
+
var pendingCompletion = null;
|
|
521
|
+
var topologyReady = false;
|
|
522
|
+
var pendingEvents = [];
|
|
523
|
+
var MAX_CONSOLE = 500;
|
|
524
|
+
var PALETTE = ["#0c0c0c", "#c50f1f", "#13a10e", "#c19c00", "#0037da",
|
|
525
|
+
"#881798", "#3a96dd", "#cccccc"];
|
|
526
|
+
var BRIGHT = ["#767676", "#e74856", "#16c60c", "#f9f1a5", "#3b78ff",
|
|
527
|
+
"#b4009e", "#61d6d6", "#f2f2f2"];
|
|
528
|
+
var COPY_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="14"'
|
|
529
|
+
+ ' height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"'
|
|
530
|
+
+ ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'
|
|
531
|
+
+ ' aria-hidden="true"><rect x="9" y="9" width="13" height="13"'
|
|
532
|
+
+ ' rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1'
|
|
533
|
+
+ ' 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
|
|
534
|
+
var CHECK_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="14"'
|
|
535
|
+
+ ' height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"'
|
|
536
|
+
+ ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'
|
|
537
|
+
+ ' aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>';
|
|
538
|
+
|
|
539
|
+
function el(id) { return document.getElementById(id); }
|
|
540
|
+
|
|
541
|
+
function logLine(text) {
|
|
542
|
+
var li = document.createElement("li");
|
|
543
|
+
li.textContent = text;
|
|
544
|
+
var log = el("run-log");
|
|
545
|
+
log.appendChild(li);
|
|
546
|
+
log.scrollTop = log.scrollHeight;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function logConsole(html) {
|
|
550
|
+
var li = document.createElement("li");
|
|
551
|
+
li.innerHTML = html;
|
|
552
|
+
var log = el("run-log");
|
|
553
|
+
log.appendChild(li);
|
|
554
|
+
log.scrollTop = log.scrollHeight;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function updateAttempt() {
|
|
558
|
+
el("attempt").textContent = attempt === null
|
|
559
|
+
? "attempt ?/" + (maxAttempts === null ? "?" : maxAttempts)
|
|
560
|
+
: "attempt " + attempt + "/" + (maxAttempts === null ? "?" : maxAttempts);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function mmss(totalSeconds) {
|
|
564
|
+
var s = totalSeconds % 60;
|
|
565
|
+
var m = Math.floor(totalSeconds / 60);
|
|
566
|
+
return m + ":" + (s < 10 ? "0" : "") + s;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function clearElapsed() {
|
|
570
|
+
var line = el("elapsed");
|
|
571
|
+
line.textContent = "";
|
|
572
|
+
line.style.display = "none";
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function statusOf(n) {
|
|
576
|
+
if (nodeStatus[n] === "running") { return "running"; }
|
|
577
|
+
if (fatalNodes.has(n)) { return "fatal"; }
|
|
578
|
+
if (nodeStatus[n] === "fatal") { return "fatal"; }
|
|
579
|
+
if (verdictFail.has(n)) { return "fail"; }
|
|
580
|
+
if (verdictPass.has(n)) { return "pass"; }
|
|
581
|
+
return nodeStatus[n] || "pending";
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function updateBadges() {
|
|
585
|
+
var box = el("verdicts");
|
|
586
|
+
box.innerHTML = "";
|
|
587
|
+
Array.from(verdictSteps).forEach(function (step) {
|
|
588
|
+
var b = document.createElement("span");
|
|
589
|
+
var v = currentVerdicts[step];
|
|
590
|
+
b.className = "badge";
|
|
591
|
+
if (v === "pass") { b.className += " badge-pass"; }
|
|
592
|
+
else if (v === "fail") { b.className += " badge-fail"; }
|
|
593
|
+
else { b.className += " badge-unclear"; }
|
|
594
|
+
b.textContent = step + ": " + (v === null || v === undefined ? "no verdict" : v);
|
|
595
|
+
box.appendChild(b);
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function resetVerdicts() {
|
|
600
|
+
verdictSteps.clear();
|
|
601
|
+
verdictPass.clear();
|
|
602
|
+
verdictFail.clear();
|
|
603
|
+
currentVerdicts = {};
|
|
604
|
+
nodeStatus = {};
|
|
605
|
+
stageWord = {};
|
|
606
|
+
pendingCompletion = null;
|
|
607
|
+
updateBadges();
|
|
608
|
+
Array.from(nodeIds).forEach(function (n) { renderStageHeader(n); });
|
|
609
|
+
renderGraph();
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function renderGraph() {
|
|
613
|
+
var container = el("mermaid-container");
|
|
614
|
+
if (mermaidFailed || typeof mermaid === "undefined" || !mermaidSource) {
|
|
615
|
+
container.innerHTML = "<p>Graph unavailable (mermaid CDN unreachable) "
|
|
616
|
+
+ "\u2014 the event log below is still live.</p>";
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
var classDefs = [
|
|
620
|
+
"classDef running fill:#1976d2,stroke:#1976d2,color:#fff",
|
|
621
|
+
"classDef pass fill:#2e7d32,stroke:#2e7d32,color:#fff",
|
|
622
|
+
"classDef fail fill:#ffb300,stroke:#ffb300,color:#fff",
|
|
623
|
+
"classDef fatal fill:#c62828,stroke:#c62828,color:#fff",
|
|
624
|
+
"classDef pending fill:#f2f0ff,stroke:#555,color:#000"
|
|
625
|
+
];
|
|
626
|
+
var classLines = Object.keys(nodeStatus).filter(function (n) {
|
|
627
|
+
return nodeIds.has(n);
|
|
628
|
+
}).map(function (n) {
|
|
629
|
+
return "class " + n + " " + statusOf(n) + ";";
|
|
630
|
+
});
|
|
631
|
+
var src = mermaidSource + "\\n" + classDefs.join("\\n") + "\\n" + classLines.join("\\n");
|
|
632
|
+
mermaid.render("graph-" + (renderCount++), src).then(function (res) {
|
|
633
|
+
container.innerHTML = res.svg;
|
|
634
|
+
if (res.bindFunctions) { res.bindFunctions(container); }
|
|
635
|
+
}).catch(function (err) {
|
|
636
|
+
container.innerHTML = "<p>Graph render failed.</p>";
|
|
637
|
+
logLine("graph render failed: " + err);
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function addPlaceholder(pre) {
|
|
642
|
+
var ph = document.createElement("div");
|
|
643
|
+
ph.className = "stage-placeholder";
|
|
644
|
+
ph.textContent = "No output";
|
|
645
|
+
pre.appendChild(ph);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function ensureSection(node) {
|
|
649
|
+
var existing = document.getElementById("stage-" + node);
|
|
650
|
+
if (existing) { return existing; }
|
|
651
|
+
var stages = el("stages");
|
|
652
|
+
var section = document.createElement("section");
|
|
653
|
+
section.className = "stage collapsed";
|
|
654
|
+
section.id = "stage-" + node;
|
|
655
|
+
|
|
656
|
+
var header = document.createElement("button");
|
|
657
|
+
header.className = "stage-header";
|
|
658
|
+
header.type = "button";
|
|
659
|
+
header.addEventListener("click", function () {
|
|
660
|
+
var willCollapse = !section.classList.contains("collapsed");
|
|
661
|
+
section.classList.toggle("collapsed");
|
|
662
|
+
renderStageHeader(node);
|
|
663
|
+
if (!willCollapse) {
|
|
664
|
+
requestAnimationFrame(function () { tailSection(section); });
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
var glyph = document.createElement("span");
|
|
669
|
+
glyph.className = "stage-glyph";
|
|
670
|
+
var name = document.createElement("span");
|
|
671
|
+
name.className = "stage-name";
|
|
672
|
+
var status = document.createElement("span");
|
|
673
|
+
status.className = "stage-status";
|
|
674
|
+
|
|
675
|
+
header.appendChild(glyph);
|
|
676
|
+
header.appendChild(name);
|
|
677
|
+
header.appendChild(status);
|
|
678
|
+
|
|
679
|
+
var console = document.createElement("pre");
|
|
680
|
+
console.className = "stage-console";
|
|
681
|
+
addPlaceholder(console);
|
|
682
|
+
|
|
683
|
+
var wrap = document.createElement("div");
|
|
684
|
+
wrap.className = "console-wrap";
|
|
685
|
+
var copyBtn = document.createElement("button");
|
|
686
|
+
copyBtn.className = "copy-btn";
|
|
687
|
+
copyBtn.type = "button";
|
|
688
|
+
copyBtn.setAttribute("title", "Copy output");
|
|
689
|
+
copyBtn.setAttribute("aria-label", "Copy " + node + " console output");
|
|
690
|
+
copyBtn.innerHTML = COPY_ICON;
|
|
691
|
+
copyBtn.addEventListener("click", function () {
|
|
692
|
+
copyConsole(console, copyBtn);
|
|
693
|
+
});
|
|
694
|
+
wrap.appendChild(copyBtn);
|
|
695
|
+
wrap.appendChild(console);
|
|
696
|
+
|
|
697
|
+
section.appendChild(header);
|
|
698
|
+
section.appendChild(wrap);
|
|
699
|
+
stages.appendChild(section);
|
|
700
|
+
renderStageHeader(node);
|
|
701
|
+
return section;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function renderStageHeader(node) {
|
|
705
|
+
var section = ensureSection(node);
|
|
706
|
+
var header = section.querySelector(".stage-header");
|
|
707
|
+
var glyph = header.querySelector(".stage-glyph");
|
|
708
|
+
var name = header.querySelector(".stage-name");
|
|
709
|
+
var status = header.querySelector(".stage-status");
|
|
710
|
+
var collapsed = section.classList.contains("collapsed");
|
|
711
|
+
glyph.textContent = collapsed ? "\u25B8" : "\u25BE";
|
|
712
|
+
name.textContent = node;
|
|
713
|
+
var word = stageWord[node] || "";
|
|
714
|
+
var suffix = "";
|
|
715
|
+
if (word === "completed") {
|
|
716
|
+
var v = currentVerdicts[node];
|
|
717
|
+
if (v === "pass") { suffix = " (verdict: PASS)"; }
|
|
718
|
+
else if (v === "fail") { suffix = " (verdict: FAIL)"; }
|
|
719
|
+
}
|
|
720
|
+
status.textContent = (word ? " " + word : "") + suffix;
|
|
721
|
+
header.className = "stage-header status-" + statusOf(node);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function setStageStatus(node, word) {
|
|
725
|
+
stageWord[node] = word;
|
|
726
|
+
renderStageHeader(node);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function flushPending() {
|
|
730
|
+
if (pendingCompletion === null) { return; }
|
|
731
|
+
var p = pendingCompletion;
|
|
732
|
+
pendingCompletion = null;
|
|
733
|
+
setStageStatus(p.node, p.word);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function expandStage(node) {
|
|
737
|
+
var section = ensureSection(node);
|
|
738
|
+
section.classList.remove("collapsed");
|
|
739
|
+
renderStageHeader(node);
|
|
740
|
+
section.scrollIntoView({ block: "nearest" });
|
|
741
|
+
requestAnimationFrame(function () { tailSection(section); });
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function collapseStage(node) {
|
|
745
|
+
ensureSection(node).classList.add("collapsed");
|
|
746
|
+
renderStageHeader(node);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function collapseAll() {
|
|
750
|
+
Array.from(nodeIds).forEach(function (n) {
|
|
751
|
+
ensureSection(n).classList.add("collapsed");
|
|
752
|
+
renderStageHeader(n);
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function clearConsoles() {
|
|
757
|
+
stageWord = {};
|
|
758
|
+
Array.from(nodeIds).forEach(function (n) {
|
|
759
|
+
var section = ensureSection(n);
|
|
760
|
+
var pre = consoleOf(section);
|
|
761
|
+
pre.innerHTML = "";
|
|
762
|
+
addPlaceholder(pre);
|
|
763
|
+
section.classList.add("collapsed");
|
|
764
|
+
renderStageHeader(n);
|
|
765
|
+
});
|
|
766
|
+
el("run-log").innerHTML = "";
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function consoleOf(section) {
|
|
770
|
+
return section.querySelector(".console-wrap")
|
|
771
|
+
.querySelector(".stage-console");
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function scrollPortOf(section) {
|
|
775
|
+
return section.querySelector(".console-wrap");
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function copyConsole(pre, btn) {
|
|
779
|
+
var lines = Array.prototype.map.call(pre.querySelectorAll(".cline"),
|
|
780
|
+
function (c) { return c.textContent; });
|
|
781
|
+
if (lines.length === 0) {
|
|
782
|
+
btn.setAttribute("title", "Nothing to copy");
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
var text = lines.join("\\n");
|
|
786
|
+
function done(ok) {
|
|
787
|
+
btn.innerHTML = ok ? CHECK_ICON : COPY_ICON;
|
|
788
|
+
btn.setAttribute("title", ok ? "Copied!" : "Copy failed");
|
|
789
|
+
if (ok) {
|
|
790
|
+
setTimeout(function () {
|
|
791
|
+
btn.innerHTML = COPY_ICON;
|
|
792
|
+
btn.setAttribute("title", "Copy output");
|
|
793
|
+
}, 1500);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
797
|
+
navigator.clipboard.writeText(text).then(function () { done(true); },
|
|
798
|
+
function () { done(false); });
|
|
799
|
+
} else {
|
|
800
|
+
// clipboard API unavailable (e.g. plain-http on a non-localhost host)
|
|
801
|
+
var ta = document.createElement("textarea");
|
|
802
|
+
ta.value = text;
|
|
803
|
+
ta.style.position = "fixed";
|
|
804
|
+
ta.style.top = "-1000px";
|
|
805
|
+
document.body.appendChild(ta);
|
|
806
|
+
ta.select();
|
|
807
|
+
var ok = false;
|
|
808
|
+
try { ok = document.execCommand("copy"); } catch (err) { ok = false; }
|
|
809
|
+
document.body.removeChild(ta);
|
|
810
|
+
done(ok);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function nearBottom(pre) {
|
|
815
|
+
return pre.scrollHeight - pre.scrollTop - pre.clientHeight < 24;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function tailSection(section) {
|
|
819
|
+
if (section.classList.contains("collapsed")) { return; }
|
|
820
|
+
var sp = scrollPortOf(section);
|
|
821
|
+
sp.scrollTop = sp.scrollHeight;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function appendConsole(section, html) {
|
|
825
|
+
var pre = consoleOf(section);
|
|
826
|
+
var sp = scrollPortOf(section);
|
|
827
|
+
var stick = !section.classList.contains("collapsed") && nearBottom(sp);
|
|
828
|
+
var ph = pre.querySelector(".stage-placeholder");
|
|
829
|
+
if (ph) { pre.removeChild(ph); }
|
|
830
|
+
if (pre.querySelectorAll(".cline").length >= MAX_CONSOLE) {
|
|
831
|
+
var first = pre.querySelector(".cline");
|
|
832
|
+
if (first) { pre.removeChild(first); }
|
|
833
|
+
var omitted = pre.querySelector(".omitted");
|
|
834
|
+
if (!omitted) {
|
|
835
|
+
omitted = document.createElement("div");
|
|
836
|
+
omitted.className = "omitted";
|
|
837
|
+
pre.insertBefore(omitted, pre.firstChild);
|
|
838
|
+
}
|
|
839
|
+
var count = parseInt(omitted.getAttribute("data-n") || "0", 10) + 1;
|
|
840
|
+
omitted.setAttribute("data-n", String(count));
|
|
841
|
+
omitted.textContent = "\u2026" + count + " line"
|
|
842
|
+
+ (count === 1 ? "" : "s") + " omitted";
|
|
843
|
+
}
|
|
844
|
+
var line = document.createElement("span");
|
|
845
|
+
line.className = "cline";
|
|
846
|
+
line.innerHTML = html;
|
|
847
|
+
pre.appendChild(line);
|
|
848
|
+
if (stick) { sp.scrollTop = sp.scrollHeight; }
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function handleConsole(ev) {
|
|
852
|
+
var html = ansiToHtml(ev.text);
|
|
853
|
+
if (ev.node && nodeIds.has(ev.node)) {
|
|
854
|
+
appendConsole(ensureSection(ev.node), html);
|
|
855
|
+
} else {
|
|
856
|
+
logConsole(html);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function ansiToHtml(text) {
|
|
861
|
+
var out = "";
|
|
862
|
+
var i = 0;
|
|
863
|
+
var ESC = "\\x1b";
|
|
864
|
+
var cur = { fg: null, bg: null, bold: false, italic: false, underline: false };
|
|
865
|
+
var open = false;
|
|
866
|
+
|
|
867
|
+
function currentStyle() {
|
|
868
|
+
var parts = [];
|
|
869
|
+
if (cur.bold) { parts.push("font-weight:bold"); }
|
|
870
|
+
if (cur.italic) { parts.push("font-style:italic"); }
|
|
871
|
+
if (cur.underline) { parts.push("text-decoration:underline"); }
|
|
872
|
+
if (cur.fg !== null) { parts.push("color:" + cur.fg); }
|
|
873
|
+
if (cur.bg !== null) { parts.push("background-color:" + cur.bg); }
|
|
874
|
+
return parts.join(";");
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function closeSpan() {
|
|
878
|
+
if (open) { out += "</span>"; open = false; }
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function pushSpan() {
|
|
882
|
+
closeSpan();
|
|
883
|
+
var s = currentStyle();
|
|
884
|
+
if (s) { out += '<span style="' + s + '">'; open = true; }
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
var html = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
888
|
+
var n = html.length;
|
|
889
|
+
while (i < n) {
|
|
890
|
+
var idx = html.indexOf(ESC, i);
|
|
891
|
+
if (idx === -1) {
|
|
892
|
+
out += html.slice(i);
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
out += html.slice(i, idx);
|
|
896
|
+
var j = idx + 1;
|
|
897
|
+
if (j >= n) { i = idx + 1; continue; }
|
|
898
|
+
var ch = html.charAt(j);
|
|
899
|
+
if (ch === "[") {
|
|
900
|
+
var k = j + 1;
|
|
901
|
+
while (k < n) {
|
|
902
|
+
var cc = html.charCodeAt(k);
|
|
903
|
+
if (cc >= 0x40 && cc <= 0x7e) { break; }
|
|
904
|
+
k++;
|
|
905
|
+
}
|
|
906
|
+
if (k >= n) { i = n; break; }
|
|
907
|
+
var final = html.charAt(k);
|
|
908
|
+
var params = html.slice(j + 1, k);
|
|
909
|
+
if (final === "m") {
|
|
910
|
+
var parts = params.split(";");
|
|
911
|
+
var codes = [];
|
|
912
|
+
for (var p = 0; p < parts.length; p++) {
|
|
913
|
+
codes.push(parts[p] === "" ? 0 : parseInt(parts[p], 10));
|
|
914
|
+
}
|
|
915
|
+
var changed = false;
|
|
916
|
+
for (var c = 0; c < codes.length; c++) {
|
|
917
|
+
var code = codes[c];
|
|
918
|
+
if (code === 0) {
|
|
919
|
+
cur = { fg: null, bg: null, bold: false, italic: false, underline: false };
|
|
920
|
+
changed = true;
|
|
921
|
+
} else if (code === 1) { cur.bold = true; changed = true; }
|
|
922
|
+
else if (code === 22) { cur.bold = false; changed = true; }
|
|
923
|
+
else if (code === 3) { cur.italic = true; changed = true; }
|
|
924
|
+
else if (code === 23) { cur.italic = false; changed = true; }
|
|
925
|
+
else if (code === 4) { cur.underline = true; changed = true; }
|
|
926
|
+
else if (code === 24) { cur.underline = false; changed = true; }
|
|
927
|
+
else if (code >= 30 && code <= 37) { cur.fg = PALETTE[code - 30]; changed = true; }
|
|
928
|
+
else if (code >= 90 && code <= 97) { cur.fg = BRIGHT[code - 90]; changed = true; }
|
|
929
|
+
else if (code >= 40 && code <= 47) { cur.bg = PALETTE[code - 40]; changed = true; }
|
|
930
|
+
else if (code >= 100 && code <= 107) { cur.bg = BRIGHT[code - 100]; changed = true; }
|
|
931
|
+
else if (code === 39) { cur.fg = null; changed = true; }
|
|
932
|
+
else if (code === 49) { cur.bg = null; changed = true; }
|
|
933
|
+
else if (code === 38 || code === 48) {
|
|
934
|
+
// extended colour: 38;5;n (256-colour) or 38;2;r;g;b (truecolor)
|
|
935
|
+
if (c + 2 < codes.length && codes[c + 1] === 5) {
|
|
936
|
+
var val = codes[c + 2];
|
|
937
|
+
var col;
|
|
938
|
+
if (val < 8) { col = PALETTE[val]; }
|
|
939
|
+
else if (val < 16) { col = BRIGHT[val - 8]; }
|
|
940
|
+
else if (val < 232) {
|
|
941
|
+
var cube = val - 16;
|
|
942
|
+
var levels = [0, 95, 135, 175, 215, 255];
|
|
943
|
+
col = "rgb(" + levels[Math.floor(cube / 36)] + ","
|
|
944
|
+
+ levels[Math.floor((cube % 36) / 6)] + ","
|
|
945
|
+
+ levels[cube % 6] + ")";
|
|
946
|
+
} else {
|
|
947
|
+
var g = 8 + 10 * (val - 232);
|
|
948
|
+
col = "rgb(" + g + "," + g + "," + g + ")";
|
|
949
|
+
}
|
|
950
|
+
if (code === 38) { cur.fg = col; } else { cur.bg = col; }
|
|
951
|
+
c += 2;
|
|
952
|
+
changed = true;
|
|
953
|
+
} else if (c + 4 < codes.length && codes[c + 1] === 2) {
|
|
954
|
+
var rgb = "rgb(" + codes[c + 2] + "," + codes[c + 3] + "," + codes[c + 4] + ")";
|
|
955
|
+
if (code === 38) { cur.fg = rgb; } else { cur.bg = rgb; }
|
|
956
|
+
c += 4;
|
|
957
|
+
changed = true;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
if (changed) { pushSpan(); }
|
|
962
|
+
}
|
|
963
|
+
i = k + 1;
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
if (ch === "]") {
|
|
967
|
+
var bel = html.indexOf(String.fromCharCode(7), j);
|
|
968
|
+
var st = html.indexOf(ESC + String.fromCharCode(92), j);
|
|
969
|
+
var end = -1;
|
|
970
|
+
if (bel !== -1) { end = bel + 1; }
|
|
971
|
+
if (st !== -1 && (end === -1 || st + 2 < end)) { end = st + 2; }
|
|
972
|
+
if (end === -1) { i = n; break; }
|
|
973
|
+
i = end;
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
i = idx + 1;
|
|
977
|
+
}
|
|
978
|
+
closeSpan();
|
|
979
|
+
return out;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function handle(ev) {
|
|
983
|
+
switch (ev.type) {
|
|
984
|
+
case "run_started":
|
|
985
|
+
flushPending();
|
|
986
|
+
clearConsoles();
|
|
987
|
+
resetVerdicts();
|
|
988
|
+
if (ev.attempt !== undefined) {
|
|
989
|
+
attempt = ev.attempt;
|
|
990
|
+
}
|
|
991
|
+
maxAttempts = ev.max_attempts;
|
|
992
|
+
el("subject").textContent = ev.subject;
|
|
993
|
+
if (ev.thread_id !== undefined) {
|
|
994
|
+
el("thread-id").textContent = ev.thread_id;
|
|
995
|
+
}
|
|
996
|
+
logLine("run started: " + ev.subject + " (thread " + ev.thread_id + ")");
|
|
997
|
+
updateAttempt();
|
|
998
|
+
break;
|
|
999
|
+
case "node_started":
|
|
1000
|
+
flushPending();
|
|
1001
|
+
if (!nodeIds.has(ev.node)) { break; }
|
|
1002
|
+
nodeStatus[ev.node] = "running";
|
|
1003
|
+
stageWord[ev.node] = "running";
|
|
1004
|
+
Array.from(nodeIds).forEach(function (n) {
|
|
1005
|
+
if (n === ev.node) { expandStage(n); } else { collapseStage(n); }
|
|
1006
|
+
});
|
|
1007
|
+
renderGraph();
|
|
1008
|
+
break;
|
|
1009
|
+
case "node_finished":
|
|
1010
|
+
clearElapsed();
|
|
1011
|
+
if (!nodeIds.has(ev.node)) { break; }
|
|
1012
|
+
if (!fatalNodes.has(ev.node)) {
|
|
1013
|
+
nodeStatus[ev.node] = ev.ok ? "pass" : "fail";
|
|
1014
|
+
}
|
|
1015
|
+
pendingCompletion = {
|
|
1016
|
+
node: ev.node,
|
|
1017
|
+
word: ev.ok ? "completed" : "errored"
|
|
1018
|
+
};
|
|
1019
|
+
setStageStatus(ev.node, ev.ok ? "completed" : "errored");
|
|
1020
|
+
collapseStage(ev.node);
|
|
1021
|
+
renderGraph();
|
|
1022
|
+
break;
|
|
1023
|
+
case "heartbeat":
|
|
1024
|
+
if (nodeStatus[ev.node] === "running") {
|
|
1025
|
+
el("elapsed").textContent = ev.node + " running \u2014 " + mmss(ev.elapsed_seconds) + " elapsed";
|
|
1026
|
+
el("elapsed").style.display = "block";
|
|
1027
|
+
}
|
|
1028
|
+
break;
|
|
1029
|
+
case "state":
|
|
1030
|
+
if (ev.attempt !== undefined) { attempt = ev.attempt; }
|
|
1031
|
+
if (ev.step_verdicts !== undefined) {
|
|
1032
|
+
Object.keys(ev.step_verdicts).forEach(function (k) {
|
|
1033
|
+
verdictSteps.add(k);
|
|
1034
|
+
var v = ev.step_verdicts[k];
|
|
1035
|
+
if (v === "pass") { verdictPass.add(k); verdictFail.delete(k); }
|
|
1036
|
+
else if (v === "fail") { verdictFail.add(k); verdictPass.delete(k); }
|
|
1037
|
+
else { verdictPass.delete(k); verdictFail.delete(k); }
|
|
1038
|
+
currentVerdicts[k] = v;
|
|
1039
|
+
});
|
|
1040
|
+
updateBadges();
|
|
1041
|
+
if (pendingCompletion !== null
|
|
1042
|
+
&& (pendingCompletion.node in ev.step_verdicts)) {
|
|
1043
|
+
flushPending();
|
|
1044
|
+
}
|
|
1045
|
+
renderGraph();
|
|
1046
|
+
}
|
|
1047
|
+
if (ev.failed) {
|
|
1048
|
+
Object.keys(nodeStatus).forEach(function (n) {
|
|
1049
|
+
if (nodeStatus[n] === "running") { fatalNodes.add(n); }
|
|
1050
|
+
});
|
|
1051
|
+
logLine("state: failed");
|
|
1052
|
+
renderGraph();
|
|
1053
|
+
}
|
|
1054
|
+
updateAttempt();
|
|
1055
|
+
break;
|
|
1056
|
+
case "run_finished":
|
|
1057
|
+
clearElapsed();
|
|
1058
|
+
flushPending();
|
|
1059
|
+
if (!ev.success) {
|
|
1060
|
+
Object.keys(nodeStatus).forEach(function (n) {
|
|
1061
|
+
var s = statusOf(n);
|
|
1062
|
+
if (s === "fail" || s === "running") { nodeStatus[n] = "fatal"; }
|
|
1063
|
+
});
|
|
1064
|
+
renderGraph();
|
|
1065
|
+
}
|
|
1066
|
+
collapseAll();
|
|
1067
|
+
logLine("run finished: " + (ev.success ? "success" : "failure"));
|
|
1068
|
+
var banner = el("run-banner");
|
|
1069
|
+
banner.textContent = ev.success ? "\u2713 SUCCESS" : "\u2718 FAILURE";
|
|
1070
|
+
banner.className = ev.success ? "banner-success" : "banner-failure";
|
|
1071
|
+
banner.style.display = "block";
|
|
1072
|
+
break;
|
|
1073
|
+
case "console":
|
|
1074
|
+
handleConsole(ev);
|
|
1075
|
+
break;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
fetch("/topology").then(function (r) { return r.json(); }).then(function (d) {
|
|
1080
|
+
el("subject").textContent = d.subject;
|
|
1081
|
+
mermaidSource = d.mermaid;
|
|
1082
|
+
(d.nodes || []).forEach(function (n) {
|
|
1083
|
+
nodeIds.add(n);
|
|
1084
|
+
ensureSection(n);
|
|
1085
|
+
});
|
|
1086
|
+
if (!mermaidFailed && typeof mermaid !== "undefined") {
|
|
1087
|
+
mermaid.initialize({ startOnLoad: false, securityLevel: "loose" });
|
|
1088
|
+
}
|
|
1089
|
+
topologyReady = true;
|
|
1090
|
+
pendingEvents.forEach(handle);
|
|
1091
|
+
pendingEvents = [];
|
|
1092
|
+
renderGraph();
|
|
1093
|
+
}).catch(function () {
|
|
1094
|
+
logLine("failed to load topology");
|
|
1095
|
+
topologyReady = true;
|
|
1096
|
+
pendingEvents.forEach(handle);
|
|
1097
|
+
pendingEvents = [];
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
setTimeout(function () {
|
|
1101
|
+
if (!topologyReady) {
|
|
1102
|
+
topologyReady = true;
|
|
1103
|
+
logLine("topology load timed out \u2014 event log only");
|
|
1104
|
+
pendingEvents.forEach(handle);
|
|
1105
|
+
pendingEvents = [];
|
|
1106
|
+
}
|
|
1107
|
+
}, 5000);
|
|
1108
|
+
|
|
1109
|
+
var es = new EventSource("/events");
|
|
1110
|
+
es.onmessage = function (e) {
|
|
1111
|
+
var ev;
|
|
1112
|
+
try { ev = JSON.parse(e.data); }
|
|
1113
|
+
catch (err) { logLine("bad event: " + e.data); return; }
|
|
1114
|
+
if (!topologyReady) { pendingEvents.push(ev); return; }
|
|
1115
|
+
try { handle(ev); }
|
|
1116
|
+
catch (err) { logLine("bad event: " + e.data); }
|
|
1117
|
+
};
|
|
1118
|
+
es.onerror = function () { logLine("event stream interrupted"); };
|
|
1119
|
+
|
|
1120
|
+
// Grab-handle between the graph and the sidebar: drag to resize the
|
|
1121
|
+
// sidebar (and with it the console panels, which stretch with its width).
|
|
1122
|
+
(function initDivider() {
|
|
1123
|
+
var divider = el("divider");
|
|
1124
|
+
var sidebar = el("sidebar");
|
|
1125
|
+
var dragging = false;
|
|
1126
|
+
divider.addEventListener("mousedown", function (e) {
|
|
1127
|
+
dragging = true;
|
|
1128
|
+
divider.classList.add("dragging");
|
|
1129
|
+
document.body.style.userSelect = "none";
|
|
1130
|
+
e.preventDefault();
|
|
1131
|
+
});
|
|
1132
|
+
window.addEventListener("mousemove", function (e) {
|
|
1133
|
+
if (!dragging) { return; }
|
|
1134
|
+
var width = window.innerWidth - e.clientX - divider.offsetWidth / 2;
|
|
1135
|
+
width = Math.max(240, Math.min(width, window.innerWidth * 0.8));
|
|
1136
|
+
sidebar.style.flex = "0 0 " + width + "px";
|
|
1137
|
+
});
|
|
1138
|
+
window.addEventListener("mouseup", function () {
|
|
1139
|
+
dragging = false;
|
|
1140
|
+
divider.classList.remove("dragging");
|
|
1141
|
+
document.body.style.userSelect = "";
|
|
1142
|
+
});
|
|
1143
|
+
})();
|
|
1144
|
+
})();
|
|
1145
|
+
</script>
|
|
1146
|
+
</body>
|
|
1147
|
+
</html>
|
|
1148
|
+
"""
|