Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,40 @@ def main() -> None:
"or =js:$vars.<node>.output.<field>"
)

if not any(
e.get("sourceNodeId") == hitl_id
and e.get("sourcePort") in ("completed", "outcome-completed")
# The task's own prompt asks for approve/reject review and a downstream log
# step; it does not prescribe HOW the task exits, and the SDK offers two
# shapes that are both correct:
#
# * the base node's 1.0 definition (and every variant: quick-form,
# action-app, document-validation) declares one source handle,
# `completed`;
# * `outcomePorts: true` / `exposeError: true` select the base node's
# 1.1/1.2 definition, whose ONLY source handle is `outcome-{item.id}`
# repeated over the outcomes. There is no `completed` handle there, and
# an edge to one is refused by `uip maestro flow validate` as an
# undeclared source handle.
#
# This gate used to require `completed` (or the `outcome-completed` special
# case added in #1477, which only helps when an outcome is literally NAMED
# "Completed"). An agent that took the second shape therefore failed a task
# it had satisfied — flow-builder-sdk#718, where the same task scored 1.0 the
# run before because the agent happened to pick the first shape. What the
# gate means to assert is that the review CONTINUES somewhere, so assert
# that.
exit_ports = {
str(e.get("sourcePort") or "")
for e in edges
if e.get("sourceNodeId") == hitl_id
}
if not (
"completed" in exit_ports
or any(port.startswith("outcome-") for port in exit_ports)
):
fail("HITL completed handle must be wired")
fail(
"HITL completion must be wired: an edge on the 'completed' handle, or "
"per-outcome edges ('outcome-<id>') when outcomePorts/exposeError is used. "
f"Found {sorted(exit_ports) or 'no outgoing edges'}"
)

scripts = [
str(n.get("inputs", {}).get("script", ""))
Expand All @@ -133,7 +161,10 @@ def main() -> None:
if not any(expected_output_path in script for script in scripts):
fail(f"Downstream script must read HITL output via {expected_output_path}")

print(f"OK: HITL node {hitl_id} uses v1.0 schema, captures approval + reason, wires completed, and uses .output paths")
print(
f"OK: HITL node {hitl_id} uses a v1.x schema, captures approval + reason, "
f"continues on {sorted(exit_ports)}, and uses .output paths"
)


if __name__ == "__main__":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,18 @@ def _flow_doc(
fields: list[dict[str, Any]],
outcomes: list[dict[str, Any]],
script_body: str = "return $vars.reviewExpense.output.rejectionreason;",
node_type: str = "uipath.human-in-the-loop.quick-form",
type_version: str = "1.0",
exit_ports: tuple[str, ...] = ("completed",),
) -> dict[str, Any]:
"""One HITL flow document.

`node_type` / `type_version` / `exit_ports` default to the shape every
existing test used (a quick-form task exiting on `completed`). They are
parameters because the SDK has a second, equally correct shape: with
`outcomePorts: true` the base node's 1.1 definition declares only
`outcome-{item.id}` handles and no `completed` one.
"""
return {
"nodes": [
{
Expand All @@ -31,8 +42,8 @@ def _flow_doc(
},
{
"id": "reviewExpense",
"type": "uipath.human-in-the-loop.quick-form",
"typeVersion": "1.0",
"type": node_type,
"typeVersion": type_version,
"inputs": {
"schema": {
"fields": fields,
Expand All @@ -49,10 +60,11 @@ def _flow_doc(
"edges": [
{
"sourceNodeId": "reviewExpense",
"sourcePort": "completed",
"sourcePort": port,
"targetNodeId": "logOutcome",
"targetPort": "input",
}
for port in exit_ports
],
}

Expand Down Expand Up @@ -160,6 +172,44 @@ def test_accepts_checker_frozen_without_sibling_shared_directory(tmp_path: Path)
assert result.returncode == 0, result.stderr


def test_accepts_outcome_ports_instead_of_completed(tmp_path: Path) -> None:
"""The base node with `outcomePorts: true` has no `completed` handle.

Its 1.1 definition declares one source handle, `outcome-{item.id}`, repeated
over the outcomes; an edge on `completed` there is refused by
`uip maestro flow validate` as an undeclared source handle. This shape is
what flow-builder-sdk#718 failed on, having passed the run before with the
other shape.
"""
_write_flow(
tmp_path,
_flow_doc(
fields=_approve_reject_fields("vars.fetchExpense.output.amount"),
outcomes=_APPROVE_REJECT_OUTCOMES,
node_type="uipath.human-in-the-loop",
type_version="1.1",
exit_ports=("outcome-approve", "outcome-reject"),
),
)
result = _run_checker(tmp_path)
assert result.returncode == 0, result.stdout + result.stderr


def test_rejects_hitl_with_no_continuation(tmp_path: Path) -> None:
"""Neither shape wired is still a failure — that is what the gate is for."""
_write_flow(
tmp_path,
_flow_doc(
fields=_approve_reject_fields("vars.fetchExpense.output.amount"),
outcomes=_APPROVE_REJECT_OUTCOMES,
exit_ports=(),
),
)
result = _run_checker(tmp_path)
assert result.returncode != 0
assert "HITL completion must be wired" in result.stdout + result.stderr


def test_rejects_hardcoded_hitl_input_binding(tmp_path: Path) -> None:
_write_approve_reject_flow(tmp_path, "hardcoded-value")

Expand Down
Loading