From 3d4e789da6a7c0ce8acff81edf06626bc26cd0e4 Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Wed, 14 Oct 2026 15:30:40 +0800 Subject: [PATCH 1/3] fix(editor): take in edits made to the notebook file; sca cell / sca new The editor held the open notebook in memory and rewrote the file on each edit, so an agent's saved change was overwritten and never noticed. The file is now watched and applied cell by cell (agent's version wins a same-cell conflict), the workspace list follows new files, and sca cell/new edit through the running editor. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 18 + docs/landing.md | 3 + docs/skill/SKILL.md | 6 +- .../domains/editor-collaboration.md | 48 ++- editor-web/src/App.tsx | 13 + editor-web/src/components/CellEditor.tsx | 56 ++- src/simplecadapi/editor/cli.py | 237 ++++++++++++ src/simplecadapi/editor/kernel.py | 44 +++ src/simplecadapi/editor/server.py | 339 ++++++++++++++++++ test/test_editor.py | 95 +++++ 10 files changed, 851 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 989b14f..f16dbf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ user-facing notes live in [`docs/updates/`](docs/updates/) (English and ## Unreleased +### Added + +- Editor: `sca cell ls|get|put|insert|rm|run` edits the open notebook's cells through the + running editor (found by `/.sca-editor/server.json`): the page shows the change live, + with no reload, the cell runs, and the command prints its status, errors and output. `sca new + NAME [--kind part|assembly]` writes a notebook the editor then lists, leaving the person's view + alone. The routes behind them are `GET /api/cells` and `POST /api/cells/{edit,insert,delete,run}`. +- Editor: a change to the open notebook's file made by someone else (an agent) is noticed + within a second and applied cell by cell (edited cells are updated and run again, added or + removed cells appear or go), without a reload; a changed setup or `@app.function` cell restarts + the kernel. Notebooks added to or removed from the workspace appear in the list by themselves. + +### Fixed + +- Editor: an edit made in the editor no longer writes over what an agent saved to the same + notebook file. If a cell was edited in both places, the file's version is kept and the + editor's unsaved text goes to the log. + ## 2.2.2 — 2026-10-14 ### Added diff --git a/docs/landing.md b/docs/landing.md index dabd451..258d0d3 100644 --- a/docs/landing.md +++ b/docs/landing.md @@ -195,6 +195,9 @@ each instruction to one action and wait for them. these edges by 1 mm"). You read the selection from the file (`selection` cell) or with `sca snapshot`, and you can look at `.sca-editor/viewport.png` to see what they see. Then you edit the notebook; they see your change in the editor. + - **Work through the editor.** Change cells with `sca cell` (`sca cell ls`, `put`, `insert`): + the page updates live and nothing the person typed is overwritten. Editing the file works + too: the editor takes the change in within a second. - **"Change this cell."** Every cell has a copy button that copies a reference such as `/path/u_link.py:L47-L48 (cell 02 ...)`. They paste it to you with what they want changed, and you edit exactly those lines. diff --git a/docs/skill/SKILL.md b/docs/skill/SKILL.md index 81dc3a8..7e2f597 100644 --- a/docs/skill/SKILL.md +++ b/docs/skill/SKILL.md @@ -50,7 +50,9 @@ Paths are relative to this file's directory. selected, and you MUST read `references/domains/editor-collaboration.md` first to see what they see (selection cell, `sca snapshot`, cell references) and how to - hand the work back. + hand the work back. While the editor runs, change its notebooks with + `sca cell` (or the file; never both blindly): the editor is the + file's writer. - Creating, installing, updating, or removing a SimpleCADAPI addon (a third-party skill+tooling package around `.scadpkg`) enters no workflow; you MUST use `references/domains/addon-development.md` @@ -202,7 +204,7 @@ print(len(rebuilt)) - `references/inspect/brep-reverse-engineering.md` - `references/domains/addon-development.md` — `sca` addon CLI and authoring guide - `references/domains/editor-collaboration.md` — working with a person in `sca edit`: - selection cell, snapshot, cell references, parameters + selection cell, snapshot, cell references, `sca cell` / `sca new`, parameters - `references/docs/guides/reconstruction-agent-test-prompt.md` - `references/docs/guides/reconstruction-agent-strategy.md` - `references/docs/guides/notebook-runtime.md` diff --git a/docs/skill/references/domains/editor-collaboration.md b/docs/skill/references/domains/editor-collaboration.md index 30e16d6..3ad24e1 100644 --- a/docs/skill/references/domains/editor-collaboration.md +++ b/docs/skill/references/domains/editor-collaboration.md @@ -6,11 +6,26 @@ edit code. It is not a workflow. The modeling you do still follows the workflow you selected; this page says how to see what the person sees and how to hand work back. -The notebook file is the only shared state. The editor autosaves every edit to +The notebook file is the shared state. The editor autosaves every edit to it, and every message the person gives you (a selection, a cell reference, a tuned value) is also something you can read from disk. Never rely on what the person says they selected when the file or the snapshot can tell you. +The editor holds the open notebook in memory and is the one writer of its +file. You have two safe ways to change it while it runs; use the first: + +1. **`sca cell` (preferred).** It edits through the editor: the page shows the + change live (no reload, no flicker), the cell runs, and you get its state back. +2. **Edit the file.** The editor notices a saved change within a second, compares + it with its cells and applies only what differs (changed cells are edited in + place and run again, added or removed cells appear or go away). A change to the + setup cell or an `@app.function` cell restarts the kernel instead. A file that + does not parse is ignored until the next save that does. + +If the person has the same cell open with unsaved typing, **your version wins**: +their text is dropped and kept in the editor's log for them. Do not rewrite a +cell the person is typing in without telling them. + ## Start the editor for a person ```bash @@ -28,6 +43,29 @@ sca edit --no-browser --port 7180 the assembly that fixes them together) to `./u_link_motor_mount`, then `sca edit ./u_link_motor_mount`. +## Edit cells through the editor: `sca cell` + +```bash +sca cell ls [--code] # numbered cells with status, errors and output +sca cell get 3 # one cell: code, status, errors, output +echo 'L = 90' | sca cell put 3 # replace the code of cell 3 and run it (--no-run to only save) +sca cell insert --after 3 -f new.py # add a cell (--after start|end|CELL, --name fn_name) +sca cell rm 4 +sca cell run [CELL ...] # run cells (default all) and what they feed +sca new bracket --kind part # a new notebook; the open editor lists it, the person's view stays +``` + +- A cell is named by its number in `sca cell ls`, its `key`, its function name, or its id. + Numbers move when cells are added or removed: read the list the command prints back. +- Each write prints the touched cell's state (`status`, `errors`, `output`). That is the + feedback: read it before the next step, as with any other operation. +- `put` fails with a conflict if you pass a base that is out of date; without one it + replaces the cell, so `get` first when the person may have changed it. +- The commands find the running editor through `/.sca-editor/server.json`. With no + editor running, or with another notebook open, `ls` and `get` read the file and the write + commands refuse: then edit the file directly, nothing else holds it. +- `@app.function` cells cannot be changed this way; edit them in the file. + ## Read what the person is doing | Need | Where | @@ -140,10 +178,12 @@ file first, since every cell is blocked until it compiles. ## Rules of thumb -- Re-read the file before editing: the person edits and autosaves too, and the - editor rewrites the selection cell. -- Edit by cell. A change that touches many cells at once makes the person's +- Re-read the cell before editing (`sca cell get`, or the file): the person edits and + autosaves too, and the editor rewrites the selection cell. +- Edit by cell, with `sca cell`. A change that touches many cells at once makes the person's screen reflow and loses their place. +- New notebooks: `sca new`, not a bare file write plus a request to refresh; the editor + lists a new file by itself either way. - After an edit, say which cells you changed (by reference) and what to look at, so the person can run them and judge the result. - Never edit `__marimo__/` or `.sca-editor/`; both are generated. diff --git a/editor-web/src/App.tsx b/editor-web/src/App.tsx index a19d286..b92747a 100644 --- a/editor-web/src/App.tsx +++ b/editor-web/src/App.tsx @@ -186,6 +186,13 @@ export function App() { setCells(next); log.knowCells(next); }); + // A notebook was added or removed under the workspace (an agent wrote one). + socket.on("workspace.changed", () => refreshWorkspace()); + // The server dropped an edit made on a cell that had changed on disk. + socket.on("cell.conflict", (envelope) => { + const mine = String(envelope["mine"] ?? ""); + log.push("error", `a cell changed on disk while you were editing it: kept the file's version. Your unsaved text was:\n${mine.length > 600 ? `${mine.slice(0, 600)}…` : mine}`); + }); socket.on("notebook.features", (envelope) => setFeatures((envelope["features"] as NotebookFeature[]) ?? [])); socket.on("preview", (envelope) => { @@ -355,6 +362,12 @@ export function App() { socket={socketRef.current} notebook={notebook} onInterrupt={handleStop} + onConflict={(cell, mine) => + log.push( + "error", + `${cell.title ?? "cell"} changed on disk while you were editing it: kept the file's version. Your unsaved text was:\n${mine.length > 600 ? `${mine.slice(0, 600)}…` : mine}`, + ) + } /> diff --git a/editor-web/src/components/CellEditor.tsx b/editor-web/src/components/CellEditor.tsx index 7b87b23..da77bec 100644 --- a/editor-web/src/components/CellEditor.tsx +++ b/editor-web/src/components/CellEditor.tsx @@ -74,6 +74,7 @@ export function CellEditor({ socket, notebook, onInterrupt, + onConflict, }: { cells: NotebookCell[]; features: NotebookFeature[]; @@ -81,6 +82,8 @@ export function CellEditor({ socket: SocketLike; notebook: string | null; onInterrupt: () => void; + /** A cell changed on disk while it had an unsaved edit here: the file's version was kept. */ + onConflict: (cell: NotebookCell, mine: string) => void; }) { const [drafts, setDrafts] = useState>(new Map()); const [running, setRunning] = useState(null); @@ -89,14 +92,60 @@ export function CellEditor({ // the second; any click elsewhere disarms. const [armed, setArmed] = useState(null); const saveTimers = useRef(new Map()); + // What each edited cell's draft started from, and the last text sent to be saved: + // a cell that arrives as something else was changed by someone else (an agent). + const bases = useRef(new Map()); + const sent = useRef(new Map()); const featureByCell = useMemo(() => new Map(features.map((feature) => [feature.cell, feature])), [features]); useEffect(() => { setCollapsed(loadCollapsed(notebook)); setDrafts(new Map()); + bases.current.clear(); + sent.current.clear(); }, [notebook]); + // The cells arrive again after every save and every change on disk: settle each draft + // against them. Saved (equal) -> done; confirms our last save -> keep editing from it; + // anything else -> the file's version wins, the person is told. + const onConflictRef = useRef(onConflict); + onConflictRef.current = onConflict; + useEffect(() => { + const same = (a: string, b: string) => a.trim() === b.trim(); + const lost: [NotebookCell, string][] = []; + setDrafts((previous) => { + let next: Map | null = null; + const drop = (id: string) => { + next ??= new Map(previous); + next.delete(id); + bases.current.delete(id); + sent.current.delete(id); + }; + for (const [id, draft] of previous) { + const cell = cells.find((candidate) => candidate.id === id); + if (!cell) { + drop(id); + continue; + } + const base = bases.current.get(id); + const last = sent.current.get(id); + if (same(cell.code, draft)) drop(id); + else if (last !== undefined && same(cell.code, last)) bases.current.set(id, cell.code); + else if (base !== undefined && !same(cell.code, base)) { + lost.push([cell, draft]); + drop(id); + } + } + return next ?? previous; + }); + for (const [cell, mine] of lost) { + cancelSave(cell.id); + onConflictRef.current(cell, mine); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cells]); + const remember = (next: Set) => { setCollapsed(next); try { @@ -122,7 +171,8 @@ export function CellEditor({ cancelSave(cell.id); const code = drafts.get(cell.id) ?? cell.code; setRunning(cell.id); - socket?.send({ type: "code.edit", cell: cell.id, code }); + sent.current.set(cell.id, code); + socket?.send({ type: "code.edit", cell: cell.id, code, base: bases.current.get(cell.id) }); window.setTimeout(() => setRunning((current) => (current === cell.id ? null : current)), 2500); }; @@ -141,6 +191,7 @@ export function CellEditor({ const insert = (after: string | null) => socket?.send({ type: "insert.request", after }); const edit = (cell: NotebookCell, value: string) => { + if (!bases.current.has(cell.id)) bases.current.set(cell.id, cell.code); setDrafts((previous) => new Map(previous).set(cell.id, value)); if (cell.readonly) return; cancelSave(cell.id); @@ -148,7 +199,8 @@ export function CellEditor({ cell.id, window.setTimeout(() => { saveTimers.current.delete(cell.id); - socket?.send({ type: "cell.save", cell: cell.id, code: value }); + sent.current.set(cell.id, value); + socket?.send({ type: "cell.save", cell: cell.id, code: value, base: bases.current.get(cell.id) }); }, AUTOSAVE_MS), ); }; diff --git a/src/simplecadapi/editor/cli.py b/src/simplecadapi/editor/cli.py index b9baa45..912babd 100644 --- a/src/simplecadapi/editor/cli.py +++ b/src/simplecadapi/editor/cli.py @@ -6,6 +6,8 @@ sca edit path/to/model.py # ... starting at one notebook sca edit --port 7180 --no-browser sca snapshot [WORKSPACE] # what the person has selected / is looking at + sca cell ls|get|put|insert|rm|run # edit the cells of the notebook the editor has open + sca new NAME [--kind part|assembly] # a new notebook; the editor lists it at once """ from __future__ import annotations @@ -43,6 +45,63 @@ def configure(subparsers: argparse._SubParsersAction) -> None: snapshot.add_argument("workspace", type=Path, nargs="?", default=Path.cwd()) snapshot.set_defaults(handler=_snapshot, printer=_print_snapshot) + cell = subparsers.add_parser( + "cell", + help="read and edit the cells of a notebook through the running editor", + description="Edit a notebook's cells through the editor that has it open: the page " + "shows the change live (no reload) and the editor, the one writer of the file, saves " + "it, so nothing is overwritten. Cells are named by number (see `sca cell ls`), by key " + "or by id. When the editor is not running, or has another notebook open, `ls` and " + "`get` read the file and the write commands refuse: edit the file directly then, " + "nothing else holds it.", + ) + cell_sub = cell.add_subparsers(dest="cell_command", required=True) + + def common(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--notebook", "-n", type=Path, default=None, help="the notebook (default: the one the editor has open)") + parser.add_argument("--workspace", "-w", type=Path, default=None, help="the workspace the editor serves (default: found from the current directory)") + + ls = cell_sub.add_parser("ls", help="list the cells with their state") + common(ls) + ls.add_argument("--code", action="store_true", help="include each cell's code") + ls.set_defaults(handler=_cell_ls, printer=_print_json) + get = cell_sub.add_parser("get", help="print one cell: its code, state, errors and output") + common(get) + get.add_argument("cell") + get.set_defaults(handler=_cell_get, printer=_print_json) + put = cell_sub.add_parser("put", help="replace a cell's code (the code comes from --file or stdin) and run it") + common(put) + put.add_argument("cell") + put.add_argument("--file", "-f", type=Path, default=None, help="read the code from this file (default: stdin)") + put.add_argument("--no-run", action="store_true", help="save the code without running the cell") + put.set_defaults(handler=_cell_put, printer=_print_json) + insert = cell_sub.add_parser("insert", help="add a cell (the code comes from --file or stdin)") + common(insert) + insert.add_argument("--after", default="end", help="a cell, or `start` / `end` (default)") + insert.add_argument("--name", default=None, help="the cell's function name") + insert.add_argument("--file", "-f", type=Path, default=None) + insert.add_argument("--no-run", action="store_true") + insert.set_defaults(handler=_cell_insert, printer=_print_json) + rm = cell_sub.add_parser("rm", help="delete a cell") + common(rm) + rm.add_argument("cell") + rm.set_defaults(handler=_cell_rm, printer=_print_json) + run = cell_sub.add_parser("run", help="run cells (default: all) and what they feed") + common(run) + run.add_argument("cells", nargs="*") + run.set_defaults(handler=_cell_run, printer=_print_json) + + new = subparsers.add_parser( + "new", + help="create a notebook in the workspace (the running editor lists it at once)", + description="Write a new notebook from the template into WORKSPACE (default: the " + "current directory). A running editor on that workspace lists it without a reload.", + ) + new.add_argument("name") + new.add_argument("--kind", choices=("part", "assembly"), default="part") + new.add_argument("--workspace", "-w", type=Path, default=None) + new.set_defaults(handler=_new_notebook, printer=_print_json) + def _execute(args: argparse.Namespace) -> tuple[dict[str, Any], int]: try: @@ -86,4 +145,182 @@ def _print_snapshot(report: dict[str, Any]) -> None: print(json.dumps(report, indent=2, ensure_ascii=False)) +# -- sca cell / sca new --------------------------------------------------------- + + +def _print_json(report: dict[str, Any]) -> None: + import json + + print(json.dumps(report, indent=2, ensure_ascii=False)) + + +def _find_editor(start: Path) -> tuple[Path, str] | None: + """The running editor whose workspace holds *start*: ``(workspace, base url)``.""" + import json + import urllib.request + + here = start.expanduser().resolve() + for directory in (here, *here.parents): + record = directory / ".sca-editor" / "server.json" + if not record.is_file(): + continue + try: + url = json.loads(record.read_text(encoding="utf-8"))["url"] + with urllib.request.urlopen(f"{url}/api/health", timeout=1.5): + pass + except (OSError, ValueError, KeyError): + continue # a stale record: the editor is gone + return directory, str(url) + return None + + +def _call(url: str, path: str, body: dict | None = None) -> dict[str, Any]: + import json + import urllib.error + import urllib.request + + data = None if body is None else json.dumps(body).encode("utf-8") + request = urllib.request.Request( + url + path, data=data, headers={"Content-Type": "application/json"}, method="GET" if body is None else "POST" + ) + try: + with urllib.request.urlopen(request, timeout=300) as response: + return json.loads(response.read()) + except urllib.error.HTTPError as error: + try: + return json.loads(error.read()) + except ValueError: + return {"error": f"editor answered {error.code}"} + + +def _session(args: argparse.Namespace, *, write: bool) -> tuple[str | None, Path | None, str]: + """``(editor url or None, notebook path or None, relative notebook name)`` for a cell command.""" + start = args.workspace or (args.notebook.parent if args.notebook else Path.cwd()) + found = _find_editor(start) + notebook = args.notebook.expanduser().resolve() if args.notebook else None + if found is None: + if write: + raise ValueError( + "no editor is running on this workspace (start `sca edit`), or edit the notebook file " + "directly: nothing else holds it" + ) + if notebook is None: + raise ValueError("no editor is running here; name the notebook with --notebook") + return None, notebook, notebook.name + workspace, url = found + open_path = _call(url, "/api/cells").get("notebook") + if notebook is None: + if open_path is None: + raise ValueError("the editor has no notebook open") + return url, workspace / open_path, open_path + relative = notebook.relative_to(workspace).as_posix() if notebook.is_relative_to(workspace) else notebook.name + if relative == open_path: + return url, notebook, relative + if write: + raise ValueError( + f"the editor has {open_path} open, not {relative}: edit {relative} directly " + "(the editor does not hold it), or open it in the editor first" + ) + return None, notebook, relative + + +def _cells_from_file(notebook: Path) -> list[dict[str, Any]]: + from ..runtime._marimo import parse_notebook + + parsed = parse_notebook(notebook.read_text(encoding="utf-8")) + cells = [] + for cell in (parsed.cells if parsed is not None else []): + if getattr(cell, "name", None) == "setup": + continue + cells.append({"index": len(cells) + 1, "name": getattr(cell, "name", None), "code": cell.code, "source": "file"}) + return cells + + +def _slim(report: dict[str, Any], *, code: bool) -> dict[str, Any]: + if "cells" not in report: + return report + keep = ("index", "key", "name", "title", "status", "errors", "output", "ref", "readonly") + return { + **report, + "cells": [ + {**{k: c[k] for k in keep if k in c and c[k] not in (None, [], False)}, **({"code": c["code"]} if code else {})} + for c in report["cells"] + ], + } + + +def _cell_ls(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + url, notebook, name = _session(args, write=False) + if url is None: + assert notebook is not None + return {"notebook": name, "source": "file", "cells": _cells_from_file(notebook)}, 0 + return _slim(_call(url, "/api/cells"), code=args.code), 0 + + +def _cell_get(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + url, notebook, name = _session(args, write=False) + if url is None: + assert notebook is not None + cells = _cells_from_file(notebook) + if not args.cell.isdigit() or not 1 <= int(args.cell) <= len(cells): + raise ValueError("without the editor, name a cell by its number (see `sca cell ls`)") + return {"notebook": name, "source": "file", **cells[int(args.cell) - 1]}, 0 + report = _call(url, "/api/cells") + for cell in report.get("cells", []): + if args.cell in (str(cell["index"]), cell.get("key"), cell["id"]): + return {"notebook": report.get("notebook"), **cell}, 0 + raise ValueError(f"no cell {args.cell!r}; run `sca cell ls`") + + +def _read_code(args: argparse.Namespace) -> str: + import sys + + if args.file is not None: + return args.file.read_text(encoding="utf-8") + if sys.stdin.isatty(): + raise ValueError("give the code with --file, or on stdin") + return sys.stdin.read() + + +def _write_op(args: argparse.Namespace, op: str, body: dict[str, Any]) -> tuple[dict[str, Any], int]: + url, _notebook, _name = _session(args, write=True) + assert url is not None + report = _call(url, f"/api/cells/{op}", body) + if "error" in report: + return _slim(report, code=False), 1 + touched = set(report.get("touched", [])) + report = _slim(report, code=False) + # What the change did: the touched cells with their state; the rest as a count. + report["cells"] = [c for c in report["cells"] if c.get("index") in touched] or report["cells"] + return report, 0 + + +def _cell_put(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + return _write_op(args, "edit", {"cell": args.cell, "code": _read_code(args), "run": not args.no_run}) + + +def _cell_insert(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + return _write_op(args, "insert", {"after": args.after, "name": args.name, "code": _read_code(args), "run": not args.no_run}) + + +def _cell_rm(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + return _write_op(args, "delete", {"cell": args.cell}) + + +def _cell_run(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + return _write_op(args, "run", {"cells": args.cells}) + + +def _new_notebook(args: argparse.Namespace) -> tuple[dict[str, Any], int]: + from .templates import create_notebook + + start = (args.workspace or Path.cwd()).expanduser().resolve() + found = _find_editor(start) + root = found[0] if found is not None else start + # Only the file is written: the running editor lists it on its own and the + # person's current notebook stays open. + path = create_notebook(root, args.name, args.kind) + return {"ok": True, "path": str(path), "editor_listed": found is not None}, 0 + + __all__ = ["configure"] diff --git a/src/simplecadapi/editor/kernel.py b/src/simplecadapi/editor/kernel.py index 1c60f4c..70ab77a 100644 --- a/src/simplecadapi/editor/kernel.py +++ b/src/simplecadapi/editor/kernel.py @@ -20,6 +20,7 @@ from __future__ import annotations +import functools import json import os import re @@ -43,6 +44,22 @@ class KernelError(RuntimeError): pass +def _locked(method): + """Run a cell-writing method under the kernel's write lock. + + The editor's own writes and the ingest of an external edit of the notebook + file must not interleave: ingest compares the file with the kernel's cells, + and a write half-way through would look like an external change. + """ + + @functools.wraps(method) + def wrapper(self, *args, **kwargs): + with self.write_lock: + return method(self, *args, **kwargs) + + return wrapper + + class MarimoKernel: def __init__( self, @@ -59,6 +76,7 @@ def __init__( self.setup_errors: list[str] = [] self._ran = threading.Event() # a run of cells completed (see wait_instantiated) self._own_calls = 0 + self.write_lock = threading.RLock() self._own_ended = 0.0 self.workspace = self.notebook.parent self.marimo_port = _free_port(server_port + 1) @@ -357,6 +375,7 @@ def run_cells(self, cell_ids: list[str]) -> dict: """Queue cells to run (lazy mode: their dependents turn stale).""" return self.execute(_driver("\n".join(f" ctx.run_cell({cell_id!r})" for cell_id in cell_ids))) + @_locked def edit_cell(self, cell_id: str, code: str) -> dict: """Replace a cell's code (persisted to the file) and run it.""" return self.execute( @@ -365,6 +384,7 @@ def edit_cell(self, cell_id: str, code: str) -> dict: + _driver(f" ctx.edit_cell({cell_id!r}, code=__code)\n ctx.run_cell({cell_id!r})") ) + @_locked def save_cell(self, cell_id: str, code: str) -> dict: """Replace a cell's code (persisted to the file) without running it.""" return self.execute( @@ -373,6 +393,7 @@ def save_cell(self, cell_id: str, code: str) -> dict: + _driver(f" ctx.edit_cell({cell_id!r}, code=__code)") ) + @_locked def put_cell(self, code: str, *, cell_id: str | None, run: bool = True) -> str | None: """Write *code* into cell *cell_id*, or append a new cell holding it. @@ -401,9 +422,11 @@ def put_cell(self, code: str, *, cell_id: str | None, run: bool = True) -> str | return line[len("SCA-CELL "):].strip() return None + @_locked def delete_cell(self, cell_id: str) -> dict: return self.execute(_driver(f" ctx.delete_cell({cell_id!r})")) + @_locked def insert_cell(self, after: str | None) -> dict: """Insert one empty, named cell after *after* (at the top if None). @@ -431,6 +454,27 @@ def insert_cell(self, after: str | None) -> dict: ) ) + @_locked + def create_cell_after(self, after: str | None, code: str, name: str | None, *, run: bool = True) -> dict: + """Create a cell holding *code* after cell *after* (at the top if None).""" + body = ( + " __names = [c.id for c in ctx.cells]\n" + + ( + f" __id = ctx.create_cell(__code, after={after!r}, name={name!r})\n" + if after + else ( + f" __id = ctx.create_cell(__code, before=__names[0], name={name!r}) if __names" + f" else ctx.create_cell(__code, name={name!r})\n" + ) + ) + + (" ctx.run_cell(__id)\n" if run else "") + + "print('SCA-CELL', __id)\n" + ) + return self.execute( + "import json as _json\n" + f"__code = _json.loads({json.dumps(json.dumps(code))})\n" + _driver(body) + ) + def reload_modules(self, names: list[str]) -> dict: """Forget the local modules *names* and run every cell again. diff --git a/src/simplecadapi/editor/server.py b/src/simplecadapi/editor/server.py index 7c5054f..3dfa5ca 100644 --- a/src/simplecadapi/editor/server.py +++ b/src/simplecadapi/editor/server.py @@ -144,6 +144,9 @@ def __init__(self, workspace: Path, notebook: Path | None = None) -> None: self._generation = 0 self._switch_lock = threading.Lock() self._booting: MarimoKernel | None = None + self._setup_code: str | None = None + self._edited_from: dict[str, set[str]] = {} + self._shutdown = threading.Event() # -- notebook / kernel lifecycle --------------------------------------------- @@ -263,6 +266,7 @@ def start_kernel(self, notebook: Path, *, restored: bool = False, generation: in self.cells = [] self.specs = {} self._watched = self._watched_files()[0] + self._setup_code = _setup_code_of(notebook) self.hub.broadcast(self.hello()) kernel.wait_instantiated() kernel.wait_idle() @@ -277,6 +281,9 @@ def start_kernel(self, notebook: Path, *, restored: bool = False, generation: in threading.Thread( target=self._watch_files, args=(kernel,), name="file-watch", daemon=True ).start() + threading.Thread( + target=self._watch_notebook, args=(kernel,), name="notebook-watch", daemon=True + ).start() # -- remembering what was published ----------------------------------------------- @@ -479,6 +486,250 @@ def _files_changed(self, paths: set[Path], *, announce: bool = True) -> None: self._module_busy = True threading.Thread(target=self._propagate, name="propagate", daemon=True).start() + # -- the notebook file changed under the editor ------------------------------ + + def _watch_notebook(self, kernel: MarimoKernel) -> None: + """Take in edits made to the open notebook's file by someone else (an agent). + + The kernel holds the notebook in memory and writes the file on every edit + the editor makes, so an outside edit that the kernel never hears of would be + written over by the editor's next save. Poll the file; once it has been + quiet for one tick, compare it with the kernel's cells (a change the editor + itself made shows no difference) and apply what differs. + """ + stamp = self._stamp(self.notebook) + pending: tuple[int, int] | None = None + while not kernel._stop.is_set(): + time.sleep(0.3) + if self.kernel is not kernel: + return + current = self._stamp(self.notebook) + if current != stamp: + stamp, pending = current, current + continue + if pending is None: + continue + pending = None + try: + self.ingest_external(kernel) + except Exception as exc: + self.hub.broadcast({"type": "status", "error": f"could not take in the file change: {str(exc)[-200:]}"}) + + @staticmethod + def _stamp(path: Path | None) -> tuple[int, int]: + try: + assert path is not None + info = path.stat() + return info.st_mtime_ns, info.st_size + except (OSError, AssertionError): + return 0, 0 + + def ingest_external(self, kernel: MarimoKernel) -> None: + """Make the kernel's cells equal the notebook file's, changing only what differs. + + Cells that differ are edited, created or deleted in place (the page updates + without a reload), then the cells that changed are run and what they feed + turns fresh. A change the cell commands cannot carry (the setup cell, an + ``@app.function`` cell) restarts the kernel instead. + """ + import difflib + + from ..runtime._marimo import parse_notebook + + notebook = self.notebook + if notebook is None: + return + with kernel.write_lock: + try: + parsed = parse_notebook(notebook.read_text(encoding="utf-8")) + except (OSError, UnicodeError): + return + except SyntaxError as exc: + self.hub.broadcast({"type": "status", "error": f"{notebook.name} has a syntax error ({exc.msg}, line {exc.lineno}); waiting for the next save"}) + return + if parsed is None: + return + setup = next((c.code.strip() for c in parsed.cells if getattr(c, "name", None) == "setup"), "") + if self._setup_code is not None and setup != self._setup_code: + self.hub.broadcast({"type": "status", "working": "the setup cell changed on disk: restarting the kernel…"}) + self._in_thread(lambda: self.start_kernel(notebook)) + return + disk = [ + (getattr(c, "name", None) or None, c.code) + for c in parsed.cells + if getattr(c, "name", None) != "setup" + ] + live = kernel.list_cells() + norm = lambda code: code.strip() # noqa: E731 + matcher = difflib.SequenceMatcher( + None, [norm(c["code"]) for c in live], [norm(code) for _name, code in disk], autojunk=False + ) + ops = [op for op in matcher.get_opcodes() if op[0] != "equal"] + if not ops: + return + touched = [live[i]["code"] for _t, i1, i2, _j1, _j2 in ops for i in range(i1, i2)] + touched += [code for _t, _i1, _i2, j1, j2 in ops for _name, code in disk[j1:j2]] + if any(_is_function_cell(code) for code in touched): + self.hub.broadcast({"type": "status", "working": "an @app.function cell changed on disk: restarting the kernel…"}) + self._in_thread(lambda: self.start_kernel(notebook)) + return + ids = [c["id"] for c in live] + changed: list[str] = [] + previous: str | None = None + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + previous = ids[i2 - 1] if i2 > i1 else previous + continue + paired = min(i2 - i1, j2 - j1) if tag == "replace" else 0 + for offset in range(paired): + kernel.save_cell(ids[i1 + offset], disk[j1 + offset][1]) + changed.append(ids[i1 + offset]) + previous = ids[i1 + offset] + for index in range(i1 + paired, i2): + kernel.delete_cell(ids[index]) + for name, code in disk[j1 + paired : j2]: + created = kernel.create_cell_after(previous, code, name, run=False) + new_id = _created_id(created) + if new_id is None: + raise RuntimeError("the kernel did not create a cell for the new code on disk") + changed.append(new_id) + previous = new_id + self.hub.broadcast({"type": "status", "info": f"{notebook.name} changed on disk: {len(changed)} cell(s) updated, running them"}) + self.push_cells() + self._in_thread(lambda: self._rerun_changed(kernel, changed)) + + # -- the same cell edits, for an agent (``sca cell``) ------------------------- + + def _brief(self, number: int, cell: dict) -> dict: + return { + "index": number, + "id": cell["id"], + "key": cell.get("key"), + "name": cell.get("name"), + "title": cell.get("title"), + "status": cell.get("status"), + "errors": cell.get("errors") or [], + "output": cell.get("output"), + "console": cell.get("console") or [], + "readonly": bool(cell.get("readonly")), + "ref": cell.get("ref"), + "code": cell["code"], + } + + def cells_report(self, *, refresh: bool = True) -> dict: + if refresh and self.kernel is not None: + self.push_cells() + return { + "notebook": self.notebook.relative_to(self.workspace).as_posix() if self.notebook else None, + "cells": [self._brief(n, c) for n, c in enumerate(self.cells, start=1)], + } + + def _find_cell(self, ref: Any) -> dict: + text = str(ref).strip() + for cell in self.cells: + if text in (cell["id"], cell.get("key")) or (text == cell.get("name") and text != "_"): + return cell + if text.isdigit() and 1 <= int(text) <= len(self.cells): + return self.cells[int(text) - 1] + raise KeyError(f"no cell {text!r} in {self.notebook.name if self.notebook else 'the notebook'}; run `sca cell ls`") + + def cell_command(self, op: str, body: dict) -> dict: + """Run one cell operation for the CLI; returns the report or ``{"error": ...}``. + + The edit goes through the kernel exactly as one made in the page does, so the + page shows it live and the file is written by the one writer that holds it. + """ + kernel = self.kernel + if kernel is None: + return {"error": "the editor has no kernel yet; wait for it to start"} + self.push_cells() + touched: list[str] = [] + try: + if op == "edit": + cell = self._find_cell(body.get("cell")) + code = _module_code(str(body.get("code", ""))) + if _is_function_cell(cell["code"]): + return {"error": "this cell is an @app.function; edit it in the file (the editor takes the change in)"} + if body.get("base") is not None and _module_code(str(body["base"])).strip() != _module_code(cell["code"]).strip(): + return {"error": "conflict: the cell changed since you read it", "theirs": cell["code"]} + if code.strip() != _module_code(cell["code"]).strip(): + (kernel.edit_cell if body.get("run", True) else kernel.save_cell)(cell["id"], code) + elif body.get("run", True): + kernel.run_cells([cell["id"]]) + touched = [cell["id"]] + elif op == "insert": + after = body.get("after") + if after in (None, "end"): + anchor = self.cells[-1]["id"] if self.cells else None + elif after == "start": + anchor = None + else: + anchor = self._find_cell(after)["id"] + code = _module_code(str(body.get("code", ""))) or "# new cell" + name = str(body["name"]) if body.get("name") else None + created = _created_id(kernel.create_cell_after(anchor, code, name, run=bool(body.get("run", True)))) + if created is None: + return {"error": "the kernel did not create the cell"} + touched = [created] + elif op == "delete": + cell = self._find_cell(body.get("cell")) + kernel.delete_cell(cell["id"]) + elif op == "run": + refs = body.get("cells") or [] + ids = [self._find_cell(ref)["id"] for ref in refs] or [c["id"] for c in self.cells] + kernel.run_until_settled(ids) + touched = ids + else: + return {"error": f"unknown cell operation {op!r}"} + except KeyError as exc: + return {"error": str(exc.args[0])} + kernel.wait_idle() + self.push_cells() + self.schedule_settle() + report = self.cells_report(refresh=False) + report["touched"] = [c["index"] for c in report["cells"] if c["id"] in touched] + return report + + def _rerun_changed(self, kernel: MarimoKernel, changed: list[str]) -> None: + kernel.run_until_settled(changed) + self.schedule_settle() + + def _watch_workspace(self) -> None: + """Tell the pages when notebooks appear or disappear under the workspace.""" + known = self._workspace_stamps() + while not self._shutdown.is_set(): + time.sleep(0.7) + try: + current = self._workspace_stamps() + except OSError: + continue + # The open notebook is rewritten by the editor on every edit; only its + # appearing or vanishing counts. + open_path = self.notebook + changed = set(current) ^ set(known) + changed |= {p for p in current if p in known and current[p] != known[p] and p != open_path} + known = current + if changed: + self.hub.broadcast({"type": "workspace.changed"}) + + def _workspace_stamps(self) -> dict[Path, int]: + import os + + stamps: dict[Path, int] = {} + for directory, subdirectories, files in os.walk(self.workspace): + subdirectories[:] = [ + name for name in subdirectories + if not name.startswith((".", "_")) and name not in _SKIPPED_DIRS + ] + for name in files: + if name.endswith(".py") and not name.startswith("_"): + path = Path(directory) / name + try: + stamps[path] = path.stat().st_mtime_ns + except OSError: + continue + return stamps + def _propagate(self) -> None: """Serialized, coalescing: one reload covers every change that arrived meanwhile.""" try: @@ -807,6 +1058,8 @@ def lookup() -> None: current = next((c["code"] for c in self.cells if c["id"] == cell), None) if current is None or code.strip() == _module_code(current).strip(): return {"type": "ack", "command": kind} + if self._conflicts(message, cell, code, current): + return {"type": "ack", "command": kind} if _is_function_cell(current): return { "type": "error", @@ -840,6 +1093,8 @@ def save() -> None: elif kind == "code.edit": code = _module_code(str(message.get("code", ""))) current = next((c["code"] for c in self.cells if c["id"] == cell), None) + if current is not None and code.strip() != _module_code(current).strip() and self._conflicts(message, cell, code, current): + return {"type": "ack", "command": kind} if current is not None and code.strip() != _module_code(current).strip() and _is_function_cell(current): # marimo's code mode would turn an @app.function into a plain # cell; edit those in the file or in marimo's own editor. @@ -870,6 +1125,27 @@ def run() -> None: self._in_thread(run) return {"type": "ack", "command": kind} + def _conflicts(self, message: dict, cell: str, mine: str, current: str) -> bool: + """The page edited a cell that changed under it: keep what is on disk. + + The page says which code its edit started from (``base``). If the cell + is something else now (an agent edited the file and the editor took the + change in), writing the page's version would erase that edit, so the + write is dropped and the page is told both versions; it shows the file's + version and keeps its own in the log. + """ + base = message.get("base") + now = _module_code(current).strip() + # A page types on: its second save still names the text it started from, which + # its own first save has replaced since. Only a text the editor did not write + # over is a change made by someone else. + mine_before = self._edited_from.setdefault(cell, set()) + if base is None or _module_code(str(base)).strip() in (now, *mine_before): + mine_before.add(now) + return False + self.hub.broadcast({"type": "cell.conflict", "cell": cell, "mine": mine, "theirs": current}) + return True + def _in_thread(self, work: Any) -> None: def run() -> None: try: @@ -1119,6 +1395,26 @@ def snapshot(self) -> dict: "model_revision": self.preview_revision, } + def write_endpoint(self, host: str) -> None: + """Say where this editor listens, so ``sca cell`` and other tools find it.""" + try: + directory = self.snapshot_dir + directory.mkdir(parents=True, exist_ok=True) + import os + + (directory / "server.json").write_text( + json.dumps({"url": f"http://{host}:{self.port}", "pid": os.getpid(), "workspace": str(self.workspace)}), + encoding="utf-8", + ) + except OSError: + pass + + def remove_endpoint(self) -> None: + try: + (self.snapshot_dir / "server.json").unlink() + except OSError: + pass + def write_snapshot(self) -> None: """Keep ``.sca-editor/snapshot.json`` current; ``sca snapshot`` reads it.""" try: @@ -1192,6 +1488,28 @@ def visit(node: ast.AST) -> None: return "\n".join(lines) +_SKIPPED_DIRS = frozenset({"node_modules", "venv", "site-packages", "build", "dist"}) + + +def _setup_code_of(notebook: Path) -> str | None: + from ..runtime._marimo import parse_notebook + + try: + parsed = parse_notebook(notebook.read_text(encoding="utf-8")) + except (OSError, UnicodeError, SyntaxError): + return None + if parsed is None: + return None + return next((c.code.strip() for c in parsed.cells if getattr(c, "name", None) == "setup"), "") + + +def _created_id(result: dict) -> str | None: + for line in (result.get("log") or "").splitlines(): + if line.startswith("SCA-CELL "): + return line[len("SCA-CELL "):].strip() + return None + + def _static_cells(notebook: Path) -> list[dict]: """The notebook's cells as the file has them, for showing before a kernel runs them.""" from ..runtime._marimo import parse_notebook @@ -1365,6 +1683,21 @@ async def latest_package(request: Request) -> Response: ) return JSONResponse({"error": "no package made yet"}, status_code=404) + async def api_cells(request: Request) -> JSONResponse: + """The open notebook's cells, with their state (for ``sca cell ls``).""" + report = await asyncio.to_thread(state.cells_report) + return JSONResponse(report) + + async def api_cell_command(request: Request) -> JSONResponse: + op = request.path_params["op"] + try: + body = json.loads(await request.body() or b"{}") + except json.JSONDecodeError: + return JSONResponse({"error": "body is not JSON"}, status_code=400) + report = await asyncio.to_thread(state.cell_command, op, body if isinstance(body, dict) else {}) + status = 409 if str(report.get("error", "")).startswith("conflict") else 400 if "error" in report else 200 + return JSONResponse(report, status_code=status) + async def sdk_index(request: Request) -> JSONResponse: """The SDK's public API (names and parameters), for the cell editor's completion.""" from .apiindex import build_index @@ -1491,6 +1824,8 @@ async def ws_endpoint(websocket) -> None: Route("/api/runtime/package", push_package, methods=["POST"]), Route("/api/package/latest", latest_package, methods=["GET"]), Route("/api/sdk", sdk_index, methods=["GET"]), + Route("/api/cells", api_cells, methods=["GET"]), + Route("/api/cells/{op}", api_cell_command, methods=["POST"]), Route("/api/workspace", workspace_list, methods=["GET"]), Route("/api/workspace/open", workspace_open, methods=["POST"]), Route("/api/workspace/new", workspace_new, methods=["POST"]), @@ -1532,6 +1867,8 @@ async def startup(sockets=None): hub = state.hub hub.attach_loop(asyncio.get_running_loop()) print(f"[sca edit] workspace {state.workspace}") + state.write_endpoint(host) + threading.Thread(target=state._watch_workspace, name="workspace-watch", daemon=True).start() if state.notebook is None: print("[sca edit] no SimpleCAD notebook found in the workspace", file=sys.stderr) @@ -1563,6 +1900,8 @@ def boot() -> None: original_shutdown = server.shutdown async def shutdown(sockets=None): + state._shutdown.set() + state.remove_endpoint() if state.kernel is not None: state.kernel.stop() await original_shutdown(sockets=sockets) diff --git a/test/test_editor.py b/test/test_editor.py index d747b84..e14be88 100644 --- a/test/test_editor.py +++ b/test/test_editor.py @@ -881,3 +881,98 @@ def test_the_server_serves_it(self) -> None: response = TestClient(app).get("/api/sdk") self.assertEqual(response.status_code, 200) self.assertIn("scad", response.json()["namespaces"]) + + +class ConflictTest(unittest.TestCase): + """A page's edit of a cell that changed on disk must not erase the change.""" + + def make_state(self) -> tuple[WorkspaceState, list[dict]]: + state = WorkspaceState(Path(tempfile.mkdtemp())) + sent: list[dict] = [] + state.hub.broadcast = sent.append # type: ignore[method-assign] + return state, sent + + def test_an_edit_that_started_from_another_text_is_dropped_and_reported(self) -> None: + state, sent = self.make_state() + self.assertTrue(state._conflicts({"base": "x = 1"}, "c1", "x = 2", "x = 9")) + self.assertEqual(sent[0]["type"], "cell.conflict") + self.assertEqual((sent[0]["mine"], sent[0]["theirs"]), ("x = 2", "x = 9")) + + def test_an_edit_from_the_current_text_goes_through(self) -> None: + state, sent = self.make_state() + self.assertFalse(state._conflicts({"base": "x = 1\n"}, "c1", "x = 2", "x = 1")) + self.assertFalse(state._conflicts({}, "c1", "x = 2", "x = 1")) # an old page sends no base + self.assertEqual(sent, []) + + def test_a_second_save_of_the_same_typing_is_not_a_conflict(self) -> None: + state, sent = self.make_state() + # first save: the page started from "a", the file has "a" + self.assertFalse(state._conflicts({"base": "a"}, "c1", "ab", "a")) + # second save arrives while the first is applied: the file has "ab", the page still says "a" + self.assertFalse(state._conflicts({"base": "a"}, "c1", "abc", "ab")) + # the file has "abc" now; an agent makes it "zzz": the page's next save from "abc" is a conflict + self.assertTrue(state._conflicts({"base": "abc"}, "c1", "abd", "zzz")) + self.assertEqual(len(sent), 1) + + +class WorkspaceWatchTest(unittest.TestCase): + def test_stamps_see_new_notebooks_and_skip_hidden_directories(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + state = WorkspaceState(root) + create_notebook(root, "first", "part") + before = state._workspace_stamps() + (root / ".sca-editor").mkdir() + (root / ".sca-editor" / "x.py").write_text("x = 1\n") + create_notebook(root, "second", "part") + after = state._workspace_stamps() + self.assertEqual({p.name for p in after} - {p.name for p in before}, {"second.py"}) + + +class CellCliTest(unittest.TestCase): + def test_cell_commands_refuse_to_write_without_an_editor(self) -> None: + from simplecadapi.cli import main + + with tempfile.TemporaryDirectory() as tmp: + path = create_notebook(Path(tmp), "bracket", "part") + self.assertEqual(main(["cell", "rm", "1", "--notebook", str(path)]), 2) + + def test_cell_ls_reads_the_file_when_no_editor_runs(self) -> None: + from simplecadapi.editor import cli + + with tempfile.TemporaryDirectory() as tmp: + path = create_notebook(Path(tmp), "bracket", "part") + cells = cli._cells_from_file(path) + self.assertTrue(cells) + self.assertEqual(cells[0]["index"], 1) + self.assertIsNone(cli._find_editor(Path(tmp))) + + +@unittest.skipUnless(os.environ.get("SCA_EDITOR_E2E"), "set SCA_EDITOR_E2E=1 to boot a marimo kernel") +class ExternalEditTest(unittest.TestCase): + def test_an_edit_of_the_file_is_taken_in_and_not_written_over(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + path = create_notebook(root, "bracket", "part") + state = WorkspaceState(root) + state.port = 17733 + state.hub.attach_loop(__import__("asyncio").new_event_loop()) + try: + state.start_kernel(path) + kernel = state.kernel + assert kernel is not None + before = [c["code"] for c in kernel.list_cells()] + text = path.read_text() + anchor = 'if __name__ == "__main__":' + added = '@app.cell\ndef _agent():\n agent_note = 1\n return (agent_note,)\n\n\n' + path.write_text(text.replace(anchor, added + anchor, 1)) + state.ingest_external(kernel) + after = [c["code"] for c in kernel.list_cells()] + self.assertEqual(len(after), len(before) + 1) + self.assertIn("agent_note = 1", after[-1]) + # the editor writes on its next save: the agent's cell is still in the file + kernel.save_cell(kernel.list_cells()[0]["id"], before[0] + "\n# touched") + self.assertIn("agent_note = 1", path.read_text()) + finally: + if state.kernel is not None: + state.kernel.stop() From f90ae2d0fef7c7d353a6fbdee389107b17dbba61 Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Wed, 14 Oct 2026 15:32:41 +0800 Subject: [PATCH 2/3] release: 2.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version bump, changelog heading, update notes (EN/中文), SKILL.md metadata, README. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ README.md | 26 ++++++++++++-------------- README.zh-CN.md | 19 ++++++++++--------- docs/skill/SKILL.md | 4 ++-- docs/updates/2.3.0.md | 37 +++++++++++++++++++++++++++++++++++++ docs/updates/2.3.0.zh-CN.md | 33 +++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 8 files changed, 98 insertions(+), 27 deletions(-) create mode 100644 docs/updates/2.3.0.md create mode 100644 docs/updates/2.3.0.zh-CN.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f16dbf1..eb00dbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ user-facing notes live in [`docs/updates/`](docs/updates/) (English and ## Unreleased +## 2.3.0 — 2026-10-14 + ### Added - Editor: `sca cell ls|get|put|insert|rm|run` edits the open notebook's cells through the diff --git a/README.md b/README.md index 430b377..cacb76a 100644 --- a/README.md +++ b/README.md @@ -149,23 +149,20 @@ the full crank cycle sweeps clean in the same sim · --- -## Update Notes (2.2.2) +## Update Notes (2.3.0) > Validate generated definitions, assembly constraints, and manufacturing > geometry before production use. -- **Code completion from the SDK.** In the editor, `scad.` (and `scad.ql.`, - `scad.std.gear.`) lists the real functions with their signatures and - docstrings. Accepting one, or typing `(` after its name, writes the call with - every required parameter as `name=`, and Tab moves between them; inside a call - the parameters not given yet complete as `name=`. -- **New notebooks from the workspace tab.** A **new** action starts a part, or - an assembly that uses the first two parts, from the SDK's template; an empty - workspace opens on that form. -- **Fix.** The cell editors no longer rebuild their configuration on every - render, which dropped a snippet in progress. - -Full notes for this version: [`docs/updates/2.2.2.md`](docs/updates/2.2.2.md). +- **The editor takes in edits made to the notebook file.** An agent's saved change is applied + cell by cell (changed cells update and run again, no reload), and is no longer written over by + the editor. If a cell was edited in both places, the file's version is kept and your unsaved + text goes to the log. Notebooks added to the workspace appear in the list by themselves. +- **`sca cell` and `sca new`.** `sca cell ls|get|put|insert|rm|run` edits the open notebook's cells + through the running editor (the page updates live, each write prints the cell's status and + errors); `sca new NAME` creates a notebook the editor then lists. + +Full notes for this version: [`docs/updates/2.3.0.md`](docs/updates/2.3.0.md). Earlier versions: the [changelog](CHANGELOG.md) and [`docs/updates/`](docs/updates/). --- @@ -186,7 +183,7 @@ in a compact public API for creating solids, applying features, tagging semantic intent, querying topology, exporting manufacturing files, and translating recorded models into FreeCAD workflows. -Current release: `simplecadapi==2.2.2`. +Current release: `simplecadapi==2.3.0`. ## What It Provides @@ -701,6 +698,7 @@ and the [STEP reconstruction workflow](docs/skill/references/workflows/step-reco ## Documentation +- 2.3.0 update notes: [`docs/updates/2.3.0.md`](docs/updates/2.3.0.md) - 2.2.2 update notes: [`docs/updates/2.2.2.md`](docs/updates/2.2.2.md) - 2.2.1 update notes: [`docs/updates/2.2.1.md`](docs/updates/2.2.1.md) - 2.1.3b1 update notes: [`docs/updates/2.1.3b1.md`](docs/updates/2.1.3b1.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 801891d..aa93996 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -133,18 +133,18 @@ CalculiX 静力 FEM(边界面按保真的 `interface.*` 标签选取,仿真 --- -## 更新日志(2.2.2) +## 更新日志(2.3.0) > 投入生产前请验证生成的定义、装配约束和制造几何。 -- **来自 SDK 的代码补全。** 在编辑器里,输入 `scad.`(以及 `scad.ql.`、`scad.std.gear.`) - 会列出真实存在的函数、签名和文档说明。选中一个函数,或在函数名后输入 `(`,会把每个必填 - 参数写成 `name=`,Tab 在它们之间跳转;在调用括号内,还没写的参数会补全成 `name=`。 -- **在工作区页面创建 notebook。** 新增的 **new** 按 SDK 模板创建一个零件,或一个使用前两个 - 零件的装配;空工作区会直接打开这个表单。 -- **修复。** cell 编辑器不再每次渲染都重建配置,这会让进行中的占位跳转丢失。 +- **编辑器会接收对 notebook 文件的修改。** agent 保存的修改会按 cell 逐个应用(变化的 cell 更新并重新运行, + 不刷新页面),不再被编辑器覆盖。同一个 cell 两边都改时,保留文件里的版本,你未保存的文字记入日志。 + 工作区里新增的 notebook 会自动出现在列表中。 +- **`sca cell` 和 `sca new`。** `sca cell ls|get|put|insert|rm|run` 通过运行中的编辑器修改打开的 + notebook 的 cell(页面实时更新,每次写入都打印该 cell 的状态和错误);`sca new NAME` 创建一个 + 编辑器随即列出的 notebook。 -本版本的完整说明:[`docs/updates/2.2.2.zh-CN.md`](docs/updates/2.2.2.zh-CN.md)。 +本版本的完整说明:[`docs/updates/2.3.0.zh-CN.md`](docs/updates/2.3.0.zh-CN.md)。 更早的版本:见[更新日志](CHANGELOG.md)和 [`docs/updates/`](docs/updates/)。 --- @@ -162,7 +162,7 @@ CalculiX 静力 FEM(边界面按保真的 `interface.*` 标签选取,仿真 SimpleCADAPI 是一个基于 OCP 的 Python CAD SDK,提供清晰的函数式建模操作和可重放的模型图。它在 OpenCascade 几何内核之上提供精简的公共 API,可用于创建实体、应用特征、添加语义标签、查询拓扑、导出制造文件,以及将记录的模型转换为 FreeCAD 工作流。 -当前版本:`simplecadapi==2.2.2`。 +当前版本:`simplecadapi==2.3.0`。 ## 核心能力 @@ -588,6 +588,7 @@ uv run --extra fem python examples/ap242_gmsh_volume_mesh/run_calculix.py ## 文档 +- 2.3.0 更新说明:[`docs/updates/2.3.0.zh-CN.md`](docs/updates/2.3.0.zh-CN.md) - 2.2.2 更新说明:[`docs/updates/2.2.2.zh-CN.md`](docs/updates/2.2.2.zh-CN.md) - 2.2.1 更新说明:[`docs/updates/2.2.1.zh-CN.md`](docs/updates/2.2.1.zh-CN.md) - 2.1.3b1 更新说明:[`docs/updates/2.1.3b1.zh-CN.md`](docs/updates/2.1.3b1.zh-CN.md) diff --git a/docs/skill/SKILL.md b/docs/skill/SKILL.md index 7e2f597..2079717 100644 --- a/docs/skill/SKILL.md +++ b/docs/skill/SKILL.md @@ -4,9 +4,9 @@ description: Build, assemble, inspect, reconstruct, and export parametric CAD mo license: Apache-2.0 metadata: project: simplecadapi - version: 2.2.2 + version: 2.3.0 package-name: simplecadapi - package-version: 2.2.2 + package-version: 2.3.0 --- # SimpleCAD SDK Skill diff --git a/docs/updates/2.3.0.md b/docs/updates/2.3.0.md new file mode 100644 index 0000000..ce1a1f8 --- /dev/null +++ b/docs/updates/2.3.0.md @@ -0,0 +1,37 @@ +# 2.3.0 Update Notes + +The editor (`sca edit`) and an agent can now work on the same notebook without overwriting each other. + +## The editor takes in edits made to the file + +Before, the editor held the open notebook in memory and rewrote the file on each edit, so a change +an agent saved was overwritten by the editor's next save and never shown. Now the file is watched: +a saved change is compared with the editor's cells and applied cell by cell. Changed cells are +updated and run again, added or removed cells appear or go away, and the page does not reload or +flicker. A change to the setup cell or an `@app.function` cell restarts the kernel instead; a file +that does not parse is ignored until the next save that does. + +If a cell was edited in both places, **the file's version is kept** and the text you had not saved +goes to the log at the bottom. Notebooks added to or removed from the workspace appear in the list +by themselves. + +## `sca cell` and `sca new` + +```bash +sca cell ls [--code] # numbered cells with status, errors and output +sca cell get 3 +echo 'L = 90' | sca cell put 3 # replace a cell's code and run it +sca cell insert --after 3 -f new.py +sca cell rm 4 +sca cell run [CELL ...] +sca new bracket --kind part # a new notebook, listed by the running editor +``` + +These go through the running editor, found by `/.sca-editor/server.json`: the page shows +the change live and each write prints the touched cell's status, errors and output. With no editor +running, or another notebook open, `ls` and `get` read the file and the write commands refuse: edit +the file directly then. + +The skill (`editor-collaboration`) and `landing.md` now tell agents to work this way. + +See the [changelog](../../CHANGELOG.md) for the complete list. diff --git a/docs/updates/2.3.0.zh-CN.md b/docs/updates/2.3.0.zh-CN.md new file mode 100644 index 0000000..a130d6e --- /dev/null +++ b/docs/updates/2.3.0.zh-CN.md @@ -0,0 +1,33 @@ +# 2.3.0 更新说明 + +`sca edit` 编辑器和 agent 现在可以同时处理同一个 notebook,互不覆盖。 + +## 编辑器会接收对文件的修改 + +以前编辑器把打开的 notebook 放在内存里,每次编辑都重写文件,agent 保存的修改会被编辑器的下一次保存 +覆盖,也不会显示出来。现在编辑器会监视文件:保存后的修改会与编辑器里的 cell 对比,按 cell 逐个应用。 +变化的 cell 会更新并重新运行,新增或删除的 cell 会出现或消失,页面不会刷新或闪烁。setup cell 或 +`@app.function` cell 的修改会重启 kernel;无法解析的文件会被忽略,直到下一次可解析的保存。 + +如果同一个 cell 两边都改了,**保留文件里的版本**,你还没保存的文字会记在底部日志里。工作区里新增或删除的 +notebook 会自动出现在列表中。 + +## `sca cell` 和 `sca new` + +```bash +sca cell ls [--code] # 带编号的 cell,含状态、错误和输出 +sca cell get 3 +echo 'L = 90' | sca cell put 3 # 替换 cell 代码并运行 +sca cell insert --after 3 -f new.py +sca cell rm 4 +sca cell run [CELL ...] +sca new bracket --kind part # 新建 notebook,运行中的编辑器会列出它 +``` + +这些命令通过运行中的编辑器工作(由 `/.sca-editor/server.json` 找到):页面实时显示修改,每次写入 +都会打印被改 cell 的状态、错误和输出。没有运行中的编辑器,或编辑器打开的是别的 notebook 时,`ls` 和 `get` +读取文件,写命令会拒绝:这时直接编辑文件即可。 + +skill(`editor-collaboration`)和 `landing.md` 已让 agent 按这种方式工作。 + +完整列表见[更新日志](../../CHANGELOG.md)。 diff --git a/pyproject.toml b/pyproject.toml index 461392f..19247c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "simplecadapi" -version = "2.2.2" +version = "2.3.0" description = "A simplified OCP-native CAD modeling Python API" readme = "README.md" license = { file = "LICENSE" } diff --git a/uv.lock b/uv.lock index 6650fff..c1a2235 100644 --- a/uv.lock +++ b/uv.lock @@ -3227,7 +3227,7 @@ wheels = [ [[package]] name = "simplecadapi" -version = "2.2.2" +version = "2.3.0" source = { editable = "." } dependencies = [ { name = "cadquery-ocp" }, From 794d9a5331168af3f0403f55b567b55ca5ff8c6f Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Wed, 14 Oct 2026 15:35:04 +0800 Subject: [PATCH 3/3] release: 2.2.3 (not 2.3.0) The version of this release is 2.2.3: rename the version, update notes and README references. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- README.md | 8 ++++---- README.zh-CN.md | 8 ++++---- docs/skill/SKILL.md | 4 ++-- docs/updates/{2.3.0.md => 2.2.3.md} | 2 +- docs/updates/{2.3.0.zh-CN.md => 2.2.3.zh-CN.md} | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 8 files changed, 15 insertions(+), 15 deletions(-) rename docs/updates/{2.3.0.md => 2.2.3.md} (98%) rename docs/updates/{2.3.0.zh-CN.md => 2.2.3.zh-CN.md} (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb00dbb..e754474 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ user-facing notes live in [`docs/updates/`](docs/updates/) (English and ## Unreleased -## 2.3.0 — 2026-10-14 +## 2.2.3 — 2026-10-14 ### Added diff --git a/README.md b/README.md index cacb76a..8e2e817 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ the full crank cycle sweeps clean in the same sim · --- -## Update Notes (2.3.0) +## Update Notes (2.2.3) > Validate generated definitions, assembly constraints, and manufacturing > geometry before production use. @@ -162,7 +162,7 @@ the full crank cycle sweeps clean in the same sim · through the running editor (the page updates live, each write prints the cell's status and errors); `sca new NAME` creates a notebook the editor then lists. -Full notes for this version: [`docs/updates/2.3.0.md`](docs/updates/2.3.0.md). +Full notes for this version: [`docs/updates/2.2.3.md`](docs/updates/2.2.3.md). Earlier versions: the [changelog](CHANGELOG.md) and [`docs/updates/`](docs/updates/). --- @@ -183,7 +183,7 @@ in a compact public API for creating solids, applying features, tagging semantic intent, querying topology, exporting manufacturing files, and translating recorded models into FreeCAD workflows. -Current release: `simplecadapi==2.3.0`. +Current release: `simplecadapi==2.2.3`. ## What It Provides @@ -698,7 +698,7 @@ and the [STEP reconstruction workflow](docs/skill/references/workflows/step-reco ## Documentation -- 2.3.0 update notes: [`docs/updates/2.3.0.md`](docs/updates/2.3.0.md) +- 2.2.3 update notes: [`docs/updates/2.2.3.md`](docs/updates/2.2.3.md) - 2.2.2 update notes: [`docs/updates/2.2.2.md`](docs/updates/2.2.2.md) - 2.2.1 update notes: [`docs/updates/2.2.1.md`](docs/updates/2.2.1.md) - 2.1.3b1 update notes: [`docs/updates/2.1.3b1.md`](docs/updates/2.1.3b1.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index aa93996..f643248 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -133,7 +133,7 @@ CalculiX 静力 FEM(边界面按保真的 `interface.*` 标签选取,仿真 --- -## 更新日志(2.3.0) +## 更新日志(2.2.3) > 投入生产前请验证生成的定义、装配约束和制造几何。 @@ -144,7 +144,7 @@ CalculiX 静力 FEM(边界面按保真的 `interface.*` 标签选取,仿真 notebook 的 cell(页面实时更新,每次写入都打印该 cell 的状态和错误);`sca new NAME` 创建一个 编辑器随即列出的 notebook。 -本版本的完整说明:[`docs/updates/2.3.0.zh-CN.md`](docs/updates/2.3.0.zh-CN.md)。 +本版本的完整说明:[`docs/updates/2.2.3.zh-CN.md`](docs/updates/2.2.3.zh-CN.md)。 更早的版本:见[更新日志](CHANGELOG.md)和 [`docs/updates/`](docs/updates/)。 --- @@ -162,7 +162,7 @@ CalculiX 静力 FEM(边界面按保真的 `interface.*` 标签选取,仿真 SimpleCADAPI 是一个基于 OCP 的 Python CAD SDK,提供清晰的函数式建模操作和可重放的模型图。它在 OpenCascade 几何内核之上提供精简的公共 API,可用于创建实体、应用特征、添加语义标签、查询拓扑、导出制造文件,以及将记录的模型转换为 FreeCAD 工作流。 -当前版本:`simplecadapi==2.3.0`。 +当前版本:`simplecadapi==2.2.3`。 ## 核心能力 @@ -588,7 +588,7 @@ uv run --extra fem python examples/ap242_gmsh_volume_mesh/run_calculix.py ## 文档 -- 2.3.0 更新说明:[`docs/updates/2.3.0.zh-CN.md`](docs/updates/2.3.0.zh-CN.md) +- 2.2.3 更新说明:[`docs/updates/2.2.3.zh-CN.md`](docs/updates/2.2.3.zh-CN.md) - 2.2.2 更新说明:[`docs/updates/2.2.2.zh-CN.md`](docs/updates/2.2.2.zh-CN.md) - 2.2.1 更新说明:[`docs/updates/2.2.1.zh-CN.md`](docs/updates/2.2.1.zh-CN.md) - 2.1.3b1 更新说明:[`docs/updates/2.1.3b1.zh-CN.md`](docs/updates/2.1.3b1.zh-CN.md) diff --git a/docs/skill/SKILL.md b/docs/skill/SKILL.md index 2079717..c16c90c 100644 --- a/docs/skill/SKILL.md +++ b/docs/skill/SKILL.md @@ -4,9 +4,9 @@ description: Build, assemble, inspect, reconstruct, and export parametric CAD mo license: Apache-2.0 metadata: project: simplecadapi - version: 2.3.0 + version: 2.2.3 package-name: simplecadapi - package-version: 2.3.0 + package-version: 2.2.3 --- # SimpleCAD SDK Skill diff --git a/docs/updates/2.3.0.md b/docs/updates/2.2.3.md similarity index 98% rename from docs/updates/2.3.0.md rename to docs/updates/2.2.3.md index ce1a1f8..390c6b4 100644 --- a/docs/updates/2.3.0.md +++ b/docs/updates/2.2.3.md @@ -1,4 +1,4 @@ -# 2.3.0 Update Notes +# 2.2.3 Update Notes The editor (`sca edit`) and an agent can now work on the same notebook without overwriting each other. diff --git a/docs/updates/2.3.0.zh-CN.md b/docs/updates/2.2.3.zh-CN.md similarity index 98% rename from docs/updates/2.3.0.zh-CN.md rename to docs/updates/2.2.3.zh-CN.md index a130d6e..2fa3381 100644 --- a/docs/updates/2.3.0.zh-CN.md +++ b/docs/updates/2.2.3.zh-CN.md @@ -1,4 +1,4 @@ -# 2.3.0 更新说明 +# 2.2.3 更新说明 `sca edit` 编辑器和 agent 现在可以同时处理同一个 notebook,互不覆盖。 diff --git a/pyproject.toml b/pyproject.toml index 19247c2..af1ec22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "simplecadapi" -version = "2.3.0" +version = "2.2.3" description = "A simplified OCP-native CAD modeling Python API" readme = "README.md" license = { file = "LICENSE" } diff --git a/uv.lock b/uv.lock index c1a2235..7bb8688 100644 --- a/uv.lock +++ b/uv.lock @@ -3227,7 +3227,7 @@ wheels = [ [[package]] name = "simplecadapi" -version = "2.3.0" +version = "2.2.3" source = { editable = "." } dependencies = [ { name = "cadquery-ocp" },