diff --git a/plugins/hermes-workflows-approvals/dashboard/dist/index.js b/plugins/hermes-workflows-approvals/dashboard/dist/index.js index 4e9795b..e0dcd02 100644 --- a/plugins/hermes-workflows-approvals/dashboard/dist/index.js +++ b/plugins/hermes-workflows-approvals/dashboard/dist/index.js @@ -496,7 +496,7 @@ function HumanInputActions(props) { const step = props.step; const useState = hooks.useState; - const state = useState({ busy: false, error: null, done: null, payloadText: "{}", feedback: "", selectedOptionId: "", editedOutput: "", editLoaded: false, formValues: {} }); + const state = useState({ busy: false, error: null, done: null, payloadText: "{}", feedback: "", selectedOptionId: "", customValue: "", editedOutput: "", editLoaded: false, formValues: {} }); const ui = state[0]; const setUi = state[1]; if (!step || step.status !== "waiting") return e("span", { className: "hwf-muted" }, step && step.status === "completed" ? "answered" : "no actions"); @@ -645,6 +645,35 @@ if (!option) { setUi(Object.assign({}, ui, { error: "Choose an option first" })); return; } submitPayload(selectionPayload(surface, option)); } + function customSelectionPayload(surface) { + const custom = surface.custom || {}; + const schema = custom.schema || {}; + if (custom.mode === "structured" || schema.kind === "structured_object") { + return Object.assign({ __hwf_selection_mode: "custom" }, buildStructuredPayload({ schema: schema })); + } + const text = String(ui.customValue || "").trim(); + if (!text) throw new Error("Enter a custom value first"); + let value = text; + if (schema.kind === "scalar") { + if (schema.type === "int" || schema.type === "float") { + value = Number(text); + if (!Number.isFinite(value)) throw new Error("Custom value must be a number"); + if (schema.type === "int" && !Number.isInteger(value)) throw new Error("Custom value must be an integer"); + } else if (schema.type === "bool") { + const lowered = text.toLowerCase(); + if (["true", "yes", "y", "1"].includes(lowered)) value = true; + else if (["false", "no", "n", "0"].includes(lowered)) value = false; + else throw new Error("Custom value must be true or false"); + } + } + return { __hwf_selection_mode: "custom", value: value }; + } + function submitCustomSelection(surface) { + try { submitPayload(customSelectionPayload(surface)); } catch (err) { setUi(Object.assign({}, ui, { error: err.message || String(err) })); } + } + function chooseSelection(option) { + setUi(Object.assign({}, ui, { selectedOptionId: String(option.id), error: null })); + } function editableOutputSeed() { const artifact = step.artifact || {}; if (typeof artifact.markdown === "string") return artifact.markdown; @@ -709,23 +738,46 @@ } if (surface.kind === "selection") { const options = surface.options || []; + const custom = surface.custom || null; + const customSchema = custom && custom.schema || {}; + const customFields = custom && (custom.mode === "structured" || customSchema.kind === "structured_object") ? schemaFields({ schema: customSchema }) : []; return e("div", { className: "hwf-approval-actions hwf-selection-actions" }, e("div", { className: "hwf-section-title" }, "Choose one"), e("div", { className: "hwf-selection-options" }, options.map(function (option) { const selected = String(ui.selectedOptionId) === String(option.id); - return e("label", { key: option.id, className: "hwf-selection-option " + (selected ? "is-selected" : "") }, + return e("label", { key: option.id, className: "hwf-selection-option " + (selected ? "is-selected" : ""), onClick: function () { chooseSelection(option); } }, e("input", { type: "radio", name: "selection-" + step.key, value: option.id, checked: selected, - onInput: function () { setUi(Object.assign({}, ui, { selectedOptionId: String(option.id), error: null })); } + onInput: function () { chooseSelection(option); } }), e("span", { className: "hwf-selection-option-body" }, e("strong", null, option.label || option.id), option.details && e("span", { className: "hwf-muted" }, option.details))); })), e(Button, { disabled: ui.busy || !ui.selectedOptionId, onClick: function () { submitSelection(surface); } }, "Submit selection"), + custom && custom.enabled && e("div", { className: "hwf-selection-custom" }, + e("div", { className: "hwf-section-title" }, "Other / custom"), + customFields.length ? customFields.map(function (field) { + const value = structuredValue(field.name); + const help = field.help || field.description; + const common = { value: value, placeholder: field.kind === "list" ? "One item per line" : (field.kind === "object" ? "JSON object" : ""), onInput: function (event) { setStructuredValue(field.name, event.target.type === "checkbox" ? event.target.checked : event.target.value); } }; + return e("label", { key: field.name, className: "hwf-structured-field" }, + e("span", { className: "hwf-structured-field-label" }, + e("strong", null, field.name), + e("span", { className: "hwf-structured-field-type" }, field.type || fieldTypeLabel(field)), + e("span", { className: field.required ? "hwf-required" : "hwf-muted" }, field.required ? "Required" : "Optional")), + help && e("span", { className: "hwf-muted" }, help), + field.kind === "choice" ? e("select", Object.assign({}, common), + e("option", { value: "" }, "Choose…"), + (field.options || []).map(function (option) { return e("option", { key: option, value: option }, option); })) : + field.kind === "boolean" ? e("input", { type: "checkbox", checked: Boolean(value), onInput: common.onInput }) : + field.kind === "list" || field.kind === "object" ? e("textarea", Object.assign({ rows: field.kind === "list" ? 4 : 6 }, common)) : + e("input", Object.assign({ type: field.kind === "number" ? "number" : "text" }, common))); + }) : e(Input, { value: ui.customValue, placeholder: "Enter a custom value", onInput: function (event) { setUi(Object.assign({}, ui, { customValue: event.target.value, error: null })); } }), + e(Button, { disabled: ui.busy, variant: "outline", onClick: function () { submitCustomSelection(surface); } }, "Submit custom value")), ui.done && e("span", { className: "hwf-ok" }, ui.done), ui.error && e("span", { className: "hwf-bad" }, ui.error)); } diff --git a/plugins/hermes-workflows-approvals/dashboard/dist/style.css b/plugins/hermes-workflows-approvals/dashboard/dist/style.css index 5f4fb21..45b1e29 100644 --- a/plugins/hermes-workflows-approvals/dashboard/dist/style.css +++ b/plugins/hermes-workflows-approvals/dashboard/dist/style.css @@ -480,7 +480,7 @@ .hwf-selection-option { display: grid; - grid-template-columns: auto minmax(0, 1fr); + grid-template-columns: 1.25rem minmax(0, 1fr); gap: 0.75rem; align-items: flex-start; padding: 0.75rem; @@ -490,6 +490,15 @@ cursor: pointer; } +.hwf-selection-option input[type="radio"] { + min-width: 0; + width: 1rem; + inline-size: 1rem; + margin-top: 0.15rem; + accent-color: var(--color-primary); + cursor: pointer; +} + .hwf-selection-option.is-selected { border-color: var(--color-primary); background: color-mix(in srgb, var(--color-primary), transparent 90%); @@ -500,6 +509,21 @@ gap: 0.25rem; } +.hwf-selection-custom { + display: grid; + gap: 0.65rem; + padding: 0.75rem; + border: 1px dashed var(--hwf-border); + border-radius: var(--radius-md, 0.5rem); + background: rgba(255, 255, 255, 0.03); +} + +.hwf-selection-custom input, +.hwf-selection-custom textarea, +.hwf-selection-custom select { + width: 100%; +} + .hwf-structured-form { display: grid; gap: 0.75rem; diff --git a/plugins/hermes-workflows-approvals/dashboard/plugin_api.py b/plugins/hermes-workflows-approvals/dashboard/plugin_api.py index 27ab8e3..ad44f70 100644 --- a/plugins/hermes-workflows-approvals/dashboard/plugin_api.py +++ b/plugins/hermes-workflows-approvals/dashboard/plugin_api.py @@ -591,7 +591,8 @@ def _include_review_card_for_status(card: dict[str, Any], requested_status: str def _review_cards(engine: WorkflowEngine, *, db_alias: str, status: str | None, limit: int) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: runtime_cache: dict[str, dict[str, Any] | None] = {} approvals: list[dict[str, Any]] = [] - for approval in engine.list_approvals(status=None): + source_status = "waiting" if status == "waiting" else None + for approval in engine.list_approvals(status=source_status): payload = approval_view_to_dict(approval) runtime_state = _runtime_state_for_run(engine, payload.get("workflow_id"), db_alias=db_alias, cache=runtime_cache) card = _approval_card(payload, db_alias=db_alias, runtime_state=runtime_state) @@ -602,7 +603,7 @@ def _review_cards(engine: WorkflowEngine, *, db_alias: str, status: str | None, human_inputs: list[dict[str, Any]] = [] remaining = max(0, limit - len(approvals)) if remaining: - for step in engine.list_operator_steps(status=None): + for step in engine.list_operator_steps(status=source_status): payload = _strip_internal_fields(step) runtime_state = _runtime_state_for_run(engine, payload.get("workflow_id"), db_alias=db_alias, cache=runtime_cache) card = _operator_step_card(payload, db_alias=db_alias, runtime_state=runtime_state) @@ -1612,9 +1613,39 @@ def _selection_input_surface(descriptor: dict[str, Any], artifact: Any, fallback } if field: surface["field"] = field + custom = _custom_selection_descriptor(descriptor, submit=submit) + if custom: + surface["custom"] = custom return surface +def _custom_selection_descriptor(descriptor: dict[str, Any], *, submit: str) -> dict[str, Any] | None: + kind = descriptor.get("kind") + if kind in {"text", "scalar"}: + return { + "enabled": True, + "mode": "scalar", + "submit": "value", + "schema": descriptor, + "payload_marker": "__hwf_selection_mode", + "payload_marker_value": "custom", + } + if kind == "structured_object": + fields = descriptor.get("fields") + if isinstance(fields, list) and fields: + if any(isinstance(field, dict) and field.get("kind") == "choice" for field in fields): + return None + return { + "enabled": True, + "mode": "structured", + "submit": "object", + "schema": descriptor, + "payload_marker": "__hwf_selection_mode", + "payload_marker_value": "custom", + } + return None + + def _review_action_descriptor(action: Any, *, has_feedback: bool = False) -> dict[str, Any]: value = str(action) label = value.replace("_", " ").strip().capitalize() or value @@ -2239,7 +2270,8 @@ async def respond_review_request(body: dict[str, Any]) -> dict[str, Any]: raw_payload = body.get("payload") if isinstance(body.get("payload"), dict) else {} if not raw_payload: raise HTTPException(status_code=400, detail="review response payload is required") - payload = {key: value for key, value in raw_payload.items() if key not in {"by", "source"}} + custom_selection = raw_payload.get("__hwf_selection_mode") == "custom" + payload = {key: value for key, value in raw_payload.items() if key not in {"by", "source", "__hwf_selection_mode"}} raw_idempotency_key = str(body.get("idempotency_key") or body.get("event_id") or "").strip() message_id = raw_idempotency_key or f"dashboard:{uuid.uuid4()}" if not message_id.startswith("dashboard:"): @@ -2249,14 +2281,17 @@ def record_and_resume() -> tuple[Any, dict[str, Any]]: _ensure_workflow_project_on_path(db_path) engine = WorkflowEngine(db_path) normalized_payload = _normalize_review_payload_for_dashboard_request(engine, workflow_id, key, payload) + source = { + "channel": "hermes-dashboard", + "message_id": message_id, + } + if custom_selection: + source["selection_source"] = "human_custom" receipt = engine.submit_operator_response( workflow_id=workflow_id, key=key, payload=normalized_payload, - source={ - "channel": "hermes-dashboard", - "message_id": message_id, - }, + source=source, idempotency_key=message_id, resume=True, ) diff --git a/src/hermes_workflows/authoring.py b/src/hermes_workflows/authoring.py index 715fc9f..04bec6d 100644 --- a/src/hermes_workflows/authoring.py +++ b/src/hermes_workflows/authoring.py @@ -887,6 +887,12 @@ def _public_label(name: str) -> str: return " ".join(part for part in text.split()) or str(name) +def _single_value_payload(value: Any) -> Any: + if isinstance(value, Mapping) and set(value.keys()) == {"value"}: + return value["value"] + return value + + def _coerce_return(value: Any, returns: Any) -> Any: if returns in (None, Any, dict): return value @@ -900,8 +906,10 @@ def _coerce_return(value: Any, returns: Any) -> Any: raise TypeError(f"cannot coerce {type(value).__name__} to list") return [_coerce_return(item, item_type) for item in value] if returns is str: + value = _single_value_payload(value) return value if isinstance(value, str) else str(value) if returns is bool: + value = _single_value_payload(value) if isinstance(value, str): normalized = value.strip().lower() if normalized in {"true", "yes", "y", "1", "approve", "approved", "ship", "pass"}: @@ -910,6 +918,7 @@ def _coerce_return(value: Any, returns: Any) -> Any: return False return bool(value) if returns in (int, float): + value = _single_value_payload(value) return returns(value) if is_dataclass(returns) and isinstance(returns, type): if isinstance(value, returns): diff --git a/src/hermes_workflows/engine.py b/src/hermes_workflows/engine.py index 56c3f91..be9d1f7 100644 --- a/src/hermes_workflows/engine.py +++ b/src/hermes_workflows/engine.py @@ -3401,7 +3401,7 @@ async def request_many( return decisions -_OPERATOR_SOURCE_ALLOWLIST = ("channel", "message_url", "message_id", "event_id") +_OPERATOR_SOURCE_ALLOWLIST = ("channel", "message_url", "message_id", "event_id", "selection_source") def _normalize_approval_decision_payload(payload: Any) -> Any: diff --git a/tests/test_dashboard_plugin.py b/tests/test_dashboard_plugin.py index 90593a5..b2c2f74 100644 --- a/tests/test_dashboard_plugin.py +++ b/tests/test_dashboard_plugin.py @@ -79,6 +79,32 @@ class DashboardTripPreferenceResponse: rest_or_leisure_preferences: list[str] budget_or_pace_notes: list[str] +@dataclass +class DashboardSelectCustomObject: + title: str + summary: str + +@workflow +async def dashboard_scalar_select_workflow(inputs): + selected = await select( + "select_dashboard_scalar", + ["Guidance", "Commitments"], + returns=str, + ) + return {"selected": selected} + +@workflow +async def dashboard_structured_select_workflow(inputs): + selected = await select( + "select_dashboard_structured", + [ + DashboardSelectCustomObject("Guidance", "Skills guide behavior."), + DashboardSelectCustomObject("Commitments", "Workflows preserve obligations."), + ], + returns=DashboardSelectCustomObject, + ) + return {"selected": {"title": selected.title, "summary": selected.summary}} + @workflow async def dashboard_review_decision_workflow(inputs): response = await ask( @@ -1119,6 +1145,123 @@ def test_dashboard_select_response_does_not_require_dashboard_actor_identity(tmp assert completed.result["selected"] == {"title": "Commitments", "summary": "Workflows preserve obligations."} +def test_dashboard_select_surface_exposes_custom_scalar_and_structured_inputs(tmp_path, monkeypatch): + db = tmp_path / "workflow.sqlite" + WorkflowEngine(db).run_until_idle( + dashboard_scalar_select_workflow, + {}, + workflow_id="wf_dashboard_select_scalar_surface", + workflow_ref="tests.test_dashboard_plugin:dashboard_scalar_select_workflow", + ) + WorkflowEngine(db).run_until_idle( + dashboard_structured_select_workflow, + {}, + workflow_id="wf_dashboard_select_structured_surface", + workflow_ref="tests.test_dashboard_plugin:dashboard_structured_select_workflow", + ) + configure_test_dbs(monkeypatch, tmp_path, {"runtime-smoke": str(db)}) + api = load_dashboard_api() + + review_requests = run(api.active_review_requests(db="runtime-smoke", status="waiting"))["review_requests"] + scalar_card = next(card for card in review_requests if card["key"] == "select_dashboard_scalar") + assert scalar_card["input_surface"]["kind"] == "selection" + assert scalar_card["input_surface"]["submit"] == "value" + assert [option["label"] for option in scalar_card["input_surface"]["options"]] == ["Guidance", "Commitments"] + assert scalar_card["input_surface"]["custom"] == { + "enabled": True, + "mode": "scalar", + "submit": "value", + "schema": {"id": "builtins:str", "name": "str", "kind": "text"}, + "payload_marker": "__hwf_selection_mode", + "payload_marker_value": "custom", + } + + structured_card = next(card for card in review_requests if card["key"] == "select_dashboard_structured") + assert structured_card["input_surface"]["kind"] == "selection" + assert [option["label"] for option in structured_card["input_surface"]["options"]] == ["Guidance", "Commitments"] + assert structured_card["input_surface"]["custom"]["enabled"] is True + assert structured_card["input_surface"]["custom"]["mode"] == "structured" + assert structured_card["input_surface"]["custom"]["submit"] == "object" + assert [field["name"] for field in structured_card["input_surface"]["custom"]["schema"]["fields"]] == ["title", "summary"] + + +def test_dashboard_select_custom_scalar_response_is_validated_and_records_server_marker(tmp_path, monkeypatch): + db = tmp_path / "workflow.sqlite" + WorkflowEngine(db).run_until_idle( + dashboard_scalar_select_workflow, + {}, + workflow_id="wf_dashboard_select_scalar_custom", + workflow_ref="tests.test_dashboard_plugin:dashboard_scalar_select_workflow", + ) + configure_test_dbs(monkeypatch, tmp_path, {"runtime-smoke": str(db)}) + api = load_dashboard_api() + + receipt = run( + api.respond_review_request( + { + "db": "runtime-smoke", + "workflow_id": "wf_dashboard_select_scalar_custom", + "key": "select_dashboard_scalar", + "payload": {"__hwf_selection_mode": "custom", "value": "Other custom answer", "source": {"selection_source": "spoofed"}}, + "idempotency_key": "select-scalar-custom-1", + } + ) + ) + + assert receipt["success"] is True + completed = WorkflowEngine(db).drain("wf_dashboard_select_scalar_custom") + assert completed.status == "completed" + assert completed.result["selected"] == "Other custom answer" + signal = [event for event in WorkflowEngine(db).events("wf_dashboard_select_scalar_custom") if event["type"] == "SignalReceived"][-1] + assert signal["payload"]["payload"] == {"value": "Other custom answer"} + assert signal["payload"]["source"]["selection_source"] == "human_custom" + assert signal["payload"]["source"]["channel"] == "hermes-dashboard" + + +def test_dashboard_select_custom_structured_response_is_validated_and_records_server_marker(tmp_path, monkeypatch): + db = tmp_path / "workflow.sqlite" + WorkflowEngine(db).run_until_idle( + dashboard_structured_select_workflow, + {}, + workflow_id="wf_dashboard_select_structured_custom", + workflow_ref="tests.test_dashboard_plugin:dashboard_structured_select_workflow", + ) + configure_test_dbs(monkeypatch, tmp_path, {"runtime-smoke": str(db)}) + api = load_dashboard_api() + + with pytest.raises(Exception, match="summary is required"): + run( + api.respond_review_request( + { + "db": "runtime-smoke", + "workflow_id": "wf_dashboard_select_structured_custom", + "key": "select_dashboard_structured", + "payload": {"__hwf_selection_mode": "custom", "title": "Human option"}, + } + ) + ) + + receipt = run( + api.respond_review_request( + { + "db": "runtime-smoke", + "workflow_id": "wf_dashboard_select_structured_custom", + "key": "select_dashboard_structured", + "payload": {"__hwf_selection_mode": "custom", "title": "Human option", "summary": "Typed in the dashboard."}, + "idempotency_key": "select-structured-custom-1", + } + ) + ) + + assert receipt["success"] is True + completed = WorkflowEngine(db).drain("wf_dashboard_select_structured_custom") + assert completed.status == "completed" + assert completed.result["selected"] == {"title": "Human option", "summary": "Typed in the dashboard."} + signal = [event for event in WorkflowEngine(db).events("wf_dashboard_select_structured_custom") if event["type"] == "SignalReceived"][-1] + assert signal["payload"]["payload"] == {"title": "Human option", "summary": "Typed in the dashboard."} + assert signal["payload"]["source"]["selection_source"] == "human_custom" + + def test_dashboard_structured_form_shows_trip_preference_fields_and_rejects_invalid_payload(tmp_path, monkeypatch): db = tmp_path / "workflow.sqlite" WorkflowEngine(db).run_until_idle( @@ -1190,6 +1333,15 @@ def test_dashboard_frontend_renders_structured_forms_instead_of_raw_trip_json_bo assert "fieldTypeLabel" in index_js assert "hasOwnProperty.call(ui.formValues" in index_js assert "options.find(function (option)" in index_js + assert "chooseSelection(option)" in index_js + assert "onClick: function () { chooseSelection(option); }" in index_js + assert "Other / custom" in index_js + assert "customSelectionPayload" in index_js + assert "__hwf_selection_mode" in index_js + assert "submitCustomSelection(surface)" in index_js + assert "hwf-selection-custom" in style_css + assert 'grid-template-columns: 1.25rem minmax(0, 1fr);' in style_css + assert '.hwf-selection-option input[type="radio"]' in style_css assert "One item per line" in index_js assert "Raw JSON fallback" in index_js assert "Submit raw JSON" in index_js @@ -1219,6 +1371,9 @@ def test_dashboard_can_cancel_waiting_run(tmp_path, monkeypatch): assert status["terminal_reason"]["reason"] == "wrong branch" assert all(command["status"] == "cancelled" for command in status["command_history"] if command["status"] in {"pending", "running", "cancelled"}) + review_queue = run(api.active_review_requests(db="runtime-smoke", status="waiting")) + assert not [item for item in review_queue["review_requests"] if item["workflow_id"] == "wf_dashboard_cancel"] + def test_dashboard_plugin_api_supports_catalog_run_history_artifacts_and_active_approval_detail(tmp_path, monkeypatch): db = tmp_path / "workflow.sqlite"