diff --git a/docs/automation/README.md b/docs/automation/README.md index 0c1d1e7f..80b73f26 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -4,12 +4,14 @@ Open CAD Studio can run without a GUI and be driven over a line-based JSON protocol — for scripts, batch jobs, or AI agents. ```sh -OpenCADStudio --serve +OpenCADStudio --serve # stdin/stdout transport +OpenCADStudio --serve --port 4242 # listen on 127.0.0.1:4242 instead ``` -It reads one JSON request per line on **stdin** and writes one JSON response per -line on **stdout**. The active document persists across requests, so a caller -can act → observe → act. +It reads one JSON request per line and writes one JSON response per line — over +**stdin/stdout**, or over a **local TCP socket** with `--port`. The active +document persists across requests (and, on the socket, across reconnects), so a +caller can act → observe → act. ## Protocol diff --git a/docs/automation/ocs.py b/docs/automation/ocs.py index 91ffac30..5b6b9898 100644 --- a/docs/automation/ocs.py +++ b/docs/automation/ocs.py @@ -19,6 +19,7 @@ Each call returns the parsed response dict and raises `OcsError` on `ok: false`. from __future__ import annotations import json +import socket import subprocess from typing import Any, Optional @@ -28,26 +29,45 @@ class OcsError(RuntimeError): class Ocs: - def __init__(self, binary: str = "OpenCADStudio") -> None: - self.proc = subprocess.Popen( - [binary, "--serve"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - text=True, - bufsize=1, - ) + """Connect by spawning the server (default) or over a TCP socket. + + - `Ocs()` spawns `OpenCADStudio --serve` and talks over stdin/stdout. + - `Ocs(port=4242)` connects to a server started with `--serve --port 4242`. + """ + + def __init__( + self, + binary: str = "OpenCADStudio", + port: Optional[int] = None, + host: str = "127.0.0.1", + ) -> None: + self.proc: Optional[subprocess.Popen] = None + self.sock: Optional[socket.socket] = None + if port is not None: + self.sock = socket.create_connection((host, port)) + io = self.sock.makefile("rw") + self._r, self._w = io, io + else: + self.proc = subprocess.Popen( + [binary, "--serve"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + bufsize=1, + ) + self._r, self._w = self.proc.stdout, self.proc.stdin self._read() # the {"ready": true} greeting # ── protocol ──────────────────────────────────────────────────────────── def _read(self) -> dict[str, Any]: - line = self.proc.stdout.readline() + line = self._r.readline() if not line: raise OcsError("server closed the connection") return json.loads(line) def _send(self, **req: Any) -> dict[str, Any]: - self.proc.stdin.write(json.dumps(req) + "\n") - self.proc.stdin.flush() + self._w.write(json.dumps(req) + "\n") + self._w.flush() resp = self._read() if not resp.get("ok", False): raise OcsError(resp.get("error", "unknown error")) @@ -114,12 +134,17 @@ class Ocs: # ── lifecycle ─────────────────────────────────────────────────────────── def close(self) -> None: - if self.proc.stdin: - self.proc.stdin.close() try: - self.proc.wait(timeout=5) - except subprocess.TimeoutExpired: - self.proc.kill() + self._w.close() + except Exception: + pass + if self.proc is not None: + try: + self.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self.proc.kill() + if self.sock is not None: + self.sock.close() def __enter__(self) -> "Ocs": return self diff --git a/src/app/automation.rs b/src/app/automation.rs index df24fb57..1d7ab1cc 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -21,17 +21,40 @@ use serde_json::{json, Value}; use super::OpenCADStudio; -/// Run the headless JSON server until stdin closes. +/// Run the headless JSON server. Default transport is stdin/stdout; with +/// `--port ` it instead listens on `127.0.0.1:` and serves one client at +/// a time (the document session persists across reconnects). pub fn serve() { let mut app = OpenCADStudio::new(); + match port_arg() { + Some(port) => serve_socket(&mut app, port), + None => serve_stdio(&mut app), + } +} + +/// `--port ` if present on the command line. +fn port_arg() -> Option { + let mut args = std::env::args(); + while let Some(a) = args.next() { + if a == "--port" { + return args.next().and_then(|s| s.parse().ok()); + } + } + None +} + +fn ready() -> Value { + json!({ "ok": true, "ready": true, "version": env!("CARGO_PKG_VERSION") }) +} + +fn serve_stdio(app: &mut OpenCADStudio) { let stdin = std::io::stdin(); let stdout = std::io::stdout(); - - emit( - &stdout, - json!({"ok": true, "ready": true, "version": env!("CARGO_PKG_VERSION")}), - ); - + { + let mut o = stdout.lock(); + let _ = writeln!(o, "{}", ready()); + let _ = o.flush(); + } for line in stdin.lock().lines() { let Ok(line) = line else { break }; let line = line.trim(); @@ -39,14 +62,42 @@ pub fn serve() { continue; } let resp = app.automation_op(line); - emit(&stdout, resp); + let mut o = stdout.lock(); + let _ = writeln!(o, "{resp}"); + let _ = o.flush(); } } -fn emit(stdout: &std::io::Stdout, value: Value) { - let mut o = stdout.lock(); - let _ = writeln!(o, "{value}"); - let _ = o.flush(); +fn serve_socket(app: &mut OpenCADStudio, port: u16) { + let listener = match std::net::TcpListener::bind(("127.0.0.1", port)) { + Ok(l) => l, + Err(e) => { + eprintln!("--serve: cannot bind 127.0.0.1:{port}: {e}"); + return; + } + }; + eprintln!("OpenCADStudio --serve listening on 127.0.0.1:{port}"); + for stream in listener.incoming().flatten() { + let Ok(read_half) = stream.try_clone() else { + continue; + }; + let reader = std::io::BufReader::new(read_half); + let mut writer = stream; + let _ = writeln!(writer, "{}", ready()); + let _ = writer.flush(); + for line in reader.lines() { + let Ok(line) = line else { break }; + let line = line.trim(); + if line.is_empty() { + continue; + } + let resp = app.automation_op(line); + if writeln!(writer, "{resp}").is_err() { + break; + } + let _ = writer.flush(); + } + } } fn err(msg: impl std::fmt::Display) -> Value {