diff --git a/engine/db.py b/engine/db.py index 2415c24..0f11426 100644 --- a/engine/db.py +++ b/engine/db.py @@ -332,13 +332,26 @@ def update_bundle(self, bundle_id: str, **fields: object) -> None: # --- Run operations (local replay/run executions) --- - def insert_run(self, run_id: str, run_path: str, *, bundle_id: str | None = None) -> None: - """Record a local replay/run execution.""" - self.conn.execute( - "INSERT INTO runs (run_id, bundle_id, run_path, created_at) VALUES (?, ?, ?, ?)", - (run_id, bundle_id, run_path, _now()), - ) - self.conn.commit() + def insert_run( + self, + run_id: str, + run_path: str, + *, + bundle_id: str | None = None, + status: str = "pending", + ) -> None: + """Record a local execution and its known outcome atomically.""" + try: + self.conn.execute( + "INSERT INTO runs " + "(run_id, bundle_id, run_path, status, created_at) " + "VALUES (?, ?, ?, ?, ?)", + (run_id, bundle_id, run_path, status, _now()), + ) + self.conn.commit() + except Exception: + self.conn.rollback() + raise def get_run(self, run_id: str) -> dict | None: """Get a single run by ID.""" diff --git a/engine/dispatch.py b/engine/dispatch.py index 25e5926..c05ac34 100644 --- a/engine/dispatch.py +++ b/engine/dispatch.py @@ -45,6 +45,20 @@ EmitFn = Callable[[str, dict], None] +_RUN_PERSISTENCE_MARKER = ".desktop-run-persistence.json" +_KNOWN_RUN_OUTCOMES = frozenset( + { + "VERIFIED", + "COMPLETED_UNVERIFIED", + "HALTED", + "FAILED", + "ROLLED_BACK", + "success", + "halt", + "unknown", + } +) + def _noop_emit(event: str, data: dict) -> None: """Default event sink -- drops events when no emitter is wired.""" @@ -211,6 +225,7 @@ def _register(self) -> None: "replay_workflow": self.replay_workflow, "run_workflow": self.run_workflow, "get_run_report": self.get_run_report, + "retry_run_persistence": self.retry_run_persistence, "teach_fix": self.teach_fix, # qualification cockpit (canonical Flow graph/policy/manifests) "get_qualification": self.get_qualification, @@ -923,11 +938,12 @@ def _replay_or_run(self, params: dict, *, run: bool) -> dict: "an action." ) - try: - self.services.db.insert_run(run_id, str(run_dir), bundle_id=workflow_id) - self.services.db.update_run(run_id, status=outcome) - except Exception: - pass + persistence = self._persist_local_run( + run_id=run_id, + run_dir=run_dir, + workflow_id=str(workflow_id), + outcome=outcome, + ) report = self._run_report( run_dir, workflow_id, @@ -935,6 +951,7 @@ def _replay_or_run(self, params: dict, *, run: bool) -> dict: outcome=outcome, error=error, ) + report["persistence"] = persistence progress_state = { "VERIFIED": "done", "COMPLETED_UNVERIFIED": "completed_unverified", @@ -982,6 +999,190 @@ def _replay_or_run(self, params: dict, *, run: bool) -> dict: ) return report + @staticmethod + def _persistence_marker_path(run_dir: Path) -> Path: + return run_dir / _RUN_PERSISTENCE_MARKER + + def _persist_local_run( + self, + *, + run_id: str, + run_dir: Path, + workflow_id: str, + outcome: str, + ) -> dict: + """Persist one local run or leave a bounded recovery marker.""" + + marker = self._persistence_marker_path(run_dir) + payload = { + "schema": "openadapt.desktop-run-persistence/v1", + "run_id": run_id, + "workflow_id": workflow_id, + "outcome": outcome, + "created_at": datetime.now(timezone.utc).isoformat(), + } + staging = marker.with_name(f"{marker.name}.{uuid.uuid4().hex}.tmp") + try: + staging.write_text(json.dumps(payload, sort_keys=True)) + staging.replace(marker) + except Exception: + staging.unlink(missing_ok=True) + return { + "state": "failed", + "retryable": False, + "message": ( + "The run report is available for this session, but Desktop " + "could not create a local history recovery record. Preserve " + "the run evidence before closing Desktop." + ), + } + + try: + self.services.db.insert_run( + run_id, + str(run_dir), + bundle_id=workflow_id, + status=outcome, + ) + except Exception: + return self._degraded_run_persistence() + + try: + marker.unlink(missing_ok=True) + except OSError: + logger.warning("Could not remove reconciled run persistence marker") + return { + "state": "persisted", + "retryable": False, + "message": "The report is saved in local history.", + } + + @staticmethod + def _degraded_run_persistence() -> dict: + return { + "state": "degraded", + "retryable": True, + "message": ( + "The report remains available, but Desktop could not add this run " + "to local history. History and Teach will not use it until the " + "local history save succeeds." + ), + } + + def _pending_run( + self, + workflow_id: str, + *, + run_id: str | None = None, + ) -> tuple[Path, dict] | None: + """Find the newest valid local-history recovery marker.""" + + runs_root = self.config.data_dir / "runs" + if not runs_root.is_dir(): + return None + matches: list[tuple[float, Path, dict]] = [] + for marker in runs_root.glob(f"*/{_RUN_PERSISTENCE_MARKER}"): + if marker.parent.is_symlink(): + continue + try: + payload = json.loads(marker.read_text()) + valid = ( + payload.get("schema") == "openadapt.desktop-run-persistence/v1" + and payload.get("workflow_id") == workflow_id + and payload.get("outcome") in _KNOWN_RUN_OUTCOMES + and isinstance(payload.get("run_id"), str) + and isinstance(payload.get("created_at"), str) + and (run_id is None or payload.get("run_id") == run_id) + ) + if valid: + matches.append((marker.stat().st_mtime, marker.parent, payload)) + except (OSError, ValueError, TypeError): + continue + if not matches: + return None + _mtime, run_dir, payload = max(matches, key=lambda item: item[0]) + return run_dir, payload + + @staticmethod + def _pending_run_is_newer(pending: tuple[Path, dict] | None, run: dict | None) -> bool: + if pending is None: + return False + if run is None: + return True + return str(pending[1].get("created_at") or "") >= str( + run.get("created_at") or "" + ) + + def retry_run_persistence(self, **params: Any) -> dict: + """Retry a failed local-history save from its bounded recovery marker.""" + + workflow_id = str(params.get("workflow_id") or "") + run_id = str(params.get("run_id") or "") + if not workflow_id or not run_id: + return {"ok": False, "error": "workflow_id and run_id are required"} + + existing = self.services.db.get_run(run_id) + if existing is not None: + if existing.get("bundle_id") != workflow_id: + return {"ok": False, "error": "The saved run belongs to another workflow"} + if existing.get("status") in _KNOWN_RUN_OUTCOMES: + report = self._run_report( + Path(str(existing["run_path"])), + workflow_id, + run_id, + outcome=str(existing["status"]), + ) + report["persistence"] = { + "state": "persisted", + "retryable": False, + "message": "The report is saved in local history.", + } + return {"ok": True, "report": report} + + pending = self._pending_run(workflow_id, run_id=run_id) + if pending is None: + return { + "ok": False, + "error": "No retryable local history record was found for this run", + } + run_dir, payload = pending + try: + if existing is None: + self.services.db.insert_run( + run_id, + str(run_dir), + bundle_id=workflow_id, + status=str(payload["outcome"]), + ) + else: + if Path(str(existing.get("run_path") or "")) != run_dir: + return { + "ok": False, + "error": "The recovery record does not match the saved run path", + } + self.services.db.update_run(run_id, status=str(payload["outcome"])) + except Exception: + return { + "ok": False, + "error": "Local history is still unavailable. Fix local storage and retry.", + } + try: + self._persistence_marker_path(run_dir).unlink(missing_ok=True) + except OSError: + logger.warning("Could not remove reconciled run persistence marker") + report = self._run_report( + run_dir, + workflow_id, + run_id, + outcome=str(payload["outcome"]), + ) + report["persistence"] = { + "state": "persisted", + "retryable": False, + "message": "The report is saved in local history.", + } + return {"ok": True, "report": report} + @staticmethod def _pre_action_refusal(error: str) -> dict: """Return the only response that proves Flow was never invoked.""" @@ -1038,10 +1239,26 @@ def _execution_target(self, params: dict) -> tuple[Any | None, Path | None]: def get_run_report(self, **params: Any) -> dict | None: """Return the latest ``RunReport`` for a workflow, or None if none.""" - workflow_id = params.get("workflow_id") + workflow_id = str(params.get("workflow_id") or "") runs = [ r for r in self.services.db.list_runs(limit=100) if r.get("bundle_id") == workflow_id ] + pending = self._pending_run(workflow_id) + pending_is_newest = self._pending_run_is_newer( + pending, + runs[0] if runs else None, + ) + if pending_is_newest: + assert pending is not None + run_dir, payload = pending + report = self._run_report( + run_dir, + workflow_id, + str(payload["run_id"]), + outcome=str(payload["outcome"]), + ) + report["persistence"] = self._degraded_run_persistence() + return report if not runs: return None run = runs[0] @@ -1049,23 +1266,42 @@ def get_run_report(self, **params: Any) -> dict | None: if not run_dir: return None stored_outcome = run.get("status") - known_outcomes = { - "VERIFIED", - "COMPLETED_UNVERIFIED", - "HALTED", - "FAILED", - "ROLLED_BACK", - "success", - "halt", - "unknown", - } - outcome = stored_outcome if stored_outcome in known_outcomes else None - return self._run_report( + if stored_outcome not in _KNOWN_RUN_OUTCOMES: + pending = self._pending_run(workflow_id, run_id=str(run.get("run_id") or "")) + if pending is not None: + pending_dir, payload = pending + report = self._run_report( + pending_dir, + workflow_id, + str(payload["run_id"]), + outcome=str(payload["outcome"]), + ) + report["persistence"] = self._degraded_run_persistence() + return report + outcome = stored_outcome if stored_outcome in _KNOWN_RUN_OUTCOMES else None + report = self._run_report( Path(run_dir), workflow_id, run.get("run_id", ""), outcome=outcome, ) + report["persistence"] = ( + { + "state": "persisted", + "retryable": False, + "message": "The report is saved in local history.", + } + if outcome is not None + else { + "state": "failed", + "retryable": False, + "message": ( + "Desktop found the run evidence, but its local history outcome " + "is incomplete and no recovery record is available." + ), + } + ) + return report def _run_report( self, @@ -1230,9 +1466,22 @@ def teach_fix(self, **params: Any) -> dict: if bundle is None: return {"promoted": False, "message": f"Unknown workflow {workflow_id}"} run = next( - (r for r in self.services.db.list_runs(limit=100) if r.get("bundle_id") == workflow_id), + ( + r + for r in self.services.db.list_runs(limit=100) + if r.get("bundle_id") == workflow_id + and r.get("status") in _KNOWN_RUN_OUTCOMES + ), None, ) + if self._pending_run_is_newer(self._pending_run(str(workflow_id)), run): + return { + "promoted": False, + "message": ( + "The latest run is not saved in local history. Retry the " + "local history save before teaching a fix." + ), + } if run is None or not run.get("run_path"): return {"promoted": False, "message": "No halted run to teach against"} out_dir = self.config.data_dir / "bundles" / f"{workflow_id}_taught_{uuid.uuid4().hex[:6]}" diff --git a/src/lib/engine.ts b/src/lib/engine.ts index 13fd64d..3b53a60 100644 --- a/src/lib/engine.ts +++ b/src/lib/engine.ts @@ -32,6 +32,7 @@ export const CMD = { REPLAY_WORKFLOW: "replay_workflow", RUN_WORKFLOW: "run_workflow", GET_RUN_REPORT: "get_run_report", + RETRY_RUN_PERSISTENCE: "retry_run_persistence", TEACH_FIX: "teach_fix", GET_QUALIFICATION: "get_qualification", INITIALIZE_QUALIFICATION: "initialize_qualification", diff --git a/src/lib/qualificationJourney.ts b/src/lib/qualificationJourney.ts new file mode 100644 index 0000000..60bca74 --- /dev/null +++ b/src/lib/qualificationJourney.ts @@ -0,0 +1,142 @@ +import type { QualificationProject } from "./types"; + +export type QualificationJourneyState = "complete" | "current" | "waiting" | "ready"; + +export interface QualificationJourneyStep { + id: string; + label: string; + detail: string; + targetId: string; + state: QualificationJourneyState; +} + +interface JourneyCandidate extends Omit { + complete: boolean; +} + +/** + * Derive the operator journey only from the signed qualification projection. + * This is presentation logic. It does not create evidence or weaken a gate. + */ +export function qualificationJourney( + project: QualificationProject, +): QualificationJourneyStep[] { + const capabilityCoverage = project.capability_coverage || { + required: [], + observed: [], + missing: [], + satisfied: false, + cases: [], + }; + const actions = project.graph.nodes.filter((node) => node.kind === "action"); + const reviewedActions = actions.filter( + (action) => project.controls.actions[action.id]?.classification?.operator_confirmed, + ).length; + const casesComplete = + project.report.case_count > 0 && + project.report.passed_case_count >= project.report.case_count && + capabilityCoverage.satisfied; + + const candidates: JourneyCandidate[] = [ + { + id: "environment", + label: "Set environment", + detail: project.project + ? `${project.project.environment.application} ${project.project.environment.application_version} · ${project.project.environment.target_kind}` + : "Name the application, version, surface, and runner requirements.", + targetId: project.project + ? "qualification-summary-section" + : "qualification-environment-section", + complete: Boolean(project.project) && !project.migration_required, + }, + { + id: "inspect", + label: "Inspect workflow", + detail: `${project.graph.nodes.length} graph nodes are available for review.`, + targetId: "qualification-graph-section", + complete: project.graph.nodes.length > 0, + }, + { + id: "risk", + label: "Review risk", + detail: `${reviewedActions} of ${actions.length} actions have an operator-confirmed risk.`, + targetId: "qualification-actions-section", + complete: actions.length > 0 && reviewedActions === actions.length, + }, + { + id: "identity", + label: "Arm identity", + detail: `${project.report.identity_covered_action_count} of ${project.report.consequential_action_count} consequential actions are covered.`, + targetId: "qualification-contract-section", + complete: + project.report.identity_covered_action_count >= + project.report.consequential_action_count, + }, + { + id: "effects", + label: "Bind effects", + detail: `${project.report.effect_covered_action_count} of ${project.report.effect_required_action_count} required effects are covered.`, + targetId: "qualification-contract-section", + complete: + project.report.effect_covered_action_count >= + project.report.effect_required_action_count, + }, + { + id: "cases", + label: "Run cases", + detail: capabilityCoverage.satisfied + ? `${project.report.passed_case_count} of ${project.report.case_count} required cases passed this revision.` + : capabilityCoverage.missing.length > 0 + ? `${project.report.passed_case_count} of ${project.report.case_count} cases passed; ${capabilityCoverage.missing.length} runner capabilities remain unobserved.` + : `${project.report.passed_case_count} of ${project.report.case_count} cases passed with current signed runner evidence.`, + targetId: "qualification-cases-section", + complete: casesComplete, + }, + { + id: "certify", + label: "Certify", + detail: project.certification_current + ? "The certification matches this exact project revision." + : "Run the certification gate after every required contract and case passes.", + targetId: "qualification-summary-section", + complete: project.certification_current, + }, + { + id: "seal", + label: "Seal", + detail: project.graph.bundle.encrypted + ? "This workflow version is sealed and encrypted." + : "Create an immutable encrypted version for export or deployment.", + targetId: "qualification-artifact-section", + complete: project.graph.bundle.encrypted, + }, + ]; + + const firstIncomplete = candidates.findIndex((candidate) => !candidate.complete); + const steps: QualificationJourneyStep[] = candidates.map((candidate, index) => ({ + id: candidate.id, + label: candidate.label, + detail: candidate.detail, + targetId: candidate.targetId, + state: candidate.complete + ? ("complete" as const) + : index === firstIncomplete + ? ("current" as const) + : ("waiting" as const), + })); + + const deliveryReady = + project.certification_current && + project.graph.bundle.encrypted && + capabilityCoverage.satisfied; + steps.push({ + id: "deliver", + label: "Export or deploy", + detail: deliveryReady + ? "The exact artifact is ready for local export or governed deployment." + : "Complete certification, sealing, and runner compatibility first.", + targetId: "qualification-artifact-section", + state: deliveryReady ? "ready" : firstIncomplete < 0 ? "current" : "waiting", + }); + return steps; +} diff --git a/src/lib/qualificationParameters.ts b/src/lib/qualificationParameters.ts new file mode 100644 index 0000000..c1ab7eb --- /dev/null +++ b/src/lib/qualificationParameters.ts @@ -0,0 +1,33 @@ +import type { QualificationParameter } from "./types"; + +export type QualificationParameterValues = Record; + +export type QualificationParameterSerialization = + | { ok: true; json: string } + | { ok: false; error: string }; + +/** Convert the default case form to the exact JSON wire format used by Flow. */ +export function serializeQualificationParameters( + parameters: QualificationParameter[], + values: QualificationParameterValues, +): QualificationParameterSerialization { + const payload: Record = {}; + for (const parameter of parameters) { + if (parameter.secret) continue; + const value = values[parameter.name] ?? ""; + if (value === "") continue; + if (parameter.type === "number") { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return { + ok: false, + error: `${parameter.name} must be a valid number.`, + }; + } + payload[parameter.name] = parsed; + } else { + payload[parameter.name] = value; + } + } + return { ok: true, json: JSON.stringify(payload) }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index bc48179..51d286a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -219,6 +219,12 @@ export interface QualificationActionControls { effects: QualificationEditableEffect[]; } +export interface QualificationParameter { + name: string; + type: string; + secret: boolean; +} + export interface QualificationNode { id: string; index: number; @@ -373,11 +379,7 @@ export interface QualificationProject { certified_at?: string | null; }; controls: { - parameters: { - name: string; - type: string; - secret: boolean; - }[]; + parameters: QualificationParameter[]; actions: Record; }; } @@ -429,6 +431,17 @@ export interface RunReport { external_network_calls: "none" | "observed" | "unknown"; compensation_actions: number; } | null; + persistence?: { + state: "persisted" | "degraded" | "failed"; + retryable: boolean; + message: string; + }; +} + +export interface RunPersistenceRetryResponse { + ok: boolean; + report?: RunReport; + error?: string; } export interface ExecutionContractCounts { diff --git a/src/screens/Qualification.test.tsx b/src/screens/Qualification.test.tsx index f93dd04..998e9e3 100644 --- a/src/screens/Qualification.test.tsx +++ b/src/screens/Qualification.test.tsx @@ -133,6 +133,7 @@ describe("Qualification effect requirements", () => { render( {}} />); + expect(await screen.findByText("Next: Run cases")).toBeTruthy(); const actionSelect = await screen.findByLabelText("Action"); const tierSelect = screen.getByLabelText( "Minimum evidence required for this effect", diff --git a/src/screens/Qualification.tsx b/src/screens/Qualification.tsx index 0c08d3d..ed208ce 100644 --- a/src/screens/Qualification.tsx +++ b/src/screens/Qualification.tsx @@ -14,6 +14,7 @@ import type { QualificationTargetKind, } from "../lib/types"; import { Button, Callout, Card, CardHead, Pill } from "../ui/primitives"; +import { QualificationJourney } from "../ui/QualificationJourney"; import { QualificationLifecycle } from "./QualificationLifecycle"; const POLICY = "clinical-write"; @@ -178,6 +179,15 @@ function certificationState(project: QualificationProject): { return { label: "needs review", tone: "crit" }; } +function scrollToQualificationSection(id: string) { + window.requestAnimationFrame(() => + document.getElementById(id)?.scrollIntoView({ + behavior: "smooth", + block: "start", + }), + ); +} + export function Qualification({ workflowId, onBack, @@ -634,6 +644,25 @@ export function Qualification({ } } + function openActionContract(stepId: string) { + setContractActionId(stepId); + scrollToQualificationSection("qualification-contract-section"); + } + + function openRefusal( + refusal: QualificationProject["report"]["refusals"][number], + ) { + if (refusal.step_id) { + openActionContract(refusal.step_id); + return; + } + scrollToQualificationSection( + refusal.case_id + ? "qualification-cases-section" + : "qualification-summary-section", + ); + } + const state = project ? certificationState(project) : null; const digest = project?.graph.bundle.provenance.content_digest; @@ -662,8 +691,10 @@ export function Qualification({ ) : ( <> + + {project.migration_required && ( - + )} - + ))} - @@ -928,7 +959,7 @@ export function Qualification({ )} - + - + - + {refusal.message}
{refusal.path}
+ ))} {project.lint.findings diff --git a/src/screens/QualificationLifecycle.test.tsx b/src/screens/QualificationLifecycle.test.tsx index d300b75..8452d17 100644 --- a/src/screens/QualificationLifecycle.test.tsx +++ b/src/screens/QualificationLifecycle.test.tsx @@ -35,7 +35,14 @@ function project(): QualificationProject { }, report: { case_count: 1, passed_case_count: 0 }, graph: { bundle: { encrypted: true } }, - controls: { parameters: [], actions: {} }, + controls: { + parameters: [ + { name: "record_id", type: "string", secret: false }, + { name: "amount", type: "number", secret: false }, + { name: "api_token", type: "string", secret: true }, + ], + actions: {}, + }, } as unknown as QualificationProject; } @@ -63,6 +70,12 @@ describe("Qualification lifecycle", () => { />, ); + 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( @@ -70,6 +83,7 @@ describe("Qualification lifecycle", () => { expect.objectContaining({ workflow_id: "wf-1", case_id: "representative-1", + parameters_json: JSON.stringify({ record_id: "CASE-42", amount: 75.5 }), target: { backend: "web" }, }), ), diff --git a/src/screens/QualificationLifecycle.tsx b/src/screens/QualificationLifecycle.tsx index 4dccd06..2a219f8 100644 --- a/src/screens/QualificationLifecycle.tsx +++ b/src/screens/QualificationLifecycle.tsx @@ -1,5 +1,9 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { CMD, engineInvoke } from "../lib/engine"; +import { + serializeQualificationParameters, + type QualificationParameterValues, +} from "../lib/qualificationParameters"; import type { ExecutionTarget, QualificationCaseKind, @@ -42,6 +46,9 @@ export function QualificationLifecycle({ ); const [selectedCaseId, setSelectedCaseId] = useState(""); const [parametersJson, setParametersJson] = useState("{}"); + const [parameterValues, setParameterValues] = + useState({}); + const [advancedParameters, setAdvancedParameters] = useState(false); const [target, setTarget] = useState(() => targetForProject(project), ); @@ -63,6 +70,13 @@ export function QualificationLifecycle({ ), [project.capability_coverage?.cases], ); + const editableParameters = useMemo( + () => project.controls.parameters.filter((parameter) => !parameter.secret), + [project.controls.parameters], + ); + const parameterSchemaKey = editableParameters + .map((parameter) => `${parameter.name}:${parameter.type}`) + .join("|"); useEffect(() => { if (!selectedCaseId && cases[0]) setSelectedCaseId(cases[0].id); @@ -70,6 +84,31 @@ export function QualificationLifecycle({ useEffect(() => setTarget(targetForProject(project)), [workflowId]); + useEffect(() => { + setParameterValues((current) => + Object.fromEntries( + editableParameters.map((parameter) => [ + parameter.name, + current[parameter.name] || "", + ]), + ), + ); + }, [parameterSchemaKey, workflowId]); + + function caseParameters(): string | null { + if (advancedParameters) return parametersJson; + const serialized = serializeQualificationParameters( + editableParameters, + parameterValues, + ); + if (!serialized.ok) { + setIssue(serialized.error); + setNotice(""); + return null; + } + return serialized.json; + } + async function mutate( command: string, params: Record, @@ -102,13 +141,15 @@ export function QualificationLifecycle({ } async function addCase() { + const caseParametersJson = caseParameters(); + if (caseParametersJson === null) return; await mutate( CMD.ADD_QUALIFICATION_CASE, { case_id: caseId.trim(), kind: caseKind, description: description.trim(), - parameters_json: parametersJson, + parameters_json: caseParametersJson, }, "add", ); @@ -116,11 +157,13 @@ export function QualificationLifecycle({ async function runCase() { if (!selectedCase) return; + const caseParametersJson = caseParameters(); + if (caseParametersJson === null) return; await mutate( CMD.RUN_QUALIFICATION_CASE, { case_id: selectedCase.id, - parameters_json: parametersJson, + parameters_json: caseParametersJson, target, ...(deploymentConfig.trim() ? { deployment_config: deploymentConfig.trim() } @@ -212,7 +255,7 @@ export function QualificationLifecycle({ )} - +
- -