From 32624b505aa169a847eeb774a4f86dce6845a936 Mon Sep 17 00:00:00 2001 From: John Chandara Date: Wed, 8 Jul 2026 17:09:08 -0700 Subject: [PATCH 1/4] fix(debugviewer): stop SVG flashing on auto-refresh; keep live run tracked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header ● live auto-refresh (and ↻ refresh) redrew the whole graph every 3s unconditionally — svg.innerHTML='' + a full async ELK relayout — even when the compiled graph was unchanged, which flashed the SVG. The same path also ran stopStream(), so an attached live run stopped being tracked each cycle. - Skip the redraw when the graph is unchanged: graphSig() signs nodes/edges/ phases (deterministic for unchanged YAML) and refresh() only redraws on a real change, via the new non-destructive redrawCurrentWorkflow(). - refresh no longer tears down the SSE stream, so a live session stays tracked while auto-refresh is on (workflow switches still reset via selectWorkflow). - Preserve run highlights across a legitimate redraw: RUN_VIS records each state's active/done class and applyRunVis() re-lights nodes after draw(). --- tools/debugviewer/static/viewer.html | 37 ++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/tools/debugviewer/static/viewer.html b/tools/debugviewer/static/viewer.html index 7fd2ae0..cc51e3b 100644 --- a/tools/debugviewer/static/viewer.html +++ b/tools/debugviewer/static/viewer.html @@ -294,6 +294,8 @@

◆ dispatch / wor const svgNS = "http://www.w3.org/2000/svg"; let SEL_WF = null, SEL_NODE = null, GRAPH = null, autoTimer = null; let RUNNABLE = [], NODE_BY_STATE = {}, ES = null, CUR_RUN = null; +let RUN_VIS = {}; // state_id -> "active"|"done-ok"|"done-err": run highlights, survive a redraw +let LAST_GRAPH_SIG = null; // signature of the last-drawn graph, so an unchanged refresh skips the (flashing) redraw let ATTR_OPEN = false; // inspector "attributes" fold state — collapsed by default, preserved across node clicks let PROBE_LIVE = false; // probe mock↔live mode — preserved across node clicks; live calls the real model // The declarative shelf-key contract for the selected workflow (graph.schemas): @@ -461,8 +463,22 @@

◆ dispatch / wor g.addEventListener("click", () => { selectNode(n); }); svg.appendChild(g); }); + applyRunVis(); // re-light any active/done states after a redraw + LAST_GRAPH_SIG = graphSig(graph); } function txt(s){ return document.createTextNode(s); } +// A stable signature of what the graph DRAWS (nodes/edges/phases). The compiled +// graph is deterministic for unchanged YAML, so an equal signature means a redraw +// would produce the identical SVG — the refresh path skips it, killing the flash. +function graphSig(g){ return JSON.stringify({ n: g.nodes, e: g.edges, p: g.phases }); } +// Re-apply the current run's node highlights to the freshly built nodes, so a +// redraw (when the YAML really did change) doesn't drop the live tracking state. +function applyRunVis(){ + for (const state in RUN_VIS) { + const g = NODE_BY_STATE[state]; + if (g) { g.classList.remove("active","done-ok","done-err"); g.classList.add(RUN_VIS[state]); } + } +} // Update selection in place: swap the .sel class and refresh the inspector, without // re-running the (async) layout — clicking a node must not reflow the whole graph. @@ -774,7 +790,7 @@

