Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ build/
.vite/
*.tsbuildinfo
.claude/settings.local.json
*.app/
32 changes: 30 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: install backend frontend dev test clean
.PHONY: install backend frontend dev test clean app app-install app-build app-bundle install-app

VENV := backend/.venv

Expand All @@ -23,7 +23,35 @@ dev:
test:
cd backend && .venv/bin/pytest -q

# --- native window app -----------------------------------------------------
app-install:
cd backend && .venv/bin/pip install -e ".[dev,app]"

app-build:
cd frontend && npm run build

app: app-build
$(VENV)/bin/python launcher.py

app-bundle: app-build
bash scripts/build_app.sh

# One-shot: build frontend, build .app, copy to /Applications.
# Quits any running instance first so the copy doesn't trip over an
# in-use bundle.
install-app: app-bundle
@echo "→ quitting any running gorchestra..."
-@pkill -f launcher.py 2>/dev/null || true
-@osascript -e 'tell application "gorchestra" to quit' >/dev/null 2>&1 || true
@sleep 1
@echo "→ installing to /Applications/gorchestra.app..."
@rm -rf /Applications/gorchestra.app
@cp -R gorchestra.app /Applications/
@echo ""
@echo "Installed. Launch via Spotlight (Cmd+Space → gorchestra) or Launchpad."
@echo "On first launch, right-click → Open to bypass the Gatekeeper warning."

clean:
rm -f backend/*.db
rm -rf backend/__pycache__ backend/app/__pycache__ backend/app/*/__pycache__
rm -rf frontend/node_modules frontend/dist
rm -rf frontend/node_modules frontend/dist gorchestra.app
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,64 @@ Then either:
directly. (Topology — adding/removing nodes and edges, designating input/
output — is owned by the orchestrator; you ask via chat.)

## Run as a native Mac app

Wrap the whole thing in a real `.app` bundle so it shows up in Spotlight,
Launchpad, and the Dock — no terminals, no `localhost:5173` to remember.

```bash
make app-install # one-time: adds pywebview to the backend venv
make install-app # build frontend + build .app + copy to /Applications
```

After `make install-app`, hit `Cmd+Space`, type `gorchestra`, press Enter.
On the very first launch, right-click the app → **Open** to bypass the
Gatekeeper "unidentified developer" warning (it's ad-hoc signed, not
notarised). After that, normal double-click works.

Other targets if you want finer control:

```bash
make app # dev mode: launch in a window without bundling
make app-bundle # just build gorchestra.app in the project root
```

### How it works

- `launcher.py` runs FastAPI in a daemon thread on port `8765` (pinned so
`localStorage` settings persist across launches — that store is keyed by
origin) and opens a native WKWebView window via `pywebview` with
`private_mode=False` so the data store survives restarts.
- The frontend's static `dist/` is served from the same origin, so all
existing relative API/WebSocket URLs work unchanged.
- `scripts/build_app.sh` produces the bundle. The executable in
`Contents/MacOS/` is a tiny C wrapper compiled to native arm64 — it just
`execv`s the project's venv Python on `launcher.py`. It has to be a real
Mach-O binary (not a shell script) or LaunchServices misreads the
architecture and falsely demands Rosetta. The bundle is then ad-hoc
signed so macOS 15+ App Management lets it launch.
- Closing the window — or force-quitting the app entirely — kills any
in-flight workflow runs by signalling their process group (the runner
spawns its child with `start_new_session=True`). For graceful close
(Cmd+Q, red button) the launcher's `closing` hook does it directly. For
abrupt death (SIGKILL, force-quit) the runner child has its own
parent-death watchdog: it holds the read end of a pipe whose write end
the parent keeps open, gets EOF the instant parent dies, and tears down
its own process group. So no orphaned `shell` tool subprocesses either way.
- Logs go to `$TMPDIR/gorchestra.log`.

### Caveats

- The bundle is hard-linked to this checkout — its launcher execs
`backend/.venv/bin/python` on this project's `launcher.py`. Move the
project directory and you'll need to `make install-app` again. For a
fully relocatable bundle, swap the C wrapper for `py2app`.
- `make dev` runs the frontend in your browser (Chrome/Safari) and uses
that browser's `localStorage`. The native-window launches (`make app`
and `gorchestra.app`) both run via Homebrew's embedded Python.app and
share a WKWebView store under `~/Library/WebKit/org.python.python/`,
so settings carry between those two — but not from the browser.

## Node code contract

