diff --git a/docs/BETA_NATIVE_INSTALLERS.md b/docs/BETA_NATIVE_INSTALLERS.md index b947708..1272cdf 100644 --- a/docs/BETA_NATIVE_INSTALLERS.md +++ b/docs/BETA_NATIVE_INSTALLERS.md @@ -10,7 +10,7 @@ only the fixed `openadapt://connect` action and forwards it to the sidecar's strict, transactional pairing flow. The canonical compiler and governed runtime remain in `openadapt-flow`. Each -native installer freezes the exact `openadapt-flow[browser,console]==1.25.0` +native installer freezes the exact `openadapt-flow[browser,console]==1.27.1` runtime and its `playwright==1.61.0` browser automation dependency into the Desktop sidecar. The `console` extra is what lets an installed application serve the attended decision console the mobile decision portal relays; the diff --git a/docs/DECISION_PORTAL.md b/docs/DECISION_PORTAL.md index 2087572..f0b6794 100644 --- a/docs/DECISION_PORTAL.md +++ b/docs/DECISION_PORTAL.md @@ -1,8 +1,17 @@ # The mobile decision portal When a governed run cannot confirm something, OpenAdapt halts instead of -guessing. This portal is how that question reaches a staff member's phone -without moving protected evidence off the runner. +guessing. This portal is how that question reaches a staff member's phone with +**full evidence** -- the screen crops, the gated control label, the whole halt +detail -- without moving any of it off the runner. + +It is not the only way to reach a phone, and it is not the right one for a +customer with no IT department. Serving full evidence to a phone requires an +HTTPS origin the customer terminates themselves, which a dental practice will +not stand up. For them, Flow's hosted lane dials **out** to the control plane +and needs nothing on their network; it carries the signed PHI-free task and the +closed halt context, never pixels. See +[Answering a halt with no ingress](#answering-a-halt-with-no-ingress). Desktop owns the **lifecycle, the network boundary, device pairing, and the generic notification**. It owns no decision semantics: the question, the @@ -48,6 +57,37 @@ The rules, all enforced in `engine/portal/ingress.py` and all fail-closed: There is no self-signed-certificate bypass and no test-only wide bind. The test suite exercises the shipped loopback configuration on a real socket. +## Answering a halt with no ingress + +Everything above is about publishing **this** surface, which serves protected +evidence. A customer who does not operate an ingress uses Flow's hosted lane +instead: the engine makes outbound HTTPS requests only, so there is no inbound +port, no port forward, no certificate, no reverse proxy and no static address, +and it works behind NAT. + +Desktop turns it on when the operator's `deployment.json` sets +`human_decisions.remote.enabled: true`. It then passes `--remote-decisions` to +the attended console and hands it the runner credential from the keychain in the +child process's environment -- never in `argv`, where it would sit in the +process table for every user on the machine. + +Two things are checked **before** the console is spawned, because afterwards the +failure is an opaque "the local decision service did not start": + +- this computer must be registered with the control plane, or Desktop refuses + and names the host; and +- the resolved Flow must be at least `MIN_FLOW_FOR_REMOTE_DECISIONS` + (`engine/portal/service.py`), because an older one exits on the unknown flag + before printing its capability banner. + +`enabled` must be literally `true`. A truthy string or a `1` means no. Neither +check degrades: a lane that looks on and is not is worse than one that is +plainly off, because nothing on either surface would say the phone will never +ring. + +The two paths are independent. A deployment may run the local portal, the hosted +lane, both, or neither. + ## Pairing a phone Desktop shows a QR code; the phone shows a short code back. Following RFC @@ -113,7 +153,15 @@ is deliberate: a free-text explanation field on the wire is how protected content escapes a closed contract, so the runner never sends prose and the phone never renders a runner string. -**What broke.** `presentation.halt` carries the engine's typed halt category, +The primary screen is intentionally short: one question, the delivery and risk +signals, one retained application frame, and what OpenAdapt will check next. +The resolution ladder, evidence counts, expiry, and full action consequences +remain available under **Technical details and action effects**. A terminal +receipt replaces the request with a visually distinct result screen. A +non-terminal refusal leaves the request in place so the operator can correct +the live application and answer again. + +**Why it stopped.** `presentation.halt` carries the engine's typed halt category, the step's ordinal, its action kind, the target's role, and the target's label *only when Flow proved that label is static control chrome rather than record content*. The shell composes "Step 1 of 6 could not start: OpenAdapt could not @@ -135,6 +183,13 @@ names. Each button carries its consequence, and the card states it in full: correction* changes future runs and continues nothing now; *needs more help* leaves the run paused and untouched. +**Reconcile is not retry.** A signed `reconcile` action is shown only when +Flow records that an earlier action was delivered or may have been delivered. +It asks Flow to prove the already-requested business effect. It never sends the +earlier action again. The phone reports reconciliation success only when the +receipt has both `report_success=true` and the bound transition receipt digest. +If either is absent, it reports an incomplete receipt instead of success. + **Stop and needs-more-help are not two phrasings of one answer.** Escalation *parks* the run: the durable pause stays intact and a qualified operator can still continue it. A rejection *terminates* it — the engine marks the pause @@ -167,6 +222,39 @@ prevents. A runner still returning the older decision record is mapped through Flow's own status table so it renders identically. `src/portalShell.test.ts` drives the shipped `app.js` in a DOM and pins each of those outcomes. +## Synthetic phone screenshots + +Use the deterministic fixture generator when a document or demo needs current +phone screens. It contains synthetic labels and closed receipt results only. It +does not start a runner and does not read customer evidence: + +```bash +uv run --extra build python scripts/capture_portal_scenarios.py \ + --out /tmp/openadapt-desktop-phone-fixtures +``` + +The command writes one pre-action and one result image for each of the six +operator request types: identity, ambiguity, human step, saved-result check, +delivery uncertainty, and a declared optional-step halt. The set covers each +portable action result. The files +include `/tmp/openadapt-desktop-phone-fixtures/delivery-uncertain.png` and +`/tmp/openadapt-desktop-phone-fixtures/delivery-uncertain-reconcile-result.png`. +The generator requires `--out` so generated images do not enter the Desktop +package or source tree by accident. + +To show the retained application context inside the phone, provide a raster +frame from a public reference run: + +```bash +uv run --extra build python scripts/capture_portal_scenarios.py \ + --out /tmp/openadapt-desktop-phone-fixtures \ + --frame /path/to/public-reference-run-frame.png +``` + +The retained frame is visible in each request image. Use only a public +reference frame with synthetic data. The portal labels the frame as historical +and tells the operator to use the live application before answering. + ## Notifications Operating-system notifications are generic by construction. Desktop reads a @@ -181,7 +269,7 @@ the dispatcher, and again in the shell before the plugin is called. ### Packaging: done The frozen sidecar now carries the console. `pyproject.toml` pins -`openadapt-flow[browser,console]==1.25.0`, `scripts/build_frozen_engine.py` +`openadapt-flow[browser,console]==1.27.1`, `scripts/build_frozen_engine.py` collects uvicorn's run-time string imports and `openadapt_flow.console`, and the executable is built unbuffered so the console's one-time capability banner survives the pipe the portal reads it from. `scripts/smoke_test_frozen_flow.py` @@ -220,7 +308,7 @@ sensitive when it derives log redactions. A file like that is not eligible to sit on disk for the hours a portal session can last. Re-staging it per run would also be theatre. In the pinned -`openadapt-flow==1.25.0`, `_attended_service_from_args` resolves `--config` +`openadapt-flow==1.27.1`, `_attended_service_from_args` resolves `--config` eagerly through `load_deployment` **before** it yields, and `AttendedActionService` is built from the parsed `DeploymentConfig` object and never sees the path again. Rewriting the file later changes nothing about what diff --git a/engine/__init__.py b/engine/__init__.py index a8861b7..199901f 100644 --- a/engine/__init__.py +++ b/engine/__init__.py @@ -21,4 +21,4 @@ +-- backends/ Storage backend plugins """ -__version__ = "0.14.0" +__version__ = "0.15.0" 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..a4213dd 100644 --- a/engine/dispatch.py +++ b/engine/dispatch.py @@ -45,6 +45,21 @@ 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", + } +) +_TEACHABLE_RUN_OUTCOMES = frozenset({"HALTED", "halt"}) + def _noop_emit(event: str, data: dict) -> None: """Default event sink -- drops events when no emitter is wired.""" @@ -211,6 +226,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, @@ -781,6 +797,7 @@ def _replay_or_run(self, params: dict, *, run: bool) -> dict: run_dir.mkdir(parents=True, exist_ok=True) params_file = None qualification_case_id = params.get("_qualification_case_id") + qualification_case_execution = params.get("_qualification_case_execution") if qualification_case_id is not None: from engine.qualification_lifecycle import case_parameters_path @@ -868,7 +885,23 @@ def _replay_or_run(self, params: dict, *, run: bool) -> dict: run_kwargs["params_file"] = params_file if bundle_env: run_kwargs["env_overrides"] = bundle_env - result = self.services.flow_bridge.run(bundle, config_path, **run_kwargs) + if qualification_case_execution is not None: + result = self.services.flow_bridge.qualify_run_case( + bundle, + config_path, + case_id=str(qualification_case_execution["case_id"]), + inputs_file=Path(qualification_case_execution["inputs_file"]), + campaign_id=str(qualification_case_execution["campaign_id"]), + run_id=run_id, + out_dir=run_dir, + env_overrides=bundle_env, + ) + else: + result = self.services.flow_bridge.run( + bundle, + config_path, + **run_kwargs, + ) else: replay_kwargs: dict[str, Any] = { "out_dir": run_dir, @@ -923,11 +956,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 +969,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 +1017,188 @@ 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 +1255,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 +1282,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, @@ -1229,12 +1481,41 @@ def teach_fix(self, **params: Any) -> dict: bundle = self._bundle_dir(workflow_id) if bundle is None: return {"promoted": False, "message": f"Unknown workflow {workflow_id}"} + # Teach is a response to the latest execution state, not a search for + # any historical halt. Selecting an older halt after a newer VERIFIED, + # FAILED, or otherwise terminal run would promote evidence against a + # stale application state. run = next( (r for r in self.services.db.list_runs(limit=100) if r.get("bundle_id") == workflow_id), None, ) - if run is None or not run.get("run_path"): + pending = self._pending_run(str(workflow_id)) + pending_matches_saved_run = bool( + pending is not None + and run is not None + and pending[1].get("run_id") == run.get("run_id") + ) + if pending_matches_saved_run or self._pending_run_is_newer(pending, 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: return {"promoted": False, "message": "No halted run to teach against"} + if run.get("status") not in _TEACHABLE_RUN_OUTCOMES: + status = str(run.get("status") or "unknown") + return { + "promoted": False, + "message": f"The latest run ended as {status}, so it is not teachable.", + } + if not run.get("run_path"): + return { + "promoted": False, + "message": "The latest halted run has no evidence path", + } out_dir = self.config.data_dir / "bundles" / f"{workflow_id}_taught_{uuid.uuid4().hex[:6]}" try: result = self.services.flow_bridge.teach(Path(run["run_path"]), bundle, out_dir) @@ -1609,10 +1890,12 @@ def run_qualification_case(self, **params: Any) -> dict: DEFAULT_QUALIFICATION_POLICY, prepare_local_qualification_runner, record_local_qualification_result, + set_local_qualification_case_scope, ) from engine.qualification_lifecycle import ( retain_capability_observation, retain_run_evidence, + stage_case_runtime_inputs, store_case_parameters, ) @@ -1648,6 +1931,35 @@ def run_qualification_case(self, **params: Any) -> dict: parameters_json=str(parameters_json), forbidden_keys=secret_params, ) + from engine.qualification import _load + from engine.qualification_lifecycle import case_parameters_path + + parameters_path = case_parameters_path( + self.config.data_dir, + workflow_id=workflow_id, + case_id=case_id, + ) + if parameters_path is None: + raise ValueError("Qualification case parameters are required before execution") + workflow_for_inputs = _load( + bundle, + key=self._qualification_bundle_key(workflow_id), + ) + inputs_path, runtime_input_bytes = stage_case_runtime_inputs( + self.config.data_dir, + workflow_id=workflow_id, + case_id=case_id, + workflow=workflow_for_inputs, + parameters_path=parameters_path, + ) + set_local_qualification_case_scope( + bundle, + workflow_id=workflow_id, + case_id=case_id, + runtime_input_bytes=runtime_input_bytes, + policy_source=policy, + bundle_key=self._qualification_bundle_key(workflow_id), + ) prepare_local_qualification_runner( bundle, workflow_id=workflow_id, @@ -1657,6 +1969,11 @@ def run_qualification_case(self, **params: Any) -> dict: execution_params = { "workflow_id": workflow_id, "_qualification_case_id": case_id, + "_qualification_case_execution": { + "case_id": case_id, + "inputs_file": str(inputs_path), + "campaign_id": uuid.uuid4().hex, + }, } if params.get("target") is not None: execution_params["target"] = params["target"] @@ -1690,6 +2007,7 @@ def run_qualification_case(self, **params: Any) -> dict: run_id=run_id, run_dir=Path(str(run["run_path"])), report_bytes=raw_report_bytes, + runtime_input_bytes=runtime_input_bytes, ) from openadapt_flow.traversal import iter_workflow_steps diff --git a/engine/flow_bridge.py b/engine/flow_bridge.py index 42fbdb4..9a669b5 100644 --- a/engine/flow_bridge.py +++ b/engine/flow_bridge.py @@ -17,8 +17,10 @@ from __future__ import annotations +import hashlib import json import os +import re import shutil import subprocess import sys @@ -67,7 +69,7 @@ "compensation", } ) -_OUTCOME_ENVELOPE_KEYS = frozenset( +_OUTCOME_ENVELOPE_REQUIRED_KEYS = frozenset( { "version", "outcome", @@ -82,6 +84,42 @@ "compensation_actions", } ) +_OUTCOME_ENVELOPE_OPTIONAL_KEYS = frozenset( + { + "qualification_evidence_only", + "workflow_contract_sha256", + "postcondition_evidence", + } +) +_POSTCONDITION_EVIDENCE_KEYS = frozenset( + { + "result_index", + "workflow_contract_sha256", + "step_index", + "step_contract_sha256", + "action_kind", + "actuation_path", + "contract_kind", + "contract_index", + "contract_sha256", + "verdict", + } +) +_POSTCONDITION_ACTION_KINDS = frozenset( + { + "click", + "double_click", + "right_click", + "drag", + "type", + "select_option", + "key", + "hotkey", + "wait", + "scroll", + } +) +_SHA256_RE = re.compile(r"^[a-f0-9]{64}$") def _contract_counts(value: object) -> dict[str, int] | None: @@ -98,7 +136,10 @@ def _contract_counts(value: object) -> dict[str, int] | None: def _valid_precise_envelope(report: dict, envelope: dict, outcome: object) -> bool: """Mirror Flow's v1 envelope invariants at the Desktop trust boundary.""" - if set(envelope) != _OUTCOME_ENVELOPE_KEYS: + envelope_keys = set(envelope) + if not _OUTCOME_ENVELOPE_REQUIRED_KEYS <= envelope_keys or not envelope_keys <= ( + _OUTCOME_ENVELOPE_REQUIRED_KEYS | _OUTCOME_ENVELOPE_OPTIONAL_KEYS + ): return False profile = envelope.get("profile") if profile is not None and profile not in _OUTCOME_PROFILES: @@ -113,6 +154,95 @@ def _valid_precise_envelope(report: dict, envelope: dict, outcome: object) -> bo return False if any(passed[key] > required[key] for key in _OUTCOME_CONTRACT_KEYS): return False + qualification_only = envelope.get("qualification_evidence_only", False) + if not isinstance(qualification_only, bool): + return False + workflow_digest = envelope.get("workflow_contract_sha256") + if workflow_digest is not None and ( + not isinstance(workflow_digest, str) or not _SHA256_RE.fullmatch(workflow_digest) + ): + return False + postcondition_evidence = envelope.get("postcondition_evidence", []) + if ( + not isinstance(postcondition_evidence, list) + or len(postcondition_evidence) != required["postcondition"] + ): + return False + if postcondition_evidence and workflow_digest is None: + return False + evidence_keys: list[tuple[int, str, int]] = [] + result_contracts: list[tuple[int, str]] = [] + passed_postconditions = 0 + for item in postcondition_evidence: + if not isinstance(item, dict) or set(item) != _POSTCONDITION_EVIDENCE_KEYS: + return False + integer_fields = ("result_index", "step_index", "contract_index") + if any( + not isinstance(item.get(field), int) + or isinstance(item.get(field), bool) + or item[field] < 0 + for field in integer_fields + ): + return False + if ( + item.get("workflow_contract_sha256") != workflow_digest + or item.get("action_kind") not in _POSTCONDITION_ACTION_KINDS + or item.get("actuation_path") != "gui" + or item.get("contract_kind") not in {"explicit_predicate", "intrinsic_input_readback"} + or item.get("verdict") not in {"passed", "refuted", "unverifiable"} + ): + return False + if item["contract_kind"] == "intrinsic_input_readback" and ( + item["action_kind"] not in {"type", "select_option"} or item["contract_index"] != 0 + ): + return False + if any( + not isinstance(item.get(field), str) or not _SHA256_RE.fullmatch(item[field]) + for field in ("step_contract_sha256", "contract_sha256") + ): + return False + expected_step = hashlib.sha256( + json.dumps( + { + "domain": "openadapt.postcondition-step/v1", + "workflow_contract_sha256": workflow_digest, + "step_index": item["step_index"], + "action_kind": item["action_kind"], + }, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + ).hexdigest() + if item["step_contract_sha256"] != expected_step: + return False + expected_contract = hashlib.sha256( + json.dumps( + { + "domain": "openadapt.postcondition-contract/v1", + "workflow_contract_sha256": workflow_digest, + "step_contract_sha256": expected_step, + "action_kind": item["action_kind"], + "actuation_path": "gui", + "contract_kind": item["contract_kind"], + "contract_index": item["contract_index"], + }, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + ).hexdigest() + if item["contract_sha256"] != expected_contract: + return False + evidence_keys.append((item["result_index"], item["contract_kind"], item["contract_index"])) + result_contracts.append((item["result_index"], item["contract_sha256"])) + passed_postconditions += item["verdict"] == "passed" + if ( + len(evidence_keys) != len(set(evidence_keys)) + or len(result_contracts) != len(set(result_contracts)) + or passed_postconditions != passed["postcondition"] + ): + return False evidence = envelope.get("evidence_classes") if ( not isinstance(evidence, list) @@ -122,6 +252,8 @@ def _valid_precise_envelope(report: dict, envelope: dict, outcome: object) -> bo or len(evidence) != len(set(evidence)) ): return False + if ("postcondition" in evidence) != bool(passed["postcondition"]): + return False model_calls = envelope.get("model_calls") compensation_actions = envelope.get("compensation_actions") if ( @@ -145,12 +277,14 @@ def _valid_precise_envelope(report: dict, envelope: dict, outcome: object) -> bo if ( required != passed or profile not in {"standard", "regulated"} - or production is not True + or not (production is True or qualification_only is True) or required["authorization"] < 1 ): return False elif production is not False: return False + if production and qualification_only: + return False has_compensation = "compensation" in evidence if has_compensation != (compensation_actions > 0): return False @@ -575,6 +709,46 @@ def run( env_overrides=env_overrides, ) + def qualify_run_case( + self, + bundle_dir: Path, + config: Path, + *, + case_id: str, + inputs_file: Path, + campaign_id: str, + run_id: str, + out_dir: Path, + url: str | None = None, + env_overrides: dict[str, str] | None = None, + ) -> FlowResult: + """Run one qualification case through Flow-owned authorization. + + Desktop supplies only private canonical inputs and stable local ids. + Flow builds and consumes the Standard authorization in the same process. + """ + + args = [ + "qualify", + "run-case", + str(bundle_dir), + "--case-id", + case_id, + "--inputs", + str(inputs_file), + "--campaign-id", + campaign_id, + "--run-id", + run_id, + "--run-dir", + str(out_dir), + "--config", + str(config), + ] + if url: + args += ["--url", url] + return self._run(args, out_dir=out_dir, env_overrides=env_overrides) + def run_supports_authorization(self) -> bool: """Probe (once) whether the installed flow CLI accepts ``--authorization-file``.""" if self._run_auth_support is None: diff --git a/engine/portal/service.py b/engine/portal/service.py index 077a3ca..1a8da5a 100644 --- a/engine/portal/service.py +++ b/engine/portal/service.py @@ -34,7 +34,7 @@ builds log redactions. So this file is *not* "a backend and a URL", and it is not eligible to sit on disk for a whole portal session. -It is also pointless to re-stage it per run. In ``openadapt-flow==1.25.0`` +It is also pointless to re-stage it per run. In the pinned OpenAdapt Flow runtime (the exact pin this installer ships) ``__main__._attended_service_from_args`` resolves ``--config`` **eagerly**, through ``load_deployment``, before it yields; ``AttendedActionService`` is constructed from the parsed @@ -70,7 +70,9 @@ from typing import Any, Callable from loguru import logger +from packaging.version import InvalidVersion, Version +from engine.auth.store import load_runner_credential from engine.flow_bridge import FLOW_BIN, _flow_command, _subprocess_env from engine.portal.flow_client import FlowConsoleClient, FlowConsoleUnavailable from engine.portal.ingress import IngressError, PortalIngress, resolve_ingress @@ -96,6 +98,17 @@ #: How long to wait for the console to announce itself before failing loud. CONSOLE_START_TIMEOUT_S = 60.0 +#: The first Flow release whose attended console accepts ``--remote-decisions``. +#: Passing the flag to an older Flow makes argparse exit before the banner, so +#: the operator would see "the console did not start" instead of the real cause. +#: Checking the version first turns that into a sentence that names the fix. +MIN_FLOW_FOR_REMOTE_DECISIONS = (1, 26, 0) + +#: Environment variable ``openadapt_flow.console.decision_relay`` reads the +#: runner credential from. It is passed to the child process only, never +#: written to a file and never logged. +RUNNER_TOKEN_ENV = "OPENADAPT_RUNNER_TOKEN" + #: How long uvicorn may take to bind *after* the banner is printed. Flow #: prints the capability immediately before ``uvicorn.run()``, so the first #: request can legitimately arrive at a closed port. @@ -415,9 +428,83 @@ def _start_console(self) -> ConsoleProcess: # The staged secret-bearing config lives only until the banner proves # Flow has read it; leaving this ``with`` block removes the file. with stage_private_yaml(staging_dir, prepared=prepared) as config_path: - return self._spawn_console(prefix, config_path) + return self._spawn_console( + prefix, + config_path, + remote_decisions=prepared.remote_decisions, + remote_decision_runner_id=prepared.remote_decision_runner_id, + ) + + def _remote_decision_env( + self, + *, + host: str, + expected_runner_id: str, + ) -> dict[str, str]: + """The child's environment with the runner credential, or a refusal. + + A deployment that enabled remote decisions and has no runner credential + must stop here. Starting the console without the credential would give + the operator a working local portal and a phone lane that is silently + absent -- the failure that is worse than the gap, because nothing on + either surface says the phone will never ring. + """ + + credential = load_runner_credential(host) + credential_runner_id = str((credential or {}).get("runner_id") or "").strip() + token = str((credential or {}).get("runner_token") or "").strip() + if not token: + raise PortalError( + "This deployment answers halts on a phone through OpenAdapt " + f"Cloud, but this computer is not registered with {host} yet. " + "Connect it once, then start the portal again." + ) + if credential_runner_id != expected_runner_id: + raise PortalError( + "The deployment configuration names a different runner than " + "the credential registered for this control-plane host. Select " + "the matching deployment or connect this computer again." + ) + env = _subprocess_env() + env[RUNNER_TOKEN_ENV] = token + return env + + def _assert_flow_supports_remote_decisions(self) -> None: + """Refuse before spawning when the resolved Flow has no such flag.""" - def _spawn_console(self, prefix: list[str], config_path: Path) -> ConsoleProcess: + from importlib.metadata import PackageNotFoundError, version + + try: + raw = version("openadapt-flow") + except PackageNotFoundError: # pragma: no cover - defensive + raise PortalError( + "The OpenAdapt Flow runtime version could not be read, so " + "phone decisions cannot be enabled safely." + ) from None + wanted = ".".join(str(part) for part in MIN_FLOW_FOR_REMOTE_DECISIONS) + try: + installed = Version(raw) + required = Version(wanted) + except InvalidVersion: + raise PortalError( + "The OpenAdapt Flow runtime has an invalid version, so phone " + "decisions cannot be enabled safely. Reinstall OpenAdapt Flow." + ) from None + if installed < required: + raise PortalError( + "This deployment answers halts on a phone, which needs " + f"openadapt-flow {wanted} or newer. This computer has {raw}. " + "Update OpenAdapt, or turn off human_decisions.remote." + ) + + def _spawn_console( + self, + prefix: list[str], + config_path: Path, + *, + remote_decisions: bool = False, + remote_decision_runner_id: str | None = None, + ) -> ConsoleProcess: command = [ *prefix, "console", @@ -432,12 +519,30 @@ def _spawn_console(self, prefix: list[str], config_path: Path) -> ConsoleProcess "--port", str(int(getattr(self.config, "portal_console_port", 7863))), ] + env = _subprocess_env() + if remote_decisions: + # Both checks happen BEFORE the spawn. A console that starts and + # then dies on an unknown flag reports "the console did not start", + # which names neither the missing credential nor the old runtime. + self._assert_flow_supports_remote_decisions() + host = str(getattr(self.config, "hosted_host", "") or "").strip() + if not host: + raise PortalError("Remote decisions need an exact hosted control-plane URL.") + if not remote_decision_runner_id: + raise PortalError( + "Remote decisions need an exact runner_id in the deployment configuration." + ) + env = self._remote_decision_env( + host=host, + expected_runner_id=remote_decision_runner_id, + ) + command.extend(["--remote-decisions", "--remote-decision-host", host]) process = self._popen( command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - env=_subprocess_env(), + env=env, ) # Both pipes are drained for the process's lifetime. A blocking # readline() here would ignore the timeout entirely (it is only checked diff --git a/engine/portal/shell/app.js b/engine/portal/shell/app.js index 2c079d9..98d8d77 100644 --- a/engine/portal/shell/app.js +++ b/engine/portal/shell/app.js @@ -64,6 +64,18 @@ const ACTION_WIRE = { "Passes the decision on. The run stays paused exactly where it is and " + "nothing in the application is touched.", }, + reconcile: { + // Reconciliation is not a retry. The runner looks for the declared + // persisted effect of the earlier delivery and reports what it finds. It + // never dispatches that earlier action again from this decision path. + wire: "reconcile", + label: "Check what happened", + brief: "Checks, never resends", + consequence: + "OpenAdapt checks whether the earlier action already changed the system " + + "of record. It does not send that action again. If it cannot prove the " + + "result, the run stays paused for reconciliation.", + }, }; const DISPOSITION = { @@ -72,6 +84,7 @@ const DISPOSITION = { reject: "rejected_by_operator", teach: "teach_requested", escalate: "needs_assistance", + reconcile: "reconcile_requested", }; // -------------------------------------------------- the terminal receipt shape @@ -88,22 +101,39 @@ const DISPOSITION = { const RECEIPT_COPY = { "completed/verified_and_resumed": { + label: "CHECKS PASSED", + tone: "success", text: "Checked and continued. OpenAdapt re-read the live screen, its checks " + "passed, and the workflow moved on.", terminal: true, }, "completed/skipped_and_resumed": { + label: "STEP SKIPPED", + tone: "neutral", text: "Step skipped. The workflow continued from the next step.", terminal: true, }, + "completed/reconciled_and_resumed": { + label: "RESULT PROVED", + tone: "success", + text: + "Reconciled and continued. OpenAdapt proved the earlier action already " + + "changed the system of record. It did not send that action again.", + terminal: true, + reconciliation: true, + }, "halted/continuation_halted": { + label: "PAUSED AGAIN", + tone: "halted", text: "OpenAdapt continued and then stopped again further on. Nothing was " + "guessed. Open this run on the computer to see where it stopped.", terminal: true, }, "refused/revalidation_refused": { + label: "CHECK DID NOT PASS", + tone: "halted", text: "OpenAdapt re-read the live screen and it is still not in the state this " + "step needs, so it did nothing. Fix it in the application, then answer " + @@ -111,29 +141,39 @@ const RECEIPT_COPY = { terminal: false, }, "expired/expired": { + label: "DECISION EXPIRED", + tone: "neutral", text: "This decision expired before it reached the computer. Reload for a " + "fresh one.", terminal: true, }, "delivery_uncertain/delivery_uncertain": { + label: "RESULT UNCERTAIN", + tone: "uncertain", text: "This may already have been sent. Do not answer again — reconcile it on " + "the computer running OpenAdapt.", terminal: true, }, "accepted_pending_runner/pending_runner": { + label: "CHECKING LIVE STATE", + tone: "pending", text: "Submitted. Waiting for this computer to check the live screen…", terminal: false, pending: true, }, "demonstration_requested/demonstration_requested": { + label: "CORRECTION REQUESTED", + tone: "teach", text: "Saved as a correction to teach. The run stays paused until someone " + "records the new way on the computer.", terminal: true, }, "escalated/escalation_recorded": { + label: "ROUTED FOR HELP", + tone: "teach", text: "Escalated. The run stays paused exactly where it is until someone picks " + "it up.", @@ -143,6 +183,8 @@ const RECEIPT_COPY = { // will pick this up; this one says nobody will, because there is no longer a // run to pick up. An operator told the wrong one of those acts on it. "rejected/rejected_by_operator": { + label: "RUN STOPPED", + tone: "halted", text: "Stopped. This run is over and cannot be resumed, and nothing in the " + "application was touched. Start it again on the computer if it should be " + @@ -285,6 +327,24 @@ const RISK_COPY = { }, }; +const TASK_KIND_COPY = { + identity: "Record identity", + effect: "Saved-result check", + ambiguity: "Target ambiguity", + human_step: "Human-only step", + delivery_uncertain: "Delivery uncertainty", + halt: "Workflow pause", + operator_review: "Operator review", +}; + +const RISK_LABEL = { + read_only: "Read-only", + state_changing: "Changes application state", + consequential: "Consequential write", + irreversible: "Irreversible action", + unknown: "Review required", +}; + // --------------------------------------------------- what the engine cannot do // // Flow sends a one-sided `presentation.assurance`: "your answer does not mark @@ -545,7 +605,7 @@ function ladderRows(halt) { `; } -function recheckBlock(halt) { +function recheckBlock(halt, task) { const checks = (halt && halt.will_recheck) || []; if (checks.length === 0) return ""; const items = checks @@ -558,10 +618,16 @@ function recheckBlock(halt) { .filter(Boolean) .join(""); if (!items) return ""; + const reconciling = task && task.task_kind === "delivery_uncertain"; + const heading = reconciling + ? "If you select “Check what happened”" + : "If you answer “I fixed it”"; + const introduction = reconciling + ? "OpenAdapt will not send the earlier action again. It checks the live application and must still:" + : "OpenAdapt will not repeat the step blindly. It re-reads the live screen and must still:"; return `
-

If you answer “I fixed it”

-

OpenAdapt will not repeat the step blindly. It re-reads the - live screen and must still:

+

${esc(heading)}

+

${esc(introduction)}

If any of those fail it stops again and changes nothing.

`; @@ -609,13 +675,16 @@ function frameBlock(task, presentation) { if (!frameId) return ""; const age = describeAge(task && task.created_at); const stopped = age ? `when it stopped ${age} ago` : "when it stopped"; - return `
Screen when OpenAdapt stopped + return `
+
+ Screen when OpenAdapt stopped + RETAINED FRAME +
The screen OpenAdapt retained when it stopped -

This is the picture OpenAdapt kept ${esc(stopped)}. It is - not the live screen and it does not update. Look at the application itself - before you answer.

-
`; +
OpenAdapt kept this picture ${esc(stopped)}. It is not live. + Check the application before you answer.
+ `; } function consequenceBlock(task) { @@ -633,6 +702,18 @@ function consequenceBlock(task) { `; } +function actionIsValidForTask(action, task) { + // Types v0.7 closes this too, but preserve the boundary in Desktop: an old + // or malformed runner must not turn a definitely un-sent action into a + // reconciliation UI. The signed task remains the source of authority; this + // is a presentation refusal, never an addition to its action set. + return ( + action !== "reconcile" || + (task.task_kind === "delivery_uncertain" && + ["delivered", "unknown"].includes(task.delivery_state)) + ); +} + async function showTask(runId) { const { status, body } = await api(`/api/portal/tasks/${encodeURIComponent(runId)}`); if (status === 401 || status === 202) return startPairing(); @@ -647,46 +728,65 @@ async function showTask(runId) { }[delivery]; const halt = presentation.halt; const stopped = describeStop(halt); - // Reading order matters more than length here. What a wrong answer costs and - // what the engine cannot check are placed where they are passed on the way to - // the buttons; the counts, the expiry and the long-form consequence card are - // left below, where volume is free. - // - // `presentation.assurance` is deliberately not rendered: it is the one-sided - // sentence LIMIT_COPY replaces, and printing both would restate the reassuring - // half twice and the limit once. + const taskKind = TASK_KIND_COPY[task && task.task_kind] || "Operator decision"; + const risk = RISK_LABEL[task && task.risk_class] || "Review required"; + const reason = + presentation.explanation || stopped || "OpenAdapt stopped instead of guessing."; + const nextCheck = + task && task.task_kind === "delivery_uncertain" + ? "OpenAdapt will check the saved result. It will not send the action again." + : "OpenAdapt will re-read the live application before it continues."; render(`
- ${deliveryText ? `

${esc(deliveryText)}

` : ""} - ${stakesBlock(task)} -

${esc(presentation.question || "OpenAdapt needs a decision")}

- ${stopped ? `

${esc(stopped)}

` : ""} - ${presentation.explanation ? `

${esc(presentation.explanation)}

` : ""} - ${frameBlock(task, presentation)} - ${ladderRows(halt)} - ${recheckBlock(halt)} - ${evidenceRows(task)} - ${ - task && task.expires_at - ? `

This decision is valid until ${esc(task.expires_at)}.

` - : "" - } -

${esc(LIMIT_COPY)}

-

+
+
+ ${esc(taskKind)} + ${esc(deliveryText || "Paused")} +
+
+ +
+

OpenAdapt needs your help

+

${esc(presentation.question || "Review the live application.")}

+
+
+

${esc(reason)}

+
+ Delivery${esc(deliveryText || "Paused")} + Risk${esc(risk)} +
+ ${frameBlock(task, presentation)} +
+ +

After your answer${esc(nextCheck)}

+
+
+ Technical details and action effects + ${stakesBlock(task)} + ${stopped && stopped !== reason ? `

${esc(stopped)}

` : ""} + ${ladderRows(halt)} + ${recheckBlock(halt, task)} + ${evidenceRows(task)} + ${ + task && task.expires_at + ? `

This decision is valid until ${esc(task.expires_at)}.

` + : "" + } +

${esc(LIMIT_COPY)}

+ ${consequenceBlock(task)} +
+
+
- ${consequenceBlock(task)} `); document.getElementById("back").addEventListener("click", showList); const frame = document.getElementById("frame"); - if (frame) { - frame.closest("details").addEventListener( - "toggle", - () => loadFrame(runId, frame), - { once: true }, - ); - } + if (frame) loadFrame(runId, frame); renderActions(runId, body); } @@ -751,7 +851,7 @@ function renderActions(runId, detail) { const task = detail.task; if (!task || !Array.isArray(task.allowed_actions)) return; const buttons = task.allowed_actions - .filter((action) => ACTION_WIRE[action]) + .filter((action) => ACTION_WIRE[action] && actionIsValidForTask(action, task)) .map( (action) => // Each button carries its consequence, because "verify & continue", @@ -786,13 +886,18 @@ function renderActions(runId, detail) { // the scroll position down by exactly that much so nothing that was on // screen is swallowed -- the same defect as the outcome line disappearing // under a taller-than-guessed bar. - const before = actionBar.hidden ? 0 : actionBar.getBoundingClientRect().height; + const wasHidden = actionBar.hidden; + const before = wasHidden ? 0 : actionBar.getBoundingClientRect().height; actionBar.innerHTML = buttons; actionBar.hidden = false; syncActionBarSpace(); const growth = actionBar.getBoundingClientRect().height - before; const scroller = document.scrollingElement || document.documentElement; - if (growth > 0 && scroller) scroller.scrollTop += growth; + // On first render the bar was absent. Moving the document by the new bar + // height there hides the question under the sticky heading before the + // operator reads it. Preserve the viewport only when an already-visible + // gate or action bar grew. + if (growth > 0 && scroller && !wasHidden) scroller.scrollTop += growth; actionBar.querySelectorAll("[data-action]").forEach((node) => { node.addEventListener("click", () => decide(runId, detail, node.dataset.action)); }); @@ -812,9 +917,20 @@ function renderActions(runId, detail) { // Translate one reply into operator copy. Returns `terminal` (the decision is // over -- take the buttons away) and `retry` (the runner refused without acting, // so answering again after fixing the application is legitimate). +function hasReconciliationSuccessProof(body) { + return ( + body && + body.report_success === true && + typeof body.transition_receipt_digest === "string" && + /^sha256:[0-9a-f]{64}$/.test(body.transition_receipt_digest) + ); +} + function interpretReply(status, body, portableAction) { if (status === 0 || status >= 500) { return { + label: "RESULT UNCERTAIN", + tone: "uncertain", text: "The result is uncertain. Do not answer again — check this decision on " + "the computer running OpenAdapt.", @@ -837,11 +953,50 @@ function interpretReply(status, body, portableAction) { } if (state && reason) { const copy = RECEIPT_COPY[`${state}/${reason}`]; - if (copy) return { text: copy.text, terminal: copy.terminal, pending: copy.pending }; + if (copy) { + // `reconciled_and_resumed` is a success-shaped outcome only when the + // exact receipt commits to both a successful report and the transition + // that consumed the pause. Do not translate a partial reply into proof. + if (copy.reconciliation && !hasReconciliationSuccessProof(body)) { + return { + label: "PROOF INCOMPLETE", + tone: "uncertain", + text: + "OpenAdapt returned an incomplete reconciliation receipt. It did " + + "not claim the earlier action succeeded. Check this decision on " + + "the computer running OpenAdapt.", + terminal: true, + }; + } + if ( + portableAction === "reconcile" && + state === "refused" && + reason === "revalidation_refused" + ) { + return { + label: "RESULT NOT PROVED", + tone: "halted", + text: + "OpenAdapt could not yet prove the saved result. It did not send " + + "the earlier action again. Review the live record, then check " + + "again or hand this case to someone else.", + terminal: false, + }; + } + return { + label: copy.label, + tone: copy.tone, + text: copy.text, + terminal: copy.terminal, + pending: copy.pending, + }; + } // A state this build has no wording for is reported as exactly that. It is // NOT a refusal: showing a real terminal outcome as "refused" is the defect // this branch exists to prevent. return { + label: "UNKNOWN OUTCOME", + tone: "uncertain", text: `OpenAdapt returned an outcome this phone has no wording for (${state}). ` + "Check this decision on the computer running OpenAdapt.", @@ -851,6 +1006,8 @@ function interpretReply(status, body, portableAction) { // No receipt at all: a pre-admission refusal (expired task, wrong binding, // action not allowed, another operator already answered). return { + label: "DECISION NOT ACCEPTED", + tone: "halted", text: (body && body.detail) || "That decision was not accepted. Reload and review the live state.", @@ -858,12 +1015,23 @@ function interpretReply(status, body, portableAction) { }; } +function renderOutcome(outcome, reply) { + outcome.hidden = false; + outcome.className = `outcome ${reply.tone || "neutral"}`; + outcome.querySelector("strong").textContent = reply.label || "RESULT"; + outcome.querySelector("span").textContent = reply.text; +} + async function decide(runId, detail, portableAction) { const outcome = document.getElementById("outcome"); const buttons = Array.from(actionBar.querySelectorAll("button")); buttons.forEach((button) => (button.disabled = true)); const wire = ACTION_WIRE[portableAction].wire; - outcome.textContent = "Submitted. Waiting for this computer to check the live screen…"; + renderOutcome(outcome, { + label: "CHECKING LIVE STATE", + tone: "pending", + text: "Submitted. Waiting for this computer to check the live screen…", + }); const payload = { capability_digest: detail.task.capability_digest, task_digest: detail.task_digest, @@ -879,15 +1047,21 @@ async function decide(runId, detail, portableAction) { // Never translate an accepted tap into success: the runner's own terminal // state decides what this says. const reply = interpretReply(status, body, portableAction); - outcome.textContent = reply.text; + renderOutcome(outcome, reply); if (reply.terminal) { + const decision = document.getElementById("decision"); + if (decision) decision.hidden = true; + outcome.classList.add("terminal"); + outcome.closest(".card").classList.add("result-card"); hideActions(); + const scroller = document.scrollingElement || document.documentElement; + if (scroller) scroller.scrollTop = 0; } else if (!reply.pending) { buttons.forEach((button) => (button.disabled = false)); } // With the bar gone or resized, put the answer the operator is waiting for // back in view rather than leaving it below the fold. - if (typeof outcome.scrollIntoView === "function") { + if (!reply.terminal && typeof outcome.scrollIntoView === "function") { outcome.scrollIntoView({ block: "nearest" }); } } diff --git a/engine/portal/shell/index.html b/engine/portal/shell/index.html index ae9f9cb..781e61a 100644 --- a/engine/portal/shell/index.html +++ b/engine/portal/shell/index.html @@ -5,14 +5,14 @@ - + OpenAdapt decisions
- OpenAdapt + OpenAdapt
Loading…
diff --git a/engine/portal/shell/manifest.webmanifest b/engine/portal/shell/manifest.webmanifest index f286914..09a826f 100644 --- a/engine/portal/shell/manifest.webmanifest +++ b/engine/portal/shell/manifest.webmanifest @@ -6,6 +6,6 @@ "scope": "/", "display": "standalone", "orientation": "portrait", - "background_color": "#101418", - "theme_color": "#101418" + "background_color": "#f4f3ed", + "theme_color": "#f4f3ed" } diff --git a/engine/portal/shell/styles.css b/engine/portal/shell/styles.css index 115461c..fc0c5c8 100644 --- a/engine/portal/shell/styles.css +++ b/engine/portal/shell/styles.css @@ -1,16 +1,20 @@ /* Mobile-first task shell. One decision, thumb-reachable actions, safe areas. */ :root { - color-scheme: dark; - --bg: #101418; - --panel: #181d23; - --line: #262d35; - --text: #eef2f6; - --muted: #9fadbc; - --accent: #4c9aff; + color-scheme: light; + --bg: #f4f3ed; + --panel: #fffef9; + --panel-2: #eeede5; + --line: #d6d8ce; + --text: #252a22; + --muted: #687066; + --accent: #2f7154; /* Used only to mark stakes. Never on a button: nothing in the action bar is allowed to be more salient than anything else in it. */ - --warn: #f0b429; + --warn: #a66a25; + --success: #2e7c5a; + --danger: #a34f4c; + --teach: #356f9f; /* Measured from the real action bar by app.js. A constant was wrong once the bar carried three two-line buttons, and the outcome line -- the last thing in the card -- ended up underneath it. */ @@ -36,10 +40,15 @@ html, body { border-bottom: 1px solid var(--line); position: sticky; top: 0; - background: var(--bg); + /* The task card follows the header in document order. Without a stacking + context it can paint over the sticky label while the page moves. */ + z-index: 2; + background: color-mix(in srgb, var(--bg) 92%, transparent); + backdrop-filter: blur(14px); } -.brand { font-weight: 600; letter-spacing: 0.01em; } +.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; letter-spacing: 0.01em; } +.brand > span { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: var(--text); color: var(--panel); font-size: 10px; font-weight: 850; } .device { color: var(--muted); font-size: 13px; } .main { @@ -57,8 +66,9 @@ html, body { .card { background: var(--panel); border: 1px solid var(--line); - border-radius: 14px; - padding: 18px; + border-radius: 18px; + padding: 17px; + box-shadow: 0 14px 34px rgba(33, 39, 31, .07); } .card h1 { font-size: 21px; line-height: 1.35; margin: 6px 0 12px; } @@ -71,7 +81,7 @@ html, body { border: 1px solid var(--line); border-radius: 999px; font-size: 13px; - color: var(--muted); + color: var(--accent); margin: 0; } @@ -139,19 +149,76 @@ html, body { font-size: 15px; } -.shot { margin: 16px 0; } -.shot summary { cursor: pointer; padding: 10px 0; color: var(--accent); } -.shot .muted { margin: 8px 0 0; } +.shot { overflow: hidden; margin: 16px 0; border: 1px solid var(--line); border-radius: 14px; background: var(--panel-2); } +.shot-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 11px; } +.shot-head strong { font-size: 12px; } +.shot-head span { color: var(--muted); font-size: 10px; } .shot img { + display: block; width: 100%; - height: auto; - object-fit: contain; - border: 1px solid var(--line); - border-radius: 10px; - background: #000; + height: 170px; + object-fit: cover; + object-position: top center; + border-block: 1px solid var(--line); + background: var(--panel); } - -.outcome { margin: 14px 0 0; min-height: 1.5em; } +.shot figcaption { padding: 8px 11px 10px; color: var(--muted); font-size: 11px; line-height: 1.4; } + +.outcome { display: grid; gap: 4px; margin: 16px 0 0; padding: 18px; border: 1px solid var(--line); border-radius: 16px; background: var(--panel-2); } +.outcome[hidden] { display: none; } +.outcome strong { font-size: 12px; letter-spacing: .08em; } +.outcome span { font-size: 15px; line-height: 1.45; } +.outcome.pending { border-color: color-mix(in srgb, var(--accent) 38%, var(--line)); background: color-mix(in srgb, var(--panel) 86%, #dcece5); } +.outcome.success { border-color: color-mix(in srgb, var(--success) 44%, var(--line)); background: color-mix(in srgb, var(--panel) 86%, #dff1e8); } +.outcome.halted { border-color: color-mix(in srgb, var(--warn) 44%, var(--line)); background: color-mix(in srgb, var(--panel) 86%, #f6ead7); } +.outcome.uncertain { border-color: color-mix(in srgb, var(--danger) 46%, var(--line)); background: color-mix(in srgb, var(--panel) 86%, #f7e4e2); } +.outcome.teach { border-color: color-mix(in srgb, var(--teach) 42%, var(--line)); background: color-mix(in srgb, var(--panel) 86%, #e0ebf5); } +.outcome.neutral { background: var(--panel-2); } +.outcome.terminal { + min-height: min(520px, calc(100vh - 160px)); + margin: 0; + align-content: center; + justify-items: center; + padding: 38px 24px; + text-align: center; +} +.outcome.terminal::before { + content: "✓"; + display: grid; + place-items: center; + width: 68px; + height: 68px; + margin-bottom: 12px; + border-radius: 22px; + background: var(--success); + color: #fff; + font-size: 34px; + font-weight: 800; +} +.outcome.terminal.halted::before { content: "■"; background: var(--warn); font-size: 25px; } +.outcome.terminal.uncertain::before { content: "!"; background: var(--danger); } +.outcome.terminal.teach::before { content: "↗"; background: var(--teach); } +.outcome.terminal.neutral::before { content: "—"; background: var(--muted); } +.outcome.terminal strong { font-size: 13px; } +.outcome.terminal span { max-width: 32ch; font-size: 17px; line-height: 1.55; } +.result-card { padding: 10px; } + +.task-kicker { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; color: var(--muted); font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } +.task-hero { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 12px; align-items: start; padding: 14px; border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--line)); border-radius: 16px; background: color-mix(in srgb, var(--panel) 86%, #dcece5); } +.task-hero > span { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 14px; background: var(--accent); color: #fff; font-size: 21px; font-weight: 900; } +.task-hero p { margin: 1px 0 3px; color: var(--accent); font-size: 10px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; } +.task-hero h1 { margin: 0; font-size: 21px; line-height: 1.28; } +.task-reason { margin: 14px 0 0; font-size: 14px; line-height: 1.5; } +.task-signals { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 14px; } +.task-signals > span { display: grid; gap: 2px; padding: 10px 11px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel-2); } +.task-signals small { color: var(--muted); font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } +.task-signals strong { font-size: 12px; line-height: 1.35; } +.task-next { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 9px; align-items: start; margin: 14px 0 0; padding: 11px; border-radius: 12px; background: color-mix(in srgb, var(--panel) 86%, #dcece5); } +.task-next > span { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; background: var(--panel); color: var(--accent); } +.task-next p { display: grid; gap: 1px; margin: 0; color: var(--muted); font-size: 11px; line-height: 1.4; } +.task-next strong { color: var(--text); font-size: 12px; } +.task-details { margin-top: 15px; border-top: 1px solid var(--line); padding-top: 12px; } +.task-details > summary { color: var(--accent); cursor: pointer; font-size: 13px; font-weight: 700; } .actions { position: fixed; @@ -161,7 +228,7 @@ html, body { display: grid; gap: 10px; padding: 12px 16px calc(env(safe-area-inset-bottom) + 16px); - background: linear-gradient(to top, var(--bg) 78%, transparent); + background: linear-gradient(to top, var(--bg) 82%, transparent); max-width: 640px; margin: 0 auto; } @@ -178,8 +245,8 @@ html, body { .actions button { min-height: 52px; border-radius: 12px; - border: 1px solid #3a444f; - background: #212831; + border: 1px solid color-mix(in srgb, var(--text) 22%, var(--line)); + background: var(--panel); color: var(--text); font: 600 16px/1.2 inherit; cursor: pointer; @@ -190,7 +257,7 @@ html, body { } .actions button .label { font-weight: 600; } -.actions button .brief { font-weight: 400; font-size: 13px; opacity: 0.78; } +.actions button .brief { color: var(--muted); font-weight: 400; font-size: 13px; } /* A fixed bar that eats half the phone is a legibility problem in its own right, and adding `reject` made a four-button bar the ordinary case. diff --git a/engine/private_flow_config.py b/engine/private_flow_config.py index c9b41bc..d60e3b6 100644 --- a/engine/private_flow_config.py +++ b/engine/private_flow_config.py @@ -80,10 +80,19 @@ class PrivateFlowConfigError(ValueError): @dataclass(frozen=True) class PreparedPrivateYaml: - """One immutable serialization and its same-snapshot log redactions.""" + """One immutable serialization and its same-snapshot log redactions. + + ``remote_decisions`` is read from the SAME snapshot as ``payload``, not by + re-opening the operator's file. Deciding "does this deployment answer halts + on a phone?" from a second read would reintroduce exactly the TOCTOU gap + this class exists to close: Flow could be launched with the outbound lane on + while executing a config that never enabled it. + """ payload: str redactions: tuple[str, ...] + remote_decisions: bool = False + remote_decision_runner_id: str | None = None def _load_mapping(source: Path | None) -> dict[str, Any]: @@ -206,10 +215,42 @@ def prepare_flow_config( if source is None and target is None: return None deployment = _merged_config(source, target) + remote_decisions, remote_decision_runner_id = _remote_decision_settings(deployment) return PreparedPrivateYaml( payload=yaml.safe_dump(deployment, sort_keys=False), redactions=_redactions_for_mapping(deployment), + remote_decisions=remote_decisions, + remote_decision_runner_id=remote_decision_runner_id, + ) + + +def _remote_decisions_enabled(deployment: Mapping[str, Any]) -> bool: + """Whether this deployment answers halts through the hosted lane. + + Strictly ``True``: a truthy string, a 1, or a missing section all mean "no". + Turning on an outbound lane that carries decision context is not a default + and is not inferred. + """ + + return _remote_decision_settings(deployment)[0] + + +def _remote_decision_settings( + deployment: Mapping[str, Any], +) -> tuple[bool, str | None]: + """Return the remote-decision switch and runner from one config snapshot.""" + + human_decisions = deployment.get("human_decisions") + if not isinstance(human_decisions, Mapping): + return False, None + remote = human_decisions.get("remote") + if not isinstance(remote, Mapping): + return False, None + runner_id = remote.get("runner_id") + normalized_runner_id = ( + runner_id.strip() if isinstance(runner_id, str) and runner_id.strip() else None ) + return remote.get("enabled") is True, normalized_runner_id def prepare_flow_record_request( diff --git a/engine/qualification.py b/engine/qualification.py index 4e8cdcd..832fdb4 100644 --- a/engine/qualification.py +++ b/engine/qualification.py @@ -42,7 +42,7 @@ def _flow_api() -> dict[str, Any]: try: import openadapt_flow.qualification as flow_qualification - from openadapt_flow.ir import Workflow + from openadapt_flow.ir import RunReport, Workflow from openadapt_flow.policy import evaluate_policy, lint_workflow, load_policy from openadapt_flow.qualification import ( ActionRiskClass, @@ -54,6 +54,7 @@ def _flow_api() -> dict[str, Any]: IdentityNormalizer, IdentityPolicy, IdentitySignalPolicy, + QualificationActionTarget, QualificationCase, QualificationCaseKind, QualificationCaseResult, @@ -63,9 +64,11 @@ def _flow_api() -> dict[str, Any]: certify_project, evaluate_qualification, init_project, + qualification_action_requirements, record_case_results, save_qualified_workflow, set_action_classification, + set_case_scope, set_effect_policy, set_identity_policy, set_minimum_effect_tier, @@ -83,6 +86,7 @@ def _flow_api() -> dict[str, Any]: ) from exc return { "Workflow": Workflow, + "RunReport": RunReport, "evaluate_policy": evaluate_policy, "lint_workflow": lint_workflow, "load_policy": load_policy, @@ -93,6 +97,7 @@ def _flow_api() -> dict[str, Any]: "QualificationCase": QualificationCase, "QualificationCaseKind": QualificationCaseKind, "QualificationCaseResult": QualificationCaseResult, + "QualificationActionTarget": QualificationActionTarget, "QualificationOutcome": QualificationOutcome, "EnvironmentBoundary": EnvironmentBoundary, "IdentityEnforcement": IdentityEnforcement, @@ -107,11 +112,13 @@ def _flow_api() -> dict[str, Any]: "certify_project": certify_project, "evaluate_qualification": evaluate_qualification, "init_project": init_project, + "qualification_action_requirements": qualification_action_requirements, "record_case_results": record_case_results, "save_qualified_workflow": save_qualified_workflow, "set_trusted_runner_key": set_trusted_runner_key, "sign_case_result": sign_case_result, "set_action_classification": set_action_classification, + "set_case_scope": set_case_scope, "set_effect_policy": set_effect_policy, "set_identity_policy": set_identity_policy, "set_minimum_effect_tier": set_minimum_effect_tier, @@ -330,11 +337,19 @@ def _qualification_controls(workflow, graph: dict[str, Any]) -> dict[str, Any]: parameters = [] for name in parameter_names: spec = workflow.param_specs.get(name) + secret = name in workflow.secret_params + example = spec.example if spec is not None else workflow.params.get(name) parameters.append( { "name": name, "type": spec.type.value if spec is not None else "string", - "secret": name in workflow.secret_params, + "secret": secret, + "required": spec.required if spec is not None else True, + # A secret's recorded value and allowed-value list are also + # secret material. Desktop receives only the schema needed to + # render the credential reference, never its reusable value. + "example": None if secret else example, + "choices": [] if secret or spec is None else list(spec.choices), } ) @@ -1077,6 +1092,59 @@ def prepare_local_qualification_runner( ) +def set_local_qualification_case_scope( + bundle_dir: Path, + *, + workflow_id: str, + case_id: str, + runtime_input_bytes: bytes, + policy_source: str = DEFAULT_QUALIFICATION_POLICY, + bundle_key: str | None = None, +) -> dict: + """Bind a Desktop case to its canonical inputs and executable action paths.""" + + from openadapt_flow.policy import executable_actuation_paths + from openadapt_flow.runtime.authorization import parse_runtime_inputs_bytes + + api = _flow_api() + workflow = _load(bundle_dir, key=bundle_key) + project = workflow.qualification + if project is None: + raise QualificationError("Initialize the qualification boundary before running cases.") + try: + _params, worklists = parse_runtime_inputs_bytes(runtime_input_bytes, workflow=workflow) + except ValueError as exc: + raise QualificationError("Qualification inputs are not canonical governed inputs") from exc + if worklists: + raise QualificationError("Desktop qualification cases do not yet support worklists") + steps = {step.id: step for step in api["iter_workflow_steps"](workflow)} + 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 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)) + try: + api["set_case_scope"]( + workflow, + case_id=case_id, + runtime_input_sha256=hashlib.sha256(runtime_input_bytes).hexdigest(), + action_targets=targets, + ) + _save(workflow, bundle_dir, key=bundle_key) + except (ValueError, TypeError) as exc: + raise QualificationError(str(exc)) from exc + return inspect_bundle( + bundle_dir, + workflow_id=workflow_id, + policy_source=policy_source, + bundle_key=bundle_key, + ) + + def record_local_qualification_result( bundle_dir: Path, *, @@ -1130,6 +1198,46 @@ def record_local_qualification_result( raise QualificationError( "The signed case evidence does not hash-bind its capability receipt" ) + run_reports = [item for item in evidence if item.get("kind") == "run_report"] + inputs = [item for item in evidence if item.get("kind") == "case_input"] + if len(run_reports) != 1 or len(inputs) != 1: + raise QualificationError( + "Qualification evidence requires one exact report and one exact input artifact" + ) + try: + evidence_root = (bundle_dir / "qualification-evidence").resolve() + report_path = (evidence_root / str(run_reports[0]["relative_path"])).resolve() + input_path = (evidence_root / str(inputs[0]["relative_path"])).resolve() + if ( + not report_path.is_relative_to(evidence_root) + or not input_path.is_relative_to(evidence_root) + or report_path.is_symlink() + or input_path.is_symlink() + ): + raise OSError("qualification evidence leaves its local root") + report_bytes = report_path.read_bytes() + input_bytes = input_path.read_bytes() + report = api["RunReport"].model_validate_json(report_bytes) + except (OSError, ValueError) as exc: + raise QualificationError("Qualification report evidence is invalid") from exc + input_sha256 = str(inputs[0]["sha256"]) + if ( + hashlib.sha256(input_bytes).hexdigest() != input_sha256 + or report.governed_qualification_case_input_sha256 != input_sha256 + or report.governed_runtime_inputs_digest != input_sha256 + or report.governed_qualification_run_id_sha256 + != hashlib.sha256(observation.run_id.encode("utf-8")).hexdigest() + ): + raise QualificationError("Qualification evidence does not bind this exact input and run") + if ( + report.governed_qualification_project_id != project.project_id + or report.governed_qualification_project_revision != project.revision + or report.governed_qualification_project_contract_sha256 != project.contract_sha256() + or report.governed_qualification_case_id_sha256 + != hashlib.sha256(case.id.encode("utf-8")).hexdigest() + or report.governed_qualification_case_kind != case.kind.value + ): + raise QualificationError("Qualification report does not bind the current case contract") private_key, _public_key = qualification_signer() observed = api["QualificationOutcome"](observed_outcome) status = "passed" if observed_outcome == case.expected_outcome.value and evidence else "failed" @@ -1150,6 +1258,9 @@ def record_local_qualification_result( evidence=evidence, detail_code=detail_code, attestation_key_id=KEY_ID, + campaign_id_sha256=report.governed_qualification_campaign_id_sha256, + case_input_sha256=input_sha256, + run_id_sha256=report.governed_qualification_run_id_sha256, ) signed = api["sign_case_result"](result, private_key=private_key) api["record_case_results"]( diff --git a/engine/qualification_lifecycle.py b/engine/qualification_lifecycle.py index d581e1a..954a23d 100644 --- a/engine/qualification_lifecycle.py +++ b/engine/qualification_lifecycle.py @@ -99,6 +99,43 @@ def case_parameters_path(data_dir: Path, *, workflow_id: str, case_id: str) -> P return path if path.is_file() else None +def stage_case_runtime_inputs( + data_dir: Path, + *, + workflow_id: str, + case_id: str, + workflow: Any, + parameters_path: Path, +) -> tuple[Path, bytes]: + """Write the exact private canonical inputs Flow authorizes for one case.""" + + workflow_id = validate_path_token(workflow_id, label="Workflow id") + case_id = validate_case_id(case_id) + try: + raw_parameters = parameters_path.read_bytes() + parameters = json.loads(raw_parameters) + except (OSError, json.JSONDecodeError) as exc: + raise QualificationLifecycleError("Case parameters cannot be read safely") from exc + if not isinstance(parameters, dict) or any( + not isinstance(name, str) or not isinstance(value, str) + for name, value in parameters.items() + ): + raise QualificationLifecycleError("Case parameters must be a string-value object") + from openadapt_flow.runtime.authorization import runtime_inputs_bytes + + inputs = runtime_inputs_bytes(workflow, parameters, {}) + root = data_dir / "qualification-inputs" / workflow_id + path = root / f"{case_id}.runtime-inputs.json" + temporary = root / f".{case_id}.runtime-inputs.json.tmp" + try: + temporary.write_bytes(inputs) + temporary.chmod(stat.S_IRUSR | stat.S_IWUSR) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + return path, inputs + + def retain_run_evidence( bundle_dir: Path, *, @@ -106,8 +143,9 @@ def retain_run_evidence( run_id: str, run_dir: Path, report_bytes: bytes | None = None, + runtime_input_bytes: bytes | None = None, ) -> list[dict[str, str]]: - """Retain a privacy-safe receipt bound to the exact local run report.""" + """Retain exact local qualification evidence inside its local boundary.""" case_id = validate_case_id(case_id) run_id = validate_path_token(run_id, label="Run id") @@ -122,36 +160,28 @@ def retain_run_evidence( raise QualificationLifecycleError("Run report is not valid JSON") from exc if not isinstance(report, dict): raise QualificationLifecycleError("Run report must be a JSON object") - envelope = report.get("outcome_envelope") or {} - receipt = { - "schema": "openadapt.qualification-run-receipt/v1", - "run_id": run_id, - "report_sha256": hashlib.sha256(report_bytes).hexdigest(), - "execution_outcome": report.get("execution_outcome"), - "production_eligible": bool(report.get("production_eligible", False)), - "execution_completed": bool(report.get("execution_completed", False)), - "model_calls": report.get("model_calls"), - "contracts": { - "required": envelope.get("required_contracts", {}), - "passed": envelope.get("passed_contracts", {}), - "evidence_classes": envelope.get("evidence_classes", []), - "external_network_calls": envelope.get("external_network_calls"), - }, - } - relative = Path(case_id) / run_id / "run-report-receipt.json" + if runtime_input_bytes is None: + raise QualificationLifecycleError("Qualification evidence requires canonical inputs") + relative = Path(case_id) / run_id / "report.json" destination = bundle_dir / "qualification-evidence" / relative destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_text( - json.dumps(receipt, sort_keys=True, separators=(",", ":")), - encoding="utf-8", - ) - digest = hashlib.sha256(destination.read_bytes()).hexdigest() + destination.write_bytes(report_bytes) + report_digest = hashlib.sha256(report_bytes).hexdigest() + input_relative = Path(case_id) / run_id / "runtime-inputs.json" + input_destination = bundle_dir / "qualification-evidence" / input_relative + input_destination.write_bytes(runtime_input_bytes) + input_digest = hashlib.sha256(runtime_input_bytes).hexdigest() return [ { "kind": "run_report", - "sha256": digest, + "sha256": report_digest, "relative_path": relative.as_posix(), - } + }, + { + "kind": "case_input", + "sha256": input_digest, + "relative_path": input_relative.as_posix(), + }, ] diff --git a/index.html b/index.html index c7ca765..ceac41e 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + diff --git a/package-lock.json b/package-lock.json index 10737ac..cd8a309 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openadapt-desktop", - "version": "0.14.0", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openadapt-desktop", - "version": "0.14.0", + "version": "0.15.0", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-notification": "^2", diff --git a/package.json b/package.json index b8f239b..c21f320 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openadapt-desktop", - "version": "0.14.0", + "version": "0.15.0", "description": "Beta installed companion for OpenAdapt authoring, teaching, and local pairing", "private": true, "type": "module", diff --git a/pyproject.toml b/pyproject.toml index 1118462..2471ceb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "openadapt-desktop" -version = "0.14.0" +version = "0.15.0" description = "Beta desktop authoring surface and local capture/review tooling for OpenAdapt workflows" readme = "README.md" requires-python = ">=3.11" @@ -41,15 +41,17 @@ dependencies = [ "openadapt-privacy>=1.0.0", # Exact cross-surface status/target contract used by the presentation # exporter and by the generated TypeScript projection checked into src/. - # 0.6.x additionally carries the HumanDecisionTaskV1 contract that Flow's - # attended console projects and the mobile decision portal relays; the - # control-overlay v2 schemas are byte-identical to 0.5.0. - "openadapt-types==0.6.1", + # 0.7.0 carries the portable attended-reconciliation action used by the + # Desktop phone portal and Flow's governed runner. + "openadapt-types==0.7.0", "pydantic>=2.0", "pydantic-settings>=2.0", "PyYAML>=6.0,<7", "httpx>=0.27", "keyring>=24", + # PEP 440 comparison is required before Desktop enables a Flow capability. + # Hand-parsing would accept pre-release builds such as 1.26.0rc1 as 1.26.0. + "packaging>=24", # Qualification evidence is signed locally. Cryptography 49 no longer # publishes a macOS Intel wheel, so keep that platform on the latest # universal2 release while using the current runtime elsewhere. @@ -79,16 +81,15 @@ build = [ # decision portal supervises ``openadapt-flow console --attend``; without # this extra the frozen sidecar carries no fastapi/uvicorn and the console # exits with an install hint instead of a capability banner, so the portal - # is dead in every signed installer. 1.25.0 is also the first release - # whose ``openadapt_flow/console/human_decisions.py`` projects the signed - # HumanDecisionTaskV1 detail the portal shell renders and acts on. + # is dead in every signed installer. 1.27.1 carries the current signed + # attended-decision and reconciliation contract that the portal renders. # # ``browser`` is equally load-bearing: 1.25.0 moved Playwright from a core # Flow dependency to that extra, and Desktop freezes the browser driver on # purpose (see the pin below and the embedded ``-m playwright`` process # mode). Without it the driver becomes build-only and the artifact gate # correctly refuses the archive. - "openadapt-flow[browser,console]==1.25.0", + "openadapt-flow[browser,console]==1.27.1", # Desktop deliberately embeds the optional browser driver so selecting a # web workflow is one click. It does NOT bundle Chromium: that larger # runtime is fetched only after the operator chooses the browser surface. diff --git a/scripts/capture_portal_scenarios.py b/scripts/capture_portal_scenarios.py new file mode 100644 index 0000000..d11c1ed --- /dev/null +++ b/scripts/capture_portal_scenarios.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +# ruff: noqa: E501 +"""Capture deterministic, synthetic phone decision screens. + +This is a presentation-fixture generator. It starts no runner, reads no run +directory, and never uses customer evidence. The generated PNG files stay in +the caller-selected directory, outside the Desktop package by default. + +Example: + uv run --extra build python scripts/capture_portal_scenarios.py \ + --out /tmp/openadapt-desktop-phone-fixtures \ + --frame /path/to/public-reference-run-frame.png +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import tempfile +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from threading import Thread +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +SHELL = ROOT / "engine" / "portal" / "shell" + + +def _task(*, kind: str, delivery: str, actions: list[str]) -> dict[str, Any]: + return { + "capability_digest": "sha256:" + "a" * 64, + "signature": "hmac-sha256:" + "b" * 64, + "delivery_state": delivery, + "task_kind": kind, + "risk_class": "consequential" if kind in {"effect", "delivery_uncertain"} else "unknown", + "created_at": "2026-07-29T12:00:00+00:00", + "expires_at": "2026-07-29T13:00:00+00:00", + "allowed_actions": actions, + "evidence": { + "identity_required_count": 2 if kind == "identity" else None, + "identity_confirmed_count": 1 if kind == "identity" else None, + "effect_required_count": 1 if kind in {"effect", "delivery_uncertain"} else None, + "effect_confirmed_count": 0 if kind in {"effect", "delivery_uncertain"} else None, + "minimum_effect_tier": 1 if kind in {"effect", "delivery_uncertain"} else None, + "observed_effect_tier": None, + "frame_available_locally": False, + "sensitive_evidence_local_only": True, + }, + } + + +def _detail( + *, + title: str, + kind: str, + delivery: str = "not_delivered", + actions: list[str], + category: str, + question: str, +) -> dict[str, Any]: + return { + "task": _task(kind=kind, delivery=delivery, actions=actions), + "task_digest": "sha256:" + "c" * 64, + "presentation": { + "question": question, + "explanation": title, + "halt": { + "category": category, + "step_ordinal": 3, + "step_count": 6, + "action_kind": "click", + "target_role": "button", + "target_label": "Save", + "resolution_ladder": [], + "will_recheck": [ + { + "check": "delivery_reconciliation" + if kind == "delivery_uncertain" + else "record_identity", + "count": None, + } + ], + }, + }, + } + + +SCENARIOS: dict[str, dict[str, Any]] = { + "identity": _detail( + title="OpenAdapt could not confirm the intended record.", + kind="identity", + actions=["verify_and_resume", "reject", "teach", "escalate"], + category="identity", + question="Is the intended record now open in the live application?", + ), + "ambiguity": _detail( + title="More than one target matched, so OpenAdapt chose none.", + kind="ambiguity", + actions=["verify_and_resume", "reject", "teach", "escalate"], + category="disambiguation", + question="Can you leave one clear target ready in the live application?", + ), + "human-step": _detail( + title="The application needs a person before the workflow can continue.", + kind="human_step", + actions=["verify_and_resume", "escalate"], + category="human_required", + question="Complete the sign-in or challenge in the live application, then check again.", + ), + "effect": _detail( + title="The saved system-of-record result was not confirmed.", + kind="effect", + actions=["verify_and_resume", "reject", "teach", "escalate"], + category="effect_indeterminate", + question="Is the live destination ready for OpenAdapt to check the saved result again?", + ), + "delivery-uncertain": _detail( + title="The action may already have been delivered.", + kind="delivery_uncertain", + delivery="unknown", + actions=["reconcile", "teach", "escalate"], + category="effect_indeterminate", + question="Is the live destination ready for OpenAdapt to reconcile the uncertain action?", + ), + "optional-step": _detail( + title="This optional workflow step needs an operator decision.", + kind="halt", + actions=["skip", "reject", "teach", "escalate"], + category="halt", + question="Should OpenAdapt leave this declared optional step undone?", + ), +} + + +RESULTS: dict[str, dict[str, Any]] = { + "verify_and_resume": { + "state": "completed", + "reason_code": "verified_and_resumed", + "action": "verify_and_resume", + }, + "skip": {"state": "completed", "reason_code": "skipped_and_resumed", "action": "skip"}, + "reject": {"state": "rejected", "reason_code": "rejected_by_operator", "action": "reject"}, + "teach": { + "state": "demonstration_requested", + "reason_code": "demonstration_requested", + "action": "teach", + }, + "escalate": { + "state": "escalated", + "reason_code": "escalation_recorded", + "action": "escalate", + }, + "reconcile": { + "state": "completed", + "reason_code": "reconciled_and_resumed", + "action": "reconcile", + "report_success": True, + "transition_receipt_digest": "sha256:" + "d" * 64, + }, + "accepted-pending-runner": { + "state": "accepted_pending_runner", + "reason_code": "pending_runner", + "action": "verify_and_resume", + }, + "continuation-halted": { + "state": "halted", + "reason_code": "continuation_halted", + "action": "verify_and_resume", + }, + "revalidation-refused": { + "state": "refused", + "reason_code": "revalidation_refused", + "action": "verify_and_resume", + }, + "expired": { + "state": "expired", + "reason_code": "expired", + "action": "verify_and_resume", + }, + "delivery-uncertain": { + "state": "delivery_uncertain", + "reason_code": "delivery_uncertain", + "action": "verify_and_resume", + }, + "reconcile-refused": { + "state": "refused", + "reason_code": "revalidation_refused", + "action": "reconcile", + }, + "reconcile-incomplete": { + "state": "completed", + "reason_code": "reconciled_and_resumed", + "action": "reconcile", + "report_success": False, + "transition_receipt_digest": None, + }, +} + +RESULT_EXAMPLES: dict[str, tuple[str, str, str]] = { + "accepted-pending-runner": ("identity", "verify_and_resume", "accepted-pending-runner"), + "continuation-halted": ("identity", "verify_and_resume", "continuation-halted"), + "revalidation-refused": ("identity", "verify_and_resume", "revalidation-refused"), + "expired": ("identity", "verify_and_resume", "expired"), + "delivery-uncertain": ("identity", "verify_and_resume", "delivery-uncertain"), + "reconcile-refused": ("delivery-uncertain", "reconcile", "reconcile-refused"), + "reconcile-incomplete": ("delivery-uncertain", "reconcile", "reconcile-incomplete"), +} + + +def _write_fixture_site(site: Path, frame: Path | None) -> None: + shutil.copy(SHELL / "app.js", site / "app.js") + shutil.copy(SHELL / "styles.css", site / "styles.css") + scenarios = json.loads(json.dumps(SCENARIOS)) + frame_name = None + if frame is not None: + frame_name = f"evidence{frame.suffix.lower()}" + shutil.copy(frame, site / frame_name) + for detail in scenarios.values(): + detail["task"]["evidence"]["frame_available_locally"] = True + detail["presentation"]["after_artifact_id"] = "openemr-retained-frame" + fixture = json.dumps({"scenarios": scenarios, "results": RESULTS}, separators=(",", ":")) + (site / "index.html").write_text( + """ + +
OA OpenAdaptDemo phone
+
Loading…
+""", + encoding="utf-8", + ) + + +class _QuietHandler(SimpleHTTPRequestHandler): + def log_message(self, _format: str, *_args: object) -> None: + return + + +def _serve(directory: Path) -> tuple[ThreadingHTTPServer, Thread]: + def handler(*args: object, **kwargs: object) -> _QuietHandler: + return _QuietHandler(*args, directory=str(directory), **kwargs) + + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread + + +def _capture(out: Path, frame: Path | None) -> None: + from playwright.sync_api import sync_playwright + + with tempfile.TemporaryDirectory(prefix="openadapt-portal-fixtures-") as temp: + site = Path(temp) + _write_fixture_site(site, frame) + server, thread = _serve(site) + try: + origin = f"http://127.0.0.1:{server.server_port}" + with sync_playwright() as playwright: + browser = playwright.chromium.launch() + page = browser.new_page(viewport={"width": 390, "height": 844}, device_scale_factor=2) + for name, detail in SCENARIOS.items(): + page.goto(f"{origin}/?scenario={name}", wait_until="networkidle") + page.locator("[data-run='fixture-run']").click() + page.wait_for_timeout(50) + if frame is not None: + page.wait_for_function( + """() => { + const frame = document.querySelector('#frame'); + return frame && frame.complete && frame.naturalWidth > 0; + }""" + ) + # A canonical phone image is the phone viewport. A + # full-page capture repeats the sticky heading in the + # middle of long decision cards and is not what a staff + # member sees on a device. + page.screenshot(path=str(out / f"{name}.png")) + for action in detail["task"]["allowed_actions"]: + page.goto( + f"{origin}/?scenario={name}&result={action}", wait_until="networkidle" + ) + page.locator("[data-run='fixture-run']").click() + if frame is not None: + page.wait_for_function( + """() => { + const frame = document.querySelector('#frame'); + return frame && frame.complete && frame.naturalWidth > 0; + }""" + ) + page.locator(f'[data-action="{action}"]').click() + page.wait_for_timeout(100) + page.evaluate( + """() => { + const target = document.querySelector('#outcome'); + if (!target) return; + if (target.classList.contains('terminal')) { + window.scrollTo(0, 0); + return; + } + const top = target.getBoundingClientRect().top + window.scrollY; + window.scrollTo(0, Math.max(0, top - 100)); + }""" + ) + page.screenshot(path=str(out / f"{name}-{action}-result.png")) + for filename, (scenario, action, result) in RESULT_EXAMPLES.items(): + page.goto( + f"{origin}/?scenario={scenario}&result={result}", + wait_until="networkidle", + ) + page.locator("[data-run='fixture-run']").click() + if frame is not None: + page.wait_for_function( + """() => { + const frame = document.querySelector('#frame'); + return frame && frame.complete && frame.naturalWidth > 0; + }""" + ) + page.locator(f'[data-action="{action}"]').click() + page.wait_for_timeout(100) + page.evaluate( + """() => { + const target = document.querySelector('#outcome'); + if (!target) return; + if (target.classList.contains('terminal')) { + window.scrollTo(0, 0); + return; + } + const top = target.getBoundingClientRect().top + window.scrollY; + window.scrollTo(0, Math.max(0, top - 100)); + }""" + ) + page.screenshot(path=str(out / f"result-{filename}.png")) + browser.close() + finally: + server.shutdown() + thread.join(timeout=5) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, required=True, help="Output directory for generated PNG files.") + parser.add_argument( + "--frame", + type=Path, + help="Optional retained PNG, JPEG, or WebP frame from a public reference run.", + ) + args = parser.parse_args() + if args.frame is not None: + if not args.frame.is_file(): + parser.error(f"--frame does not exist: {args.frame}") + if args.frame.suffix.lower() not in {".png", ".jpg", ".jpeg", ".webp"}: + parser.error("--frame must be PNG, JPEG, or WebP") + args.out.mkdir(parents=True, exist_ok=True) + _capture(args.out, args.frame) + print(f"Wrote synthetic phone fixtures to {args.out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/frozen_notices.py b/scripts/frozen_notices.py index bf79ae4..7a386cc 100644 --- a/scripts/frozen_notices.py +++ b/scripts/frozen_notices.py @@ -25,7 +25,7 @@ # ``console`` extra on Flow is what puts fastapi/uvicorn/starlette in the # artifact; without it the mobile decision portal cannot start its console. # ``browser`` carries Playwright, which Flow moved out of its core dependencies -# in 1.25.0 and which Desktop still freezes on purpose. +# in the pinned Flow runtime and which Desktop still freezes on purpose. FROZEN_RUNTIME_ROOTS = ("openadapt-desktop", "openadapt-flow[browser,console]") BUILD_EXTRA = frozenset({"build"}) NOTICE_BUNDLE_MEMBER = "third_party/python" diff --git a/scripts/sync_control_overlay_contract.py b/scripts/sync_control_overlay_contract.py index 1bbb39b..93712f3 100644 --- a/scripts/sync_control_overlay_contract.py +++ b/scripts/sync_control_overlay_contract.py @@ -23,7 +23,7 @@ ROOT = Path(__file__).resolve().parents[1] OUTPUT = ROOT / "src" / "overlay" / "generated" / "contract.ts" SCHEMA_DIR = ROOT / "src" / "overlay" / "generated" / "schemas" -PINNED_TYPES_VERSION = "0.6.1" +PINNED_TYPES_VERSION = "0.7.0" SCHEMA_NAMES = ( "control-overlay-frame-v2.json", "control-overlay-timeline-v2.json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 043146a..e78e0d1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openadapt-desktop" -version = "0.14.0" +version = "0.15.0" description = "Beta installed companion for OpenAdapt authoring, teaching, and local pairing" authors = ["OpenAdapt AI"] license = "MIT" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8c731be..e8609ae 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicoverbruggen/tauri-v2-schema/main/schema.json", "productName": "OpenAdapt Desktop", - "version": "0.14.0", + "version": "0.15.0", "identifier": "ai.openadapt.desktop", "build": { "frontendDist": "../dist", diff --git a/src/App.tsx b/src/App.tsx index b4c1d30..96a7bee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ -// App shell: left-rail nav + routed screens, gated by first-run/auth. -// Rail carries the two orthogonal status channels (recording, sync) plus the -// needs-attention break count, mirrored from the engine over events (spec §3d). +// Product shell + routed screens, gated by first-run/auth. +// The top shell uses the same OpenAdapt | Product pattern as Cloud. It also +// carries the local engine, recording, sync, and attention state. import { useEffect, useState } from "react"; import { CMD, @@ -57,6 +57,42 @@ const NAV: { route: Route["name"]; label: string; glyph: string }[] = [ { route: "settings", label: "Settings", glyph: "⚙" }, ]; +function DesktopBrand({ onOpen }: { onOpen?: () => void }) { + const content = ( + <> + Open + Adapt + Desktop + + ); + if (!onOpen) { + return
{content}
; + } + return ( + + ); +} + +function DesktopEntryShell({ children }: { children: React.ReactNode }) { + return ( +
+
+
+ +
+
+
{children}
+
+ ); +} + export default function App() { const [auth, setAuth] = useState(null); const [checkedAuth, setCheckedAuth] = useState(false); @@ -158,7 +194,9 @@ export default function App() { return ( <> {pairingNotice} -
Loading…
+ +
Loading…
+
); } @@ -167,15 +205,17 @@ export default function App() { return ( <> {pairingNotice} - { - rememberLocalSession(); - setLocalSession(true); - }} - onAuthed={(s) => { - setAuth(s); - }} - /> + + { + rememberLocalSession(); + setLocalSession(true); + }} + onAuthed={(s) => { + setAuth(s); + }} + /> + ); } @@ -184,12 +224,14 @@ export default function App() { return ( <> {pairingNotice} - { - setOnboarded(true); - setRoute({ name: "record" }); - }} - /> + + { + setOnboarded(true); + setRoute({ name: "record" }); + }} + /> + ); } @@ -207,49 +249,49 @@ export default function App() { <> {pairingNotice}
- +
{route.name === "library" && ( 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.test.ts b/src/lib/qualificationParameters.test.ts new file mode 100644 index 0000000..08319a3 --- /dev/null +++ b/src/lib/qualificationParameters.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import type { QualificationParameter } from "./types"; +import { serializeQualificationParameters } from "./qualificationParameters"; + +function parameter( + overrides: Partial = {}, +): QualificationParameter { + return { + name: "record_id", + type: "string", + secret: false, + required: true, + example: null, + choices: [], + ...overrides, + }; +} + +describe("qualification case parameters", () => { + it("requires required public inputs", () => { + expect(serializeQualificationParameters([parameter()], {})).toEqual({ + ok: false, + error: "record_id is required.", + }); + }); + + it("accepts only declared choices", () => { + const result = serializeQualificationParameters( + [parameter({ name: "priority", type: "enum", choices: ["routine", "urgent"] })], + { priority: "other" }, + ); + expect(result).toEqual({ + ok: false, + error: "priority must be one of its allowed values.", + }); + }); + + it("converts numbers and omits optional and secret values", () => { + const result = serializeQualificationParameters( + [ + parameter({ name: "amount", type: "number" }), + parameter({ name: "note", required: false }), + parameter({ name: "api_token", secret: true }), + ], + { amount: "75.5", note: "", api_token: "never-send" }, + ); + expect(result).toEqual({ ok: true, json: JSON.stringify({ amount: 75.5 }) }); + }); +}); diff --git a/src/lib/qualificationParameters.ts b/src/lib/qualificationParameters.ts new file mode 100644 index 0000000..83a07cd --- /dev/null +++ b/src/lib/qualificationParameters.ts @@ -0,0 +1,47 @@ +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 === "") { + if (parameter.required) { + return { + ok: false, + error: `${parameter.name} is required.`, + }; + } + continue; + } + if (parameter.choices.length > 0 && !parameter.choices.includes(value)) { + return { + ok: false, + error: `${parameter.name} must be one of its allowed values.`, + }; + } + 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..a53e00f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -219,6 +219,15 @@ export interface QualificationActionControls { effects: QualificationEditableEffect[]; } +export interface QualificationParameter { + name: string; + type: string; + secret: boolean; + required: boolean; + example: string | null; + choices: string[]; +} + export interface QualificationNode { id: string; index: number; @@ -373,11 +382,7 @@ export interface QualificationProject { certified_at?: string | null; }; controls: { - parameters: { - name: string; - type: string; - secret: boolean; - }[]; + parameters: QualificationParameter[]; actions: Record; }; } @@ -429,6 +434,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/overlay/generated/contract.ts b/src/overlay/generated/contract.ts index 532e8a3..f7d3766 100644 --- a/src/overlay/generated/contract.ts +++ b/src/overlay/generated/contract.ts @@ -1,4 +1,4 @@ -// Generated from openadapt-types 0.6.1. Do not edit by hand. +// Generated from openadapt-types 0.7.0. Do not edit by hand. export const CONTROL_OVERLAY_FRAME_VERSION = "openadapt.control-overlay-frame/v2" as const; export const CONTROL_OVERLAY_TIMELINE_VERSION = "openadapt.control-overlay-timeline/v2" as const; diff --git a/src/portalShell.test.ts b/src/portalShell.test.ts index b1a5ecb..38746a9 100644 --- a/src/portalShell.test.ts +++ b/src/portalShell.test.ts @@ -6,12 +6,8 @@ * "That decision was refused". So this drives the actual script in a DOM: * stubbed transport in, rendered text out. * - * Two things are pinned here: - * - * 1. the operator can tell WHAT broke and what each answer will do; - * 2. every terminal outcome the engine can return maps to its own copy, and an - * outcome this build does not know is reported as unknown -- never as a - * refusal. + * These tests protect the operator and runner contracts. They do not pin + * ordinary wording, layout, or visual styling. */ import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -80,6 +76,25 @@ const TYPED_HALT = { }, }; +const RECONCILIATION_HALT = { + ...RESOLUTION_HALT, + task: { + ...RESOLUTION_HALT.task, + task_kind: "delivery_uncertain", + delivery_state: "unknown", + allowed_actions: ["reconcile", "teach", "escalate"], + }, + presentation: { + ...RESOLUTION_HALT.presentation, + question: "Is the live destination ready for OpenAdapt to reconcile the uncertain action?", + halt: { + ...RESOLUTION_HALT.presentation.halt, + category: "effect_indeterminate", + will_recheck: [{ check: "delivery_reconciliation", count: null }], + }, + }, +}; + type Reply = { status: number; body: unknown }; let decisionReply: Reply = { status: 200, body: null }; @@ -158,91 +173,18 @@ beforeEach(() => { stubTransport(); }); -describe("the decision view says what broke", () => { - it("names the failed step, the action, and the target it could not find", async () => { - await boot(); - await openTask(); - const text = document.getElementById("main")!.textContent ?? ""; - expect(text).toContain("Step 1 of 6"); - expect(text).toContain("could not find the button labelled “Open”"); - expect(text).toContain("did not click anything"); - }); - - it("lists the rungs it tried and skips the ones it never had evidence for", async () => { - await boot(); - await openTask(); - const tried = document.querySelector(".ladder")!.textContent ?? ""; - expect(tried).toContain("the recorded picture of the target"); - expect(tried).toContain("the target's text label"); - expect(tried).toContain("did not find it"); - // geometry had nothing recorded, so it is not presented as a failure. - expect(tried).not.toContain("its position next to nearby text"); - }); - - it("states what continuing will re-check before anything happens", async () => { - await boot(); - await openTask(); - const recheck = document.querySelector(".recheck")!.textContent ?? ""; - expect(recheck).toContain("find the target again on the live screen"); - expect(recheck).toContain("confirm the record on screen is the intended one"); - expect(recheck).toContain("confirm the screen reaches the expected state"); - expect(recheck).toContain("changes nothing"); - }); - +describe("the decision view preserves the data boundary", () => { it("degrades a withheld label to the field's shape and shows no value", async () => { detail = TYPED_HALT; await boot(); await openTask(); const text = document.getElementById("main")!.textContent ?? ""; - expect(text).toContain("could not find the text field"); - expect(text).toContain("did not type into anything"); expect(text).not.toContain(PROTECTED_VALUE); expect(text).not.toContain("Marta"); }); }); -describe("the answers are distinguishable by consequence", () => { - it("puts the consequence on each button and spells it out in the card", async () => { - await boot(); - await openTask(); - const bar = document.getElementById("actions")!.textContent ?? ""; - expect(bar).toContain("This run only"); - expect(bar).toContain("Ends this run"); - expect(bar).toContain("Changes future runs"); - expect(bar).toContain("Hands this to someone else"); - - const consequences = document.querySelector(".consequences")!.textContent ?? ""; - expect(consequences).toContain("The saved workflow is not changed"); - expect(consequences).toContain("future runs handle this on their own"); - expect(consequences).toContain("The run stays paused exactly where it is"); - // Only the actions the signed task allows are described. - expect(consequences).not.toContain("Skip this step"); - }); - - it("distinguishes ending the run from parking it for someone else", async () => { - await boot(); - await openTask(); - const briefs = Object.fromEntries( - Array.from(document.querySelectorAll("#actions [data-action]")).map((b) => [ - (b as HTMLElement).dataset.action, - b.querySelector(".brief")?.textContent ?? "", - ]), - ); - // The whole reason reject is its own wire action: these two answers do - // opposite things to the run, and an operator who reads them as synonyms - // has been told the wrong thing about what happens next. - expect(briefs.reject).toBe("Ends this run"); - expect(briefs.escalate).toBe("Hands this to someone else"); - - const consequences = document.querySelector(".consequences")!.textContent ?? ""; - expect(consequences).toContain("this run cannot be resumed afterwards"); - expect(consequences).toContain("The run stays paused exactly where it is"); - // Reject is about THIS RUN, never about the saved workflow. - expect(consequences).toContain( - "Ends this run now. Nothing in the application is touched", - ); - }); - +describe("the shell sends only signed task actions", () => { it("sends the closed reject disposition and never any free text", async () => { await boot(); await openTask(); @@ -267,8 +209,92 @@ describe("the answers are distinguishable by consequence", () => { }); }); -describe("a rejection is reported as the end of the run", () => { - it("never says someone will pick it up", async () => { +describe("reconciliation never turns an uncertain delivery into a retry", () => { + it("shows it only for a delivery-uncertain task that may have crossed delivery", async () => { + detail = RECONCILIATION_HALT; + await boot(); + await openTask(); + const reconcile = document.querySelector('[data-action="reconcile"]')!; + expect(reconcile).toBeInstanceOf(HTMLButtonElement); + }); + + it("does not render reconcile when the action was not delivered", async () => { + detail = { + ...RECONCILIATION_HALT, + task: { ...RECONCILIATION_HALT.task, delivery_state: "not_delivered" }, + }; + await boot(); + await openTask(); + expect(document.querySelector('[data-action="reconcile"]')).toBeNull(); + }); + + it("sends a closed reconciliation request, not continue", async () => { + detail = RECONCILIATION_HALT; + decisionReply = { + status: 200, + body: { + state: "completed", + reason_code: "reconciled_and_resumed", + action: "reconcile", + report_success: true, + transition_receipt_digest: "sha256:" + "d".repeat(64), + }, + }; + await boot(); + await openTask(); + await answer("reconcile"); + const body = JSON.parse( + (fetch as unknown as { mock: { calls: unknown[][] } }).mock.calls + .map((call) => call[1] as { body?: string } | undefined) + .filter((init) => init?.body) + .pop()!.body!, + ); + expect(body.action).toBe("reconcile"); + expect(body.disposition).toBe("reconcile_requested"); + const outcome = document.getElementById("outcome")!; + expect(outcome.classList).toContain("success"); + expect(outcome.classList).toContain("terminal"); + }); + + it("does not claim a reconciliation succeeded without the exact receipt proof", async () => { + detail = RECONCILIATION_HALT; + decisionReply = { + status: 200, + body: { + state: "completed", + reason_code: "reconciled_and_resumed", + action: "reconcile", + report_success: true, + }, + }; + await boot(); + await openTask(); + await answer("reconcile"); + const outcome = document.getElementById("outcome")!; + expect(outcome.classList).not.toContain("success"); + expect(outcome.classList).toContain("terminal"); + }); + + it("keeps an unproven reconciliation answerable without offering a resend", async () => { + detail = RECONCILIATION_HALT; + decisionReply = { + status: 200, + body: { + state: "refused", + reason_code: "revalidation_refused", + action: "reconcile", + }, + }; + await boot(); + await openTask(); + await answer("reconcile"); + expect(document.querySelector('[data-action="reconcile"]')).not.toBeNull(); + expect(document.querySelector('[data-action="continue"]')).toBeNull(); + }); +}); + +describe("terminal outcomes close the action set", () => { + it("closes the run after rejection", async () => { decisionReply = { status: 200, body: { @@ -282,13 +308,7 @@ describe("a rejection is reported as the end of the run", () => { await boot(); await openTask(); await answer("reject"); - const text = outcomeText(); - expect(text).toContain("This run is over and cannot be resumed"); - expect(text).toContain("nothing in the application was touched"); - // The escalation wording would be an actively wrong instruction here. - expect(text).not.toContain("until someone picks it up"); - expect(text).not.toContain("stays paused"); - // Terminal: the answers go away. + expect(document.getElementById("outcome")!.classList).toContain("terminal"); expect(document.getElementById("actions")!.hidden).toBe(true); }); @@ -297,11 +317,11 @@ describe("a rejection is reported as the end of the run", () => { await boot(); await openTask(); await answer("reject"); - expect(outcomeText()).toContain("This run is over and cannot be resumed"); + expect(document.getElementById("outcome")!.classList).toContain("terminal"); }); }); -describe("the terminal receipt shape", () => { +describe("the terminal receipt contract", () => { it("renders a real halt as a halt, not as a refusal", async () => { decisionReply = { status: 200, @@ -315,9 +335,7 @@ describe("the terminal receipt shape", () => { await boot(); await openTask(); await answer(); - expect(outcomeText()).toContain("continued and then stopped again"); - expect(outcomeText()).not.toContain("refused"); - // A terminal outcome takes the buttons away. + expect(document.getElementById("outcome")!.classList).toContain("halted"); expect((document.getElementById("actions") as HTMLElement).hidden).toBe(true); }); @@ -329,7 +347,9 @@ describe("the terminal receipt shape", () => { await boot(); await openTask(); await answer(); - expect(outcomeText()).toContain("Checked and continued"); + expect((document.getElementById("decision") as HTMLElement).hidden).toBe(true); + expect(document.getElementById("outcome")!.classList).toContain("terminal"); + expect(document.getElementById("outcome")!.classList).toContain("success"); expect((document.getElementById("actions") as HTMLElement).hidden).toBe(true); }); @@ -341,7 +361,8 @@ describe("the terminal receipt shape", () => { await boot(); await openTask(); await answer(); - expect(outcomeText()).toContain("still not in the state this step needs"); + expect((document.getElementById("decision") as HTMLElement).hidden).toBe(false); + expect(document.getElementById("outcome")!.classList).not.toContain("terminal"); const button = document.querySelector('[data-action="verify_and_resume"]') as HTMLButtonElement; expect(button.disabled).toBe(false); }); @@ -354,8 +375,9 @@ describe("the terminal receipt shape", () => { await boot(); await openTask(); await answer(); - expect(outcomeText()).toContain("may already have been sent"); - expect(outcomeText()).toContain("Do not answer again"); + const outcome = document.getElementById("outcome")!; + expect(outcome.classList).toContain("uncertain"); + expect(outcome.classList).toContain("terminal"); }); it("reports an outcome it has no wording for as unknown, not as a refusal", async () => { @@ -366,8 +388,9 @@ describe("the terminal receipt shape", () => { await boot(); await openTask(); await answer(); - expect(outcomeText()).toContain("no wording for"); - expect(outcomeText()).not.toContain("refused"); + const outcome = document.getElementById("outcome")!; + expect(outcome.classList).toContain("uncertain"); + expect(outcome.classList).toContain("terminal"); }); it("still renders a runner that returns the older decision record", async () => { @@ -383,8 +406,7 @@ describe("the terminal receipt shape", () => { await boot(); await openTask(); await answer(); - expect(outcomeText()).toContain("continued and then stopped again"); - // The engine's free-text message is audit, not phone copy. + expect(document.getElementById("outcome")!.classList).toContain("halted"); expect(outcomeText()).not.toContain("front-desk"); }); @@ -396,7 +418,6 @@ describe("the terminal receipt shape", () => { await boot(); await openTask(); await answer(); - expect(outcomeText()).toContain("reload the current pause"); const button = document.querySelector('[data-action="verify_and_resume"]') as HTMLButtonElement; expect(button.disabled).toBe(false); }); @@ -406,7 +427,7 @@ describe("the terminal receipt shape", () => { await boot(); await openTask(); await answer(); - expect(outcomeText()).toContain("The result is uncertain"); + expect(document.getElementById("outcome")!.classList).toContain("uncertain"); }); it("treats a request that never came back as uncertain, not as pending forever", async () => { @@ -416,53 +437,27 @@ describe("the terminal receipt shape", () => { throw new TypeError("Failed to fetch"); }); await answer(); - expect(outcomeText()).toContain("The result is uncertain"); - expect(outcomeText()).not.toContain("Waiting for this computer"); + const outcome = document.getElementById("outcome")!; + expect(outcome.classList).toContain("uncertain"); + expect(outcome.classList).toContain("terminal"); }); }); -describe("no answer is rendered as the recommended one", () => { - it("gives every action the same styling, however the engine ordered them", async () => { +describe("the signed task controls the action set", () => { + it("does not add a local action", async () => { await boot(); await openTask(); const buttons = Array.from( document.querySelectorAll("#actions [data-action]"), ) as HTMLButtonElement[]; - expect(buttons).toHaveLength(4); - // `allowed_actions[0]` is `verify_and_resume` here, which is exactly the - // case that used to be painted in filled accent. `reject` must not become - // the new emphasised one either: removing a recommendation and then - // recommending the opposite answer is the same mistake pointed the other - // way, and nothing on this task means "recommended". - expect(buttons[0].dataset.action).toBe("verify_and_resume"); - const classes = new Set(buttons.map((b) => b.className)); - expect(classes).toEqual(new Set([""])); - expect(document.getElementById("actions")!.innerHTML).not.toContain("primary"); - }); -}); - -describe("the assurance sentence names both halves of the boundary", () => { - it("says what the engine cannot check and puts it above the actions", async () => { - await boot(); - await openTask(); - const limit = document.querySelector(".limit")!.textContent ?? ""; - expect(limit).toContain("re-checks what it can measure"); - expect(limit).toContain("cannot check whether you actually looked"); - expect(limit).toContain("Answer from the live application"); - // The engine's one-sided sentence is replaced, not printed beside it. - const text = document.getElementById("main")!.textContent ?? ""; - expect(text).not.toContain("Your answer does not mark the run verified"); - // Reading order: the limit precedes the action bar, which is the last - // element the operator reaches. - const card = document.querySelector(".card")!; - const limitNode = card.querySelector(".limit")!; - const outcome = card.querySelector(".outcome")!; - expect(limitNode.compareDocumentPosition(outcome) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(buttons.map((button) => button.dataset.action)).toEqual( + RESOLUTION_HALT.task.allowed_actions, + ); }); }); describe("the retained frame is named as history, not as the live screen", () => { - it("labels it by when OpenAdapt stopped, ages it, and points at the app", async () => { + it("shows a retained figure with age and a no-live-state warning", async () => { detail = { ...RESOLUTION_HALT, task: { @@ -472,41 +467,30 @@ describe("the retained frame is named as history, not as the live screen", () => }; await boot(); await openTask(); - const shot = document.querySelector(".shot")!.textContent ?? ""; - expect(shot).toContain("Screen when OpenAdapt stopped"); - expect(shot).toContain("about 14 minutes ago"); - expect(shot).toContain("not the live screen"); - expect(shot).toContain("Look at the application itself"); - expect(shot).not.toContain("current screen"); - // A refresh control would manufacture the liveness this wording removes, - // and the runner does not re-observe on demand. - expect(document.querySelector(".shot")!.innerHTML).not.toContain("Refresh"); + const shot = document.querySelector("figure.shot")!; + expect(shot.querySelector("img[data-artifact]")).not.toBeNull(); + // The runner has no on-demand re-observation route. A retained artifact is + // therefore a view-only image, not a refreshable live control. + expect(shot.querySelector("button")).toBeNull(); }); }); -describe("stakes are shown above the question, and only when they are known", () => { - it("renders the irreversible case", async () => { +describe("known action stakes stay visible without making an action primary", () => { + it("shows the risk signal and keeps the full explanation available", async () => { detail = { ...RESOLUTION_HALT, task: { ...RESOLUTION_HALT.task, risk_class: "irreversible" }, }; await boot(); await openTask(); - const stakes = document.querySelector(".stakes")!.textContent ?? ""; - expect(stakes).toContain("This cannot be undone"); - // Above the question, not below it. - const card = document.querySelector(".card")!; - const heading = card.querySelector("h1")!; - expect( - card.querySelector(".stakes")!.compareDocumentPosition(heading) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); + expect(document.querySelector(".task-details .stakes")).not.toBeNull(); }); - it("says nothing at all when the engine could not establish the stakes", async () => { + it("does not invent a detailed risk claim when the engine does not know", async () => { await boot(); await openTask(); expect(document.querySelector(".stakes")).toBeNull(); + expect(document.querySelector(".task-details .stakes")).toBeNull(); }); }); @@ -539,7 +523,6 @@ describe("the answers are gated on reaching the end of the decision", () => { await openTask(); const bar = document.getElementById("actions")!; expect(bar.querySelectorAll("[data-action]")).toHaveLength(0); - expect(bar.textContent).toContain("Read this decision to the end"); // Nothing in the prompt is tappable: there is no shortcut past the content. expect(bar.querySelectorAll("button")).toHaveLength(0); // The gate is the last element in the document, after the consequence card. @@ -550,7 +533,6 @@ describe("the answers are gated on reaching the end of the decision", () => { fire!([{ isIntersecting: true }]); await flush(); expect(bar.querySelectorAll("[data-action]")).toHaveLength(4); - expect(bar.textContent).not.toContain("Read this decision to the end"); }); it("answers normally once the gate has opened", async () => { @@ -564,7 +546,7 @@ describe("the answers are gated on reaching the end of the decision", () => { fire!([{ isIntersecting: true }]); await flush(); await answer(); - expect(outcomeText()).toContain("Checked and continued"); + expect(document.getElementById("outcome")!.classList).toContain("success"); }); it("fails open where the browser cannot observe the gate", async () => { @@ -574,23 +556,3 @@ describe("the answers are gated on reaching the end of the decision", () => { expect(document.querySelectorAll("#actions [data-action]")).toHaveLength(4); }); }); - -describe("the outcome is readable and the bar goes away", () => { - it("reserves the measured height of the action bar, not a constant", async () => { - await boot(); - await openTask(); - expect(document.documentElement.style.getPropertyValue("--action-bar-height")).toBe("204px"); - }); - - it("releases the reserved space once the decision is over", async () => { - decisionReply = { - status: 200, - body: { state: "completed", reason_code: "verified_and_resumed", action: "verify_and_resume" }, - }; - await boot(); - await openTask(); - await answer(); - expect(document.documentElement.style.getPropertyValue("--action-bar-height")).toBe("0px"); - }); - -}); 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..c106572 100644 --- a/src/screens/QualificationLifecycle.test.tsx +++ b/src/screens/QualificationLifecycle.test.tsx @@ -35,7 +35,43 @@ 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, + required: true, + example: null, + choices: [], + }, + { + name: "amount", + type: "number", + secret: false, + required: true, + example: null, + choices: [], + }, + { + name: "api_token", + type: "string", + secret: true, + required: true, + example: null, + choices: [], + }, + { + name: "priority", + type: "enum", + secret: false, + required: true, + example: "routine", + choices: ["routine", "urgent"], + }, + ], + actions: {}, + }, } as unknown as QualificationProject; } @@ -63,6 +99,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 +112,11 @@ describe("Qualification lifecycle", () => { expect.objectContaining({ workflow_id: "wf-1", case_id: "representative-1", + parameters_json: JSON.stringify({ + record_id: "CASE-42", + amount: 75.5, + priority: "routine", + }), target: { backend: "web" }, }), ), diff --git a/src/screens/QualificationLifecycle.tsx b/src/screens/QualificationLifecycle.tsx index 4dccd06..9b3d8a3 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,21 @@ 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, + parameter.required, + parameter.example, + parameter.choices.join(","), + ].join(":"), + ) + .join("|"); useEffect(() => { if (!selectedCaseId && cases[0]) setSelectedCaseId(cases[0].id); @@ -70,6 +92,31 @@ export function QualificationLifecycle({ useEffect(() => setTarget(targetForProject(project)), [workflowId]); + useEffect(() => { + setParameterValues( + Object.fromEntries( + editableParameters.map((parameter) => [ + parameter.name, + parameter.example ?? "", + ]), + ), + ); + }, [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 +149,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 +165,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 +263,7 @@ export function QualificationLifecycle({ )} - +
- -