Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/skill/references/docs/guides/notebook-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions docs/skill/references/domains/editor-collaboration.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,14 @@ sca edit <workspace> --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
Expand Down
3 changes: 0 additions & 3 deletions editor-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const [kernelAlive, setKernelAlive] = useState(false);
const [marimoUrl, setMarimoUrl] = useState<string | null>(null);
const [status, setStatus] = useState("Connecting…");

const [params, setParams] = useState<ParamMeta[]>([]);
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -335,7 +333,6 @@ export function App() {
kind={kind}
kernelAlive={kernelAlive}
status={status}
marimoUrl={marimoUrl}
themePreference={theme.preference}
onTheme={theme.cycle}
running={running}
Expand Down
11 changes: 1 addition & 10 deletions editor-web/src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -15,7 +15,6 @@ export function Header({
onRunAll,
running,
onStop,
marimoUrl,
themePreference,
onTheme,
}: {
Expand All @@ -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;
}) {
Expand Down Expand Up @@ -63,13 +61,6 @@ export function Header({
<Play /> run all
</Button>
)}
{marimoUrl && (
<Button size="sm" variant="ghost" asChild>
<a href={marimoUrl} target="_blank" rel="noreferrer" title="Open the raw marimo editor">
<ExternalLink /> marimo
</a>
</Button>
)}
<Button
size="sm"
variant="ghost"
Expand Down
31 changes: 31 additions & 0 deletions src/simplecadapi/editor/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 ----------------------------------------
Expand Down Expand Up @@ -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()
Expand Down
125 changes: 125 additions & 0 deletions test/test_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Loading