```python
Expand Down
37 changes: 37 additions & 0 deletions backend/app/runner/child.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,45 @@ def _handler(signum, frame):
pass


def _install_parent_death_watchdog() -> None:
"""Self-terminate if the parent runner dies abruptly (e.g. SIGKILL).

The parent passes the read end of a pipe via PARENT_DEATH_FD. As long as
parent is alive, it holds the write end open. If parent is killed by any
means (force-quit, OOM kill, segfault), the kernel closes the write end
and our blocking read returns EOF immediately. We then SIGTERM our own
process group to take down any grandchildren (shell tools, etc.) and
exit. Without this, force-quitting the app would orphan in-flight runs.
"""
fd_str = os.environ.get("PARENT_DEATH_FD")
if not fd_str:
return
try:
fd = int(fd_str)
except ValueError:
return

def _watch() -> None:
try:
os.read(fd, 1)
except (OSError, ValueError):
pass
# Defensive: if ppid != 1, the FD closed for some other reason and
# parent is still alive — leave it alone.
if os.getppid() != 1:
return
try:
os.killpg(os.getpgid(0), signal.SIGTERM)
except (ProcessLookupError, OSError):
pass
os._exit(1)

threading.Thread(target=_watch, daemon=True).start()


def main() -> None:
_install_sigterm_handler()
_install_parent_death_watchdog()
raw = sys.stdin.read()
payload = json.loads(raw)

Expand Down
14 changes: 12 additions & 2 deletions backend/app/runner/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
"""
from __future__ import annotations
import asyncio
import os
import signal
import subprocess
import threading
from dataclasses import dataclass, field
Expand Down Expand Up @@ -64,7 +66,12 @@ def set_proc(run_id: str, proc: subprocess.Popen) -> None:


def cancel(run_id: str) -> bool:
"""Best-effort: SIGTERM the run's subprocess. Returns True if a signal was sent."""
"""Best-effort: SIGTERM the run's process group. Returns True if a signal was sent.

The runner spawns the child with `start_new_session=True`, so signalling
the whole process group also reaps any grandchildren the child spawned
(e.g. `shell` tool subprocesses).
"""
st = _RUNS.get(run_id)
if not st or st.finished:
return False
Expand All @@ -74,7 +81,10 @@ def cancel(run_id: str) -> bool:
with st.lock:
st.cancelled = True
try:
proc.terminate()
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
proc.terminate()
return True
except Exception:
return False
Expand Down
143 changes: 86 additions & 57 deletions backend/app/runner/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,33 @@ def run_workflow_streaming(
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"

# Parent-death pipe: child blocks reading from death_r; if parent dies for
# any reason (SIGKILL, force-quit, segfault), the kernel closes death_w
# and the child's read returns EOF immediately, triggering self-teardown
# of the child + its process group. Covers the case the SIGTERM-based
# cancel and atexit hooks can't (since neither runs on SIGKILL).
death_r, death_w = os.pipe()
env["PARENT_DEATH_FD"] = str(death_r)

try:
proc = subprocess.Popen(
[sys.executable, "-m", "app.runner.child"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
# New session = new process group, so a single killpg() reaps
# the child plus anything it spawned (shell tools, etc.).
start_new_session=True,
# Inherit the read end of the death pipe with the same fd number.
pass_fds=(death_r,),
)
except Exception as e:
for fd in (death_r, death_w):
try:
os.close(fd)
except OSError:
pass
ev_mod.append_event(
run_id,
{
Expand All @@ -92,76 +110,87 @@ def run_workflow_streaming(
)
return

# Parent doesn't need the read end; only the child does.
os.close(death_r)
ev_mod.set_proc(run_id, proc)

try:
assert proc.stdin is not None
proc.stdin.write(json.dumps(payload).encode())
proc.stdin.close()
except Exception as e:
ev_mod.append_event(
run_id,
{
"type": "run_finished",
"status": "error",
"error": f"failed to write to runner stdin: {e}",
"outputs": {},
"total_cost": 0.0,
},
)
try:
proc.kill()
except Exception:
pass
return

saw_finished = False
assert proc.stdout is not None
for raw in proc.stdout:
line = raw.decode(errors="replace").strip()
if not line:
continue
try:
event = json.loads(line)
except Exception:
continue
ev_mod.append_event(run_id, event)
if event.get("type") == "run_finished":
saw_finished = True

rc = proc.wait()

if not saw_finished:
stderr_text = ""
try:
if proc.stderr is not None:
stderr_text = proc.stderr.read().decode(errors="replace")
except Exception:
pass
st = ev_mod.get(run_id)
cancelled = bool(st and st.cancelled)
if cancelled or rc < 0:
ev_mod.append_event(
run_id,
{
"type": "run_finished",
"status": "cancelled",
"error": "cancelled by user" if cancelled else f"runner killed (rc={rc})",
"outputs": {},
"total_cost": 0.0,
},
)
else:
assert proc.stdin is not None
proc.stdin.write(json.dumps(payload).encode())
proc.stdin.close()
except Exception as e:
ev_mod.append_event(
run_id,
{
"type": "run_finished",
"status": "error",
"error": f"runner exited rc={rc}: {stderr_text[-1000:]}",
"error": f"failed to write to runner stdin: {e}",
"outputs": {},
"total_cost": 0.0,
},
)
try:
proc.kill()
except Exception:
pass
return

saw_finished = False
assert proc.stdout is not None
for raw in proc.stdout:
line = raw.decode(errors="replace").strip()
if not line:
continue
try:
event = json.loads(line)
except Exception:
continue
ev_mod.append_event(run_id, event)
if event.get("type") == "run_finished":
saw_finished = True

rc = proc.wait()

if not saw_finished:
stderr_text = ""
try:
if proc.stderr is not None:
stderr_text = proc.stderr.read().decode(errors="replace")
except Exception:
pass
st = ev_mod.get(run_id)
cancelled = bool(st and st.cancelled)
if cancelled or rc < 0:
ev_mod.append_event(
run_id,
{
"type": "run_finished",
"status": "cancelled",
"error": "cancelled by user" if cancelled else f"runner killed (rc={rc})",
"outputs": {},
"total_cost": 0.0,
},
)
else:
ev_mod.append_event(
run_id,
{
"type": "run_finished",
"status": "error",
"error": f"runner exited rc={rc}: {stderr_text[-1000:]}",
"outputs": {},
"total_cost": 0.0,
},
)
finally:
# Child has exited (proc.wait completed). Close the write end so the
# FD doesn't leak. The watchdog in the (already-dead) child can't
# observe this, but on parent SIGKILL the kernel does this for us.
try:
os.close(death_w)
except OSError:
pass


def run_workflow_sync(
Expand Down
3 changes: 3 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ dependencies = [
dev = [
"pytest>=8.0",
]
app = [
"pywebview>=5.0",
]

[build-system]
requires = ["setuptools>=68"]
Expand Down
Loading