diff --git a/README.md b/README.md index c9165275..603f4528 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,20 @@ application itself never calls, so the screen cannot influence it. It ends `VERIFIED` with zero model calls, and writes a shareable `receipt.png` / `receipt.json` beside the run. +For a live walkthrough, perform the demonstration yourself and then watch the +compiled replay at a visible pace: + +```bash +openadapt-flow tutorial --guided +``` + +The recording browser closes after OpenAdapt observes the saved record through +the separate read-only interface. OpenAdapt then compiles, certifies, and +replays what you demonstrated. If you prefer a fully automatic presentation, use +`openadapt-flow tutorial --headed --presentation-delay 1`. The delay applies +only to this bundled tutorial. The ordinary `tutorial`, `replay`, and `run` +paths keep their normal execution speed. + That receipt is generated from a closed allow-list — outcomes, counts, digests, and validated package versions — so it can carry no screenshot, OCR text, typed value, parameter, URL, hostname, coordinate, operator text, or free-form diff --git a/docs/EXECUTION_PROFILES.md b/docs/EXECUTION_PROFILES.md index 0c16b133..f376a25d 100644 --- a/docs/EXECUTION_PROFILES.md +++ b/docs/EXECUTION_PROFILES.md @@ -71,6 +71,32 @@ The tutorial also omits the login screen: typing a credential into a recording produces an artifact whose plaintext value is a secret carrier for no evidentiary gain, and the demonstration proves exactly as much without it. +The fast command remains suitable for a quickstart and CI: + +```bash +openadapt-flow tutorial +``` + +For a guided presentation, the operator can perform the same demonstration in +the browser. The interactive recorder retains the separate read-only +system-of-record snapshots needed for effect mining. The recording browser +closes after OpenAdapt observes the saved record through that separate +read-only interface. OpenAdapt then compiles, certifies, and replays the result +at a visible pace: + +```bash +openadapt-flow tutorial --guided +``` + +The automatic alternative is: + +```bash +openadapt-flow tutorial --headed --presentation-delay 1 +``` + +The delay is bounded to the bundled tutorial. It does not change ordinary +`replay`, `run`, or production runtime timing. + The result is checkable rather than asserted: run the same bundle against an injected backend fault and it does not verify. Both injected faults terminate `HALTED` / `RECONCILIATION_REQUIRED`, and both are pinned in diff --git a/openadapt_flow/__main__.py b/openadapt_flow/__main__.py index 24ad08d5..3cd394d8 100644 --- a/openadapt_flow/__main__.py +++ b/openadapt_flow/__main__.py @@ -925,6 +925,7 @@ def _cmd_tutorial(args: argparse.Namespace) -> int: from datetime import datetime, timezone from openadapt_flow.tutorial import ( + GUIDED_PRESENTATION_DELAY_S, TUTORIAL_WORKFLOW_NAME, TutorialError, run_tutorial, @@ -936,12 +937,18 @@ def _cmd_tutorial(args: argparse.Namespace) -> int: else Path("tutorials") / ("tutorial-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")) ) + interactive_record = bool(args.interactive_record or args.guided) + presentation_delay_s = args.presentation_delay + if presentation_delay_s is None: + presentation_delay_s = GUIDED_PRESENTATION_DELAY_S if args.guided else 0.0 try: result = run_tutorial( out, - headed=args.headed, + headed=bool(args.headed or args.guided), name=args.name or TUTORIAL_WORKFLOW_NAME, emit_receipt=not args.no_receipt, + interactive_record=interactive_record, + presentation_delay_s=presentation_delay_s, echo=print, ) except TutorialError as e: @@ -949,10 +956,14 @@ def _cmd_tutorial(args: argparse.Namespace) -> int: return 2 print(f"\n{result.execution_outcome}: {result.run_dir / 'REPORT.md'}") - print( - f" transaction {result.transaction_outcome} " - f"(billable: {'yes' if result.transaction_billable else 'no'})" - ) + print(f" transaction {result.transaction_outcome}") + metering_class = "billable" if result.transaction_billable else "not billable" + local_charge = ( + "reported" + if result.reported_to_metering + else "this local tutorial was not reported or charged" + ) + print(f" metering class {metering_class} ({local_charge})") print(f" profile {result.execution_profile}") print(f" model calls {result.model_calls}") print( @@ -3647,6 +3658,32 @@ def build_parser() -> argparse.ArgumentParser: help="Workflow name for the compiled bundle (default: local-quickstart)", ) p.add_argument("--headed", action="store_true", help="Run the browser headed") + p.add_argument( + "--guided", + action="store_true", + help=( + "Presentation mode: record the demonstration yourself, then watch " + "a visibly paced governed replay" + ), + ) + p.add_argument( + "--interactive-record", + action="store_true", + help=( + "Perform the tutorial demonstration yourself in a headed browser; " + "the browser closes after the saved record is observed" + ), + ) + p.add_argument( + "--presentation-delay", + type=float, + default=None, + metavar="SECONDS", + help=( + "Tutorial-only pause before each scripted recording action and " + "replay step (0-5 seconds; guided default: 1)" + ), + ) p.add_argument( "--no-receipt", action="store_true", diff --git a/openadapt_flow/interactive_recorder.py b/openadapt_flow/interactive_recorder.py index fc78e221..4179f02b 100644 --- a/openadapt_flow/interactive_recorder.py +++ b/openadapt_flow/interactive_recorder.py @@ -382,6 +382,10 @@ def __init__( settle_stable_frames: int = 2, settle_interval_s: float = 0.15, viewport: tuple[int, int] = (1280, 800), + system_of_record_reader: Optional[ + Callable[[], Optional[list[dict[str, Any]]]] + ] = None, + stop_when: Optional[Callable[[], bool]] = None, ) -> None: self._url = url self._out_dir = Path(out_dir) @@ -391,6 +395,12 @@ def __init__( self._headless = headless self._poll_ms = poll_ms self._viewport = viewport + # Recording-only, read-only observation. This does not add an effect + # verifier to the browser backend or grant the later runtime authority. + self._system_of_record_reader = system_of_record_reader + # Optional recording completion condition. It is evaluated only after + # queued browser events have been persisted, while the page is open. + self._stop_when = stop_when self._settle = dict( settle_timeout_s=settle_timeout_s, settle_stable_frames=settle_stable_frames, @@ -450,18 +460,30 @@ def start(self) -> None: pass self.backend = PlaywrightBackend(self.page) self.recorder = Recorder( - self.backend, self._out_dir, app_url=self._url, **self._settle + self.backend, + self._out_dir, + app_url=self._url, + system_of_record_reader=self._system_of_record_reader, + **self._settle, ) self._last_frame = self.recorder._wait_settled() self._last_structural = self._structural_state() def run(self) -> Path: - """Human loop: pump until the user stops (Ctrl-C / closes the window), - then flush and finish.""" + """Pump until completion, an operator stop, or a closed window.""" + if self._stop_when is None: + finish_instruction = ( + "Press Ctrl-C here (or close the browser window) to finish." + ) + else: + finish_instruction = ( + "Complete the workflow. Recording stops automatically after " + "the configured result is observed. Press Ctrl-C only to stop early." + ) print( f"Recording {self._url}\n" " Perform your workflow in the browser window.\n" - " Press Ctrl-C here (or close the browser window) to finish." + f" {finish_instruction}" ) try: while not self.done: @@ -502,6 +524,8 @@ def pump(self) -> bool: return self._pump() def _pump(self) -> bool: + if self.done: + return False try: self.page.wait_for_timeout(self._poll_ms) except Exception: @@ -515,10 +539,10 @@ def _pump(self) -> bool: # is NOT idle-flushed (a mid-word pause must not split it) — it # flushes on the next boundary event or at finish(). self._flush_scroll() - return True + return not self._stop_condition_reached() for ev in batch: self._process(ev) - return True + return not self._stop_condition_reached() def _process(self, ev: dict[str, Any]) -> None: kind = ev.get("kind") @@ -735,8 +759,26 @@ def _structural_state(self) -> dict[str, Any]: value = None if value is not None: state[key] = value + if self._system_of_record_reader is not None: + try: + records = self._system_of_record_reader() + except Exception: + records = None + if records is not None: + state["sor"] = records return state + def _stop_condition_reached(self) -> bool: + if self._stop_when is None: + return False + try: + done = bool(self._stop_when()) + except Exception: + done = False + if done: + self.done = True + return done + def record_interactive( url: str, @@ -747,6 +789,10 @@ def record_interactive( identifier_fields: tuple[str, ...] = (), headless: bool = False, script: Optional[Callable[[Any, Callable[[], None]], None]] = None, + system_of_record_reader: Optional[ + Callable[[], Optional[list[dict[str, Any]]]] + ] = None, + stop_when: Optional[Callable[[], bool]] = None, **kwargs: Any, ) -> Path: """Record a live demonstration the user drives against ``url``. @@ -773,6 +819,13 @@ def record_interactive( a human recording is headed). script: Test hook — ``script(page, pump)`` drives synthetic input and pumps the loop; when given, the human wait loop is skipped. + system_of_record_reader: Optional read-only observation of the + authoritative records. The recorder retains the observed + before/after state so compilation can propose effect contracts. + This observer is recording-only and does not become a runtime + verifier. + stop_when: Optional recording completion condition. It is evaluated + after queued events are persisted and before the browser closes. Returns: The recording directory. @@ -784,6 +837,8 @@ def record_interactive( param_fields=param_fields, identifier_fields=identifier_fields, headless=headless, + system_of_record_reader=system_of_record_reader, + stop_when=stop_when, **kwargs, ) session.start() diff --git a/openadapt_flow/tutorial.py b/openadapt_flow/tutorial.py index d650fbf2..89777a6a 100644 --- a/openadapt_flow/tutorial.py +++ b/openadapt_flow/tutorial.py @@ -53,6 +53,13 @@ from typing import Any, Callable, Optional from urllib.request import Request, urlopen +#: The guided tutorial pauses before each scripted demonstration action and +#: each replay step. The fast tutorial keeps a zero delay. +GUIDED_PRESENTATION_DELAY_S = 1.0 + +#: A tutorial delay is a presentation aid, not an unbounded runtime wait. +MAX_PRESENTATION_DELAY_S = 5.0 + #: The note typed during the tutorial. Synthetic, constant, and free of #: anything that could be mistaken for a real clinical note. TUTORIAL_NOTE = "Synthetic follow-up in two weeks" @@ -92,6 +99,9 @@ class TutorialResult: effect_tier: Optional[int] bundle_digest: Optional[str] system_of_record_records: int + # The report can classify a VERIFIED production-profile transaction as + # billable. This local-only tutorial never reports usage to Cloud. + reported_to_metering: bool = False receipt_paths: dict[str, Path] = field(default_factory=dict) @@ -126,11 +136,47 @@ def _center(page: Any, selector: str) -> tuple[int, int]: return int(box["x"] + box["width"] / 2), int(box["y"] + box["height"] / 2) +def _validated_presentation_delay(value: float) -> float: + delay = float(value) + if not 0.0 <= delay <= MAX_PRESENTATION_DELAY_S: + raise TutorialError( + "presentation delay must be between 0 and " + f"{MAX_PRESENTATION_DELAY_S:g} seconds" + ) + return delay + + +def _presentation_pause( + delay_s: float, *, sleep: Callable[[float], None] = time.sleep +) -> None: + """Pause one tutorial stage without changing production runtime timing.""" + + delay = _validated_presentation_delay(delay_s) + if delay: + sleep(delay) + + +def _presentation_replayer_type(replayer_type: type[Any], delay_s: float) -> type[Any]: + """Return a tutorial-only replayer type that pauses once per IR step.""" + + delay = _validated_presentation_delay(delay_s) + if not delay: + return replayer_type + + class PresentationReplayer(replayer_type): + def _run_step(self, *args: Any, **kwargs: Any) -> Any: + _presentation_pause(delay) + return super()._run_step(*args, **kwargs) + + return PresentationReplayer + + def record_tutorial( base_url: str, recording_dir: Path, *, headed: bool = False, + presentation_delay_s: float = 0.0, ) -> Path: """Record the triage demonstration WITH system-of-record observation. @@ -144,6 +190,7 @@ def record_tutorial( from openadapt_flow.backends.playwright_backend import PlaywrightBackend from openadapt_flow.recorder import Recorder + delay = _validated_presentation_delay(presentation_delay_s) _http_json(f"{base_url.rstrip('/')}/api/reset", method="POST", body={}) entry_url = f"{base_url.rstrip('/')}/{TUTORIAL_ENTRY_QUERY}" backend, close = PlaywrightBackend.launch(entry_url, headless=not headed) @@ -155,12 +202,17 @@ def record_tutorial( app_url=entry_url, system_of_record_reader=lambda: _records(base_url), ) - recorder.click(*_center(page, ".open-btn")) - recorder.click(*_center(page, "#new-encounter")) - recorder.click(*_center(page, "#type-triage")) - recorder.click(*_center(page, "#note")) - recorder.type_text(TUTORIAL_NOTE, param="note") - recorder.click(*_center(page, "#save-encounter")) + + def demonstrate(action: Callable[[], None]) -> None: + _presentation_pause(delay) + action() + + demonstrate(lambda: recorder.click(*_center(page, ".open-btn"))) + demonstrate(lambda: recorder.click(*_center(page, "#new-encounter"))) + demonstrate(lambda: recorder.click(*_center(page, "#type-triage"))) + demonstrate(lambda: recorder.click(*_center(page, "#note"))) + demonstrate(lambda: recorder.type_text(TUTORIAL_NOTE, param="note")) + demonstrate(lambda: recorder.click(*_center(page, "#save-encounter"))) page.wait_for_selector("#saved-banner", state="visible") page.wait_for_timeout(250) return recorder.finish() @@ -168,6 +220,35 @@ def record_tutorial( close() +def record_tutorial_interactive(base_url: str, recording_dir: Path) -> Path: + """Record the bundled workflow from a real human browser demonstration.""" + + from openadapt_flow.interactive_recorder import record_interactive + + _http_json(f"{base_url.rstrip('/')}/api/reset", method="POST", body={}) + baseline_records = len(_records(base_url)) + entry_url = f"{base_url.rstrip('/')}/{TUTORIAL_ENTRY_QUERY}" + print( + "\nGuided recording\n" + " 1. Open the first task.\n" + " 2. Select New encounter, then Triage.\n" + " 3. Select the Note field and type a short synthetic note.\n" + " 4. Select Save encounter.\n" + " 5. Wait for the saved message. The recording browser closes " + "automatically.\n" + "\nOpenAdapt records your actions and observes the separate local " + "system of record." + ) + return record_interactive( + entry_url, + recording_dir, + param_fields=("note",), + headless=False, + system_of_record_reader=lambda: _records(base_url), + stop_when=lambda: len(_records(base_url)) > baseline_records, + ) + + def consequential_step(workflow: Any) -> Any: """The single consequential, effect-bound step the compiler derived. @@ -229,6 +310,7 @@ def run_tutorial_workflow( bundle_dir: Path, run_dir: Path, headed: bool = False, + presentation_delay_s: float = 0.0, ) -> Any: """Admit and execute the tutorial under the ``standard`` profile.""" @@ -242,6 +324,7 @@ def run_tutorial_workflow( from openadapt_flow.runtime import Replayer from openadapt_flow.runtime.effects import RestRecordVerifier + delay = _validated_presentation_delay(presentation_delay_s) _http_json(f"{base_url.rstrip('/')}/api/reset", method="POST", body={}) entry_url = f"{base_url.rstrip('/')}/{TUTORIAL_ENTRY_QUERY}" @@ -268,16 +351,20 @@ def run_tutorial_workflow( "the standard run gate REFUSED the tutorial bundle; nothing was " f"executed:\n{gate.render()}" ) + replay_params = { + "note": str(getattr(workflow, "params", {}).get("note", TUTORIAL_NOTE)) + } authorization = build_runtime_authorization( workflow, gate, approval_source="openadapt-flow-tutorial", - params={"note": TUTORIAL_NOTE}, + params=replay_params, ) backend, close = PlaywrightBackend.launch(entry_url, headless=not headed) try: - return Replayer( + replayer_type = _presentation_replayer_type(Replayer, delay) + return replayer_type( backend, effect_verifier=verifier, governed_authorization=authorization, @@ -285,7 +372,7 @@ def run_tutorial_workflow( require_settled=True, ).run( workflow.model_copy(deep=True), - params={"note": TUTORIAL_NOTE}, + params=replay_params, bundle_dir=bundle_dir, run_dir=run_dir, execution_target_kind="web", @@ -302,6 +389,8 @@ def run_tutorial( headed: bool = False, name: str = TUTORIAL_WORKFLOW_NAME, emit_receipt: bool = True, + interactive_record: bool = False, + presentation_delay_s: float = 0.0, echo: Optional[Callable[[str], None]] = None, ) -> TutorialResult: """Run the complete free path and return its evidence. @@ -319,6 +408,7 @@ def run_tutorial( from openadapt_flow.report import render_run_report say = echo or (lambda message: None) + delay = _validated_presentation_delay(presentation_delay_s) root = Path(work_dir) root.mkdir(parents=True, exist_ok=True) recording_dir = root / "recording" @@ -327,8 +417,17 @@ def run_tutorial( base_url, _db, stop = serve() try: + visible = headed or interactive_record or delay > 0 say("[1/5] Record the demonstration against a real persistence boundary") - record_tutorial(base_url, recording_dir, headed=headed) + if interactive_record: + record_tutorial_interactive(base_url, recording_dir) + else: + record_tutorial( + base_url, + recording_dir, + headed=visible, + presentation_delay_s=delay, + ) say("[2/5] Compile, mining the effect contract from the observed delta") workflow = compile_recording( @@ -350,7 +449,8 @@ def run_tutorial( workflow=workflow, bundle_dir=bundle_dir, run_dir=run_dir, - headed=headed, + headed=visible, + presentation_delay_s=delay, ) render_run_report(run_dir) elapsed = time.monotonic() - started diff --git a/tests/e2e/test_free_path_e2e.py b/tests/e2e/test_free_path_e2e.py index 62e45e58..63f22421 100644 --- a/tests/e2e/test_free_path_e2e.py +++ b/tests/e2e/test_free_path_e2e.py @@ -108,6 +108,9 @@ def test_free_path_terminates_verified_with_independent_effect_evidence( # COMPLETED_UNVERIFIED is never billable; VERIFIED under a production # profile is the only outcome that is. assert free_path.transaction_billable is True + # The classification remains available for production accounting, but the + # local tutorial never sends a usage event and cannot create a charge. + assert free_path.reported_to_metering is False # A healthy run makes no model calls. assert free_path.model_calls == 0 # The evidence that did not exist before: every declared effect confirmed, diff --git a/tests/test_tutorial_presentation.py b/tests/test_tutorial_presentation.py new file mode 100644 index 00000000..7e5614b1 --- /dev/null +++ b/tests/test_tutorial_presentation.py @@ -0,0 +1,243 @@ +"""Behavior contracts for the optional guided local tutorial.""" + +from __future__ import annotations + +import json +from argparse import Namespace +from pathlib import Path +from typing import Any + +import pytest + +from openadapt_flow import interactive_recorder, recorder, tutorial +from openadapt_flow.__main__ import _cmd_tutorial, build_parser +from openadapt_flow.compiler import compile_recording +from openadapt_flow.mockmed.fault_server import serve +from openadapt_flow.tutorial import TutorialResult + + +def test_fast_tutorial_remains_the_cli_default() -> None: + args = build_parser().parse_args(["tutorial"]) + + assert args.guided is False + assert args.interactive_record is False + assert args.presentation_delay is None + + +def test_guided_cli_composes_human_recording_and_paced_replay( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + observed: dict[str, Any] = {} + + def fake_run(work_dir: Path, **kwargs: Any) -> TutorialResult: + observed.update(work_dir=work_dir, **kwargs) + return TutorialResult( + recording_dir=work_dir / "recording", + bundle_dir=work_dir / "bundle", + run_dir=work_dir / "run", + execution_outcome="VERIFIED", + transaction_outcome="VERIFIED", + execution_profile="standard", + transaction_billable=True, + model_calls=0, + effects_required=2, + effects_confirmed=2, + effect_tier=1, + bundle_digest="a" * 64, + system_of_record_records=1, + ) + + monkeypatch.setattr(tutorial, "run_tutorial", fake_run) + args = Namespace( + out=str(tmp_path / "tutorial"), + headed=False, + guided=True, + interactive_record=False, + presentation_delay=None, + name=None, + no_receipt=True, + ) + + assert _cmd_tutorial(args) == 0 + assert observed["interactive_record"] is True + assert observed["headed"] is True + assert observed["presentation_delay_s"] == tutorial.GUIDED_PRESENTATION_DELAY_S + + metering_line = next( + line + for line in capsys.readouterr().out.splitlines() + if "metering class" in line + ) + assert "billable" in metering_line + assert "not" in metering_line + assert "charged" in metering_line + + +def test_presentation_delay_is_bounded_and_injected() -> None: + waits: list[float] = [] + + tutorial._presentation_pause(0.75, sleep=waits.append) + tutorial._presentation_pause(0.0, sleep=waits.append) + + assert waits == [0.75] + with pytest.raises(tutorial.TutorialError): + tutorial._presentation_pause(tutorial.MAX_PRESENTATION_DELAY_S + 0.1) + + +def test_paced_replayer_pauses_once_before_each_logical_step( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[tuple[str, object]] = [] + + class FakeReplayer: + def _run_step(self, step: object) -> object: + events.append(("run", step)) + return step + + monkeypatch.setattr( + tutorial, + "_presentation_pause", + lambda delay: events.append(("pause", delay)), + ) + paced_type = tutorial._presentation_replayer_type(FakeReplayer, 1.25) + step = object() + + assert paced_type()._run_step(step) is step + assert events == [("pause", 1.25), ("run", step)] + assert tutorial._presentation_replayer_type(FakeReplayer, 0.0) is FakeReplayer + + +def test_scripted_presentation_pauses_before_each_recorded_action( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[tuple[str, object]] = [] + + class FakePage: + def wait_for_selector(self, *args: Any, **kwargs: Any) -> None: + return None + + def wait_for_timeout(self, milliseconds: int) -> None: + events.append(("settle", milliseconds)) + + class FakeBackend: + page = FakePage() + + class FakeRecorder: + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass + + def click(self, *point: int) -> None: + events.append(("click", point)) + + def type_text(self, text: str, *, param: str) -> None: + events.append(("type", (text, param))) + + def finish(self) -> Path: + return tmp_path + + monkeypatch.setattr(tutorial, "_http_json", lambda *args, **kwargs: None) + monkeypatch.setattr(tutorial, "_center", lambda page, selector: (1, 2)) + monkeypatch.setattr( + tutorial, + "_presentation_pause", + lambda delay: events.append(("pause", delay)), + ) + monkeypatch.setattr(recorder, "Recorder", FakeRecorder) + + # record_tutorial imports PlaywrightBackend locally, so patch the source + # module rather than adding a second test-only seam to the product. + from openadapt_flow.backends import playwright_backend + + monkeypatch.setattr( + playwright_backend.PlaywrightBackend, + "launch", + lambda *args, **kwargs: (FakeBackend(), lambda: None), + ) + + tutorial.record_tutorial( + "http://127.0.0.1:1", + tmp_path, + headed=True, + presentation_delay_s=0.5, + ) + + actions = [event for event in events if event[0] in {"click", "type"}] + pauses = [event for event in events if event[0] == "pause"] + assert len(actions) == 6 + assert pauses == [("pause", 0.5)] * len(actions) + + +@pytest.mark.timeout(600) +def test_human_recorder_output_can_pass_the_tutorial_effect_contract( + tmp_path: Path, +) -> None: + """The human recorder supplies the evidence used by Standard admission.""" + + base_url, _db, stop = serve() + root_url = base_url.rstrip("/") + tutorial._http_json(f"{root_url}/api/reset", method="POST", body={}) + + def drive(page, pump) -> None: + def click(selector: str) -> None: + page.wait_for_selector(selector, state="visible", timeout=20000) + page.click(selector) + pump() + pump() + + click(".open-btn") + click("#new-encounter") + click("#type-triage") + click("#note") + page.keyboard.type("A note demonstrated by the operator") + pump() + pump() + click("#save-encounter") + page.wait_for_selector("#saved-banner", state="visible", timeout=20000) + pump() + pump() + + try: + entry_url = f"{root_url}/{tutorial.TUTORIAL_ENTRY_QUERY}" + recording_dir = interactive_recorder.record_interactive( + entry_url, + tmp_path / "recording", + param_fields=("note",), + headless=True, + script=drive, + system_of_record_reader=lambda: tutorial._records(base_url), + stop_when=lambda: bool(tutorial._records(base_url)), + ) + events = [ + json.loads(line) + for line in (recording_dir / "events.jsonl").read_text().splitlines() + if line.strip() + ] + assert events[-1]["kind"] == "click" + assert events[-1]["sor_before"] == [] + assert len(events[-1]["sor_after"]) == 1 + + bundle_dir = tmp_path / "bundle" + workflow = compile_recording( + recording_dir, + bundle_dir, + name="human-guided-tutorial", + mine_effects=True, + ) + + assert tutorial.consequential_step(workflow).effects + assert tutorial.certify_tutorial(workflow).passed is True + report = tutorial.run_tutorial_workflow( + base_url=base_url, + workflow=workflow, + bundle_dir=bundle_dir, + run_dir=tmp_path / "run", + ) + assert report.execution_outcome == "VERIFIED" + assert report.model_calls == 0 + assert report.outcome_envelope.required_contracts.effect == 2 + assert report.outcome_envelope.passed_contracts.effect == 2 + finally: + stop()