Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 55 additions & 3 deletions plugins/hermes-workflows-approvals/dashboard/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
Expand Down
26 changes: 25 additions & 1 deletion plugins/hermes-workflows-approvals/dashboard/dist/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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%);
Expand All @@ -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;
Expand Down
49 changes: 42 additions & 7 deletions plugins/hermes-workflows-approvals/dashboard/plugin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:"):
Expand All @@ -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,
)
Expand Down
9 changes: 9 additions & 0 deletions src/hermes_workflows/authoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"}:
Expand All @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion src/hermes_workflows/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading