From fcd95a46122e2f2a6fe8ed69e625684d15439538 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Mon, 16 Mar 2026 18:45:41 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20feat:=20improve=20gh-release=20?= =?UTF-8?q?simplify=20start=20justfile=20gh-release=20task=20to=20create?= =?UTF-8?q?=20or=20update=20GitHub=20releasesbased=20on=20latest=20tag=20r?= =?UTF-8?q?eachable=20from=20origin/main.=20tags=20first,?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit determine the latest tag on origin/main, find a previous tag for notes, and either mark an existing release as latest or create new(with generated notes). Add safer bash options and handle the casewhere no tags exist. This makes releases deterministic (based onorigin/main) and robust when rerunning CI or creating the firstrelease. Also modify tests for the CLI start to avoid spawning asubprocess. Replace subprocess.Popen mocks with Server.launchcalls and a small control in the test or patch fileence to exercise the built-in fallback. The tests now assert thatthe Server.launch is and for the built fallbackoutput, simplifying test isolation removing reliance on process. From 82e4488bb3be112adeda806e923c53b5312d3481 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Tue, 17 Mar 2026 00:31:01 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20feat:=20Dialog=20HITL,=20WebSoc?= =?UTF-8?q?ket=20monitor,=20local=20mode=20fixes,=20env=20pre-fill,=20code?= =?UTF-8?q?=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dialog HITL: new chat-like interface for iterative content review (supervaizer_dialog payload) - WebSocket monitor: replaced HTTP polling with WS push (monitor/console/jobs signals) - Local mode: force localhost URLs, skip SaaS events, load .env automatically - Env pre-fill: agent parameters auto-populated from .env with visual indicators - Server.__init__: resolve defaults from env vars at call time (not class definition) - CLI: .env loaded before env var setup, --public-url detection via sentinel - Field renderer: ChoiceField unpacks [value, label] tuples, aligned form layout - HITL Answer display: shows originating step label with 👤 emoji - Code cleanup: is_local_mode() helper, deque for log buffer, _postAnswer() JS extraction - Workbench monitor: Case: prefix, approved content card, terminal WS signal --- docs/CHANGELOG.md | 15 ++ src/supervaizer/account_service.py | 7 +- src/supervaizer/admin/routes.py | 7 +- .../admin/static/js/workbench-form.js | 205 +++++++++++++----- .../templates/components/dialog_renderer.html | 177 +++++++++++++++ .../templates/components/field_renderer.html | 12 +- .../admin/templates/workbench.html | 15 +- .../admin/templates/workbench_monitor.html | 24 +- src/supervaizer/admin/workbench_routes.py | 149 +++++++++++-- src/supervaizer/cli.py | 45 ++-- src/supervaizer/common.py | 5 + src/supervaizer/server.py | 29 ++- 12 files changed, 575 insertions(+), 115 deletions(-) create mode 100644 src/supervaizer/admin/templates/components/dialog_renderer.html diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ce98500..ebf2582 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,21 @@ All notable changes to this project will be documented in this file. ## Unreleased +### v0.10.29 (dev) + +- **Dialog HITL** — New HITL type for interactive content review via chat interface. When a `CaseNodeUpdate` payload contains `supervaizer_dialog`, the workbench renders a conversation UI instead of a fixed form. Supports iterative refinement through LLM-powered feedback loops. Fields: `content` (JSON string), `content_type` (email/text/code), `objective`, `instructions`, `messages` (conversation history), `confirm_label`. + - Template: `dialog_renderer.html` with `render_hitl_dialog` and `render_dialog_confirmed` macros + - JS: `submitDialogMessage(caseId, message)` and `confirmDialog(caseId)` in `workbench-form.js` + - Route: `workbench_routes.py` detects `supervaizer_dialog` in AWAITING case payloads + +- **Local mode URL fixes** — `supervaizer start --local --port N` now correctly shows localhost URLs everywhere: CLI output, admin interface logs, storage, and uvicorn. Fixed by resolving `Server.__init__` defaults from env vars at call time (not class definition time) and ensuring CLI-provided values take precedence over `.env` file values. + +- **Local mode event skipping** — `account_service.send_event()` returns a no-op `ApiSuccess` when `SUPERVAIZER_LOCAL_MODE=true`, preventing HTTP errors against the SaaS API during local development. + +- **Agent parameter env pre-fill** — In local mode, the workbench auto-loads `.env` values into agent parameter fields with green `.env` badge indicators. Secret fields show a masked placeholder; non-secret fields display the value. Backend falls back to env values for empty fields on job submission. + +- **HTMX polling guard** — Monitor template suppresses `hx-trigger` polling when a HITL dialog or form is active, preventing DOM overwrites while users interact with forms. + ### v0.10.27 - **Agent Workbench** — Full-featured testing interface for agents directly from the admin panel. Four-zone layout with agent parameters, job control, execution monitor, and live console log. Supports starting/stopping jobs, real-time case and step tracking via HTMX polling, and Human-in-the-Loop (HITL) form rendering and submission. Job history panel lists all past executions with status badges. diff --git a/src/supervaizer/account_service.py b/src/supervaizer/account_service.py index 26d3c00..bb871c0 100644 --- a/src/supervaizer/account_service.py +++ b/src/supervaizer/account_service.py @@ -14,7 +14,7 @@ import httpx -from supervaizer.common import ApiError, ApiResult, ApiSuccess, log +from supervaizer.common import ApiError, ApiResult, ApiSuccess, is_local_mode, log logger = logging.getLogger("httpx") # Enable httpx debug logging (optional - uncomment for transport-level debugging) @@ -57,6 +57,11 @@ def send_event( Tested in tests/test_account_service.py """ + # In local mode, skip sending events to the SaaS API entirely. + if is_local_mode(): + log.debug(f"[Send event] Local mode — skipping {event.type.name}") + return ApiSuccess(message=f"Event {event.type.name} skipped (local mode)", detail=None) + headers = account.api_headers payload = event.payload url_event = ( diff --git a/src/supervaizer/admin/routes.py b/src/supervaizer/admin/routes.py index 5d23925..fe40716 100644 --- a/src/supervaizer/admin/routes.py +++ b/src/supervaizer/admin/routes.py @@ -1223,12 +1223,17 @@ async def execute_console_command( return {"status": "error", "message": str(e)} # Include workbench sub-router - from supervaizer.admin.workbench_routes import create_workbench_routes + from supervaizer.admin.workbench_routes import ( + create_workbench_routes, + create_workbench_ws_routes, + ) router.include_router( create_workbench_routes(), dependencies=[Depends(verify_admin_access)], ) + # WebSocket routes are mounted separately — WS can't use APIKeyHeader auth + router.include_router(create_workbench_ws_routes()) return router diff --git a/src/supervaizer/admin/static/js/workbench-form.js b/src/supervaizer/admin/static/js/workbench-form.js index 80fb58c..def0591 100644 --- a/src/supervaizer/admin/static/js/workbench-form.js +++ b/src/supervaizer/admin/static/js/workbench-form.js @@ -18,7 +18,7 @@ class WorkbenchForm { this._getFields = config.getFields || null; if (config.onJobStarted) this.onJobStarted = config.onJobStarted; if (config.onError) this.onError = config.onError; - this._pollInterval = null; + this._ws = null; } getApiKey() { @@ -157,20 +157,9 @@ class WorkbenchForm { } } - async submitHitlAnswer(caseId, formElement) { - const answer = {}; - new FormData(formElement).forEach((value, key) => { - answer[key] = value; - }); - // Handle checkboxes that aren't in FormData when unchecked - formElement.querySelectorAll('input[type="checkbox"]').forEach(cb => { - if (!(cb.name in answer)) { - answer[cb.name] = false; - } else { - answer[cb.name] = true; - } - }); - + /** Shared POST to /cases/{caseId}/answer endpoint. */ + async _postAnswer(caseId, answerPayload) { + if (!this.activeJobId) return null; try { const response = await fetch( `${this.basePath}/jobs/${this.activeJobId}/cases/${caseId}/answer`, @@ -180,22 +169,52 @@ class WorkbenchForm { 'Content-Type': 'application/json', 'X-API-Key': this.getApiKey(), }, - body: JSON.stringify({ answer }), + body: JSON.stringify({ answer: answerPayload }), }, ); const result = await response.json(); if (!response.ok) { this.onError(result.detail || result.message || 'Failed to submit answer'); - } else { - // Remove the form so polling resumes, then refresh immediately - formElement.remove(); - this.refreshMonitor(); + return null; } + this.onError(''); + this.refreshMonitor(true); + return result; } catch (e) { this.onError(`Network error: ${e.message}`); + return null; + } + } + + async submitHitlAnswer(caseId, formElement) { + const answer = {}; + new FormData(formElement).forEach((value, key) => { + answer[key] = value; + }); + // Handle checkboxes that aren't in FormData when unchecked + formElement.querySelectorAll('input[type="checkbox"]').forEach(cb => { + if (!(cb.name in answer)) { + answer[cb.name] = false; + } else { + answer[cb.name] = true; + } + }); + + const result = await this._postAnswer(caseId, answer); + if (result) { + formElement.remove(); } } + async submitDialogMessage(caseId, message) { + if (!message) return; + await this._postAnswer(caseId, { action: 'message', text: message }); + } + + async confirmDialog(caseId) { + await this._postAnswer(caseId, { action: 'confirm' }); + } + /** Show/hide Stop and Status buttons based on active job. */ _updateButtons(terminal) { const stop = document.getElementById('btn-stop'); @@ -209,14 +228,29 @@ class WorkbenchForm { } } - /** Fetch and render the monitor partial for the active job. */ - refreshMonitor() { + /** True when a HITL dialog or form is visible in the monitor. */ + _hasActiveHitl() { + const container = document.getElementById(this.monitorContainerId); + if (!container) return false; + return !!( + container.querySelector('form[id^="hitl-form-"]') || + container.querySelector('[id^="hitl-dialog-"]') + ); + } + + /** Fetch and render the monitor partial for the active job. + * @param {boolean} force - bypass HITL guard (used after dialog submit/confirm) + */ + refreshMonitor(force) { if (!this.activeJobId) return; const container = document.getElementById(this.monitorContainerId); if (!container) return; + // Don't overwrite while user interacts with HITL (unless forced) + if (!force && this._hasActiveHitl()) return; const url = `${this.basePath}/jobs/${this.activeJobId}`; const apiKey = this.getApiKey(); const headers = apiKey ? { 'X-API-Key': apiKey } : {}; + const self = this; fetch(url, { headers }) .then(r => r.text()) .then(html => { @@ -224,46 +258,109 @@ class WorkbenchForm { if (typeof Alpine !== 'undefined') { Alpine.initTree(container); } + // Disconnect WebSocket when job reaches terminal state + const partial = container.querySelector('#workbench-monitor-partial'); + if (partial && partial.dataset.jobTerminal === 'true') { + self.disconnectWebSocket(); + self._updateButtons(true); + } }) .catch(() => {}); } - /** Load monitor partial and start polling. Stops when job reaches terminal state. */ + /** Check if the monitor shows a terminal job state. */ + _isJobTerminal() { + var container = document.getElementById(this.monitorContainerId); + if (!container) return false; + var partial = container.querySelector('#workbench-monitor-partial'); + return partial && partial.dataset.jobTerminal === 'true'; + } + + /** Connect a WebSocket that pushes typed signals when state changes. */ + connectWebSocket(jobId) { + // Don't connect for terminal jobs + if (this._isJobTerminal()) return; + this.disconnectWebSocket(); + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${location.host}${this.basePath}/jobs/${jobId}/ws`; + this._ws = new WebSocket(wsUrl); + const self = this; + this._ws.onmessage = (event) => { + const msg = event.data; + if (msg === 'terminal') { + self.refreshMonitor(true); + self._refreshConsole(); + self._refreshJobsList(); + self.disconnectWebSocket(); + self._updateButtons(true); + } else if (msg === 'monitor' || msg === 'refresh') { + self.refreshMonitor(); + } else if (msg === 'console') { + self._refreshConsole(); + } else if (msg === 'jobs') { + self._refreshJobsList(); + } else if (msg === 'ping') { + self._ws.send('pong'); + } + }; + this._ws.onclose = () => { + // Only reconnect if job is still active and we didn't intentionally disconnect + if (self.activeJobId && self._ws && !self._isJobTerminal()) { + self._ws = null; + setTimeout(() => { + if (self.activeJobId && !self._isJobTerminal()) { + self.connectWebSocket(jobId); + } + }, 3000); + } + }; + this._ws.onerror = () => {}; + } + + /** Fetch and swap console log entries. */ + _refreshConsole() { + var el = document.getElementById('console-log-container'); + if (!el) return; + var url = el.dataset.url; + if (!url) return; + fetch(url).then(r => r.text()).then(html => { + el.innerHTML = html; + el.scrollTop = el.scrollHeight; + if (typeof applyConsoleFilter === 'function') applyConsoleFilter(); + }).catch(() => {}); + } + + /** Fetch and swap job history list. */ + _refreshJobsList() { + var el = document.getElementById('jobs-list-container'); + if (!el) return; + var url = el.dataset.url; + if (!url) return; + fetch(url).then(r => r.text()).then(html => { + el.innerHTML = html; + }).catch(() => {}); + } + + /** Close the WebSocket connection. */ + disconnectWebSocket() { + if (this._ws) { + const ws = this._ws; + this._ws = null; + ws.close(); + } + } + + /** Load monitor partial and connect WebSocket for live updates. */ onJobStarted(result) { const container = document.getElementById(this.monitorContainerId); if (!container) return; - const url = `${this.basePath}/jobs/${result.id}`; - const apiKey = this.getApiKey(); - const self = this; this._updateButtons(false); - const loadMonitor = () => { - // Don't overwrite the monitor while user is filling a HITL form - if (container.querySelector('form[id^="hitl-form-"]')) return; - - const headers = apiKey ? { 'X-API-Key': apiKey } : {}; - fetch(url, { headers }) - .then(r => r.text()) - .then(html => { - container.innerHTML = html; - // Re-initialize Alpine on swapped content - if (typeof Alpine !== 'undefined') { - Alpine.initTree(container); - } - // Stop polling once job is terminal - const partial = container.querySelector('#workbench-monitor-partial'); - if (partial && partial.dataset.jobTerminal === 'true') { - if (self._pollInterval) { - clearInterval(self._pollInterval); - self._pollInterval = null; - } - self._updateButtons(true); - } - }) - .catch(() => {}); - }; - loadMonitor(); - if (this._pollInterval) clearInterval(this._pollInterval); - this._pollInterval = setInterval(loadMonitor, 2000); + // Initial load + this.refreshMonitor(); + this._refreshConsole(); + this._refreshJobsList(); + // Connect WebSocket — pushes monitor, console, and jobs updates + this.connectWebSocket(result.id); } /** Show error in errors container. Override in template if needed. */ diff --git a/src/supervaizer/admin/templates/components/dialog_renderer.html b/src/supervaizer/admin/templates/components/dialog_renderer.html new file mode 100644 index 0000000..3b2864d --- /dev/null +++ b/src/supervaizer/admin/templates/components/dialog_renderer.html @@ -0,0 +1,177 @@ +{# Dialog HITL renderer — chat-like interface for content review #} + +{% macro render_hitl_dialog(case_id, dialog) %} +{% set content_raw = dialog.get("content", "") %} +{% set content_type = dialog.get("content_type", "text") %} +{% set objective = dialog.get("objective", "") %} +{% set messages = dialog.get("messages", []) %} +{% set confirm_label = dialog.get("confirm_label", "Approve") %} + +
+ {# Header #} +
+ + + + Review & Respond +
+ + {# Objective #} + {% if objective %} +
{{ objective }}
+ {% endif %} + + {# Content preview card #} + {% if content_raw %} +
+ {% if content_type == "email" %} + {# Email: render subject + HTML body via Alpine #} + + + {% elif content_type == "code" %} +
{{ content_raw }}
+ {% else %} +
{{ content_raw }}
+ {% endif %} +
+ {% endif %} + + {# Chat messages #} +
+ {% if messages %} + {% for msg in messages %} + {% set role = msg.get("role", "assistant") %} + {% set text = msg.get("text", "") %} + {% if role == "user" %} +
+
+ {{ text }} +
+
+ {% else %} +
+
+ {{ text }} +
+
+ {% endif %} + {% endfor %} + {% else %} +
No messages yet.
+ {% endif %} +
+ + {# Input + action buttons #} +
+
+ + +
+ +
+
+{% endmacro %} + + +{# Confirmed content display — shown after dialog is approved #} +{% macro render_dialog_confirmed(content_raw, content_type) %} +
+
+ + + Approved Content + +
+ {% if content_type == "email" %} +
+ + +
+ {% else %} +
{{ content_raw }}
+ {% endif %} +
+{% endmacro %} diff --git a/src/supervaizer/admin/templates/components/field_renderer.html b/src/supervaizer/admin/templates/components/field_renderer.html index e2bca5f..9bb29af 100644 --- a/src/supervaizer/admin/templates/components/field_renderer.html +++ b/src/supervaizer/admin/templates/components/field_renderer.html @@ -14,14 +14,15 @@ #} {% macro render_field(field, prefix="field_") %} -
-