diff --git a/.gitignore b/.gitignore index 10075b3..422e4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -252,3 +252,6 @@ uv.lock .pypirc .deployment/ +.playwright-cli/ +/ +but/ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ce98500..30cc75a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,39 @@ All notable changes to this project will be documented in this file. ## Unreleased +### v0.11.0 + +- **Job Poll mechanism** — New optional `job_poll` method in `AgentMethods` for manual external service polling. When defined, the workbench shows a "Check for updates" button on active jobs. Clicking it calls the agent's poll handler, which checks external services (email inboxes, call status APIs, etc.) and updates cases accordingly. Enables local development without webhooks — production uses real-time webhooks, local mode uses the poll button. + - `AgentMethods`: new `job_poll: AgentMethod | None` field + - Route: `POST /workbench/jobs/{job_id}/poll` triggers the agent's poll handler + - UI: "Check for updates" button with loading state, conditionally rendered via `has_poll` template variable + - JS: `pollJob()` in `workbench-form.js` with disable-during-request pattern + +- **HITL double-click guard** — All HITL buttons are disabled during submission via `_postAnswer()`. Buttons get `opacity-50 cursor-not-allowed` while in flight and re-enable on error. Dialog HITL `submitMessage`/`confirmDialog` are now async, properly awaiting the form submission before resetting state. + +- **Monitor reply display** — Step payloads with a `reply` key render as a blue blockquote below the step row. Step payloads with `approved_content` render as a green confirmed card. + +- **Monitor duration display** — `step_duration` format now handles both numeric and string values, preventing Jinja2 `TypeError` when duration comes from HITL form input. + +- **WebSocket terminal signal** — Terminal job state sends a `terminal` WS signal that keeps the connection idle, preventing reconnect loops. + +- **`is_local_mode()` helper** — Centralized in `supervaizer.common`, replacing inline `os.environ.get("SUPERVAIZER_LOCAL_MODE")` checks across `account_service.py`, `server.py`. + +- **`deque` for log buffer** — `_workbench_log_buffer` changed from `list` to `collections.deque(maxlen=500)`, removing manual trim logic. + +- **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 or plain text), `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/justfile b/justfile index 490d63a..e6905d2 100644 --- a/justfile +++ b/justfile @@ -147,18 +147,50 @@ release: just gh-release @echo "✅ Release complete! Main branch and tags pushed to remote" -# Create GitHub release for the current version +# Create or update GitHub release for the latest tag on origin/main gh-release: #!/usr/bin/env bash - VERSION=$(grep '^VERSION = ' src/supervaizer/__version__.py | cut -d'"' -f2) - TAG="v${VERSION}" - echo "Creating GitHub release ${TAG}..." - PREV_TAG=$(git tag --sort=-creatordate | grep -v "^${TAG}$" | head -1) - NOTES=$(git log "${PREV_TAG}..${TAG}" --oneline --no-merges | grep -v "Bump version") - gh release create "${TAG}" \ - --repo supervaize/supervaizer \ - --title "${TAG}" \ - --latest \ - --generate-notes \ - --notes-start-tag "${PREV_TAG}" - echo "✅ GitHub release ${TAG} created" + set -euo pipefail + + echo "Fetching latest tags from origin..." + git fetch origin --tags + + # Find the latest tag reachable from origin/main + LATEST_TAG=$(git describe --tags --abbrev=0 origin/main) + + if [ -z "${LATEST_TAG}" ]; then + echo "❌ No tags found on origin/main. Aborting." + exit 1 + fi + + echo "Using latest tag on origin/main: ${LATEST_TAG}" + + # Find previous tag (for release notes range) + PREV_TAG=$(git tag --merged origin/main --sort=-creatordate | grep -v "^${LATEST_TAG}$" | head -1 || true) + + # If a release already exists, just mark it as latest; otherwise create it + if gh release view "${LATEST_TAG}" --repo supervaize/supervaizer >/dev/null 2>&1; then + echo "GitHub release ${LATEST_TAG} already exists. Marking as latest..." + gh release edit "${LATEST_TAG}" \ + --repo supervaize/supervaizer \ + --latest + echo "✅ GitHub release ${LATEST_TAG} updated as latest" + else + echo "Creating GitHub release ${LATEST_TAG}..." + if [ -n "${PREV_TAG}" ]; then + gh release create "${LATEST_TAG}" \ + --repo supervaize/supervaizer \ + --title "${LATEST_TAG}" \ + --latest \ + --generate-notes \ + --notes-start-tag "${PREV_TAG}" + else + # First release: no previous tag + gh release create "${LATEST_TAG}" \ + --repo supervaize/supervaizer \ + --title "${LATEST_TAG}" \ + --latest \ + --generate-notes + fi + echo "✅ GitHub release ${LATEST_TAG} created" + fi diff --git a/src/supervaizer/account_service.py b/src/supervaizer/account_service.py index 26d3c00..fd10fbd 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,13 @@ 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 ad1a9d6..f1d3bb8 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() { @@ -61,7 +61,9 @@ class WorkbenchForm { const elements = document.querySelectorAll(selector); elements.forEach(el => { const value = el.type === 'checkbox' ? el.checked : (el.value || '').toString().trim(); - if (el.type === 'checkbox' ? !value : value === '') { + // Skip validation for env-set fields — backend will fill them + const envSet = el.dataset.envSet === 'true'; + if (el.type === 'checkbox' ? !value : (value === '' && !envSet)) { el.classList.add('border-red-500'); valid = false; } else { @@ -120,6 +122,35 @@ class WorkbenchForm { } } + async pollJob() { + if (!this.activeJobId) return; + const btn = document.getElementById('btn-poll'); + if (btn) { + btn.disabled = true; + btn.classList.add('opacity-50'); + } + try { + const response = await fetch(`${this.basePath}/jobs/${this.activeJobId}/poll`, { + method: 'POST', + headers: { 'X-API-Key': this.getApiKey() }, + }); + const result = await response.json(); + if (!response.ok) { + this.onError(result.detail || 'Poll failed'); + } else { + this.onError(''); + this.refreshMonitor(true); + } + } catch (e) { + this.onError(`Network error: ${e.message}`); + } finally { + if (btn) { + btn.disabled = false; + btn.classList.remove('opacity-50'); + } + } + } + async stopJob(jobId) { const targetJobId = jobId || this.activeJobId; if (!targetJobId) return; @@ -155,20 +186,22 @@ 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; - } + /** Disable/enable all HITL buttons in the monitor to prevent double-clicks. */ + _setHitlButtonsDisabled(disabled) { + const container = document.getElementById(this.monitorContainerId); + if (!container) return; + container.querySelectorAll('button').forEach(btn => { + btn.disabled = disabled; + if (disabled) btn.classList.add('opacity-50', 'cursor-not-allowed'); + else btn.classList.remove('opacity-50', 'cursor-not-allowed'); }); + } + /** Shared POST to /cases/{caseId}/answer endpoint. */ + async _postAnswer(caseId, answerPayload) { + if (!this.activeJobId || this._submitting) return null; + this._submitting = true; + this._setHitlButtonsDisabled(true); try { const response = await fetch( `${this.basePath}/jobs/${this.activeJobId}/cases/${caseId}/answer`, @@ -178,43 +211,95 @@ 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(); + this._setHitlButtonsDisabled(false); + return null; } + this.onError(''); + this.refreshMonitor(true); + return result; } catch (e) { this.onError(`Network error: ${e.message}`); + this._setHitlButtonsDisabled(false); + return null; + } finally { + this._submitting = false; } } - /** Show/hide Stop and Status buttons based on active job. */ + 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, Poll, and Status buttons based on active job. */ _updateButtons(terminal) { const stop = document.getElementById('btn-stop'); const status = document.getElementById('btn-status'); + const poll = document.getElementById('btn-poll'); if (this.activeJobId && !terminal) { if (stop) stop.classList.remove('hidden'); if (status) status.classList.remove('hidden'); + if (poll) poll.classList.remove('hidden'); } else { if (stop) stop.classList.add('hidden'); + if (poll) poll.classList.add('hidden'); if (status) status.classList.remove('hidden'); } } - /** 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 => { @@ -222,46 +307,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..8075fbe --- /dev/null +++ b/src/supervaizer/admin/templates/components/dialog_renderer.html @@ -0,0 +1,184 @@ +{# 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") %} + +
{{ content_raw }}
+ {% else %}
+