feat(automation): local TCP socket transport for --serve

OpenCADStudio --serve --port <N> listens on 127.0.0.1:<N> and serves the
same line-based JSON protocol over the socket (one client at a time; the
document session persists across reconnects), as an alternative to
stdin/stdout. ocs.py grows a port= option to connect either way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-18 01:54:03 +03:00
commit 8bae517eec
3 changed files with 110 additions and 32 deletions

View file

@ -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. protocol — for scripts, batch jobs, or AI agents.
```sh ```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 It reads one JSON request per line and writes one JSON response per line — over
line on **stdout**. The active document persists across requests, so a caller **stdin/stdout**, or over a **local TCP socket** with `--port`. The active
can act → observe → act. document persists across requests (and, on the socket, across reconnects), so a
caller can act → observe → act.
## Protocol ## Protocol

View file

@ -19,6 +19,7 @@ Each call returns the parsed response dict and raises `OcsError` on `ok: false`.
from __future__ import annotations from __future__ import annotations
import json import json
import socket
import subprocess import subprocess
from typing import Any, Optional from typing import Any, Optional
@ -28,26 +29,45 @@ class OcsError(RuntimeError):
class Ocs: class Ocs:
def __init__(self, binary: str = "OpenCADStudio") -> None: """Connect by spawning the server (default) or over a TCP socket.
self.proc = subprocess.Popen(
[binary, "--serve"], - `Ocs()` spawns `OpenCADStudio --serve` and talks over stdin/stdout.
stdin=subprocess.PIPE, - `Ocs(port=4242)` connects to a server started with `--serve --port 4242`.
stdout=subprocess.PIPE, """
text=True,
bufsize=1, 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 self._read() # the {"ready": true} greeting
# ── protocol ──────────────────────────────────────────────────────────── # ── protocol ────────────────────────────────────────────────────────────
def _read(self) -> dict[str, Any]: def _read(self) -> dict[str, Any]:
line = self.proc.stdout.readline() line = self._r.readline()
if not line: if not line:
raise OcsError("server closed the connection") raise OcsError("server closed the connection")
return json.loads(line) return json.loads(line)
def _send(self, **req: Any) -> dict[str, Any]: def _send(self, **req: Any) -> dict[str, Any]:
self.proc.stdin.write(json.dumps(req) + "\n") self._w.write(json.dumps(req) + "\n")
self.proc.stdin.flush() self._w.flush()
resp = self._read() resp = self._read()
if not resp.get("ok", False): if not resp.get("ok", False):
raise OcsError(resp.get("error", "unknown error")) raise OcsError(resp.get("error", "unknown error"))
@ -114,12 +134,17 @@ class Ocs:
# ── lifecycle ─────────────────────────────────────────────────────────── # ── lifecycle ───────────────────────────────────────────────────────────
def close(self) -> None: def close(self) -> None:
if self.proc.stdin:
self.proc.stdin.close()
try: try:
self.proc.wait(timeout=5) self._w.close()
except subprocess.TimeoutExpired: except Exception:
self.proc.kill() 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": def __enter__(self) -> "Ocs":
return self return self

View file

@ -21,17 +21,40 @@ use serde_json::{json, Value};
use super::OpenCADStudio; use super::OpenCADStudio;
/// Run the headless JSON server until stdin closes. /// Run the headless JSON server. Default transport is stdin/stdout; with
/// `--port <N>` it instead listens on `127.0.0.1:<N>` and serves one client at
/// a time (the document session persists across reconnects).
pub fn serve() { pub fn serve() {
let mut app = OpenCADStudio::new(); let mut app = OpenCADStudio::new();
match port_arg() {
Some(port) => serve_socket(&mut app, port),
None => serve_stdio(&mut app),
}
}
/// `--port <N>` if present on the command line.
fn port_arg() -> Option<u16> {
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 stdin = std::io::stdin();
let stdout = std::io::stdout(); let stdout = std::io::stdout();
{
emit( let mut o = stdout.lock();
&stdout, let _ = writeln!(o, "{}", ready());
json!({"ok": true, "ready": true, "version": env!("CARGO_PKG_VERSION")}), let _ = o.flush();
); }
for line in stdin.lock().lines() { for line in stdin.lock().lines() {
let Ok(line) = line else { break }; let Ok(line) = line else { break };
let line = line.trim(); let line = line.trim();
@ -39,14 +62,42 @@ pub fn serve() {
continue; continue;
} }
let resp = app.automation_op(line); 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) { fn serve_socket(app: &mut OpenCADStudio, port: u16) {
let mut o = stdout.lock(); let listener = match std::net::TcpListener::bind(("127.0.0.1", port)) {
let _ = writeln!(o, "{value}"); Ok(l) => l,
let _ = o.flush(); 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 { fn err(msg: impl std::fmt::Display) -> Value {