◆ dispatch / wor async function selectWorkflow(name) { SEL_WF = name; SEL_NODE = null; - stopStream(); CUR_RUN = null; setStatus("idle", "idle"); + stopStream(); CUR_RUN = null; RUN_VIS = {}; setStatus("idle", "idle"); el("wfsel").value = name; document.querySelectorAll(".wf").forEach(d => d.classList.toggle("sel", d.querySelector(".n").textContent===name)); el("insp").innerHTML = `
Click a state to inspect it.
`; @@ -786,7 +802,21 @@

◆ dispatch / wor async function refresh() { await loadWorkflowList(); - if (SEL_WF) await selectWorkflow(SEL_WF); + if (SEL_WF) await redrawCurrentWorkflow(); +} + +// Recompile the CURRENT workflow from YAML and redraw ONLY if the graph actually +// changed — without tearing down an in-flight run. This is both the manual "↻ refresh" +// and the "● live" auto-refresh path. Unchanged YAML => no redraw (no flashing SVG); +// and unlike selectWorkflow it never stops the SSE stream or clears run visuals, so a +// live session keeps being tracked while auto-refresh is on. +async function redrawCurrentWorkflow() { + const g = await (await fetch("/api/workflows/" + encodeURIComponent(SEL_WF))).json(); + if (g.error) return; + const svg = el("svg"); + if (svg.childElementCount && graphSig(g) === LAST_GRAPH_SIG) { updateRunButton(); return; } + await draw(g); // graph genuinely changed (YAML edited) — redraw, preserving run highlights + updateRunButton(); } function renderLegend() { @@ -818,6 +848,7 @@

◆ dispatch / wor while (box.childElementCount > 400) box.removeChild(box.firstChild); } function clearRunVisuals() { + RUN_VIS = {}; document.querySelectorAll(".node.active,.node.done-ok,.node.done-err") .forEach(g => g.classList.remove("active","done-ok","done-err")); } @@ -885,11 +916,13 @@

