Skip to content
Merged
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
1 change: 1 addition & 0 deletions engine/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1957,6 +1957,7 @@ def run_qualification_case(self, **params: Any) -> dict:
workflow_id=workflow_id,
case_id=case_id,
runtime_input_bytes=runtime_input_bytes,
fault_target=params.get("fault_target"),
policy_source=policy,
bundle_key=self._qualification_bundle_key(workflow_id),
)
Expand Down
37 changes: 36 additions & 1 deletion engine/qualification.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,8 @@ def _effect_view(index: int, effect, verification_policy) -> dict[str, Any]:
def _qualification_controls(workflow, graph: dict[str, Any]) -> dict[str, Any]:
"""Project writable controls from the executable workflow and canonical project."""

from openadapt_flow.policy import executable_actuation_paths

parameter_names = sorted(
set(workflow.params) | set(workflow.param_specs) | set(workflow.secret_params)
)
Expand Down Expand Up @@ -374,6 +376,7 @@ def _qualification_controls(workflow, graph: dict[str, Any]) -> dict[str, Any]:
identity_policy = project.identity_policies.get(step.id) if project is not None else None
actions[node["id"]] = {
"step_id": step.id,
"execution_paths": sorted(executable_actuation_paths(step)),
"classification": (classification.model_dump(mode="json") if classification else None),
"identity": {
"can_arm": bool(sources),
Expand Down Expand Up @@ -1098,6 +1101,7 @@ def set_local_qualification_case_scope(
workflow_id: str,
case_id: str,
runtime_input_bytes: bytes,
fault_target: dict[str, Any] | None = None,
policy_source: str = DEFAULT_QUALIFICATION_POLICY,
bundle_key: str | None = None,
) -> dict:
Expand All @@ -1118,12 +1122,42 @@ def set_local_qualification_case_scope(
if worklists:
raise QualificationError("Desktop qualification cases do not yet support worklists")
steps = {step.id: step for step in api["iter_workflow_steps"](workflow)}
selected_fault_target = None
if fault_target is not None:
if not isinstance(fault_target, dict) or set(fault_target) != {
"step_id",
"actuation_path",
}:
raise QualificationError("Fault target must name one exact action and actuation path")
try:
selected_fault_target = api["QualificationActionTarget"](
step_id=fault_target["step_id"],
actuation_path=fault_target["actuation_path"],
)
except (ValueError, TypeError) as exc:
raise QualificationError(
"Fault target must name one exact action and actuation path"
) from exc
fault_step = steps.get(selected_fault_target.step_id)
if (
fault_step is None
or selected_fault_target.actuation_path
not in executable_actuation_paths(fault_step)
):
raise QualificationError("Fault target is outside the executable case scope")
targets = []
for step_id, step in sorted(steps.items()):
paths = executable_actuation_paths(step)
if not paths:
continue
path = "gui" if "gui" in paths else "api" if "api" in paths else None
if selected_fault_target is not None and selected_fault_target.step_id == step_id:
path = selected_fault_target.actuation_path
elif "gui" in paths:
path = "gui"
elif "api" in paths:
path = "api"
else:
path = None
if path is None:
raise QualificationError(f"Qualification action {step_id!r} has no executable path")
targets.append(api["QualificationActionTarget"](step_id=step_id, actuation_path=path))
Expand All @@ -1133,6 +1167,7 @@ def set_local_qualification_case_scope(
case_id=case_id,
runtime_input_sha256=hashlib.sha256(runtime_input_bytes).hexdigest(),
action_targets=targets,
fault_target=selected_fault_target,
)
_save(workflow, bundle_dir, key=bundle_key)
except (ValueError, TypeError) as exc:
Expand Down
3 changes: 3 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export interface QualificationEditableEffect {

export interface QualificationActionControls {
step_id: string;
execution_paths: ("gui" | "api")[];
classification?: {
step_id: string;
classification: QualificationRisk | "unknown";
Expand Down Expand Up @@ -290,6 +291,8 @@ export interface QualificationProject {
input_ref?: string | null;
expected_outcome: string;
required: boolean;
action_targets?: { step_id: string; actuation_path: "gui" | "api" }[];
fault_target?: { step_id: string; actuation_path: "gui" | "api" } | null;
results: {
project_revision: number;
runner_capabilities: string[];
Expand Down
4 changes: 2 additions & 2 deletions src/screens/Qualification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1382,7 +1382,7 @@ export function Qualification({
? "64-character session digest"
: signal.source === "application"
? "accuro or https://app.example"
: "patient-chart"
: "record-review"
}
spellCheck={false}
/>
Expand Down Expand Up @@ -1695,7 +1695,7 @@ export function Qualification({
{!parameters.length ? (
<Callout tone="warn" title="Workflow parameters required">
Add typed workflow parameters before binding a reusable effect.
OpenAdapt will not seal a patient or account literal into this form.
OpenAdapt will not seal a live identity value into this form.
</Callout>
) : (
<>
Expand Down
121 changes: 121 additions & 0 deletions src/screens/QualificationLifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,33 @@ function project(): QualificationProject {
} as unknown as QualificationProject;
}

function twoActionFaultProject(): QualificationProject {
const value = project();
value.project!.cases.push({
id: "fault-wrong-identity",
kind: "wrong_identity",
description: "Deterministic wrong identity refusal case",
expected_outcome: "halted",
required: true,
results: [],
});
value.controls.actions = {
open: {
step_id: "open",
execution_paths: ["gui"],
identity: { can_arm: false, armed: false, sources: [], policy: null },
effects: [],
},
save: {
step_id: "save",
execution_paths: ["api", "gui"],
identity: { can_arm: false, armed: false, sources: [], policy: null },
effects: [],
},
};
return value;
}

describe("Qualification lifecycle", () => {
beforeEach(() => {
mockedEngineInvoke.mockReset();
Expand Down Expand Up @@ -125,4 +152,98 @@ describe("Qualification lifecycle", () => {
fireEvent.click(screen.getByRole("button", { name: "Create working version" }));
await waitFor(() => expect(onOpenWorkflow).toHaveBeenCalledWith("wf-2"));
});

it("prefills a typed fault case instead of requiring a hand-written case id", async () => {
render(
<QualificationLifecycle
workflowId="wf-1"
project={project()}
onProject={() => {}}
onOpenWorkflow={() => {}}
/>,
);

fireEvent.click(screen.getByText("Add another qualification case"));
fireEvent.click(screen.getByRole("button", { name: "Use Wrong record" }));

expect((screen.getByLabelText("Case id") as HTMLInputElement).value).toBe(
"wrong-identity-1",
);
expect((screen.getByLabelText("Case type") as HTMLSelectElement).value).toBe(
"wrong_identity",
);
expect((screen.getByLabelText("Description") as HTMLInputElement).value).toBe(
"The live record does not match the qualified identity; the run must halt.",
);

fireEvent.change(screen.getByLabelText("record id"), {
target: { value: "CASE-42" },
});
fireEvent.change(screen.getByLabelText("amount"), {
target: { value: "75.5" },
});
fireEvent.click(screen.getByRole("button", { name: "Add case" }));
await waitFor(() =>
expect(mockedEngineInvoke).toHaveBeenCalledWith(
CMD.ADD_QUALIFICATION_CASE,
expect.objectContaining({
workflow_id: "wf-1",
case_id: "wrong-identity-1",
kind: "wrong_identity",
description:
"The live record does not match the qualified identity; the run must halt.",
parameters_json: JSON.stringify({
record_id: "CASE-42",
amount: 75.5,
priority: "routine",
}),
}),
),
);
});

it("reuses a default fault case and sends its exact target for two actions", async () => {
const value = twoActionFaultProject();
render(
<QualificationLifecycle
workflowId="wf-1"
project={value}
onProject={() => {}}
onOpenWorkflow={() => {}}
/>,
);

fireEvent.click(screen.getByText("Add another qualification case"));
fireEvent.click(screen.getByRole("button", { name: "Select Wrong record" }));
expect(screen.getByText("Run fault-wrong-identity")).toBeTruthy();
expect(mockedEngineInvoke).not.toHaveBeenCalledWith(
CMD.ADD_QUALIFICATION_CASE,
expect.anything(),
);

fireEvent.change(screen.getByLabelText("Fault action"), {
target: { value: "save" },
});
fireEvent.change(screen.getByLabelText("Actuation path"), {
target: { value: "api" },
});
fireEvent.change(screen.getByLabelText("record id"), {
target: { value: "CASE-42" },
});
fireEvent.change(screen.getByLabelText("amount"), {
target: { value: "75.5" },
});
fireEvent.click(screen.getByRole("button", { name: "Run and sign case" }));

await waitFor(() =>
expect(mockedEngineInvoke).toHaveBeenCalledWith(
CMD.RUN_QUALIFICATION_CASE,
expect.objectContaining({
workflow_id: "wf-1",
case_id: "fault-wrong-identity",
fault_target: { step_id: "save", actuation_path: "api" },
}),
),
);
});
});
Loading