From 9e62c38360848eb63d1c3c662930c430dccbc844 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Thu, 24 Sep 2026 23:16:43 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feature/Qualify-starters-pipecode=20=C2=B7?= =?UTF-8?q?=20L-260924-765858=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every demo command, in all three modes, now sends its pipe as the bundle's qualified reference (`extract_entities.extract_entities`) instead of the bare code. The runtime resolves that as an exact key, while a bare code is searched across the bundle's domains and fails as ambiguous once two of them declare it. A new test in `tests/unit/test_mode_symmetry.py` reads each bundle's `domain` and `main_pipe` and checks every demo's call site against them. The live e2e tests pass with the qualified code on `execute` and on `start`. Closes L-260924-765858 πŸ€– Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01N7PSUoKkQLc7VcS7AkFJkM --- ## Summary by cubic Changes every demo command, in all three modes, to send its pipe as the bundle's qualified reference (`domain.pipe_code`, e.g. `extract_entities.extract_entities`) instead of the bare code, so the runtime resolves an exact key. A bare code is searched across every domain of the bundle and fails as ambiguous once two domains declare it. The qualified form ties each call site to its bundle's `domain`, so renaming the domain now requires renaming the call sites together; detached `status` likewise reports the qualified pipe code, matching what the platform records when a run is started. `tests/unit/test_mode_symmetry.py` reads each bundle's `domain` and `main_pipe` and fails when a demo's call site no longer matches, and the e2e and unit tests, docs, and changelog are updated to the qualified references. Written for commit 3643b25ca8eba671d887ca343845fa6178ea8ca1. Summary will update on new commits. Review in cubic Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 4 +++ README.md | 2 +- docs/add-method.md | 2 +- docs/cli-architecture.md | 4 ++- docs/codegen.md | 2 +- tests/e2e/test_extract_entities.py | 2 +- tests/e2e/test_generate_image.py | 2 +- tests/e2e/test_summarize_pdf.py | 2 +- tests/unit/test_attended_cli.py | 2 +- tests/unit/test_blocking_cli.py | 2 +- tests/unit/test_detached_cli.py | 9 ++++-- tests/unit/test_mode_symmetry.py | 48 +++++++++++++++++++++++++++++- widget/attended/cli.py | 6 ++-- widget/blocking/cli.py | 6 ++-- widget/detached/cli.py | 6 ++-- 15 files changed, 77 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8573523..c5b9884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- **The demos name their pipe by its qualified reference**: every demo command, in every mode, sends `pipe_code` as `domain.pipe_code` (`extract_entities.extract_entities`) rather than the bare code, the exact key the runtime resolves. A bare code is searched for across every domain of the bundle and fails as ambiguous once two domains declare it, so code copied from a demo keeps working as its bundle grows; rename a bundle's `domain` and its call sites together. + ## [v0.1.0] - 2026-09-22 ### Highlights diff --git a/README.md b/README.md index a42312b..dc1e989 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ You get the extracted entities as JSON: ## Try the demos -Each demo is one self-contained `widget` command β€” a bundle path, a pipe code, and a typed narrowing of the result into its *generated* model. Every demo exists in every execution mode; the commands below use `blocking`, the simplest one. +Each demo is one self-contained `widget` command β€” a bundle path, the pipe's qualified reference (`domain.pipe_code`), and a typed narrowing of the result into its *generated* model. Every demo exists in every execution mode; the commands below use `blocking`, the simplest one. **Every demo runs with no arguments.** Give it nothing and it uses a bundled sample (and tells you so on stderr), so you can see a working result before you have any input of your own β€” then pass your own text, prompt, or file to replace it: diff --git a/docs/add-method.md b/docs/add-method.md index 410e114..d5053a9 100644 --- a/docs/add-method.md +++ b/docs/add-method.md @@ -91,7 +91,7 @@ A published package can carry several pipes, so the pipe is chosen by a rule tha 3. The only pipe, when the method declares exactly one. 4. Otherwise a refusal listing the pipes and asking for `PIPE`. -The command sends the chosen ref **qualified** (`pipe_code="stats.analyze_text"`) beside the selector. A bare code would be ambiguous in exactly the case step 1 refuses to guess at β€” a code two domains of the method both declare β€” and the runtime resolves a `domain.pipe_code` directly, so the qualified ref is right on every path. The demo commands send bare codes because their bundles have one domain each. +The command sends the chosen ref **qualified** (`pipe_code="stats.analyze_text"`) beside the selector. A bare code would be ambiguous in exactly the case step 1 refuses to guess at β€” a code two domains of the method both declare β€” and the runtime resolves a `domain.pipe_code` directly, so the qualified ref is right on every path. The demo commands send qualified refs too, for the same reason. ## The command's parameters are the method's inputs diff --git a/docs/cli-architecture.md b/docs/cli-architecture.md index 3224784..e231133 100644 --- a/docs/cli-architecture.md +++ b/docs/cli-architecture.md @@ -65,7 +65,7 @@ Three SDK capabilities show up in every result-producing path: - **Produced files.** A run that generates an image or a document does not return the bytes: the output carries a durable `pipelex-storage://` reference beside a signed `public_url` that expires, so the link must not be stored. `widget/artifacts.py` brings the files down through the SDK's artifact stack β€” `collect_artifacts` answers offline whether the output references any file at all, so a text result costs nothing, and `download_artifacts` mints a fresh link for each and saves it under `DEFAULT_DOWNLOAD_DIR`, never reading the embedded `public_url` and never overwriting a file. It answers a verdict with errors as values, so a file that did not come down is reported rather than raised. See `docs/artifact-download.md` in `pipelex-sdk`. - **File upload.** `summarize-pdf` feeds a *file* to a pipe. A hosted run cannot see your filesystem, so `inputs.py`'s `upload_document_input` uploads the file first (`client.upload_file`) and the run request carries only the returned `pipelex-storage://` URI β€” never the bytes. Preparation is a step of its own, before the run: an unreadable file or an upload-incapable deployment fails before any run is created, presented through the same `widget/errors.py` path as every other SDK error. -## Two conventions worth copying +## Conventions worth copying **stdout is the result; stderr is everything else.** Progress spinners, run ids in attended mode, error messages, and hints all go to stderr, so stdout stays pipeable. In detached mode the run id *is* the result, so it goes to stdout bare (`print`, not Rich) β€” `RUN_ID=$(widget detached generate-image "…")` just works. @@ -77,6 +77,8 @@ The hints name the mode *groups*, because the fix for a failed run is usually an **Every demo runs with zero arguments.** When you give neither an argument nor `--file`, the input helper returns a bundled sample (`widget/inputs.py`'s `SAMPLE_*` constants), and the command prints a one-line notice on stderr saying so. A fresh clone shows a working result on its very first command once your API key is set; stdout stays the clean, pipeable result because the notice is on stderr. Sample data is orthogonal to execution, so like input encoding it is shared, not duplicated per mode. +**A demo names its pipe by its qualified reference.** Each call sends `pipe_code="."` β€” `extract_entities.extract_entities`, the bundle's own `domain` and its `main_pipe` β€” never the bare code. The runtime keys a pipe by exactly that reference, while a bare code is searched for across every domain of the bundle and fails as ambiguous once two domains declare the same one, which a bundle you grow from a demo can easily come to do. The qualified form ties the call site to the bundle's `domain`, so rename the two together; `tests/unit/test_mode_symmetry.py` reads the reference from each bundle and fails when a call site no longer matches it. + ## Why `attended` and `detached`, not `durable` Both start the *same* durable run β€” one that lives server-side behind an id and outlives your terminal. The only difference is who waits: `attended` polls from your terminal, `detached` exits and lets you collect the result later. Naming the middle one "durable" would suggest detached is not, which is exactly backwards. The mode names the axis that actually differs. diff --git a/docs/codegen.md b/docs/codegen.md index 7825503..65b15fb 100644 --- a/docs/codegen.md +++ b/docs/codegen.md @@ -11,7 +11,7 @@ The typed models this starter parses run results into are **generated from the ` Each `models.py` starts with a `pipelex-codegen-stamp` header recording the source crate fingerprint, engine version, projection, and a content hash. The sibling `codegen.lock` records the generated artifact set. Together they make drift detectable offline. -The demo commands in each mode CLI (`widget/blocking/cli.py`, `widget/attended/cli.py`) import the generated models and only add the bundle path, the pipe code, and the narrowing line (`Model.model_validate(main_stuff)`) β€” nothing method-shaped is hand-written. +The demo commands in each mode CLI (`widget/blocking/cli.py`, `widget/attended/cli.py`) import the generated models and only add the bundle path, the pipe's qualified reference, and the narrowing line (`Model.model_validate(main_stuff)`) β€” nothing method-shaped is hand-written. ## Workflow diff --git a/tests/e2e/test_extract_entities.py b/tests/e2e/test_extract_entities.py index 45b745b..bc53765 100644 --- a/tests/e2e/test_extract_entities.py +++ b/tests/e2e/test_extract_entities.py @@ -18,6 +18,6 @@ async def test_blocking(self): # The blocking lifecycle end to end: one `execute` call, then narrow. # Extraction finishes well under the hosted ~30s cap, so the blocking mode owns this demo. bundle = BUNDLE_PATH.read_text() - results = await execute_pipe(pipe_code="extract_entities", mthds_contents=[bundle], inputs={"text": SAMPLE_TEXT}) + results = await execute_pipe(pipe_code="extract_entities.extract_entities", mthds_contents=[bundle], inputs={"text": SAMPLE_TEXT}) entities = ExtractedEntities.model_validate(results.main_stuff) assert any("Curie" in person for person in entities.people) diff --git a/tests/e2e/test_generate_image.py b/tests/e2e/test_generate_image.py index cee24ee..9c158dc 100644 --- a/tests/e2e/test_generate_image.py +++ b/tests/e2e/test_generate_image.py @@ -17,7 +17,7 @@ async def test_detached(self): # start, get an id back, then pick the run up again through that id alone. # Image generation outlives the ~30s blocking cap, so it is the demo detached mode owns. bundle = BUNDLE_PATH.read_text() - run_id = await start_pipe(pipe_code="generate_image", mthds_contents=[bundle], inputs={"image_prompt": SAMPLE_PROMPT}) + run_id = await start_pipe(pipe_code="generate_image.generate_image", mthds_contents=[bundle], inputs={"image_prompt": SAMPLE_PROMPT}) assert run_id results = await attend_run(run_id) image = Image.model_validate(results.main_stuff) diff --git a/tests/e2e/test_summarize_pdf.py b/tests/e2e/test_summarize_pdf.py index fc72a5f..5439eb2 100644 --- a/tests/e2e/test_summarize_pdf.py +++ b/tests/e2e/test_summarize_pdf.py @@ -19,7 +19,7 @@ async def test_attended(self): # The attended lifecycle end to end: upload the PDF, start a durable run, poll it, narrow. bundle = BUNDLE_PATH.read_text() inputs = {"document": await upload_document_input(SAMPLE_PDF)} - results = await start_and_wait(pipe_code="summarize_pdf", mthds_contents=[bundle], inputs=inputs) + results = await start_and_wait(pipe_code="summarize_pdf.summarize_pdf", mthds_contents=[bundle], inputs=inputs) summary = DocumentSummary.model_validate(results.main_stuff) assert summary.title assert summary.doc_type diff --git a/tests/unit/test_attended_cli.py b/tests/unit/test_attended_cli.py index 259199e..c9aa3a3 100644 --- a/tests/unit/test_attended_cli.py +++ b/tests/unit/test_attended_cli.py @@ -41,7 +41,7 @@ def test_extract_entities_starts_waits_and_prints_result(self, mocker: MockerFix assert result.exit_code == 0 attended_mock.assert_awaited_once() assert attended_mock.await_args is not None - assert attended_mock.await_args.kwargs["pipe_code"] == "extract_entities" + assert attended_mock.await_args.kwargs["pipe_code"] == "extract_entities.extract_entities" assert attended_mock.await_args.kwargs["inputs"] == {"text": "some text"} assert "Marie Curie" in result.output diff --git a/tests/unit/test_blocking_cli.py b/tests/unit/test_blocking_cli.py index 95053e2..8241f6f 100644 --- a/tests/unit/test_blocking_cli.py +++ b/tests/unit/test_blocking_cli.py @@ -41,7 +41,7 @@ def test_extract_entities_executes_and_prints_result(self, mocker: MockerFixture assert result.exit_code == 0 execute_mock.assert_awaited_once() assert execute_mock.await_args is not None - assert execute_mock.await_args.kwargs["pipe_code"] == "extract_entities" + assert execute_mock.await_args.kwargs["pipe_code"] == "extract_entities.extract_entities" assert execute_mock.await_args.kwargs["inputs"] == {"text": "some text"} assert "Marie Curie" in result.output diff --git a/tests/unit/test_detached_cli.py b/tests/unit/test_detached_cli.py index ac9542d..8ee2c9c 100644 --- a/tests/unit/test_detached_cli.py +++ b/tests/unit/test_detached_cli.py @@ -41,7 +41,7 @@ def test_extract_entities_starts_the_run_and_prints_its_id(self, mocker: MockerF assert result.exit_code == 0 start_mock.assert_awaited_once() assert start_mock.await_args is not None - assert start_mock.await_args.kwargs["pipe_code"] == "extract_entities" + assert start_mock.await_args.kwargs["pipe_code"] == "extract_entities.extract_entities" assert start_mock.await_args.kwargs["inputs"] == {"text": "some text"} # The bare id on stdout is the contract: RUN_ID=$(widget detached extract-entities "…") assert result.stdout.strip() == RUN_ID @@ -128,13 +128,16 @@ def test_wait_brings_down_the_files_the_run_produced(self, mocker: MockerFixture assert stub_download in result.output def test_status_reports_the_run_status(self, mocker: MockerFixture): - run = RunRead(pipeline_run_id=RUN_ID, pipe_code="extract_entities", status=RunStatus.RUNNING, created_at="2026-07-13T10:00:00Z") + # The platform records the pipe_code the run was started with, which the demos send qualified. + run = RunRead( + pipeline_run_id=RUN_ID, pipe_code="extract_entities.extract_entities", status=RunStatus.RUNNING, created_at="2026-07-13T10:00:00Z" + ) mocker.patch("widget.detached.cli.fetch_run_status", return_value=run) result = runner.invoke(app, ["detached", "status", RUN_ID]) assert result.exit_code == 0 assert RUN_ID in result.output assert "RUNNING" in result.output - assert "extract_entities" in result.output + assert "extract_entities.extract_entities" in result.output def test_status_flags_a_degraded_reading(self, mocker: MockerFixture): run = RunRead(pipeline_run_id=RUN_ID, status=RunStatus.RUNNING, created_at="2026-07-13T10:00:00Z", degraded=True) diff --git a/tests/unit/test_mode_symmetry.py b/tests/unit/test_mode_symmetry.py index c5f8f1b..2c75097 100644 --- a/tests/unit/test_mode_symmetry.py +++ b/tests/unit/test_mode_symmetry.py @@ -3,13 +3,21 @@ Every demo exists in every mode group, with the same arguments β€” that symmetry is the pedagogy (diff two mode files and only the lifecycle helper differs), and the duplication it implies is exactly what drifts. A demo added to one mode and -forgotten in another fails here. +forgotten in another fails here, and so does a demo whose call site no longer +names its own bundle's pipe. """ import inspect +import tomllib +from pathlib import Path +import pytest import typer +from pipelex_sdk.runs import RunResults +from pytest_mock import MockerFixture +from typer.testing import CliRunner +import widget from widget.attended.cli import app as attended_app from widget.blocking.cli import app as blocking_app from widget.cli import app as root_app @@ -18,12 +26,30 @@ MODE_APPS = {"blocking": blocking_app, "attended": attended_app, "detached": detached_app} DEMO_COMMANDS = {"extract-entities", "summarize-pdf", "generate-image"} LIFECYCLE_COMMANDS = {"wait", "status", "result"} +# Each demo command runs the bundle in the method directory of the same name. +METHODS_DIR = Path(widget.__file__).parent / "methods" +# The one public lifecycle helper each mode's demos call, which is what gets patched below. +LIFECYCLE_HELPERS = {"blocking": "execute_pipe", "attended": "start_and_wait", "detached": "start_pipe"} +# Outputs each demo's generated model accepts, so blocking and attended get past their narrowing. +DEMO_OUTPUTS: dict[str, dict[str, object]] = { + "extract-entities": {"people": [], "orgs": [], "dates": []}, + "summarize-pdf": {"title": "Invoice", "doc_type": "invoice", "key_points": []}, + "generate-image": {"url": "pipelex-storage://run-1/cat.png", "public_url": "https://cdn.example.com/signed/cat.png"}, +} + +runner = CliRunner() def _command_names(mode_app: typer.Typer) -> set[str]: return {command.name for command in mode_app.registered_commands if command.name is not None} +def _qualified_main_pipe(demo: str) -> str: + """The demo bundle's main pipe as the runtime keys it: the bundle's own domain, a dot, the pipe's code.""" + bundle = tomllib.loads((METHODS_DIR / demo / "main.mthds").read_text()) + return f"{bundle['domain']}.{bundle['main_pipe']}" + + def _demo_signatures(mode_app: typer.Typer) -> dict[str, list[str]]: signatures: dict[str, list[str]] = {} for command in mode_app.registered_commands: @@ -58,3 +84,23 @@ def test_the_demos_take_the_same_arguments_in_every_mode(self): def test_the_root_app_mounts_the_modes_in_reading_order(self): mounted = [group.name for group in root_app.registered_groups if group.name in MODE_APPS] assert mounted == ["blocking", "attended", "detached"] + + @pytest.mark.usefixtures("stub_upload", "stub_download") + @pytest.mark.parametrize("demo", sorted(DEMO_COMMANDS)) + @pytest.mark.parametrize("mode", MODE_APPS) + def test_every_demo_names_its_pipe_by_its_bundles_qualified_ref(self, mocker: MockerFixture, mode: str, demo: str): + """The runtime keys a pipe by `domain.pipe_code`; a bare code is searched across every domain of the bundle. + + The search fails as ambiguous once two domains declare the same code, so the demos send the exact key. + That key couples the call site to the bundle's `domain`, which a bare code never did: renaming the + domain without the call sites would pass every offline test and fail every live run, unless this + test reads the name from the bundle itself. + """ + returned: RunResults | str = "run-abc123" + if mode != "detached": + returned = RunResults(pipeline_run_id="run-1", main_stuff=DEMO_OUTPUTS[demo], tokens_usages=None, usage_assembly_error=None) + helper = mocker.patch(f"widget.{mode}.cli.{LIFECYCLE_HELPERS[mode]}", return_value=returned) + result = runner.invoke(root_app, [mode, demo]) + assert result.exit_code == 0, result.output + assert helper.await_args is not None + assert helper.await_args.kwargs["pipe_code"] == _qualified_main_pipe(demo) diff --git a/widget/attended/cli.py b/widget/attended/cli.py index fbd5054..8b65648 100644 --- a/widget/attended/cli.py +++ b/widget/attended/cli.py @@ -107,7 +107,7 @@ def extract_entities( if resolved.is_sample: progress_console.print(f"[dim]No text given β€” using the sample: {resolved.text!r}. Pass your own as an argument or via --file.[/dim]") bundle = (METHODS_DIR / "extract-entities" / "main.mthds").read_text() - results = _run(start_and_wait(pipe_code="extract_entities", mthds_contents=[bundle], inputs={"text": resolved.text})) + results = _run(start_and_wait(pipe_code="extract_entities.extract_entities", mthds_contents=[bundle], inputs={"text": resolved.text})) # Narrow into the generated typed model (validates the concept's shape), then print it as JSON. entities = ExtractedEntities.model_validate(results.main_stuff) output_console.print_json(data=entities.model_dump()) @@ -128,7 +128,7 @@ def summarize_pdf( bundle = (METHODS_DIR / "summarize-pdf" / "main.mthds").read_text() # Upload the file first (a separate step from the run) β€” the run request carries only its URI. inputs = {"document": _run(upload_document_input(document))} - results = _run(start_and_wait(pipe_code="summarize_pdf", mthds_contents=[bundle], inputs=inputs)) + results = _run(start_and_wait(pipe_code="summarize_pdf.summarize_pdf", mthds_contents=[bundle], inputs=inputs)) summary = DocumentSummary.model_validate(results.main_stuff) output_console.print_json(data=summary.model_dump()) print_cost_report(progress_console, results) @@ -149,7 +149,7 @@ def generate_image( if resolved.is_sample: progress_console.print(f"[dim]No prompt given β€” using the sample: {resolved.text!r}. Pass your own as an argument or via --file.[/dim]") bundle = (METHODS_DIR / "generate-image" / "main.mthds").read_text() - results = _run(start_and_wait(pipe_code="generate_image", mthds_contents=[bundle], inputs={"image_prompt": resolved.text})) + results = _run(start_and_wait(pipe_code="generate_image.generate_image", mthds_contents=[bundle], inputs={"image_prompt": resolved.text})) # On the hosted path the runtime returns a storage `url` (`pipelex-storage://…`) # *and* a web-renderable `public_url` (a signed URL); the model keeps both. image = Image.model_validate(results.main_stuff) diff --git a/widget/blocking/cli.py b/widget/blocking/cli.py index c389529..8a6cec5 100644 --- a/widget/blocking/cli.py +++ b/widget/blocking/cli.py @@ -87,7 +87,7 @@ def extract_entities( if resolved.is_sample: progress_console.print(f"[dim]No text given β€” using the sample: {resolved.text!r}. Pass your own as an argument or via --file.[/dim]") bundle = (METHODS_DIR / "extract-entities" / "main.mthds").read_text() - results = _run(execute_pipe(pipe_code="extract_entities", mthds_contents=[bundle], inputs={"text": resolved.text})) + results = _run(execute_pipe(pipe_code="extract_entities.extract_entities", mthds_contents=[bundle], inputs={"text": resolved.text})) # Narrow into the generated typed model (validates the concept's shape), then print it as JSON. entities = ExtractedEntities.model_validate(results.main_stuff) output_console.print_json(data=entities.model_dump()) @@ -108,7 +108,7 @@ def summarize_pdf( bundle = (METHODS_DIR / "summarize-pdf" / "main.mthds").read_text() # Upload the file first (a separate step from the run) β€” the run request carries only its URI. inputs = {"document": _run(upload_document_input(document))} - results = _run(execute_pipe(pipe_code="summarize_pdf", mthds_contents=[bundle], inputs=inputs)) + results = _run(execute_pipe(pipe_code="summarize_pdf.summarize_pdf", mthds_contents=[bundle], inputs=inputs)) summary = DocumentSummary.model_validate(results.main_stuff) output_console.print_json(data=summary.model_dump()) print_cost_report(progress_console, results) @@ -129,7 +129,7 @@ def generate_image( if resolved.is_sample: progress_console.print(f"[dim]No prompt given β€” using the sample: {resolved.text!r}. Pass your own as an argument or via --file.[/dim]") bundle = (METHODS_DIR / "generate-image" / "main.mthds").read_text() - results = _run(execute_pipe(pipe_code="generate_image", mthds_contents=[bundle], inputs={"image_prompt": resolved.text})) + results = _run(execute_pipe(pipe_code="generate_image.generate_image", mthds_contents=[bundle], inputs={"image_prompt": resolved.text})) # On the hosted path the runtime returns a storage `url` (`pipelex-storage://…`) # *and* a web-renderable `public_url` (a signed URL); the model keeps both. image = Image.model_validate(results.main_stuff) diff --git a/widget/detached/cli.py b/widget/detached/cli.py index 314b7ce..a3a96db 100644 --- a/widget/detached/cli.py +++ b/widget/detached/cli.py @@ -126,7 +126,7 @@ def extract_entities( if resolved.is_sample: progress_console.print(f"[dim]No text given β€” using the sample: {resolved.text!r}. Pass your own as an argument or via --file.[/dim]") bundle = (METHODS_DIR / "extract-entities" / "main.mthds").read_text() - run_id = _run(start_pipe(pipe_code="extract_entities", mthds_contents=[bundle], inputs={"text": resolved.text})) + run_id = _run(start_pipe(pipe_code="extract_entities.extract_entities", mthds_contents=[bundle], inputs={"text": resolved.text})) _print_run_id(run_id) @@ -144,7 +144,7 @@ def summarize_pdf( bundle = (METHODS_DIR / "summarize-pdf" / "main.mthds").read_text() # Upload the file first (a separate step from the run) β€” the run request carries only its URI. inputs = {"document": _run(upload_document_input(document))} - run_id = _run(start_pipe(pipe_code="summarize_pdf", mthds_contents=[bundle], inputs=inputs)) + run_id = _run(start_pipe(pipe_code="summarize_pdf.summarize_pdf", mthds_contents=[bundle], inputs=inputs)) _print_run_id(run_id) @@ -162,7 +162,7 @@ def generate_image( if resolved.is_sample: progress_console.print(f"[dim]No prompt given β€” using the sample: {resolved.text!r}. Pass your own as an argument or via --file.[/dim]") bundle = (METHODS_DIR / "generate-image" / "main.mthds").read_text() - run_id = _run(start_pipe(pipe_code="generate_image", mthds_contents=[bundle], inputs={"image_prompt": resolved.text})) + run_id = _run(start_pipe(pipe_code="generate_image.generate_image", mthds_contents=[bundle], inputs={"image_prompt": resolved.text})) _print_run_id(run_id) From 705701e2cb6f11c779285420db0b3d53428a7823 Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sun, 27 Sep 2026 12:45:52 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feature/Failed-run-reason=20=C2=B7=20L-2609?= =?UTF-8?q?25-3c79e2=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A durable run that ended without a result used to print its status three times and never its reason. The failed-run presentation in `widget/errors.py`, used by `widget attended`, `widget detached wait` and `widget detached result`, now reads out the stored error report the SDK carries (the reason from its title and message, the next step and the retry advice), `widget detached status` prints the same lines under the status, and a run with no stored report says that no reason was recorded and what is left to do. It depends on the published `pipelex-sdk` 0.13.0, which carries that typed report, and raises the `mthds` floor to the 0.16.0 that release pins. Closes L-260925-3c79e2 πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- ## Summary by cubic A failed durable run used to print its status three times and never why it failed. The failed-run presentation now reads out the stored error reportβ€”the reason, next step, and retry adviceβ€”across `widget attended`, `widget detached wait`, and `widget detached result`; `widget detached status` prints the same lines under the status, and a run with no stored report says no reason was recorded and what is left to do. Closes L-260925-3c79e2. **Bug Fixes** - The reason is the report's title and message (its error type when it carries neither), the next step its advice, and the retry line its retryable flag, left unsaid when unknown. - Server error text is escaped before Rich renders it, so bracketed spans in provider messages no longer disappear or crash the print. **Dependencies** - Raises the floors to `pipelex-sdk` 0.13.0 and `mthds` 0.16.0, the versions carrying the typed error report. Written for commit 911ac6cdbeab65ffe6ae86cd881285cbe08e4338. Summary will update on new commits. Review in cubic --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 6 ++ CLAUDE.md | 2 +- README.md | 2 +- docs/cli-architecture.md | 15 ++- pyproject.toml | 4 +- tests/conftest.py | 2 +- tests/unit/test_artifacts.py | 16 +++- tests/unit/test_attended_cli.py | 23 ++++- tests/unit/test_detached_cli.py | 67 ++++++++++++++ tests/unit/test_errors.py | 103 +++++++++++++++++++-- uv.lock | 16 ++-- widget/attended/cli.py | 7 +- widget/blocking/cli.py | 7 +- widget/detached/cli.py | 28 ++++-- widget/errors.py | 157 ++++++++++++++++++++++++++++++-- 15 files changed, 401 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b9884..a235dce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ ### Changed - **The demos name their pipe by its qualified reference**: every demo command, in every mode, sends `pipe_code` as `domain.pipe_code` (`extract_entities.extract_entities`) rather than the bare code, the exact key the runtime resolves. A bare code is searched for across every domain of the bundle and fails as ambiguous once two domains declare it, so code copied from a demo keeps working as its bundle grows; rename a bundle's `domain` and its call sites together. +- **`pipelex-sdk` 0.13.0 and `mthds` 0.16.0 are the floors**: the failed-run presentation reads the SDK's typed error report, which first shipped in `pipelex-sdk` 0.13.0, and `mthds` follows the version that release pins exactly. + +### Fixed + +- **A failed run says why**: a durable run that ended without a result, whether met by `widget attended …`, `widget detached wait` or `widget detached result`, now prints the reason the runner stored for it (its title and message), the next step it advises and whether running it again can succeed, instead of repeating its status; `widget detached status` prints the same lines under the status. A run that ended with no stored report, such as a cancelled one, says that no reason was recorded, keeps the platform's own sentence and says what is left to do. +- **An error that stops a command prints the server's text as it came**: a bracketed span in the message, its explanation or its hint, and in a failed run's stored report read out by `widget detached status`, such as a provider's `[/x]`, is no longer read as Rich markup, so it neither disappears nor crashes the print. ## [v0.1.0] - 2026-09-22 diff --git a/CLAUDE.md b/CLAUDE.md index 335d299..a13723a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ This starter calls the **hosted Pipelex API** via the `pipelex-sdk` package (`Pi - Credentials/endpoint come from `PIPELEX_BASE_URL` / `PIPELEX_API_KEY` (see `.env.example`). `python-dotenv` loads `.env` when running the CLI or tests. - **The execution mode is the command group, not an option.** There are exactly three, each a self-contained Typer sub-package: `widget blocking …` (`client.execute` β€” one call, dies at the hosted ~30s cap), `widget attended …` (`client.start` + `client.wait_for_result` β€” durable, you wait), `widget detached …` (`client.start` only β€” durable, you collect it later with `widget detached status|result|wait `). Attended and detached start the *same* durable run; the axis they name is who waits. There is no default mode and no `--mode` option: `widget/cli.py` is a thin assembler (`load_dotenv` callback + three `add_typer` calls in reading order) and nothing else. -- **Each mode file is a copy-paste unit; lifecycle code is never shared.** `widget//cli.py` holds that mode's whole story: its `typer.Typer`, its consoles (results β†’ stdout, progress β†’ stderr), its one public lifecycle helper (`execute_pipe` / `start_and_wait` / `start_pipe`, plus `attend_run` + the fetchers in detached; every result-producing one returns the SDK's `RunResults`, blocking included, through `results_from_execute`), its demo commands, and a private `_run()` that wraps `asyncio.run` and catches SDK errors once. The **only** shared modules are those orthogonal to execution: `widget/inputs.py` (text-or-file input with a built-in **sample fallback** so every demo runs with zero arguments β€” `read_text_input` returns `TextInput(text, is_sample)` and the demo prints a stderr notice when the sample was used; plus file β†’ `{"concept": "Document", "content": …}` envelope β€” `upload_document_input` uploads the file to hosted storage with `client.upload_file` and wraps the returned `pipelex-storage://` URI, `build_document_input(path, uri)` being the pure envelope builder β€” and the `SAMPLE_*` constants), `widget/errors.py` (SDK error β†’ message + hint, hints naming the mode groups; it reads the RFC 7807 **problem+json** body off raw protocol-route `httpx.HTTPStatusError`s and branches on the structured `error_type`, e.g. `StartRequiresAsyncOrchestration` β†’ "use `widget blocking`"; it also hints the file-upload error family and the artifact-download one, whose hints never say to rerun because the run was already paid for), `widget/usage.py` (cost report: `print_cost_report` renders `pipelex_sdk.usage.summarize_usage(results)` to **stderr** β€” the SDK owns the folding rules and this module re-derives none of them), `widget/artifacts.py` (produced files: `collect_artifacts` answers offline whether the output references any, `download_artifacts` saves them under `DEFAULT_DOWNLOAD_DIR` with links minted fresh rather than the expiring `public_url`), and `widget/outputs.py` (`list_items`, the one place a **plural** output's two wire shapes β€” a bare array or an `items` envelope, which of them you get depends on the execution path rather than on the method β€” are read as one; the Python twin of `pipelex-starter-js`'s `wireListOutput`, and a workaround with an expiry). Do not introduce a shared runner β€” the dispatch indirection is exactly what this layout removed. See `docs/cli-architecture.md`. +- **Each mode file is a copy-paste unit; lifecycle code is never shared.** `widget//cli.py` holds that mode's whole story: its `typer.Typer`, its consoles (results β†’ stdout, progress β†’ stderr), its one public lifecycle helper (`execute_pipe` / `start_and_wait` / `start_pipe`, plus `attend_run` + the fetchers in detached; every result-producing one returns the SDK's `RunResults`, blocking included, through `results_from_execute`), its demo commands, and a private `_run()` that wraps `asyncio.run` and catches SDK errors once. The **only** shared modules are those orthogonal to execution: `widget/inputs.py` (text-or-file input with a built-in **sample fallback** so every demo runs with zero arguments β€” `read_text_input` returns `TextInput(text, is_sample)` and the demo prints a stderr notice when the sample was used; plus file β†’ `{"concept": "Document", "content": …}` envelope β€” `upload_document_input` uploads the file to hosted storage with `client.upload_file` and wraps the returned `pipelex-storage://` URI, `build_document_input(path, uri)` being the pure envelope builder β€” and the `SAMPLE_*` constants), `widget/errors.py` (SDK error β†’ message + hint, hints naming the mode groups; it reads the RFC 7807 **problem+json** body off raw protocol-route `httpx.HTTPStatusError`s and branches on the structured `error_type`, e.g. `StartRequiresAsyncOrchestration` β†’ "use `widget blocking`"; it also hints the file-upload error family and the artifact-download one, whose hints never say to rerun because the run was already paid for; a run that ended without a result is presented from its stored error report β€” `present_failed_run` and `report_lines` read out the reason, the next step and the retry advice, the same lines `widget detached status` and `result` print β€” and `print_error` escapes the server's text before Rich sees it), `widget/usage.py` (cost report: `print_cost_report` renders `pipelex_sdk.usage.summarize_usage(results)` to **stderr** β€” the SDK owns the folding rules and this module re-derives none of them), `widget/artifacts.py` (produced files: `collect_artifacts` answers offline whether the output references any, `download_artifacts` saves them under `DEFAULT_DOWNLOAD_DIR` with links minted fresh rather than the expiring `public_url`), and `widget/outputs.py` (`list_items`, the one place a **plural** output's two wire shapes β€” a bare array or an `items` envelope, which of them you get depends on the execution path rather than on the method β€” are read as one; the Python twin of `pipelex-starter-js`'s `wireListOutput`, and a workaround with an expiry). Do not introduce a shared runner β€” the dispatch indirection is exactly what this layout removed. See `docs/cli-architecture.md`. - **Full demo matrix, guarded.** All three demos exist in all three modes: `extract-entities` (text in), `summarize-pdf` (a *file* in), `generate-image` (prompt in). `generate-image` is the deliberate slow case that overruns the ~30s blocking cap β€” `widget blocking generate-image` is *expected to fail*, and that is the teaching moment for the durable modes. The near-duplication across mode files is the pedagogy (diff two mode files and only the lifecycle helper differs); `tests/unit/test_mode_symmetry.py` keeps it from drifting. `samples/sample-invoice.pdf` is shipped for `summarize-pdf`. - The SDK resolves the main output on every result-producing path (`client.execute` returns a `PipelexExecuteResult`, the durable path a `RunResults`, both exposing a resolved `.main_stuff`, typed `Any`; a completed run with no main stuff raises `MissingMainStuffError`). So the result-producing lifecycle helpers (`execute_pipe`, `start_and_wait`, detached's `attend_run`) all return the SDK's `RunResults` β€” one object carrying the resolved output, what the run consumed and the references to the files it produced. The blocking mode reaches it through `pipelex_sdk.execute_result.results_from_execute`, the SDK's public lift (0.10.2), so no mode reads the runner's raw `pipe_output`. The blocking/attended demo commands narrow `results.main_stuff` inline β€” e.g. `ExtractedEntities.model_validate(results.main_stuff)` β€” into the generated model, then hand `results` to the cost report and the download. Detached is the exception by design: `start_pipe` returns only the run id (the demos print it bare, no cost β€” the run isn't done), and the run-id commands (`wait`/`result`) print the output generically **and** its produced files and cost report β€” no model narrowing, since at collection time the command doesn't know which method the run executed. There is no per-example wrapper layer. - The modes spell out lifecycles the SDK could hide: `client.start_and_wait()` is a self-healing one-liner that picks the path for you (the production shortcut). The starter writes them out because teaching the difference is the point. diff --git a/README.md b/README.md index dc1e989..2ae86ce 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ Same durable run, but `widget` exits as soon as it has the id β€” on stdout, so ```bash RUN_ID=$(uv run widget detached generate-image "a fox reading under a tree") -uv run widget detached status $RUN_ID # where is it now? (no waiting) +uv run widget detached status $RUN_ID # where is it now, and why did it fail if it did? (no waiting) uv run widget detached result $RUN_ID # its result, if it is done (no waiting) uv run widget detached wait $RUN_ID # block until it is done, then print the result ``` diff --git a/docs/cli-architecture.md b/docs/cli-architecture.md index e231133..43033e7 100644 --- a/docs/cli-architecture.md +++ b/docs/cli-architecture.md @@ -69,11 +69,22 @@ Three SDK capabilities show up in every result-producing path: **stdout is the result; stderr is everything else.** Progress spinners, run ids in attended mode, error messages, and hints all go to stderr, so stdout stays pipeable. In detached mode the run id *is* the result, so it goes to stdout bare (`print`, not Rich) β€” `RUN_ID=$(widget detached generate-image "…")` just works. -**SDK errors are presented once, at the root of the command.** `_run()` catches `PipelineRequestError` (the base of every error the SDK client raises) and the raw `httpx.HTTPStatusError` its protocol routes surface, maps it to a `(message, hint)` pair via `widget/errors.py`, and exits non-zero. Ctrl-C is handled separately: the durable lifecycle helpers catch the cancellation just long enough to print the resume hint before re-raising, and `_run()` maps the resulting `KeyboardInterrupt` to exit 130. Beyond those two, nothing is caught: an unexpected exception crashes loudly with its traceback, which is what you want while you are building. +**SDK errors are presented once, at the root of the command.** `_run()` catches `PipelineRequestError` (the base of every error the SDK client raises) and the raw `httpx.HTTPStatusError` its protocol routes surface, maps it to an `ErrorPresentation` (a message, the lines that explain it, and a hint) via `widget/errors.py`, prints it with `print_error`, and exits non-zero. `print_error` escapes every piece before it reaches Rich, because the message and its lines carry the server's text, and a bracketed span in it would otherwise be read as markup. Ctrl-C is handled separately: the durable lifecycle helpers catch the cancellation just long enough to print the resume hint before re-raising, and `_run()` maps the resulting `KeyboardInterrupt` to exit 130. Beyond those two, nothing is caught: an unexpected exception crashes loudly with its traceback, which is what you want while you are building. The protocol routes (`execute`/`start`/`runs/*`) surface a non-2xx as a raw `httpx.HTTPStatusError`, whose default string is useless (`Client error '400 Bad Request' for url …` + an MDN link). `widget/errors.py` instead reads the API's RFC 7807 **problem+json** body and shows the server's own `detail`, branching on the structured `error_type` (never the transport status) for the cases worth a hint β€” a `/start` against a synchronous-only runner (`StartRequiresAsyncOrchestration`) is presented with a hint pointing at `widget blocking`. -The hints name the mode *groups*, because the fix for a failed run is usually another group: a blocking run that hit the ~30s cap tells you to rerun it with `widget attended`; a run that timed out while you waited tells you to resume it with `widget detached wait `; a durable run against a runner that can't do them tells you to use `widget blocking`. +**A failed run says why.** A durable run that ended without a result carries the error report the runner stored when it failed, which the SDK hands back typed as `RunErrorReport` on `RunFailedError.error` (out of `wait_for_result`, `start_and_wait` and an artifact download), on the failed arm of `get_run_result`, and on the status read's `RunRead.error`. `present_failed_run` presents all three the same way, and `report_lines` reads the report out as the lines a person reads: + +```text +Error: Run 3f2a… failed. + Reason: LLM completion β€” The model refused the request. + Next step: Rephrase the prompt, or pick another model. + Retry: running it again may succeed. +``` + +The reason is the report's `title` and `message` (its `error_type` when it carries neither), the next step its `user_action` (the advice's own words, or a sentence for its `kind` when it gives none), and the retry line its `retryable`, left unsaid when that is `None`, which means unknown rather than no. There is no hint under a report: the next step is the advice. `widget detached status ` prints the same lines under the status, so a failed run reads the same whichever command you met it with. A run that ended with no stored report β€” a cancelled or timed-out one, or one the platform finalized itself β€” says that no reason was recorded, keeps the platform's own sentence (on a stored result the platform refuses to serve, that sentence is the only thing that says what happened), and hints at what is left: support for a failure, starting it again for a run that was stopped. The report is the runner's verbose one, so a provider's raw text can reach the terminal; that is right for a developer's tool, and an application in front of end users decides what of it they see. + +The other hints name the mode *groups*, because when a mode cannot carry a run, the fix is usually another group: a blocking run that hit the ~30s cap tells you to rerun it with `widget attended`; a run that timed out while you waited tells you to resume it with `widget detached wait `; a durable run against a runner that can't do them tells you to use `widget blocking`. **Every demo runs with zero arguments.** When you give neither an argument nor `--file`, the input helper returns a bundled sample (`widget/inputs.py`'s `SAMPLE_*` constants), and the command prints a one-line notice on stderr saying so. A fresh clone shows a working result on its very first command once your API key is set; stdout stays the clean, pipeable result because the notice is on stderr. Sample data is orthogonal to execution, so like input encoding it is shared, not duplicated per mode. diff --git a/pyproject.toml b/pyproject.toml index c9f1428..79b5ebe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,8 +24,8 @@ classifiers = [ # pin always satisfies it, so bumping the SDK never has to be matched here. dependencies = [ "httpx>=0.27.0", - "mthds>=0.14.0", - "pipelex-sdk>=0.10.2", + "mthds>=0.16.0", + "pipelex-sdk>=0.13.0", "python-dotenv>=1.0.0", "rich>=13.0.0", "typer>=0.15.0", diff --git a/tests/conftest.py b/tests/conftest.py index 2cea829..ecc37b8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,7 +46,7 @@ def stub_download(mocker: MockerFixture) -> str: which is the shape the hosted runtime really returns for an image β€” must take this fixture or it will reach for the network. Returns the path the stub pretends it wrote, for assertions. """ - artifact = DownloadedArtifact(uri=STUB_ARTIFACT_URI, path=STUB_ARTIFACT_PATH, content_type="image/png", size=3) + artifact = DownloadedArtifact(uri=STUB_ARTIFACT_URI, found_at=["$.url"], path=STUB_ARTIFACT_PATH, content_type="image/png", size=3) result = DownloadArtifactsResult(scope=ArtifactScope.MAIN_STUFF, artifacts=[artifact], saved_paths=[STUB_ARTIFACT_PATH], all_saved=True) fake_client = mocker.AsyncMock() fake_client.download_artifacts.return_value = result diff --git a/tests/unit/test_artifacts.py b/tests/unit/test_artifacts.py index 243c16b..86d33df 100644 --- a/tests/unit/test_artifacts.py +++ b/tests/unit/test_artifacts.py @@ -61,7 +61,9 @@ async def test_a_text_output_stays_offline_and_opens_no_client(self, mocker: Moc client.assert_not_called() async def test_an_output_referencing_a_file_goes_through_the_client(self, mocker: MockerFixture, tmp_path: Path): - artifact = DownloadedArtifact(uri="pipelex-storage://run-1/cat.png", path=str(tmp_path / "cat.png"), content_type="image/png", size=3) + artifact = DownloadedArtifact( + uri="pipelex-storage://run-1/cat.png", found_at=["$.url"], path=str(tmp_path / "cat.png"), content_type="image/png", size=3 + ) fake_client = mocker.AsyncMock() fake_client.download_artifacts.return_value = _verdict(artifact) async_cm = mocker.MagicMock() @@ -84,11 +86,13 @@ def test_says_nothing_when_the_run_produced_no_file(self): assert _render(None) == "" def test_names_each_saved_file(self): - rendered = _render(_verdict(DownloadedArtifact(uri="pipelex-storage://run-1/cat.png", path="/tmp/out/cat.png", size=3))) + rendered = _render(_verdict(DownloadedArtifact(uri="pipelex-storage://run-1/cat.png", found_at=["$.url"], path="/tmp/out/cat.png", size=3))) assert "/tmp/out/cat.png" in rendered def test_names_a_reference_that_did_not_come_down(self): - failed = DownloadedArtifact(uri="pipelex-storage://run-1/cat.png", error=ArtifactItemError(code="forbidden", detail="Not your run.")) + failed = DownloadedArtifact( + uri="pipelex-storage://run-1/cat.png", found_at=["$.url"], error=ArtifactItemError(code="forbidden", detail="Not your run.") + ) rendered = _render(_verdict(failed)) # The reference, the machine code and the sentence a person reads β€” a failed reference is # reported rather than raised, so the message is the only place it surfaces. @@ -97,8 +101,10 @@ def test_names_a_reference_that_did_not_come_down(self): assert "Not your run." in rendered def test_reports_both_arms_of_a_partial_download(self): - saved = DownloadedArtifact(uri="pipelex-storage://run-1/ok.png", path="/tmp/out/ok.png", size=3) - failed = DownloadedArtifact(uri="pipelex-storage://run-1/bad.png", error=ArtifactItemError(code="write_failed", detail="Disk full.")) + saved = DownloadedArtifact(uri="pipelex-storage://run-1/ok.png", found_at=["$[0].url"], path="/tmp/out/ok.png", size=3) + failed = DownloadedArtifact( + uri="pipelex-storage://run-1/bad.png", found_at=["$[1].url"], error=ArtifactItemError(code="write_failed", detail="Disk full.") + ) rendered = _render(_verdict(saved, failed)) assert "/tmp/out/ok.png" in rendered assert "Disk full." in rendered diff --git a/tests/unit/test_attended_cli.py b/tests/unit/test_attended_cli.py index c9aa3a3..36239b9 100644 --- a/tests/unit/test_attended_cli.py +++ b/tests/unit/test_attended_cli.py @@ -1,6 +1,8 @@ from pathlib import Path -from pipelex_sdk.runs import RunResults +from pipelex_sdk.error_models import RunErrorReport, UserAction +from pipelex_sdk.errors import RunFailedError +from pipelex_sdk.runs import RunResults, RunStatus from pytest_mock import MockerFixture from typer.testing import CliRunner @@ -110,3 +112,22 @@ def test_generate_image_sends_the_prompt(self, mocker: MockerFixture, stub_downl assert stub_download in result.output assert attended_mock.await_args is not None assert attended_mock.await_args.kwargs["inputs"] == {"image_prompt": "a cat wearing a hat"} + + def test_a_failed_run_prints_the_reason_the_next_step_and_the_retry_advice(self, mocker: MockerFixture): + report = RunErrorReport( + title="LLM completion", + message="The model refused the request.", + retryable=False, + user_action=UserAction(kind="change_input", detail="Rephrase the prompt, or pick another model."), + ) + error = RunFailedError( + "Run finished with status FAILED: The model refused the request.", run_id="run-1", status=RunStatus.FAILED, error=report + ) + mocker.patch("widget.attended.cli.start_and_wait", side_effect=error) + result = runner.invoke(app, ["attended", "extract-entities", "some text"]) + assert result.exit_code == 1 + output = " ".join(result.output.split()) + assert "Run run-1 failed." in output + assert "Reason: LLM completion β€” The model refused the request." in output + assert "Next step: Rephrase the prompt, or pick another model." in output + assert "Retry: running it again will fail the same way until the cause is fixed." in output diff --git a/tests/unit/test_detached_cli.py b/tests/unit/test_detached_cli.py index 8ee2c9c..c0b3951 100644 --- a/tests/unit/test_detached_cli.py +++ b/tests/unit/test_detached_cli.py @@ -1,5 +1,7 @@ from pathlib import Path +from pipelex_sdk.error_models import RunErrorReport, UserAction +from pipelex_sdk.errors import RunFailedError from pipelex_sdk.runs import RunRead, RunResultCompleted, RunResultFailed, RunResultRunning, RunResults, RunStatus from pytest_mock import MockerFixture from typer.testing import CliRunner @@ -12,6 +14,14 @@ # walks, plus the short-lived signed link beside it, which is never what gets downloaded. IMAGE_CONTENT = {"url": "pipelex-storage://run-1/cat.png", "public_url": "https://cdn.example.com/signed/cat.png"} RUN_ID = "run-abc123" +# A failed run's stored report, and the platform's sentence about the run that carries it. +MODEL_REPORT = RunErrorReport( + title="LLM completion", + message="The model refused the request.", + retryable=True, + user_action=UserAction(kind="change_input", detail="Rephrase the prompt, or pick another model."), +) +REPORTED_DETAIL = "Run finished with status FAILED: The model refused the request." # The lifecycle helpers hand back a whole `RunResults`; these offline tests carry no usage, so the @@ -169,3 +179,60 @@ def test_result_exits_non_zero_when_the_run_failed(self, mocker: MockerFixture): result = runner.invoke(app, ["detached", "result", RUN_ID]) assert result.exit_code == 1 assert "the pipe blew up" in result.output + + def test_result_of_a_failed_run_reads_out_its_stored_report(self, mocker: MockerFixture): + failed = RunResultFailed(pipeline_run_id=RUN_ID, status=RunStatus.FAILED, message=REPORTED_DETAIL, error=MODEL_REPORT) + mocker.patch("widget.detached.cli.fetch_run_result", return_value=failed) + result = runner.invoke(app, ["detached", "result", RUN_ID]) + assert result.exit_code == 1 + _assert_reads_out_the_report(result.output) + + def test_wait_on_a_failed_run_reads_out_its_stored_report(self, mocker: MockerFixture): + error = RunFailedError(REPORTED_DETAIL, run_id=RUN_ID, status=RunStatus.FAILED, error=MODEL_REPORT) + mocker.patch("widget.detached.cli.attend_run", side_effect=error) + result = runner.invoke(app, ["detached", "wait", RUN_ID]) + assert result.exit_code == 1 + _assert_reads_out_the_report(result.output) + + def test_status_of_a_failed_run_reads_out_its_stored_report(self, mocker: MockerFixture): + run = RunRead(pipeline_run_id=RUN_ID, status=RunStatus.FAILED, created_at="2026-07-13T10:00:00Z", error=MODEL_REPORT) + mocker.patch("widget.detached.cli.fetch_run_status", return_value=run) + result = runner.invoke(app, ["detached", "status", RUN_ID]) + assert result.exit_code == 0 + assert "FAILED" in result.stdout + # The report is what the status read answered, so it is the command's output: stdout. + _assert_reads_out_the_report(result.stdout) + + def test_status_of_a_failed_run_without_a_report_says_no_reason_was_recorded(self, mocker: MockerFixture): + run = RunRead(pipeline_run_id=RUN_ID, status=RunStatus.FAILED, created_at="2026-07-13T10:00:00Z") + mocker.patch("widget.detached.cli.fetch_run_status", return_value=run) + result = runner.invoke(app, ["detached", "status", RUN_ID]) + assert result.exit_code == 0 + output = " ".join(result.output.split()) + assert "No reason was recorded for this run." in output + assert "support" in output + + def test_status_of_a_cancelled_run_without_a_report_says_to_start_again(self, mocker: MockerFixture): + run = RunRead(pipeline_run_id=RUN_ID, status=RunStatus.CANCELLED, created_at="2026-07-13T10:00:00Z") + mocker.patch("widget.detached.cli.fetch_run_status", return_value=run) + result = runner.invoke(app, ["detached", "status", RUN_ID]) + assert result.exit_code == 0 + output = " ".join(result.output.split()) + assert "No reason was recorded for this run." in output + assert "start it again" in output.lower() + + def test_status_of_a_run_in_flight_says_nothing_about_a_reason(self, mocker: MockerFixture): + run = RunRead(pipeline_run_id=RUN_ID, status=RunStatus.RUNNING, created_at="2026-07-13T10:00:00Z") + mocker.patch("widget.detached.cli.fetch_run_status", return_value=run) + result = runner.invoke(app, ["detached", "status", RUN_ID]) + assert result.exit_code == 0 + assert "reason" not in result.output.lower() + assert "Hint" not in result.output + + +def _assert_reads_out_the_report(output: str) -> None: + """The report's title and message, its next step and its retry advice, whatever the console wrapped.""" + flattened = " ".join(output.split()) + assert "Reason: LLM completion β€” The model refused the request." in flattened + assert "Next step: Rephrase the prompt, or pick another model." in flattened + assert "Retry: running it again may succeed." in flattened diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index 231e766..1b419f0 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -1,7 +1,10 @@ +import io from collections.abc import Mapping import httpx +import pytest from pipelex_sdk.artifact_models import ArtifactScope, DownloadArtifactsResult +from pipelex_sdk.error_models import RunErrorReport, UserAction from pipelex_sdk.errors import ( ApiUnreachableError, ArtifactAuthenticationError, @@ -16,8 +19,22 @@ UploadAuthenticationError, ) from pipelex_sdk.runs import RunStatus - -from widget.errors import present_error +from rich.console import Console + +from widget.errors import ErrorPresentation, present_error, print_error, report_lines + +# A failed run's stored report as the runner writes it for an inference failure, and the platform's +# sentence about the run with and without one (`Run finished with status : `). +MODEL_REPORT = RunErrorReport( + error_type="LLMCompletionError", + title="LLM completion", + message="The model refused the request.", + error_domain="runtime", + retryable=True, + user_action=UserAction(kind="change_input", detail="Rephrase the prompt, or pick another model."), +) +REPORTED_DETAIL = "Run finished with status FAILED: The model refused the request." +UNREPORTED_DETAIL = "Run finished with status FAILED; no result available" def _http_status_error(status_code: int, *, problem: Mapping[str, object] | None = None) -> httpx.HTTPStatusError: @@ -87,11 +104,38 @@ def test_unreachable_hints_base_url(self): assert presentation.hint is not None assert "PIPELEX_BASE_URL" in presentation.hint - def test_run_failed_names_run_id(self): - presentation = present_error(RunFailedError("run failed", run_id="run-9", status=RunStatus.FAILED)) - assert "run-9" in presentation.message + def test_run_failed_reads_out_the_stored_report(self): + presentation = present_error(RunFailedError(REPORTED_DETAIL, run_id="run-9", status=RunStatus.FAILED, error=MODEL_REPORT)) + assert presentation.message == "Run run-9 failed." + assert presentation.details == ( + "Reason: LLM completion β€” The model refused the request.", + "Next step: Rephrase the prompt, or pick another model.", + "Retry: running it again may succeed.", + ) + # The next step is the advice, so no hint sends the reader to a command that prints the same lines again. + assert presentation.hint is None + + def test_run_failed_without_a_stored_report_still_says_what_happened(self): + presentation = present_error(RunFailedError(UNREPORTED_DETAIL, run_id="run-9", status=RunStatus.FAILED)) + assert presentation.message == "Run run-9 failed, and no reason was recorded for it." + # The platform's own sentence is kept: on a refused stored result it is the only thing that says what happened. + assert presentation.details == (f"The platform said: {UNREPORTED_DETAIL}",) assert presentation.hint is not None - assert "widget detached status run-9" in presentation.hint + assert "support" in presentation.hint + assert "run-9" in presentation.hint + + def test_a_cancelled_run_without_a_report_is_told_to_start_again(self): + presentation = present_error( + RunFailedError("Run finished with status CANCELLED; no result available", run_id="run-9", status=RunStatus.CANCELLED) + ) + assert presentation.message == "Run run-9 was cancelled, and no reason was recorded for it." + assert presentation.hint is not None + assert "start it again" in presentation.hint.lower() + + def test_a_report_carrying_nothing_to_read_out_is_treated_as_no_report(self): + presentation = present_error(RunFailedError(UNREPORTED_DETAIL, run_id="run-9", status=RunStatus.TIMED_OUT, error=RunErrorReport())) + assert presentation.message == "Run run-9 timed out, and no reason was recorded for it." + assert presentation.details == (f"The platform said: {UNREPORTED_DETAIL}",) def test_run_timeout_hints_wait(self): presentation = present_error(RunTimeoutError("too slow", run_id="run-9", timeout_seconds=1200.0)) @@ -135,3 +179,50 @@ def test_artifact_operation_hints_the_download_directory_without_saying_rerun(se assert "download directory" in presentation.hint # The run itself succeeded β€” a hint that sent the reader back to rerun it would cost them. assert "rerun" not in presentation.hint.lower() + + @pytest.mark.parametrize( + ("report", "expected_lines"), + [ + pytest.param(None, (), id="no report"), + pytest.param(RunErrorReport(message="The input 'text' is empty."), ("Reason: The input 'text' is empty.",), id="message alone"), + pytest.param(RunErrorReport(title="LLM completion"), ("Reason: LLM completion",), id="title alone"), + pytest.param(RunErrorReport(error_type="SandboxProvisioningError"), ("Reason: SandboxProvisioningError",), id="class as last resort"), + pytest.param( + RunErrorReport(message="Rate limited.", user_action=UserAction(kind="wait_and_retry")), + ("Reason: Rate limited.", "Next step: Wait a moment, then run it again."), + id="kind speaks without detail", + ), + pytest.param( + RunErrorReport(message="Something broke.", user_action=UserAction(kind="unknown")), + ("Reason: Something broke.",), + id="unknown kind without detail", + ), + pytest.param( + RunErrorReport(message="The model does not exist.", retryable=False), + ("Reason: The model does not exist.", "Retry: running it again will fail the same way until the cause is fixed."), + id="not retryable", + ), + # `None` means the runner does not know, never "no" β€” so nothing is claimed either way. + pytest.param(RunErrorReport(message="Something broke."), ("Reason: Something broke.",), id="retry advice unknown"), + ], + ) + def test_report_lines_read_out_what_the_report_carries(self, report: RunErrorReport | None, expected_lines: tuple[str, ...]): + assert report_lines(report) == expected_lines + + def test_print_error_prints_the_message_the_details_and_the_hint(self): + buffer = io.StringIO() + print_error(Console(file=buffer, width=200), ErrorPresentation(message="Run run-9 failed.", hint="Do this.", details=("Reason: it broke.",))) + rendered = buffer.getvalue() + assert "Error: Run run-9 failed." in rendered + assert "Reason: it broke." in rendered + assert "Hint: Do this." in rendered + + def test_print_error_prints_server_text_verbatim_rather_than_as_markup(self): + # A report's message is the runner's text, provider wording included: a bracketed span in it + # must neither vanish as a style tag nor crash the print as an unmatched closing tag. + buffer = io.StringIO() + presentation = ErrorPresentation(message="Bad value [x] in [/y].", hint=None, details=("Reason: list [1, 2] [bold]",)) + print_error(Console(file=buffer, width=200), presentation) + rendered = buffer.getvalue() + assert "Bad value [x] in [/y]." in rendered + assert "Reason: list [1, 2] [bold]" in rendered diff --git a/uv.lock b/uv.lock index 3e5da48..a3321f4 100644 --- a/uv.lock +++ b/uv.lock @@ -192,7 +192,7 @@ wheels = [ [[package]] name = "mthds" -version = "0.14.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -201,9 +201,9 @@ dependencies = [ { name = "tomlkit" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/59/4ca9539571a2030f9427aeddb01bc334910953ebdfa4ab7db2051a6b8ae7/mthds-0.14.0.tar.gz", hash = "sha256:d2b4a9cd064004dfd5b802bb71c9894601fba9e598426f96e4bede16d52c3900", size = 221186, upload-time = "2026-09-06T21:44:54.585Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/03/4aca733536f741f741d0cba3ae3d23dfd56f691c53a0d7e3e7baea371585/mthds-0.16.0.tar.gz", hash = "sha256:cc8f54bc76c9ed13273e7cd2e1c66767e5478fc3640a26481bf099451d56aa29", size = 259394, upload-time = "2026-09-23T12:13:31.752Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/0b/32908eeed33396c5aafd4bcb2a1adc38d0ab8d85c2fde3af0bf4c2f73745/mthds-0.14.0-py3-none-any.whl", hash = "sha256:59a706205b6e6df47345caac01038588d21ab9e9c246afc765ee50b5f27f05a8", size = 87192, upload-time = "2026-09-06T21:44:53.022Z" }, + { url = "https://files.pythonhosted.org/packages/d0/00/a80f90f0ed7ddff9886f925fbb8dd0061ec0b0e1af49901971664f1a3e12/mthds-0.16.0-py3-none-any.whl", hash = "sha256:67468669451278e8c17409c5fed60eafab8453fd5cd92f333d788b21f7500ebe", size = 103813, upload-time = "2026-09-23T12:13:30.288Z" }, ] [[package]] @@ -283,7 +283,7 @@ wheels = [ [[package]] name = "pipelex-sdk" -version = "0.10.2" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -291,9 +291,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/75/05a0f85f93c5cf67c18af19a5f994a3c3f948dae18092cf096bb2dc2d7bb/pipelex_sdk-0.10.2.tar.gz", hash = "sha256:96392a3361066d400f6401749e7783092e928e5242f41f07db38f4fc3c84c1fa", size = 355371, upload-time = "2026-09-22T09:15:47.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/47/0baf77c28bd66fd76d7dfe41c56a2040a99a2f1fbdf12c81fd7538cba423/pipelex_sdk-0.13.0.tar.gz", hash = "sha256:2ad0a9f619742c99b23df7dc5a39fb1feb33ef633d249674e7c2958b03150107", size = 391622, upload-time = "2026-09-27T10:38:31.576Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/a7/ad571bb94d94bf7b7b5a953962d2bc6c1eb4adcdbb96f46706e97fcbd6b0/pipelex_sdk-0.10.2-py3-none-any.whl", hash = "sha256:d8e009679d767a3a08380a4b41d773ab7772a4923a77f6a96009a00994f3135a", size = 117470, upload-time = "2026-09-22T09:15:45.499Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/1929a77768c58a3e9917a7b811ed8cc2999abfdac3345f2252724baab0bd/pipelex_sdk-0.13.0-py3-none-any.whl", hash = "sha256:75b1fe64c4b4d8db56d602300da4da70c97d98b116f09fd17753232c7bc4498d", size = 132322, upload-time = "2026-09-27T10:38:30.21Z" }, ] [[package]] @@ -655,9 +655,9 @@ dev = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, - { name = "mthds", specifier = ">=0.14.0" }, + { name = "mthds", specifier = ">=0.16.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.19.1" }, - { name = "pipelex-sdk", specifier = ">=0.10.2" }, + { name = "pipelex-sdk", specifier = ">=0.13.0" }, { name = "pipelex-tools", marker = "extra == 'dev'", specifier = ">=0.7.2" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.411" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, diff --git a/widget/attended/cli.py b/widget/attended/cli.py index 8b65648..84ea3db 100644 --- a/widget/attended/cli.py +++ b/widget/attended/cli.py @@ -31,7 +31,7 @@ # add-method:imports β€” `make add-method` inserts a scaffolded method's generated-model import # into the block below, in sorted position. Keep the token; the prose after it is free. from widget.artifacts import DEFAULT_DOWNLOAD_DIR, download_produced_files, print_downloads -from widget.errors import present_error +from widget.errors import present_error, print_error from widget.generated.extract_entities.models import ExtractedEntities from widget.generated.generate_image.models import Image from widget.generated.summarize_pdf.models import DocumentSummary @@ -174,10 +174,7 @@ def _run(coro: Coroutine[Any, Any, ResultT]) -> ResultT: try: return asyncio.run(coro) except (PipelineRequestError, httpx.HTTPStatusError) as exc: - presentation = present_error(exc) - progress_console.print(f"[red]Error:[/red] {presentation.message}") - if presentation.hint: - progress_console.print(f"\n[yellow]Hint:[/yellow] {presentation.hint}") + print_error(progress_console, present_error(exc)) raise typer.Exit(1) from exc except KeyboardInterrupt as exc: # The resume hint was already printed by `start_and_wait`; the run keeps executing server-side. diff --git a/widget/blocking/cli.py b/widget/blocking/cli.py index 8a6cec5..3ff99f0 100644 --- a/widget/blocking/cli.py +++ b/widget/blocking/cli.py @@ -25,7 +25,7 @@ # add-method:imports β€” `make add-method` inserts a scaffolded method's generated-model import # into the block below, in sorted position. Keep the token; the prose after it is free. from widget.artifacts import DEFAULT_DOWNLOAD_DIR, download_produced_files, print_downloads -from widget.errors import present_error +from widget.errors import present_error, print_error from widget.generated.extract_entities.models import ExtractedEntities from widget.generated.generate_image.models import Image from widget.generated.summarize_pdf.models import DocumentSummary @@ -154,10 +154,7 @@ def _run(coro: Coroutine[Any, Any, ResultT]) -> ResultT: try: return asyncio.run(coro) except (PipelineRequestError, httpx.HTTPStatusError) as exc: - presentation = present_error(exc) - progress_console.print(f"[red]Error:[/red] {presentation.message}") - if presentation.hint: - progress_console.print(f"\n[yellow]Hint:[/yellow] {presentation.hint}") + print_error(progress_console, present_error(exc)) raise typer.Exit(1) from exc except KeyboardInterrupt as exc: raise typer.Exit(130) from exc diff --git a/widget/detached/cli.py b/widget/detached/cli.py index a3a96db..31c2945 100644 --- a/widget/detached/cli.py +++ b/widget/detached/cli.py @@ -6,7 +6,7 @@ from another terminal, another machine, another day: - `widget detached wait ` β€” poll it to completion and print its result. -- `widget detached status ` β€” where is it right now, without waiting. +- `widget detached status ` β€” where is it right now, without waiting, and why it failed if it did. - `widget detached result ` β€” its result if it is done, without waiting. Same durable run as `widget attended`; the only difference is who waits. @@ -37,11 +37,12 @@ WaitForResultOptions, ) from rich.console import Console +from rich.markup import escape # add-method:imports β€” `make add-method` inserts a scaffolded method's generated-model import # into the block below, in sorted position. Keep the token; the prose after it is free. from widget.artifacts import DEFAULT_DOWNLOAD_DIR, download_produced_files, print_downloads -from widget.errors import present_error +from widget.errors import present_error, present_failed_run, print_error from widget.inputs import SAMPLE_ENTITIES_TEXT, SAMPLE_IMAGE_PROMPT, SAMPLE_INVOICE, read_text_input, upload_document_input from widget.usage import print_cost_report @@ -177,12 +178,20 @@ def wait( @app.command(name="status") def status(run_id: Annotated[str, typer.Argument(help="The pipeline run id printed when the run started.")]) -> None: - """Show a run's coarse status without waiting.""" + """Show a run's coarse status without waiting β€” and, for a run that ended without a result, why.""" run = _run(fetch_run_status(run_id)) pipe_part = f" (pipe: {run.pipe_code})" if run.pipe_code else "" - output_console.print(f"{run.pipeline_run_id}: [bold]{run.status}[/bold]{pipe_part}") + output_console.print(f"{run.pipeline_run_id}: [bold]{run.status}[/bold]{escape(pipe_part)}") if run.degraded: output_console.print("[yellow]Status is degraded β€” last-known value, the status backend was unreachable; retry shortly.[/yellow]") + if run.status.is_terminal and not run.status.is_success: + # The status read carries the run's stored error report, read out as a failed `wait` reads it. + # The reason is part of the answer (stdout); the hint, for a run that recorded none, is chatter (stderr). + failure = present_failed_run(run_id=run.pipeline_run_id, status=run.status, report=run.error, platform_message=None) + for line in failure.details or ("No reason was recorded for this run.",): + output_console.print(escape(line)) + if failure.hint: + progress_console.print(f"[yellow]Hint:[/yellow] {escape(failure.hint)}") @app.command(name="result") @@ -201,7 +210,11 @@ def result( case RunResultCompleted(): _print_results(state.result) case RunResultFailed(): - progress_console.print(f"[red]Run {state.pipeline_run_id} ended with status {state.status}: {state.message}[/red]") + # The failed arm carries what `RunFailedError` carries, so it reads exactly as a failed `wait` does. + print_error( + progress_console, + present_failed_run(run_id=state.pipeline_run_id, status=state.status, report=state.error, platform_message=state.message), + ) raise typer.Exit(1) @@ -237,10 +250,7 @@ def _run(coro: Coroutine[Any, Any, ResultT]) -> ResultT: try: return asyncio.run(coro) except (PipelineRequestError, httpx.HTTPStatusError) as exc: - presentation = present_error(exc) - progress_console.print(f"[red]Error:[/red] {presentation.message}") - if presentation.hint: - progress_console.print(f"\n[yellow]Hint:[/yellow] {presentation.hint}") + print_error(progress_console, present_error(exc)) raise typer.Exit(1) from exc except KeyboardInterrupt as exc: # The resume hint was already printed by `attend_run`; the run keeps executing server-side. diff --git a/widget/errors.py b/widget/errors.py index 11a99ef..8937ba2 100644 --- a/widget/errors.py +++ b/widget/errors.py @@ -3,9 +3,16 @@ This module defines no exception classes β€” it is a presentation mapper. Each mode package's `_run()` wrapper (`widget/blocking/cli.py`, `widget/attended/cli.py`, `widget/detached/cli.py`) catches `PipelineRequestError` (the base of every error the -`pipelex-sdk` client raises) exactly once, turns it into a `(message, hint)` pair here, -and exits non-zero. Unexpected exceptions are deliberately NOT caught anywhere: they -crash loudly with a full traceback. +`pipelex-sdk` client raises) exactly once, turns it into an `ErrorPresentation` here β€” +a message, the lines that explain it, and a hint β€” prints it with `print_error`, and +exits non-zero. Unexpected exceptions are deliberately NOT caught anywhere: they crash +loudly with a full traceback. + +A run that ended without a result is presented from the error report the runner stored +when it failed, which the SDK hands back typed as `RunErrorReport`: `report_lines` reads +out its reason, its next step and its retry advice, and the detached `status` and +`result` commands print the same lines, so a failed run reads the same wherever you +meet it. Error presentation is orthogonal to execution mode, so it is shared β€” but the hints name the mode *groups*, since the fix for a timed-out blocking run is to rerun it under @@ -17,6 +24,7 @@ import httpx from mthds.protocol.exceptions import PipelineRequestError +from pipelex_sdk.error_models import RunErrorReport, UserAction from pipelex_sdk.errors import ( ApiResponseError, ApiUnreachableError, @@ -32,13 +40,30 @@ UnsupportedUploadCapabilityError, UploadAuthenticationError, ) +from pipelex_sdk.runs import RunStatus +from rich.console import Console +from rich.markup import escape + +#: A sentence for each kind of advice the runner names, for a report whose `user_action` carries no +#: `detail`. The kinds are an open set on the wire, so a kind missing here prints no next step rather +#: than a guess β€” `unknown` among them. +_NEXT_STEP_BY_KIND: dict[str, str] = { + "wait_and_retry": "Wait a moment, then run it again.", + "check_billing": "Check your plan and credits.", + "check_credentials": "Check the credentials the failing call uses.", + "change_input": "Change the inputs, then run it again.", + "change_model": "Change the model the failing pipe uses.", + "contact_support": "Contact support with the run id.", +} class ErrorPresentation(NamedTuple): - """What the CLI shows for a failed command: the error and what to do about it.""" + """What the CLI shows for a failed command: the error, the lines that explain it, and what to do about it.""" message: str hint: str | None + #: Lines printed under the message β€” for a failed run, its stored report read out field by field. + details: tuple[str, ...] = () def present_error(exc: PipelineRequestError | httpx.HTTPStatusError) -> ErrorPresentation: @@ -77,10 +102,7 @@ def present_error(exc: PipelineRequestError | httpx.HTTPStatusError) -> ErrorPre hint="Check PIPELEX_BASE_URL β€” and if you self-host, make sure your runner is up.", ) if isinstance(exc, RunFailedError): - return ErrorPresentation( - message=f"Run {exc.run_id} ended with status {exc.status}: {exc}", - hint=f"Inspect it with `widget detached status {exc.run_id}`.", - ) + return present_failed_run(run_id=exc.run_id, status=exc.status, report=exc.error, platform_message=str(exc)) if isinstance(exc, RunTimeoutError): return ErrorPresentation( message=f"Gave up waiting for run {exc.run_id} after {exc.timeout_seconds:.0f}s β€” the run is still executing server-side.", @@ -95,6 +117,125 @@ def present_error(exc: PipelineRequestError | httpx.HTTPStatusError) -> ErrorPre return ErrorPresentation(message=str(exc), hint=None) +def present_failed_run(*, run_id: str, status: RunStatus, report: RunErrorReport | None, platform_message: str | None) -> ErrorPresentation: + """Present a run that ended without a result, from the report the runner stored when it failed. + + The SDK hands the same three things back wherever such a run surfaces β€” `RunFailedError` out of + `wait_for_result`, `start_and_wait` or an artifact download, the failed arm of `get_run_result`, and + the status read: the terminal `status`, the stored `report` (`None` when the run ended with none, + as a cancelled run does), and, on the first two, the platform's one sentence about the run. + + With a report, the lines under the message read it out and there is no hint: the report's next + step is the advice, and a hint pointing at `widget detached status` would only print the same + lines again. Without one, the platform's sentence is kept β€” on a stored result the platform + refuses to serve it is the only thing that says what happened β€” and the hint says what is left. + """ + how_it_ended = _how_the_run_ended(status) + lines = report_lines(report) + if lines: + return ErrorPresentation(message=f"Run {run_id} {how_it_ended}.", hint=None, details=lines) + platform_lines = (f"The platform said: {platform_message}",) if platform_message else () + return ErrorPresentation( + message=f"Run {run_id} {how_it_ended}, and no reason was recorded for it.", + hint=_hint_without_a_reason(run_id=run_id, status=status), + details=platform_lines, + ) + + +def report_lines(report: RunErrorReport | None) -> tuple[str, ...]: + """A failed run's stored report as the lines a person reads: the reason, the next step, the retry advice. + + The reason is the report's `title` and `message` (its `error_type`, the runner's exception class, + when it carries neither); the next step is its `user_action`; the retry advice is its `retryable`, + left unsaid when that is `None`, which means unknown rather than no. Empty when there is no report + or it carries none of these, so the caller can tell a report worth reading from its absence. + + The report is the runner's verbose one, so `message` can hold a provider's raw text. This is a + developer's tool, so it is printed as it came; an application in front of end users decides what + of it they see. + """ + if report is None: + return () + lines: list[str] = [] + reason = _reason(report) + if reason: + lines.append(f"Reason: {reason}") + next_step = _next_step(report.user_action) + if next_step: + lines.append(f"Next step: {next_step}") + match report.retryable: + case True: + lines.append("Retry: running it again may succeed.") + case False: + lines.append("Retry: running it again will fail the same way until the cause is fixed.") + case None: + pass + return tuple(lines) + + +def print_error(console: Console, presentation: ErrorPresentation) -> None: + """Print a presentation: the message, its detail lines, then the hint. + + Every piece is escaped before it reaches Rich, because the message and the details carry the + server's text: a bracketed span in a provider's message would otherwise be read as a style tag, + swallowed, or crash the print as an unmatched closing tag. + """ + console.print(f"[red]Error:[/red] {escape(presentation.message)}") + for line in presentation.details: + console.print(f" {escape(line)}") + if presentation.hint: + console.print(f"\n[yellow]Hint:[/yellow] {escape(presentation.hint)}") + + +def _how_the_run_ended(status: RunStatus) -> str: + match status: + case RunStatus.FAILED: + return "failed" + case RunStatus.CANCELLED: + return "was cancelled" + case RunStatus.TERMINATED: + return "was terminated" + case RunStatus.TIMED_OUT: + return "timed out" + case RunStatus.PENDING | RunStatus.STARTED | RunStatus.RUNNING | RunStatus.COMPLETED: + # Not an ending without a result, so it is named as the status rather than worded as one. + return f"ended with status {status}" + + +def _hint_without_a_reason(*, run_id: str, status: RunStatus) -> str: + match status: + case RunStatus.FAILED: + return f"Nothing more is recorded about this failure β€” contact support with the run id {run_id}." + case RunStatus.CANCELLED | RunStatus.TERMINATED | RunStatus.TIMED_OUT: + return "The run stopped before it produced a result β€” start it again to get one." + case RunStatus.PENDING | RunStatus.STARTED | RunStatus.RUNNING | RunStatus.COMPLETED: + return f"Check it again with `widget detached status {run_id}`." + + +def _reason(report: RunErrorReport) -> str | None: + """The report's title and message, whichever it carries, or its exception class as a last resort.""" + title = _text(report.title) + message = _text(report.message) + if title and message: + return f"{title} β€” {message}" + return title or message or _text(report.error_type) + + +def _next_step(user_action: UserAction | None) -> str | None: + """The advice's own words, or a sentence for its kind when it gives none.""" + if user_action is None: + return None + return _text(user_action.detail) or _NEXT_STEP_BY_KIND.get(user_action.kind or "") + + +def _text(value: str | None) -> str | None: + """A report field as printable text: `None` for a missing or blank one.""" + if value is None: + return None + stripped = value.strip() + return stripped or None + + def _present_upload_error(exc: InputPreparationError) -> ErrorPresentation: """Present a file-upload (input-preparation) failure. The SDK already gives each a clear message; here we add the actionable hint per semantic category.""" From 990d09ff5dd03d12e00daca265f0e13757ccb4fa Mon Sep 17 00:00:00 2001 From: Louis Choquel Date: Sun, 27 Sep 2026 12:50:38 +0200 Subject: [PATCH 3/3] Release v0.1.1: a failed run says why, and the demos name their pipe by its qualified reference Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a235dce..f21d514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [v0.1.1] - 2026-09-27 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 79b5ebe..bd43d6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "widget" -version = "0.1.0" +version = "0.1.1" description = "Replace this with your project description" # authors = [{ name = "Your Name", email = "your.email@example.com" }] license = "MIT" diff --git a/uv.lock b/uv.lock index a3321f4..b853d31 100644 --- a/uv.lock +++ b/uv.lock @@ -629,7 +629,7 @@ wheels = [ [[package]] name = "widget" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "httpx" },