◆ dispatch / wor function handleRunEvent(e) { const t = (e.t!=null) ? `${e.t.toFixed(2)}s ` : ""; if (e.type === "enter") { + RUN_VIS[e.state] = "active"; const g = NODE_BY_STATE[e.state]; if (g) { g.classList.add("active"); g.classList.remove("done-ok","done-err"); } if (e.kind === "leaf" || e.kind === "loop") logEvent(`${t}→ ${shortState(e.state)}`); } else if (e.type === "leave") { + RUN_VIS[e.state] = e.ok ? "done-ok" : "done-err"; const g = NODE_BY_STATE[e.state]; if (g) { g.classList.remove("active"); g.classList.add(e.ok ? "done-ok" : "done-err"); } } else if (e.type === "event") { From cf9f805c3a13e83c177646a10cc8036615e18077 Mon Sep 17 00:00:00 2001 From: John Chandara Date: Wed, 8 Jul 2026 17:21:32 -0700 Subject: [PATCH 2/4] refactor(engine): hoist live-trace observer into BaseWorkflow.run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live-trace sidecar (what makes a run watchable in the debug viewer) was wired only in baseworkflow.run_live, so websitewf runs — a BaseWorkflow subclass with its own run_live/_run — wrote no sidecar and were invisible to the viewer. Wiring it per engine is engine plumbing leaking into consumer overlays. Attach the observer once in BaseWorkflow.run (inherited by every engine): when tracing is enabled () and the caller supplied no observer, attach a FileObserver labelled by the workflow's YAML stem (WORKFLOW_PATH -> baseworkflow/websitewf, matching the viewer's graph names) and finish only the observer we created. The in-process BusObserver path is untouched (guarded by ctx.observer is None; the viewer finishes its own). - Remove the now-redundant wiring from baseworkflow.run_live. - Revert the copy from websitewf._run — it inherits BaseWorkflow.run. Verified: baseworkflow + websitewf run_mock each emit a correctly-labelled sidecar with a terminal status; the viewer picks up websitewf runs like baseworkflow ones. --- baseworkflow/baseworkflow.py | 55 +++++++++++++++++++++++------------- websitewf/websitewf.py | 2 ++ 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/baseworkflow/baseworkflow.py b/baseworkflow/baseworkflow.py index 6e28051..450dc7d 100644 --- a/baseworkflow/baseworkflow.py +++ b/baseworkflow/baseworkflow.py @@ -163,11 +163,43 @@ def context(self, **kw: Any) -> Context: kw.setdefault("services", self._services) return super().context(**kw) + def _trace_name(self) -> str: + """Label for the live-trace sidecar: the workflow's YAML stem, which is the + name the debug viewer lists a workflow under — so an attached run maps onto + the right graph. A subclass engine gets the right label for free via its + overridden ``WORKFLOW_PATH`` (``workflows/websitewf.yml`` → ``websitewf``).""" + return os.path.splitext(os.path.basename(self.WORKFLOW_PATH))[0] + def run(self, payload: Any = None, ctx: Optional[Context] = None, *, until: str = ""): if ctx is None: ctx = self.context() self._seed(ctx.shelves) - return super().run(payload, ctx=ctx, until=until) + # Auto-attach the live-trace sidecar observer HERE, in the engine base, so + # every workflow built on BaseWorkflow — baseworkflow, websitewf, any future + # overlay — is watchable in the debug viewer with zero per-engine wiring (a + # consumer overlay must not have to know sidecars exist). Opt-in via + # $DISPATCH_LIVE_TRACE[_DIR]; passive telemetry — it can never change the + # outcome. We never clobber a caller-supplied observer (the in-process viewer + # sets ctx.observer itself and finishes it itself) — we only own, and finish, + # an observer we create here. + own_observer = None + if getattr(ctx, "observer", None) is None: + try: + from foundation import trace + if trace.tracing_enabled(): + own_observer = trace.new_observer(self._trace_name()) + ctx.observer = own_observer + except Exception: # noqa: BLE001 — tracing is best-effort telemetry + own_observer = None + try: + result = super().run(payload, ctx=ctx, until=until) + except BaseException: + if own_observer is not None: + own_observer.finish("errored") + raise + if own_observer is not None: + own_observer.finish("succeeded" if getattr(result, "ok", False) else "failed") + return result def run_mock( @@ -246,23 +278,10 @@ def run_live( svc = services if services is not None else bw_services.build_services() wf = BaseWorkflow(factory, job=job, triage=triage, request=request, services=svc) ctx = wf.context(dry_run=dry_run) - - # Opt-in live-trace sidecar so an out-of-process watcher (the debug viewer) can - # follow this run. Off unless $DISPATCH_LIVE_TRACE[_DIR] is set; never overrides a - # caller-supplied observer; passive (an observer cannot change the run's outcome). - observer = getattr(ctx, "observer", None) - if observer is None: - try: - from foundation import trace - if trace.tracing_enabled(): - observer = trace.new_observer("baseworkflow") - ctx.observer = observer - except Exception: # noqa: BLE001 — tracing is best-effort telemetry - observer = None + # Live-trace sidecar wiring now lives in BaseWorkflow.run (shared by every engine), + # so there is nothing to do here beyond running and tearing down the shelf root. try: result = wf.run(ctx=ctx) - if observer is not None: - observer.finish("succeeded" if result.ok else "failed") return { "result": result, "ctx": ctx, @@ -270,9 +289,5 @@ def run_live( "deliverables": wf.shelves.deliverables.snapshot(), "interpreter": wf.last_interpreter, } - except BaseException: - if observer is not None: - observer.finish("errored") - raise finally: shutil.rmtree(shelf_root, ignore_errors=True) diff --git a/websitewf/websitewf.py b/websitewf/websitewf.py index 3188939..f0d58a0 100644 --- a/websitewf/websitewf.py +++ b/websitewf/websitewf.py @@ -88,6 +88,8 @@ def _run( ) -> Dict[str, Any]: wf = WebsiteWF(factory, job=job, triage=triage, request=request, services=services) ctx = wf.context(dry_run=dry_run) + # Live-trace sidecar wiring is inherited from BaseWorkflow.run — nothing to do + # here, so a websitewf overlay never has to know sidecars exist. result = wf.run(ctx=ctx) return { "result": result, From 8b26629f345f584c3ba40e9a84e534f017a84d5a Mon Sep 17 00:00:00 2001 From: John Chandara Date: Wed, 8 Jul 2026 17:39:23 -0700 Subject: [PATCH 3/4] =?UTF-8?q?fix(pipeline):=20unblock=20live=20oneshot?= =?UTF-8?q?=20e2e=20=E2=80=94=20research=20dispatch,=20adapter,=20migrate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects surfaced by an end-to-end live run (the action.py stack dump from the prior commit pinpointed each). With these the full spec→work→build tick completes live and deploys. - research.py: LIVE_INFERENCE_WORKERS was keyed "research_investigate" but ArchitectFactory dispatches by token.split(":")[-1] = "investigate", so the live FileResearcher/WebResearcher worker was never invoked — raw model prose hit the JSON adapter and failed the tick. Re-keyed to "investigate" (every other worker's bind name equals its token's last segment; research was the lone mismatch). The worker parses loosely and fail-safes to baseline. - adapter.py: JsonAdapter.decode json.loads'd model output unguarded — empty output crashed with a bare JSONDecodeError. Now: empty -> {} (failures-as-data), fenced/prose-wrapped JSON salvaged, genuinely-garbled -> a HANDLED ActionError. - admin.py: admin:migrate constructed _env.Environment() with no args, but the ctor requires `sources` — a TypeError that aborted the tick just before publish. Use Environment.default() (the canonical factory, as at admin.py:292/442). Verified: live e2e green (ok=True) — engineer codegen builds, publish deploys to Vercel, consolidate opens one PR, no-issue guard skips the label machine. --- baseworkflow/bindings/admin.py | 2 +- baseworkflow/bindings/research.py | 8 ++++++- foundation/actions/adapter.py | 38 ++++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/baseworkflow/bindings/admin.py b/baseworkflow/bindings/admin.py index 09dcb19..b75c81b 100644 --- a/baseworkflow/bindings/admin.py +++ b/baseworkflow/bindings/admin.py @@ -700,7 +700,7 @@ def record(**kw: Any) -> Dict[str, Any]: f"{', '.join(names) or '(none)'}") return record(planned=names) - environ = _env.Environment() + environ = _env.Environment.default() token = environ.get("SUPABASE_ACCESS_TOKEN") or os.environ.get("SUPABASE_ACCESS_TOKEN") ref = environ.get("SUPABASE_PROJECT_REF") or os.environ.get("SUPABASE_PROJECT_REF") if not (token and ref): diff --git a/baseworkflow/bindings/research.py b/baseworkflow/bindings/research.py index 75b68f5..e543715 100644 --- a/baseworkflow/bindings/research.py +++ b/baseworkflow/bindings/research.py @@ -331,4 +331,10 @@ def register(reg: Any) -> None: reg.register_predicate("questions_satisfied", questions_satisfied) reg.register_predicate("research_exhausted", research_exhausted) from .architect import LIVE_INFERENCE_WORKERS - LIVE_INFERENCE_WORKERS["research_investigate"] = _investigate_worker + # Keyed by the action token's LAST SEGMENT ("research:investigate" -> "investigate"), + # matching ArchitectFactory._inference_runner's `token.split(":")[-1]` dispatch — NOT + # the manifest bind name "research_investigate". They differ only for this token, so + # the old key never matched: the live worker was silently bypassed and the raw model + # text (prose) hit the JSON adapter, failing the tick. (Every other worker's bind + # name equals its token's last segment, so they matched by coincidence.) + LIVE_INFERENCE_WORKERS["investigate"] = _investigate_worker diff --git a/foundation/actions/adapter.py b/foundation/actions/adapter.py index 124f60a..0aefec4 100644 --- a/foundation/actions/adapter.py +++ b/foundation/actions/adapter.py @@ -19,9 +19,25 @@ from abc import ABC, abstractmethod from typing import Any +from .result import ActionError + VALID_FORMATS = ("text", "json", "yaml") +def _salvage_json(s: str) -> Any: + """Best-effort extract a JSON object/array embedded in prose or code fences — + models often wrap the payload. Returns the parsed value, or ``None`` if nothing + parseable is found.""" + for opener, closer in (("{", "}"), ("[", "]")): + start, end = s.find(opener), s.rfind(closer) + if 0 <= start < end: + try: + return json.loads(s[start:end + 1]) + except ValueError: + continue + return None + + class Adapter(ABC): """Two-way coercion between text and a structured object.""" @@ -60,7 +76,27 @@ def encode(self, obj: Any) -> str: def decode(self, text: str) -> Any: if not isinstance(text, str): return text - return json.loads(text) + s = text.strip() + # An empty model response carries no structured payload. Fail SAFE to an + # empty object rather than crashing the whole tick with a bare + # JSONDecodeError — the inference layer's failures-as-data contract. (This + # is the common real failure: a model that times out / returns nothing.) + if not s: + return {} + try: + return json.loads(s) + except json.JSONDecodeError: + # Models often wrap the payload in prose or ``` fences — salvage the + # embedded object/array before giving up. + salvaged = _salvage_json(s) + if salvaged is not None: + return salvaged + # Genuinely non-JSON, non-empty output: surface a HANDLED ActionError so + # the leaf boundary records a clean failure (not an uncaught crash). + raise ActionError( + f"json adapter: model output is not valid JSON " + f"({len(s)} chars, starts {s[:60]!r})" + ) class YamlAdapter(Adapter): From e4a64b78e0a45110e24abc73b5b277e9f43cb4b3 Mon Sep 17 00:00:00 2001 From: John Chandara Date: Wed, 8 Jul 2026 17:46:58 -0700 Subject: [PATCH 4/4] feat(debugviewer): surface live sessions in the sidebar, auto-attach, resizable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live runs were buried in a collapsed right-side 'mocked session' fold that only polled while open, and attaching was a manual click — so a live session looked invisible. Make a live session track itself: - Move live sessions to a always-visible panel at the top of the LEFT sidebar, above the workflow list; demote the in-process mock runner to a small fold. - Poll for external live runs unconditionally, and auto-attach the newest actively-running one (⦿ auto toggle to opt out; manual click still works). - Add a draggable width handle to the left sidebar, mirroring the inspector's (double-click resets; width persisted). Robust liveness, not a guess: a live run goes silent for minutes during long inference/publish steps, so a plain mtime cutoff would call it dead. FileObserver now heartbeats its sidecar's mtime every 15s (touch only, no extra lines) and stops on finish(); bus.py exposes mtime; the viewer treats a run as live only if status=running AND mtime is fresh (60s) — which also flags a sidecar whose writer died (stuck at 'running') as dead instead of auto-attaching it. Verified: heartbeat advances mtime while quiet and stops on finish; JS parses; live endpoint carries mtime. --- foundation/trace.py | 27 +++++- tools/debugviewer/bus.py | 8 +- tools/debugviewer/static/viewer.html | 128 +++++++++++++++++++++------ 3 files changed, 134 insertions(+), 29 deletions(-) diff --git a/foundation/trace.py b/foundation/trace.py index ec084f7..55a35c4 100644 --- a/foundation/trace.py +++ b/foundation/trace.py @@ -27,12 +27,19 @@ import json import os +import threading import time from typing import Any, Dict, Optional _ENV_DIR = "DISPATCH_LIVE_TRACE_DIR" _ENV_ON = "DISPATCH_LIVE_TRACE" +# How often the FileObserver bumps its sidecar's mtime while the run is alive, so a +# watcher can tell a live-but-quiet run (mid-inference — no state transitions for +# minutes) from a dead one whose writer vanished. Must be well under the watcher's +# staleness cutoff. +_HEARTBEAT_SECS = 15.0 + def trace_dir() -> str: """Directory holding live-run sidecars. Override with ``$DISPATCH_LIVE_TRACE_DIR``; @@ -64,6 +71,20 @@ def __init__(self, path: str, *, workflow: str) -> None: except Exception: # noqa: BLE001 pass self._write({"type": "meta", "workflow": workflow, "started": self._started}, mode="w") + # Liveness heartbeat: bump the sidecar's mtime every _HEARTBEAT_SECS so a + # watcher can distinguish a live-but-quiet run from a dead writer. Daemon + # thread — it dies with the process (a hard kill correctly stops the beat), + # and finish() stops it cleanly on a normal end. + self._stop = threading.Event() + self._beat = threading.Thread(target=self._heartbeat, name="trace-heartbeat", daemon=True) + self._beat.start() + + def _heartbeat(self) -> None: + while not self._stop.wait(_HEARTBEAT_SECS): + try: + os.utime(self.path, None) # touch mtime only; adds no line for the tailer to parse + except OSError: + return def _write(self, event: Dict[str, Any], *, mode: str = "a") -> None: try: @@ -84,8 +105,12 @@ def on_event(self, event: Dict[str, Any]) -> None: self._write({"type": "event", **event}) def finish(self, status: str) -> None: - """Write the terminal status line. Idempotent enough for best-effort use.""" + """Write the terminal status line and stop the heartbeat. Idempotent enough + for best-effort use.""" self._write({"type": "status", "status": status}) + stop = getattr(self, "_stop", None) + if stop is not None: + stop.set() def new_observer(workflow: str, *, run_id: Optional[str] = None) -> "FileObserver": diff --git a/tools/debugviewer/bus.py b/tools/debugviewer/bus.py index 0c71091..d9cc023 100644 --- a/tools/debugviewer/bus.py +++ b/tools/debugviewer/bus.py @@ -191,8 +191,14 @@ def list_external(self) -> List[Dict[str, Any]]: files = [] out: List[Dict[str, Any]] = [] for p in files[:50]: + try: + mtime = os.path.getmtime(p) + except OSError: + mtime = 0.0 + # mtime lets the UI tell a genuinely-active run from a sidecar whose writer + # died without a terminal status line (which otherwise reads "running" forever). out.append({"id": os.path.splitext(os.path.basename(p))[0], - "path": p, **self._read_sidecar(p)}) + "path": p, "mtime": mtime, **self._read_sidecar(p)}) return out def attach_external(self, run_id: str) -> Optional[Run]: diff --git a/tools/debugviewer/static/viewer.html b/tools/debugviewer/static/viewer.html index cc51e3b..1858454 100644 --- a/tools/debugviewer/static/viewer.html +++ b/tools/debugviewer/static/viewer.html @@ -13,6 +13,7 @@ --ok: #3fb950; --warn: #d29922; --err: #f85149; --edge: #566; --edge-loop: #d29922; --insp-w: 300px; /* inspector width — the drag handle rewrites this */ + --side-w: 232px; /* left sidebar width — its drag handle rewrites this */ --input-bg: #0d1117; /* form fields, code + event panes */ --dot: #1b2129; /* canvas dot-grid */ --code-ink: #a5d6ff; /* JSON output text */ @@ -50,7 +51,7 @@ body { background: var(--bg); color: var(--ink); font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - display: grid; grid-template-columns: 232px 1fr var(--insp-w); grid-template-rows: 46px 1fr; + display: grid; grid-template-columns: var(--side-w) 1fr var(--insp-w); grid-template-rows: 46px 1fr; grid-template-areas: "head head head" "side canvas insp"; height: 100vh; overflow: hidden; padding: 0; } @@ -73,7 +74,11 @@ button:hover, select:hover { border-color: var(--accent); } button.on { border-color: var(--accent); color: var(--accent); } - aside.side { grid-area: side; background: var(--panel); border-right: 1px solid var(--line); overflow-y: auto; } + aside.side { grid-area: side; position: relative; background: var(--panel); border-right: 1px solid var(--line); overflow-y: auto; } + /* left-sidebar drag handle: sits on the sidebar's right edge; mirrors .insp-resize */ + .side-resize { position: absolute; right: -3px; top: 0; bottom: 0; width: 7px; cursor: col-resize; z-index: 6; } + .side-resize::after { content: ""; position: absolute; left: 3px; top: 0; bottom: 0; width: 1px; background: transparent; transition: background .15s; } + .side-resize:hover::after, body.resizing .side-resize::after { background: var(--accent); } .side .cap { padding: 10px 14px 6px; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .8px; } .wf { padding: 9px 14px; border-left: 3px solid transparent; cursor: pointer; } .wf:hover { background: var(--panel2); } @@ -215,7 +220,8 @@ /* compact panel pinned to the foot of the inspector column (the #insp body above is flex:1 and pushes it down). flex:0 0 auto = size to content, not 46% of panel. */ - .sessions { border-top: 1px solid var(--line); padding: 12px 14px; background: var(--panel2); display: flex; flex-direction: column; flex: 0 0 auto; } + /* live-sessions panel: top of the left sidebar, above the workflow list */ + .sessions { border-bottom: 1px solid var(--line); padding: 12px 14px; background: var(--panel2); display: flex; flex-direction: column; } .sessions .cap { padding: 0 0 6px; } /* collapsible: summary keeps the .cap look, disclosure arrow floats right */ .sessions > summary { list-style: none; cursor: pointer; user-select: none; display: flex; align-items: center; } @@ -231,7 +237,7 @@ .st.running { color: var(--accent); } .st.succeeded { color: var(--ok); } .st.failed, .st.errored { color: var(--err); } .st.idle, .st.stopped { color: var(--muted); } /* live runs: out-of-process sidecars the viewer tails (foundation.trace) */ - .sessions .live-cap { padding: 12px 0 5px; display: flex; align-items: center; gap: 6px; } + .sessions .live-cap { padding: 0 0 6px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .sessions .mini { padding: 1px 6px; font-size: 10px; line-height: 1.3; } .liveruns { display: flex; flex-direction: column; gap: 6px; } .liveruns .small { font-size: 10.5px; } @@ -241,6 +247,19 @@ .lr-id { font-weight: 600; font-size: 11px; word-break: break-all; } .lr-main .st { margin-left: auto; font-size: 10px; } .lr-sub { color: var(--muted); font-size: 10px; margin-top: 2px; } + /* live-sessions header: label + rescan/auto controls, run status pinned right */ + .live-cap .st { margin-left: auto; } + .mini.toggle { color: var(--muted); } + .mini.toggle.on { color: var(--accent); border-color: var(--accent); } + /* mock runner tucked into a small collapsed fold below the live sessions */ + .mockfold { margin-top: 10px; border-top: 1px solid var(--line); padding-top: 8px; } + .mockfold > summary { list-style: none; cursor: pointer; user-select: none; color: var(--muted); + font-size: 11px; text-transform: uppercase; letter-spacing: .8px; padding: 2px 0; display: flex; align-items: center; } + .mockfold > summary::-webkit-details-marker { display: none; } + .mockfold > summary::after { content: "▾"; margin-left: auto; font-size: 10px; transition: transform .12s; } + .mockfold:not([open]) > summary::after { transform: rotate(-90deg); } + .mockfold > summary:hover { color: var(--accent); } + .mockfold .row { margin-top: 8px; } button.run { border-color: var(--ok); color: var(--ok); } button:disabled { opacity: .4; cursor: not-allowed; border-color: var(--line); color: var(--muted); } .elog { height: 168px; overflow-y: auto; font-size: 10.5px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; background: var(--input-bg); } @@ -264,8 +283,25 @@

◆ dispatch / wor @@ -277,16 +313,6 @@

◆ dispatch / wor
inspector
Click a state to inspect its action, budget, permissions and I/O.
-
- mocked session -
- - idle -
-
No run yet. “run & watch” drives the workflow under the mock engine (no network) and lights up each state as it executes.
-
live runs
-
-