From 678cc0f376ffba10895e1607e35260a0599796a0 Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Thu, 15 Oct 2026 02:06:30 +0800 Subject: [PATCH 1/3] fix(editor): a failed publish is told on every run someone asked for, and to a late page The page's veil lifts on a preview or an error. A publish error was deduped per identical message with no reset when a new run began, so a repeat of the same failure (a rerun after the first report) stayed silent: no preview, no error, and the page waited out its three-minute veil. The dedup is kept for the parameter burst; discrete runs (page commands, agent cell commands, a file change, a module reload, a param commit) reset it. A page that connects after the failure is told the last error at the end of the handshake. Co-Authored-By: Claude Sonnet 5.5 --- src/simplecadapi/editor/server.py | 31 ++++++++ test/test_editor.py | 125 ++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/src/simplecadapi/editor/server.py b/src/simplecadapi/editor/server.py index 3dfa5ca..02e4650 100644 --- a/src/simplecadapi/editor/server.py +++ b/src/simplecadapi/editor/server.py @@ -642,6 +642,7 @@ def cell_command(self, op: str, body: dict) -> dict: kernel = self.kernel if kernel is None: return {"error": "the editor has no kernel yet; wait for it to start"} + self._begin_run() self.push_cells() touched: list[str] = [] try: @@ -691,6 +692,7 @@ def cell_command(self, op: str, body: dict) -> dict: return report def _rerun_changed(self, kernel: MarimoKernel, changed: list[str]) -> None: + self._begin_run() kernel.run_until_settled(changed) self.schedule_settle() @@ -758,6 +760,7 @@ def _propagate(self) -> None: if not uses: continue kernel.run_until_settled(uses) + self._begin_run() self.schedule_settle() except Exception as exc: with self._module_lock: @@ -881,6 +884,17 @@ def hello(self) -> dict: "marimo_url": self.kernel.marimo_url if self.kernel else None, } + def settled_error(self) -> dict | None: + """The publish error of the last settle, for a page that connects late. + + ``status`` envelopes are momentary and not replayed, but a page that + refreshes after a failed publish must still learn why no model is + coming — its veil only lifts on a ``preview`` or an error. + """ + if not self._last_publish_error: + return None + return {"type": "status", "error": self._last_publish_error[-300:]} + # -- settling: the cells ran; publish what they made ----------------------- def schedule_settle(self) -> None: @@ -931,6 +945,18 @@ def settle(self) -> None: self._last_publish_error = message self.hub.broadcast({"type": "status", "error": message[-300:]}) + def _begin_run(self) -> None: + """Cells are about to run because someone asked for it: report afresh. + + The dedup above is for the parameter burst, where a product-less + notebook would repeat the same line on every slider tick. A discrete + run (a page command, an agent edit, a file change) is a new question: + its failure must be told even if it repeats the last error word for + word, or the page waits out its whole veil for an answer that never + comes. + """ + self._last_publish_error = "" + @property def preview_url(self) -> str: return f"http://127.0.0.1:{self.port}/api/runtime/preview" @@ -1111,6 +1137,7 @@ def save() -> None: return {"type": "error", "message": f"unknown command: {kind}"} def run() -> None: + self._begin_run() result = action() if result.get("success") is False: self.hub.broadcast( @@ -1265,6 +1292,7 @@ def _commit(self, kernel: MarimoKernel, names: list[str], *, revert: bool) -> No write_saved_params(config, saved) if revert: kernel.run_until_settled([self.specs[name].cell for name in names if name in self.specs]) + self._begin_run() self.schedule_settle() # -- selection and the agent snapshot ---------------------------------------- @@ -1801,6 +1829,9 @@ async def ws_endpoint(websocket) -> None: await websocket.send_json(state.hello()) for envelope in state.replay(): await websocket.send_json(envelope) + settled = state.settled_error() + if settled is not None: + await websocket.send_json(settled) try: while True: message = await websocket.receive_json() diff --git a/test/test_editor.py b/test/test_editor.py index e14be88..f704cfa 100644 --- a/test/test_editor.py +++ b/test/test_editor.py @@ -976,3 +976,128 @@ def test_an_edit_of_the_file_is_taken_in_and_not_written_over(self) -> None: finally: if state.kernel is not None: state.kernel.stop() + + +class PublishErrorReportingTest(unittest.TestCase): + """A page's veil lifts on a preview or an error: a failed publish is told + once per identical state, told again on every run someone asked for, and + told to a page that connects after it happened.""" + + ERROR = "ProductNotFoundError: no top-level Part or Assembly has the notebook id" + + def setUp(self) -> None: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name).resolve() + + def state(self) -> tuple[WorkspaceState, list[dict]]: + path = create_notebook(self.root, "bracket", "part") + state = WorkspaceState(self.root, path) + sent: list[dict] = [] + state.hub.broadcast = sent.append # type: ignore[method-assign] + state.push_cells = lambda: None # type: ignore[method-assign] + state._signature = lambda notebook: None # type: ignore[method-assign] + self.fail_kernel(state) + return state, sent + + def fail_kernel(self, state: WorkspaceState) -> None: + state_error = self.ERROR + state.kernel = type( # type: ignore[assignment] + "K", + (), + { + "publish_preview": lambda self, url, step="": {"ok": False, "error": state_error}, + "setup_errors": [], + }, + )() + + @staticmethod + def told(sent: list[dict]) -> int: + return len([message for message in sent if message.get("type") == "status" and message.get("error")]) + + def test_a_failure_is_told_once_and_an_identical_repeat_is_not(self) -> None: + state, sent = self.state() + state.settle() + state.settle() + self.assertEqual(self.told(sent), 1) + + def test_a_rerun_asked_for_on_the_page_reports_afresh(self) -> None: + import threading + + state, sent = self.state() + state_error = self.ERROR + state.kernel = type( # type: ignore[assignment] + "K", + (), + { + "run_cells": lambda self, ids: {}, + "wait_idle": lambda self: True, + "publish_preview": lambda self, url, step="": {"ok": False, "error": state_error}, + "setup_errors": [], + }, + )() + state.cells = [{"id": "c1", "code": "x = 1"}] + settled = threading.Event() + state.schedule_settle = lambda: settled.set() # type: ignore[method-assign] + state.settle() + state.settle() + self.assertEqual(self.told(sent), 1) + state.dispatch_command({"type": "rerun.request"}) + self.assertTrue(settled.wait(5)) + state.settle() + self.assertEqual(self.told(sent), 2) + + def test_an_agent_cell_run_reports_afresh(self) -> None: + state, sent = self.state() + state_error = self.ERROR + state.kernel = type( # type: ignore[assignment] + "K", + (), + { + "run_until_settled": lambda self, ids: {}, + "wait_idle": lambda self: True, + "publish_preview": lambda self, url, step="": {"ok": False, "error": state_error}, + "setup_errors": [], + }, + )() + state.cells = [{"id": "c1", "code": "x = 1"}] + state.schedule_settle = lambda: None # type: ignore[method-assign] + state.settle() + state.settle() + state.cell_command("run", {"cells": ["c1"]}) + state.settle() + self.assertEqual(self.told(sent), 2) + + def test_a_page_that_connects_late_is_told_the_last_error_until_it_clears(self) -> None: + state, _ = self.state() + self.assertIsNone(state.settled_error()) + state.settle() + self.assertEqual(state.settled_error(), {"type": "status", "error": self.ERROR}) + state.kernel = type( # type: ignore[assignment] + "K", + (), + {"publish_preview": lambda self, url, step="": {"ok": True}, "setup_errors": []}, + )() + state.settle() + self.assertIsNone(state.settled_error()) + + def test_the_ws_handshake_ends_with_the_last_error(self) -> None: + from starlette.testclient import TestClient + + from simplecadapi.editor.server import create_app + + state, _ = self.state() + state.settle() + state.kernel = type( # type: ignore[assignment] + "K", + (), + { + "publish_preview": lambda self, url, step="": {"ok": False, "error": self.ERROR}, + "setup_errors": [], + "alive": False, + "marimo_url": None, + }, + )() + with TestClient(create_app(state)) as client, client.websocket_connect("/ws") as ws: + self.assertEqual(ws.receive_json()["type"], "hello") + self.assertEqual(ws.receive_json(), {"type": "status", "error": self.ERROR}) From f33a84847e36631ef6f9065321226bc43aa433cd Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Thu, 15 Oct 2026 02:06:31 +0800 Subject: [PATCH 2/3] docs(skill): which notebooks the workspace scan lists Subfolders of any depth are listed; names decide what is skipped (dot/underscore-prefixed, node_modules, venv, site-packages, build, dist). Co-Authored-By: Claude Sonnet 5.5 --- docs/skill/references/docs/guides/notebook-runtime.md | 6 +++++- docs/skill/references/domains/editor-collaboration.md | 11 ++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/skill/references/docs/guides/notebook-runtime.md b/docs/skill/references/docs/guides/notebook-runtime.md index 44f22d2..96d35c9 100644 --- a/docs/skill/references/docs/guides/notebook-runtime.md +++ b/docs/skill/references/docs/guides/notebook-runtime.md @@ -167,7 +167,11 @@ sca ls [DIR] # the notebooks under DIR: id, part or assembly, th ``` A **workspace** is any directory tree: its models are the notebooks whose -header has `[tool.simplecadapi]`, wherever they are. There is no manifest. +header has `[tool.simplecadapi]`, at any depth. There is no manifest. Skipped +everywhere: names starting with `.` or `_`, and `node_modules`, `venv`, +`site-packages`, `build`, `dist` — a notebook in such a place is not listed +by `sca ls` or the editor's workspace tab, so keep notebooks in normally +named paths. `sca edit [WORKSPACE]` (needs `pip install "simplecadapi[editor]"`) opens a code-first editor on a workspace. The notebooks run in a marimo kernel with diff --git a/docs/skill/references/domains/editor-collaboration.md b/docs/skill/references/domains/editor-collaboration.md index 3ad24e1..ab65bcd 100644 --- a/docs/skill/references/domains/editor-collaboration.md +++ b/docs/skill/references/domains/editor-collaboration.md @@ -35,9 +35,14 @@ sca edit --no-browser --port 7180 - Needs the `editor` extra (`pip install "simplecadapi[editor]"`); without it the command says so and stops. -- The workspace is any directory tree. Models are the notebooks whose header has - `[tool.simplecadapi]`; assemblies list the parts they `use(...)`. Open the - assembly to see the product, a part to edit that part. +- The workspace is any directory tree, scanned to any depth. Models are the + notebooks whose header has `[tool.simplecadapi]`; assemblies list the parts + they `use(...)`. Open the assembly to see the product, a part to edit that + part. Working in a subfolder is fine; what hides a file from the workspace + tab is its name: directories and files starting with `.` or `_`, and + anything under `node_modules`, `venv`, `site-packages`, `build` or `dist`, + are not listed and are never a candidate at boot. Keep notebooks and the + modules they import in normally named paths. - The URL is printed (default `http://127.0.0.1:7180`). Give it to the person. - To try it: `sca example u_link` copies a motor-mount workspace (two parts and the assembly that fixes them together) to `./u_link_motor_mount`, then From fb7218b145f30c9ba683b8aa05a5be6fcf1a3d69 Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Thu, 15 Oct 2026 02:06:31 +0800 Subject: [PATCH 3/3] fix(editor): remove the header's jump-to-marimo button Co-Authored-By: Claude Sonnet 5.5 --- editor-web/src/App.tsx | 3 --- editor-web/src/components/Header.tsx | 11 +---------- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/editor-web/src/App.tsx b/editor-web/src/App.tsx index b92747a..9425eae 100644 --- a/editor-web/src/App.tsx +++ b/editor-web/src/App.tsx @@ -48,7 +48,6 @@ export function App() { // kernel is up, and a late answer of the workspace list cannot undo it. const [currentPath, setCurrentPath] = useState(null); const [kernelAlive, setKernelAlive] = useState(false); - const [marimoUrl, setMarimoUrl] = useState(null); const [status, setStatus] = useState("Connecting…"); const [params, setParams] = useState([]); @@ -126,7 +125,6 @@ export function App() { const stage = envelope["notebook"] ? (envelope["kernel"] ? "building the model" : "starting the kernel") : null; setLoading(stage); setStatus(stage ? `${stage[0].toUpperCase()}${stage.slice(1)}…` : "No notebook yet: create one in the workspace tab"); - setMarimoUrl((envelope["marimo_url"] as string) ?? null); refreshWorkspace(); }); @@ -335,7 +333,6 @@ export function App() { kind={kind} kernelAlive={kernelAlive} status={status} - marimoUrl={marimoUrl} themePreference={theme.preference} onTheme={theme.cycle} running={running} diff --git a/editor-web/src/components/Header.tsx b/editor-web/src/components/Header.tsx index 37a3936..4ca168c 100644 --- a/editor-web/src/components/Header.tsx +++ b/editor-web/src/components/Header.tsx @@ -1,4 +1,4 @@ -import { ExternalLink, Monitor, Moon, Play, Square, Sun } from "lucide-react"; +import { Monitor, Moon, Play, Square, Sun } from "lucide-react"; import type { ThemePreference } from "@/lib/theme"; @@ -15,7 +15,6 @@ export function Header({ onRunAll, running, onStop, - marimoUrl, themePreference, onTheme, }: { @@ -27,7 +26,6 @@ export function Header({ /** The kernel is running cells: the button stops them instead. */ running: boolean; onStop: () => void; - marimoUrl: string | null; themePreference: ThemePreference; onTheme: () => void; }) { @@ -63,13 +61,6 @@ export function Header({ run all )} - {marimoUrl && ( - - )}